diff --git a/.gitignore b/.gitignore index f59c2e7..1f27a34 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ peers.txt **/mutants.out*/ members/nullnet-server/ui/node_modules/ members/nullnet-server/ui/dist/ +members/nullnet-server/certs diff --git a/Cargo.lock b/Cargo.lock index 22c42d3..e04dfcb 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 0.1.7", + "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" @@ -39,12 +74,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" @@ -131,6 +160,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" @@ -143,7 +181,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" dependencies = [ - "asn1-rs-derive", + "asn1-rs-derive 0.5.1", "asn1-rs-impl", "displaydoc", "nom", @@ -153,6 +191,22 @@ dependencies = [ "time", ] +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive 0.6.0", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + [[package]] name = "asn1-rs-derive" version = "0.5.1" @@ -165,6 +219,18 @@ dependencies = [ "synstructure", ] +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "asn1-rs-impl" version = "0.2.0" @@ -217,21 +283,34 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-credential-types" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -239,6 +318,276 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "aws-runtime" +version = "1.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ed8e8c52d2dc2390ad9f15647fe663f71e9780b4262c190fbb823a32721566" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 1.4.0", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-route53" +version = "1.114.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4b3ded4d6f91aefec370eac63d8522786f17c0763ef531e6a3be88f459219bc" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae38512beae0ffee7010fc24e7a8a123c53efdfef42a61e80fda4882418dc71" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2 0.11.0", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3ef8931ad1c98aa6a55b4256f847f3116090819844e0dd41ea682cac5dd2d3" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.13", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.9.0", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.9", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.37", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "701a947f4797e52a911e114a898667c746c39feea467bbd1abd7b3721f702ffa" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e6f5caf6fea86f8c2206541ab5857cfcda9013426cdbe8fa0098b9e2d32182" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9db177daa6ba8afb9ee1aefcf548c907abcf52065e394ee11a92780057fe0e8c" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.4.0", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53f93074121a1be41317b9aa607143ae17900631f7f59a99f2b905d519d6783b" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16bf10b03a3c01e6b3b7d47cd964e873ffe9e7d4e80fad16bd4c077cb068531" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.8.9" @@ -249,10 +598,10 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-util", "itoa", "matchit", @@ -280,8 +629,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -291,6 +640,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", + "hyper-util", + "pin-project-lite", + "rustls 0.23.37", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -312,6 +683,25 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -330,7 +720,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -342,6 +732,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -414,6 +813,16 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "c2rust-bitfields" version = "0.21.0" @@ -510,6 +919,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 0.1.7", + "inout", +] + [[package]] name = "clap" version = "4.6.0" @@ -538,7 +957,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", @@ -559,12 +978,28 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -574,6 +1009,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.9.4" @@ -640,16 +1081,35 @@ dependencies = [ name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] [[package]] -name = "crypto-common" -version = "0.1.7" +name = "ctr" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "generic-array", - "typenum", + "cipher", ] [[package]] @@ -663,6 +1123,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "daemonize" version = "0.5.0" @@ -728,7 +1197,21 @@ version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" dependencies = [ - "asn1-rs", + "asn1-rs 0.6.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs 0.7.2", "displaydoc", "nom", "num-bigint", @@ -793,11 +1276,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -812,9 +1307,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -973,6 +1468,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,6 +1492,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" +dependencies = [ + "autocfg", + "tokio", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1133,8 +1653,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1177,12 +1699,41 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.13.1", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.13" @@ -1194,7 +1745,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap 2.13.1", "slab", "tokio", @@ -1246,12 +1797,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" @@ -1264,6 +1809,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "hostname" version = "0.4.2" @@ -1275,6 +1829,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -1285,6 +1850,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1292,7 +1868,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -1303,8 +1879,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -1320,6 +1896,39 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1330,9 +1939,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", - "http", - "http-body", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1342,13 +1951,45 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.0", + "hyper 1.9.0", + "hyper-util", + "rustls 0.23.37", + "rustls-native-certs", + "rustls-platform-verifier", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + [[package]] name = "hyper-timeout" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper", + "hyper 1.9.0", "hyper-util", "pin-project-lite", "tokio", @@ -1361,18 +2002,23 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1399,6 +2045,88 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -1411,6 +2139,27 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1453,6 +2202,41 @@ 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 = "instant-acme" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f05ad37c421b962354c358d347d4a6130151df9407978372d3ad7f0c8f71a64" +dependencies = [ + "async-trait", + "aws-lc-rs", + "base64", + "bytes", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "httpdate", + "hyper 1.9.0", + "hyper-rustls 0.27.9", + "hyper-util", + "rcgen", + "rustls 0.23.37", + "rustls-pki-types", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1475,19 +2259,68 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] -name = "itertools" -version = "0.14.0" +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" dependencies = [ - "either", + "jni-sys-macros", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "jni-sys-macros" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] [[package]] name = "jobserver" @@ -1505,6 +2338,8 @@ version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1597,6 +2432,12 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "local-ip-address" version = "0.6.12" @@ -1632,6 +2473,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchit" version = "0.8.4" @@ -1881,12 +2728,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" @@ -1979,13 +2820,17 @@ 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", "gag", "nullnet-grpc-lib", "nullnet-liberror", + "openssl", "pingora-core", + "pingora-http", + "pingora-openssl", "pingora-proxy", "tokio", ] @@ -1994,18 +2839,29 @@ dependencies = [ name = "nullnet-server" version = "0.1.0" dependencies = [ + "aes-gcm", + "anyhow", "async-trait", + "aws-credential-types", + "aws-sdk-route53", "axum", + "axum-server", + "base64", "chrono", "ctrlc", "futures", "gag", + "instant-acme", "ipnetwork", "mime_guess", "notify", "nullnet-grpc-lib", "nullnet-liberror", + "openssl", "prost", + "quick-xml", + "rcgen", + "reqwest", "rust-embed", "serde", "serde_json", @@ -2015,6 +2871,7 @@ dependencies = [ "tonic", "tonic-prost", "uuid", + "x509-parser 0.16.0", ] [[package]] @@ -2029,9 +2886,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -2081,7 +2938,16 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" dependencies = [ - "asn1-rs", + "asn1-rs 0.6.2", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs 0.7.2", ] [[package]] @@ -2096,6 +2962,37 @@ 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" +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,29 +3006,33 @@ 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]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking" version = "2.2.1" @@ -2167,6 +3068,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2220,6 +3131,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pingora-cache" version = "0.8.0" @@ -2234,7 +3151,7 @@ dependencies = [ "cf-rustracing", "cf-rustracing-jaeger", "hex", - "http", + "http 1.4.0", "httparse", "httpdate", "indexmap 1.9.3", @@ -2275,8 +3192,8 @@ dependencies = [ "derivative", "flate2", "futures", - "h2", - "http", + "h2 0.4.13", + "http 1.4.0", "httparse", "httpdate", "libc", @@ -2284,14 +3201,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", @@ -2299,7 +3215,7 @@ dependencies = [ "serde", "serde_yaml", "sfv", - "socket2", + "socket2 0.6.3", "strum", "strum_macros", "tokio", @@ -2307,7 +3223,6 @@ dependencies = [ "tokio-test", "unicase", "windows-sys 0.59.0", - "x509-parser", "zstd", ] @@ -2324,7 +3239,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2705feb8b50d4e734e0c7d3879aa040e655a45656276323ff530e254585dd816" dependencies = [ "bytes", - "http", + "http 1.4.0", "httparse", "pingora-error", "pingora-http", @@ -2340,7 +3255,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbb52d4651b687fab6abf669539cfd97b7cd94b301fde8f57c63354f9c9cc5e2" dependencies = [ "bytes", - "http", + "http 1.4.0", "pingora-error", ] @@ -2356,6 +3271,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" @@ -2381,8 +3309,8 @@ dependencies = [ "bytes", "clap", "futures", - "h2", - "http", + "h2 0.4.13", + "http 1.4.0", "log", "once_cell", "pingora-cache", @@ -2406,23 +3334,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,6 +3364,27 @@ 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 = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2509,19 +3441,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 +3472,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", @@ -2616,6 +3535,71 @@ dependencies = [ "pulldown-cmark", ] +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.37", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls 0.23.37", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2644,10 +3628,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.1" @@ -2669,6 +3663,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -2678,12 +3682,36 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "aws-lc-rs", + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser 0.18.1", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2716,12 +3744,59 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-rustls 0.27.9", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.37", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -2732,7 +3807,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -2832,7 +3907,7 @@ version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ - "sha2", + "sha2 0.10.9", "walkdir", ] @@ -2852,6 +3927,21 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -2874,6 +3964,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.37" @@ -2884,52 +3986,68 @@ dependencies = [ "log", "once_cell", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.10", "subtle", "zeroize", ] [[package]] name = "rustls-native-certs" -version = "0.7.3" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe 0.1.6", - "rustls-pemfile", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", - "security-framework 2.11.1", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", ] [[package]] -name = "rustls-native-certs" -version = "0.8.3" +name = "rustls-platform-verifier" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "openssl-probe 0.2.1", - "rustls-pki-types", - "schannel", - "security-framework 3.7.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls 0.23.37", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.10", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", ] [[package]] -name = "rustls-pemfile" -version = "2.2.0" +name = "rustls-platform-verifier-android" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] -name = "rustls-pki-types" -version = "1.14.0" +name = "rustls-webpki" +version = "0.101.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ - "zeroize", + "ring", + "untrusted 0.9.0", ] [[package]] @@ -2941,7 +4059,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -2981,16 +4099,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] -name = "security-framework" -version = "2.11.1" +name = "sct" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", + "ring", + "untrusted 0.9.0", ] [[package]] @@ -3129,7 +4244,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -3154,6 +4280,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -3166,6 +4308,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -3186,10 +4338,10 @@ dependencies = [ ] [[package]] -name = "static_assertions" -version = "1.1.0" +name = "stable_deref_trait" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "strsim" @@ -3212,7 +4364,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", @@ -3252,6 +4404,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -3264,6 +4419,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + [[package]] name = "system-configuration-sys" version = "0.6.0" @@ -3377,6 +4543,31 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.51.1" @@ -3388,7 +4579,7 @@ dependencies = [ "mio", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -3404,13 +4595,34 @@ 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.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.37", "tokio", ] @@ -3499,20 +4711,20 @@ dependencies = [ "axum", "base64", "bytes", - "h2", - "http", - "http-body", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-timeout", "hyper-util", "percent-encoding", "pin-project", - "rustls-native-certs 0.8.3", - "socket2", + "rustls-native-certs", + "socket2 0.6.3", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-stream", "tower", "tower-layer", @@ -3578,6 +4790,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -3697,18 +4927,52 @@ 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 0.1.7", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -3738,6 +5002,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" @@ -3794,6 +5064,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.117" @@ -3860,6 +5140,35 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "widestring" version = "1.2.1" @@ -4042,6 +5351,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -4293,7 +5613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "wit-parser", ] @@ -4304,7 +5624,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 +5685,54 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "x509-parser" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" dependencies = [ - "asn1-rs", + "asn1-rs 0.6.2", "data-encoding", - "der-parser", + "der-parser 9.0.0", "lazy_static", "nom", - "oid-registry", + "oid-registry 0.7.1", "rusticata-macros", "thiserror 1.0.69", "time", ] +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs 0.7.2", + "aws-lc-rs", + "data-encoding", + "der-parser 10.0.0", + "lazy_static", + "nom", + "oid-registry 0.8.1", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "xtask" version = "0.1.0" @@ -4391,10 +5742,37 @@ dependencies = [ ] [[package]] -name = "yansi" -version = "1.0.1" +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] [[package]] name = "zerocopy" @@ -4416,12 +5794,66 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/README.md b/README.md index a294838..afa09b5 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,11 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip ``` NET_TYPE=VXLAN TIMEOUT=0 + CERT_ENCRYPTION_KEY=<32 raw bytes or 64 hex chars> ``` + `CERT_ENCRYPTION_KEY` is **required** — the server refuses to start without it. It encrypts + TLS certificate private keys at rest; keep it stable, since rotating it makes existing + encrypted keys undecryptable. Generate one with `openssl rand -hex 32`. - service configuration is split per **stack** — one TOML file per stack under `members/nullnet-server/services/`. The filename (minus `.toml`) is the stack name. @@ -91,7 +95,8 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip ./setup-proxy.sh ``` -- the proxy will run on port 80 and receive requests in the form `service_name:80` +- the proxy listens on port 80 (requests in the form `service_name:80`) and, for hosts that have a + TLS certificate, on port 443 — HTTP requests to those hosts get a 301 redirect to HTTPS *** diff --git a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto index cc68128..b04f5a6 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,26 @@ 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. 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; +} + +// The full certificate set. Sent in whole on every WatchCertificates push +// (the proxy rebuilds its store from the snapshot). +message CertBundle { + repeated TlsCertificate certificates = 1; +} + // Misc ---------------------------------------------------------------------------------------------------------------- message Empty { } @@ -179,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; } @@ -205,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/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..7006d7c 100644 --- a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs +++ b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs @@ -161,6 +161,28 @@ 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. 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, +} +/// The full certificate set. Sent in whole 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. @@ -168,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, } @@ -221,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), @@ -366,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, @@ -632,6 +663,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 +750,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 +1104,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 9a852eb..5cb2b06 100644 --- a/members/nullnet-proxy/Cargo.toml +++ b/members/nullnet-proxy/Cargo.toml @@ -7,8 +7,12 @@ 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" +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/main.rs b/members/nullnet-proxy/src/main.rs index 9c420fb..cfea049 100644 --- a/members/nullnet-proxy/src/main.rs +++ b/members/nullnet-proxy/src/main.rs @@ -1,29 +1,63 @@ mod env; mod nullnet_proxy; +mod tls; 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, - 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; 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; +// TODO: store in certs also encrypted API keys to allow automatic renewals + 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.load().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", @@ -39,46 +73,50 @@ impl ProxyHttp for NullnetProxy { .map(|a| a.ip().to_string()); let client_ip_for_events = client_ip_opt.clone().unwrap_or_default(); - let host_header = match session.get_header("host") { - Some(h) => h, - None => { - let server = self.server.clone(); - let cip = client_ip_for_events.clone(); - tokio::spawn(async move { - let _ = server - .report_event(AgentEvent { - event: Some(AgentEventKind::ProxyRequestMissingHost( - AgentProxyRequestMissingHost { client_ip: cip }, - )), - }) - .await; - }); - return Err(Error::explain( - ErrorType::BindError, - "No host header in request", - )); - } - }; - let host_str = match host_header.to_str() { - Ok(s) => s, - Err(_) => { - let server = self.server.clone(); - let cip = client_ip_for_events.clone(); - tokio::spawn(async move { - let _ = server - .report_event(AgentEvent { - event: Some(AgentEventKind::ProxyRequestInvalidHost( - AgentProxyRequestInvalidHost { client_ip: cip }, - )), - }) - .await; - }); - return Err(Error::explain(ErrorType::BindError, "Invalid host header")); - } + // HTTP/1.1 carries the target in the `Host` header; HTTP/2 carries it in + // the `:authority` pseudo-header, which pingora exposes via the request URI. + let host_str = match session.get_header("host") { + Some(h) => match h.to_str() { + Ok(s) => s.to_string(), + Err(_) => { + let server = self.server.clone(); + let cip = client_ip_for_events.clone(); + tokio::spawn(async move { + let _ = server + .report_event(AgentEvent { + event: Some(AgentEventKind::ProxyRequestInvalidHost( + AgentProxyRequestInvalidHost { client_ip: cip }, + )), + }) + .await; + }); + return Err(Error::explain(ErrorType::BindError, "Invalid host header")); + } + }, + None => match session.req_header().uri.host() { + Some(h) => h.to_string(), + None => { + let server = self.server.clone(); + let cip = client_ip_for_events.clone(); + tokio::spawn(async move { + let _ = server + .report_event(AgentEvent { + event: Some(AgentEventKind::ProxyRequestMissingHost( + AgentProxyRequestMissingHost { client_ip: cip }, + )), + }) + .await; + }); + return Err(Error::explain( + ErrorType::BindError, + "No host header in request", + )); + } + }, }; let url = host_str - .strip_suffix(&format!(":{PROXY_PORT}")) - .unwrap_or(host_str); + .rsplit_once(':') + .map_or(host_str.as_str(), |(host, _)| host); let client_ip = match session.client_addr() { None => { @@ -198,18 +236,43 @@ 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); + // 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?; - println!("Running Nullnet proxy at {proxy_address}\n"); + // 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()); + http_proxy.add_tcp(&http_address); + my_server.add_service(http_proxy); + + // HTTPS listener: per-domain cert resolved by SNI (exact + wildcard) + let mut https_app = nullnet_proxy; + https_app.tls = true; + let mut tls_settings = TlsSettings::with_callbacks(Box::new(TlsResolver::new(cert_store))) + .handle_err(location!())?; + // advertise HTTP/2 (and HTTP/1.1) via ALPN during the TLS handshake + tls_settings.enable_h2(); + 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 +280,96 @@ 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) + } +} + +/// 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 (new_store, failures) = CertStore::from_bundle(&bundle); + let n = new_store.len(); + 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 + .report_event(AgentEvent { + event: Some(AgentEventKind::TlsCertificateInvalid( + AgentTlsCertificateInvalid { domain, reason }, + )), + }) + .await; + } + } + 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; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a minimal GET request with the given Host header and target URI. + fn req(host: &str, uri: &str) -> RequestHeader { + let mut req = RequestHeader::build("GET", uri.as_bytes(), None).unwrap(); + req.insert_header("host", host).unwrap(); + req + } + + #[test] + fn redirect_strips_port_and_targets_443() { + let r = req("color.com:80", "/"); + assert_eq!(https_redirect_url(&r, 443), "https://color.com/"); + } + + #[test] + fn redirect_preserves_path_and_query() { + let r = req("color.com", "/a/b?x=1&y=2"); + assert_eq!(https_redirect_url(&r, 443), "https://color.com/a/b?x=1&y=2"); + } + + #[test] + fn redirect_includes_non_default_https_port() { + let r = req("color.com:8080", "/path"); + assert_eq!(https_redirect_url(&r, 8443), "https://color.com:8443/path"); + } + + #[test] + fn redirect_with_missing_host_yields_empty_authority() { + let mut r = RequestHeader::build("GET", b"/", None).unwrap(); + r.remove_header("host"); + assert_eq!(https_redirect_url(&r, 443), "https:///"); + } +} + // 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..a775350 100644 --- a/members/nullnet-proxy/src/nullnet_proxy.rs +++ b/members/nullnet-proxy/src/nullnet_proxy.rs @@ -1,25 +1,39 @@ 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, }; 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; + // 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!())?; - 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..7076c1c --- /dev/null +++ b/members/nullnet-proxy/src/tls.rs @@ -0,0 +1,353 @@ +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, X509Ref}; +use std::cmp::Ordering; +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 { + /// 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() { + 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()) + .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!())?; + + // 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, + chain, + private_key, + }) + } +} + +/// 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. +/// +/// Keys are SNI names: exact (`color.com`) or wildcard (`*.color.com`). +#[derive(Default)] +pub struct CertStore { + certs: HashMap>, +} + +impl CertStore { + /// Build a store from a bundle received over gRPC, validating each + /// 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.domain, &c.fullchain_pem, &c.key_pem) { + Ok(cert) => { + certs.insert(c.domain.clone(), Arc::new(cert)); + } + Err(e) => failures.push((c.domain.clone(), e.to_str().to_string())), + } + } + (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 + /// (`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. +/// Reads the live `ArcSwap` so hot-reloaded certs are picked up immediately. +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 store = self.store.load(); + let Some(cert) = 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); + } + } +} + +#[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/.cargo/config.toml b/members/nullnet-server/.cargo/config.toml index a83484c..24969dc 100644 --- a/members/nullnet-server/.cargo/config.toml +++ b/members/nullnet-server/.cargo/config.toml @@ -1,3 +1,4 @@ [env] NET_TYPE="VXLAN" TIMEOUT="0" +CERT_ENCRYPTION_KEY="3bf1a1f639274d6ca683b311ef137a49a056ce937c8cfc7faac2a7983c42a71b" 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..5bb1fd6 100644 --- a/members/nullnet-server/Cargo.toml +++ b/members/nullnet-server/Cargo.toml @@ -25,8 +25,21 @@ notify.workspace = true gag.workspace = true chrono.workspace = true axum = "0.8" +axum-server = { version = "0.8", features = ["tls-rustls"] } +rcgen = "0.14" rust-embed = { version = "8", features = ["axum"] } mime_guess = "2" +aes-gcm = "0.10" +base64 = "0.22" +x509-parser = "0.16" +# ACME (Let's Encrypt) cert issuance via DNS-01, ported from ../routix +anyhow = "1.0" +instant-acme = { version = "0.8", features = ["hyper-rustls"] } +reqwest = { version = "0.13", features = ["json", "query", "form"] } +openssl = "0.10" +quick-xml = "0.39" +aws-sdk-route53 = "1" +aws-credential-types = { version = "1", features = ["hardcoded-credentials"] } [build-dependencies] diff --git a/members/nullnet-server/src/cert/authority.rs b/members/nullnet-server/src/cert/authority.rs new file mode 100644 index 0000000..ac72f7c --- /dev/null +++ b/members/nullnet-server/src/cert/authority.rs @@ -0,0 +1,172 @@ +use crate::cert::DnsProvider; +use anyhow::{Context, Result, bail}; +use instant_acme::{ + Account, AccountCredentials, AuthorizationStatus, ChallengeType, Identifier, NewAccount, + NewOrder, Order, OrderStatus, RetryPolicy, +}; + +/// Issues certificates from an ACME CA (Let's Encrypt) using the DNS-01 challenge. +pub struct CertificateAuthority { + acme_url: String, +} + +impl CertificateAuthority { + pub fn new(acme_url: impl Into) -> Self { + Self { + acme_url: acme_url.into(), + } + } + + pub fn staging() -> Self { + Self::new(instant_acme::LetsEncrypt::Staging.url()) + } + + pub fn production() -> Self { + Self::new(instant_acme::LetsEncrypt::Production.url()) + } + + /// Run the full DNS-01 flow for `domain` and return the issued + /// `(fullchain_pem, private_key_pem)`. The TXT challenge records are cleaned + /// up before returning, success or failure. + pub async fn request_certificate( + &self, + domain: &str, + dns_provider: &dyn DnsProvider, + dns_propagation_secs: u64, + ) -> Result<(String, String)> { + let (account, _) = self.acme_account().await?; + let mut order = self.acme_order(domain, &account).await?; + + let records = self + .dns_challenge(&mut order, dns_provider, dns_propagation_secs) + .await?; + + if records.is_empty() { + bail!("No DNS challenges were completed — no TXT records created"); + } + + let result = self.finalize_order(&mut order).await; + + self.cleanup_records(&records, dns_provider).await; + + let (private_key_pem, cert_chain_pem) = result?; + Ok((cert_chain_pem, private_key_pem)) + } + + async fn finalize_order(&self, order: &mut Order) -> Result<(String, String)> { + let status = order.poll_ready(&RetryPolicy::default()).await?; + if status != OrderStatus::Ready { + bail!("Unexpected order status after polling: {status:?}"); + } + + let private_key_pem = order.finalize().await?; + let cert_chain_pem = order.poll_certificate(&RetryPolicy::default()).await?; + + Ok((private_key_pem, cert_chain_pem)) + } + + async fn cleanup_records(&self, records: &[String], dns_provider: &dyn DnsProvider) { + for record_id in records { + if let Err(e) = dns_provider.delete_txt_record(record_id).await { + eprintln!("Failed to delete DNS TXT record {record_id}: {e}"); + } + } + } + + async fn acme_account(&self) -> Result<(Account, AccountCredentials)> { + let new_account = NewAccount { + contact: &[], + terms_of_service_agreed: true, + only_return_existing: false, + }; + + Account::builder() + .context("Failed to create ACME account builder")? + .create(&new_account, self.acme_url.clone(), None) + .await + .context("Failed to create ACME account") + } + + async fn acme_order(&self, domain: &str, account: &Account) -> Result { + let identifiers = [Identifier::Dns(domain.to_string())]; + let new_order: NewOrder<'_> = NewOrder::new(&identifiers); + + account + .new_order(&new_order) + .await + .context("Failed to place ACME order") + } + + async fn dns_challenge( + &self, + order: &mut Order, + dns_provider: &dyn DnsProvider, + dns_propagation_secs: u64, + ) -> Result> { + use std::time::Duration; + use tokio::time::sleep; + + let mut authorizations = order.authorizations(); + let mut record_ids: Vec = Vec::new(); + + while let Some(Ok(mut auth_handle)) = authorizations.next().await { + match auth_handle.status { + AuthorizationStatus::Valid => continue, + AuthorizationStatus::Pending => {} + _ => bail!("Unexpected authorization status: {:?}", auth_handle.status), + } + + let mut challenge = auth_handle + .challenge(ChallengeType::Dns01) + .context("No DNS-01 challenge found in authorization")?; + + let record_name = acme_record_name(&challenge.identifier().to_string()); + + let record_id = dns_provider + .create_txt_record(&record_name, &challenge.key_authorization().dns_value()) + .await + .context("Failed to create DNS TXT record")?; + + record_ids.push(record_id); + + sleep(Duration::from_secs(dns_propagation_secs)).await; + + challenge + .set_ready() + .await + .context("Failed to mark challenge as ready")?; + } + + Ok(record_ids) + } +} + +/// The TXT record name proving control of `identifier`. A wildcard +/// (`*.example.com`) is validated against the bare domain, so the `*.` prefix is +/// stripped first: both `example.com` and `*.example.com` map to +/// `_acme-challenge.example.com`. +fn acme_record_name(identifier: &str) -> String { + let bare = identifier.strip_prefix("*.").unwrap_or(identifier); + format!("_acme-challenge.{bare}") +} + +#[cfg(test)] +mod tests { + use super::acme_record_name; + + #[test] + fn record_name_exact_and_wildcard() { + assert_eq!( + acme_record_name("example.com"), + "_acme-challenge.example.com" + ); + assert_eq!( + acme_record_name("*.example.com"), + "_acme-challenge.example.com" + ); + assert_eq!( + acme_record_name("app.example.com"), + "_acme-challenge.app.example.com" + ); + } +} diff --git a/members/nullnet-server/src/cert/dns_providers/azure/config.rs b/members/nullnet-server/src/cert/dns_providers/azure/config.rs new file mode 100644 index 0000000..aae5d03 --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/azure/config.rs @@ -0,0 +1,7 @@ +pub struct AzureConfig { + pub tenant_id: String, + pub client_id: String, + pub client_secret: String, + pub subscription_id: String, + pub resource_group: String, +} diff --git a/members/nullnet-server/src/cert/dns_providers/azure/mod.rs b/members/nullnet-server/src/cert/dns_providers/azure/mod.rs new file mode 100644 index 0000000..c4cfb43 --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/azure/mod.rs @@ -0,0 +1,178 @@ +use crate::cert::DnsProvider; +use anyhow::{Context, Result, bail}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +mod config; +pub use config::AzureConfig; + +const API_VERSION: &str = "2018-05-01"; +const MANAGEMENT_URL: &str = "https://management.azure.com"; + +pub struct AzureDns { + tenant_id: String, + client_id: String, + client_secret: String, + subscription_id: String, + resource_group: String, + client: reqwest::Client, +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, +} + +#[derive(Deserialize)] +struct ZoneListResponse { + value: Vec, +} + +#[derive(Deserialize)] +struct AzureZone { + name: String, +} + +#[derive(Serialize)] +struct RecordSetBody { + properties: RecordSetProperties, +} + +#[derive(Serialize)] +struct RecordSetProperties { + #[serde(rename = "TTL")] + ttl: u32, + #[serde(rename = "TXTRecords")] + txt_records: Vec, +} + +#[derive(Serialize)] +struct TxtRecordValue { + value: Vec, +} + +impl AzureDns { + pub fn new(config: AzureConfig) -> Self { + Self { + tenant_id: config.tenant_id, + client_id: config.client_id, + client_secret: config.client_secret, + subscription_id: config.subscription_id, + resource_group: config.resource_group, + client: reqwest::Client::new(), + } + } + + async fn get_token(&self) -> Result { + let token_url = format!( + "https://login.microsoftonline.com/{}/oauth2/v2.0/token", + self.tenant_id + ); + + let response: TokenResponse = self + .client + .post(&token_url) + .form(&[ + ("client_id", self.client_id.as_str()), + ("client_secret", self.client_secret.as_str()), + ("grant_type", "client_credentials"), + ("scope", "https://management.azure.com/.default"), + ]) + .send() + .await + .context("Failed to request Azure OAuth2 token")? + .json() + .await + .context("Failed to parse Azure OAuth2 token response")?; + + Ok(response.access_token) + } + + fn zone_base_url(&self) -> String { + format!( + "{MANAGEMENT_URL}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Network/dnsZones", + self.subscription_id, self.resource_group + ) + } + + async fn find_zone(&self, name: &str, token: &str) -> Result<(String, String)> { + let response: ZoneListResponse = self + .client + .get(self.zone_base_url()) + .bearer_auth(token) + .query(&[("api-version", API_VERSION)]) + .send() + .await + .context("Failed to list Azure DNS zones")? + .json() + .await + .context("Failed to parse Azure DNS zones response")?; + + let labels: Vec<&str> = name.split('.').collect(); + + for i in 1..labels.len() { + let candidate = labels[i..].join("."); + let relative = labels[..i].join("."); + + if response.value.iter().any(|z| z.name == candidate) { + return Ok((candidate, relative)); + } + } + + bail!("No Azure DNS zone found for: {name}") + } + + fn record_url(&self, zone: &str, relative_name: &str) -> String { + format!("{}/{zone}/TXT/{relative_name}", self.zone_base_url()) + } +} + +#[async_trait] +impl DnsProvider for AzureDns { + async fn create_txt_record(&self, name: &str, value: &str) -> Result { + let token = self.get_token().await?; + let (zone, relative_name) = self.find_zone(name, &token).await?; + + let body = RecordSetBody { + properties: RecordSetProperties { + ttl: 60, + txt_records: vec![TxtRecordValue { + value: vec![value.to_string()], + }], + }, + }; + + self.client + .put(self.record_url(&zone, &relative_name)) + .bearer_auth(&token) + .query(&[("api-version", API_VERSION)]) + .json(&body) + .send() + .await + .context("Failed to create TXT record in Azure DNS")? + .error_for_status() + .context("Azure DNS create TXT record request failed")?; + + Ok(format!("{zone}|{relative_name}")) + } + + async fn delete_txt_record(&self, record_id: &str) -> Result<()> { + let (zone, relative_name) = record_id + .split_once('|') + .context("Invalid Azure record_id format, expected '|'")?; + + let token = self.get_token().await?; + + self.client + .delete(self.record_url(zone, relative_name)) + .bearer_auth(&token) + .query(&[("api-version", API_VERSION)]) + .send() + .await + .context("Failed to delete TXT record in Azure DNS")? + .error_for_status() + .context("Azure DNS delete request failed")?; + + Ok(()) + } +} diff --git a/members/nullnet-server/src/cert/dns_providers/cloudflare/config.rs b/members/nullnet-server/src/cert/dns_providers/cloudflare/config.rs new file mode 100644 index 0000000..a057a45 --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/cloudflare/config.rs @@ -0,0 +1,4 @@ +#[derive(Debug, Clone)] +pub struct CloudflareDnsConfig { + pub api_token: String, +} diff --git a/members/nullnet-server/src/cert/dns_providers/cloudflare/mod.rs b/members/nullnet-server/src/cert/dns_providers/cloudflare/mod.rs new file mode 100644 index 0000000..261cc7b --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/cloudflare/mod.rs @@ -0,0 +1,154 @@ +use crate::cert::DnsProvider; +use anyhow::{Context, Result, bail}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +mod config; +pub use config::CloudflareDnsConfig; + +pub struct CloudflareDns { + api_token: String, + client: reqwest::Client, +} + +#[derive(Serialize)] +struct CreateRecordRequest<'a> { + r#type: &'a str, + name: &'a str, + content: &'a str, + ttl: u32, +} + +#[derive(Deserialize)] +struct CloudflareResponse { + success: bool, + result: Option, + errors: Vec, +} + +#[derive(Deserialize)] +struct CloudflareApiError { + message: String, +} + +impl CloudflareDns { + pub fn new(config: CloudflareDnsConfig) -> Self { + Self { + api_token: config.api_token, + client: reqwest::Client::new(), + } + } + + async fn resolve_zone_id(&self, domain: &str) -> Result { + #[derive(Deserialize)] + struct ZoneResult { + id: String, + } + + let labels: Vec<&str> = domain.split('.').collect(); + + for i in 0..labels.len() - 1 { + let candidate = labels[i..].join("."); + + let response: CloudflareResponse> = self + .client + .get("https://api.cloudflare.com/client/v4/zones") + .bearer_auth(&self.api_token) + .query(&[("name", candidate.as_str()), ("status", "active")]) + .send() + .await + .with_context(|| format!("Failed to query zones for {candidate}"))? + .json() + .await + .context("Failed to parse zone list response")?; + + if !response.success { + bail!("Cloudflare API error: {}", response.error_messages()); + } + + if let Some(zone) = response.result.and_then(|z| z.into_iter().next()) { + return Ok(zone.id); + } + } + + bail!("No active Cloudflare zone found for domain: {domain}") + } + + fn records_url(zone_id: &str) -> String { + format!("https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records") + } +} + +#[async_trait] +impl DnsProvider for CloudflareDns { + async fn create_txt_record(&self, name: &str, value: &str) -> Result { + #[derive(Deserialize)] + struct DnsRecord { + id: String, + } + + let domain = name.trim_start_matches("_acme-challenge."); + let zone_id = self.resolve_zone_id(domain).await?; + + let response: CloudflareResponse = self + .client + .post(Self::records_url(&zone_id)) + .bearer_auth(&self.api_token) + .json(&CreateRecordRequest { + r#type: "TXT", + name, + content: value, + ttl: 60, + }) + .send() + .await + .context("Failed to send create TXT record request")? + .json() + .await + .context("Failed to parse create TXT record response")?; + + if !response.success { + bail!("Cloudflare API error: {}", response.error_messages()); + } + + let record_id = response + .result + .ok_or_else(|| anyhow::anyhow!("Empty result in create TXT record response"))? + .id; + + Ok(format!("{zone_id}/{record_id}")) + } + + async fn delete_txt_record(&self, record_id: &str) -> Result<()> { + let (zone_id, id) = record_id + .split_once('/') + .context("Invalid record_id format, expected '/'")?; + + let response: CloudflareResponse = self + .client + .delete(format!("{}/{}", Self::records_url(zone_id), id)) + .bearer_auth(&self.api_token) + .send() + .await + .context("Failed to send delete TXT record request")? + .json() + .await + .context("Failed to parse delete TXT record response")?; + + if !response.success { + bail!("Cloudflare API error: {}", response.error_messages()); + } + + Ok(()) + } +} + +impl CloudflareResponse { + fn error_messages(&self) -> String { + self.errors + .iter() + .map(|e| e.message.as_str()) + .collect::>() + .join(", ") + } +} diff --git a/members/nullnet-server/src/cert/dns_providers/digitalocean/config.rs b/members/nullnet-server/src/cert/dns_providers/digitalocean/config.rs new file mode 100644 index 0000000..e5e1d59 --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/digitalocean/config.rs @@ -0,0 +1,3 @@ +pub struct DigitalOceanConfig { + pub api_token: String, +} diff --git a/members/nullnet-server/src/cert/dns_providers/digitalocean/mod.rs b/members/nullnet-server/src/cert/dns_providers/digitalocean/mod.rs new file mode 100644 index 0000000..ca4677d --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/digitalocean/mod.rs @@ -0,0 +1,121 @@ +use crate::cert::DnsProvider; +use anyhow::{Context, Result, bail}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +mod config; +pub use config::DigitalOceanConfig; + +const BASE_URL: &str = "https://api.digitalocean.com/v2"; + +pub struct DigitalOceanDns { + api_token: String, + client: reqwest::Client, +} + +#[derive(Deserialize)] +struct DomainsResponse { + domains: Vec, +} + +#[derive(Deserialize)] +struct Domain { + name: String, +} + +#[derive(Serialize)] +struct CreateRecordRequest<'a> { + r#type: &'a str, + name: &'a str, + data: &'a str, + ttl: u32, +} + +#[derive(Deserialize)] +struct CreateRecordResponse { + domain_record: DomainRecord, +} + +#[derive(Deserialize)] +struct DomainRecord { + id: u64, +} + +impl DigitalOceanDns { + pub fn new(config: DigitalOceanConfig) -> Self { + Self { + api_token: config.api_token, + client: reqwest::Client::new(), + } + } + + async fn find_domain(&self, name: &str) -> Result<(String, String)> { + let response: DomainsResponse = self + .client + .get(format!("{BASE_URL}/domains")) + .bearer_auth(&self.api_token) + .query(&[("per_page", "200")]) + .send() + .await + .context("Failed to list DigitalOcean domains")? + .json() + .await + .context("Failed to parse DigitalOcean domains response")?; + + let labels: Vec<&str> = name.split('.').collect(); + + for i in 1..labels.len() { + let candidate = labels[i..].join("."); + let relative = labels[..i].join("."); + + if response.domains.iter().any(|d| d.name == candidate) { + return Ok((candidate, relative)); + } + } + + bail!("No DigitalOcean domain found for: {name}") + } +} + +#[async_trait] +impl DnsProvider for DigitalOceanDns { + async fn create_txt_record(&self, name: &str, value: &str) -> Result { + let (domain, relative_name) = self.find_domain(name).await?; + + let response: CreateRecordResponse = self + .client + .post(format!("{BASE_URL}/domains/{domain}/records")) + .bearer_auth(&self.api_token) + .json(&CreateRecordRequest { + r#type: "TXT", + name: &relative_name, + data: value, + ttl: 60, + }) + .send() + .await + .context("Failed to create TXT record in DigitalOcean")? + .json() + .await + .context("Failed to parse DigitalOcean create TXT record response")?; + + Ok(format!("{domain}|{}", response.domain_record.id)) + } + + async fn delete_txt_record(&self, record_id: &str) -> Result<()> { + let (domain, id) = record_id + .split_once('|') + .context("Invalid DigitalOcean record_id format, expected '|'")?; + + self.client + .delete(format!("{BASE_URL}/domains/{domain}/records/{id}")) + .bearer_auth(&self.api_token) + .send() + .await + .context("Failed to delete TXT record in DigitalOcean")? + .error_for_status() + .context("DigitalOcean DNS delete request failed")?; + + Ok(()) + } +} diff --git a/members/nullnet-server/src/cert/dns_providers/godaddy/config.rs b/members/nullnet-server/src/cert/dns_providers/godaddy/config.rs new file mode 100644 index 0000000..7e5c70a --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/godaddy/config.rs @@ -0,0 +1,4 @@ +pub struct GoDaddyConfig { + pub api_key: String, + pub api_secret: String, +} diff --git a/members/nullnet-server/src/cert/dns_providers/godaddy/mod.rs b/members/nullnet-server/src/cert/dns_providers/godaddy/mod.rs new file mode 100644 index 0000000..29b85c7 --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/godaddy/mod.rs @@ -0,0 +1,110 @@ +use crate::cert::DnsProvider; +use anyhow::{Context, Result, bail}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +mod config; +pub use config::GoDaddyConfig; + +const BASE_URL: &str = "https://api.godaddy.com/v1"; + +pub struct GoDaddyDns { + api_key: String, + api_secret: String, + client: reqwest::Client, +} + +#[derive(Deserialize)] +struct GoDaddyDomain { + domain: String, +} + +#[derive(Serialize)] +struct TxtRecord<'a> { + data: &'a str, + ttl: u32, +} + +impl GoDaddyDns { + pub fn new(config: GoDaddyConfig) -> Self { + Self { + api_key: config.api_key, + api_secret: config.api_secret, + client: reqwest::Client::new(), + } + } + + fn auth_header(&self) -> String { + format!("sso-key {}:{}", self.api_key, self.api_secret) + } + + async fn find_domain(&self, name: &str) -> Result<(String, String)> { + let domains: Vec = self + .client + .get(format!("{BASE_URL}/domains")) + .header("Authorization", self.auth_header()) + .query(&[("limit", "500"), ("statuses", "ACTIVE")]) + .send() + .await + .context("Failed to list GoDaddy domains")? + .json() + .await + .context("Failed to parse GoDaddy domains response")?; + + let labels: Vec<&str> = name.split('.').collect(); + + for i in 1..labels.len() { + let candidate = labels[i..].join("."); + let relative = labels[..i].join("."); + + if domains.iter().any(|d| d.domain == candidate) { + return Ok((candidate, relative)); + } + } + + bail!("No GoDaddy domain found for: {name}") + } +} + +#[async_trait] +impl DnsProvider for GoDaddyDns { + async fn create_txt_record(&self, name: &str, value: &str) -> Result { + let (domain, relative_name) = self.find_domain(name).await?; + + self.client + .put(format!( + "{BASE_URL}/domains/{domain}/records/TXT/{relative_name}" + )) + .header("Authorization", self.auth_header()) + .json(&[TxtRecord { + data: value, + ttl: 600, + }]) + .send() + .await + .context("Failed to create TXT record in GoDaddy")? + .error_for_status() + .context("GoDaddy create TXT record request failed")?; + + Ok(format!("{domain}|{relative_name}")) + } + + async fn delete_txt_record(&self, record_id: &str) -> Result<()> { + let (domain, relative_name) = record_id + .split_once('|') + .context("Invalid GoDaddy record_id format, expected '|'")?; + + self.client + .delete(format!( + "{BASE_URL}/domains/{domain}/records/TXT/{relative_name}" + )) + .header("Authorization", self.auth_header()) + .send() + .await + .context("Failed to delete TXT record in GoDaddy")? + .error_for_status() + .context("GoDaddy DNS delete request failed")?; + + Ok(()) + } +} diff --git a/members/nullnet-server/src/cert/dns_providers/google/config.rs b/members/nullnet-server/src/cert/dns_providers/google/config.rs new file mode 100644 index 0000000..57a1fdc --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/google/config.rs @@ -0,0 +1,5 @@ +pub struct GoogleDnsConfig { + pub project_id: String, + pub client_email: String, + pub private_key: String, +} diff --git a/members/nullnet-server/src/cert/dns_providers/google/mod.rs b/members/nullnet-server/src/cert/dns_providers/google/mod.rs new file mode 100644 index 0000000..ae75bcd --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/google/mod.rs @@ -0,0 +1,215 @@ +use crate::cert::DnsProvider; +use anyhow::{Context, Result, bail}; +use async_trait::async_trait; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::{Deserialize, Serialize}; + +mod config; +pub use config::GoogleDnsConfig; + +const DNS_API: &str = "https://dns.googleapis.com/dns/v1"; +const TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; + +pub struct GoogleDns { + project_id: String, + client_email: String, + private_key_pem: String, + client: reqwest::Client, +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, +} + +#[derive(Deserialize)] +struct ManagedZoneListResponse { + #[serde(rename = "managedZones")] + managed_zones: Vec, +} + +#[derive(Deserialize)] +struct ManagedZone { + name: String, // e.g. "example-com" + #[serde(rename = "dnsName")] + dns_name: String, // e.g. "example.com." (trailing dot) +} + +#[derive(Serialize)] +struct ChangeRequest<'a> { + additions: Option>>, + deletions: Option>>, +} + +#[derive(Serialize)] +struct ResourceRecordSet<'a> { + name: &'a str, + r#type: &'a str, + ttl: u32, + rrdatas: Vec, +} + +impl GoogleDns { + pub fn new(config: GoogleDnsConfig) -> Self { + Self { + project_id: config.project_id, + client_email: config.client_email, + private_key_pem: config.private_key, + client: reqwest::Client::new(), + } + } + + async fn get_access_token(&self) -> Result { + let jwt = self.create_jwt()?; + + let response: TokenResponse = self + .client + .post(TOKEN_URL) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), + ("assertion", jwt.as_str()), + ]) + .send() + .await + .context("Failed to exchange JWT for Google access token")? + .json() + .await + .context("Failed to parse Google access token response")?; + + Ok(response.access_token) + } + + fn create_jwt(&self) -> Result { + use openssl::hash::MessageDigest; + use openssl::pkey::PKey; + use openssl::rsa::Rsa; + use openssl::sign::Signer; + + let now = chrono::Utc::now().timestamp(); + + let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256","typ":"JWT"}"#); + let claims = serde_json::json!({ + "iss": self.client_email, + "scope": "https://www.googleapis.com/auth/cloud-platform", + "aud": TOKEN_URL, + "iat": now, + "exp": now + 3600, + }); + let payload = URL_SAFE_NO_PAD.encode(claims.to_string()); + let to_sign = format!("{header}.{payload}"); + + let rsa = Rsa::private_key_from_pem(self.private_key_pem.as_bytes()) + .context("Failed to parse GCP RSA private key")?; + let pkey = PKey::from_rsa(rsa).context("Failed to create PKey from RSA")?; + + let mut signer = + Signer::new(MessageDigest::sha256(), &pkey).context("Failed to create JWT signer")?; + signer + .update(to_sign.as_bytes()) + .context("Failed to update JWT signer")?; + let sig = signer.sign_to_vec().context("Failed to sign JWT")?; + + Ok(format!("{to_sign}.{}", URL_SAFE_NO_PAD.encode(&sig))) + } + + async fn find_zone(&self, name: &str, token: &str) -> Result<(String, String)> { + let response: ManagedZoneListResponse = self + .client + .get(format!( + "{DNS_API}/projects/{}/managedZones", + self.project_id + )) + .bearer_auth(token) + .send() + .await + .context("Failed to list Google Cloud DNS managed zones")? + .json() + .await + .context("Failed to parse Google Cloud DNS managed zones response")?; + + let labels: Vec<&str> = name.split('.').collect(); + + for i in 1..labels.len() { + let candidate_dns = format!("{}.", labels[i..].join(".")); + + if let Some(zone) = response + .managed_zones + .iter() + .find(|z| z.dns_name == candidate_dns) + { + let fqdn = format!("{name}."); + return Ok((zone.name.clone(), fqdn)); + } + } + + bail!("No Google Cloud DNS managed zone found for: {name}") + } +} + +#[async_trait] +impl DnsProvider for GoogleDns { + async fn create_txt_record(&self, name: &str, value: &str) -> Result { + let token = self.get_access_token().await?; + let (zone, fqdn) = self.find_zone(name, &token).await?; + + let change = ChangeRequest { + additions: Some(vec![ResourceRecordSet { + name: &fqdn, + r#type: "TXT", + ttl: 60, + rrdatas: vec![format!("\"{value}\"")], + }]), + deletions: None, + }; + + self.client + .post(format!( + "{DNS_API}/projects/{}/managedZones/{zone}/changes", + self.project_id + )) + .bearer_auth(&token) + .json(&change) + .send() + .await + .context("Failed to create TXT record in Google Cloud DNS")? + .error_for_status() + .context("Google Cloud DNS create TXT record request failed")?; + + Ok(format!("{zone}|{fqdn}|{value}")) + } + + async fn delete_txt_record(&self, record_id: &str) -> Result<()> { + let parts: Vec<&str> = record_id.splitn(3, '|').collect(); + if parts.len() != 3 { + bail!("Invalid Google Cloud DNS record_id format, expected '||'"); + } + let (zone, fqdn, value) = (parts[0], parts[1], parts[2]); + + let token = self.get_access_token().await?; + + let change = ChangeRequest { + additions: None, + deletions: Some(vec![ResourceRecordSet { + name: fqdn, + r#type: "TXT", + ttl: 60, + rrdatas: vec![format!("\"{value}\"")], + }]), + }; + + self.client + .post(format!( + "{DNS_API}/projects/{}/managedZones/{zone}/changes", + self.project_id + )) + .bearer_auth(&token) + .json(&change) + .send() + .await + .context("Failed to delete TXT record in Google Cloud DNS")? + .error_for_status() + .context("Google Cloud DNS delete TXT record request failed")?; + + Ok(()) + } +} diff --git a/members/nullnet-server/src/cert/dns_providers/hetzner/config.rs b/members/nullnet-server/src/cert/dns_providers/hetzner/config.rs new file mode 100644 index 0000000..9b8eab3 --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/hetzner/config.rs @@ -0,0 +1,3 @@ +pub struct HetznerDnsConfig { + pub api_token: String, +} diff --git a/members/nullnet-server/src/cert/dns_providers/hetzner/mod.rs b/members/nullnet-server/src/cert/dns_providers/hetzner/mod.rs new file mode 100644 index 0000000..91b3f45 --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/hetzner/mod.rs @@ -0,0 +1,119 @@ +use crate::cert::DnsProvider; +use anyhow::{Context, Result, bail}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +mod config; +pub use config::HetznerDnsConfig; + +const BASE_URL: &str = "https://dns.hetzner.com/api/v1"; + +pub struct HetznerDns { + api_token: String, + client: reqwest::Client, +} + +#[derive(Serialize)] +struct CreateRecordRequest<'a> { + zone_id: &'a str, + r#type: &'a str, + name: &'a str, + value: &'a str, + ttl: u32, +} + +#[derive(Deserialize)] +struct ZoneListResponse { + zones: Vec, +} + +#[derive(Deserialize)] +struct Zone { + id: String, +} + +#[derive(Deserialize)] +struct RecordResponse { + record: Record, +} + +#[derive(Deserialize)] +struct Record { + id: String, +} + +impl HetznerDns { + pub fn new(config: HetznerDnsConfig) -> Self { + Self { + api_token: config.api_token, + client: reqwest::Client::new(), + } + } + + async fn find_zone(&self, name: &str) -> Result<(String, String)> { + let labels: Vec<&str> = name.split('.').collect(); + + for i in 1..labels.len() { + let zone_name = labels[i..].join("."); + let relative_name = labels[..i].join("."); + + let response: ZoneListResponse = self + .client + .get(format!("{BASE_URL}/zones")) + .header("Auth-API-Token", &self.api_token) + .query(&[("name", &zone_name)]) + .send() + .await + .with_context(|| format!("Failed to query Hetzner zones for {zone_name}"))? + .json() + .await + .context("Failed to parse Hetzner zone list response")?; + + if let Some(zone) = response.zones.into_iter().next() { + return Ok((zone.id, relative_name)); + } + } + + bail!("No Hetzner DNS zone found for: {name}") + } +} + +#[async_trait] +impl DnsProvider for HetznerDns { + async fn create_txt_record(&self, name: &str, value: &str) -> Result { + let (zone_id, relative_name) = self.find_zone(name).await?; + + let response: RecordResponse = self + .client + .post(format!("{BASE_URL}/records")) + .header("Auth-API-Token", &self.api_token) + .json(&CreateRecordRequest { + zone_id: &zone_id, + r#type: "TXT", + name: &relative_name, + value: &format!("\"{value}\""), + ttl: 60, + }) + .send() + .await + .context("Failed to create TXT record in Hetzner DNS")? + .json() + .await + .context("Failed to parse Hetzner create TXT record response")?; + + Ok(response.record.id) + } + + async fn delete_txt_record(&self, record_id: &str) -> Result<()> { + self.client + .delete(format!("{BASE_URL}/records/{record_id}")) + .header("Auth-API-Token", &self.api_token) + .send() + .await + .context("Failed to delete TXT record in Hetzner DNS")? + .error_for_status() + .context("Hetzner DNS delete request failed")?; + + Ok(()) + } +} diff --git a/members/nullnet-server/src/cert/dns_providers/mod.rs b/members/nullnet-server/src/cert/dns_providers/mod.rs new file mode 100644 index 0000000..60ca637 --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/mod.rs @@ -0,0 +1,23 @@ +mod azure; +mod cloudflare; +mod digitalocean; +mod godaddy; +mod google; +mod hetzner; +mod namecheap; +mod ovh; +mod porkbun; +mod route53; +mod vultr; + +pub use azure::*; +pub use cloudflare::*; +pub use digitalocean::*; +pub use godaddy::*; +pub use google::*; +pub use hetzner::*; +pub use namecheap::*; +pub use ovh::*; +pub use porkbun::*; +pub use route53::*; +pub use vultr::*; diff --git a/members/nullnet-server/src/cert/dns_providers/namecheap/config.rs b/members/nullnet-server/src/cert/dns_providers/namecheap/config.rs new file mode 100644 index 0000000..cf6994e --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/namecheap/config.rs @@ -0,0 +1,5 @@ +pub struct NamecheapConfig { + pub api_user: String, + pub api_key: String, + pub client_ip: String, +} diff --git a/members/nullnet-server/src/cert/dns_providers/namecheap/mod.rs b/members/nullnet-server/src/cert/dns_providers/namecheap/mod.rs new file mode 100644 index 0000000..3c0dde2 --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/namecheap/mod.rs @@ -0,0 +1,235 @@ +use crate::cert::DnsProvider; +use anyhow::{Context, Result, bail}; +use async_trait::async_trait; + +mod config; +pub use config::NamecheapConfig; + +const BASE_URL: &str = "https://api.namecheap.com/xml.response"; + +pub struct NamecheapDns { + api_user: String, + api_key: String, + client_ip: String, + client: reqwest::Client, +} + +#[derive(Debug, Default, Clone)] +struct HostRecord { + name: String, + record_type: String, + address: String, + ttl: String, + mx_pref: String, +} + +impl NamecheapDns { + pub fn new(config: NamecheapConfig) -> Self { + Self { + api_user: config.api_user, + api_key: config.api_key, + client_ip: config.client_ip, + client: reqwest::Client::new(), + } + } + + fn base_params(&self) -> Vec<(&str, &str)> { + vec![ + ("ApiUser", &self.api_user), + ("ApiKey", &self.api_key), + ("UserName", &self.api_user), + ("ClientIp", &self.client_ip), + ] + } + + fn split_domain(domain: &str) -> (&str, &str) { + domain.split_once('.').unwrap_or((domain, "")) + } + + async fn find_domain(&self, name: &str) -> Result<(String, String, String)> { + let mut params = self.base_params(); + params.push(("Command", "namecheap.domains.getList")); + params.push(("PageSize", "100")); + + let xml = self + .client + .get(BASE_URL) + .query(¶ms) + .send() + .await + .context("Failed to list Namecheap domains")? + .text() + .await + .context("Failed to read Namecheap domain list response")?; + + let domain_names = parse_domain_list(&xml)?; + let labels: Vec<&str> = name.split('.').collect(); + + for i in 1..labels.len() { + let candidate = labels[i..].join("."); + let relative = labels[..i].join("."); + + if domain_names.contains(&candidate) { + let (sld, tld) = Self::split_domain(&candidate); + return Ok((sld.to_string(), tld.to_string(), relative)); + } + } + + bail!("No Namecheap domain found for: {name}") + } + + async fn get_hosts(&self, sld: &str, tld: &str) -> Result> { + let mut params = self.base_params(); + params.push(("Command", "namecheap.domains.dns.getHosts")); + let sld_param = sld.to_string(); + let tld_param = tld.to_string(); + params.push(("SLD", &sld_param)); + params.push(("TLD", &tld_param)); + + let xml = self + .client + .get(BASE_URL) + .query(¶ms) + .send() + .await + .context("Failed to get Namecheap DNS hosts")? + .text() + .await + .context("Failed to read Namecheap getHosts response")?; + + parse_hosts(&xml) + } + + async fn set_hosts(&self, sld: &str, tld: &str, hosts: &[HostRecord]) -> Result<()> { + let mut params: Vec<(String, String)> = vec![ + ("ApiUser".into(), self.api_user.clone()), + ("ApiKey".into(), self.api_key.clone()), + ("UserName".into(), self.api_user.clone()), + ("ClientIp".into(), self.client_ip.clone()), + ("Command".into(), "namecheap.domains.dns.setHosts".into()), + ("SLD".into(), sld.to_string()), + ("TLD".into(), tld.to_string()), + ]; + + for (i, host) in hosts.iter().enumerate() { + let n = i + 1; + params.push((format!("HostName{n}"), host.name.clone())); + params.push((format!("RecordType{n}"), host.record_type.clone())); + params.push((format!("Address{n}"), host.address.clone())); + params.push((format!("TTL{n}"), host.ttl.clone())); + if !host.mx_pref.is_empty() && host.record_type == "MX" { + params.push((format!("MXPref{n}"), host.mx_pref.clone())); + } + } + + self.client + .get(BASE_URL) + .query(¶ms) + .send() + .await + .context("Failed to set Namecheap DNS hosts")? + .error_for_status() + .context("Namecheap setHosts request failed")?; + + Ok(()) + } +} + +#[async_trait] +impl DnsProvider for NamecheapDns { + async fn create_txt_record(&self, name: &str, value: &str) -> Result { + let (sld, tld, relative_name) = self.find_domain(name).await?; + + let mut hosts = self.get_hosts(&sld, &tld).await?; + hosts.push(HostRecord { + name: relative_name.clone(), + record_type: "TXT".into(), + address: value.to_string(), + ttl: "60".into(), + mx_pref: String::new(), + }); + + self.set_hosts(&sld, &tld, &hosts).await?; + + Ok(format!("{sld}.{tld}|{relative_name}|{value}")) + } + + async fn delete_txt_record(&self, record_id: &str) -> Result<()> { + let parts: Vec<&str> = record_id.splitn(3, '|').collect(); + if parts.len() != 3 { + bail!("Invalid Namecheap record_id format, expected '||'"); + } + let (domain, host_name, value) = (parts[0], parts[1], parts[2]); + + let (sld, tld) = NamecheapDns::split_domain(domain); + let mut hosts = self.get_hosts(sld, tld).await?; + + hosts.retain(|h| !(h.name == host_name && h.record_type == "TXT" && h.address == value)); + + self.set_hosts(sld, tld, &hosts).await + } +} + +fn parse_domain_list(xml: &str) -> Result> { + use quick_xml::Reader; + use quick_xml::events::Event; + + let mut reader = Reader::from_str(xml); + let mut domains = Vec::new(); + + loop { + match reader + .read_event() + .context("Failed to parse Namecheap domain list XML")? + { + Event::Empty(e) | Event::Start(e) if e.local_name().as_ref() == b"Domain" => { + for attr in e.attributes().flatten() { + if attr.key.local_name().as_ref() == b"Name" { + let val = String::from_utf8_lossy(&attr.value).to_string(); + domains.push(val); + break; + } + } + } + Event::Eof => break, + _ => {} + } + } + + Ok(domains) +} + +fn parse_hosts(xml: &str) -> Result> { + use quick_xml::Reader; + use quick_xml::events::Event; + + let mut reader = Reader::from_str(xml); + let mut hosts = Vec::new(); + + loop { + match reader + .read_event() + .context("Failed to parse Namecheap getHosts XML")? + { + Event::Empty(e) | Event::Start(e) if e.local_name().as_ref() == b"host" => { + let mut record = HostRecord::default(); + for attr in e.attributes().flatten() { + let val = String::from_utf8_lossy(&attr.value).to_string(); + match attr.key.local_name().as_ref() { + b"Name" => record.name = val, + b"Type" => record.record_type = val, + b"Address" => record.address = val, + b"TTL" => record.ttl = val, + b"MXPref" => record.mx_pref = val, + _ => {} + } + } + hosts.push(record); + } + Event::Eof => break, + _ => {} + } + } + + Ok(hosts) +} diff --git a/members/nullnet-server/src/cert/dns_providers/ovh/config.rs b/members/nullnet-server/src/cert/dns_providers/ovh/config.rs new file mode 100644 index 0000000..e17eb21 --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/ovh/config.rs @@ -0,0 +1,7 @@ +pub struct OvhConfig { + /// Base URL, e.g. `https://eu.api.ovh.com/1.0` or `https://ca.api.ovh.com/1.0`. + pub endpoint: String, + pub app_key: String, + pub app_secret: String, + pub consumer_key: String, +} diff --git a/members/nullnet-server/src/cert/dns_providers/ovh/mod.rs b/members/nullnet-server/src/cert/dns_providers/ovh/mod.rs new file mode 100644 index 0000000..e4a26f9 --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/ovh/mod.rs @@ -0,0 +1,219 @@ +use crate::cert::DnsProvider; +use anyhow::{Context, Result, bail}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::fmt::Write; + +mod config; +pub use config::OvhConfig; + +pub struct OvhDns { + endpoint: String, + app_key: String, + app_secret: String, + consumer_key: String, + client: reqwest::Client, +} + +#[derive(Serialize)] +struct CreateRecordRequest<'a> { + #[serde(rename = "fieldType")] + field_type: &'a str, + #[serde(rename = "subDomain")] + sub_domain: &'a str, + target: &'a str, + ttl: u32, +} + +#[derive(Deserialize)] +struct OvhRecord { + id: u64, +} + +impl OvhDns { + pub fn new(config: OvhConfig) -> Self { + Self { + endpoint: config.endpoint, + app_key: config.app_key, + app_secret: config.app_secret, + consumer_key: config.consumer_key, + client: reqwest::Client::new(), + } + } + + fn sign(&self, method: &str, url: &str, body: &str, timestamp: i64) -> Result { + use openssl::hash::MessageDigest; + use openssl::pkey::PKey; + use openssl::sign::Signer; + + let preimage = format!( + "{}+{}+{}+{}+{}+{}", + self.app_secret, self.consumer_key, method, url, body, timestamp + ); + + let key = + PKey::hmac(self.app_secret.as_bytes()).context("Failed to create OVH HMAC key")?; + let mut signer = + Signer::new(MessageDigest::sha1(), &key).context("Failed to create OVH signer")?; + signer + .update(preimage.as_bytes()) + .context("Failed to update OVH signer")?; + let sig = signer + .sign_to_vec() + .context("Failed to compute OVH HMAC-SHA1")?; + + let hex: String = sig.iter().try_fold::>( + String::new(), + |mut acc, b| { + write!(acc, "{b:02x}").map_err(|e| anyhow::anyhow!(e))?; + Ok(acc) + }, + )?; + + Ok(format!("$1${hex}")) + } + + async fn get_timestamp(&self) -> Result { + let ts: i64 = self + .client + .get(format!("{}/auth/time", self.endpoint)) + .send() + .await + .context("Failed to get OVH server time")? + .json() + .await + .context("Failed to parse OVH server time")?; + Ok(ts) + } + + async fn signed_get Deserialize<'de>>(&self, path: &str) -> Result { + let url = format!("{}{}", self.endpoint, path); + let ts = self.get_timestamp().await?; + let sig = self.sign("GET", &url, "", ts)?; + + self.client + .get(&url) + .header("X-Ovh-Application", &self.app_key) + .header("X-Ovh-Consumer", &self.consumer_key) + .header("X-Ovh-Timestamp", ts.to_string()) + .header("X-Ovh-Signature", sig) + .send() + .await + .with_context(|| format!("OVH GET {path} failed"))? + .json() + .await + .with_context(|| format!("Failed to parse OVH GET {path} response")) + } + + async fn signed_post Deserialize<'de>>( + &self, + path: &str, + body: &B, + ) -> Result { + let url = format!("{}{}", self.endpoint, path); + let body_str = + serde_json::to_string(body).context("Failed to serialize OVH request body")?; + let ts = self.get_timestamp().await?; + let sig = self.sign("POST", &url, &body_str, ts)?; + + self.client + .post(&url) + .header("X-Ovh-Application", &self.app_key) + .header("X-Ovh-Consumer", &self.consumer_key) + .header("X-Ovh-Timestamp", ts.to_string()) + .header("X-Ovh-Signature", sig) + .header("Content-Type", "application/json") + .body(body_str) + .send() + .await + .with_context(|| format!("OVH POST {path} failed"))? + .json() + .await + .with_context(|| format!("Failed to parse OVH POST {path} response")) + } + + async fn signed_delete(&self, path: &str) -> Result<()> { + let url = format!("{}{}", self.endpoint, path); + let ts = self.get_timestamp().await?; + let sig = self.sign("DELETE", &url, "", ts)?; + + self.client + .delete(&url) + .header("X-Ovh-Application", &self.app_key) + .header("X-Ovh-Consumer", &self.consumer_key) + .header("X-Ovh-Timestamp", ts.to_string()) + .header("X-Ovh-Signature", sig) + .send() + .await + .with_context(|| format!("OVH DELETE {path} failed"))? + .error_for_status() + .with_context(|| format!("OVH DELETE {path} returned error status"))?; + + Ok(()) + } + + async fn find_zone(&self, name: &str) -> Result<(String, String)> { + let zones: Vec = self.signed_get("/domain/zone").await?; + + let labels: Vec<&str> = name.split('.').collect(); + + for i in 1..labels.len() { + let candidate = labels[i..].join("."); + let relative = labels[..i].join("."); + + if zones.contains(&candidate) { + return Ok((candidate, relative)); + } + } + + bail!("No OVH DNS zone found for: {name}") + } +} + +#[async_trait] +impl DnsProvider for OvhDns { + async fn create_txt_record(&self, name: &str, value: &str) -> Result { + let (zone, sub_domain) = self.find_zone(name).await?; + + let record: OvhRecord = self + .signed_post( + &format!("/domain/zone/{zone}/record"), + &CreateRecordRequest { + field_type: "TXT", + sub_domain: &sub_domain, + target: &format!("\"{value}\""), + ttl: 60, + }, + ) + .await?; + + let _: serde_json::Value = self + .signed_post( + &format!("/domain/zone/{zone}/refresh"), + &serde_json::json!({}), + ) + .await + .unwrap_or(serde_json::Value::Null); + + Ok(format!("{zone}|{}", record.id)) + } + + async fn delete_txt_record(&self, record_id: &str) -> Result<()> { + let (zone, id) = record_id + .split_once('|') + .context("Invalid OVH record_id format, expected '|'")?; + + self.signed_delete(&format!("/domain/zone/{zone}/record/{id}")) + .await?; + + let _: serde_json::Value = self + .signed_post( + &format!("/domain/zone/{zone}/refresh"), + &serde_json::json!({}), + ) + .await + .unwrap_or(serde_json::Value::Null); + + Ok(()) + } +} diff --git a/members/nullnet-server/src/cert/dns_providers/porkbun/config.rs b/members/nullnet-server/src/cert/dns_providers/porkbun/config.rs new file mode 100644 index 0000000..0960000 --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/porkbun/config.rs @@ -0,0 +1,4 @@ +pub struct PorkbunConfig { + pub api_key: String, + pub secret_api_key: String, +} diff --git a/members/nullnet-server/src/cert/dns_providers/porkbun/mod.rs b/members/nullnet-server/src/cert/dns_providers/porkbun/mod.rs new file mode 100644 index 0000000..637a66d --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/porkbun/mod.rs @@ -0,0 +1,132 @@ +use crate::cert::DnsProvider; +use anyhow::{Context, Result, bail}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +mod config; +pub use config::PorkbunConfig; + +const BASE_URL: &str = "https://porkbun.com/api/json/v3"; + +pub struct PorkbunDns { + api_key: String, + secret_api_key: String, + client: reqwest::Client, +} + +#[derive(Serialize)] +struct AuthBody<'a> { + apikey: &'a str, + secretapikey: &'a str, +} + +#[derive(Serialize)] +struct CreateRecordRequest<'a> { + apikey: &'a str, + secretapikey: &'a str, + name: &'a str, + r#type: &'a str, + content: &'a str, + ttl: &'a str, +} + +#[derive(Deserialize)] +struct CreateRecordResponse { + id: u64, +} + +#[derive(Deserialize)] +struct DomainsResponse { + domains: Vec, +} + +#[derive(Deserialize)] +struct PorkbunDomain { + domain: String, +} + +impl PorkbunDns { + pub fn new(config: PorkbunConfig) -> Self { + Self { + api_key: config.api_key, + secret_api_key: config.secret_api_key, + client: reqwest::Client::new(), + } + } + + async fn find_domain(&self, name: &str) -> Result<(String, String)> { + let response: DomainsResponse = self + .client + .post(format!("{BASE_URL}/domain/listAll")) + .json(&AuthBody { + apikey: &self.api_key, + secretapikey: &self.secret_api_key, + }) + .send() + .await + .context("Failed to list Porkbun domains")? + .json() + .await + .context("Failed to parse Porkbun domains response")?; + + let labels: Vec<&str> = name.split('.').collect(); + + for i in 1..labels.len() { + let candidate = labels[i..].join("."); + let relative = labels[..i].join("."); + + if response.domains.iter().any(|d| d.domain == candidate) { + return Ok((candidate, relative)); + } + } + + bail!("No Porkbun domain found for: {name}") + } +} + +#[async_trait] +impl DnsProvider for PorkbunDns { + async fn create_txt_record(&self, name: &str, value: &str) -> Result { + let (domain, relative_name) = self.find_domain(name).await?; + + let response: CreateRecordResponse = self + .client + .post(format!("{BASE_URL}/dns/createRecord/{domain}")) + .json(&CreateRecordRequest { + apikey: &self.api_key, + secretapikey: &self.secret_api_key, + name: &relative_name, + r#type: "TXT", + content: value, + ttl: "60", + }) + .send() + .await + .context("Failed to create TXT record in Porkbun")? + .json() + .await + .context("Failed to parse Porkbun create TXT record response")?; + + Ok(format!("{domain}|{}", response.id)) + } + + async fn delete_txt_record(&self, record_id: &str) -> Result<()> { + let (domain, id) = record_id + .split_once('|') + .context("Invalid Porkbun record_id format, expected '|'")?; + + self.client + .post(format!("{BASE_URL}/dns/deleteRecords/{domain}/{id}")) + .json(&AuthBody { + apikey: &self.api_key, + secretapikey: &self.secret_api_key, + }) + .send() + .await + .context("Failed to delete TXT record in Porkbun")? + .error_for_status() + .context("Porkbun DNS delete request failed")?; + + Ok(()) + } +} diff --git a/members/nullnet-server/src/cert/dns_providers/route53/config.rs b/members/nullnet-server/src/cert/dns_providers/route53/config.rs new file mode 100644 index 0000000..7a39ca6 --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/route53/config.rs @@ -0,0 +1,5 @@ +pub struct Route53Config { + pub access_key_id: String, + pub secret_access_key: String, + pub region: String, +} diff --git a/members/nullnet-server/src/cert/dns_providers/route53/mod.rs b/members/nullnet-server/src/cert/dns_providers/route53/mod.rs new file mode 100644 index 0000000..5aebf84 --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/route53/mod.rs @@ -0,0 +1,123 @@ +use crate::cert::DnsProvider; +use anyhow::{Context, Result, bail}; +use async_trait::async_trait; +use aws_credential_types::Credentials; +use aws_sdk_route53::{ + Client, + config::Region, + types::{Change, ChangeAction, ChangeBatch, ResourceRecord, ResourceRecordSet, RrType}, +}; + +mod config; +pub use config::Route53Config; + +pub struct Route53Dns { + client: Client, +} + +impl Route53Dns { + pub fn new(config: Route53Config) -> Self { + let credentials = Credentials::new( + config.access_key_id, + config.secret_access_key, + None, + None, + "routix", + ); + + let sdk_config = aws_sdk_route53::Config::builder() + .credentials_provider(credentials) + .region(Region::new(config.region)) + .build(); + + Self { + client: Client::from_conf(sdk_config), + } + } + + async fn resolve_zone_id(&self, domain: &str) -> Result { + let response = self + .client + .list_hosted_zones() + .send() + .await + .context("Failed to list Route 53 hosted zones")?; + + let labels: Vec<&str> = domain.split('.').collect(); + + for i in 0..labels.len() - 1 { + let candidate = format!("{}.", labels[i..].join(".")); + + for zone in response.hosted_zones() { + if zone.name() == candidate { + let id = zone.id().trim_start_matches("/hostedzone/").to_string(); + return Ok(id); + } + } + } + + bail!("No Route 53 hosted zone found for domain: {domain}") + } +} + +#[async_trait] +impl DnsProvider for Route53Dns { + async fn create_txt_record(&self, name: &str, value: &str) -> Result { + let domain = name.trim_start_matches("_acme-challenge."); + let zone_id = self.resolve_zone_id(domain).await?; + + let change = build_change(ChangeAction::Create, name, value)?; + let batch = ChangeBatch::builder().changes(change).build()?; + + self.client + .change_resource_record_sets() + .hosted_zone_id(&zone_id) + .change_batch(batch) + .send() + .await + .context("Failed to create TXT record in Route 53")?; + + Ok(format!("{zone_id}|{name}|{value}")) + } + + async fn delete_txt_record(&self, record_id: &str) -> Result<()> { + let parts: Vec<&str> = record_id.splitn(3, '|').collect(); + if parts.len() != 3 { + bail!("Invalid Route 53 record_id format, expected '||'"); + } + let (zone_id, name, value) = (parts[0], parts[1], parts[2]); + + let change = build_change(ChangeAction::Delete, name, value)?; + let batch = ChangeBatch::builder().changes(change).build()?; + + self.client + .change_resource_record_sets() + .hosted_zone_id(zone_id) + .change_batch(batch) + .send() + .await + .context("Failed to delete TXT record in Route 53")?; + + Ok(()) + } +} + +fn build_change(action: ChangeAction, name: &str, value: &str) -> Result { + let record = ResourceRecord::builder() + .value(format!("\"{value}\"")) + .build()?; + + let rrs = ResourceRecordSet::builder() + .name(name) + .r#type(RrType::Txt) + .ttl(60) + .resource_records(record) + .build()?; + + let change = Change::builder() + .action(action) + .resource_record_set(rrs) + .build()?; + + Ok(change) +} diff --git a/members/nullnet-server/src/cert/dns_providers/vultr/config.rs b/members/nullnet-server/src/cert/dns_providers/vultr/config.rs new file mode 100644 index 0000000..210f82e --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/vultr/config.rs @@ -0,0 +1,3 @@ +pub struct VultrConfig { + pub api_key: String, +} diff --git a/members/nullnet-server/src/cert/dns_providers/vultr/mod.rs b/members/nullnet-server/src/cert/dns_providers/vultr/mod.rs new file mode 100644 index 0000000..5642a8a --- /dev/null +++ b/members/nullnet-server/src/cert/dns_providers/vultr/mod.rs @@ -0,0 +1,121 @@ +use crate::cert::DnsProvider; +use anyhow::{Context, Result, bail}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +mod config; +pub use config::VultrConfig; + +const BASE_URL: &str = "https://api.vultr.com/v2"; + +pub struct VultrDns { + api_key: String, + client: reqwest::Client, +} + +#[derive(Deserialize)] +struct DomainsResponse { + domains: Vec, +} + +#[derive(Deserialize)] +struct VultrDomain { + domain: String, +} + +#[derive(Serialize)] +struct CreateRecordRequest<'a> { + r#type: &'a str, + name: &'a str, + data: &'a str, + ttl: u32, +} + +#[derive(Deserialize)] +struct CreateRecordResponse { + record: VultrRecord, +} + +#[derive(Deserialize)] +struct VultrRecord { + id: String, +} + +impl VultrDns { + pub fn new(config: VultrConfig) -> Self { + Self { + api_key: config.api_key, + client: reqwest::Client::new(), + } + } + + async fn find_domain(&self, name: &str) -> Result<(String, String)> { + let response: DomainsResponse = self + .client + .get(format!("{BASE_URL}/domains")) + .bearer_auth(&self.api_key) + .query(&[("per_page", "100")]) + .send() + .await + .context("Failed to list Vultr domains")? + .json() + .await + .context("Failed to parse Vultr domains response")?; + + let labels: Vec<&str> = name.split('.').collect(); + + for i in 1..labels.len() { + let candidate = labels[i..].join("."); + let relative = labels[..i].join("."); + + if response.domains.iter().any(|d| d.domain == candidate) { + return Ok((candidate, relative)); + } + } + + bail!("No Vultr domain found for: {name}") + } +} + +#[async_trait] +impl DnsProvider for VultrDns { + async fn create_txt_record(&self, name: &str, value: &str) -> Result { + let (domain, relative_name) = self.find_domain(name).await?; + + let response: CreateRecordResponse = self + .client + .post(format!("{BASE_URL}/domains/{domain}/records")) + .bearer_auth(&self.api_key) + .json(&CreateRecordRequest { + r#type: "TXT", + name: &relative_name, + data: value, + ttl: 60, + }) + .send() + .await + .context("Failed to create TXT record in Vultr")? + .json() + .await + .context("Failed to parse Vultr create TXT record response")?; + + Ok(format!("{domain}|{}", response.record.id)) + } + + async fn delete_txt_record(&self, record_id: &str) -> Result<()> { + let (domain, id) = record_id + .split_once('|') + .context("Invalid Vultr record_id format, expected '|'")?; + + self.client + .delete(format!("{BASE_URL}/domains/{domain}/records/{id}")) + .bearer_auth(&self.api_key) + .send() + .await + .context("Failed to delete TXT record in Vultr")? + .error_for_status() + .context("Vultr DNS delete request failed")?; + + Ok(()) + } +} diff --git a/members/nullnet-server/src/cert/mod.rs b/members/nullnet-server/src/cert/mod.rs new file mode 100644 index 0000000..3b8c424 --- /dev/null +++ b/members/nullnet-server/src/cert/mod.rs @@ -0,0 +1,247 @@ +//! ACME (Let's Encrypt) certificate issuance via DNS-01, ported from ../routix. +//! Credentials are supplied inline per request and never persisted. +mod dns_providers; +use async_trait::async_trait; +pub use dns_providers::*; +use serde::Deserialize; + +mod authority; + +pub use authority::*; + +/// Credentials for a DNS provider, supplied directly in the certificate request payload. +/// The `"provider"` field acts as the discriminant. +/// +/// Example JSON: +/// ```json +/// { "provider": "cloudflare", "api_token": "..." } +/// { "provider": "azure", "tenant_id": "...", "client_id": "...", "client_secret": "...", "subscription_id": "...", "resource_group": "..." } +/// ``` +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "provider", rename_all = "snake_case")] +pub enum DnsProviderCredentials { + Azure { + tenant_id: String, + client_id: String, + client_secret: String, + subscription_id: String, + resource_group: String, + }, + Cloudflare { + api_token: String, + }, + #[serde(rename = "digitalocean")] + DigitalOcean { + api_token: String, + }, + #[serde(rename = "godaddy")] + GoDaddy { + api_key: String, + api_secret: String, + }, + Google { + project_id: String, + client_email: String, + private_key: String, + }, + Hetzner { + api_token: String, + }, + Namecheap { + api_user: String, + api_key: String, + client_ip: String, + }, + Ovh { + app_key: String, + app_secret: String, + consumer_key: String, + /// Defaults to `https://eu.api.ovh.com/1.0` when omitted. + endpoint: Option, + }, + Porkbun { + api_key: String, + secret_api_key: String, + }, + #[serde(rename = "route53")] + Route53 { + access_key_id: String, + secret_access_key: String, + /// Defaults to `us-east-1` when omitted. + region: Option, + }, + Vultr { + api_key: String, + }, +} + +impl DnsProviderCredentials { + /// Returns the stable lowercase provider name used for display. + pub fn provider_name(&self) -> &'static str { + match self { + DnsProviderCredentials::Azure { .. } => "azure", + DnsProviderCredentials::Cloudflare { .. } => "cloudflare", + DnsProviderCredentials::DigitalOcean { .. } => "digitalocean", + DnsProviderCredentials::GoDaddy { .. } => "godaddy", + DnsProviderCredentials::Google { .. } => "google", + DnsProviderCredentials::Hetzner { .. } => "hetzner", + DnsProviderCredentials::Namecheap { .. } => "namecheap", + DnsProviderCredentials::Ovh { .. } => "ovh", + DnsProviderCredentials::Porkbun { .. } => "porkbun", + DnsProviderCredentials::Route53 { .. } => "route53", + DnsProviderCredentials::Vultr { .. } => "vultr", + } + } +} + +#[async_trait] +pub trait DnsProvider: Send + Sync { + async fn create_txt_record( + &self, + name: &str, // e.g. "_acme-challenge.example.com" + value: &str, // the ACME key authorization digest + ) -> anyhow::Result; + + async fn delete_txt_record(&self, record_id: &str) -> anyhow::Result<()>; +} + +pub fn create_dns_provider( + credentials: DnsProviderCredentials, +) -> anyhow::Result> { + let provider: Box = match credentials { + DnsProviderCredentials::Azure { + tenant_id, + client_id, + client_secret, + subscription_id, + resource_group, + } => Box::new(AzureDns::new(AzureConfig { + tenant_id, + client_id, + client_secret, + subscription_id, + resource_group, + })), + DnsProviderCredentials::Cloudflare { api_token } => { + Box::new(CloudflareDns::new(CloudflareDnsConfig { api_token })) + } + DnsProviderCredentials::DigitalOcean { api_token } => { + Box::new(DigitalOceanDns::new(DigitalOceanConfig { api_token })) + } + DnsProviderCredentials::GoDaddy { + api_key, + api_secret, + } => Box::new(GoDaddyDns::new(GoDaddyConfig { + api_key, + api_secret, + })), + DnsProviderCredentials::Google { + project_id, + client_email, + private_key, + } => Box::new(GoogleDns::new(GoogleDnsConfig { + project_id, + client_email, + private_key, + })), + DnsProviderCredentials::Hetzner { api_token } => { + Box::new(HetznerDns::new(HetznerDnsConfig { api_token })) + } + DnsProviderCredentials::Namecheap { + api_user, + api_key, + client_ip, + } => Box::new(NamecheapDns::new(NamecheapConfig { + api_user, + api_key, + client_ip, + })), + DnsProviderCredentials::Ovh { + app_key, + app_secret, + consumer_key, + endpoint, + } => Box::new(OvhDns::new(OvhConfig { + app_key, + app_secret, + consumer_key, + endpoint: endpoint.unwrap_or_else(|| "https://eu.api.ovh.com/1.0".to_string()), + })), + DnsProviderCredentials::Porkbun { + api_key, + secret_api_key, + } => Box::new(PorkbunDns::new(PorkbunConfig { + api_key, + secret_api_key, + })), + DnsProviderCredentials::Route53 { + access_key_id, + secret_access_key, + region, + } => Box::new(Route53Dns::new(Route53Config { + access_key_id, + secret_access_key, + region: region.unwrap_or_else(|| "us-east-1".to_string()), + })), + DnsProviderCredentials::Vultr { api_key } => { + Box::new(VultrDns::new(VultrConfig { api_key })) + } + }; + + Ok(provider) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(json: &str) -> DnsProviderCredentials { + serde_json::from_str(json).expect("valid credentials") + } + + #[test] + fn cloudflare_round_trip() { + let c = parse(r#"{"provider":"cloudflare","api_token":"tok"}"#); + assert_eq!(c.provider_name(), "cloudflare"); + assert!( + matches!(&c, DnsProviderCredentials::Cloudflare { api_token } if api_token == "tok") + ); + assert!(create_dns_provider(c).is_ok()); + } + + #[test] + fn renamed_provider_tags_match() { + // variants with explicit #[serde(rename)] must use the lowercase id + assert_eq!( + parse(r#"{"provider":"route53","access_key_id":"a","secret_access_key":"s"}"#) + .provider_name(), + "route53" + ); + assert_eq!( + parse(r#"{"provider":"digitalocean","api_token":"t"}"#).provider_name(), + "digitalocean" + ); + assert_eq!( + parse(r#"{"provider":"godaddy","api_key":"k","api_secret":"s"}"#).provider_name(), + "godaddy" + ); + } + + #[test] + fn optional_fields_may_be_omitted() { + // route53 region and ovh endpoint are optional + assert!(matches!( + parse(r#"{"provider":"route53","access_key_id":"a","secret_access_key":"s"}"#), + DnsProviderCredentials::Route53 { region: None, .. } + )); + assert!(matches!( + parse(r#"{"provider":"ovh","app_key":"a","app_secret":"s","consumer_key":"c"}"#), + DnsProviderCredentials::Ovh { endpoint: None, .. } + )); + } + + #[test] + fn unknown_provider_is_rejected() { + assert!(serde_json::from_str::(r#"{"provider":"nope"}"#).is_err()); + } +} diff --git a/members/nullnet-server/src/certs.rs b/members/nullnet-server/src/certs.rs new file mode 100644 index 0000000..4c869bb --- /dev/null +++ b/members/nullnet-server/src/certs.rs @@ -0,0 +1,125 @@ +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::{Path, PathBuf}; +use std::time::Duration; +use tokio::sync::mpsc as tokio_mpsc; +use tokio::sync::watch; +use tokio::time::Instant; + +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` + 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; + + 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; + }; + 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}'"); + certificates.push(TlsCertificate { + domain, + fullchain_pem, + key_pem, + }); + } + + 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> { + 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/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/events.rs b/members/nullnet-server/src/events.rs index 5ca7a8b..c8ea547 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 { @@ -249,6 +254,20 @@ pub(crate) enum Event { latency_ms: u64, timestamp: u64, }, + + // --- Certificate events --- + CertificateInstalled { + domain: String, + timestamp: u64, + }, + CertificateRenewed { + domain: String, + timestamp: u64, + }, + CertificateRemoved { + domain: String, + timestamp: u64, + }, } impl Event { @@ -294,7 +313,11 @@ 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", + Self::CertificateInstalled { .. } => "certificate_installed", + Self::CertificateRenewed { .. } => "certificate_renewed", + Self::CertificateRemoved { .. } => "certificate_removed", } } @@ -312,7 +335,9 @@ impl Event { | Self::VlanSetupCompleted { .. } | Self::ControlChannelEstablished { .. } | Self::ServicesListUpdated { .. } - | Self::ProxyRequestRouted { .. } => Severity::Info, + | Self::ProxyRequestRouted { .. } + | Self::CertificateInstalled { .. } + | Self::CertificateRenewed { .. } => Severity::Info, Self::NodeDisconnected { .. } | Self::ServiceUnregistered { .. } @@ -322,7 +347,8 @@ impl Event { | Self::ProxyClientTimedOut { .. } | Self::MaxNetworksLimitEnforced { .. } | Self::BackendTriggerSetupBailed { .. } - | Self::ControlChannelClosed { .. } => Severity::Warning, + | Self::ControlChannelClosed { .. } + | Self::CertificateRemoved { .. } => Severity::Warning, Self::SetupTimeout { .. } | Self::NetIdPoolExhausted { .. } @@ -342,7 +368,8 @@ impl Event { | Self::ProxyRequestMissingHost { .. } | Self::ProxyRequestInvalidHost { .. } | Self::UpstreamIpParseFailed { .. } - | Self::ProxyClientNotInet { .. } => Severity::Error, + | Self::ProxyClientNotInet { .. } + | Self::TlsCertificateInvalid { .. } => Severity::Error, } } @@ -697,6 +724,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, @@ -711,6 +746,27 @@ impl Event { timestamp: now_secs(), } } + + pub(crate) fn certificate_installed(domain: String) -> Self { + Self::CertificateInstalled { + domain, + timestamp: now_secs(), + } + } + + pub(crate) fn certificate_renewed(domain: String) -> Self { + Self::CertificateRenewed { + domain, + timestamp: now_secs(), + } + } + + pub(crate) fn certificate_removed(domain: String) -> Self { + Self::CertificateRemoved { + domain, + timestamp: now_secs(), + } + } } /// Shared event store: ring buffer + broadcast channel for SSE subscribers. 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..33f5b53 --- /dev/null +++ b/members/nullnet-server/src/http_server/certificates.rs @@ -0,0 +1,272 @@ +use super::AppState; +use crate::cert::{self, CertificateAuthority, DnsProviderCredentials}; +use crate::certs::{CERTS_DIR, KEY_ENCRYPTED, KEY_PLAINTEXT}; +use crate::crypto; +use crate::events::Event; +use axum::extract::{Path as AxumPath, State}; +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( + State(state): State, + 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"); + } + persist_cert(&state, &domain, &req.fullchain_pem, &req.key_pem).await +} + +/// Encrypt the key, write `fullchain.pem` + `privkey.enc` into `./certs//`, +/// drop any stale plaintext key, and emit an install/renew event. Shared by the +/// manual-upload and ACME-request handlers. The certs watcher propagates the write. +async fn persist_cert( + state: &AppState, + domain: &str, + fullchain_pem: &str, + key_pem: &str, +) -> axum::response::Response { + let Ok(encoded) = crypto::cipher().encrypt(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); + let renewal = tokio::fs::try_exists(&dir).await.unwrap_or(false); + if tokio::fs::create_dir_all(&dir).await.is_err() + || tokio::fs::write(dir.join("fullchain.pem"), 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; + let event = if renewal { + Event::certificate_renewed(domain.to_string()) + } else { + Event::certificate_installed(domain.to_string()) + }; + state.events.emit(event).await; + StatusCode::NO_CONTENT.into_response() +} + +/// Default seconds to wait for a TXT record to propagate before asking the CA to +/// validate the DNS-01 challenge. +const DEFAULT_DNS_PROPAGATION_SECS: u64 = 30; + +#[derive(Deserialize)] +pub(super) struct RequestReq { + domain: String, + credentials: DnsProviderCredentials, + dns_propagation_secs: Option, +} + +/// Issue a cert from Let's Encrypt via a DNS-01 challenge. The DNS-provider +/// credentials are used for this request only and never persisted. On success +/// the cert is stored exactly like a manual upload (encrypted key at rest) and +/// the watcher pushes it to the proxies. +pub(super) async fn request_handler( + State(state): State, + axum::Json(req): axum::Json, +) -> impl IntoResponse { + let Some(domain) = sanitize_domain(&req.domain) else { + return bad_request("invalid domain"); + }; + let provider_name = req.credentials.provider_name(); + println!("ACME request for '{domain}' via DNS provider '{provider_name}'"); + let provider = match cert::create_dns_provider(req.credentials) { + Ok(p) => p, + Err(e) => return bad_request(&format!("invalid DNS provider credentials: {e}")), + }; + + // staging CA in debug builds, production in release (matches ../routix) + let ca = if cfg!(debug_assertions) { + CertificateAuthority::staging() + } else { + CertificateAuthority::production() + }; + let propagation = req + .dns_propagation_secs + .unwrap_or(DEFAULT_DNS_PROPAGATION_SECS); + + match ca + .request_certificate(&domain, provider.as_ref(), propagation) + .await + { + Ok((fullchain_pem, key_pem)) => { + persist_cert(&state, &domain, &fullchain_pem, &key_pem).await + } + Err(e) => ( + StatusCode::BAD_GATEWAY, + axum::Json(ErrorJson { + error: format!("ACME issuance failed: {e:#}"), + }), + ) + .into_response(), + } +} + +/// Remove a cert. The change is pushed to the proxies and hot-reloaded like any +/// other; removing the last cert clears the set everywhere. +pub(super) async fn delete_handler( + State(state): State, + 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(()) => { + state.events.emit(Event::certificate_removed(domain)).await; + 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..c1dadab 100644 --- a/members/nullnet-server/src/http_server/mod.rs +++ b/members/nullnet-server/src/http_server/mod.rs @@ -2,11 +2,12 @@ use crate::events::EventStore; use crate::orchestrator::Orchestrator; use crate::services::input::StackMap; use axum::Router; -use axum::routing::{delete, get}; +use axum::routing::{delete, get, post}; 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,18 @@ 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/request", + post(certificates::request_handler), + ) + .route( + "/api/certificates/{domain}", + delete(certificates::delete_handler), + ) .route("/api/events", get(events::events_handler)) .route( "/api/events/stream", @@ -45,9 +58,20 @@ pub async fn serve(state: AppState) { .fallback(get(static_files::static_handler)) .with_state(state); + // Self-signed cert, regenerated each start. The admin UI is single-origin, so + // relative /api calls inherit HTTPS; browsers prompt to trust the cert once. + let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]) + .expect("failed to generate self-signed certificate"); + let config = axum_server::tls_rustls::RustlsConfig::from_pem( + cert.cert.pem().into_bytes(), + cert.signing_key.serialize_pem().into_bytes(), + ) + .await + .expect("failed to build TLS config"); + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), HTTP_PORT); - let listener = tokio::net::TcpListener::bind(addr) + axum_server::bind_rustls(addr, config) + .serve(app.into_make_service()) .await - .expect("failed to bind HTTP listener"); - axum::serve(listener, app).await.expect("HTTP server error"); + .expect("HTTPS server error"); } diff --git a/members/nullnet-server/src/main.rs b/members/nullnet-server/src/main.rs index ab17b7d..08e4c1d 100644 --- a/members/nullnet-server/src/main.rs +++ b/members/nullnet-server/src/main.rs @@ -1,3 +1,6 @@ +mod cert; +mod certs; +mod crypto; mod env; mod events; mod graphviz; @@ -33,6 +36,13 @@ 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 + // plane on a trusted network. Proxy side: NullnetGrpcInterface::new(.., 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..fc68a02 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 {})); @@ -1056,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, 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..c8a4f8f --- /dev/null +++ b/members/nullnet-server/ui/src/pages/Certificates.tsx @@ -0,0 +1,341 @@ +import { useState } from 'react'; +import Layout from '../components/Layout'; +import { useApi } from '../hooks/useApi'; +import type { CertJson } from '../types'; + +type CredField = { key: string; label: string; optional?: boolean; textarea?: boolean }; +type Provider = { id: string; name: string; fields: CredField[] }; + +// Provider ids/field keys mirror the server's DnsProviderCredentials enum. +const PROVIDERS: Provider[] = [ + { id: 'cloudflare', name: 'Cloudflare', fields: [{ key: 'api_token', label: 'API token' }] }, + { id: 'route53', name: 'AWS Route 53', fields: [ + { key: 'access_key_id', label: 'Access key ID' }, + { key: 'secret_access_key', label: 'Secret access key' }, + { key: 'region', label: 'Region (default us-east-1)', optional: true }, + ] }, + { id: 'azure', name: 'Azure DNS', fields: [ + { key: 'tenant_id', label: 'Tenant ID' }, + { key: 'client_id', label: 'Client ID' }, + { key: 'client_secret', label: 'Client secret' }, + { key: 'subscription_id', label: 'Subscription ID' }, + { key: 'resource_group', label: 'Resource group' }, + ] }, + { id: 'google', name: 'Google Cloud DNS', fields: [ + { key: 'project_id', label: 'Project ID' }, + { key: 'client_email', label: 'Service account email' }, + { key: 'private_key', label: 'Service account private key (PEM)', textarea: true }, + ] }, + { id: 'digitalocean', name: 'DigitalOcean', fields: [{ key: 'api_token', label: 'API token' }] }, + { id: 'hetzner', name: 'Hetzner DNS', fields: [{ key: 'api_token', label: 'API token' }] }, + { id: 'namecheap', name: 'Namecheap', fields: [ + { key: 'api_user', label: 'API user' }, + { key: 'api_key', label: 'API key' }, + { key: 'client_ip', label: 'Whitelisted client IP' }, + ] }, + { id: 'ovh', name: 'OVH', fields: [ + { key: 'app_key', label: 'Application key' }, + { key: 'app_secret', label: 'Application secret' }, + { key: 'consumer_key', label: 'Consumer key' }, + { key: 'endpoint', label: 'Endpoint (default eu.api.ovh.com)', optional: true }, + ] }, + { id: 'porkbun', name: 'Porkbun', fields: [ + { key: 'api_key', label: 'API key' }, + { key: 'secret_api_key', label: 'Secret API key' }, + ] }, + { id: 'godaddy', name: 'GoDaddy', fields: [ + { key: 'api_key', label: 'API key' }, + { key: 'api_secret', label: 'API secret' }, + ] }, + { id: 'vultr', name: 'Vultr', fields: [{ key: 'api_key', label: 'API key' }] }, +]; + +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); + + // Let's Encrypt (ACME / DNS-01) request form + const [leDomain, setLeDomain] = useState(''); + const [providerId, setProviderId] = useState(PROVIDERS[0].id); + const [creds, setCreds] = useState>({}); + const [propagation, setPropagation] = useState(''); + const [leSubmitting, setLeSubmitting] = useState(false); + const [leError, setLeError] = useState(null); + + const provider = PROVIDERS.find(p => p.id === providerId) ?? PROVIDERS[0]; + + 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 requestLe(e: React.FormEvent) { + e.preventDefault(); + setLeError(null); + setLeSubmitting(true); + try { + const credentials: Record = { provider: providerId }; + for (const f of provider.fields) { + const v = (creds[f.key] ?? '').trim(); + if (v) credentials[f.key] = v; + } + const body: Record = { domain: leDomain.trim(), credentials }; + const secs = parseInt(propagation, 10); + if (!Number.isNaN(secs)) body.dns_propagation_secs = secs; + + const res = await fetch('/api/certificates/request', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` })); + throw new Error(err.error ?? `HTTP ${res.status}`); + } + setLeDomain(''); setCreds({}); setPropagation(''); + refetch(); + } catch (err) { + setLeError(String(err instanceof Error ? err.message : err)); + } finally { + setLeSubmitting(false); + } + } + + async function remove(d: string) { + if (!confirm(`Delete certificate for ${d}?`)) 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; + + const leRenew = existing.has(leDomain.trim()); + const credsComplete = provider.fields.every(f => f.optional || (creds[f.key] ?? '').trim() !== ''); + const canRequest = leDomain.trim() !== '' && credsComplete && !leSubmitting; + + 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()}»} +
+
+ + +