diff --git a/Cargo.lock b/Cargo.lock index 7ce00e92d6..d84d660b74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -880,6 +880,27 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "file-id" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "filetime" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.60.2", +] + [[package]] name = "find-msvc-tools" version = "0.1.5" @@ -995,6 +1016,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "fslock" version = "0.2.1" @@ -1567,6 +1597,26 @@ dependencies = [ "hashbrown 0.16.0", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "instant" version = "0.1.13" @@ -1696,6 +1746,26 @@ dependencies = [ "indexmap", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1718,6 +1788,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall 0.7.0", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1849,6 +1930,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.1.0" @@ -1975,6 +2068,39 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.10.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "notify-debouncer-full" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb7fd166739789c9ff169e654dc1501373db9d80a4c3f972817c8a4d7cf8f34e" +dependencies = [ + "crossbeam-channel", + "file-id", + "log", + "notify", + "parking_lot", + "walkdir", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2169,7 +2295,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -2468,7 +2594,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" dependencies = [ "heck", - "itertools 0.10.5", + "itertools 0.14.0", "log", "multimap", "once_cell", @@ -2488,7 +2614,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.110", @@ -2501,7 +2627,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.110", @@ -2736,6 +2862,15 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "regex" version = "1.12.2" @@ -3468,7 +3603,7 @@ checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ "bytes", "libc", - "mio", + "mio 1.1.0", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -4448,6 +4583,8 @@ dependencies = [ "matches", "netns-rs", "nix 0.30.1", + "notify", + "notify-debouncer-full", "num_cpus", "oid-registry", "once_cell", @@ -4468,15 +4605,18 @@ dependencies = [ "rustls-native-certs", "rustls-openssl", "rustls-pemfile", + "rustls-webpki", "serde", "serde_json", "serde_yaml", "socket2 0.6.1", "split-iter", + "tempfile", "test-case", "textnonce", "thiserror 2.0.17", "tikv-jemallocator", + "time", "tls-listener", "tokio", "tokio-rustls", diff --git a/Cargo.toml b/Cargo.toml index ad64fd192b..ea79b1b140 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,6 +72,8 @@ keyed_priority_queue = "0.4" libc = "0.2" log = "0.4" nix = { version = "0.30", features = ["socket", "sched", "uio", "fs", "ioctl", "user", "net", "mount", "resource" ] } +notify = "6.1" +notify-debouncer-full = "0.3" once_cell = "1.21" num_cpus = "1.16" ppp = "2.3" @@ -101,6 +103,7 @@ tracing = { version = "0.1"} tracing-subscriber = { version = "0.3", features = ["registry", "env-filter", "json"] } url = "2.5" x509-parser = { version = "0.17", default-features = false } +rustls-webpki = { version = "0.103", default-features = false, features = ["alloc"] } tracing-log = "0.2" backoff = "0.4" pin-project-lite = "0.2" @@ -155,7 +158,9 @@ test-case = "3.3" oid-registry = "0.8" rcgen = { version = "0.14", features = ["pem", "x509-parser"] } x509-parser = { version = "0.17", default-features = false, features = ["verify"] } +time = "0.3" ctor = "0.5" +tempfile = "3.21" [lints.clippy] # This rule makes code more confusing diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index ba6cc05983..11f6ebde6a 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -755,6 +755,27 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "file-id" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "filetime" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.60.2", +] + [[package]] name = "findshlibs" version = "0.10.2" @@ -812,6 +833,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.31" @@ -1417,6 +1447,26 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "instant" version = "0.1.13" @@ -1519,6 +1569,26 @@ dependencies = [ "indexmap", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1557,6 +1627,17 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags 2.8.0", + "libc", + "redox_syscall", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1670,6 +1751,18 @@ dependencies = [ "adler2", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.0.3" @@ -1772,6 +1865,39 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.8.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "notify-debouncer-full" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb7fd166739789c9ff169e654dc1501373db9d80a4c3f972817c8a4d7cf8f34e" +dependencies = [ + "crossbeam-channel", + "file-id", + "log", + "notify", + "parking_lot", + "walkdir", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -2399,9 +2525,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.9" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b568323e98e49e2a0899dcee453dd679fae22d69adf9b11dd508d1549b7e2f" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ "bitflags 2.8.0", ] @@ -2537,7 +2663,7 @@ dependencies = [ "log", "once_cell", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.102.8", "subtle", "zeroize", ] @@ -2581,6 +2707,17 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustls-webpki" +version = "0.103.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.19" @@ -3016,7 +3153,7 @@ dependencies = [ "backtrace", "bytes", "libc", - "mio", + "mio 1.0.3", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -3528,6 +3665,12 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-result" version = "0.2.0" @@ -3574,6 +3717,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -3598,13 +3750,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -3617,6 +3786,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -3629,6 +3804,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -3641,12 +3822,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -3659,6 +3852,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -3671,6 +3870,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -3683,6 +3888,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -3695,6 +3906,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.6" @@ -3913,6 +4130,8 @@ dependencies = [ "log", "netns-rs", "nix 0.30.1", + "notify", + "notify-debouncer-full", "num_cpus", "once_cell", "pin-project-lite", @@ -3930,6 +4149,7 @@ dependencies = [ "rustls", "rustls-native-certs", "rustls-pemfile", + "rustls-webpki 0.103.3", "serde", "serde_json", "serde_yaml", diff --git a/src/config.rs b/src/config.rs index afdbd5c2ef..2918f143cf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -78,6 +78,8 @@ const HTTP2_FRAME_SIZE: &str = "HTTP2_FRAME_SIZE"; const UNSTABLE_ENABLE_SOCKS5: &str = "UNSTABLE_ENABLE_SOCKS5"; +const CRL_PATH: &str = "CRL_PATH"; + const DEFAULT_WORKER_THREADS: u16 = 2; const DEFAULT_ADMIN_PORT: u16 = 15000; const DEFAULT_READINESS_PORT: u16 = 15021; @@ -311,6 +313,9 @@ pub struct Config { pub ztunnel_workload: Option, pub ipv6_enabled: bool, + + // path to CRL file; if set, enables CRL checking + pub crl_path: Option, } #[derive(serde::Serialize, Clone, Copy, Debug)] @@ -865,6 +870,11 @@ pub fn construct_config(pc: ProxyConfig) -> Result { ztunnel_identity, ztunnel_workload, ipv6_enabled, + + crl_path: env::var(CRL_PATH) + .ok() + .filter(|s| !s.is_empty()) + .map(PathBuf::from), }) } diff --git a/src/proxy.rs b/src/proxy.rs index a8b3e013e5..b780769858 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -118,6 +118,7 @@ impl DefaultSocketFactory { let res = socket2::SockRef::from(&s).set_tcp_keepalive(&ka); tracing::trace!("set keepalive: {:?}", res); } + #[cfg(target_os = "linux")] if cfg.user_timeout_enabled { // https://blog.cloudflare.com/when-tcp-sockets-refuse-to-die/ // TCP_USER_TIMEOUT = TCP_KEEPIDLE + TCP_KEEPINTVL * TCP_KEEPCNT. @@ -258,6 +259,7 @@ pub(super) struct ProxyInputs { resolver: Option>, // If true, inbound connections created with these inputs will not attempt to preserve the original source IP. pub disable_inbound_freebind: bool, + pub(super) crl_manager: Option>, } #[allow(clippy::too_many_arguments)] @@ -271,6 +273,7 @@ impl ProxyInputs { resolver: Option>, local_workload_information: Arc, disable_inbound_freebind: bool, + crl_manager: Option>, ) -> Arc { Arc::new(Self { cfg, @@ -281,6 +284,7 @@ impl ProxyInputs { local_workload_information, resolver, disable_inbound_freebind, + crl_manager, }) } } diff --git a/src/proxy/inbound.rs b/src/proxy/inbound.rs index 72c79a5270..bddf80180e 100644 --- a/src/proxy/inbound.rs +++ b/src/proxy/inbound.rs @@ -83,6 +83,7 @@ impl Inbound { let pi = self.pi.clone(); let acceptor = InboundCertProvider { local_workload: self.pi.local_workload_information.clone(), + crl_manager: self.pi.crl_manager.clone(), }; // Safety: we set nodelay directly in tls_server, so it is safe to convert to a normal listener. @@ -683,6 +684,7 @@ impl InboundFlagError { #[derive(Clone)] struct InboundCertProvider { local_workload: Arc, + crl_manager: Option>, } #[async_trait::async_trait] @@ -693,7 +695,7 @@ impl crate::tls::ServerCertProvider for InboundCertProvider { "fetching cert" ); let cert = self.local_workload.fetch_certificate().await?; - Ok(Arc::new(cert.server_config()?)) + Ok(Arc::new(cert.server_config(self.crl_manager.clone())?)) } } @@ -914,6 +916,7 @@ mod tests { None, local_workload, false, + None, )); let inbound_request = Inbound::build_inbound_request(&pi, conn, &request_parts).await; match want { diff --git a/src/proxy/outbound.rs b/src/proxy/outbound.rs index 6c23376522..40529fef37 100644 --- a/src/proxy/outbound.rs +++ b/src/proxy/outbound.rs @@ -804,6 +804,7 @@ mod tests { connection_manager: ConnectionManager::default(), resolver: None, disable_inbound_freebind: false, + crl_manager: None, }), id: TraceParent::new(), pool: WorkloadHBONEPool::new( diff --git a/src/proxyfactory.rs b/src/proxyfactory.rs index afedabf7c7..0bdb883b11 100644 --- a/src/proxyfactory.rs +++ b/src/proxyfactory.rs @@ -15,6 +15,7 @@ use crate::config; use crate::identity::SecretManager; use crate::state::{DemandProxyState, WorkloadInfo}; +use crate::tls; use std::sync::Arc; use tracing::error; @@ -34,6 +35,7 @@ pub struct ProxyFactory { proxy_metrics: Arc, dns_metrics: Option>, drain: DrainWatcher, + crl_manager: Option>, } impl ProxyFactory { @@ -55,6 +57,36 @@ impl ProxyFactory { } }; + // Initialize CRL manager if crl_path is set + let crl_manager = if let Some(crl_path) = &config.crl_path { + match tls::crl::CrlManager::new(crl_path.clone()) { + Ok(manager) => { + let manager_arc = Arc::new(manager); + + if let Err(e) = manager_arc.start_file_watcher() { + tracing::warn!( + "crl file watcher could not be started: {}. \ + crl validation will continue with current file, but \ + crl updates will require restarting ztunnel.", + e + ); + } + + Some(manager_arc) + } + Err(e) => { + tracing::warn!( + path = ?crl_path, + error = %e, + "failed to initialize crl manager" + ); + None + } + } + } else { + None + }; + Ok(ProxyFactory { config, state, @@ -62,6 +94,7 @@ impl ProxyFactory { proxy_metrics, dns_metrics, drain, + crl_manager, }) } @@ -132,6 +165,7 @@ impl ProxyFactory { resolver, local_workload_information, false, + self.crl_manager.clone(), ); result.connection_manager = Some(cm); result.proxy = Some(Proxy::from_inputs(pi, drain).await?); @@ -177,6 +211,7 @@ impl ProxyFactory { None, local_workload_information, true, + self.crl_manager.clone(), ); let inbound = Inbound::new(pi, self.drain.clone()).await?; diff --git a/src/test_helpers/app.rs b/src/test_helpers/app.rs index ef3f625a5c..3572d45778 100644 --- a/src/test_helpers/app.rs +++ b/src/test_helpers/app.rs @@ -50,6 +50,7 @@ pub struct TestApp { pub udp_dns_proxy_address: Option, pub cert_manager: Arc, + #[cfg(target_os = "linux")] pub namespace: Option, pub shutdown: ShutdownTrigger, pub ztunnel_identity: Option, @@ -65,6 +66,7 @@ impl From<(&Bound, Arc)> for TestApp { tcp_dns_proxy_address: app.tcp_dns_proxy_address, udp_dns_proxy_address: app.udp_dns_proxy_address, cert_manager, + #[cfg(target_os = "linux")] namespace: None, shutdown: app.shutdown.trigger(), ztunnel_identity: None, @@ -113,6 +115,7 @@ impl TestApp { Ok(client.request(req).await?) }; + #[cfg(target_os = "linux")] match self.namespace { Some(ref _ns) => { // TODO: if this is needed, do something like admin_request_body. @@ -122,6 +125,8 @@ impl TestApp { } None => get_resp().await, } + #[cfg(not(target_os = "linux"))] + get_resp().await } pub async fn admin_request_body(&self, path: &str) -> anyhow::Result { let port = self.admin_address.port(); @@ -139,10 +144,13 @@ impl TestApp { Ok::<_, anyhow::Error>(res.collect().await?.to_bytes()) }; + #[cfg(target_os = "linux")] match self.namespace { Some(ref ns) => ns.clone().run(get_resp)?.join().unwrap(), None => get_resp().await, } + #[cfg(not(target_os = "linux"))] + get_resp().await } pub async fn metrics(&self) -> anyhow::Result { diff --git a/src/tls.rs b/src/tls.rs index 4228748e8b..1eebcadf01 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -14,6 +14,7 @@ mod certificate; mod control; +pub mod crl; pub mod csr; mod lib; #[cfg(any(test, feature = "testing"))] diff --git a/src/tls/certificate.rs b/src/tls/certificate.rs index 230676217a..78c5064605 100644 --- a/src/tls/certificate.rs +++ b/src/tls/certificate.rs @@ -298,15 +298,30 @@ impl WorkloadCertificate { .collect() } - pub fn server_config(&self) -> Result { + pub fn server_config( + &self, + crl_manager: Option>, + ) -> Result { let td = self.cert.identity().map(|i| match i { Identity::Spiffe { trust_domain, .. } => trust_domain, }); - let raw_client_cert_verifier = WebPkiClientVerifier::builder_with_provider( + + // build the base client cert verifier with optional CRL support + let mut builder = WebPkiClientVerifier::builder_with_provider( self.root_store.clone(), crate::tls::lib::provider(), - ) - .build()?; + ); + + // add CRLs if available + if let Some(ref mgr) = crl_manager { + let crls = mgr.get_crl_ders(); + if !crls.is_empty() { + builder = builder.with_crls(crls).allow_unknown_revocation_status(); // fail-open for unknown status + } + } + + // TODO: check if our own certificate is revoked in the CRL and log warning + let raw_client_cert_verifier = builder.build()?; let client_cert_verifier = crate::tls::workload::TrustDomainVerifier::new(raw_client_cert_verifier, td); @@ -322,6 +337,8 @@ impl WorkloadCertificate { Ok(sc) } + // TODO: add CRL support for outbound connections (client verifying server certs) + // this requires a separate design due to complexity - deferred for follow-up pub fn client_config(&self, identity: Vec) -> Result { let roots = self.root_store.clone(); let verifier = IdentityVerifier { roots, identity }; @@ -449,7 +466,7 @@ mod test { WorkloadCertificate::new(key.as_bytes(), cert.as_bytes(), vec![&joined]).unwrap(); // Do a simple handshake between them; we should be able to accept the trusted root - let server = cert1.server_config().unwrap(); + let server = cert1.server_config(None).unwrap(); let tls = TlsAcceptor::from(Arc::new(server)); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); diff --git a/src/tls/crl.rs b/src/tls/crl.rs new file mode 100644 index 0000000000..25c9dab55b --- /dev/null +++ b/src/tls/crl.rs @@ -0,0 +1,350 @@ +// Copyright Istio Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use notify::RecommendedWatcher; +use notify_debouncer_full::{ + DebounceEventResult, Debouncer, FileIdMap, new_debouncer, + notify::{RecursiveMode, Watcher}, +}; +use rustls::pki_types::CertificateRevocationListDer; +use rustls_pemfile::Item; +use std::io::Cursor; +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; +use std::time::Duration; +use tracing::{debug, warn}; + +#[derive(Debug, thiserror::Error)] +pub enum CrlError { + #[error("failed to read CRL file: {0}")] + IoError(#[from] std::io::Error), + + #[error("failed to parse CRL: {0}")] + ParseError(String), + + #[error("CRL error: {0}")] + WebPkiError(String), +} + +#[derive(Clone)] +/// NOTE: CRL updates take effect when new ServerConfigs are created, which happens +/// on certificate refresh (~12hrs). For immediate CRL enforcement, a custom +/// ClientCertVerifier wrapper would be needed, but rustls doesn't provide a +/// built-in mechanism like `with_cert_resolver` for dynamic CRL updates. +pub struct CrlManager { + inner: Arc>, +} + +impl std::fmt::Debug for CrlManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CrlManager").finish_non_exhaustive() + } +} + +struct CrlManagerInner { + crl_ders: Option>>, // None = not loaded, Some = loaded (may be empty) + crl_path: PathBuf, + _debouncer: Option>, +} + +impl CrlManager { + /// creates a new CRL manager + pub fn new(crl_path: PathBuf) -> Result { + debug!(path = ?crl_path, "initializing crl manager"); + + let manager = Self { + inner: Arc::new(RwLock::new(CrlManagerInner { + crl_ders: None, + crl_path: crl_path.clone(), + _debouncer: None, + })), + }; + + // try to load the CRL, but don't fail if the file doesn't exist yet + // (it might be mounted later via ConfigMap) + if let Err(e) = manager.load_crl() { + match e { + CrlError::IoError(ref io_err) if io_err.kind() == std::io::ErrorKind::NotFound => { + warn!( + path = ?crl_path, + "crl file not found, will retry on first validation" + ); + } + _ => { + debug!(error = %e, "failed to initialize crl manager"); + return Err(e); + } + } + } + + Ok(manager) + } + + pub fn load_crl(&self) -> Result<(), CrlError> { + let mut inner = self.inner.write().unwrap(); + + let data = std::fs::read(&inner.crl_path)?; + + // empty file means no revocations - this is valid + if data.is_empty() { + debug!(path = ?inner.crl_path, "crl file is empty, treating as no revocations"); + inner.crl_ders = Some(Vec::new()); + return Ok(()); + } + + // parse all CRL blocks (handles concatenated CRLs) + let is_pem = data.starts_with(b"-----BEGIN"); + let der_crls = if is_pem { + Self::parse_pem_crls(&data)? + } else { + vec![data] + }; + + // empty PEM file (no CRL blocks) means no revocations + if der_crls.is_empty() { + debug!(path = ?inner.crl_path, "no crl blocks found, treating as no revocations"); + inner.crl_ders = Some(Vec::new()); + return Ok(()); + } + + let mut validated_ders = Vec::new(); + + for (idx, der_data) in der_crls.into_iter().enumerate() { + // validate with webpki to catch parse errors early + // rustls will use the raw DER bytes directly + webpki::OwnedCertRevocationList::from_der(&der_data).map_err(|e| { + CrlError::WebPkiError(format!("failed to parse crl {}: {:?}", idx + 1, e)) + })?; + + validated_ders.push(der_data); + } + + // store validated DER bytes + inner.crl_ders = Some(validated_ders); + + debug!( + path = ?inner.crl_path, + format = if is_pem { "PEM" } else { "DER" }, + count = inner.crl_ders.as_ref().map(|v| v.len()).unwrap_or(0), + "crl loaded successfully" + ); + Ok(()) + } + + /// parses PEM-encoded CRL data that may contain multiple CRL blocks + /// returns a Vec of DER-encoded CRLs (empty vec if no blocks found) + fn parse_pem_crls(pem_data: &[u8]) -> Result>, CrlError> { + let mut reader = std::io::BufReader::new(Cursor::new(pem_data)); + + rustls_pemfile::read_all(&mut reader) + .filter_map(|result| match result { + Ok(Item::Crl(crl)) => Some(Ok(crl.to_vec())), + Ok(_) => None, // skip non-CRL items + Err(e) => Some(Err(CrlError::ParseError(format!( + "failed to parse PEM: {}", + e + )))), + }) + .collect() + } + + /// returns CRLs as DER bytes for rustls's with_crls(). + /// if no CRLs are loaded, attempts to load them first. + pub fn get_crl_ders(&self) -> Vec> { + let inner = self.inner.read().unwrap(); + if let Some(ref crl_ders) = inner.crl_ders { + // already loaded, use existing lock directly + crl_ders + .iter() + .map(|der| CertificateRevocationListDer::from(der.clone())) + .collect() + } else { + // not loaded yet, drop lock to call load_crl() + drop(inner); + debug!("crl not loaded, attempting to load now"); + if let Err(e) = self.load_crl() { + debug!(error = %e, "failed to load crl"); + return Vec::new(); + } + // re-acquire after loading + let inner = self.inner.read().unwrap(); + inner + .crl_ders + .as_ref() + .map(|ders| { + ders.iter() + .map(|der| CertificateRevocationListDer::from(der.clone())) + .collect() + }) + .unwrap_or_default() + } + } + + /// starts watching the CRL file for changes. + /// uses debouncer to handle all file update patterns + pub fn start_file_watcher(self: &Arc) -> Result<(), CrlError> { + let crl_path = { + let inner = self.inner.read().unwrap(); + inner.crl_path.clone() + }; + + // watch the parent directory to catch ConfigMap updates via symlinks + let watch_path = crl_path + .parent() + .ok_or_else(|| CrlError::ParseError("crl path has no parent directory".to_string()))?; + + debug!( + path = ?watch_path, + debounce_secs = 2, + "starting crl file watcher" + ); + + let manager = Arc::clone(self); + + // create debouncer with 2-second timeout + // this collapses multiple events (CREATE/CHMOD/RENAME/REMOVE) into a single reload + let mut debouncer = new_debouncer( + Duration::from_secs(2), + None, + move |result: DebounceEventResult| { + match result { + Ok(events) => { + if !events.is_empty() { + debug!(event_count = events.len(), "crl directory events detected"); + + // reload CRL for any changes in the watched directory + // this handles Kubernetes ConfigMap updates (..data symlink changes) + // as well as direct file writes and text editor saves + debug!("crl directory changed, reloading"); + match manager.load_crl() { + Ok(()) => { + debug!("crl reloaded successfully after file change"); + } + Err(e) => debug!(error = %e, "failed to reload crl"), + } + } + } + Err(errors) => { + for error in errors { + debug!(error = ?error, "crl watcher error"); + } + } + } + }, + ) + .map_err(|e| CrlError::ParseError(format!("failed to create debouncer: {}", e)))?; + + // start watching the directory + debouncer + .watcher() + .watch(watch_path, RecursiveMode::NonRecursive) + .map_err(|e| CrlError::ParseError(format!("failed to watch directory: {}", e)))?; + + // store debouncer to keep it alive + { + let mut inner = self.inner.write().unwrap(); + inner._debouncer = Some(debouncer); + } + + debug!("crl file watcher started successfully"); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn test_crl_manager_missing_file() { + let result = CrlManager::new(PathBuf::from("/nonexistent/path/crl.pem")); + assert!(result.is_ok(), "should handle missing CRL file gracefully"); + } + + #[test] + fn test_crl_manager_invalid_file() { + let mut file = NamedTempFile::new().expect("failed to create temporary test file"); + file.write_all(b"not a valid CRL") + .expect("failed to write test data to temporary file"); + file.flush().expect("failed to flush temporary test file"); + + let result = CrlManager::new(file.path().to_path_buf()); + assert!(result.is_err(), "should fail on invalid CRL data"); + } + + #[test] + fn test_crl_manager_empty_file() { + let file = NamedTempFile::new().expect("failed to create temporary test file"); + // file is empty by default + + let result = CrlManager::new(file.path().to_path_buf()); + assert!(result.is_ok(), "should handle empty CRL file gracefully"); + } + + #[test] + fn test_crl_manager_valid_crl() { + use rcgen::{ + CertificateParams, CertificateRevocationListParams, Issuer, KeyIdMethod, KeyPair, + RevocationReason, RevokedCertParams, SerialNumber, + }; + + // generate a CA key pair + let ca_key_pair = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256) + .expect("failed to generate CA key pair"); + + // create CA certificate params + let mut ca_params = CertificateParams::default(); + ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + ca_params.key_usages = vec![ + rcgen::KeyUsagePurpose::KeyCertSign, + rcgen::KeyUsagePurpose::CrlSign, + ]; + + // create issuer from CA params and key + let issuer = Issuer::from_params(&ca_params, &ca_key_pair); + + // create CRL with one revoked certificate + let crl_params = CertificateRevocationListParams { + this_update: time::OffsetDateTime::now_utc(), + next_update: time::OffsetDateTime::now_utc() + time::Duration::days(30), + crl_number: SerialNumber::from(1u64), + issuing_distribution_point: None, + revoked_certs: vec![RevokedCertParams { + serial_number: SerialNumber::from(12345u64), + revocation_time: time::OffsetDateTime::now_utc(), + reason_code: Some(RevocationReason::KeyCompromise), + invalidity_date: None, + }], + key_identifier_method: KeyIdMethod::Sha256, + }; + + let crl = crl_params.signed_by(&issuer).expect("failed to sign CRL"); + let crl_pem = crl.pem().expect("failed to encode CRL as PEM"); + + // write CRL to temp file + let mut file = NamedTempFile::new().expect("failed to create temporary test file"); + file.write_all(crl_pem.as_bytes()) + .expect("failed to write CRL to temporary file"); + file.flush().expect("failed to flush temporary test file"); + + // test that CrlManager can load it + let manager = CrlManager::new(file.path().to_path_buf()) + .expect("should successfully parse valid CRL"); + + let ders = manager.get_crl_ders(); + assert_eq!(ders.len(), 1, "should have loaded one CRL"); + } +}