diff --git a/Cargo.lock b/Cargo.lock index 33321d6..23e083b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -383,6 +383,61 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "astral-tokio-tar" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb50a7aae84a03bf55b067832bc376f4961b790c97e64d3eacee97d389b90277" +dependencies = [ + "filetime", + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "tokio", + "tokio-stream", + "xattr", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -970,6 +1025,49 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "backon" version = "1.6.0" @@ -997,6 +1095,12 @@ dependencies = [ "vsimd", ] +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bb8" version = "0.9.1" @@ -1038,6 +1142,15 @@ dependencies = [ "serde_json", ] +[[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 = "2.13.0" @@ -1062,6 +1175,80 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bollard" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" +dependencies = [ + "async-stream", + "base64", + "bitflags", + "bollard-buildkit-proto", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "home", + "http 1.4.2", + "http-body-util", + "hyper 1.10.1", + "hyper-named-pipe", + "hyper-rustls 0.27.9", + "hyper-util", + "hyperlocal", + "log", + "num", + "pin-project-lite", + "rand 0.9.4", + "rustls 0.23.40", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-buildkit-proto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" +dependencies = [ + "prost", + "prost-types", + "tonic", + "tonic-prost", + "ureq", +] + +[[package]] +name = "bollard-stubs" +version = "1.52.1-rc.29.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" +dependencies = [ + "base64", + "bollard-buildkit-proto", + "bytes", + "prost", + "serde", + "serde_json", + "serde_repr", + "time", +] + [[package]] name = "brotli" version = "8.0.3" @@ -1273,6 +1460,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -1495,6 +1688,43 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive", + "flagset", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1668,7 +1898,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -1684,6 +1914,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "docker_credential" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" +dependencies = [ + "base64", + "serde", + "serde_json", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -1779,6 +2020,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -1812,12 +2063,39 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ferroid" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4" +dependencies = [ + "portable-atomic", + "rand 0.10.1", + "web-time", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.9" @@ -2109,6 +2387,15 @@ version = "1.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "hostname" version = "0.4.2" @@ -2240,6 +2527,7 @@ dependencies = [ "http 1.4.2", "http-body 1.0.1", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -2247,6 +2535,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-named-pipe" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper 1.10.1", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", +] + [[package]] name = "hyper-rustls" version = "0.24.2" @@ -2315,6 +2618,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper 1.10.1", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -2811,6 +3129,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -2870,6 +3194,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "md-5" version = "0.11.0" @@ -2913,6 +3243,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2935,6 +3271,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2944,6 +3290,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2954,6 +3314,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2969,6 +3338,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2996,6 +3387,15 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3078,6 +3478,31 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parse-display" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" +dependencies = [ + "parse-display-derive", + "regex", + "regex-syntax", +] + +[[package]] +name = "parse-display-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn 2.0.117", +] + [[package]] name = "pem" version = "3.0.6" @@ -3322,14 +3747,21 @@ dependencies = [ "maplit", "platz-chart-ext", "prometheus", + "rcgen", "rust_decimal", + "rustls 0.23.40", + "rustls-native-certs", + "rustls-pemfile", "serde", "serde_json", "serde_with", "strum", + "tempfile", + "testcontainers", "thiserror 2.0.18", "tokio", "tokio-postgres", + "tokio-postgres-rustls", "tracing", "url", "utoipa", @@ -3538,6 +3970,38 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + [[package]] name = "protobuf" version = "3.7.2" @@ -3690,6 +4154,20 @@ 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 = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3833,6 +4311,28 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.21.12" @@ -3854,6 +4354,7 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki 0.103.13", "subtle", @@ -3872,6 +4373,15 @@ dependencies = [ "security-framework", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -4115,6 +4625,17 @@ dependencies = [ "unsafe-libyaml-norway", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -4331,6 +4852,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4354,6 +4885,29 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn 2.0.117", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "strum" version = "0.28.0" @@ -4429,6 +4983,50 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "testcontainers" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfd5785b5483672915ed5fe3cddf9f546802779fc1eceff0a6fb7321fac81c1e" +dependencies = [ + "astral-tokio-tar", + "async-trait", + "bollard", + "bytes", + "docker_credential", + "either", + "etcetera", + "ferroid", + "futures", + "http 1.4.2", + "itertools", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4543,6 +5141,27 @@ dependencies = [ "regex", ] +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio" version = "1.52.3" @@ -4597,6 +5216,20 @@ dependencies = [ "whoami", ] +[[package]] +name = "tokio-postgres-rustls" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c2ad44aa0ae96db89c4742212ed41645b2f597311ff6e1945542a4d9fadc2fb" +dependencies = [ + "rustls 0.23.40", + "sha2 0.11.0", + "tokio", + "tokio-postgres", + "tokio-rustls 0.26.4", + "x509-cert", +] + [[package]] name = "tokio-rustls" version = "0.24.1" @@ -4694,6 +5327,46 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2 0.4.14", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "hyper 1.10.1", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2 0.6.4", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + [[package]] name = "tower" version = "0.5.3" @@ -4702,7 +5375,9 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.14.0", "pin-project-lite", + "slab", "sync_wrapper", "tokio", "tokio-util", @@ -4913,6 +5588,33 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "log", + "percent-encoding", + "rustls 0.23.40", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http 1.4.2", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4938,6 +5640,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5230,6 +5938,22 @@ dependencies = [ "web-sys", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -5239,6 +5963,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -5566,12 +6296,62 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", + "tls_codec", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "xmlparser" version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/README.md b/README.md index 4f9a347..98dbb19 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,17 @@ for you and that production deployments set via the platzio helm chart: for new chart artifacts. * `platz-api` reads `OIDC_*` environment variables for OIDC config and `ADMIN_EMAILS` as a space-delimited allow-list. +* All workers connect to PostgreSQL using the `PG*` variables (`PGHOST`, + `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`). TLS for those connections — + including the `LISTEN`/`NOTIFY` event stream — is controlled by `PGSSLMODE` + (mirroring libpq), defaulting to `prefer`: + * `disable` — plaintext, no TLS. + * `prefer` *(default)* — use TLS if the server offers it, otherwise + plaintext; the server certificate is not verified. + * `require` — always use TLS; the certificate is not verified. + * `verify-full` — always use TLS and verify the certificate chain and + hostname against the system trust store, or against the CA bundle pointed + to by `PGSSLROOTCERT`. ## Crates Overview diff --git a/db/Cargo.toml b/db/Cargo.toml index 6f81e2f..11e1e54 100644 --- a/db/Cargo.toml +++ b/db/Cargo.toml @@ -40,6 +40,9 @@ prometheus = { workspace = true } rust_decimal = { version = "1.42.0", default-features = false, features = [ "tokio-postgres", ] } +rustls = "0.23.40" +rustls-native-certs = "0.8.4" +rustls-pemfile = "2" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" serde_with = "3.20.0" @@ -47,6 +50,7 @@ strum = { version = "0.28.0", features = ["derive"] } thiserror = "2.0.18" tokio = "1.52.3" tokio-postgres = "0.7.17" +tokio-postgres-rustls = "0.14.0" tracing = "0.1.44" url = "2.5.8" utoipa = { version = "5.5.0", features = [ @@ -57,3 +61,10 @@ utoipa = { version = "5.5.0", features = [ "uuid", ] } uuid = { version = "1.23.1", features = ["serde", "v4"] } + +[dev-dependencies] +rcgen = "0.14.8" +tempfile = "3" +testcontainers = "0.27.3" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tokio-postgres = "0.7" diff --git a/db/src/config.rs b/db/src/config.rs index 8184268..bffd354 100644 --- a/db/src/config.rs +++ b/db/src/config.rs @@ -8,6 +8,9 @@ const DB_POOL_CONNECTION_TIMEOUT_SECS: &str = "DB_POOL_CONNECTION_TIMEOUT_SECS"; const DB_POOL_IDLE_TIMEOUT_SECS: &str = "DB_POOL_IDLE_TIMEOUT_SECS"; const DB_POOL_MAX_LIFETIME_SECS: &str = "DB_POOL_MAX_LIFETIME_SECS"; +const PGSSLMODE: &str = "PGSSLMODE"; +const PGSSLROOTCERT: &str = "PGSSLROOTCERT"; + #[derive(Debug, Clone)] pub struct DbPoolOptions { pub max_size: u32, @@ -43,6 +46,62 @@ pub fn database_url() -> String { format!("postgres://{pg_user}:{pg_password}@{pg_host}:{pg_port}/{pg_database}") } +/// How the backend negotiates TLS when connecting to PostgreSQL. +/// +/// Mirrors the relevant subset of libpq's `sslmode` values. Because Platz +/// assembles the connection itself (it does not link libpq), this is the +/// authoritative knob — `PGSSLMODE` and `PGSSLROOTCERT` are read here and +/// applied to both the connection pool and the `LISTEN`/`NOTIFY` connection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumString)] +#[strum(ascii_case_insensitive)] +pub enum SslMode { + /// Never use TLS. The connection is plaintext (legacy behavior). + #[strum(serialize = "disable")] + Disable, + /// Try TLS first, fall back to plaintext if the server doesn't offer it. + /// The server certificate is not verified. + #[strum(serialize = "prefer")] + Prefer, + /// Require TLS, but do not verify the server certificate. + #[strum(serialize = "require")] + Require, + /// Require TLS and verify the server certificate chain against the + /// trusted CAs, including that the hostname matches the certificate. + #[strum(serialize = "verify-full", serialize = "verify_full")] + VerifyFull, +} + +/// Resolved TLS settings for connecting to PostgreSQL, derived from the +/// `PGSSLMODE` and `PGSSLROOTCERT` environment variables. +#[derive(Debug, Clone)] +pub struct SslSettings { + pub mode: SslMode, + /// Path to a PEM-encoded CA bundle used to verify the server certificate + /// in `verify-full` mode. When `None`, the system trust store is used. + pub root_cert: Option, +} + +impl SslSettings { + /// Reads the TLS settings from the environment. + /// + /// `PGSSLMODE` defaults to `prefer` (opportunistic TLS) so that existing + /// plaintext databases keep working while TLS-capable databases are used + /// encrypted without any extra configuration. + pub fn from_env() -> Result { + let mode = match env::var(PGSSLMODE) { + Ok(value) => value.parse().map_err(|_| { + format!( + "Invalid {PGSSLMODE} value {value:?}. \ + Expected one of: disable, prefer, require, verify-full" + ) + })?, + Err(_) => SslMode::Prefer, + }; + let root_cert = env::var(PGSSLROOTCERT).ok().filter(|s| !s.is_empty()); + Ok(Self { mode, root_cert }) + } +} + pub fn db_pool_options() -> DbPoolOptions { let defaults = DbPoolOptions::default(); DbPoolOptions { diff --git a/db/src/errors.rs b/db/src/errors.rs index 0802e67..9cb1b80 100644 --- a/db/src/errors.rs +++ b/db/src/errors.rs @@ -12,6 +12,12 @@ pub enum DbError { #[error("Database pool startup error: {0}")] Bb8PoolError(#[from] diesel_async::pooled_connection::PoolError), + #[error("Database TLS error: {0}")] + TlsError(#[from] crate::tls::TlsError), + + #[error("Invalid database TLS configuration: {0}")] + SslConfigError(String), + #[error("Database was not initialized")] DbNotInitialized, diff --git a/db/src/events.rs b/db/src/events.rs index bc23997..9ed86cd 100644 --- a/db/src/events.rs +++ b/db/src/events.rs @@ -1,8 +1,15 @@ use crate::DbTable; +use crate::config::SslSettings; use serde::{Deserialize, Serialize}; use std::{future::poll_fn, task::ready}; -use tokio::{spawn, sync::broadcast, time}; -use tokio_postgres::AsyncMessage; +use tokio::{ + io::{AsyncRead, AsyncWrite}, + spawn, + sync::broadcast, + task::JoinHandle, + time, +}; +use tokio_postgres::{AsyncMessage, Connection, NoTls}; use tracing::{debug, error, trace}; use utoipa::ToSchema; use uuid::Uuid; @@ -72,6 +79,10 @@ pub enum DbEventsError { PollError(tokio_postgres::Error), #[error("Error running LISTEN query: {0}")] ListenQueryFailed(tokio_postgres::Error), + #[error("Invalid database TLS configuration: {0}")] + SslConfigError(String), + #[error("Database TLS error: {0}")] + TlsError(crate::tls::TlsError), } impl DbEventsError { @@ -82,6 +93,9 @@ impl DbEventsError { Self::ConnectError(_) => true, Self::PollError(_) => true, Self::ListenQueryFailed(_) => false, + // Misconfiguration won't fix itself on retry. + Self::SslConfigError(_) => false, + Self::TlsError(_) => false, } } } @@ -109,34 +123,31 @@ impl DbEventBroadcast { async fn listen_for_notifications(&self, channel_name: &str) -> Result<(), DbEventsError> { let events_tx = self.tx.clone(); - let (client, mut connection) = - tokio_postgres::connect(&crate::config::database_url(), tokio_postgres::NoTls) - .await - .map_err(DbEventsError::ConnectError)?; - - let events_task = spawn(poll_fn(move |cx| { - loop { - while let Some(message) = ready!( - connection - .poll_message(cx) - .map_err(DbEventsError::PollError)? - ) { - match message { - AsyncMessage::Notice(notice) => { - trace!("Database notice: {notice:?}"); - } - AsyncMessage::Notification(notification) => { - let event: DbEvent = serde_json::from_str(notification.payload()) - .map_err(DbEventsError::EventParseError)?; - events_tx.send(event).ok(); - } - other => { - trace!("Got unknown message from Postgres: {other:?}"); - } - } + let url = crate::config::database_url(); + let ssl = SslSettings::from_env().map_err(DbEventsError::SslConfigError)?; + + // Establish the dedicated LISTEN/NOTIFY connection using the same TLS + // settings as the connection pool. With TLS disabled we keep the + // original plaintext (`NoTls`) path. + let (client, events_task) = + match crate::tls::build_connector(&ssl).map_err(DbEventsError::TlsError)? { + None => { + let (client, connection) = tokio_postgres::connect(&url, NoTls) + .await + .map_err(DbEventsError::ConnectError)?; + (client, spawn_event_pump(connection, events_tx)) } - } - })); + Some(connector) => { + let mut config: tokio_postgres::Config = + url.parse().map_err(DbEventsError::ConnectError)?; + config.ssl_mode(crate::tls::pg_ssl_mode(ssl.mode)); + let (client, connection) = config + .connect(connector) + .await + .map_err(DbEventsError::ConnectError)?; + (client, spawn_event_pump(connection, events_tx)) + } + }; client .execute(&format!("LISTEN {channel_name}"), &[]) @@ -146,3 +157,39 @@ impl DbEventBroadcast { events_task.await? } } + +/// Pumps `LISTEN`/`NOTIFY` messages off a Postgres connection and rebroadcasts +/// them as [`DbEvent`]s. Generic over the connection's stream type so it works +/// with both the plaintext (`NoTls`) and TLS-wrapped connections. +fn spawn_event_pump( + mut connection: Connection, + events_tx: DbEventSender, +) -> JoinHandle> +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, + T: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + spawn(poll_fn(move |cx| { + loop { + while let Some(message) = ready!( + connection + .poll_message(cx) + .map_err(DbEventsError::PollError)? + ) { + match message { + AsyncMessage::Notice(notice) => { + trace!("Database notice: {notice:?}"); + } + AsyncMessage::Notification(notification) => { + let event: DbEvent = serde_json::from_str(notification.payload()) + .map_err(DbEventsError::EventParseError)?; + events_tx.send(event).ok(); + } + other => { + trace!("Got unknown message from Postgres: {other:?}"); + } + } + } + } + })) +} diff --git a/db/src/lib.rs b/db/src/lib.rs index 7962f68..ffbd96c 100644 --- a/db/src/lib.rs +++ b/db/src/lib.rs @@ -6,15 +6,17 @@ mod identity; pub mod json_diff; pub mod schema; mod stats; +pub mod tls; mod ui_collection; use crate::config::{DbPoolOptions, database_url, db_pool_options}; +pub use config::{SslMode, SslSettings}; pub use db_table::*; use diesel_async::{ AsyncPgConnection, async_connection_wrapper::AsyncConnectionWrapper, pooled_connection::{ - AsyncDieselConnectionManager, + AsyncDieselConnectionManager, ManagerConfig, bb8::{Pool, PooledConnection}, }, }; @@ -47,8 +49,24 @@ pub struct Db { impl Db { async fn new(pool_options: DbPoolOptions) -> DbResult { let connection_url = database_url(); - info!("Connecting to {connection_url}"); - let config = AsyncDieselConnectionManager::::new(connection_url); + let ssl = SslSettings::from_env().map_err(errors::DbError::SslConfigError)?; + info!("Connecting to {connection_url} (sslmode={:?})", ssl.mode); + + // Wire the TLS connector into every pooled connection via a custom + // setup callback, so the pool negotiates TLS exactly like the + // LISTEN/NOTIFY connection in `events.rs`. + let connector = tls::build_connector(&ssl)?; + let mode = ssl.mode; + let mut manager_config = ManagerConfig::default(); + manager_config.custom_setup = Box::new(move |url| { + let connector = connector.clone(); + let url = url.to_string(); + Box::pin(async move { tls::establish_connection(&url, connector, mode).await }) + }); + let config = AsyncDieselConnectionManager::::new_with_config( + connection_url, + manager_config, + ); let pool = Pool::builder() .max_size(pool_options.max_size) .min_idle(pool_options.min_idle) diff --git a/db/src/tls.rs b/db/src/tls.rs new file mode 100644 index 0000000..4c003e4 --- /dev/null +++ b/db/src/tls.rs @@ -0,0 +1,270 @@ +//! TLS support for PostgreSQL connections. +//! +//! Platz assembles the PostgreSQL connection itself and does not link libpq, +//! so libpq-style controls (`PGSSLMODE`, `PGSSLROOTCERT`, …) are interpreted +//! here and turned into a `rustls`-backed TLS connector that is shared by both +//! the `diesel-async` connection pool and the dedicated `LISTEN`/`NOTIFY` +//! connection in [`crate::events`]. +//! +//! # Connectivity notes +//! +//! These are the behaviors observed while validating this code against a live +//! TLS-enabled PostgreSQL, captured here so the next person doesn't have to +//! rediscover them. +//! +//! ## Who decides what +//! +//! - **`tokio-postgres` decides _whether_ to use TLS.** The `SslMode` we pass +//! via [`pg_ssl_mode`] only controls negotiation: `Disable` never offers +//! TLS, `Prefer` uses it if the server supports it (falling back to +//! plaintext otherwise), and `Require` insists on it. It performs **no** +//! certificate checks itself. +//! - **The rustls connector decides _whether to trust_ the server.** All +//! certificate validation lives in the [`ClientConfig`] built by +//! [`build_connector`]. This split is why `require` can encrypt without +//! verifying, while `verify-full` adds full chain + hostname checks. +//! +//! ## Mode behavior, confirmed end-to-end +//! +//! Validated by connecting to a live server and reading `pg_stat_ssl`: +//! +//! | `PGSSLMODE` | Server offers TLS | Result | +//! |---------------|--------------------------|-----------------------------------------| +//! | `disable` | (irrelevant) | plaintext session (`ssl = false`) | +//! | `prefer` | yes | encrypted (`ssl = true`), cert unchecked | +//! | `prefer` | no | falls back to plaintext, connects | +//! | `require` | yes | encrypted, cert unchecked | +//! | `verify-full` | yes, cert trusted | encrypted, chain + hostname verified | +//! | `verify-full` | yes, cert **untrusted** | connection **rejected** at the handshake | +//! +//! ## `verify-full` requires a real, CA-signed certificate +//! +//! Two rustls/webpki requirements bite when testing `verify-full`: +//! +//! 1. **A bare self-signed leaf cert is not a usable trust anchor.** rustls +//! will not validate a server cert that is simply its own issuer; you need +//! an actual CA certificate (with `CA:TRUE`) that signed the server cert, +//! and that CA is what `PGSSLROOTCERT` must point at. +//! 2. **The hostname is matched against the certificate's SAN, not its CN.** +//! The server cert must carry a `subjectAltName` covering the host you +//! connect to (e.g. `DNS:localhost`); a CN-only cert is rejected. +//! +//! This is why the local dev Postgres (a quick self-signed cert) is fine for +//! `prefer`/`require` but cannot be used with `verify-full` without minting a +//! proper CA → server chain. Production `verify-full` deployments should point +//! `PGSSLROOTCERT` at the CA that issued the database's server certificate. +//! +//! ## Testing +//! +//! The end-to-end behavior above is covered by the self-contained +//! `tests/tls_modes.rs` integration test, which spins up a TLS-enabled +//! PostgreSQL via `testcontainers` (mints its own CA → server chain with +//! `rcgen`) and exercises every mode. It is skipped automatically when no +//! Docker daemon is reachable. + +use crate::config::{SslMode, SslSettings}; +use diesel::{ConnectionError, ConnectionResult}; +use diesel_async::AsyncPgConnection; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::{ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme}; +use std::fs::File; +use std::io::BufReader; +use std::sync::Arc; +use tokio_postgres::NoTls; +use tokio_postgres::config::SslMode as PgSslMode; +use tokio_postgres_rustls::MakeRustlsConnect; + +/// Errors that can occur while building the TLS connector. +#[derive(Debug, thiserror::Error)] +pub enum TlsError { + #[error("Invalid TLS configuration: {0}")] + Config(String), + #[error("Failed reading CA bundle {path:?}: {source}")] + ReadCaBundle { + path: String, + source: std::io::Error, + }, + #[error("CA bundle {0:?} contained no certificates")] + EmptyCaBundle(String), + #[error("Failed loading certificates: {0}")] + Rustls(#[from] rustls::Error), +} + +/// The TLS mode mapped to the [`PgSslMode`] understood by `tokio-postgres`. +/// +/// `tokio-postgres` only uses this to decide *whether* to attempt or require +/// TLS during negotiation; certificate verification is the connector's job and +/// is handled by the [`ClientConfig`] we build in [`build_connector`]. +pub fn pg_ssl_mode(mode: SslMode) -> PgSslMode { + match mode { + SslMode::Disable => PgSslMode::Disable, + SslMode::Prefer => PgSslMode::Prefer, + SslMode::Require | SslMode::VerifyFull => PgSslMode::Require, + } +} + +/// Builds the rustls-based TLS connector for the given settings. +/// +/// Returns `Ok(None)` when TLS is disabled, in which case callers should use +/// [`tokio_postgres::NoTls`] and the plaintext code path. +pub fn build_connector(settings: &SslSettings) -> Result, TlsError> { + let config = match settings.mode { + SslMode::Disable => return Ok(None), + // prefer/require encrypt the connection but do not verify the server + // certificate, matching libpq's behavior for these modes. + SslMode::Prefer | SslMode::Require => client_config_builder()? + .dangerous() + .with_custom_certificate_verifier(Arc::new(AcceptAnyServerCert)) + .with_no_client_auth(), + // verify-full validates the certificate chain and the hostname. + SslMode::VerifyFull => client_config_builder()? + .with_root_certificates(load_root_store(settings.root_cert.as_deref())?) + .with_no_client_auth(), + }; + Ok(Some(MakeRustlsConnect::new(config))) +} + +/// Starts a rustls [`ClientConfig`] builder using an explicit crypto provider. +/// +/// Using `builder_with_provider` avoids depending on a process-wide default +/// provider being installed, which the rest of the workspace does not do. +fn client_config_builder() +-> Result, TlsError> { + ClientConfig::builder_with_provider(Arc::new(rustls::crypto::aws_lc_rs::default_provider())) + .with_safe_default_protocol_versions() + .map_err(|err| TlsError::Config(err.to_string())) +} + +/// Loads the trust anchors for `verify-full`. When a CA bundle path is given it +/// is used exclusively; otherwise the operating system trust store is used. +fn load_root_store(root_cert: Option<&str>) -> Result { + let mut roots = RootCertStore::empty(); + + match root_cert { + Some(path) => { + let file = File::open(path).map_err(|source| TlsError::ReadCaBundle { + path: path.to_string(), + source, + })?; + let mut reader = BufReader::new(file); + let mut added = 0usize; + for cert in rustls_pemfile::certs(&mut reader) { + let cert = cert.map_err(|source| TlsError::ReadCaBundle { + path: path.to_string(), + source, + })?; + roots.add(cert)?; + added += 1; + } + if added == 0 { + return Err(TlsError::EmptyCaBundle(path.to_string())); + } + } + None => { + let result = rustls_native_certs::load_native_certs(); + for cert in result.certs { + // Skip certificates the trust store can't parse rather than + // failing the whole connection over one bad entry. + roots.add(cert).ok(); + } + if roots.is_empty() { + return Err(TlsError::Config( + "No usable certificates found in the system trust store; \ + set PGSSLROOTCERT to a CA bundle" + .to_string(), + )); + } + } + } + + Ok(roots) +} + +/// Establishes a single `AsyncPgConnection`, applying the configured TLS mode. +/// +/// This is wired into the `diesel-async` pool via `ManagerConfig::custom_setup` +/// so that every pooled connection negotiates TLS identically. +pub async fn establish_connection( + url: &str, + connector: Option, + mode: SslMode, +) -> ConnectionResult { + match connector { + None => { + let (client, conn) = tokio_postgres::connect(url, NoTls) + .await + .map_err(|err| ConnectionError::BadConnection(err.to_string()))?; + AsyncPgConnection::try_from_client_and_connection(client, conn).await + } + Some(connector) => { + let mut config: tokio_postgres::Config = + url.parse().map_err(|err: tokio_postgres::Error| { + ConnectionError::BadConnection(err.to_string()) + })?; + config.ssl_mode(pg_ssl_mode(mode)); + let (client, conn) = config + .connect(connector) + .await + .map_err(|err| ConnectionError::BadConnection(err.to_string()))?; + AsyncPgConnection::try_from_client_and_connection(client, conn).await + } + } +} + +/// A certificate verifier that accepts any server certificate. +/// +/// Used for the `prefer` and `require` modes, which encrypt the connection but +/// — like libpq — do not authenticate the server. Use `verify-full` when the +/// server's identity must be verified. +#[derive(Debug)] +struct AcceptAnyServerCert; + +impl ServerCertVerifier for AcceptAnyServerCert { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + SignatureScheme::RSA_PKCS1_SHA1, + SignatureScheme::ECDSA_SHA1_Legacy, + SignatureScheme::RSA_PKCS1_SHA256, + SignatureScheme::ECDSA_NISTP256_SHA256, + SignatureScheme::RSA_PKCS1_SHA384, + SignatureScheme::ECDSA_NISTP384_SHA384, + SignatureScheme::RSA_PKCS1_SHA512, + SignatureScheme::ECDSA_NISTP521_SHA512, + SignatureScheme::RSA_PSS_SHA256, + SignatureScheme::RSA_PSS_SHA384, + SignatureScheme::RSA_PSS_SHA512, + SignatureScheme::ED25519, + SignatureScheme::ED448, + ] + } +} diff --git a/db/tests/tls_modes.rs b/db/tests/tls_modes.rs new file mode 100644 index 0000000..910a841 --- /dev/null +++ b/db/tests/tls_modes.rs @@ -0,0 +1,233 @@ +//! End-to-end verification of the PostgreSQL TLS modes. +//! +//! Spins up a TLS-enabled PostgreSQL via `testcontainers`, minting its own +//! CA -> server certificate chain with `rcgen`, and drives every `PGSSLMODE` +//! through the crate's real connector ([`platz_db::tls::build_connector`] + +//! [`platz_db::tls::pg_ssl_mode`]). For each mode it reads `pg_stat_ssl` to +//! confirm whether the session is actually encrypted, and checks that +//! `verify-full` accepts a trusted cert while rejecting an untrusted one. +//! +//! Self-contained: `cargo test` runs it with no external setup. It needs a +//! reachable Docker daemon (to start the container) and pulls +//! `postgres:16-alpine`; when Docker is unavailable the test skips cleanly +//! instead of failing. + +use std::time::Duration; + +use platz_db::{SslMode, SslSettings, tls}; +use rcgen::{ + BasicConstraints, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair, + KeyUsagePurpose, +}; +use testcontainers::core::{IntoContainerPort, WaitFor}; +use testcontainers::runners::AsyncRunner; +use testcontainers::{CopyTargetOptions, GenericImage, ImageExt}; +use tokio::time::sleep; + +/// Runs as the container entrypoint: copies the injected certs to a location +/// the postgres user can read (key must be 0600 and owned by `postgres`), then +/// hands off to the stock entrypoint with TLS enabled. +const ENTRYPOINT_SCRIPT: &str = "set -e +mkdir -p /etc/pg-certs +cp /certs/server.crt /certs/server.key /etc/pg-certs/ +chown postgres:postgres /etc/pg-certs/server.crt /etc/pg-certs/server.key +chmod 600 /etc/pg-certs/server.key +exec docker-entrypoint.sh postgres \ + -c ssl=on \ + -c ssl_cert_file=/etc/pg-certs/server.crt \ + -c ssl_key_file=/etc/pg-certs/server.key"; + +struct TestCerts { + ca_pem: String, + server_cert_pem: String, + server_key_pem: String, +} + +/// Mints a CA and a `localhost` server certificate signed by it. The server +/// cert carries a SAN and the serverAuth EKU so it passes `verify-full`. +fn mint_certs() -> TestCerts { + let ca_key = KeyPair::generate().expect("generate CA key"); + let mut ca_params = CertificateParams::new(Vec::::new()).expect("CA params"); + ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + ca_params + .distinguished_name + .push(DnType::CommonName, "platz-test-ca"); + let ca_cert = ca_params.self_signed(&ca_key).expect("self-sign CA"); + + let server_key = KeyPair::generate().expect("generate server key"); + let mut server_params = + CertificateParams::new(vec!["localhost".to_string()]).expect("server params"); + server_params + .distinguished_name + .push(DnType::CommonName, "localhost"); + server_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + + let issuer = Issuer::new(ca_params, ca_key); + let server_cert = server_params + .signed_by(&server_key, &issuer) + .expect("sign server cert"); + + TestCerts { + ca_pem: ca_cert.pem(), + server_cert_pem: server_cert.pem(), + server_key_pem: server_key.serialize_pem(), + } +} + +/// Connects with the given settings through the crate's connector and reports +/// whether the resulting session is encrypted, per `pg_stat_ssl`. +async fn negotiated_ssl(port: u16, settings: &SslSettings) -> Result { + let connector = tls::build_connector(settings).map_err(|e| e.to_string())?; + let base = format!("host=localhost port={port} user=postgres dbname=platz"); + let client = match connector { + None => { + let (client, conn) = tokio_postgres::connect(&base, tokio_postgres::NoTls) + .await + .map_err(|e| e.to_string())?; + tokio::spawn(async move { + let _ = conn.await; + }); + client + } + Some(connector) => { + let mut config: tokio_postgres::Config = base + .parse() + .map_err(|e: tokio_postgres::Error| e.to_string())?; + config.ssl_mode(tls::pg_ssl_mode(settings.mode)); + let (client, conn) = config.connect(connector).await.map_err(|e| e.to_string())?; + tokio::spawn(async move { + let _ = conn.await; + }); + client + } + }; + let row = client + .query_one( + "SELECT coalesce(ssl, false) FROM pg_stat_ssl WHERE pid = pg_backend_pid()", + &[], + ) + .await + .map_err(|e| e.to_string())?; + Ok(row.get::<_, bool>(0)) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tls_modes_end_to_end() { + let certs = mint_certs(); + + let image = GenericImage::new("postgres", "16-alpine") + .with_exposed_port(5432.tcp()) + .with_wait_for(WaitFor::message_on_stderr( + "database system is ready to accept connections", + )) + .with_entrypoint("sh") + .with_env_var("POSTGRES_HOST_AUTH_METHOD", "trust") + .with_env_var("POSTGRES_DB", "platz") + .with_env_var("POSTGRES_USER", "postgres") + .with_copy_to( + CopyTargetOptions::new("/certs/server.crt"), + certs.server_cert_pem.into_bytes(), + ) + .with_copy_to( + CopyTargetOptions::new("/certs/server.key"), + certs.server_key_pem.into_bytes(), + ) + .with_cmd(["-c", ENTRYPOINT_SCRIPT]); + + let container = match image.start().await { + Ok(container) => container, + Err(err) => { + eprintln!("skipping tls_modes_end_to_end: Docker not available ({err})"); + return; + } + }; + let port = container + .get_host_port_ipv4(5432.tcp()) + .await + .expect("mapped host port"); + + // Trust anchor for verify-full: the CA we minted, written to a file that + // PGSSLROOTCERT (root_cert) points at. + let ca_file = tempfile::NamedTempFile::new().expect("temp CA file"); + std::fs::write(ca_file.path(), &certs.ca_pem).expect("write CA PEM"); + let ca_path = ca_file.path().to_str().expect("utf-8 CA path").to_string(); + + let require = SslSettings { + mode: SslMode::Require, + root_cert: None, + }; + + // The wait strategy fires on the first "ready" line, which is the temporary + // init server. Poll over TCP until the real TLS server is accepting. + let mut ready = false; + for _ in 0..60 { + if negotiated_ssl(port, &require).await.is_ok() { + ready = true; + break; + } + sleep(Duration::from_secs(1)).await; + } + assert!(ready, "postgres did not become ready in time"); + + // disable -> plaintext session. + let disable = SslSettings { + mode: SslMode::Disable, + root_cert: None, + }; + assert_eq!( + negotiated_ssl(port, &disable).await, + Ok(false), + "disable must produce a plaintext session" + ); + + // prefer -> TLS, since the server offers it. + let prefer = SslSettings { + mode: SslMode::Prefer, + root_cert: None, + }; + assert_eq!( + negotiated_ssl(port, &prefer).await, + Ok(true), + "prefer must negotiate TLS when the server offers it" + ); + + // require -> TLS. + assert_eq!( + negotiated_ssl(port, &require).await, + Ok(true), + "require must use TLS" + ); + + // verify-full with the CA trusted -> TLS, chain + hostname verified. + let verify_full_trusted = SslSettings { + mode: SslMode::VerifyFull, + root_cert: Some(ca_path.clone()), + }; + assert_eq!( + negotiated_ssl(port, &verify_full_trusted).await, + Ok(true), + "verify-full must succeed when the server CA is trusted" + ); + + // verify-full without our CA (system trust store) -> rejected. + let verify_full_untrusted = SslSettings { + mode: SslMode::VerifyFull, + root_cert: None, + }; + assert!( + negotiated_ssl(port, &verify_full_untrusted).await.is_err(), + "verify-full must reject a certificate signed by an untrusted CA" + ); + + // Finally, exercise the actual diesel-async pool setup path (the same one + // the pool uses via ManagerConfig::custom_setup) over verified TLS. + let connector = tls::build_connector(&verify_full_trusted).expect("build connector"); + let url = format!("host=localhost port={port} user=postgres dbname=platz"); + assert!( + tls::establish_connection(&url, connector, SslMode::VerifyFull) + .await + .is_ok(), + "diesel-async pool setup should connect with verify-full + trusted CA" + ); +}