diff --git a/Cargo.lock b/Cargo.lock index e197bd30..924553b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,9 +20,9 @@ dependencies = [ [[package]] name = "aes" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1" dependencies = [ "cipher", "cpubits", @@ -149,7 +149,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", ] @@ -196,7 +196,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -222,9 +222,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.18.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +checksum = "048006790404103c488e6c5616b3e7e702a71292aadda08e8f7698a9e0d2eb53" dependencies = [ "aws-lc-sys", "zeroize", @@ -232,9 +232,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.45.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +checksum = "8bfbe32ac36b3c428696ce315464181bc4029d689fd58e3f2a4e58c252242f52" dependencies = [ "cc", "cmake", @@ -419,25 +419,25 @@ dependencies = [ [[package]] name = "bon" -version = "3.10.1" +version = "3.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60eafe0d77c3a2fc292c1d1346c3041b33c0a108085a2afabf672b70f69dbbc9" +checksum = "a05442cd7005532d0cfedec676b5b9333d2b4c7c3afabe2b1a9ce2742fbe2ee6" dependencies = [ "bon-macros", ] [[package]] name = "bon-macros" -version = "3.10.1" +version = "3.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0f9631d8aaaee112c41985d675ef269e02acbd4f33122836af4f0c5f699ff6" +checksum = "93833e97ac7c46ba97045afcf247238c27057538fcf2f06344662ddd5f567be6" dependencies = [ "darling", "ident_case", "prettyplease 0.3.0", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -478,9 +478,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.6" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" dependencies = [ "find-msvc-tools", "jobserver", @@ -613,7 +613,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -728,9 +728,9 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" dependencies = [ "cfg-if", ] @@ -768,6 +768,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "crypto-bigint" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -791,9 +807,9 @@ dependencies = [ [[package]] name = "ctap-hid-fido2" -version = "3.6.0" +version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73e2cee48715b2a8fae3739aeb8433817051a6772562ba67f9b631fc833eb793" +checksum = "fe0d3f8b069ea5c3da6d9d0d483a39cd09df25457aff86033ae8f09661be0396" dependencies = [ "aes", "anyhow", @@ -813,11 +829,12 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.5" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" +checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -884,7 +901,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -895,7 +912,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -928,8 +945,9 @@ dependencies = [ "rustls-pki-types", "rustls-webpki", "serde", + "serde_json", "syslog", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", "toml", @@ -945,9 +963,9 @@ dependencies = [ [[package]] name = "defguard_boringtun" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a575302d61f1019ba326452a03ca7df013187e5966214e244fdab4900127ca3" +checksum = "5fa2a70b14430feb68aa2ffe2be259650c0eb1c182ee49c9c7edeae3a0a07cc6" dependencies = [ "aead", "base64 0.23.1", @@ -959,11 +977,11 @@ dependencies = [ "ip_network_table", "libc", "nix", - "parking_lot", - "rand 0.10.2", + "parking_lot 0.13.0", + "rand 0.10.3", "ring", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "x25519-dalek", ] @@ -977,7 +995,7 @@ dependencies = [ "chrono", "rcgen", "rustls-pki-types", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "x509-parser 0.18.1", ] @@ -1010,10 +1028,10 @@ dependencies = [ "serde", "serde_cbor_2", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "sqlx", "struct-patch", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "totp-lite", "tracing", @@ -1033,7 +1051,7 @@ dependencies = [ "http", "hyper-rustls", "rustls", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tonic", "tower-service", @@ -1051,7 +1069,7 @@ dependencies = [ "os_info", "semver", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "tonic", "tower", "tracing", @@ -1060,9 +1078,9 @@ dependencies = [ [[package]] name = "defguard_wireguard_rs" -version = "0.12.1" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6200ebad7ba02fc3272f9bd3883ef1aa187b95f0205491c1685b4168b22a3b8" +checksum = "8c2a381d2f363f68431e9ff7f90304a98752321e048c2264c8d205af6d465e5c" dependencies = [ "base64 0.23.1", "defguard_boringtun", @@ -1076,7 +1094,7 @@ dependencies = [ "netlink-packet-wireguard", "netlink-sys", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "windows", "wireguard-nt", "x25519-dalek", @@ -1084,9 +1102,9 @@ dependencies = [ [[package]] name = "defmt" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" dependencies = [ "bitflags 1.3.2", "defmt-macros", @@ -1094,23 +1112,23 @@ dependencies = [ [[package]] name = "defmt-macros" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "defmt-parser" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -1120,7 +1138,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", - "pem-rfc7468", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] @@ -1154,9 +1183,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" dependencies = [ "serde_core", ] @@ -1203,7 +1232,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1230,12 +1259,27 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest 0.10.7", - "elliptic-curve", - "rfc6979", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", "signature 2.2.0", - "spki", + "spki 0.7.3", +] + +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der 0.8.3", + "digest 0.11.3", + "elliptic-curve 0.14.3", + "rfc6979 0.6.0", + "signature 3.0.0", + "spki 0.8.1", + "zeroize", ] [[package]] @@ -1244,7 +1288,7 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", + "pkcs8 0.10.2", "signature 2.2.0", ] @@ -1254,6 +1298,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ + "pkcs8 0.11.0", "serdect", "signature 3.0.0", ] @@ -1282,7 +1327,7 @@ dependencies = [ "ed25519 3.0.0", "rand_core 0.10.1", "serde", - "sha2 0.11.0", + "sha2 0.11.1", "signature 3.0.0", "subtle", "zeroize", @@ -1290,9 +1335,9 @@ dependencies = [ [[package]] name = "either" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" dependencies = [ "serde", ] @@ -1304,29 +1349,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct 0.2.0", - "crypto-bigint", + "crypto-bigint 0.5.5", "digest 0.10.7", - "ff", + "ff 0.13.1", "generic-array", - "group", + "group 0.13.0", "hkdf 0.12.4", - "pem-rfc7468", - "pkcs8", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", "rand_core 0.6.4", - "sec1", + "sec1 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c" +dependencies = [ + "base16ct 1.0.0", + "crypto-bigint 0.7.10", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", + "pem-rfc7468 1.0.0", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sec1 0.8.1", "subtle", "zeroize", ] [[package]] name = "encoding_rs" -version = "0.8.41" +version = "0.8.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5" dependencies = [ "cfg-if", "core_detect", - "multiversion", "multiversion_no_op", "rustversion", "scopeguard", @@ -1358,9 +1423,9 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -1408,6 +1473,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1422,9 +1497,9 @@ checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" [[package]] name = "find-msvc-tools" -version = "0.1.12" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "fixedbitset" @@ -1536,8 +1611,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" dependencies = [ "futures-core", - "lock_api", - "parking_lot", + "lock_api 0.4.14", + "parking_lot 0.12.5", ] [[package]] @@ -1616,9 +1691,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1639,16 +1716,27 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff", + "ff 0.13.1", "rand_core 0.6.4", "subtle", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "h2" -version = "0.4.19" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" dependencies = [ "atomic-waker", "bytes", @@ -1844,14 +1932,16 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ + "subtle", "typenum", + "zeroize", ] [[package]] name = "hyper" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", @@ -1871,9 +1961,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -1917,16 +2007,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -1966,9 +2057,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", @@ -1980,9 +2071,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", @@ -1993,9 +2084,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2007,15 +2098,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ "displaydoc", "icu_collections", @@ -2028,15 +2119,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", @@ -2099,9 +2190,9 @@ dependencies = [ [[package]] name = "inout" -version = "0.2.2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" dependencies = [ "block-padding", "hybrid-array", @@ -2150,15 +2241,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.14.0" @@ -2176,9 +2258,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.37" +version = "0.2.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +checksum = "3ac703058abac89e5c39b0b879dce2b6b14a4ba36600c7e7e3d0c60a6883ef3c" dependencies = [ "defmt", "jiff-core", @@ -2193,9 +2275,9 @@ dependencies = [ [[package]] name = "jiff-core" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +checksum = "aee43309eb53bafb8c36d35d6e444769c8f8e6206a9fbf7546f5db9d7d4a0e93" dependencies = [ "defmt", "log", @@ -2203,9 +2285,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.37" +version = "0.2.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +checksum = "6dc32470c67eb7d0e9ccb74303fa925313dc04cecb1aa048beaa6f391e36a93d" dependencies = [ "jiff-core", "proc-macro2", @@ -2215,9 +2297,9 @@ dependencies = [ [[package]] name = "jiff-tzdb" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" [[package]] name = "jiff-tzdb-platform" @@ -2240,9 +2322,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.105" +version = "0.3.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" dependencies = [ "cfg-if", "futures-util", @@ -2260,8 +2342,8 @@ dependencies = [ "getrandom 0.2.17", "hmac 0.12.1", "js-sys", - "p256", - "p384", + "p256 0.13.2", + "p384 0.13.1", "pem 3.0.6", "rand 0.8.8", "rsa", @@ -2275,18 +2357,18 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" dependencies = [ "spin", ] [[package]] name = "libc" -version = "0.2.189" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libgit2-sys" @@ -2328,9 +2410,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.29" +version = "1.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +checksum = "ff20308ecabd635a2dc1dd5834a86c462cca20efba1d5d3ddab1f8aea3455cb2" dependencies = [ "cc", "libc", @@ -2359,11 +2441,20 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lock_api" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65d91b22da179613dcf4d6ec8349563f52a363f6849e03b00dd0a185ec4a59af" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" -version = "0.4.34" +version = "0.4.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" [[package]] name = "matchers" @@ -2420,9 +2511,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", @@ -2457,7 +2548,7 @@ source = "git+https://github.com/DefGuard/defguard.git?rev=d80302d6841e107e09825 dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2466,27 +2557,6 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" -[[package]] -name = "multiversion" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" -dependencies = [ - "multiversion-macros", -] - -[[package]] -name = "multiversion-macros" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" -dependencies = [ - "proc-macro2", - "quote", - "rustversion", - "syn 3.0.6", -] - [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -2528,9 +2598,9 @@ dependencies = [ [[package]] name = "netlink-packet-route" -version = "0.33.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59d48ffc8b73a506e1ff0878528c72668f2bfbc3d875b6be890a96be5d0d5576" +checksum = "d3c407d8e95c0128ee5729728482a369f3ba9a0d9ba8335c20949a8d8989cf6b" dependencies = [ "bitflags 2.13.2", "libc", @@ -2547,14 +2617,14 @@ checksum = "3176f18d11a1ae46053e59ec89d46ba318ae1343615bd3f8c908bfc84edae35c" dependencies = [ "byteorder", "pastey", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "netlink-packet-wireguard" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1586e7d665d51e10a6e67eaf433beeea784c8dcb96d89c7b9b99444de4221d16" +checksum = "34d055764275f14746b846c11314ec2ec35cde7be77f50b1362f02b84e250d00" dependencies = [ "bitflags 2.13.2", "libc", @@ -2726,9 +2796,9 @@ dependencies = [ [[package]] name = "num_threads" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" dependencies = [ "libc", ] @@ -2755,9 +2825,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -2944,21 +3014,22 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openidconnect" -version = "4.0.1" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" +checksum = "df0d098a59353679624d406825940139e2b2d554a52b64c9fb1b1bc42a11fe5c" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "chrono", "dyn-clone", - "ed25519-dalek 2.2.0", + "ed25519-dalek 3.0.0", + "getrandom 0.4.3", "hmac 0.12.1", "http", - "itertools 0.10.5", + "itertools", "log", "oauth2", - "p256", - "p384", + "p256 0.14.0", + "p384 0.14.0", "rand 0.8.8", "rsa", "serde", @@ -2975,9 +3046,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.81" +version = "0.10.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +checksum = "4de8111e94a56adca7ebfe6c365b6d42f99deb69b74b6becacd4204e2f193aa2" dependencies = [ "bitflags 2.13.2", "cfg-if", @@ -3000,15 +3071,15 @@ dependencies = [ [[package]] name = "openssl-probe" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" [[package]] name = "openssl-sys" -version = "0.9.117" +version = "0.9.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +checksum = "19c413d6ec9ebaf5e1c829d355cb3e9f783778d700f135be0921d52fce3c60ae" dependencies = [ "cc", "libc", @@ -3047,24 +3118,51 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", "sha2 0.10.9", ] +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa 0.17.0", + "elliptic-curve 0.14.3", + "primefield", + "primeorder 0.14.0", + "sha2 0.11.1", +] + [[package]] name = "p384" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", "sha2 0.10.9", ] +[[package]] +name = "p384" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" +dependencies = [ + "ecdsa 0.17.0", + "elliptic-curve 0.14.3", + "fiat-crypto 0.3.0", + "primefield", + "primeorder 0.14.0", + "sha2 0.11.1", +] + [[package]] name = "pad" version = "0.1.6" @@ -3086,8 +3184,18 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ - "lock_api", - "parking_lot_core", + "lock_api 0.4.14", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9cf937ca4e1d0b7578784559063422fc39ae7c5a00f02471f2614d5486fd226" +dependencies = [ + "lock_api 0.5.0", + "parking_lot_core 0.10.0", ] [[package]] @@ -3098,7 +3206,19 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "parking_lot_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cfff060068cc18aa7815010fcaf80c68c7fb9e85fc7f5c0f9bf142c21d19826" +dependencies = [ + "libc", + "redox_syscall 0.9.5", "smallvec", "windows-link", ] @@ -3148,6 +3268,15 @@ dependencies = [ "base64ct", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3208,9 +3337,9 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der", - "pkcs8", - "spki", + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", ] [[package]] @@ -3219,8 +3348,18 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.3", + "spki 0.8.1", ] [[package]] @@ -3265,9 +3404,9 @@ dependencies = [ [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "ppv-lite86" @@ -3295,7 +3434,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" dependencies = [ "proc-macro2", - "syn 3.0.6", + "syn 3.0.9", +] + +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint 0.7.10", + "crypto-common 0.2.2", + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", + "zeroize", ] [[package]] @@ -3304,7 +3457,20 @@ version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "elliptic-curve", + "elliptic-curve 0.13.8", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve 0.14.3", + "once_cell", + "primefield", + "serdect", + "wnaf", ] [[package]] @@ -3333,7 +3499,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck", - "itertools 0.14.0", + "itertools", "log", "multimap", "petgraph", @@ -3354,7 +3520,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools", "proc-macro2", "quote", "syn 2.0.119", @@ -3371,9 +3537,9 @@ dependencies = [ [[package]] name = "pulldown-cmark" -version = "0.13.4" +version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +checksum = "3db2be46835f013dd3f4fa7f5f7402c537787b54ebb6fe2b94bbdd7e2669bb7b" dependencies = [ "bitflags 2.13.2", "memchr", @@ -3382,9 +3548,9 @@ dependencies = [ [[package]] name = "pulldown-cmark-to-cmark" -version = "22.0.1" +version = "22.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60" +checksum = "84bbb29c624230c4bd1047bbdb2aa47e41c860e9665ce62ba9504eebe91bf867" dependencies = [ "pulldown-cmark", ] @@ -3433,9 +3599,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -3509,6 +3675,15 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "redox_syscall" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "ref-cast" version = "1.0.27" @@ -3526,7 +3701,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3608,6 +3783,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint 0.7.10", + "hmac 0.13.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -3634,10 +3819,10 @@ dependencies = [ "num-integer", "num-traits", "pkcs1", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", "signature 2.2.0", - "spki", + "spki 0.7.3", "subtle", "zeroize", ] @@ -3662,9 +3847,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags 2.13.2", "errno", @@ -3691,9 +3876,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -3736,9 +3921,9 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "schannel" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" dependencies = [ "windows-sys 0.61.2", ] @@ -3757,9 +3942,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" dependencies = [ "dyn-clone", "ref-cast", @@ -3780,9 +3965,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct 0.2.0", - "der", + "der 0.7.10", "generic-array", - "pkcs8", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct 1.0.0", + "ctutils", + "der 0.8.3", + "hybrid-array", "subtle", "zeroize", ] @@ -3877,14 +4076,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -3915,9 +4114,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -3936,9 +4135,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" dependencies = [ "base64 0.23.1", "bs58", @@ -3948,7 +4147,7 @@ dependencies = [ "indexmap 2.14.2", "jiff", "schemars 0.9.0", - "schemars 1.2.2", + "schemars 1.2.3", "serde_core", "serde_json", "serde_with_macros", @@ -3957,14 +4156,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" dependencies = [ "darling", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4012,9 +4211,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", @@ -4062,6 +4261,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ + "digest 0.11.3", "rand_core 0.10.1", ] @@ -4085,7 +4285,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", ] @@ -4097,9 +4297,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.1" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" dependencies = [ "serde", ] @@ -4120,7 +4320,7 @@ version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ - "lock_api", + "lock_api 0.4.14", ] [[package]] @@ -4130,7 +4330,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der 0.8.3", ] [[package]] @@ -4176,7 +4386,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", "tracing", @@ -4218,7 +4428,7 @@ dependencies = [ "sqlx-postgres", "sqlx-sqlite", "syn 2.0.119", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "url", ] @@ -4244,9 +4454,9 @@ dependencies = [ "percent-encoding", "serde", "sha1 0.11.0", - "sha2 0.11.0", + "sha2 0.11.1", "sqlx-core", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "uuid", ] @@ -4276,14 +4486,14 @@ dependencies = [ "log", "md-5", "memchr", - "rand 0.10.2", + "rand 0.10.3", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "uuid", "whoami", @@ -4309,7 +4519,7 @@ dependencies = [ "percent-encoding", "serde", "sqlx-core", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "url", "uuid", @@ -4395,9 +4605,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -4432,7 +4642,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4475,7 +4685,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -4492,11 +4702,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -4512,13 +4722,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4564,9 +4774,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", "zerovec", @@ -4580,14 +4790,14 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.1" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", "mio", - "parking_lot", + "parking_lot 0.12.5", "pin-project-lite", "signal-hook-registry", "socket2", @@ -4603,7 +4813,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4618,9 +4828,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.5" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -4639,9 +4849,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.19" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ "bytes", "futures-core", @@ -4653,9 +4863,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.6+spec-1.1.0" +version = "1.2.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +checksum = "c5ffcdf8f50436dbbfd675c1fe1b8e0854f42b7defea8584bdebe1aee900e561" dependencies = [ "serde_core", "serde_spanned", @@ -4666,27 +4876,27 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow", ] [[package]] name = "tonic" -version = "0.14.6" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +checksum = "e2cdfc673aabd30fe8fee92e65837b644c37c5d47f0f97ae6d5e0f1237d15e1b" dependencies = [ "async-trait", "axum", @@ -4708,6 +4918,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-stream", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -4716,9 +4927,9 @@ dependencies = [ [[package]] name = "tonic-build" -version = "0.14.6" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +checksum = "ac6d916d206ed7bdda1778ddff9bda43303789c258877e7bc22fb44ee5c984b4" dependencies = [ "prettyplease 0.2.37", "proc-macro2", @@ -4728,9 +4939,9 @@ dependencies = [ [[package]] name = "tonic-prost" -version = "0.14.6" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +checksum = "eba464054e2226f182a34489b8b079bf4c6e3e7ab5fd7fc30e024f24aa92a484" dependencies = [ "bytes", "prost", @@ -4739,9 +4950,9 @@ dependencies = [ [[package]] name = "tonic-prost-build" -version = "0.14.6" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +checksum = "2ea216ddc448e08e7839ab09e20e60b06b6a1c32745ccb212c0ebd161c199f1b" dependencies = [ "prettyplease 0.2.37", "proc-macro2", @@ -4890,9 +5101,9 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicase" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" [[package]] name = "unicode-bidi" @@ -4902,24 +5113,24 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" dependencies = [ "tinyvec", ] [[package]] name = "unicode-properties" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +checksum = "993ad287eb20e8ba755312801cfe1413e0f25d604075863acaddca10b04c1833" [[package]] name = "unicode-width" @@ -4958,9 +5169,12 @@ dependencies = [ [[package]] name = "utf8_iter" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] [[package]] name = "utf8parse" @@ -4968,11 +5182,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + [[package]] name = "uuid" -version = "1.26.1" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -5038,9 +5258,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -5062,12 +5282,11 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" dependencies = [ "cfg-if", - "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared", @@ -5075,19 +5294,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.78" +version = "0.4.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5095,31 +5315,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.105" +version = "0.3.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" dependencies = [ "js-sys", "wasm-bindgen", @@ -5419,9 +5639,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" [[package]] name = "wireguard-nt" @@ -5445,6 +5665,18 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", + "primefield", +] + [[package]] name = "writeable" version = "0.6.4" @@ -5494,7 +5726,7 @@ dependencies = [ "oid-registry 0.8.1", "ring", "rusticata-macros", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", ] @@ -5510,9 +5742,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -5527,24 +5759,24 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure 0.14.0", ] [[package]] name = "zerocopy" -version = "0.8.57" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.57" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -5568,15 +5800,15 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure 0.14.0", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" dependencies = [ "zeroize_derive", ] @@ -5605,9 +5837,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ "yoke", "zerofrom", @@ -5616,13 +5848,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3871fbde..d75e2272 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,9 +23,10 @@ log = "0.4" prost = "0.14" prost-types = "0.14" serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" syslog = "7.0" thiserror = "2.0" -tokio = { version = "1", features = ["fs", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] } +tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } tokio-stream = { version = "0.1", features = [] } toml = { version = "1.0", default-features = false, features = [ "parse", diff --git a/build.rs b/build.rs index ddd98b1e..3e3de333 100644 --- a/build.rs +++ b/build.rs @@ -20,6 +20,7 @@ fn main() -> Result<(), Box> { &[ "proto/v2/gateway.proto", "proto/enterprise/v2/firewall/firewall.proto", + "proto/enterprise/v2/monitor/monitor.proto", ], &["proto"], )?; diff --git a/example-config.toml b/example-config.toml index 8864e501..a8edfada 100644 --- a/example-config.toml +++ b/example-config.toml @@ -51,3 +51,9 @@ syslog_socket = "/var/run/log" # Optional: Clean the network interface and VPN configuration on quit. #clean_on_quit = true + +# Start connection monitoring (Linux, FreeBSD and macOS). +#connection_monitor = true + +# pflog interface for connection monitoring (FreeBSD and macOS); created if missing. +#pflog_ifname = "pflog0" diff --git a/proto b/proto index 0b8e6736..6e435ae8 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit 0b8e6736171ea06b810a82365add477d78119976 +Subproject commit 6e435ae825003794bab90c9375f45ff09e28b2da diff --git a/src/bin/defguard-gateway.rs b/src/bin/defguard-gateway.rs index 59887f6d..dde686b2 100644 --- a/src/bin/defguard-gateway.rs +++ b/src/bin/defguard-gateway.rs @@ -17,7 +17,7 @@ use defguard_gateway::{ config::get_config, error::GatewayError, execute_command, - gateway::{Gateway, TlsConfig, run_gateway_loop, run_stats}, + gateway::{Gateway, TlsConfig, run_connection_events, run_gateway_loop, run_stats}, init_syslog, logging::init_tracing, server::run_http_server, @@ -177,6 +177,11 @@ async fn main() -> Result<(), GatewayError> { // Launch statistics gathering task. let gateway = Arc::new(Mutex::new(gateway)); tasks.spawn(run_stats(Arc::clone(&gateway), config.stats_period())); + // Launch task sending connection monitoring events to Core. + tasks.spawn(run_connection_events( + Arc::clone(&gateway), + config.stats_period(), + )); let (shutdown_tx, shutdown_rx) = watch::channel(false); let mut gateway_task = tokio::spawn(run_gateway_loop( diff --git a/src/config.rs b/src/config.rs index 0f621699..0429e924 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,8 +1,9 @@ use std::{ - fs, + fmt, fs, net::{IpAddr, Ipv4Addr, SocketAddr}, num::NonZeroU64, path::PathBuf, + str::FromStr, time::Duration, }; @@ -205,6 +206,16 @@ pub struct Config { #[arg(long, env = "DEFGUARD_CLEAN_ON_QUIT")] #[serde(default)] pub clean_on_quit: bool, + + /// Enables connection monitoring (Linux, FreeBSD and macOS). + #[arg(long, env = "DEFGUARD_CONNECTION_MONITOR")] + #[serde(default)] + pub connection_monitor: bool, + + /// pflog interface for connection monitoring (FreeBSD and macOS); created if missing. + #[arg(long, env = "DEFGUARD_PFLOG_IFNAME", default_value = "pflog0")] + #[serde(default)] + pub pflog_ifname: PflogIfname, } impl Config { @@ -263,6 +274,8 @@ impl Default for Config { cert_dir: PathBuf::from("/etc/defguard/certs"), adoption_timeout: 5, clean_on_quit: false, + connection_monitor: false, + pflog_ifname: PflogIfname::default(), } } } @@ -285,6 +298,39 @@ pub fn get_config() -> Result { Ok(cli_config) } +/// pflog interface name, `pflog`. +#[derive(Clone, Copy, Debug, Default, Deserialize)] +#[serde(try_from = "String")] +pub struct PflogIfname(pub u8); + +impl FromStr for PflogIfname { + type Err = String; + + fn from_str(name: &str) -> Result { + name.strip_prefix("pflog") + .and_then(|unit| unit.parse().ok()) + .map(Self) + .filter(|ifname| ifname.to_string() == name) + .ok_or_else(|| { + format!("invalid pflog interface \"{name}\": expected pflog0 to pflog255") + }) + } +} + +impl TryFrom for PflogIfname { + type Error = String; + + fn try_from(name: String) -> Result { + name.parse() + } +} + +impl fmt::Display for PflogIfname { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "pflog{}", self.0) + } +} + fn validate_ifname(ifname: &str) -> Result<(), GatewayError> { if ifname.len() > 15 { return Err(GatewayError::InvalidConfigFile(format!( @@ -295,6 +341,19 @@ fn validate_ifname(ifname: &str) -> Result<(), GatewayError> { Ok(()) } +#[test] +fn test_pflog_ifname() { + let parse = |name: &str| name.parse::().ok().map(|ifname| ifname.0); + assert_eq!(parse("pflog0"), Some(0)); + assert_eq!(parse("pflog12"), Some(12)); + assert_eq!(parse("pflog255"), Some(255)); + assert_eq!(parse("pflog256"), None); + assert_eq!(parse("pflog"), None); + assert_eq!(parse("pflog01"), None); + assert_eq!(parse("pflog+1"), None); + assert_eq!(parse("wg0"), None); +} + #[test] fn verify_cli() { use clap::CommandFactory; diff --git a/src/enterprise/firewall/api.rs b/src/enterprise/firewall/api.rs index 777d6dbb..8f7d5271 100644 --- a/src/enterprise/firewall/api.rs +++ b/src/enterprise/firewall/api.rs @@ -19,8 +19,10 @@ pub(crate) struct FirewallApi { pub(crate) ifname: String, #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] pub(crate) file: File, - #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] pub(crate) default_policy: Policy, + /// pflog unit to log to; `None` disables logging. + #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] + pub(crate) log_interface: Option, #[cfg(target_os = "linux")] pub(crate) socket: mnl::Socket, } @@ -34,8 +36,9 @@ impl FirewallApi { ifname: ifname.into(), #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] file: OpenOptions::new().read(true).write(true).open(DEV_PF)?, - #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] default_policy: Policy::Deny, + #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] + log_interface: None, #[cfg(target_os = "linux")] socket: mnl::Socket::new(mnl::Bus::Netfilter).map_err(|err| { FirewallError::NetlinkError(format!("Failed to create socket: {err:?}")) diff --git a/src/enterprise/firewall/flow.rs b/src/enterprise/firewall/flow.rs new file mode 100644 index 00000000..3e4d23d0 --- /dev/null +++ b/src/enterprise/firewall/flow.rs @@ -0,0 +1,143 @@ +//! Identification of connections from raw packets, shared by firewall connection monitors. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +// IANA protocol numbers not exposed by `libc`. +const IPPROTO_DCCP: u8 = 33; +const IPPROTO_SCTP: u8 = 132; +const IPPROTO_UDPLITE: u8 = 136; + +/// Uniquely identifies a connection, as seen in its original direction. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) struct FlowKey { + pub(crate) protocol: u8, + pub(crate) source: IpAddr, + pub(crate) destination: IpAddr, + /// Source port, or ICMP identifier. + pub(crate) source_port: u16, + pub(crate) destination_port: u16, +} + +pub(crate) fn protocol_has_ports(protocol: u8) -> bool { + matches!(i32::from(protocol), libc::IPPROTO_TCP | libc::IPPROTO_UDP) + || matches!(protocol, IPPROTO_DCCP | IPPROTO_SCTP | IPPROTO_UDPLITE) +} + +pub(crate) fn be_u16(data: &[u8]) -> Option { + Some(u16::from_be_bytes(data.get(..2)?.try_into().ok()?)) +} + +pub(crate) fn ipv4(data: &[u8]) -> Option { + Some(IpAddr::V4(Ipv4Addr::from( + <[u8; 4]>::try_from(data.get(..4)?).ok()?, + ))) +} + +pub(crate) fn ipv6(data: &[u8]) -> Option { + Some(IpAddr::V6(Ipv6Addr::from( + <[u8; 16]>::try_from(data.get(..16)?).ok()?, + ))) +} + +/// Extract connection details from a packet, starting at the network header. +pub(crate) fn parse_packet(packet: &[u8]) -> Option { + let (protocol, source, destination, transport) = match packet.first()? >> 4 { + 4 => { + let header_len = usize::from(packet[0] & 0x0f) * 4; + ( + *packet.get(9)?, + ipv4(packet.get(12..)?)?, + ipv4(packet.get(16..)?)?, + packet.get(header_len..)?, + ) + } + 6 => { + let mut next_header = *packet.get(6)?; + let mut offset = 40; + loop { + match i32::from(next_header) { + libc::IPPROTO_HOPOPTS | libc::IPPROTO_ROUTING | libc::IPPROTO_DSTOPTS => { + let header = packet.get(offset..offset + 2)?; + next_header = header[0]; + offset += (usize::from(header[1]) + 1) * 8; + } + libc::IPPROTO_FRAGMENT => { + next_header = *packet.get(offset)?; + offset += 8; + } + _ => break, + } + } + ( + next_header, + ipv6(packet.get(8..)?)?, + ipv6(packet.get(24..)?)?, + packet.get(offset..)?, + ) + } + _ => return None, + }; + + // Match the way conntrack identifies connections. + let (source_port, destination_port) = match i32::from(protocol) { + libc::IPPROTO_ICMP | libc::IPPROTO_ICMPV6 => (be_u16(transport.get(4..)?)?, 0), + _ if protocol_has_ports(protocol) => (be_u16(transport)?, be_u16(transport.get(2..)?)?), + _ => (0, 0), + }; + + Some(FlowKey { + protocol, + source, + destination, + source_port, + destination_port, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tcp_packet() -> Vec { + let mut packet = vec![0u8; 40]; + packet[0] = 0x45; + packet[9] = libc::IPPROTO_TCP as u8; + packet[12..16].copy_from_slice(&[10, 0, 0, 2]); + packet[16..20].copy_from_slice(&[192, 168, 1, 1]); + packet[20..22].copy_from_slice(&51000u16.to_be_bytes()); + packet[22..24].copy_from_slice(&443u16.to_be_bytes()); + packet + } + + #[test] + fn test_parse_ipv4_tcp_packet() { + assert_eq!( + parse_packet(&tcp_packet()), + Some(FlowKey { + protocol: libc::IPPROTO_TCP as u8, + source: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)), + destination: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), + source_port: 51000, + destination_port: 443, + }) + ); + } + + #[test] + fn test_parse_ipv6_udp_packet_with_extension_header() { + let mut packet = vec![0u8; 56]; + packet[0] = 0x60; + packet[6] = libc::IPPROTO_DSTOPTS as u8; + packet[8..24].copy_from_slice(&Ipv6Addr::LOCALHOST.octets()); + packet[24..40].copy_from_slice(&Ipv6Addr::LOCALHOST.octets()); + // Destination options header, 8 bytes long. + packet[40] = libc::IPPROTO_UDP as u8; + packet[48..50].copy_from_slice(&5353u16.to_be_bytes()); + packet[50..52].copy_from_slice(&53u16.to_be_bytes()); + + let key = parse_packet(&packet).unwrap(); + assert_eq!(key.protocol, libc::IPPROTO_UDP as u8); + assert_eq!(key.source_port, 5353); + assert_eq!(key.destination_port, 53); + } +} diff --git a/src/enterprise/firewall/mod.rs b/src/enterprise/firewall/mod.rs index befb71a0..e42ace0b 100644 --- a/src/enterprise/firewall/mod.rs +++ b/src/enterprise/firewall/mod.rs @@ -1,5 +1,8 @@ pub mod api; +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))] +mod flow; mod iprange; +pub mod monitor; #[cfg(target_os = "linux")] mod nftables; #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "netbsd"))] @@ -18,6 +21,11 @@ use tracing::error; use crate::proto; +#[cfg(target_os = "linux")] +pub use nftables::ConnectionMonitor; +#[cfg(any(target_os = "freebsd", target_os = "macos"))] +pub use packetfilter::ConnectionMonitor; + #[derive(Clone, Debug, PartialEq)] pub(crate) enum Address { Network(IpNetwork), @@ -189,6 +197,16 @@ pub(crate) enum Policy { Deny, } +#[cfg(target_os = "linux")] +impl Policy { + pub(crate) fn as_str(&self) -> &str { + match self { + Self::Allow => "a", + Self::Deny => "d", + } + } +} + impl From for Policy { fn from(allow: bool) -> Self { if allow { Self::Allow } else { Self::Deny } diff --git a/src/enterprise/firewall/monitor.rs b/src/enterprise/firewall/monitor.rs new file mode 100644 index 00000000..9828d26b --- /dev/null +++ b/src/enterprise/firewall/monitor.rs @@ -0,0 +1,185 @@ +//! Platform-agnostic types describing connections observed by the firewall. + +use std::{net::IpAddr, time::SystemTime}; + +use serde::{Deserialize, Serialize}; + +use crate::proto::enterprise::monitor as proto; + +/// Direction of traffic, relative to the VPN interface. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Direction { + /// Connection initiated from an external network towards a VPN device. + Inbound, + /// Connection initiated by a VPN device to an external network. + Outbound, +} + +#[cfg(target_os = "linux")] +impl Direction { + pub(crate) fn as_str(&self) -> &str { + match self { + Self::Inbound => "i", + Self::Outbound => "o", + } + } +} + +/// Firewall verdict for a connection. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Status { + Allowed, + Blocked, +} + +/// A single connection seen by the firewall on a VPN interface. +/// +/// Blocked connections are reported as soon as the first packet is dropped. +/// Allowed connections are reported once the connection ends, so the traffic counters are final. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ConnectionEvent { + pub timestamp: SystemTime, + pub direction: Direction, + pub status: Status, + /// IANA protocol number (e.g. 6 for TCP, 17 for UDP). + pub protocol: u8, + /// Origin of the traffic. + pub source: IpAddr, + pub source_port: Option, + /// Target of the traffic. + pub destination: IpAddr, + pub destination_port: Option, + /// ID of the Defguard ACL rule that matched, or `None` if the default policy applied or the + /// traffic is not subject to ACL rules. + pub rule_id: Option, + /// Bytes sent by the VPN device. + pub upload_bytes: u64, + /// Bytes received by the VPN device. + pub download_bytes: u64, +} + +impl ConnectionEvent { + /// Data uploaded by the VPN device in KB. + #[must_use] + pub fn upload_kb(&self) -> u64 { + self.upload_bytes.div_ceil(1024) + } + + /// Data downloaded by the VPN device in KB. + #[must_use] + pub fn download_kb(&self) -> u64 { + self.download_bytes.div_ceil(1024) + } +} + +impl From for proto::Direction { + fn from(direction: Direction) -> Self { + match direction { + Direction::Inbound => Self::Inbound, + Direction::Outbound => Self::Outbound, + } + } +} + +impl From for proto::Status { + fn from(status: Status) -> Self { + match status { + Status::Allowed => Self::Allowed, + Status::Blocked => Self::Blocked, + } + } +} + +/// Convert an IP address to its network-order byte representation (4 or 16 bytes). +fn ip_to_bytes(addr: IpAddr) -> Vec { + match addr { + IpAddr::V4(addr) => addr.octets().to_vec(), + IpAddr::V6(addr) => addr.octets().to_vec(), + } +} + +impl From<&ConnectionEvent> for proto::ConnectionEvent { + fn from(event: &ConnectionEvent) -> Self { + Self { + timestamp: Some(event.timestamp.into()), + direction: proto::Direction::from(event.direction).into(), + status: proto::Status::from(event.status).into(), + protocol: event.protocol.into(), + source_ip_addr: ip_to_bytes(event.source), + source_port: event.source_port.map(Into::into), + destination_ip_addr: ip_to_bytes(event.destination), + destination_port: event.destination_port.map(Into::into), + rule_id: event.rule_id, + upload_bytes: event.upload_bytes, + download_bytes: event.download_bytes, + } + } +} + +#[cfg(test)] +mod tests { + use std::{ + net::Ipv4Addr, + time::{Duration, UNIX_EPOCH}, + }; + + use super::*; + + #[test] + fn test_connection_event_json() { + let event = ConnectionEvent { + timestamp: UNIX_EPOCH + Duration::from_micros(1_793_198_060_792_864), + direction: Direction::Outbound, + status: Status::Allowed, + protocol: 6, + source: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)), + source_port: Some(51000), + destination: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), + destination_port: Some(443), + rule_id: Some(7), + upload_bytes: 3528, + download_bytes: 33192, + }; + let json = serde_json::to_string(&event).unwrap(); + assert_eq!( + json, + r#"{"timestamp":{"secs_since_epoch":1793198060,"nanos_since_epoch":792864000},"direction":"outbound","status":"allowed","protocol":6,"source":"10.0.0.2","source_port":51000,"destination":"192.168.1.1","destination_port":443,"rule_id":7,"upload_bytes":3528,"download_bytes":33192}"# + ); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + event + ); + } + + #[test] + fn test_connection_event_proto() { + let event = ConnectionEvent { + timestamp: UNIX_EPOCH + Duration::from_micros(1_793_198_060_792_864), + direction: Direction::Inbound, + status: Status::Blocked, + protocol: 17, + source: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), + source_port: Some(53), + destination: "fd00::2".parse().unwrap(), + destination_port: None, + rule_id: None, + upload_bytes: 0, + download_bytes: 128, + }; + let proto = proto::ConnectionEvent::from(&event); + let timestamp = proto.timestamp.unwrap(); + assert_eq!(timestamp.seconds, 1_793_198_060); + assert_eq!(timestamp.nanos, 792_864_000); + assert_eq!(proto.direction(), proto::Direction::Inbound); + assert_eq!(proto.status(), proto::Status::Blocked); + assert_eq!(proto.protocol, 17); + assert_eq!(proto.source_ip_addr, vec![192, 168, 1, 1]); + assert_eq!(proto.source_port, Some(53)); + assert_eq!(proto.destination_ip_addr.len(), 16); + assert_eq!(proto.destination_port, None); + assert_eq!(proto.rule_id, None); + assert_eq!(proto.download_bytes, 128); + } +} diff --git a/src/enterprise/firewall/nftables/mod.rs b/src/enterprise/firewall/nftables/mod.rs index 1ae96787..4efc4557 100644 --- a/src/enterprise/firewall/nftables/mod.rs +++ b/src/enterprise/firewall/nftables/mod.rs @@ -1,3 +1,4 @@ +mod monitor; mod netfilter; use std::{ @@ -5,9 +6,11 @@ use std::{ sync::atomic::{AtomicU32, Ordering}, }; +pub use monitor::ConnectionMonitor; +use monitor::LogTag; use netfilter::{ - allow_established_traffic, apply_filter_rules, drop_table, ignore_unrelated_traffic, - init_firewall, send_batch, set_nat_rules, + Log, allow_established_traffic, apply_filter_rules, drop_table, ignore_unrelated_traffic, + init_firewall, log_inbound_traffic, send_batch, set_nat_rules, }; use nftnl::Batch; @@ -15,6 +18,7 @@ use super::{ Address, FirewallError, FirewallRule, Policy, Port, Protocol, SnatBinding, api::{FirewallApi, FirewallManagementApi}, iprange::IpAddrRangeError, + monitor::Direction, }; use crate::enterprise::firewall::iprange::IpAddrRange; @@ -54,6 +58,8 @@ struct FilterRule<'a> { comment: Option, negated_oifname: bool, negated_iifname: bool, + // Send matching packets to the connection monitor. + log: Option, } /// Merges any contiguous subets or addres ranges into an address range. @@ -145,6 +151,14 @@ impl FirewallApi { let source_addrs = merge_addrs(rule.source_addrs.clone())?; let dest_addrs = merge_addrs(rule.destination_addrs.clone())?; + let log = Log::new( + &self.ifname, + LogTag { + direction: Direction::Outbound, + verdict: rule.verdict, + rule_id: Some(rule.id), + }, + ); if rule.destination_ports.is_empty() { debug!( @@ -160,6 +174,7 @@ impl FirewallApi { defguard_rule_id: rule.id, v4: rule.ipv4, comment: rule.comment.clone(), + log: Some(log.clone()), ..Default::default() }; filter_rules.push(rule); @@ -179,6 +194,7 @@ impl FirewallApi { defguard_rule_id: rule.id, v4: rule.ipv4, comment: rule.comment.clone(), + log: Some(log.clone()), ..Default::default() }; if protocol.supports_ports() { @@ -209,6 +225,7 @@ impl FirewallApi { defguard_rule_id: rule.id, v4: rule.ipv4, comment: rule.comment.clone(), + log: Some(log.clone()), ..Default::default() }; filter_rules.push(rule); @@ -233,10 +250,12 @@ impl FirewallManagementApi for FirewallApi { priority: Option, ) -> Result<(), FirewallError> { debug!("Initializing firewall, VPN interface: {}", self.ifname); + self.default_policy = default_policy; let mut batch = Batch::new(); drop_table(&mut batch, &self.ifname); init_firewall(default_policy, priority, &mut batch, &self.ifname); debug!("Allowing all established traffic"); + log_inbound_traffic(&mut batch, &self.ifname)?; ignore_unrelated_traffic(&mut batch, &self.ifname)?; allow_established_traffic(&mut batch, &self.ifname)?; send_batch(&batch.finalize(), &self.socket)?; @@ -280,269 +299,27 @@ impl FirewallManagementApi for FirewallApi { for rule in rules { self.add_rule(rule)?; } + // Explicitly apply the default policy at the end of the chain, so connections which did + // not match any rule are reported to the connection monitor. + let default_rule = FilterRule { + action: self.default_policy, + counter: true, + comment: Some("Default policy".to_string()), + log: Some(Log::new( + &self.ifname, + LogTag { + direction: Direction::Outbound, + verdict: self.default_policy, + rule_id: None, + }, + )), + ..Default::default() + }; + apply_filter_rules(&[default_rule], &self.ifname, &self.socket)?; debug!("Applied all Defguard ACL rules"); Ok(()) } } #[cfg(test)] -mod tests { - use std::str::FromStr; - - use ipnetwork::IpNetwork; - - use super::*; - - #[test] - fn test_sorting() { - let mut addrs = vec![ - Address::Network(IpNetwork::from_str("10.10.10.11/24").unwrap()), - Address::Network(IpNetwork::from_str("10.10.10.12/24").unwrap()), - Address::Network(IpNetwork::from_str("10.10.11.10/32").unwrap()), - Address::Network(IpNetwork::from_str("10.10.11.11/32").unwrap()), - Address::Network(IpNetwork::from_str("10.10.10.10/24").unwrap()), - Address::Network(IpNetwork::from_str("10.10.11.12/32").unwrap()), - ]; - - addrs.sort_by(|a, b| { - a.first() - .partial_cmp(&b.first()) - .unwrap_or(std::cmp::Ordering::Equal) - }); - - assert_eq!( - addrs, - vec![ - Address::Network(IpNetwork::from_str("10.10.10.10/24").unwrap()), - Address::Network(IpNetwork::from_str("10.10.10.11/24").unwrap()), - Address::Network(IpNetwork::from_str("10.10.10.12/24").unwrap()), - Address::Network(IpNetwork::from_str("10.10.11.10/32").unwrap()), - Address::Network(IpNetwork::from_str("10.10.11.11/32").unwrap()), - Address::Network(IpNetwork::from_str("10.10.11.12/32").unwrap()), - ] - ); - - let _prepared_addrs = merge_addrs(addrs).unwrap(); - } - - #[test] - fn test_merge_addrs_empty() { - let addrs = Vec::new(); - let result = merge_addrs(addrs).unwrap(); - assert!(result.is_empty()); - } - - #[test] - fn test_merge_addrs_single_address() { - let addrs = vec![Address::Network( - IpNetwork::from_str("192.168.1.10/32").unwrap(), - )]; - let result = merge_addrs(addrs.clone()).unwrap(); - - assert_eq!(result, addrs); - } - - #[test] - fn test_merge_addrs_adjacent_ranges() { - let addrs = vec![ - Address::Range( - IpAddrRange::new( - IpAddr::from_str("192.168.1.10").unwrap(), - IpAddr::from_str("192.168.1.20").unwrap(), - ) - .unwrap(), - ), - Address::Range( - IpAddrRange::new( - IpAddr::from_str("192.168.1.21").unwrap(), - IpAddr::from_str("192.168.1.30").unwrap(), - ) - .unwrap(), - ), - ]; - let result = merge_addrs(addrs).unwrap(); - - assert_eq!(result.len(), 1); - if let Address::Range(range) = &result[0] { - assert_eq!(range.start(), IpAddr::from_str("192.168.1.10").unwrap()); - assert_eq!(range.end(), IpAddr::from_str("192.168.1.30").unwrap()); - } else { - panic!("Expected Address::Range"); - } - } - - #[test] - fn test_merge_addrs_adjacent_single_addresses() { - let addrs = vec![ - Address::Network(IpNetwork::from_str("192.168.1.10/32").unwrap()), - Address::Network(IpNetwork::from_str("192.168.1.11/32").unwrap()), - Address::Network(IpNetwork::from_str("192.168.1.12/32").unwrap()), - ]; - let result = merge_addrs(addrs).unwrap(); - - assert_eq!(result.len(), 1); - if let Address::Range(range) = &result[0] { - assert_eq!(range.start(), IpAddr::from_str("192.168.1.10").unwrap()); - assert_eq!(range.end(), IpAddr::from_str("192.168.1.12").unwrap()); - } else { - panic!("Expected Address::Range"); - } - } - - #[test] - fn test_merge_addrs_non_adjacent_ranges() { - let addrs = vec![ - Address::Range( - IpAddrRange::new( - IpAddr::from_str("192.168.1.10").unwrap(), - IpAddr::from_str("192.168.1.20").unwrap(), - ) - .unwrap(), - ), - Address::Range( - IpAddrRange::new( - IpAddr::from_str("192.168.1.30").unwrap(), - IpAddr::from_str("192.168.1.40").unwrap(), - ) - .unwrap(), - ), - ]; - let result = merge_addrs(addrs).unwrap(); - - assert_eq!(result.len(), 2); - if let Address::Range(range1) = &result[0] { - assert_eq!(range1.start(), IpAddr::from_str("192.168.1.10").unwrap()); - assert_eq!(range1.end(), IpAddr::from_str("192.168.1.20").unwrap()); - } else { - panic!("Expected Address::Range"); - } - if let Address::Range(range2) = &result[1] { - assert_eq!(range2.start(), IpAddr::from_str("192.168.1.30").unwrap()); - assert_eq!(range2.end(), IpAddr::from_str("192.168.1.40").unwrap()); - } else { - panic!("Expected Address::Range"); - } - } - - #[test] - fn test_merge_addrs_mixed_networks_and_ranges() { - let addrs = vec![ - Address::Network(IpNetwork::from_str("192.168.1.10/32").unwrap()), - Address::Range( - IpAddrRange::new( - IpAddr::from_str("192.168.1.11").unwrap(), - IpAddr::from_str("192.168.1.15").unwrap(), - ) - .unwrap(), - ), - Address::Network(IpNetwork::from_str("192.168.1.16/32").unwrap()), - ]; - let result = merge_addrs(addrs).unwrap(); - - assert_eq!(result.len(), 1); - if let Address::Range(range) = &result[0] { - assert_eq!(range.start(), IpAddr::from_str("192.168.1.10").unwrap()); - assert_eq!(range.end(), IpAddr::from_str("192.168.1.16").unwrap()); - } else { - panic!("Expected Address::Range"); - } - } - - #[test] - fn test_merge_addrs_non_adjacent_singles() { - let addrs = vec![ - Address::Network(IpNetwork::from_str("192.168.1.10/32").unwrap()), - Address::Network(IpNetwork::from_str("192.168.1.11/32").unwrap()), - Address::Network(IpNetwork::from_str("192.168.1.15/32").unwrap()), - Address::Network(IpNetwork::from_str("192.168.1.20/32").unwrap()), - ]; - let result = merge_addrs(addrs).unwrap(); - - // These should result in 3 separate ranges: 10-11, 15, 20 - let expected_addrs = vec![ - Address::Range( - IpAddrRange::new( - IpAddr::from_str("192.168.1.10").unwrap(), - IpAddr::from_str("192.168.1.11").unwrap(), - ) - .unwrap(), - ), - Address::Network(IpNetwork::from_str("192.168.1.15/32").unwrap()), - Address::Network(IpNetwork::from_str("192.168.1.20/32").unwrap()), - ]; - assert_eq!(result, expected_addrs); - } - - #[test] - fn test_merge_addrs_ipv6() { - let addrs = vec![ - Address::Network(IpNetwork::from_str("2001:db8::1/128").unwrap()), - Address::Network(IpNetwork::from_str("2001:db8::2/128").unwrap()), - Address::Network(IpNetwork::from_str("2001:db8::3/128").unwrap()), - ]; - let result = merge_addrs(addrs).unwrap(); - - assert_eq!(result.len(), 1); - if let Address::Range(range) = &result[0] { - assert_eq!(range.start(), IpAddr::from_str("2001:db8::1").unwrap()); - assert_eq!(range.end(), IpAddr::from_str("2001:db8::3").unwrap()); - } else { - panic!("Expected Address::Range"); - } - } - - #[test] - fn test_next_ip_ipv4() { - assert_eq!( - next_ip(IpAddr::from_str("192.168.1.10").unwrap()), - IpAddr::from_str("192.168.1.11").unwrap() - ); - - // Test overflow - assert_eq!( - next_ip(IpAddr::from_str("255.255.255.255").unwrap()), - IpAddr::from_str("0.0.0.0").unwrap() - ); - } - - #[test] - fn test_next_ip_ipv6() { - assert_eq!( - next_ip(IpAddr::from_str("2001:db8::1").unwrap()), - IpAddr::from_str("2001:db8::2").unwrap() - ); - - // Test overflow - assert_eq!( - next_ip(IpAddr::from_str("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff").unwrap()), - IpAddr::from_str("::").unwrap() - ); - } - - #[test] - fn test_merge_addrs_gap() { - let addrs = vec![ - Address::Network(IpNetwork::from_str("192.168.1.1/32").unwrap()), - Address::Network(IpNetwork::from_str("192.168.1.100/32").unwrap()), - ]; - let result = merge_addrs(addrs.clone()).unwrap(); - - // Should not merge since there's a gap - assert_eq!(result, addrs); - - let addrs = vec![ - Address::Range( - IpAddrRange::new( - IpAddr::from_str("192.168.1.10").unwrap(), - IpAddr::from_str("192.168.1.20").unwrap(), - ) - .unwrap(), - ), - Address::Network(IpNetwork::from_str("192.168.1.100/32").unwrap()), - ]; - let result = merge_addrs(addrs.clone()).unwrap(); - - // Should not merge since there's a gap - assert_eq!(result, addrs); - } -} +mod tests; diff --git a/src/enterprise/firewall/nftables/monitor.rs b/src/enterprise/firewall/nftables/monitor.rs new file mode 100644 index 00000000..ebd00f07 --- /dev/null +++ b/src/enterprise/firewall/nftables/monitor.rs @@ -0,0 +1,411 @@ +//! Connection monitoring for the nftables firewall. +//! +//! Rules in the Defguard table carry `log group prefix ""` statements (see [`LogTag`]). +//! Established traffic is accepted before ACL rules are evaluated, so only the first packet of +//! each connection is copied to userspace through NFLOG, together with a tag identifying the +//! matching rule, its verdict and the traffic direction. +//! +//! Blocked connections are reported straight away. Allowed connections are kept until conntrack +//! reports their destruction, which carries the final byte counters. Note that conntrack destroys +//! expired entries lazily, so this may happen some time after the connection ends. + +mod worker; + +use std::{ + fs, io, mem, + os::fd::{AsRawFd, FromRawFd, OwnedFd}, + sync::mpsc, + thread::{self, JoinHandle}, +}; + +use tokio::sync::broadcast::Sender; + +use self::worker::Worker; +use super::Policy; +use crate::{ + config::PflogIfname, + enterprise::firewall::{ + FirewallConfig, FirewallError, + monitor::{ConnectionEvent, Direction}, + }, +}; + +// linux/netlink.h +const NLMSG_HDRLEN: usize = 16; +const NLA_HDRLEN: usize = 4; +const NLA_TYPE_MASK: u16 = 0x3fff; + +// linux/netfilter/nfnetlink.h +const NFGENMSG_LEN: usize = 4; +const NFNETLINK_V0: u8 = 0; +const NFNL_SUBSYS_CTNETLINK: u16 = 1; +const NFNL_SUBSYS_ULOG: u16 = 4; +const NFNLGRP_CONNTRACK_DESTROY: u32 = 3; + +// linux/netfilter/nfnetlink_log.h +const NFULNL_MSG_PACKET: u16 = 0; +const NFULNL_MSG_CONFIG: u16 = 1; +const NFULA_TIMESTAMP: u16 = 3; +const NFULA_PAYLOAD: u16 = 9; +const NFULA_PREFIX: u16 = 10; +const NFULA_CT: u16 = 18; +const NFULA_CFG_CMD: u16 = 1; +const NFULA_CFG_MODE: u16 = 2; +const NFULA_CFG_QTHRESH: u16 = 5; +const NFULA_CFG_FLAGS: u16 = 6; +const NFULNL_CFG_CMD_BIND: u8 = 1; +const NFULNL_COPY_PACKET: u8 = 2; +const NFULNL_CFG_F_CONNTRACK: u16 = 0x0004; + +const LOG_PREFIX: &str = "DG"; +/// Number of packet bytes copied to userspace; enough for IP (with IPv6 extension headers) and +/// transport headers. +const NFLOG_COPY_RANGE: u32 = 256; +/// Send each logged packet to userspace immediately, so it's never late compared to conntrack. +const NFLOG_QUEUE_THRESHOLD: u32 = 1; +const SOCKET_BUFFER_SIZE: usize = 4 * 1024 * 1024; +/// Upper bound on the number of allowed connections awaiting conntrack destruction. +const MAX_PENDING_CONNECTIONS: usize = 65536; +const CONNTRACK_ACCT_SYSCTL: &str = "/proc/sys/net/netfilter/nf_conntrack_acct"; + +/// NFLOG group used for `ifname`. +/// +/// Derived from the interface name, so gateways serving different interfaces on the same host do +/// not compete for one group. Kept in the upper half of the range, away from low group numbers +/// commonly used by other software. +pub(super) fn nflog_group(ifname: &str) -> u16 { + // FNV-1a + let hash = ifname.bytes().fold(0x811c_9dc5_u32, |hash, byte| { + (hash ^ u32::from(byte)).wrapping_mul(0x0100_0193) + }); + 0x8000 | (hash as u16 & 0x7fff) +} + +/// Information attached to logged packets through the NFLOG prefix. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct LogTag { + pub direction: Direction, + pub verdict: Policy, + /// Defguard ACL rule ID; `None` for the default policy and inbound traffic. + pub rule_id: Option, +} + +impl LogTag { + /// Format as `DG:::`. + pub(super) fn to_prefix(self) -> String { + let id = match self.rule_id { + Some(id) => id.to_string(), + None => String::from("-"), + }; + format!( + "{LOG_PREFIX}:{}:{}:{id}", + self.direction.as_str(), + self.verdict.as_str() + ) + } + + fn from_prefix(prefix: &str) -> Option { + let mut parts = prefix.split(':'); + if parts.next()? != LOG_PREFIX { + return None; + } + let direction = match parts.next()? { + "o" => Direction::Outbound, + "i" => Direction::Inbound, + _ => return None, + }; + let verdict = match parts.next()? { + "a" => Policy::Allow, + "d" => Policy::Deny, + _ => return None, + }; + let rule_id = match parts.next()? { + "-" => None, + id => Some(id.parse().ok()?), + }; + if parts.next().is_some() { + return None; + } + + Some(Self { + direction, + verdict, + rule_id, + }) + } +} + +/// Monitors connections handled by the Defguard firewall on a WireGuard interface. +/// +/// Monitoring stops when [`ConnectionMonitor::unregister`] is called or the monitor is dropped. +pub struct ConnectionMonitor { + ifname: String, + wakeup: OwnedFd, + thread: Option>, +} + +impl ConnectionMonitor { + /// Register for connection monitoring on interface `ifname`. Connection events are published + /// to `events`; events are dropped while there are no subscribers. + /// + /// Only one monitor can be registered for a given interface at a time. `_pflog_ifname` is only + /// used with Packet Filter. + pub fn register( + ifname: &str, + _pflog_ifname: PflogIfname, + events: Sender, + ) -> Result { + debug!("Registering connection monitor for interface {ifname}"); + enable_conntrack_accounting(); + + let wakeup = unsafe { libc::eventfd(0, libc::EFD_CLOEXEC | libc::EFD_NONBLOCK) }; + if wakeup < 0 { + return Err(io::Error::last_os_error().into()); + } + let wakeup = unsafe { OwnedFd::from_raw_fd(wakeup) }; + + let group = nflog_group(ifname); + let worker = Worker::new(ifname, events); + let worker_wakeup = wakeup.try_clone()?; + // `mnl::Socket` can't be sent between threads, so sockets are opened by the worker thread. + let (setup_tx, setup_rx) = mpsc::sync_channel(1); + let thread = thread::Builder::new() + .name(format!("monitor-{ifname}")) + .spawn(move || match open_sockets(group) { + Ok((nflog, conntrack)) => { + let _ = setup_tx.send(Ok(())); + worker.run(&nflog, &conntrack, &worker_wakeup); + } + Err(err) => { + let _ = setup_tx.send(Err(err)); + } + })?; + let setup = setup_rx.recv().unwrap_or_else(|_| { + Err(FirewallError::NetlinkError( + "Connection monitor thread exited unexpectedly".to_string(), + )) + }); + if let Err(err) = setup { + let _ = thread.join(); + return Err(err); + } + + info!("Registered connection monitor for interface {ifname} (NFLOG group {group})"); + + Ok(Self { + ifname: ifname.to_string(), + wakeup, + thread: Some(thread), + }) + } + + /// Unregister from connection monitoring. Connections still in progress are not reported. + pub fn unregister(mut self) { + self.stop(); + } + + /// Inform the monitor about the current firewall rules. Rule IDs are carried in NFLOG + /// prefixes, so there's nothing to do. + pub(crate) fn update_rules(&self, _config: &FirewallConfig) {} + + fn stop(&mut self) { + let Some(thread) = self.thread.take() else { + return; + }; + let value = 1u64.to_ne_bytes(); + let ret = + unsafe { libc::write(self.wakeup.as_raw_fd(), value.as_ptr().cast(), value.len()) }; + if ret < 0 { + error!( + "Failed to stop connection monitor for interface {}: {}", + self.ifname, + io::Error::last_os_error() + ); + return; + } + if thread.join().is_err() { + error!( + "Connection monitor thread for interface {} panicked", + self.ifname + ); + } + info!( + "Unregistered connection monitor for interface {}", + self.ifname + ); + } +} + +impl Drop for ConnectionMonitor { + fn drop(&mut self) { + self.stop(); + } +} + +/// Open and configure the NFLOG and conntrack sockets. +fn open_sockets(group: u16) -> Result<(mnl::Socket, mnl::Socket), FirewallError> { + // Subscribing to conntrack events loads ctnetlink, which NFLOG needs to attach conntrack + // information to logged packets. + let conntrack = open_socket(1 << (NFNLGRP_CONNTRACK_DESTROY - 1))?; + + let nflog = open_socket(0)?; + nflog_request(&nflog, 1, group, &[(NFULA_CFG_CMD, &[NFULNL_CFG_CMD_BIND])]).map_err(|err| { + FirewallError::NetlinkError(format!("Failed to bind to NFLOG group {group}: {err}")) + })?; + // struct nfulnl_msg_config_mode + let mut mode = NFLOG_COPY_RANGE.to_be_bytes().to_vec(); + mode.extend_from_slice(&[NFULNL_COPY_PACKET, 0]); + nflog_request( + &nflog, + 2, + group, + &[ + (NFULA_CFG_MODE, &mode), + (NFULA_CFG_QTHRESH, &NFLOG_QUEUE_THRESHOLD.to_be_bytes()), + (NFULA_CFG_FLAGS, &NFULNL_CFG_F_CONNTRACK.to_be_bytes()), + ], + ) + .map_err(|err| { + FirewallError::NetlinkError(format!("Failed to configure NFLOG group {group}: {err}")) + })?; + + // From now on, sockets are only read after polling. + set_nonblocking(&nflog)?; + set_nonblocking(&conntrack)?; + + Ok((nflog, conntrack)) +} + +/// Traffic counters are only gathered by conntrack when accounting is enabled. +fn enable_conntrack_accounting() { + if fs::read_to_string(CONNTRACK_ACCT_SYSCTL).is_ok_and(|value| value.trim() == "1") { + return; + } + match fs::write(CONNTRACK_ACCT_SYSCTL, "1") { + Ok(()) => debug!("Enabled conntrack accounting"), + Err(err) => warn!( + "Failed to enable conntrack accounting in {CONNTRACK_ACCT_SYSCTL}: {err}. Traffic \ + counters will not be available." + ), + } +} + +fn be_u64(data: &[u8]) -> Option { + Some(u64::from_be_bytes(data.get(..8)?.try_into().ok()?)) +} + +const fn nl_align(len: usize) -> usize { + (len + 3) & !3 +} + +/// Receive buffer aligned to `nlmsghdr`, as required by `mnl`. +#[repr(C, align(4))] +struct RecvBuffer([u8; 65536]); + +impl RecvBuffer { + fn new() -> Box { + Box::new(Self([0; 65536])) + } +} + +struct NetlinkMessage<'a> { + msg_type: u16, + seq: u32, + payload: &'a [u8], +} + +impl<'a> NetlinkMessage<'a> { + /// Parse a single message, as returned by `mnl::NlMessages`. + fn parse(message: &'a [u8]) -> Option { + Some(Self { + msg_type: u16::from_ne_bytes(message.get(4..6)?.try_into().ok()?), + seq: u32::from_ne_bytes(message.get(8..12)?.try_into().ok()?), + payload: message.get(NLMSG_HDRLEN..)?, + }) + } +} + +/// Open a `NETLINK_NETFILTER` socket subscribed to multicast `groups` (bitmask). +fn open_socket(groups: u32) -> io::Result { + let socket = mnl::Socket::open(mnl::Bus::Netfilter)?; + // Don't leak the socket into child processes. + if unsafe { libc::fcntl(socket.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) } < 0 { + return Err(io::Error::last_os_error()); + } + socket.bind(groups, mnl::mnl_sys::MNL_SOCKET_AUTOPID)?; + + // Enlarge the receive buffer to avoid losing events during bursts. + // SO_RCVBUFFORCE exceeds the `rmem_max` limit, but requires CAP_NET_ADMIN. + let size = libc::c_int::try_from(SOCKET_BUFFER_SIZE).unwrap_or(libc::c_int::MAX); + let set = |option| unsafe { + libc::setsockopt( + socket.as_raw_fd(), + libc::SOL_SOCKET, + option, + (&raw const size).cast(), + mem::size_of::() as libc::socklen_t, + ) == 0 + }; + if !set(libc::SO_RCVBUFFORCE) && !set(libc::SO_RCVBUF) { + warn!( + "Failed to set netlink socket receive buffer size: {}", + io::Error::last_os_error() + ); + } + + Ok(socket) +} + +fn set_nonblocking(socket: &mnl::Socket) -> io::Result<()> { + let fd = socket.as_raw_fd(); + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + if flags < 0 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +/// Send NFLOG configuration message and wait for acknowledgement. +fn nflog_request( + socket: &mnl::Socket, + seq: u32, + res_id: u16, + attrs: &[(u16, &[u8])], +) -> io::Result<()> { + let mut message = vec![0; NLMSG_HDRLEN]; + // struct nfgenmsg + message.extend_from_slice(&[libc::AF_UNSPEC as u8, NFNETLINK_V0]); + message.extend_from_slice(&res_id.to_be_bytes()); + for (attr_type, data) in attrs { + let len = (NLA_HDRLEN + data.len()) as u16; + message.extend_from_slice(&len.to_ne_bytes()); + message.extend_from_slice(&attr_type.to_ne_bytes()); + message.extend_from_slice(data); + message.resize(nl_align(message.len()), 0); + } + // struct nlmsghdr + let len = message.len() as u32; + let flags = (libc::NLM_F_REQUEST | libc::NLM_F_ACK) as u16; + message[0..4].copy_from_slice(&len.to_ne_bytes()); + message[4..6].copy_from_slice(&(NFNL_SUBSYS_ULOG << 8 | NFULNL_MSG_CONFIG).to_ne_bytes()); + message[6..8].copy_from_slice(&flags.to_ne_bytes()); + message[8..12].copy_from_slice(&seq.to_ne_bytes()); + socket.send(&message)?; + + // Skip other messages (e.g. logged packets) until the acknowledgement arrives. + let mut buf = RecvBuffer::new(); + loop { + for message in socket.recv(&mut buf.0)? { + let message = message?; + if NetlinkMessage::parse(message).is_some_and(|parsed| { + parsed.seq == seq && parsed.msg_type == libc::NLMSG_ERROR as u16 + }) { + return mnl::cb_run(message, seq, socket.portid()).map(|_| ()); + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/src/enterprise/firewall/nftables/monitor/tests.rs b/src/enterprise/firewall/nftables/monitor/tests.rs new file mode 100644 index 00000000..2309283e --- /dev/null +++ b/src/enterprise/firewall/nftables/monitor/tests.rs @@ -0,0 +1,176 @@ +use std::net::{IpAddr, Ipv4Addr}; + +use super::{worker::*, *}; +use crate::enterprise::firewall::{flow::FlowKey, monitor::Status}; + +#[test] +fn test_log_tag_roundtrip() { + for tag in [ + LogTag { + direction: Direction::Outbound, + verdict: Policy::Allow, + rule_id: Some(42), + }, + LogTag { + direction: Direction::Outbound, + verdict: Policy::Deny, + rule_id: None, + }, + LogTag { + direction: Direction::Inbound, + verdict: Policy::Allow, + rule_id: None, + }, + ] { + assert_eq!(LogTag::from_prefix(&tag.to_prefix()), Some(tag)); + } + assert_eq!(LogTag::from_prefix("DG:o:a:42").unwrap().rule_id, Some(42)); + assert_eq!(LogTag::from_prefix("XX:o:a:42"), None); + assert_eq!(LogTag::from_prefix("DG:o:a:42:1"), None); + assert_eq!(LogTag::from_prefix("DG:o:x:42"), None); +} + +#[test] +fn test_nflog_group() { + assert_eq!(nflog_group("wg0"), nflog_group("wg0")); + assert_ne!(nflog_group("wg0"), nflog_group("wg1")); + assert!(nflog_group("wg0") >= 0x8000); +} + +fn tcp_packet() -> Vec { + let mut packet = vec![0u8; 40]; + packet[0] = 0x45; + packet[9] = libc::IPPROTO_TCP as u8; + packet[12..16].copy_from_slice(&[10, 0, 0, 2]); + packet[16..20].copy_from_slice(&[192, 168, 1, 1]); + packet[20..22].copy_from_slice(&51000u16.to_be_bytes()); + packet[22..24].copy_from_slice(&443u16.to_be_bytes()); + packet +} + +fn tcp_key() -> FlowKey { + FlowKey { + protocol: libc::IPPROTO_TCP as u8, + source: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)), + destination: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), + source_port: 51000, + destination_port: 443, + } +} + +fn attr(attr_type: u16, data: &[u8]) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&((NLA_HDRLEN + data.len()) as u16).to_ne_bytes()); + buf.extend_from_slice(&attr_type.to_ne_bytes()); + buf.extend_from_slice(data); + buf.resize(nl_align(buf.len()), 0); + buf +} + +fn tcp_tuple() -> Vec { + let ip = [ + attr(CTA_IP_V4_SRC, &[10, 0, 0, 2]), + attr(CTA_IP_V4_DST, &[192, 168, 1, 1]), + ] + .concat(); + let proto = [ + attr(CTA_PROTO_NUM, &[libc::IPPROTO_TCP as u8]), + attr(CTA_PROTO_SRC_PORT, &51000u16.to_be_bytes()), + attr(CTA_PROTO_DST_PORT, &443u16.to_be_bytes()), + ] + .concat(); + [attr(CTA_TUPLE_IP, &ip), attr(CTA_TUPLE_PROTO, &proto)].concat() +} + +fn conntrack_destroy(orig_bytes: u64, reply_bytes: u64) -> Vec { + [ + vec![libc::AF_INET as u8, NFNETLINK_V0, 0, 0], + attr(CTA_TUPLE_ORIG, &tcp_tuple()), + attr( + CTA_COUNTERS_ORIG, + &attr(CTA_COUNTERS_BYTES, &orig_bytes.to_be_bytes()), + ), + attr( + CTA_COUNTERS_REPLY, + &attr(CTA_COUNTERS_BYTES, &reply_bytes.to_be_bytes()), + ), + ] + .concat() +} + +fn nflog_packet(prefix: &str, status: u32) -> Vec { + let conntrack = [ + attr(CTA_TUPLE_ORIG, &tcp_tuple()), + attr(CTA_STATUS, &status.to_be_bytes()), + ] + .concat(); + [ + vec![libc::AF_INET as u8, NFNETLINK_V0, 0, 0], + attr(NFULA_PREFIX, format!("{prefix}\0").as_bytes()), + attr(NFULA_PAYLOAD, &tcp_packet()), + attr(NFULA_CT, &conntrack), + ] + .concat() +} + +#[test] +fn test_parse_conntrack_destroy() { + let destroyed = parse_conntrack_destroy(&conntrack_destroy(1000, 5000)).unwrap(); + assert_eq!(destroyed.key, tcp_key()); + assert_eq!(destroyed.orig_bytes, 1000); + assert_eq!(destroyed.reply_bytes, 5000); +} + +#[test] +fn test_worker_matches_connections() { + let (events, mut receiver) = tokio::sync::broadcast::channel(10); + let mut worker = Worker::new("wg0", events); + fn nflog(payload: &[u8]) -> NetlinkMessage<'_> { + NetlinkMessage { + msg_type: NFNL_SUBSYS_ULOG << 8 | NFULNL_MSG_PACKET, + seq: 0, + payload, + } + } + fn conntrack(payload: &[u8]) -> NetlinkMessage<'_> { + NetlinkMessage { + msg_type: NFNL_SUBSYS_CTNETLINK << 8 | IPCTNL_MSG_CT_DELETE, + seq: 0, + payload, + } + } + + // Blocked connections are reported immediately. + worker.handle_nflog(nflog(&nflog_packet("DG:o:d:-", 0))); + let event = receiver.try_recv().unwrap(); + assert_eq!(event.status, Status::Blocked); + assert_eq!(event.rule_id, None); + assert_eq!(event.destination_port, Some(443)); + + // Two connections reusing a tuple, with a retransmission of the first packet. + worker.handle_nflog(nflog(&nflog_packet("DG:o:a:1", 0))); + worker.handle_nflog(nflog(&nflog_packet("DG:o:a:1", IPS_CONFIRMED))); + worker.handle_nflog(nflog(&nflog_packet("DG:o:a:2", 0))); + assert!(receiver.try_recv().is_err()); + assert_eq!(worker.pending_count, 2); + + worker.handle_conntrack(conntrack(&conntrack_destroy(100, 2048))); + let event = receiver.try_recv().unwrap(); + assert_eq!(event.status, Status::Allowed); + assert_eq!(event.rule_id, Some(1)); + assert_eq!((event.upload_bytes, event.download_bytes), (100, 2048)); + assert_eq!(event.download_kb(), 2); + + worker.handle_conntrack(conntrack(&conntrack_destroy(1, 2))); + assert_eq!(receiver.try_recv().unwrap().rule_id, Some(2)); + assert!(worker.pending.is_empty()); + assert_eq!(worker.pending_count, 0); + + // Untracked connections are ignored. + worker.handle_conntrack(conntrack(&conntrack_destroy(1, 2))); + assert!(receiver.try_recv().is_err()); + + // Events are dropped while there are no subscribers. + drop(receiver); + worker.handle_nflog(nflog(&nflog_packet("DG:o:d:-", 0))); +} diff --git a/src/enterprise/firewall/nftables/monitor/worker.rs b/src/enterprise/firewall/nftables/monitor/worker.rs new file mode 100644 index 00000000..3e532945 --- /dev/null +++ b/src/enterprise/firewall/nftables/monitor/worker.rs @@ -0,0 +1,400 @@ +use std::{ + collections::{HashMap, VecDeque}, + ffi::CStr, + io, + os::fd::{AsRawFd, OwnedFd}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use tokio::sync::broadcast::Sender; + +use super::{ + MAX_PENDING_CONNECTIONS, NFGENMSG_LEN, NFNL_SUBSYS_CTNETLINK, NFNL_SUBSYS_ULOG, NFULA_CT, + NFULA_PAYLOAD, NFULA_PREFIX, NFULA_TIMESTAMP, NFULNL_MSG_PACKET, NLA_HDRLEN, NLA_TYPE_MASK, + NetlinkMessage, Policy, RecvBuffer, be_u64, nl_align, +}; +use crate::enterprise::firewall::{ + flow::{FlowKey, be_u16, ipv4, ipv6, parse_packet, protocol_has_ports}, + monitor::{ConnectionEvent, Direction, Status}, + nftables::LogTag, +}; + +// linux/netfilter/nfnetlink_conntrack.h +pub(super) const IPCTNL_MSG_CT_DELETE: u16 = 2; +pub(super) const CTA_TUPLE_ORIG: u16 = 1; +pub(super) const CTA_STATUS: u16 = 3; +pub(super) const CTA_COUNTERS_ORIG: u16 = 9; +pub(super) const CTA_COUNTERS_REPLY: u16 = 10; +pub(super) const CTA_TUPLE_IP: u16 = 1; +pub(super) const CTA_TUPLE_PROTO: u16 = 2; +pub(super) const CTA_IP_V4_SRC: u16 = 1; +pub(super) const CTA_IP_V4_DST: u16 = 2; +pub(super) const CTA_IP_V6_SRC: u16 = 3; +pub(super) const CTA_IP_V6_DST: u16 = 4; +pub(super) const CTA_PROTO_NUM: u16 = 1; +pub(super) const CTA_PROTO_SRC_PORT: u16 = 2; +pub(super) const CTA_PROTO_DST_PORT: u16 = 3; +pub(super) const CTA_PROTO_ICMP_ID: u16 = 4; +pub(super) const CTA_PROTO_ICMPV6_ID: u16 = 7; +pub(super) const CTA_COUNTERS_BYTES: u16 = 2; +// linux/netfilter/nf_conntrack_common.h +pub(super) const IPS_CONFIRMED: u32 = 1 << 3; + +pub(super) struct DestroyedConnection { + pub(super) key: FlowKey, + pub(super) orig_bytes: u64, + pub(super) reply_bytes: u64, +} + +fn attributes(mut buf: &[u8]) -> impl Iterator { + std::iter::from_fn(move || { + let header = buf.get(..NLA_HDRLEN)?; + let len = usize::from(u16::from_ne_bytes([header[0], header[1]])); + let attr_type = u16::from_ne_bytes([header[2], header[3]]) & NLA_TYPE_MASK; + if !(NLA_HDRLEN..=buf.len()).contains(&len) { + return None; + } + let data = &buf[NLA_HDRLEN..len]; + buf = buf.get(nl_align(len)..).unwrap_or_default(); + Some((attr_type, data)) + }) +} + +pub(super) fn parse_conntrack_destroy(payload: &[u8]) -> Option { + let mut key = None; + let mut orig_bytes = 0; + let mut reply_bytes = 0; + for (attr_type, data) in attributes(payload.get(NFGENMSG_LEN..)?) { + match attr_type { + CTA_TUPLE_ORIG => key = parse_conntrack_tuple(data), + CTA_COUNTERS_ORIG => orig_bytes = parse_conntrack_bytes(data).unwrap_or_default(), + CTA_COUNTERS_REPLY => reply_bytes = parse_conntrack_bytes(data).unwrap_or_default(), + _ => {} + } + } + + Some(DestroyedConnection { + key: key?, + orig_bytes, + reply_bytes, + }) +} + +fn parse_conntrack_tuple(data: &[u8]) -> Option { + let mut protocol = None; + let mut source = None; + let mut destination = None; + let mut source_port = 0; + let mut destination_port = 0; + for (attr_type, data) in attributes(data) { + match attr_type { + CTA_TUPLE_IP => { + for (attr_type, data) in attributes(data) { + match attr_type { + CTA_IP_V4_SRC => source = ipv4(data), + CTA_IP_V4_DST => destination = ipv4(data), + CTA_IP_V6_SRC => source = ipv6(data), + CTA_IP_V6_DST => destination = ipv6(data), + _ => {} + } + } + } + CTA_TUPLE_PROTO => { + for (attr_type, data) in attributes(data) { + match attr_type { + CTA_PROTO_NUM => protocol = data.first().copied(), + CTA_PROTO_SRC_PORT | CTA_PROTO_ICMP_ID | CTA_PROTO_ICMPV6_ID => { + source_port = be_u16(data)?; + } + CTA_PROTO_DST_PORT => destination_port = be_u16(data)?, + _ => {} + } + } + } + _ => {} + } + } + + Some(FlowKey { + protocol: protocol?, + source: source?, + destination: destination?, + source_port, + destination_port, + }) +} + +fn parse_conntrack_bytes(data: &[u8]) -> Option { + attributes(data) + .find(|(attr_type, _)| *attr_type == CTA_COUNTERS_BYTES) + .and_then(|(_, data)| be_u64(data)) +} + +/// Parse conntrack information attached to a logged packet: original tuple and status. +fn parse_conntrack_info(data: &[u8]) -> Option<(FlowKey, u32)> { + let mut key = None; + let mut status = None; + for (attr_type, data) in attributes(data) { + match attr_type { + CTA_TUPLE_ORIG => key = parse_conntrack_tuple(data), + CTA_STATUS => { + status = Some(u32::from_be_bytes(data.get(..4)?.try_into().ok()?)); + } + _ => {} + } + } + + Some((key?, status?)) +} + +/// Parse `struct nfulnl_msg_packet_timestamp`. +fn parse_timestamp(data: &[u8]) -> Option { + let secs = be_u64(data)?; + let usecs = be_u64(data.get(8..)?)?; + UNIX_EPOCH.checked_add(Duration::from_secs(secs) + Duration::from_micros(usecs)) +} + +pub(super) struct Worker { + ifname: String, + events: Sender, + /// Allowed connections awaiting destruction, by conntrack original tuple. A tuple can be + /// reused by a new connection before the previous one is reported as destroyed. + pub(super) pending: HashMap>, + pub(super) pending_count: usize, +} + +impl Worker { + #[must_use] + pub(super) fn new(ifname: &str, events: Sender) -> Self { + Self { + ifname: ifname.to_string(), + events, + pending: HashMap::new(), + pending_count: 0, + } + } + + pub(super) fn run(mut self, nflog: &mnl::Socket, conntrack: &mnl::Socket, wakeup: &OwnedFd) { + let mut nflog_buf = RecvBuffer::new(); + let mut conntrack_buf = RecvBuffer::new(); + 'poll: loop { + let mut fds = + [nflog.as_raw_fd(), conntrack.as_raw_fd(), wakeup.as_raw_fd()].map(|fd| { + libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, + } + }); + if unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as libc::nfds_t, -1) } < 0 { + let err = io::Error::last_os_error(); + if err.kind() == io::ErrorKind::Interrupted { + continue; + } + error!( + "Connection monitor for interface {} failed: {err}", + self.ifname + ); + break; + } + if fds[2].revents != 0 { + break; + } + // The first packet of a connection is logged before conntrack can destroy it, so pick + // up all logged packets before each batch of conntrack events. + loop { + if !self.drain(nflog, &mut nflog_buf) { + break 'poll; + } + if fds[1].revents == 0 { + break; + } + match self.receive(conntrack, &mut conntrack_buf, Self::handle_conntrack) { + Some(true) => {} + Some(false) => break, + None => break 'poll, + } + } + } + // Closing the NFLOG socket releases the group. + debug!("Connection monitor for interface {} stopped", self.ifname); + } + + /// Process all logged packets waiting on `nflog`. Returns `false` if the socket failed. + fn drain(&mut self, nflog: &mnl::Socket, buf: &mut RecvBuffer) -> bool { + loop { + match self.receive(nflog, buf, Self::handle_nflog) { + Some(true) => {} + Some(false) => return true, + None => return false, + } + } + } + + /// Process one batch of messages waiting on `socket`. Returns whether there were any messages, + /// or `None` if the socket failed. + fn receive( + &mut self, + socket: &mnl::Socket, + buf: &mut RecvBuffer, + handle: fn(&mut Self, NetlinkMessage), + ) -> Option { + loop { + let messages = match socket.recv(&mut buf.0) { + Ok(messages) => messages, + Err(err) if err.kind() == io::ErrorKind::WouldBlock => return Some(false), + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) if err.raw_os_error() == Some(libc::ENOBUFS) => { + warn!( + "Connection monitor for interface {} can't keep up, some events were lost", + self.ifname + ); + return Some(true); + } + Err(err) => { + error!( + "Connection monitor for interface {} failed: {err}", + self.ifname + ); + return None; + } + }; + for message in messages { + let message = match message.map(NetlinkMessage::parse) { + Ok(Some(message)) => message, + Ok(None) => continue, + Err(err) => { + warn!("Received invalid netlink message: {err}"); + break; + } + }; + handle(self, message); + } + return Some(true); + } + } + + pub(super) fn handle_nflog(&mut self, message: NetlinkMessage) { + if message.msg_type != (NFNL_SUBSYS_ULOG << 8 | NFULNL_MSG_PACKET) { + return; + } + let mut tag = None; + let mut key = None; + let mut timestamp = None; + let mut conntrack = None; + for (attr_type, data) in attributes(message.payload.get(NFGENMSG_LEN..).unwrap_or_default()) + { + match attr_type { + NFULA_PREFIX => { + tag = CStr::from_bytes_until_nul(data) + .ok() + .and_then(|prefix| prefix.to_str().ok()) + .and_then(LogTag::from_prefix); + } + NFULA_PAYLOAD => key = parse_packet(data), + NFULA_TIMESTAMP => timestamp = parse_timestamp(data), + NFULA_CT => conntrack = parse_conntrack_info(data), + _ => {} + } + } + let (Some(tag), Some(key)) = (tag, key) else { + debug!("Ignoring unrecognized NFLOG packet"); + return; + }; + + let has_ports = protocol_has_ports(key.protocol); + let event = ConnectionEvent { + timestamp: timestamp.unwrap_or_else(SystemTime::now), + direction: tag.direction, + status: match tag.verdict { + Policy::Allow => Status::Allowed, + Policy::Deny => Status::Blocked, + }, + protocol: key.protocol, + source: key.source, + source_port: has_ports.then_some(key.source_port), + destination: key.destination, + destination_port: has_ports.then_some(key.destination_port), + rule_id: tag.rule_id, + upload_bytes: 0, + download_bytes: 0, + }; + + if event.status == Status::Blocked { + self.emit(event); + return; + } + // Without conntrack, traffic counters can't be determined. + let Some((conntrack_key, status)) = conntrack else { + self.emit(event); + return; + }; + // A packet of an already confirmed connection is a retransmission of its first packet. + if status & IPS_CONFIRMED != 0 { + return; + } + if self.pending_count >= MAX_PENDING_CONNECTIONS { + self.forget_oldest(); + } + trace!("Tracking allowed connection {conntrack_key:?}"); + self.pending + .entry(conntrack_key) + .or_default() + .push_back(event); + self.pending_count += 1; + } + + /// Stop tracking the oldest allowed connection; its destruction was likely missed. + fn forget_oldest(&mut self) { + let Some(oldest) = self + .pending + .iter() + .filter_map(|(key, events)| Some((key, events.front()?.timestamp))) + .min_by_key(|(_, timestamp)| *timestamp) + .map(|(key, _)| key.clone()) + else { + return; + }; + debug!("Too many tracked connections, forgetting {oldest:?}"); + self.take_pending(&oldest); + } + + fn take_pending(&mut self, key: &FlowKey) -> Option { + let events = self.pending.get_mut(key)?; + let event = events.pop_front(); + if events.is_empty() { + self.pending.remove(key); + } + if event.is_some() { + self.pending_count -= 1; + } + event + } + + pub(super) fn handle_conntrack(&mut self, message: NetlinkMessage) { + if message.msg_type != (NFNL_SUBSYS_CTNETLINK << 8 | IPCTNL_MSG_CT_DELETE) { + return; + } + let Some(destroyed) = parse_conntrack_destroy(message.payload) else { + debug!("Ignoring unrecognized conntrack message"); + return; + }; + let Some(mut event) = self.take_pending(&destroyed.key) else { + trace!("Ignoring untracked connection {:?}", destroyed.key); + return; + }; + // The original direction is the one in which the connection was initiated. + (event.upload_bytes, event.download_bytes) = match event.direction { + Direction::Outbound => (destroyed.orig_bytes, destroyed.reply_bytes), + Direction::Inbound => (destroyed.reply_bytes, destroyed.orig_bytes), + }; + self.emit(event); + } + + fn emit(&self, event: ConnectionEvent) { + // Sending fails only if there are no subscribers at the moment. + if self.events.send(event).is_err() { + trace!("No subscribers for connection events"); + } + } +} diff --git a/src/enterprise/firewall/nftables/netfilter.rs b/src/enterprise/firewall/nftables/netfilter.rs index 55f376a5..0f44f77f 100644 --- a/src/enterprise/firewall/nftables/netfilter.rs +++ b/src/enterprise/firewall/nftables/netfilter.rs @@ -9,15 +9,21 @@ use nftnl::{ expr::{Expression, Immediate, InterfaceName, Nat, NatType, Register}, nft_expr, nft_nlmsg_maxsize, nftnl_sys::{ - NFTNL_SET_ELEM_FLAGS, NFTNL_SET_ELEM_KEY, NFTNL_SET_FLAGS, NFTNL_UDATA_COMMENT_MAXLEN, - nftnl_set, nftnl_set_elem_add, nftnl_set_elem_alloc, nftnl_set_elem_set, - nftnl_set_elem_set_u32, nftnl_set_set_u32, + NFTNL_EXPR_LOG_GROUP, NFTNL_EXPR_LOG_PREFIX, NFTNL_SET_ELEM_FLAGS, NFTNL_SET_ELEM_KEY, + NFTNL_SET_FLAGS, NFTNL_UDATA_COMMENT_MAXLEN, nftnl_expr, nftnl_expr_alloc, + nftnl_expr_set_str, nftnl_expr_set_u16, nftnl_set, nftnl_set_elem_add, + nftnl_set_elem_alloc, nftnl_set_elem_set, nftnl_set_elem_set_u32, nftnl_set_set_u32, }, set::{Set, SetKey}, }; -use super::{Address, FilterRule, Policy, Port, Protocol, State, get_set_id}; -use crate::enterprise::firewall::{FirewallError, SnatBinding, iprange::IpAddrRange, max_address}; +use super::{ + Address, FilterRule, Policy, Port, Protocol, State, get_set_id, + monitor::{LogTag, nflog_group}, +}; +use crate::enterprise::firewall::{ + FirewallError, SnatBinding, iprange::IpAddrRange, max_address, monitor::Direction, +}; const FILTER_TABLE: &str = "filter"; const NAT_TABLE: &str = "nat"; @@ -80,6 +86,39 @@ impl SetKey for Protocol { } } +/// `log group prefix ` statement, passing packets to the connection monitor. +#[derive(Clone, Debug)] +pub(super) struct Log { + group: u16, + prefix: CString, +} + +impl Log { + pub(super) fn new(ifname: &str, tag: LogTag) -> Self { + Self { + group: nflog_group(ifname), + prefix: CString::new(tag.to_prefix()).expect("Log prefix contains a nul byte"), + } + } +} + +impl Expression for Log { + fn to_expr(&self, _rule: &Rule) -> NonNull { + let expr = NonNull::new(unsafe { nftnl_expr_alloc(c"log".as_ptr()) }) + .expect("Failed to allocate memory for log expression"); + unsafe { + nftnl_expr_set_u16(expr.as_ptr(), NFTNL_EXPR_LOG_GROUP as u16, self.group); + // The prefix is copied by libnftnl. + nftnl_expr_set_str( + expr.as_ptr(), + NFTNL_EXPR_LOG_PREFIX as u16, + self.prefix.as_ptr(), + ); + } + expr + } +} + pub trait FirewallRule { fn to_chain_rule<'a>( &self, @@ -407,6 +446,12 @@ impl FirewallRule for FilterRule<'_> { matches.push(format!("ANY CT STATES: {:?}", self.states)); } + if let Some(log) = &self.log { + // log group prefix + rule.add_expr(log); + debug!("Added log expression to rule: {log:?}"); + } + if self.counter { // counter rule.add_expr(&nft_expr!(counter)); @@ -746,6 +791,39 @@ pub(super) fn allow_established_traffic( Ok(()) } +/// Reports new connections coming into the VPN to the connection monitor. +/// Inbound traffic is not subject to ACL rules, so it is accepted here as well. +pub(super) fn log_inbound_traffic(batch: &mut Batch, ifname: &str) -> Result<(), FirewallError> { + let table = Tables::Defguard(ProtoFamily::Inet).to_table(ifname); + batch.add(&table, MsgType::Add); + + let forward_chain = Chains::Forward.to_chain(&table); + batch.add(&forward_chain, MsgType::Add); + + let inbound_rule = FilterRule { + iifname: Some(ifname.to_string()), + negated_iifname: true, + oifname: Some(ifname.to_string()), + states: vec![State::New], + action: Policy::Allow, + counter: true, + comment: Some("Log new inbound VPN connections".to_string()), + log: Some(Log::new( + ifname, + LogTag { + direction: Direction::Inbound, + verdict: Policy::Allow, + rule_id: None, + }, + )), + ..Default::default() + } + .to_chain_rule(&forward_chain, batch)?; + batch.add(&inbound_rule, MsgType::Add); + + Ok(()) +} + pub(super) fn ignore_unrelated_traffic( batch: &mut Batch, ifname: &str, diff --git a/src/enterprise/firewall/nftables/tests.rs b/src/enterprise/firewall/nftables/tests.rs new file mode 100644 index 00000000..e98ce3ca --- /dev/null +++ b/src/enterprise/firewall/nftables/tests.rs @@ -0,0 +1,320 @@ +use std::cmp::Ordering; + +use ipnetwork::{IpNetwork, Ipv4Network, Ipv6Network}; + +use super::*; + +#[test] +fn test_sorting() { + let mut addrs = vec![ + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(10, 10, 10, 11), 24).unwrap(), + )), + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(10, 10, 10, 12), 24).unwrap(), + )), + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(10, 10, 11, 10), 32).unwrap(), + )), + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(10, 10, 11, 11), 32).unwrap(), + )), + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(10, 10, 10, 10), 24).unwrap(), + )), + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(10, 10, 11, 12), 32).unwrap(), + )), + ]; + + addrs.sort_by(|a, b| a.first().partial_cmp(&b.first()).unwrap_or(Ordering::Equal)); + + assert_eq!( + addrs, + vec![ + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(10, 10, 10, 10), 24).unwrap(), + )), + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(10, 10, 10, 11), 24).unwrap(), + )), + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(10, 10, 10, 12), 24).unwrap(), + )), + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(10, 10, 11, 10), 32).unwrap(), + )), + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(10, 10, 11, 11), 32).unwrap(), + )), + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(10, 10, 11, 12), 32).unwrap(), + )), + ] + ); + + let _prepared_addrs = merge_addrs(addrs).unwrap(); +} + +#[test] +fn test_merge_addrs_empty() { + let addrs = Vec::new(); + let result = merge_addrs(addrs).unwrap(); + assert!(result.is_empty()); +} + +#[test] +fn test_merge_addrs_single_address() { + let addrs = vec![Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(192, 168, 1, 10), 32).unwrap(), + ))]; + let result = merge_addrs(addrs.clone()).unwrap(); + + assert_eq!(result, addrs); +} + +#[test] +fn test_merge_addrs_adjacent_ranges() { + let addrs = vec![ + Address::Range( + IpAddrRange::new( + IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)), + IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20)), + ) + .unwrap(), + ), + Address::Range( + IpAddrRange::new( + IpAddr::V4(Ipv4Addr::new(192, 168, 1, 21)), + IpAddr::V4(Ipv4Addr::new(192, 168, 1, 30)), + ) + .unwrap(), + ), + ]; + let result = merge_addrs(addrs).unwrap(); + + assert_eq!(result.len(), 1); + if let Address::Range(range) = &result[0] { + assert_eq!(range.start(), IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10))); + assert_eq!(range.end(), IpAddr::V4(Ipv4Addr::new(192, 168, 1, 30))); + } else { + panic!("Expected Address::Range"); + } +} + +#[test] +fn test_merge_addrs_adjacent_single_addresses() { + let addrs = vec![ + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(192, 168, 1, 10), 32).unwrap(), + )), + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(192, 168, 1, 11), 32).unwrap(), + )), + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(192, 168, 1, 12), 32).unwrap(), + )), + ]; + let result = merge_addrs(addrs).unwrap(); + + assert_eq!(result.len(), 1); + if let Address::Range(range) = &result[0] { + assert_eq!(range.start(), IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10))); + assert_eq!(range.end(), IpAddr::V4(Ipv4Addr::new(192, 168, 1, 12))); + } else { + panic!("Expected Address::Range"); + } +} + +#[test] +fn test_merge_addrs_non_adjacent_ranges() { + let addrs = vec![ + Address::Range( + IpAddrRange::new( + IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)), + IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20)), + ) + .unwrap(), + ), + Address::Range( + IpAddrRange::new( + IpAddr::V4(Ipv4Addr::new(192, 168, 1, 30)), + IpAddr::V4(Ipv4Addr::new(192, 168, 1, 40)), + ) + .unwrap(), + ), + ]; + let result = merge_addrs(addrs).unwrap(); + + assert_eq!(result.len(), 2); + if let Address::Range(range1) = &result[0] { + assert_eq!(range1.start(), IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10))); + assert_eq!(range1.end(), IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20))); + } else { + panic!("Expected Address::Range"); + } + if let Address::Range(range2) = &result[1] { + assert_eq!(range2.start(), IpAddr::V4(Ipv4Addr::new(192, 168, 1, 30))); + assert_eq!(range2.end(), IpAddr::V4(Ipv4Addr::new(192, 168, 1, 40))); + } else { + panic!("Expected Address::Range"); + } +} + +#[test] +fn test_merge_addrs_mixed_networks_and_ranges() { + let addrs = vec![ + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(192, 168, 1, 10), 32).unwrap(), + )), + Address::Range( + IpAddrRange::new( + IpAddr::V4(Ipv4Addr::new(192, 168, 1, 11)), + IpAddr::V4(Ipv4Addr::new(192, 168, 1, 15)), + ) + .unwrap(), + ), + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(192, 168, 1, 16), 32).unwrap(), + )), + ]; + let result = merge_addrs(addrs).unwrap(); + + assert_eq!(result.len(), 1); + if let Address::Range(range) = &result[0] { + assert_eq!(range.start(), IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10))); + assert_eq!(range.end(), IpAddr::V4(Ipv4Addr::new(192, 168, 1, 16))); + } else { + panic!("Expected Address::Range"); + } +} + +#[test] +fn test_merge_addrs_non_adjacent_singles() { + let addrs = vec![ + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(192, 168, 1, 10), 32).unwrap(), + )), + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(192, 168, 1, 11), 32).unwrap(), + )), + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(192, 168, 1, 15), 32).unwrap(), + )), + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(192, 168, 1, 20), 32).unwrap(), + )), + ]; + let result = merge_addrs(addrs).unwrap(); + + // These should result in 3 separate ranges: 10-11, 15, 20 + let expected_addrs = vec![ + Address::Range( + IpAddrRange::new( + IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)), + IpAddr::V4(Ipv4Addr::new(192, 168, 1, 11)), + ) + .unwrap(), + ), + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(192, 168, 1, 15), 32).unwrap(), + )), + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(192, 168, 1, 20), 32).unwrap(), + )), + ]; + assert_eq!(result, expected_addrs); +} + +#[test] +fn test_merge_addrs_ipv6() { + let addrs = vec![ + Address::Network(IpNetwork::V6( + Ipv6Network::new(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 1), 128).unwrap(), + )), + Address::Network(IpNetwork::V6( + Ipv6Network::new(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 2), 128).unwrap(), + )), + Address::Network(IpNetwork::V6( + Ipv6Network::new(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 3), 128).unwrap(), + )), + ]; + let result = merge_addrs(addrs).unwrap(); + + assert_eq!(result.len(), 1); + if let Address::Range(range) = &result[0] { + assert_eq!( + range.start(), + IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 1)) + ); + assert_eq!( + range.end(), + IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 3)) + ); + } else { + panic!("Expected Address::Range"); + } +} + +#[test] +fn test_next_ip_ipv4() { + assert_eq!( + next_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10))), + IpAddr::V4(Ipv4Addr::new(192, 168, 1, 11)) + ); + + // Test overflow + assert_eq!( + next_ip(IpAddr::V4(Ipv4Addr::BROADCAST)), + IpAddr::V4(Ipv4Addr::UNSPECIFIED) + ); +} + +#[test] +fn test_next_ip_ipv6() { + assert_eq!( + next_ip(IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 1))), + IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 2)) + ); + + // Test overflow + assert_eq!( + next_ip(IpAddr::V6(Ipv6Addr::new( + 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff + ))), + IpAddr::V6(Ipv6Addr::UNSPECIFIED) + ); +} + +#[test] +fn test_merge_addrs_gap() { + let addrs = vec![ + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(192, 168, 1, 1), 32).unwrap(), + )), + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(192, 168, 1, 100), 32).unwrap(), + )), + ]; + let result = merge_addrs(addrs.clone()).unwrap(); + + // Should not merge since there's a gap + assert_eq!(result, addrs); + + let addrs = vec![ + Address::Range( + IpAddrRange::new( + IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)), + IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20)), + ) + .unwrap(), + ), + Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(192, 168, 1, 100), 32).unwrap(), + )), + ]; + let result = merge_addrs(addrs.clone()).unwrap(); + + // Should not merge since there's a gap + assert_eq!(result, addrs); +} diff --git a/src/enterprise/firewall/packetfilter/api.rs b/src/enterprise/firewall/packetfilter/api.rs index af541bfe..8a84ed77 100644 --- a/src/enterprise/firewall/packetfilter/api.rs +++ b/src/enterprise/firewall/packetfilter/api.rs @@ -4,7 +4,7 @@ use defguard_wireguard_rs::bsd::c_int_to_error; use libc::ioctl; use super::{ - FirewallRule, + FirewallRule, anchor_rules, calls::{DIOCXBEGIN, DIOCXCOMMIT, DIOCXROLLBACK, IocTrans, IocTransElement}, rule::RuleSet, }; @@ -42,20 +42,9 @@ impl FirewallManagementApi for FirewallApi { let ticket = elements[0].ticket; let pool_ticket = self.get_pool_ticket(anchor)?; - // Create first rule from the default policy. - if let Err(err) = self.add_rule_policy(ticket, pool_ticket, anchor) { - error!("Default policy rule can't be added"); - debug!("Rollback pf transaction"); - // Rule cannot be added, so rollback. - let result = unsafe { ioctl(self.fd(), DIOCXROLLBACK, &raw mut ioc_trans) }; - c_int_to_error(result)?; - - return Err(FirewallError::TransactionFailed(err.to_string())); - } - - for rule in rules { - if let Err(err) = self.add_rule(rule, ticket, pool_ticket, anchor) { - error!("Firewall rule {} can't be added", rule.id); + for (rule, _) in anchor_rules(&self.ifname, self.default_policy, rules, self.log_interface) + { + if let Err(err) = self.add_rule(&rule, ticket, pool_ticket, anchor) { debug!("Rollback pf transaction"); // Rule cannot be added, so rollback. let result = unsafe { ioctl(self.fd(), DIOCXROLLBACK, &raw mut ioc_trans) }; diff --git a/src/enterprise/firewall/packetfilter/calls.rs b/src/enterprise/firewall/packetfilter/calls.rs index 6fc5efca..5c3a46ef 100644 --- a/src/enterprise/firewall/packetfilter/calls.rs +++ b/src/enterprise/firewall/packetfilter/calls.rs @@ -11,7 +11,7 @@ use defguard_wireguard_rs::bsd::ioctl::iowr; use ipnetwork::IpNetwork; use libc::{IFNAMSIZ, c_ulong, pid_t, uid_t}; -use super::rule::{Action, AddressFamily, Direction, PacketFilterRule, RuleSet, State}; +use super::rule::{Action, AddressFamily, Direction, PF_LOG, PacketFilterRule, RuleSet, State}; use crate::enterprise::firewall::Port; /// Equivalent to `struct pf_addr`: fits 128-bit address, either IPv4 or IPv6. @@ -572,7 +572,10 @@ impl Rule { self_ptr.action = pf_rule.action; self_ptr.direction = pf_rule.direction; - self_ptr.log = pf_rule.log; + if let Some(logif) = pf_rule.log { + self_ptr.log = PF_LOG; + self_ptr.logif = logif; + } self_ptr.quick = pf_rule.quick; self_ptr.keep_state = pf_rule.state; diff --git a/src/enterprise/firewall/packetfilter/mod.rs b/src/enterprise/firewall/packetfilter/mod.rs index 325b7804..632333bf 100644 --- a/src/enterprise/firewall/packetfilter/mod.rs +++ b/src/enterprise/firewall/packetfilter/mod.rs @@ -14,6 +14,8 @@ //! https://man.openbsd.org/pf.4 mod calls; +#[cfg(any(target_os = "freebsd", target_os = "macos"))] +mod monitor; mod rule; use std::os::fd::{AsRawFd, RawFd}; @@ -21,11 +23,13 @@ use std::os::fd::{AsRawFd, RawFd}; use defguard_wireguard_rs::bsd::c_int_to_error; use libc::ioctl; +#[cfg(any(target_os = "freebsd", target_os = "macos"))] +pub use self::monitor::ConnectionMonitor; use self::{ - calls::{Change, DIOCADDRULE, DIOCBEGINADDRS, IocPoolAddr, IocRule, Rule}, + calls::{DIOCADDRULE, DIOCBEGINADDRS, IocPoolAddr, IocRule, Rule}, rule::PacketFilterRule, }; -use super::{FirewallError, FirewallRule, api::FirewallApi}; +use super::{FirewallError, FirewallRule, Policy, api::FirewallApi}; use crate::enterprise::firewall::Port; const ANCHOR_PREFIX: &str = "defguard/"; @@ -50,14 +54,16 @@ impl FirewallApi { Ok(ioc.ticket) } - fn add_rule_policy( + /// Add `rule` to `anchor`. + fn add_rule( &self, + rule: &PacketFilterRule, ticket: u32, pool_ticket: u32, anchor: &str, ) -> Result<(), FirewallError> { - let rule = PacketFilterRule::for_policy(self.default_policy, &self.ifname); - let mut ioc = IocRule::with_rule(anchor, Rule::from_pf_rule(&rule)); + debug!("Adding Packet Filter rule {rule}"); + let mut ioc = IocRule::with_rule(anchor, Rule::from_pf_rule(rule)); ioc.ticket = ticket; ioc.pool_ticket = pool_ticket; let result = unsafe { ioctl(self.fd(), DIOCADDRULE, &raw mut ioc) }; @@ -68,32 +74,32 @@ impl FirewallApi { Ok(()) } +} - /// Add a single firewall `rule`. - fn add_rule( - &self, - rule: &FirewallRule, - ticket: u32, - pool_ticket: u32, - anchor: &str, - ) -> Result<(), FirewallError> { - debug!("add_rule {rule:?}"); - let rules = PacketFilterRule::from_firewall_rule(&self.ifname, rule); - - for rule in rules { - let mut ioc = IocRule::with_rule(anchor, Rule::from_pf_rule(&rule)); - ioc.action = Change::None; - ioc.ticket = ticket; - ioc.pool_ticket = pool_ticket; - let result = unsafe { ioctl(self.fd(), DIOCADDRULE, &raw mut ioc) }; - if let Err(err) = c_int_to_error(result) { - error!("Packet filter rule {rule} can't be added."); - return Err(err.into()); - } - } - - Ok(()) +/// Rules in the Defguard anchor, in order, with the Defguard rule ID each one comes from. +/// Also used by the connection monitor to map logged rule numbers to Defguard rules. +/// Rules log to pflog unit `log`, if set. +fn anchor_rules( + ifname: &str, + default_policy: Policy, + rules: &[FirewallRule], + log: Option, +) -> Vec<(PacketFilterRule, Option)> { + let mut anchor_rules = vec![ + ( + PacketFilterRule::for_policy(default_policy, ifname, log), + None, + ), + (PacketFilterRule::for_inbound(ifname, log), None), + ]; + for rule in rules { + anchor_rules.extend( + PacketFilterRule::from_firewall_rule(ifname, rule, log) + .into_iter() + .map(|pf_rule| (pf_rule, Some(rule.id))), + ); } + anchor_rules } mod api; diff --git a/src/enterprise/firewall/packetfilter/monitor.rs b/src/enterprise/firewall/packetfilter/monitor.rs new file mode 100644 index 00000000..59d5a824 --- /dev/null +++ b/src/enterprise/firewall/packetfilter/monitor.rs @@ -0,0 +1,630 @@ +//! Connection monitoring for the Packet Filter firewall on FreeBSD and macOS. +//! +//! Defguard rules are created with the `log` option, so pf passes the packet which creates a state +//! (or is blocked) to a pflog interface, which is read through BPF. Each logged packet carries +//! the number of the matching rule within the Defguard anchor, which is mapped back to the Defguard +//! rule (see [`ConnectionMonitor::update_rules`]). +//! +//! Blocked connections are reported straight away. pf has no notification of state removal, so +//! while allowed connections are in progress, the state table is polled periodically. Once a state +//! is gone, its connection is reported with the traffic counters from the last poll; traffic since +//! then is not counted. + +#[cfg(target_os = "freebsd")] +mod freebsd; +#[cfg(target_os = "macos")] +mod macos; + +use std::{ + collections::HashMap, + ffi::{CStr, CString}, + fs::File, + io, mem, + os::fd::{AsRawFd, FromRawFd, OwnedFd}, + sync::{Arc, Mutex}, + thread::{self, JoinHandle}, + time::{Duration, Instant, SystemTime}, +}; + +use defguard_wireguard_rs::bsd::ioctl::{ior, iow}; +use libc::{c_int, c_uint, c_ulong}; +use tokio::sync::broadcast::Sender; + +#[cfg(target_os = "freebsd")] +use self::freebsd as platform; +#[cfg(target_os = "macos")] +use self::macos as platform; +use self::platform::{STATE_SIZE, open_bpf, parse_bpf_header, parse_state, read_states}; +use super::anchor_rules; +use crate::{ + config::PflogIfname, + enterprise::firewall::{ + FirewallConfig, FirewallError, FirewallRule, Policy, + flow::{FlowKey, parse_packet, protocol_has_ports}, + monitor::{ConnectionEvent, Direction, Status}, + }, +}; + +const DEV_PF: &str = "/dev/pf"; + +// net/bpf.h +const BIOCGBLEN: c_ulong = ior::(b'B', 102); +const BIOCSETIF: c_ulong = iow::(b'B', 108); +const BIOCIMMEDIATE: c_ulong = iow::(b'B', 112); + +// net/if_pflog.h: offsets in `struct pfloghdr`. +const PFLOG_LENGTH: usize = 0; +const PFLOG_ACTION: usize = 2; +const PFLOG_IFNAME_OFFSET: usize = 4; +const PFLOG_RULESET: usize = 20; +const PFLOG_SUBRULENR: usize = 40; +const PFLOG_DIR: usize = 60; +const PFLOG_ALIGNMENT: usize = 4; + +// pf.h +const PF_PASS: u8 = 0; +const PF_DROP: u8 = 1; +const PF_IN: u8 = 1; +const PF_OUT: u8 = 2; + +/// How often the state table is read while allowed connections are in progress. +const STATE_POLL_INTERVAL: Duration = Duration::from_secs(5); +/// Upper bound on the number of allowed connections in progress. +const MAX_PENDING_CONNECTIONS: usize = 65536; + +/// Defguard rule IDs, indexed by rule number within the Defguard anchor. +type RuleMap = Arc>>>; + +/// Monitors connections handled by the Defguard firewall on a WireGuard interface. +/// +/// Monitoring stops when [`ConnectionMonitor::unregister`] is called or the monitor is dropped. +pub struct ConnectionMonitor { + ifname: String, + rules: RuleMap, + wakeup: OwnedFd, + thread: Option>, + /// Dropped after the thread stops. + pflog: PflogInterface, +} + +impl ConnectionMonitor { + /// Register for connection monitoring on interface `ifname`. Connection events are published + /// to `events`; events are dropped while there are no subscribers. + /// + /// Creates `pflog_ifname` if missing. + pub fn register( + ifname: &str, + pflog_ifname: PflogIfname, + events: Sender, + ) -> Result { + debug!("Registering connection monitor for interface {ifname} using {pflog_ifname}"); + let pflog = PflogInterface::create(pflog_ifname)?; + let bpf = open_pflog(&pflog.name)?; + let pf = File::open(DEV_PF)?; + let (wakeup_rx, wakeup) = pipe()?; + + let rules = RuleMap::default(); + let worker = Worker { + ifname: ifname.to_string(), + rules: Arc::clone(&rules), + events, + pf, + pending: HashMap::new(), + generation: 0, + next_poll: Instant::now(), + }; + let thread = thread::Builder::new() + .name(format!("monitor-{ifname}")) + .spawn(move || worker.run(&bpf, &wakeup_rx))?; + + info!("Registered connection monitor for interface {ifname}"); + + Ok(Self { + ifname: ifname.to_string(), + rules, + wakeup, + thread: Some(thread), + pflog, + }) + } + + /// Unit of the pflog interface which rules must log to. + #[must_use] + pub fn pflog_unit(&self) -> u8 { + self.pflog.ifname.0 + } + + /// Unregister from connection monitoring. Connections still in progress are not reported. + pub fn unregister(mut self) { + self.stop(); + } + + /// Update the rule map. Must be called whenever the firewall configuration is applied. + pub(crate) fn update_rules(&self, config: &FirewallConfig) { + *self.rules.lock().expect("Failed to lock rule map") = + rule_map(&self.ifname, config.default_policy, &config.rules); + } + + fn stop(&mut self) { + let Some(thread) = self.thread.take() else { + return; + }; + let ret = unsafe { libc::write(self.wakeup.as_raw_fd(), [0u8].as_ptr().cast(), 1) }; + if ret < 0 { + error!( + "Failed to stop connection monitor for interface {}: {}", + self.ifname, + io::Error::last_os_error() + ); + return; + } + if thread.join().is_err() { + error!( + "Connection monitor thread for interface {} panicked", + self.ifname + ); + } + info!( + "Unregistered connection monitor for interface {}", + self.ifname + ); + } +} + +impl Drop for ConnectionMonitor { + fn drop(&mut self) { + self.stop(); + } +} + +/// Map rule numbers within the Defguard anchor to Defguard rule IDs. +fn rule_map(ifname: &str, default_policy: Policy, rules: &[FirewallRule]) -> Vec> { + anchor_rules(ifname, default_policy, rules, None) + .into_iter() + .map(|(_, rule_id)| rule_id) + .collect() +} + +#[cfg(target_os = "freebsd")] +#[link(name = "c")] +unsafe extern "C" { + // Note: libc crate doesn't export kldload. + fn kldload(file: *const libc::c_char) -> libc::c_int; +} + +#[cfg(target_os = "freebsd")] +pub fn load_pflog_kernel_module() -> Result<(), io::Error> { + // Prefer kldload(2) to kld_load(3). + // The latter needs libutil, which differs between FreeBSD 14 and 15. + let retval = unsafe { kldload(c"pflog".as_ptr()) }; + // Based on https://github.com/freebsd/freebsd-src/blob/main/lib/libutil/kld.c#L70 + if retval == -1 { + let err = io::Error::last_os_error(); + if err.raw_os_error() != Some(libc::EEXIST) { + return Err(err); + } + } + Ok(()) +} + +/// pflog interface; destroyed on drop if created here. +struct PflogInterface { + ifname: PflogIfname, + name: String, + created: bool, +} + +impl PflogInterface { + /// Create interface `ifname` if missing. + fn create(ifname: PflogIfname) -> Result { + #[cfg(target_os = "freebsd")] + if let Err(err) = load_pflog_kernel_module() { + warn!("Failed to load the pflog kernel module: {err}"); + } + let name = ifname.to_string(); + let created = match defguard_wireguard_rs::bsd::create_interface(&name) { + Ok(()) => { + info!("Created interface {name}"); + true + } + Err(_) if interface_exists(&name) => { + debug!("Using existing interface {name}"); + false + } + Err(err) => { + return Err(FirewallError::InvalidConfiguration(format!( + "Failed to create {name}: {err}. {}", + platform::PFLOG_HINT + ))); + } + }; + Ok(Self { + ifname, + name, + created, + }) + } +} + +impl Drop for PflogInterface { + fn drop(&mut self) { + if !self.created { + return; + } + match defguard_wireguard_rs::bsd::delete_interface(&self.name) { + Ok(()) => info!("Destroyed interface {}", self.name), + Err(err) => error!("Failed to destroy interface {}: {err}", self.name), + } + } +} + +/// Check if network interface `name` exists. +fn interface_exists(name: &str) -> bool { + CString::new(name).is_ok_and(|name| unsafe { libc::if_nametoindex(name.as_ptr()) } != 0) +} + +/// Open a BPF device listening on `pflog_ifname`. +fn open_pflog(pflog_ifname: &str) -> Result { + let bpf = open_bpf()?; + + let mut ifreq: libc::ifreq = unsafe { mem::zeroed() }; + for (dst, src) in ifreq + .ifr_name + .iter_mut() + .take(libc::IFNAMSIZ - 1) + .zip(pflog_ifname.bytes()) + { + *dst = src as libc::c_char; + } + if unsafe { libc::ioctl(bpf.as_raw_fd(), BIOCSETIF, &raw const ifreq) } < 0 { + return Err(FirewallError::InvalidConfiguration(format!( + "Failed to listen on {pflog_ifname}: {}. {}", + io::Error::last_os_error(), + platform::PFLOG_HINT + ))); + } + // Deliver packets as soon as they arrive, instead of when the buffer fills up. + let immediate: c_uint = 1; + if unsafe { libc::ioctl(bpf.as_raw_fd(), BIOCIMMEDIATE, &raw const immediate) } < 0 { + return Err(io::Error::last_os_error().into()); + } + + Ok(bpf) +} + +/// Create a pipe used to wake up the monitor thread; returns (read end, write end). +fn pipe() -> io::Result<(OwnedFd, OwnedFd)> { + let mut fds = [0; 2]; + if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 { + return Err(io::Error::last_os_error()); + } + let fds = unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) }; + for fd in [&fds.0, &fds.1] { + if unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) } < 0 { + return Err(io::Error::last_os_error()); + } + } + Ok(fds) +} + +/// Allowed connection in progress. +struct Pending { + event: ConnectionEvent, + /// Bytes sent by the initiator and the responder, as of the last state poll. + bytes: (u64, u64), + /// Last state poll which found this connection. + generation: u64, +} + +struct Worker { + ifname: String, + rules: RuleMap, + events: Sender, + pf: File, + pending: HashMap, + /// Number of state polls so far. + generation: u64, + next_poll: Instant, +} + +impl Worker { + fn run(mut self, bpf: &File, wakeup: &OwnedFd) { + let mut buf_len: c_uint = 0; + if unsafe { libc::ioctl(bpf.as_raw_fd(), BIOCGBLEN, &raw mut buf_len) } < 0 { + error!( + "Connection monitor for interface {} failed to get BPF buffer size: {}", + self.ifname, + io::Error::last_os_error() + ); + return; + } + // Use `u64` to keep the buffer aligned for `struct bpf_hdr`. + let mut buf = vec![0u64; (buf_len as usize).div_ceil(8)]; + + loop { + // Wake up for the next state poll only while there are connections in progress. + let timeout = if self.pending.is_empty() { + -1 + } else { + let remaining = self.next_poll.saturating_duration_since(Instant::now()); + c_int::try_from(remaining.as_millis()).unwrap_or(c_int::MAX) + }; + let mut fds = [bpf.as_raw_fd(), wakeup.as_raw_fd()].map(|fd| libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, + }); + if unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as libc::nfds_t, timeout) } < 0 { + let err = io::Error::last_os_error(); + if err.kind() == io::ErrorKind::Interrupted { + continue; + } + error!( + "Connection monitor for interface {} failed: {err}", + self.ifname + ); + break; + } + if fds[1].revents != 0 { + break; + } + if fds[0].revents != 0 { + let len = unsafe { + libc::read( + bpf.as_raw_fd(), + buf.as_mut_ptr().cast(), + buf.len() * mem::size_of::(), + ) + }; + if len < 0 { + let err = io::Error::last_os_error(); + if err.kind() != io::ErrorKind::Interrupted { + error!( + "Connection monitor for interface {} failed to read from BPF: {err}", + self.ifname + ); + break; + } + } else { + let bytes = unsafe { + std::slice::from_raw_parts(buf.as_ptr().cast::(), len as usize) + }; + self.handle_bpf(bytes); + } + } + if !self.pending.is_empty() && Instant::now() >= self.next_poll { + self.poll_states(); + } + } + debug!("Connection monitor for interface {} stopped", self.ifname); + } + + /// Process packets read from BPF; each is preceded by `struct bpf_hdr`. + fn handle_bpf(&mut self, mut buf: &[u8]) { + while let Some(header) = parse_bpf_header(buf) { + let end = header.length + header.captured; + let Some(packet) = buf.get(header.length..end) else { + break; + }; + self.handle_pflog(packet, header.timestamp); + buf = buf + .get(end.next_multiple_of(platform::BPF_ALIGNMENT)..) + .unwrap_or_default(); + } + } + + /// Process a packet logged by pf, preceded by `struct pfloghdr`. + fn handle_pflog(&mut self, packet: &[u8], timestamp: SystemTime) { + let Some(log) = PflogHeader::parse(packet) else { + debug!("Ignoring unrecognized pflog packet"); + return; + }; + // The ruleset is the name of the anchor, which is the interface name. + if log.ifname != self.ifname || log.ruleset != self.ifname { + return; + } + let status = match log.action { + PF_PASS => Status::Allowed, + PF_DROP => Status::Blocked, + _ => return, + }; + let direction = match log.dir { + PF_IN => Direction::Outbound, + PF_OUT => Direction::Inbound, + _ => return, + }; + let Some(key) = packet.get(log.length..).and_then(parse_packet) else { + debug!("Ignoring unrecognized packet logged by pf"); + return; + }; + // VPN-to-VPN traffic is logged both ways; report it only as outbound. + if direction == Direction::Inbound + && self + .pending + .get(&key) + .is_some_and(|pending| pending.event.direction == Direction::Outbound) + { + return; + } + let rule_id = self + .rules + .lock() + .expect("Failed to lock rule map") + .get(log.rule_number as usize) + .copied() + .flatten(); + + let has_ports = protocol_has_ports(key.protocol); + let event = ConnectionEvent { + timestamp, + direction, + status, + protocol: key.protocol, + source: key.source, + source_port: has_ports.then_some(key.source_port), + destination: key.destination, + destination_port: has_ports.then_some(key.destination_port), + rule_id, + upload_bytes: 0, + download_bytes: 0, + }; + + if status == Status::Blocked { + self.emit(event); + return; + } + if self.pending.is_empty() { + self.next_poll = Instant::now() + STATE_POLL_INTERVAL; + } + if self.pending.len() >= MAX_PENDING_CONNECTIONS && !self.pending.contains_key(&key) { + self.forget_oldest(); + } + trace!("Tracking allowed connection {key:?}"); + let pending = Pending { + event, + bytes: (0, 0), + generation: self.generation, + }; + // A new connection reuses the address and ports, so the previous one has ended. + if let Some(previous) = self.pending.insert(key, pending) { + self.finish(previous); + } + } + + /// Update traffic counters of connections in progress, and report the ones which have ended. + fn poll_states(&mut self) { + self.next_poll = Instant::now() + STATE_POLL_INTERVAL; + match read_states(&self.pf) { + Ok(states) => self.update_states(&states), + Err(err) => warn!("Failed to read Packet Filter states: {err}"), + } + } + + /// Process the state table, an array of platform-specific state structures. + fn update_states(&mut self, states: &[u8]) { + self.generation += 1; + for state in states.as_chunks::().0 { + let Some(state) = parse_state(state) else { + continue; + }; + // Not all platforms record the interface on which states were created. + if state.ifname.is_some_and(|ifname| ifname != self.ifname) { + continue; + } + let (key, bytes) = (state.key, state.bytes); + if let Some(pending) = self.pending.get_mut(&key) { + pending.bytes = bytes; + pending.generation = self.generation; + } + } + + let ended: Vec<_> = self + .pending + .iter() + .filter(|(_, pending)| pending.generation != self.generation) + .map(|(key, _)| key.clone()) + .collect(); + for key in ended { + if let Some(pending) = self.pending.remove(&key) { + self.finish(pending); + } + } + } + + /// Stop tracking the oldest connection in progress, and report it. + fn forget_oldest(&mut self) { + let Some(oldest) = self + .pending + .iter() + .min_by_key(|(_, pending)| pending.event.timestamp) + .map(|(key, _)| key.clone()) + else { + return; + }; + debug!("Too many tracked connections, reporting {oldest:?} early"); + if let Some(pending) = self.pending.remove(&oldest) { + self.finish(pending); + } + } + + /// Report an ended connection with its traffic counters. + fn finish(&self, pending: Pending) { + let mut event = pending.event; + let (initiator, responder) = pending.bytes; + (event.upload_bytes, event.download_bytes) = match event.direction { + Direction::Outbound => (initiator, responder), + Direction::Inbound => (responder, initiator), + }; + self.emit(event); + } + + fn emit(&self, event: ConnectionEvent) { + // Sending fails only if there are no subscribers at the moment. + if self.events.send(event).is_err() { + trace!("No subscribers for connection events"); + } + } +} + +/// Fields of `struct pfloghdr` used by the monitor. +#[derive(Debug, PartialEq)] +struct PflogHeader<'a> { + /// Header length, including padding. + length: usize, + action: u8, + ifname: &'a str, + ruleset: &'a str, + /// Number of the matching rule within its anchor. + rule_number: u32, + dir: u8, +} + +impl<'a> PflogHeader<'a> { + fn parse(packet: &'a [u8]) -> Option { + let length = usize::from(*packet.get(PFLOG_LENGTH)?).next_multiple_of(PFLOG_ALIGNMENT); + if length <= PFLOG_DIR || packet.len() < length { + return None; + } + Some(Self { + length, + action: packet[PFLOG_ACTION], + ifname: c_str(&packet[PFLOG_IFNAME_OFFSET..PFLOG_RULESET]), + ruleset: c_str(&packet[PFLOG_RULESET..PFLOG_RULESET + 16]), + rule_number: u32::from_be_bytes( + packet[PFLOG_SUBRULENR..PFLOG_SUBRULENR + 4] + .try_into() + .ok()?, + ), + dir: packet[PFLOG_DIR], + }) + } +} + +/// Fields of `struct bpf_hdr`. +struct BpfHeader { + /// Header length, including padding. + length: usize, + /// Length of the captured packet. + captured: usize, + timestamp: SystemTime, +} + +/// Connection details from a Packet Filter state. +struct State<'a> { + /// Interface on which the state was created, if known. + ifname: Option<&'a str>, + key: FlowKey, + /// Bytes sent by the initiator and the responder. + bytes: (u64, u64), +} + +/// Interpret a nul-terminated buffer as a string; empty if it's not valid UTF-8. +fn c_str(buf: &[u8]) -> &str { + CStr::from_bytes_until_nul(buf) + .ok() + .and_then(|name| name.to_str().ok()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests; diff --git a/src/enterprise/firewall/packetfilter/monitor/freebsd.rs b/src/enterprise/firewall/packetfilter/monitor/freebsd.rs new file mode 100644 index 00000000..115b4050 --- /dev/null +++ b/src/enterprise/firewall/packetfilter/monitor/freebsd.rs @@ -0,0 +1,136 @@ +//! FreeBSD specifics of the connection monitor. + +use std::{ + fs::{File, OpenOptions}, + io, mem, + os::{fd::AsRawFd, unix::fs::OpenOptionsExt}, + ptr, + time::{Duration, UNIX_EPOCH}, +}; + +use defguard_wireguard_rs::bsd::ioctl::iowr; +use libc::{c_int, c_ulong, c_void}; + +use super::{BpfHeader, PF_OUT, State, c_str}; +use crate::enterprise::firewall::flow::{FlowKey, ipv4, ipv6, protocol_has_ports}; + +const DEV_BPF: &str = "/dev/bpf"; + +pub(super) const PFLOG_HINT: &str = + "Load the pflog module with `kldload pflog` or `pflog_enable=\"YES\"` in rc.conf."; + +// net/bpf.h +pub(super) const BPF_ALIGNMENT: usize = mem::size_of::(); +/// `struct bpf_hdr`: `struct timeval` (two 64-bit fields), then lengths. +const BPF_CAPLEN: usize = 16; +const BPF_HDRLEN: usize = 24; + +// net/pfvar.h: `struct pf_state_export` and offsets of its fields. +const PF_STATE_VERSION: u64 = 20_230_404; +pub(super) const STATE_SIZE: usize = 384; +pub(super) const STATE_ORIG_IFNAME: usize = 32; +/// `key[PF_SK_WIRE]`: `addr[2]` followed by `port[2]`. +pub(super) const STATE_KEY: usize = 48; +pub(super) const STATE_BYTES: usize = 240; +pub(super) const STATE_AF: usize = 264; +pub(super) const STATE_PROTO: usize = 265; +pub(super) const STATE_DIRECTION: usize = 266; + +/// Equivalent to `struct pfioc_states_v2`. +#[repr(C)] +struct IocStatesV2 { + len: c_int, + req_version: u64, + buf: *mut c_void, +} + +const DIOCGETSTATESV2: c_ulong = iowr::(b'D', 93); + +/// Open a BPF device; `/dev/bpf` hands out a new one on each open. +pub(super) fn open_bpf() -> io::Result { + OpenOptions::new() + .read(true) + .custom_flags(libc::O_CLOEXEC) + .open(DEV_BPF) +} + +pub(super) fn parse_bpf_header(buf: &[u8]) -> Option { + let field = |offset: usize, len: usize| buf.get(offset..offset + len); + let secs = i64::from_ne_bytes(field(0, 8)?.try_into().ok()?); + let usecs = i64::from_ne_bytes(field(8, 8)?.try_into().ok()?); + Some(BpfHeader { + length: usize::from(u16::from_ne_bytes(field(BPF_HDRLEN, 2)?.try_into().ok()?)), + captured: u32::from_ne_bytes(field(BPF_CAPLEN, 4)?.try_into().ok()?) as usize, + timestamp: UNIX_EPOCH + + Duration::from_secs(secs as u64) + + Duration::from_micros(usecs as u64), + }) +} + +/// Parse `struct pf_state_export`. +pub(super) fn parse_state(state: &[u8; STATE_SIZE]) -> Option> { + let protocol = state[STATE_PROTO]; + let addr = |index: usize| { + let data = &state[STATE_KEY + index * 16..]; + match i32::from(state[STATE_AF]) { + libc::AF_INET => ipv4(data), + libc::AF_INET6 => ipv6(data), + _ => None, + } + }; + let port = |index: usize| { + let offset = STATE_KEY + 32 + index * 2; + u16::from_be_bytes([state[offset], state[offset + 1]]) + }; + // The key is ordered by the direction in which the state was created. + let (src, dst) = if state[STATE_DIRECTION] == PF_OUT { + (1, 0) + } else { + (0, 1) + }; + // Match the way connections are identified from packets: ICMP by identifier only. + let (source_port, destination_port) = match i32::from(protocol) { + libc::IPPROTO_ICMP | libc::IPPROTO_ICMPV6 => (port(src), 0), + _ if protocol_has_ports(protocol) => (port(src), port(dst)), + _ => (0, 0), + }; + let counter = |index: usize| { + let offset = STATE_BYTES + index * 8; + u64::from_ne_bytes(state[offset..offset + 8].try_into().unwrap_or_default()) + }; + Some(State { + ifname: Some(c_str(&state[STATE_ORIG_IFNAME..])), + key: FlowKey { + protocol, + source: addr(src)?, + destination: addr(dst)?, + source_port, + destination_port, + }, + bytes: (counter(0), counter(1)), + }) +} + +/// Read the whole Packet Filter state table, as an array of `struct pf_state_export`. +pub(super) fn read_states(pf: &File) -> io::Result> { + let mut ioc = IocStatesV2 { + len: 0, + req_version: PF_STATE_VERSION, + buf: ptr::null_mut(), + }; + // With zero length, the kernel reports the size needed for the current number of states. + if unsafe { libc::ioctl(pf.as_raw_fd(), DIOCGETSTATESV2, &raw mut ioc) } < 0 { + return Err(io::Error::last_os_error()); + } + // States may be added in the meantime, and the kernel silently truncates the list, so leave + // plenty of room. + let capacity = usize::try_from(ioc.len).unwrap_or_default() * 2 + 64 * STATE_SIZE; + let mut buf = vec![0u8; capacity]; + ioc.len = c_int::try_from(capacity).unwrap_or(c_int::MAX); + ioc.buf = buf.as_mut_ptr().cast(); + if unsafe { libc::ioctl(pf.as_raw_fd(), DIOCGETSTATESV2, &raw mut ioc) } < 0 { + return Err(io::Error::last_os_error()); + } + buf.truncate(usize::try_from(ioc.len).unwrap_or_default()); + Ok(buf) +} diff --git a/src/enterprise/firewall/packetfilter/monitor/macos.rs b/src/enterprise/firewall/packetfilter/monitor/macos.rs new file mode 100644 index 00000000..93a7024d --- /dev/null +++ b/src/enterprise/firewall/packetfilter/monitor/macos.rs @@ -0,0 +1,148 @@ +//! macOS specifics of the connection monitor. +//! +//! Source: https://github.com/apple-oss-distributions/xnu/blob/main/bsd/net/pfvar.h + +use std::{ + fs::{File, OpenOptions}, + io, + os::{fd::AsRawFd, unix::fs::OpenOptionsExt}, + ptr, + time::{Duration, UNIX_EPOCH}, +}; + +use defguard_wireguard_rs::bsd::ioctl::iowr; +use libc::{c_int, c_ulong, c_void}; + +use super::{BpfHeader, PF_OUT, State}; +use crate::enterprise::firewall::flow::{FlowKey, ipv4, ipv6, protocol_has_ports}; + +/// BPF devices are `/dev/bpf0`, `/dev/bpf1`, and so on. +const MAX_BPF_DEVICES: u32 = 256; + +pub(super) const PFLOG_HINT: &str = "Make sure the Gateway runs with root privileges."; + +// net/bpf.h +pub(super) const BPF_ALIGNMENT: usize = 4; +/// `struct bpf_hdr`: `struct timeval32` (two 32-bit fields), then lengths. +const BPF_CAPLEN: usize = 8; +const BPF_HDRLEN: usize = 16; + +// net/pfvar.h: `struct pfsync_state` (packed) and offsets of its fields. +pub(super) const STATE_SIZE: usize = 297; +/// `struct pfsync_state_host`: `struct pf_addr`, then `union pf_state_xport`. +pub(super) const STATE_LAN: usize = 24; +pub(super) const STATE_EXT_LAN: usize = 72; +/// `bytes[2][2]`: each counter split into high and low 32 bits. +pub(super) const STATE_BYTES: usize = 260; +pub(super) const STATE_AF: usize = 282; +pub(super) const STATE_PROTO: usize = 284; +pub(super) const STATE_DIRECTION: usize = 285; + +/// Equivalent to `struct pfioc_states`. +#[repr(C)] +struct IocStates { + len: c_int, + buf: *mut c_void, +} + +const DIOCGETSTATES: c_ulong = iowr::(b'D', 25); + +/// Open the first BPF device which isn't in use. +pub(super) fn open_bpf() -> io::Result { + for index in 0..MAX_BPF_DEVICES { + match OpenOptions::new() + .read(true) + .custom_flags(libc::O_CLOEXEC) + .open(format!("/dev/bpf{index}")) + { + Ok(file) => return Ok(file), + Err(err) if err.raw_os_error() == Some(libc::EBUSY) => {} + Err(err) => return Err(err), + } + } + Err(io::Error::from_raw_os_error(libc::EBUSY)) +} + +pub(super) fn parse_bpf_header(buf: &[u8]) -> Option { + let field = |offset: usize, len: usize| buf.get(offset..offset + len); + let secs = i32::from_ne_bytes(field(0, 4)?.try_into().ok()?); + let usecs = i32::from_ne_bytes(field(4, 4)?.try_into().ok()?); + Some(BpfHeader { + length: usize::from(u16::from_ne_bytes(field(BPF_HDRLEN, 2)?.try_into().ok()?)), + captured: u32::from_ne_bytes(field(BPF_CAPLEN, 4)?.try_into().ok()?) as usize, + timestamp: UNIX_EPOCH + + Duration::from_secs(secs as u64) + + Duration::from_micros(usecs as u64), + }) +} + +/// Parse `struct pfsync_state`. +pub(super) fn parse_state(state: &[u8; STATE_SIZE]) -> Option> { + let af = i32::from(state[STATE_AF]); + // ICMPv6 states are recorded as ICMP. + let protocol = match (i32::from(state[STATE_PROTO]), af) { + (libc::IPPROTO_ICMP, libc::AF_INET6) => libc::IPPROTO_ICMPV6 as u8, + (protocol, _) => protocol as u8, + }; + let addr = |host: usize| match af { + libc::AF_INET => ipv4(&state[host..]), + libc::AF_INET6 => ipv6(&state[host..]), + _ => None, + }; + let port = |host: usize| u16::from_be_bytes([state[host + 16], state[host + 17]]); + // `lan` is the local side, `ext_lan` the remote one: for states created by incoming packets, + // the remote side is the initiator. + let (src, dst) = if state[STATE_DIRECTION] == PF_OUT { + (STATE_LAN, STATE_EXT_LAN) + } else { + (STATE_EXT_LAN, STATE_LAN) + }; + // Match the way connections are identified from packets: ICMP by identifier only, which is + // always stored on the `lan` side. + let (source_port, destination_port) = match i32::from(protocol) { + libc::IPPROTO_ICMP | libc::IPPROTO_ICMPV6 => (port(STATE_LAN), 0), + _ if protocol_has_ports(protocol) => (port(src), port(dst)), + _ => (0, 0), + }; + let counter = |index: usize| { + let offset = STATE_BYTES + index * 8; + let high = u32::from_ne_bytes(state[offset..offset + 4].try_into().unwrap_or_default()); + let low = u32::from_ne_bytes(state[offset + 4..offset + 8].try_into().unwrap_or_default()); + (u64::from(high) << 32) | u64::from(low) + }; + Some(State { + // States don't record the interface on which they were created. + ifname: None, + key: FlowKey { + protocol, + source: addr(src)?, + destination: addr(dst)?, + source_port, + destination_port, + }, + bytes: (counter(0), counter(1)), + }) +} + +/// Read the whole Packet Filter state table, as an array of `struct pfsync_state`. +pub(super) fn read_states(pf: &File) -> io::Result> { + let mut ioc = IocStates { + len: 0, + buf: ptr::null_mut(), + }; + // With zero length, the kernel reports the size needed for the current number of states. + if unsafe { libc::ioctl(pf.as_raw_fd(), DIOCGETSTATES, &raw mut ioc) } < 0 { + return Err(io::Error::last_os_error()); + } + // States may be added in the meantime, and the kernel silently truncates the list, so leave + // plenty of room. + let capacity = usize::try_from(ioc.len).unwrap_or_default() * 2 + 64 * STATE_SIZE; + let mut buf = vec![0u8; capacity]; + ioc.len = c_int::try_from(capacity).unwrap_or(c_int::MAX); + ioc.buf = buf.as_mut_ptr().cast(); + if unsafe { libc::ioctl(pf.as_raw_fd(), DIOCGETSTATES, &raw mut ioc) } < 0 { + return Err(io::Error::last_os_error()); + } + buf.truncate(usize::try_from(ioc.len).unwrap_or_default()); + Ok(buf) +} diff --git a/src/enterprise/firewall/packetfilter/monitor/tests.rs b/src/enterprise/firewall/packetfilter/monitor/tests.rs new file mode 100644 index 00000000..5f95bc24 --- /dev/null +++ b/src/enterprise/firewall/packetfilter/monitor/tests.rs @@ -0,0 +1,266 @@ +use std::{ + net::{IpAddr, Ipv4Addr}, + time::UNIX_EPOCH, +}; + +use ipnetwork::{IpNetwork, Ipv4Network}; +use tokio::sync::broadcast::Receiver; + +use super::*; +use crate::enterprise::firewall::{Address, Policy, Port, Protocol}; + +const IFNAME: &str = "wg0"; + +/// `struct pfloghdr` followed by an IPv4 TCP packet from 10.0.0.2:51000 to 192.168.1.1:443. +fn pflog_packet(action: u8, rule_number: u32, dir: u8) -> Vec { + let mut packet = vec![0u8; 72 + 40]; + packet[PFLOG_LENGTH] = 69; + packet[1] = libc::AF_INET as u8; + packet[PFLOG_ACTION] = action; + packet[PFLOG_IFNAME_OFFSET..PFLOG_IFNAME_OFFSET + 3].copy_from_slice(IFNAME.as_bytes()); + packet[PFLOG_RULESET..PFLOG_RULESET + 3].copy_from_slice(IFNAME.as_bytes()); + packet[36..40].copy_from_slice(&1u32.to_be_bytes()); + packet[PFLOG_SUBRULENR..PFLOG_SUBRULENR + 4].copy_from_slice(&rule_number.to_be_bytes()); + packet[PFLOG_DIR] = dir; + + let ip = &mut packet[72..]; + ip[0] = 0x45; + ip[9] = libc::IPPROTO_TCP as u8; + ip[12..16].copy_from_slice(&[10, 0, 0, 2]); + ip[16..20].copy_from_slice(&[192, 168, 1, 1]); + ip[20..22].copy_from_slice(&51000u16.to_be_bytes()); + ip[22..24].copy_from_slice(&443u16.to_be_bytes()); + packet +} + +/// `struct pf_state_export` for the connection in `pflog_packet()`, created in `direction`. +#[cfg(target_os = "freebsd")] +fn state(direction: u8, initiator_bytes: u64, responder_bytes: u64) -> [u8; STATE_SIZE] { + use super::platform::{ + STATE_AF, STATE_BYTES, STATE_DIRECTION, STATE_KEY, STATE_ORIG_IFNAME, STATE_PROTO, + }; + + let mut state = [0u8; STATE_SIZE]; + state[STATE_ORIG_IFNAME..STATE_ORIG_IFNAME + 3].copy_from_slice(IFNAME.as_bytes()); + // The key is ordered by the direction in which the state was created. + let (src, dst) = if direction == PF_OUT { (1, 0) } else { (0, 1) }; + let addr = |index: usize| STATE_KEY + index * 16; + let port = |index: usize| STATE_KEY + 32 + index * 2; + state[addr(src)..addr(src) + 4].copy_from_slice(&[10, 0, 0, 2]); + state[addr(dst)..addr(dst) + 4].copy_from_slice(&[192, 168, 1, 1]); + state[port(src)..port(src) + 2].copy_from_slice(&51000u16.to_be_bytes()); + state[port(dst)..port(dst) + 2].copy_from_slice(&443u16.to_be_bytes()); + state[STATE_BYTES..STATE_BYTES + 8].copy_from_slice(&initiator_bytes.to_ne_bytes()); + state[STATE_BYTES + 8..STATE_BYTES + 16].copy_from_slice(&responder_bytes.to_ne_bytes()); + state[STATE_AF] = libc::AF_INET as u8; + state[STATE_PROTO] = libc::IPPROTO_TCP as u8; + state[STATE_DIRECTION] = direction; + state +} + +/// `struct pfsync_state` for the connection in `pflog_packet()`, created in `direction`. +#[cfg(target_os = "macos")] +fn state(direction: u8, initiator_bytes: u64, responder_bytes: u64) -> [u8; STATE_SIZE] { + use super::platform::{ + STATE_AF, STATE_BYTES, STATE_DIRECTION, STATE_EXT_LAN, STATE_LAN, STATE_PROTO, + }; + + let mut state = [0u8; STATE_SIZE]; + // For states created by incoming packets, the initiator is on the `ext_lan` side. + let (src, dst) = if direction == PF_OUT { + (STATE_LAN, STATE_EXT_LAN) + } else { + (STATE_EXT_LAN, STATE_LAN) + }; + state[src..src + 4].copy_from_slice(&[10, 0, 0, 2]); + state[src + 16..src + 18].copy_from_slice(&51000u16.to_be_bytes()); + state[dst..dst + 4].copy_from_slice(&[192, 168, 1, 1]); + state[dst + 16..dst + 18].copy_from_slice(&443u16.to_be_bytes()); + for (index, bytes) in [initiator_bytes, responder_bytes].into_iter().enumerate() { + let offset = STATE_BYTES + index * 8; + state[offset..offset + 4].copy_from_slice(&((bytes >> 32) as u32).to_ne_bytes()); + state[offset + 4..offset + 8].copy_from_slice(&(bytes as u32).to_ne_bytes()); + } + state[STATE_AF] = libc::AF_INET as u8; + state[STATE_PROTO] = libc::IPPROTO_TCP as u8; + state[STATE_DIRECTION] = direction; + state +} + +/// Worker for `IFNAME` with the given rule map, and a receiver of its events. +fn worker(rules: Vec>) -> (Worker, Receiver) { + let (events, receiver) = tokio::sync::broadcast::channel(10); + let worker = Worker { + ifname: IFNAME.to_string(), + rules: Arc::new(Mutex::new(rules)), + events, + pf: File::open("/dev/null").unwrap(), + pending: HashMap::new(), + generation: 0, + next_poll: Instant::now(), + }; + (worker, receiver) +} + +fn tcp_key() -> FlowKey { + FlowKey { + protocol: libc::IPPROTO_TCP as u8, + source: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)), + destination: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)), + source_port: 51000, + destination_port: 443, + } +} + +#[test] +fn test_parse_pflog_header() { + let packet = pflog_packet(PF_DROP, 3, PF_IN); + assert_eq!( + PflogHeader::parse(&packet), + Some(PflogHeader { + length: 72, + action: PF_DROP, + ifname: IFNAME, + ruleset: IFNAME, + rule_number: 3, + dir: PF_IN, + }) + ); + assert_eq!(PflogHeader::parse(&packet[..40]), None); +} + +#[test] +fn test_parse_state() { + // Large enough to need both halves of split counters. + for direction in [PF_IN, PF_OUT] { + let state = state(direction, 10, 5 << 32); + let parsed = parse_state(&state).unwrap(); + assert_eq!(parsed.key, tcp_key()); + assert_eq!(parsed.bytes, (10, 5 << 32)); + #[cfg(target_os = "freebsd")] + assert_eq!(parsed.ifname, Some(IFNAME)); + #[cfg(target_os = "macos")] + assert_eq!(parsed.ifname, None); + } +} + +#[test] +fn test_parse_bpf_header() { + let mut buf = [0u8; 32]; + #[cfg(target_os = "freebsd")] + { + buf[0..8].copy_from_slice(&1_700_000_000i64.to_ne_bytes()); + buf[8..16].copy_from_slice(&500i64.to_ne_bytes()); + buf[16..20].copy_from_slice(&112u32.to_ne_bytes()); + buf[24..26].copy_from_slice(&26u16.to_ne_bytes()); + } + #[cfg(target_os = "macos")] + { + buf[0..4].copy_from_slice(&1_700_000_000i32.to_ne_bytes()); + buf[4..8].copy_from_slice(&500i32.to_ne_bytes()); + buf[8..12].copy_from_slice(&112u32.to_ne_bytes()); + buf[16..18].copy_from_slice(&26u16.to_ne_bytes()); + } + let header = parse_bpf_header(&buf).unwrap(); + assert_eq!((header.length, header.captured), (26, 112)); + assert_eq!( + header.timestamp, + UNIX_EPOCH + Duration::from_secs(1_700_000_000) + Duration::from_micros(500) + ); +} + +#[test] +fn test_rule_map() { + let rule = |id, destination_ports: Vec| FirewallRule { + comment: None, + destination_addrs: vec![Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(192, 168, 1, 0), 24).unwrap(), + ))], + destination_ports, + id, + verdict: Policy::Allow, + protocols: vec![Protocol::Tcp], + source_addrs: Vec::new(), + ipv4: true, + }; + let rules = [ + rule(7, vec![Port::Single(80), Port::Single(443)]), + rule(9, Vec::new()), + ]; + assert_eq!( + rule_map(IFNAME, Policy::Deny, &rules), + vec![None, None, Some(7), Some(7), Some(9)] + ); +} + +#[test] +fn test_worker_tracks_connections() { + let (mut worker, mut receiver) = worker(vec![None, Some(7), Some(8)]); + let timestamp = SystemTime::now(); + + // Blocked connections are reported immediately. + worker.handle_pflog(&pflog_packet(PF_DROP, 2, PF_IN), timestamp); + let event = receiver.try_recv().unwrap(); + assert_eq!(event.status, Status::Blocked); + assert_eq!(event.rule_id, Some(8)); + assert_eq!(event.destination_port, Some(443)); + + // Packets logged for other interfaces are ignored. + let mut other = pflog_packet(PF_DROP, 2, PF_IN); + other[PFLOG_IFNAME_OFFSET + 2] = b'1'; + worker.handle_pflog(&other, timestamp); + assert!(receiver.try_recv().is_err()); + + // Allowed connections are reported once their state is gone. + worker.handle_pflog(&pflog_packet(PF_PASS, 1, PF_IN), timestamp); + assert!(receiver.try_recv().is_err()); + worker.update_states(&state(PF_IN, 100, 2048)); + assert!(receiver.try_recv().is_err()); + worker.update_states(&[]); + let event = receiver.try_recv().unwrap(); + assert_eq!(event.status, Status::Allowed); + assert_eq!(event.direction, Direction::Outbound); + assert_eq!(event.rule_id, Some(7)); + assert_eq!((event.upload_bytes, event.download_bytes), (100, 2048)); + assert!(worker.pending.is_empty()); + + // The default policy rule has no Defguard rule ID. + worker.handle_pflog(&pflog_packet(PF_PASS, 0, PF_IN), timestamp); + worker.update_states(&[]); + assert_eq!(receiver.try_recv().unwrap().rule_id, None); +} + +#[test] +fn test_worker_tracks_inbound_connections() { + let (mut worker, mut receiver) = worker(vec![None, None, Some(7)]); + + // Logged by the inbound rule, as the packet leaves through the VPN interface. + worker.handle_pflog(&pflog_packet(PF_PASS, 1, PF_OUT), SystemTime::now()); + worker.update_states(&state(PF_OUT, 100, 2048)); + assert!(receiver.try_recv().is_err()); + worker.update_states(&[]); + + let event = receiver.try_recv().unwrap(); + assert_eq!(event.status, Status::Allowed); + assert_eq!(event.direction, Direction::Inbound); + assert_eq!(event.rule_id, None); + // The VPN device is the responder. + assert_eq!((event.upload_bytes, event.download_bytes), (2048, 100)); +} + +#[test] +fn test_worker_reports_vpn_to_vpn_connections_once() { + let (mut worker, mut receiver) = worker(vec![None, None, Some(7)]); + let timestamp = SystemTime::now(); + + // Logged by an ACL rule on the way in, then by the inbound rule on the way out. + worker.handle_pflog(&pflog_packet(PF_PASS, 2, PF_IN), timestamp); + worker.handle_pflog(&pflog_packet(PF_PASS, 1, PF_OUT), timestamp); + assert!(receiver.try_recv().is_err()); + worker.update_states(&[]); + + let event = receiver.try_recv().unwrap(); + assert_eq!(event.direction, Direction::Outbound); + assert_eq!(event.rule_id, Some(7)); + assert!(receiver.try_recv().is_err()); +} diff --git a/src/enterprise/firewall/packetfilter/rule.rs b/src/enterprise/firewall/packetfilter/rule.rs index e2b1ff3f..cd6baa2a 100644 --- a/src/enterprise/firewall/packetfilter/rule.rs +++ b/src/enterprise/firewall/packetfilter/rule.rs @@ -91,7 +91,7 @@ impl fmt::Display for Direction { } } -const PF_LOG: u8 = 0x01; +pub(super) const PF_LOG: u8 = 0x01; // const PF_LOG_ALL: u8 = 0x02; // const PF_LOG_SOCKET_LOOKUP: u8 = 0x04; // #[cfg(target_os = "freebsd")] @@ -187,8 +187,8 @@ pub(super) struct PacketFilterRule { pub(super) action: Action, pub(super) direction: Direction, pub(super) quick: bool, - /// See `PF_LOG`. - pub(super) log: u8, + /// pflog unit to log to; `None` disables logging. + pub(super) log: Option, pub(super) state: State, pub(super) interface: Option, pub(super) proto: Protocol, @@ -200,7 +200,7 @@ pub(super) struct PacketFilterRule { impl PacketFilterRule { /// Default rule for policy. #[must_use] - pub(super) fn for_policy(policy: Policy, ifname: &str) -> Self { + pub(super) fn for_policy(policy: Policy, ifname: &str, log: Option) -> Self { let (action, state) = match policy { Policy::Allow => (Action::Pass, State::Normal), Policy::Deny => (Action::Drop, State::None), @@ -213,7 +213,7 @@ impl PacketFilterRule { action, direction: Direction::In, quick: false, - log: PF_LOG, + log, state, interface: Some(ifname.to_owned()), proto: Protocol::Any, @@ -223,6 +223,16 @@ impl PacketFilterRule { } } + /// Pass and log new connections towards VPN devices, for the connection monitor. + #[must_use] + pub(super) fn for_inbound(ifname: &str, log: Option) -> Self { + Self { + direction: Direction::Out, + quick: true, + ..Self::for_policy(Policy::Allow, ifname, log) + } + } + /// Determine address family. pub(super) fn address_family(&self) -> AddressFamily { match self.to { @@ -237,7 +247,11 @@ impl PacketFilterRule { } /// Expand `FirewallRule` into a set of `PacketFilterRule`s. - pub(super) fn from_firewall_rule(ifname: &str, fr: &FirewallRule) -> Vec { + pub(super) fn from_firewall_rule( + ifname: &str, + fr: &FirewallRule, + log: Option, + ) -> Vec { let mut rules = Vec::new(); let (action, state) = match fr.verdict { Policy::Allow => (Action::Pass, State::Normal), @@ -302,7 +316,7 @@ impl PacketFilterRule { direction: Direction::In, // Enable "quick" to match NFTables behaviour. quick: true, - log: PF_LOG, + log, state, interface: Some(ifname.to_owned()), proto: *proto, @@ -325,7 +339,9 @@ impl fmt::Display for PacketFilterRule { // Display `PacketFilterRule` in similar format to rules in pf.conf. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{} {}", self.action, self.direction)?; - // TODO: log + if let Some(logif) = self.log { + write!(f, " log (to pflog{logif})")?; + } if self.quick { f.write_str(" quick")?; } @@ -360,6 +376,14 @@ mod tests { use super::*; + #[test] + fn inbound_rule() { + assert_eq!( + PacketFilterRule::for_inbound("lo0", Some(0)).to_string(), + "pass out log (to pflog0) quick on lo0 from any to any keep state" + ); + } + #[test] fn unroll_firewall_rule() { // Empty rule @@ -374,7 +398,7 @@ mod tests { ipv4: true, }; - let rules = PacketFilterRule::from_firewall_rule("lo0", &fr); + let rules = PacketFilterRule::from_firewall_rule("lo0", &fr, None); assert_eq!(1, rules.len()); assert_eq!( rules[0].to_string(), @@ -396,7 +420,7 @@ mod tests { ipv4: true, }; - let rules = PacketFilterRule::from_firewall_rule("lo0", &fr); + let rules = PacketFilterRule::from_firewall_rule("lo0", &fr, None); assert_eq!(1, rules.len()); assert_eq!( rules[0].to_string(), @@ -421,7 +445,7 @@ mod tests { ipv4: true, }; - let rules = PacketFilterRule::from_firewall_rule("lo0", &fr); + let rules = PacketFilterRule::from_firewall_rule("lo0", &fr, None); assert_eq!(4, rules.len()); assert_eq!( rules[0].to_string(), @@ -455,7 +479,7 @@ mod tests { ipv4: true, }; - let rules = PacketFilterRule::from_firewall_rule("lo0", &fr); + let rules = PacketFilterRule::from_firewall_rule("lo0", &fr, None); assert_eq!(1, rules.len()); assert_eq!( rules[0].to_string(), diff --git a/src/gateway.rs b/src/gateway.rs index 8f36a9dc..8d414a14 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -13,18 +13,21 @@ use std::{ use defguard_wireguard_rs::{WireguardInterfaceApi, net::IpAddrMask}; use tokio::{ - sync::{mpsc, oneshot, watch}, + sync::{broadcast, mpsc, oneshot, watch}, task::JoinSet, time::interval, }; use tonic::Status; use tracing::instrument; +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))] +use crate::enterprise::firewall::ConnectionMonitor; use crate::{ config::Config, enterprise::firewall::{ FirewallConfig, FirewallError, FirewallRule, SnatBinding, api::{FirewallApi, FirewallManagementApi}, + monitor::ConnectionEvent, }, error::GatewayError, gateway_server::GatewayServer, @@ -49,32 +52,29 @@ pub async fn run_gateway_loop( tls_config: Option, mut shutdown: watch::Receiver, ) { - let mut tls_config = match tls_config { - Some(tls_config) => { - log::info!( - "Using existing gRPC TLS certificates from {}", - config.cert_dir.display() - ); - tls_config - } - None => { - log::info!( - "gRPC TLS certificates not found in {}. They will be generated during setup.", - config.cert_dir.display() - ); - tokio::select! { - biased; - _ = shutdown.changed() => { - cleanup_on_quit(&config, &gateway); + let mut tls_config = if let Some(tls_config) = tls_config { + log::info!( + "Using existing gRPC TLS certificates from {}", + config.cert_dir.display() + ); + tls_config + } else { + log::info!( + "gRPC TLS certificates not found in {}. They will be generated during setup.", + config.cert_dir.display() + ); + tokio::select! { + biased; + _ = shutdown.changed() => { + cleanup_on_quit(&config, &gateway); + return; + } + result = run_setup(&config, Arc::clone(&logs_rx)) => match result { + Ok(tls_config) => tls_config, + Err(err) => { + error!("Failed to run setup: {err}"); return; } - result = run_setup(&config, Arc::clone(&logs_rx)) => match result { - Ok(tls_config) => tls_config, - Err(err) => { - error!("Failed to run setup: {err}"); - return; - } - } } } }; @@ -181,6 +181,9 @@ impl From for InterfaceConfiguration { type PubKey = String; +/// Number of connection events buffered for each subscriber. +const CONNECTION_EVENTS_CAPACITY: usize = 1024; + #[derive(Clone, Debug)] pub struct TlsConfig { pub grpc_cert_pem: String, @@ -201,6 +204,10 @@ pub struct Gateway { // Transmission channel. Important: allows only one connected client. pub(crate) client_tx: Option>>, pub(crate) tls_config: Option, + /// Connection monitoring events, published while the firewall is active. + connection_events: broadcast::Sender, + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))] + connection_monitor: Option, } impl Gateway { @@ -217,9 +224,37 @@ impl Gateway { connected: Arc::new(AtomicBool::new(false)), client_tx: None, tls_config: None, + connection_events: broadcast::channel(CONNECTION_EVENTS_CAPACITY).0, + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))] + connection_monitor: None, }) } + /// Sender of connection monitoring events; call `subscribe()` on it to listen to them. + /// + /// Events are only published when connection monitoring is enabled with `connection_monitor` + /// (Linux, FreeBSD and macOS) and the firewall is managed by the Gateway. + #[must_use] + pub fn connection_events(&self) -> broadcast::Sender { + self.connection_events.clone() + } + + /// Start monitoring connections on the VPN interface, unless it's already running or disabled. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))] + fn start_connection_monitor(&mut self) { + if self.connection_monitor.is_some() || !self.config.connection_monitor { + return; + } + match ConnectionMonitor::register( + &self.config.ifname, + self.config.pflog_ifname, + self.connection_events.clone(), + ) { + Ok(monitor) => self.connection_monitor = Some(monitor), + Err(err) => error!("Failed to start connection monitoring: {err}"), + } + } + /// Purge the `Gateway` and prepare to enter setup mode. pub(crate) fn purge(&mut self) { // Drop the interface. @@ -397,12 +432,26 @@ impl Gateway { "Received firewall configuration is different than current one. \ Reconfiguring firewall..." ); + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))] + self.start_connection_monitor(); let mut firewall_api = FirewallApi::new(&self.config.ifname)?; + // Log only while the monitor (which owns the pflog interface) is running. + #[cfg(any(target_os = "freebsd", target_os = "macos"))] + { + firewall_api.log_interface = self + .connection_monitor + .as_ref() + .map(ConnectionMonitor::pflog_unit); + } firewall_api.setup(fw_config.default_policy, self.config.fw_priority)?; firewall_api.setup_nat(self.config.masquerade, &fw_config.snat_bindings)?; firewall_api.add_rules(&fw_config.rules)?; self.firewall_config = Some(fw_config.clone()); info!("Reconfigured firewall with new configuration"); + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))] + if let Some(monitor) = &self.connection_monitor { + monitor.update_rules(fw_config); + } } else { debug!( "Received firewall configuration is the same as current one. Skipping \ @@ -410,7 +459,7 @@ impl Gateway { ); } } else { - debug!("Received firewall configuration is empty, cleaning up firewall rules..."); + debug!("Received firewall configuration is empty, cleaning up firewall rules"); self.cleanup_firewall()?; debug!("Cleaned up firewall rules"); } @@ -419,6 +468,11 @@ impl Gateway { } fn cleanup_firewall(&mut self) -> Result<(), FirewallError> { + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))] + { + // Dropping the monitor unregisters it. + self.connection_monitor = None; + } let mut firewall_api = FirewallApi::new(&self.config.ifname)?; firewall_api.cleanup()?; firewall_api.setup_nat(self.config.masquerade, &[])?; @@ -673,3 +727,60 @@ pub async fn run_stats(gateway: Arc>, period: Duration) { } } } + +/// Collect connection monitoring events and periodically send them to Core through gRPC. +/// +/// Events are buffered between ticks of `period`; events observed while Core is disconnected +/// are discarded. +pub async fn run_connection_events(gateway: Arc>, period: Duration) { + let (mut events, connected) = { + let gateway = gateway.lock().expect("gateway mutex poison"); + ( + gateway.connection_events().subscribe(), + Arc::clone(&gateway.connected), + ) + }; + let mut buffer = Vec::new(); + let mut interval = interval(period); + let mut id = 1; + loop { + tokio::select! { + result = events.recv() => match result { + Ok(event) => { + if connected.load(Ordering::Relaxed) { + buffer.push(event); + } + } + Err(broadcast::error::RecvError::Lagged(count)) => { + warn!("Dropped {count} connection events that were not sent to Core in time"); + } + Err(broadcast::error::RecvError::Closed) => break, + }, + _ = interval.tick() => { + let batch = std::mem::take(&mut buffer); + if batch.is_empty() { + continue; + } + // Hold the gateway lock only to obtain the sender. + let Some(tx) = gateway.lock().expect("gateway mutex poison").client_tx.clone() + else { + debug!("Core disconnected, discarding {} connection events", batch.len()); + continue; + }; + let count = batch.len(); + for event in &batch { + let message = CoreRequest { + id, + payload: Some(core_request::Payload::ConnectionEvent(event.into())), + }; + id += 1; + if tx.send(Ok(message)).is_err() { + warn!("Failed to send connection events to Core"); + break; + } + } + debug!("Sent {count} connection events to Core"); + } + } + } +} diff --git a/src/gateway/tests.rs b/src/gateway/tests.rs index eb4359e0..199db6ba 100644 --- a/src/gateway/tests.rs +++ b/src/gateway/tests.rs @@ -8,7 +8,7 @@ use defguard_wireguard_rs::Kernel; #[cfg(any(target_os = "macos", target_os = "netbsd"))] use defguard_wireguard_rs::Userspace; use defguard_wireguard_rs::WGApi; -use ipnetwork::IpNetwork; +use ipnetwork::{IpNetwork, Ipv4Network}; use super::*; use crate::enterprise::firewall::{Address, FirewallRule, Policy, Port, Protocol}; @@ -60,6 +60,9 @@ async fn test_configuration_comparison() { connected: Arc::new(AtomicBool::new(false)), client_tx: None, tls_config: None, + connection_events: broadcast::channel(1).0, + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))] + connection_monitor: None, }; // new config is the same @@ -209,16 +212,16 @@ async fn test_firewall_rules_comparison() { let rule3 = FirewallRule { comment: Some("Rule 3".to_string()), - destination_addrs: vec![Address::Network( - IpNetwork::from_str("10.0.1.0/24").unwrap(), - )], + destination_addrs: vec![Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(10, 0, 1, 0), 24).unwrap(), + ))], destination_ports: vec![Port::Range(1000, 2000)], id: 3, verdict: Policy::Deny, protocols: vec![Protocol::Udp], - source_addrs: vec![Address::Network( - IpNetwork::from_str("192.168.0.0/16").unwrap(), - )], + source_addrs: vec![Address::Network(IpNetwork::V4( + Ipv4Network::new(Ipv4Addr::new(192, 168, 0, 0), 16).unwrap(), + ))], ipv4: true, }; @@ -245,6 +248,9 @@ async fn test_firewall_rules_comparison() { connected: Arc::new(AtomicBool::new(false)), client_tx: None, tls_config: None, + connection_events: broadcast::channel(1).0, + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))] + connection_monitor: None, }; // Gateway has no firewall config, new rules are empty @@ -311,6 +317,9 @@ async fn test_firewall_config_comparison() { connected: Arc::new(AtomicBool::new(false)), client_tx: None, tls_config: None, + connection_events: broadcast::channel(1).0, + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))] + connection_monitor: None, }; // Gateway has no config gateway.firewall_config = None; diff --git a/src/lib.rs b/src/lib.rs index a92efb55..ddd9d4da 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,11 @@ pub mod generated { tonic::include_proto!("defguard.enterprise.firewall.v2"); } } + pub mod monitor { + pub mod v2 { + tonic::include_proto!("defguard.enterprise.monitor.v2"); + } + } } } } @@ -41,6 +46,9 @@ pub mod proto { pub mod firewall { pub use crate::generated::defguard::enterprise::firewall::v2::*; } + pub mod monitor { + pub use crate::generated::defguard::enterprise::monitor::v2::*; + } } } diff --git a/src/tests/recovery.rs b/src/tests/recovery.rs index dfaa1068..7853aefe 100644 --- a/src/tests/recovery.rs +++ b/src/tests/recovery.rs @@ -38,7 +38,7 @@ fn test_purge_then_configure_recovers_interface() { name: "wg0".into(), private_key: "aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abC=".into(), port: 51820, - peers: vec![], + peers: Vec::new(), addresses: vec!["10.0.0.1/24".into()], firewall_config: None, mtu: 1420,