diff --git a/Cargo.lock b/Cargo.lock index 7475ae235..500636dd6 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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall", +] + [[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" @@ -3445,7 +3571,7 @@ checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ "bytes", "libc", - "mio", + "mio 1.1.0", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -4400,6 +4526,8 @@ dependencies = [ "matches", "netns-rs", "nix 0.30.1", + "notify", + "notify-debouncer-full", "num_cpus", "oid-registry", "once_cell", @@ -4421,11 +4549,13 @@ 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", diff --git a/Cargo.toml b/Cargo.toml index 3c0122464..f4bcf7d1c 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" @@ -100,6 +102,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" @@ -156,6 +159,7 @@ oid-registry = "0.8" rcgen = { version = "0.14", features = ["pem", "x509-parser"] } x509-parser = { version = "0.17", default-features = false, features = ["verify"] } 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 8342caf69..f2dc33b2c 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", @@ -3503,6 +3640,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" @@ -3549,6 +3692,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" @@ -3573,13 +3725,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" @@ -3592,6 +3761,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" @@ -3604,6 +3779,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" @@ -3616,12 +3797,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" @@ -3634,6 +3827,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" @@ -3646,6 +3845,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" @@ -3658,6 +3863,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" @@ -3670,6 +3881,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" @@ -3888,6 +4105,8 @@ dependencies = [ "log", "netns-rs", "nix 0.30.1", + "notify", + "notify-debouncer-full", "num_cpus", "once_cell", "pin-project-lite", @@ -3906,6 +4125,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 afdbd5c2e..0c1b098f3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -78,6 +78,10 @@ const HTTP2_FRAME_SIZE: &str = "HTTP2_FRAME_SIZE"; const UNSTABLE_ENABLE_SOCKS5: &str = "UNSTABLE_ENABLE_SOCKS5"; +const ENABLE_CRL: &str = "ENABLE_CRL"; +const CRL_PATH: &str = "CRL_PATH"; +const ALLOW_EXPIRED_CRL: &str = "ALLOW_EXPIRED_CRL"; + const DEFAULT_WORKER_THREADS: u16 = 2; const DEFAULT_ADMIN_PORT: u16 = 15000; const DEFAULT_READINESS_PORT: u16 = 15021; @@ -108,6 +112,7 @@ const DEFAULT_ROOT_CERT_PROVIDER: &str = "./var/run/secrets/istio/root-cert.pem" const TOKEN_PROVIDER_ENV: &str = "AUTH_TOKEN"; const DEFAULT_TOKEN_PROVIDER: &str = "./var/run/secrets/tokens/istio-token"; const CERT_SYSTEM: &str = "SYSTEM"; +const DEFAULT_CRL_PATH: &str = "./var/run/secrets/istio/crl/ca-crl.pem"; const PROXY_MODE_DEDICATED: &str = "dedicated"; const PROXY_MODE_SHARED: &str = "shared"; @@ -311,6 +316,15 @@ pub struct Config { pub ztunnel_workload: Option, pub ipv6_enabled: bool, + + // Enable CRL (Certificate Revocation List) checking + pub enable_crl: bool, + + // Path to CRL file + pub crl_path: PathBuf, + + // Allow expired CRL (for testing/rollout scenarios) + pub allow_expired_crl: bool, } #[derive(serde::Serialize, Clone, Copy, Debug)] @@ -865,6 +879,10 @@ pub fn construct_config(pc: ProxyConfig) -> Result { ztunnel_identity, ztunnel_workload, ipv6_enabled, + + enable_crl: parse_default(ENABLE_CRL, false)?, + crl_path: parse_default(CRL_PATH, PathBuf::from(DEFAULT_CRL_PATH))?, + allow_expired_crl: parse_default(ALLOW_EXPIRED_CRL, false)?, }) } diff --git a/src/proxy.rs b/src/proxy.rs index 5b45d5582..3567466d6 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -120,6 +120,7 @@ impl DefaultSocketFactory { socket2::SockRef::from(&s).set_tcp_keepalive(&ka) ); } + #[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. @@ -262,6 +263,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)] @@ -275,6 +277,7 @@ impl ProxyInputs { resolver: Option>, local_workload_information: Arc, disable_inbound_freebind: bool, + crl_manager: Option>, ) -> Arc { Arc::new(Self { cfg, @@ -285,6 +288,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 51a41615a..cd0ef9b2d 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 e468ed7f6..a99fb2f67 100644 --- a/src/proxy/outbound.rs +++ b/src/proxy/outbound.rs @@ -803,6 +803,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 afedabf7c..abb1635bf 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,32 @@ impl ProxyFactory { } }; + // Initialize CRL manager ONCE if enabled + let crl_manager = if config.enable_crl { + match tls::crl::CrlManager::new(config.crl_path.clone(), config.allow_expired_crl) { + 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::error!("Failed to initialize CRL manager: {}", e); + None + } + } + } else { + None + }; + Ok(ProxyFactory { config, state, @@ -62,6 +90,7 @@ impl ProxyFactory { proxy_metrics, dns_metrics, drain, + crl_manager, }) } @@ -132,6 +161,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 +207,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/socket.rs b/src/socket.rs index b2c24f1a4..7da524196 100644 --- a/src/socket.rs +++ b/src/socket.rs @@ -180,6 +180,7 @@ impl Listener { SockRef::from(&stream).set_tcp_keepalive(&ka) ); } + #[cfg(target_os = "linux")] if cfg.user_timeout_enabled { let ut = cfg.keepalive_time + cfg.keepalive_retries * cfg.keepalive_interval; tracing::trace!( diff --git a/src/test_helpers/app.rs b/src/test_helpers/app.rs index ef3f625a5..3572d4577 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 4228748e8..1eebcadf0 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 a41bf0895..cd426f7ce 100644 --- a/src/tls/certificate.rs +++ b/src/tls/certificate.rs @@ -298,7 +298,10 @@ 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, }); @@ -308,8 +311,11 @@ impl WorkloadCertificate { ) .build()?; - let client_cert_verifier = - crate::tls::workload::TrustDomainVerifier::new(raw_client_cert_verifier, td); + let client_cert_verifier = crate::tls::workload::TrustDomainVerifier::new( + raw_client_cert_verifier, + td, + crl_manager, + ); let mut sc = ServerConfig::builder_with_provider(crate::tls::lib::provider()) .with_protocol_versions(tls::TLS_VERSIONS) .expect("server config must be valid") @@ -444,7 +450,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 000000000..2c8d4eef3 --- /dev/null +++ b/src/tls/crl.rs @@ -0,0 +1,483 @@ +// 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::CertificateDer; +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, SystemTime}; +use tracing::{debug, error, info, warn}; +use webpki::CertRevocationList; + +#[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 is expired")] + ExpiredCrl, + + #[error("failed to parse certificate: {0}")] + CertificateParseError(String), + + #[error("lock error: {0}")] + LockError(String), + + #[error("CRL error: {0}")] + WebPkiError(String), +} + +#[derive(Clone)] +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_list: Vec>, + crl_path: PathBuf, + allow_expired: bool, + last_load_time: Option, + _debouncer: Option>, +} + +impl CrlManager { + /// Create a new CRL manager + pub fn new(crl_path: PathBuf, allow_expired: bool) -> Result { + debug!( + "initializing CRL Manager: path={:?}, allow_expired={}", + crl_path, allow_expired + ); + + let manager = Self { + inner: Arc::new(RwLock::new(CrlManagerInner { + crl_list: Vec::new(), + crl_path: crl_path.clone(), + allow_expired, + last_load_time: None, + _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!( + "CRL file not found at {:?}, will retry on first validation", + crl_path + ); + } + _ => { + error!("failed to initialize CRL Manager: {}", e); + return Err(e); + } + } + } + + Ok(manager) + } + + pub fn load_crl(&self) -> Result { + let mut inner = self + .inner + .write() + .map_err(|e| CrlError::LockError(format!("failed to acquire write lock: {}", e)))?; + + debug!("loading CRL from {:?}", inner.crl_path); + let data = std::fs::read(&inner.crl_path)?; + + if data.is_empty() { + warn!("CRL file is empty at {:?}", inner.crl_path); + return Err(CrlError::ParseError("CRL file is empty".to_string())); + } + + debug!("read CRL file: {} bytes", data.len()); + + // Parse all CRL blocks (handles concatenated CRLs) + let der_crls = if data.starts_with(b"-----BEGIN") { + debug!("CRL is in PEM format, extracting all CRL blocks"); + Self::parse_pem_crls(&data)? + } else { + debug!("CRL is in DER format"); + // Single DER-encoded CRL + vec![data] + }; + + debug!("found {} CRL block(s) in file", der_crls.len()); + + let mut parsed_crls = Vec::new(); + let mut total_revoked = 0; + + for (idx, der_data) in der_crls.iter().enumerate() { + let owned_crl = webpki::OwnedCertRevocationList::from_der(der_data).map_err(|e| { + CrlError::WebPkiError(format!("failed to parse CRL {}: {:?}", idx + 1, e)) + })?; + + let crl = CertRevocationList::from(owned_crl); + + // use x509-parser for detail logging + use x509_parser::prelude::*; + if let Ok((_, crl_info)) = CertificateRevocationList::from_der(der_data) { + debug!("CRL {}:", idx + 1); + debug!(" Issuer: {}", crl_info.tbs_cert_list.issuer); + debug!(" this update: {:?}", crl_info.tbs_cert_list.this_update); + if let Some(next_update) = &crl_info.tbs_cert_list.next_update { + debug!(" next update: {:?}", next_update); + } + let revoked_count = crl_info.tbs_cert_list.revoked_certificates.len(); + debug!(" revoked certificates: {}", revoked_count); + total_revoked += revoked_count; + + // validate CRL expiration using x509-parser + // webpki doesn't expose next_update easily + Self::validate_crl(der_data, inner.allow_expired)?; + } else { + // if x509-parser fails, we still have webpki parsed CRL, but with fewer log details + debug!("CRL {}: parsed successfully", idx + 1); + } + + parsed_crls.push(crl); + } + + let has_new_revocations = parsed_crls.len() != inner.crl_list.len(); + + if has_new_revocations { + warn!( + "CRL file changed - reloaded with {} CRL(s)", + parsed_crls.len() + ); + } + + // store parsed CRL objects + inner.crl_list = parsed_crls; + inner.last_load_time = Some(SystemTime::now()); + + debug!( + "CRL loaded successfully ({} CRL(s), {} total revoked certificate(s))", + inner.crl_list.len(), + total_revoked + ); + Ok(has_new_revocations) + } + + /// Parse PEM-encoded CRL data that may contain multiple CRL blocks + /// Returns a Vec of DER-encoded CRLs + fn parse_pem_crls(pem_data: &[u8]) -> Result>, CrlError> { + let data_str = std::str::from_utf8(pem_data) + .map_err(|e| CrlError::ParseError(format!("Invalid UTF-8: {}", e)))?; + + let mut crls = Vec::new(); + let mut in_pem = false; + let mut base64_data = String::new(); + + for line in data_str.lines() { + if line.starts_with("-----BEGIN") { + in_pem = true; + base64_data.clear(); // Start new CRL block + continue; + } + if line.starts_with("-----END") { + if in_pem && !base64_data.is_empty() { + use base64::Engine; + let der = base64::engine::general_purpose::STANDARD + .decode(&base64_data) + .map_err(|e| { + CrlError::ParseError(format!("failed to decode base64: {}", e)) + })?; + crls.push(der); + base64_data.clear(); + } + in_pem = false; + continue; + } + if in_pem { + base64_data.push_str(line.trim()); + } + } + + if crls.is_empty() { + return Err(CrlError::ParseError( + "no valid CRL blocks found in PEM data".to_string(), + )); + } + + Ok(crls) + } + + /// Validate CRL + fn validate_crl(der_data: &[u8], allow_expired: bool) -> Result<(), CrlError> { + use x509_parser::prelude::*; + + // parse with x509-parser only for expiration validation + let (_, crl) = CertificateRevocationList::from_der(der_data) + .map_err(|e| CrlError::ParseError(format!("validation parse failed: {}", e)))?; + + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_err(|e| CrlError::ParseError(format!("system time error: {}", e)))?; + let unix_now = now.as_secs() as i64; + + // check thisUpdate (CRL issue time) + if unix_now < crl.tbs_cert_list.this_update.timestamp() { + warn!("CRL is not yet valid"); + } + + // check nextUpdate (CRL expiry) + if let Some(next_update) = &crl.tbs_cert_list.next_update + && unix_now > next_update.timestamp() + { + if !allow_expired { + return Err(CrlError::ExpiredCrl); + } + warn!("CRL is expired but allow_expired_crl is enabled"); + } + + Ok(()) + } + + /// Check if any certificate in the chain is revoked + pub fn is_revoked_chain( + &self, + end_entity: &CertificateDer, + intermediates: &[CertificateDer], + ) -> Result { + use x509_parser::prelude::*; + + debug!( + "checking certificate chain against CRL (chain length: {})", + 1 + intermediates.len() + ); + + // Log end-entity certificate serial + if let Ok((_, parsed)) = X509Certificate::from_der(end_entity) { + debug!(" end-entity serial: {:?}", parsed.serial.to_bytes_be()); + } + + // Log intermediate certificate serials + for (idx, intermediate) in intermediates.iter().enumerate() { + if let Ok((_, parsed)) = X509Certificate::from_der(intermediate) { + debug!( + " intermediate {} serial: {:?}", + idx, + parsed.serial.to_bytes_be() + ); + } + } + + debug!("checking leaf certificate"); + if self.is_cert_revoked(end_entity)? { + warn!("leaf certificate is REVOKED"); + return Ok(true); + } + + // check all intermediate certificates + for (idx, intermediate) in intermediates.iter().enumerate() { + debug!("checking intermediate certificate {} in chain", idx); + if self.is_cert_revoked(intermediate)? { + warn!("intermediate CA certificate at position {} is REVOKED", idx); + return Ok(true); + } + } + + debug!("certificate chain validation passed - no revoked certificates found"); + Ok(false) + } + + /// Internal method to check if a single certificate is revoked + /// Checks the certificate against ALL loaded CRLs using rustls-webpki + fn is_cert_revoked(&self, cert: &CertificateDer) -> Result { + let inner = self + .inner + .read() + .map_err(|e| CrlError::LockError(format!("failed to acquire read lock: {}", e)))?; + + // if no CRLs are loaded, try to load them now + if inner.crl_list.is_empty() { + drop(inner); + debug!("CRL not loaded, attempting to load now"); + self.load_crl()?; + return self.is_cert_revoked(cert); + } + + // extract certificate serial number using x509-parser + // webpki's Cert::from_der is not public, so we need x509-parser for this + use x509_parser::prelude::*; + let (_, parsed_cert) = X509Certificate::from_der(cert) + .map_err(|e| CrlError::CertificateParseError(e.to_string()))?; + + let cert_serial = parsed_cert.serial.to_bytes_be(); + debug!("certificate serial number: {:?}", cert_serial); + + // check the certificate against ALL CRLs + for (idx, crl) in inner.crl_list.iter().enumerate() { + debug!("checking against CRL {}", idx + 1); + + match crl.find_serial(&cert_serial) { + Ok(Some(revoked_cert)) => { + warn!( + "certificate with serial {:?} is REVOKED in CRL {}", + cert_serial, + idx + 1 + ); + warn!("revocation date: {:?}", revoked_cert.revocation_date); + if let Some(reason) = revoked_cert.reason_code { + warn!("revocation reason: {:?}", reason); + } + return Ok(true); + } + Ok(None) => { + // certificate isn't found in this CRL, continue checking others + continue; + } + Err(e) => { + // error parsing revoked certificates in this CRL + error!("error checking CRL {}: {:?}", idx + 1, e); + return Err(CrlError::WebPkiError(format!("CRL lookup failed: {:?}", e))); + } + } + } + + debug!( + "certificate serial {:?} is not in any of the {} CRL(s)", + cert_serial, + inner.crl_list.len() + ); + Ok(false) + } + + /// Start 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() + .map_err(|e| CrlError::LockError(format!("failed to acquire read lock: {}", e)))?; + 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!( + "starting CRL file watcher (debounced) for directory: {:?}", + watch_path + ); + debug!(" debounce timeout: 2 seconds"); + debug!(" watching for: Kubernetes ConfigMaps, direct writes, text editor saves"); + + 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() { + // log all events for debugging + debug!("CRL directory events: {} event(s) detected", events.len()); + for event in events.iter() { + debug!( + " Event: kind={:?}, paths={:?}", + event.event.kind, event.event.paths + ); + } + + // 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(has_new_revocations) => { + debug!("CRL reloaded successfully after file change"); + if has_new_revocations { + info!("New revocation detected"); + } + } + Err(e) => error!("failed to reload CRL: {}", e), + } + } + } + Err(errors) => { + for error in errors { + error!("CRL watcher error: {:?}", 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() + .map_err(|e| CrlError::LockError(format!("failed to acquire write lock: {}", e)))?; + 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"), false); + 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(), false); + assert!(result.is_err(), "should fail on invalid CRL data"); + } +} diff --git a/src/tls/workload.rs b/src/tls/workload.rs index 92e05d40c..1bb14c935 100644 --- a/src/tls/workload.rs +++ b/src/tls/workload.rs @@ -35,9 +35,10 @@ use tokio::io::{AsyncRead, AsyncWrite}; use crate::strng::Strng; use crate::tls; +use crate::tls::crl::CrlManager; use tokio::net::TcpStream; use tokio_rustls::client; -use tracing::{debug, trace}; +use tracing::{debug, error, trace}; #[derive(Clone, Debug)] pub struct InboundAcceptor { @@ -54,11 +55,20 @@ impl InboundAcceptor { pub(super) struct TrustDomainVerifier { base: Arc, trust_domain: Option, + crl_manager: Option>, } impl TrustDomainVerifier { - pub fn new(base: Arc, trust_domain: Option) -> Arc { - Arc::new(Self { base, trust_domain }) + pub fn new( + base: Arc, + trust_domain: Option, + crl_manager: Option>, + ) -> Arc { + Arc::new(Self { + base, + trust_domain, + crl_manager, + }) } fn verify_trust_domain(&self, client_cert: &CertificateDer<'_>) -> Result<(), rustls::Error> { @@ -112,6 +122,29 @@ impl ClientCertVerifier for TrustDomainVerifier { .base .verify_client_cert(end_entity, intermediates, now)?; self.verify_trust_domain(end_entity)?; + + // Check CRL if enabled + if let Some(crl_manager) = &self.crl_manager { + debug!("CRL checking enabled for client certificate"); + let is_revoked = crl_manager + .is_revoked_chain(end_entity, intermediates) + .map_err(|e| { + error!("CRL validation failed for client certificate: {}", e); + rustls::Error::General(format!("Certificate revocation check failed: {}", e)) + })?; + + if is_revoked { + error!("Client certificate is REVOKED - rejecting connection"); + return Err(rustls::Error::InvalidCertificate( + rustls::CertificateError::Revoked, + )); + } + + debug!("Client certificate chain is valid (not revoked)"); + } else { + debug!("CRL checking disabled for client certificate"); + } + Ok(res) }