From 7b7fac315e207b424fedf953cf0710fd9ebaeb8f Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 23 Apr 2026 18:56:11 +0000 Subject: [PATCH] feat(M8+M9): CA certificates page + Reporting CSV/PDF with charts --- Cargo.lock | 542 ++++++++++++++++++++++-- Cargo.toml | 5 + crates/pm-ca/Cargo.toml | 23 +- crates/pm-ca/src/ca.rs | 421 +++++++++++++++++- crates/pm-ca/src/lib.rs | 6 +- crates/pm-reports/Cargo.toml | 9 + crates/pm-reports/src/csv.rs | 335 ++++++++++++++- crates/pm-reports/src/lib.rs | 24 +- crates/pm-reports/src/pdf.rs | 454 +++++++++++++++++++- crates/pm-web/Cargo.toml | 2 + crates/pm-web/src/main.rs | 21 + crates/pm-web/src/routes/ca.rs | 349 +++++++++++++++ crates/pm-web/src/routes/mod.rs | 3 + crates/pm-web/src/routes/reports.rs | 153 +++++++ frontend/src/App.tsx | 8 +- frontend/src/api/client.ts | 50 +++ frontend/src/pages/CertificatesPage.tsx | 483 +++++++++++++++++++++ frontend/src/pages/DashboardPage.tsx | 24 +- frontend/src/pages/HostDetailPage.tsx | 32 +- frontend/src/pages/ReportsPage.tsx | 273 ++++++++++++ frontend/src/types/index.ts | 27 ++ tasks/todo.md | 36 +- 22 files changed, 3210 insertions(+), 70 deletions(-) create mode 100644 crates/pm-web/src/routes/ca.rs create mode 100644 crates/pm-web/src/routes/reports.rs create mode 100644 frontend/src/pages/CertificatesPage.tsx create mode 100644 frontend/src/pages/ReportsPage.tsx diff --git a/Cargo.lock b/Cargo.lock index d15565b..ccb66b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -50,6 +56,45 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -212,6 +257,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -239,18 +290,41 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -304,6 +378,12 @@ dependencies = [ "cc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -424,6 +504,15 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -455,6 +544,27 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -486,6 +596,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -612,12 +736,31 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.11.1" @@ -820,6 +963,26 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80792593675e051cf94a4b111980da2ba60d4a83e43e0048c5693baab3977045" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "h2" version = "0.4.13" @@ -1197,6 +1360,35 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "gif 0.13.3", + "jpeg-decoder", + "num-traits", + "png 0.17.16", + "tiff", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1241,6 +1433,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" + [[package]] name = "js-sys" version = "0.3.95" @@ -1312,7 +1510,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags", + "bitflags 2.11.1", "libc", "plain", "redox_syscall 0.7.4", @@ -1328,6 +1526,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1355,6 +1559,23 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lopdf" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c8e1b6184b1b32ea5f72f572ebdc40e5da1d2921fa469947ff7c480ad1f85a" +dependencies = [ + "encoding_rs", + "flate2", + "itoa", + "linked-hash-map", + "log", + "md5", + "pom", + "time", + "weezl", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1386,6 +1607,12 @@ dependencies = [ "digest", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.0" @@ -1408,6 +1635,22 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -1419,6 +1662,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -1436,6 +1689,16 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1507,6 +1770,15 @@ dependencies = [ "libm", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1519,7 +1791,7 @@ version = "0.10.78" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", @@ -1567,6 +1839,15 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "owned_ttf_parser" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706de7e2214113d63a8238d1910463cfce781129a6f263d13fdb09ff64355ba4" +dependencies = [ + "ttf-parser", +] + [[package]] name = "parking" version = "2.2.1" @@ -1720,6 +2001,36 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-bitmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-bitmap" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ce181e3f6bf82d6c1dc569103ca7b1bd964c60ba03d7e6cdfbb3e3eb7f7405" +dependencies = [ + "gif 0.12.0", + "image 0.24.9", + "plotters-backend", +] + [[package]] name = "pm-agent-client" version = "0.1.0" @@ -1731,7 +2042,7 @@ dependencies = [ "rustls", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "uuid", @@ -1756,7 +2067,7 @@ dependencies = [ "serde_json", "sha2", "sqlx", - "thiserror", + "thiserror 2.0.18", "tokio", "totp-rs", "tracing", @@ -1769,12 +2080,21 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", + "hex", + "pem", "pm-core", + "rand 0.8.6", + "rcgen", + "rustls", "serde", "serde_json", - "thiserror", + "sha2", + "sqlx", + "thiserror 2.0.18", + "time", "tokio", "tracing", + "uuid", ] [[package]] @@ -1790,7 +2110,7 @@ dependencies = [ "serde_json", "sha2", "sqlx", - "thiserror", + "thiserror 2.0.18", "tokio", "toml 0.8.23", "tracing", @@ -1805,12 +2125,19 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", + "csv", + "image 0.25.10", + "plotters", + "plotters-bitmap", "pm-core", + "printpdf", "serde", "serde_json", - "thiserror", + "sqlx", + "thiserror 2.0.18", "tokio", "tracing", + "uuid", ] [[package]] @@ -1824,11 +2151,13 @@ dependencies = [ "dashmap", "ipnet", "pm-auth", + "pm-ca", "pm-core", + "pm-reports", "serde", "serde_json", "sqlx", - "thiserror", + "thiserror 2.0.18", "tokio", "tower", "tower-http", @@ -1852,7 +2181,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-rustls", "tokio-tungstenite 0.26.2", @@ -1861,6 +2190,41 @@ dependencies = [ "uuid", ] +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "pom" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c972d8f86e943ad532d0b04e8965a749ad1d18bb981a9c7b3ae72fe7fd7744b" +dependencies = [ + "bstr", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1895,6 +2259,19 @@ dependencies = [ "syn", ] +[[package]] +name = "printpdf" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c30a4cc87c3ca9a98f4970db158a7153f8d1ec8076e005751173c57836380b1d" +dependencies = [ + "image 0.24.9", + "js-sys", + "lopdf", + "owned_ttf_parser", + "time", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1904,6 +2281,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + [[package]] name = "quinn" version = "0.11.9" @@ -1918,7 +2301,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -1939,7 +2322,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -2039,13 +2422,27 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -2054,7 +2451,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -2138,7 +2535,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" dependencies = [ - "bitflags", + "bitflags 2.11.1", "once_cell", "serde", "serde_derive", @@ -2182,13 +2579,22 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -2275,7 +2681,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -2451,6 +2857,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -2459,7 +2871,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -2549,7 +2961,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -2604,7 +3016,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64", - "bitflags", + "bitflags 2.11.1", "byteorder", "bytes", "chrono", @@ -2634,7 +3046,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -2648,7 +3060,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64", - "bitflags", + "bitflags 2.11.1", "byteorder", "chrono", "crc", @@ -2673,7 +3085,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -2699,7 +3111,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.18", "tracing", "url", "uuid", @@ -2765,7 +3177,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -2793,13 +3205,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -2822,6 +3254,17 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + [[package]] name = "time" version = "0.3.47" @@ -3097,7 +3540,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytes", "futures-core", "futures-util", @@ -3213,6 +3656,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1" + [[package]] name = "tungstenite" version = "0.26.2" @@ -3228,7 +3677,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1", - "thiserror", + "thiserror 2.0.18", "utf-8", ] @@ -3245,7 +3694,7 @@ dependencies = [ "log", "rand 0.9.4", "sha1", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -3510,7 +3959,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -3554,6 +4003,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whoami" version = "1.6.1" @@ -3938,7 +4393,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -3974,6 +4429,24 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "yaml-rust2" version = "0.10.4" @@ -3985,6 +4458,15 @@ dependencies = [ "hashlink", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 68f04ee..f14925e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,11 @@ reqwest = { version = "0.12", features = ["rustls-tls", "json"] } # TLS rustls = { version = "0.23" } +# Certificate Authority +rcgen = { version = "0.13", features = ["pem", "x509-parser"] } +pem = { version = "3" } +time = { version = "0.3", features = ["std"] } + # Config config = { version = "0.15" } diff --git a/crates/pm-ca/Cargo.toml b/crates/pm-ca/Cargo.toml index f4ed8ed..ae401ca 100644 --- a/crates/pm-ca/Cargo.toml +++ b/crates/pm-ca/Cargo.toml @@ -6,11 +6,20 @@ authors.workspace = true license.workspace = true [dependencies] -pm-core = { path = "../pm-core" } -tokio = { workspace = true } -serde = { workspace = true } +pm-core = { path = "../pm-core" } +tokio = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } -thiserror = { workspace = true } -anyhow = { workspace = true } -tracing = { workspace = true } -chrono = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +chrono = { workspace = true } +sqlx = { workspace = true } +uuid = { workspace = true } +rand = { workspace = true } +hex = { workspace = true } +sha2 = { workspace = true } +rustls = { workspace = true } +rcgen = { workspace = true } +pem = { workspace = true } +time = { workspace = true } diff --git a/crates/pm-ca/src/ca.rs b/crates/pm-ca/src/ca.rs index 9dd0a79..b46e1f5 100644 --- a/crates/pm-ca/src/ca.rs +++ b/crates/pm-ca/src/ca.rs @@ -1 +1,420 @@ -//! Internal CA stub for M8. +//! Internal Certificate Authority for Linux Patch Manager. +//! +//! Issues and renews mTLS client certificates for agent communication. +//! Uses rcgen (ECDSA P-256) for all certificate generation. +//! CA key and certificate are stored on disk under `base_dir` +//! (default: /etc/patch-manager/ca/). +//! Certificate metadata is persisted in the `certificates` PostgreSQL table. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Duration as ChronoDuration, Utc}; +use rand::RngCore; +use rcgen::{ + BasicConstraints, Certificate, CertificateParams, DistinguishedName, DnType, + ExtendedKeyUsagePurpose, Ia5String, IsCa, KeyPair, KeyUsagePurpose, SanType, + SerialNumber, PKCS_ECDSA_P256_SHA256, +}; +use sqlx::{PgPool, Row}; +use time::{Duration as TimeDuration, OffsetDateTime}; +use uuid::Uuid; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/// Returned by [`CertAuthority::issue_client_cert`] and [`CertAuthority::renew_cert`]. +/// +/// The private key is intentionally **not** stored in the database. +#[derive(Debug, Clone)] +pub struct IssuedCert { + /// PEM-encoded public certificate. + pub cert_pem: String, + /// PEM-encoded private key (PKCS#8). + pub key_pem: String, + /// Hex-encoded 16-byte random serial number. + pub serial_number: String, + /// Certificate expiry timestamp (UTC). + pub expires_at: DateTime, +} + +// --------------------------------------------------------------------------- +// CertAuthority +// --------------------------------------------------------------------------- + +/// Thread-safe, cloneable handle to the internal certificate authority. +/// +/// CA certificate and key are held in memory as PEM strings; rcgen objects +/// are reconstructed on demand so this struct is unconditionally `Send + Sync`. +#[derive(Debug, Clone)] +pub struct CertAuthority { + #[allow(dead_code)] + base_dir: PathBuf, + /// PEM-encoded CA certificate (public cert only). + ca_cert_pem: String, + /// PEM-encoded CA private key (PKCS#8). + ca_key_pem: String, +} + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +/// Generate a 16-byte cryptographically-random serial number. +/// Returns `(rcgen::SerialNumber, hex_encoded_string)`. +fn make_serial() -> (SerialNumber, String) { + let mut bytes = [0u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + let hex_serial = hex::encode(bytes); + let serial = SerialNumber::from_slice(&bytes); + (serial, hex_serial) +} + +/// `OffsetDateTime::now_utc()` offset forward by `days` (for rcgen params). +fn odt_offset_days(days: i64) -> OffsetDateTime { + OffsetDateTime::now_utc() + TimeDuration::days(days) +} + +/// `chrono::Utc::now()` offset forward by `days` (for DB / return values). +fn chrono_offset_days(days: i64) -> DateTime { + Utc::now() + ChronoDuration::days(days) +} + +/// Build a `CertificateParams` with common fields pre-filled. +/// Caller still needs to set `is_ca`, `key_usages`, `extended_key_usages`, and `subject_alt_names`. +fn base_params( + cn: &str, + validity_days: i64, +) -> Result<(CertificateParams, String, DateTime)> { + let (serial, serial_hex) = make_serial(); + let expires_at = chrono_offset_days(validity_days); + + let mut params = CertificateParams::default(); + params.not_before = OffsetDateTime::now_utc(); + params.not_after = odt_offset_days(validity_days); + params.serial_number = Some(serial); + + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, cn); + params.distinguished_name = dn; + + Ok((params, serial_hex, expires_at)) +} + +/// Write `contents` to `path` and set Unix permissions to `0600`. +async fn write_protected(path: &Path, contents: &str) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + tokio::fs::write(path, contents).await?; + let perms = std::fs::Permissions::from_mode(0o600); + tokio::fs::set_permissions(path, perms).await?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// impl CertAuthority +// --------------------------------------------------------------------------- + +impl CertAuthority { + // ----------------------------------------------------------------------- + // Construction + // ----------------------------------------------------------------------- + + /// Load an existing CA from disk, or generate a brand-new one if absent. + /// + /// Files managed: + /// * `{base_dir}/ca.key` — PKCS#8 PEM private key (mode `0600`) + /// * `{base_dir}/ca.crt` — PEM certificate (mode `0600`) + /// + /// On first generation the CA row is inserted into `certificates` + /// with `host_id = NULL` (marks it as the root CA record). + pub async fn init(base_dir: &Path, db: &PgPool) -> Result { + let key_path = base_dir.join("ca.key"); + let crt_path = base_dir.join("ca.crt"); + + // ── Load existing CA ────────────────────────────────────────────── + if key_path.exists() && crt_path.exists() { + tracing::info!(path = %base_dir.display(), "Loading existing root CA from disk"); + + let ca_key_pem = tokio::fs::read_to_string(&key_path) + .await + .context("read ca.key")?; + let ca_cert_pem = tokio::fs::read_to_string(&crt_path) + .await + .context("read ca.crt")?; + + // Validate that both PEMs parse without error. + KeyPair::from_pem(&ca_key_pem) + .context("parse CA private-key PEM")?; + CertificateParams::from_ca_cert_pem(&ca_cert_pem) + .context("parse CA certificate PEM")?; + + tracing::info!("Root CA loaded successfully"); + return Ok(Self { + base_dir: base_dir.to_owned(), + ca_cert_pem, + ca_key_pem, + }); + } + + // ── Generate new CA ─────────────────────────────────────────────── + tracing::info!( + path = %base_dir.display(), + "Generating new root CA (ECDSA P-256, 10-year validity)" + ); + tokio::fs::create_dir_all(base_dir) + .await + .context("create CA directory")?; + + let ca_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256) + .context("generate CA key pair")?; + + let (serial, serial_hex) = make_serial(); + let expires_at = chrono_offset_days(365 * 10); + + let mut params = CertificateParams::default(); + params.not_before = OffsetDateTime::now_utc(); + params.not_after = odt_offset_days(365 * 10); + params.serial_number = Some(serial); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![ + KeyUsagePurpose::KeyCertSign, + KeyUsagePurpose::CrlSign, + ]; + + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, "Patch Manager Root CA"); + dn.push(DnType::OrganizationName, "Patch Manager"); + params.distinguished_name = dn; + + let ca_cert_obj = params.self_signed(&ca_key) + .context("self-sign CA certificate")?; + let ca_cert_pem = ca_cert_obj.pem(); + let ca_key_pem = ca_key.serialize_pem(); + + write_protected(&key_path, &ca_key_pem) + .await + .context("write ca.key")?; + write_protected(&crt_path, &ca_cert_pem) + .await + .context("write ca.crt")?; + + tracing::info!( + serial = %serial_hex, + expires_at = %expires_at, + "Root CA generated and written to disk" + ); + + // Persist CA cert metadata (host_id = NULL marks the root CA row). + sqlx::query( + "INSERT INTO certificates \ + (host_id, serial_number, common_name, status, expires_at, cert_pem) \ + VALUES (NULL, $1, 'Patch Manager Root CA', 'active'::cert_status, $2, $3)", + ) + .bind(&serial_hex) + .bind(expires_at) + .bind(&ca_cert_pem) + .execute(db) + .await + .context("insert root CA cert into database")?; + + tracing::info!("Root CA certificate recorded in database"); + + Ok(Self { + base_dir: base_dir.to_owned(), + ca_cert_pem, + ca_key_pem, + }) + } + + // ----------------------------------------------------------------------- + // Public accessors + // ----------------------------------------------------------------------- + + /// Return the PEM-encoded root CA certificate (public cert only). + pub fn root_cert_pem(&self) -> &str { + &self.ca_cert_pem + } + + // ----------------------------------------------------------------------- + // Certificate issuance + // ----------------------------------------------------------------------- + + /// Issue a one-year mTLS client certificate for a managed host. + /// + /// * Subject: `CN=` + /// * Key usage: Digital Signature + /// * Extended key usage: Client Authentication + /// + /// The certificate PEM is stored in `certificates`. + /// The private key is returned to the caller **only** and never persisted. + pub async fn issue_client_cert( + &self, + host_id: Uuid, + hostname: &str, + db: &PgPool, + ) -> Result { + tracing::info!(host_id = %host_id, hostname, "Issuing mTLS client certificate"); + + let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256) + .context("generate client key pair")?; + + let (mut params, serial_hex, expires_at) = base_params(hostname, 365)?; + params.is_ca = IsCa::ExplicitNoCa; + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth]; + + let (ca_key, ca_cert) = self.ca_objects()?; + let cert = params + .signed_by(&key, &ca_cert, &ca_key) + .context("sign client cert with CA")?; + + let cert_pem = cert.pem(); + let key_pem = key.serialize_pem(); + + sqlx::query( + "INSERT INTO certificates \ + (host_id, serial_number, common_name, status, expires_at, cert_pem) \ + VALUES ($1, $2, $3, 'active'::cert_status, $4, $5)", + ) + .bind(host_id) + .bind(&serial_hex) + .bind(hostname) + .bind(expires_at) + .bind(&cert_pem) + .execute(db) + .await + .context("insert client cert into database")?; + + tracing::info!( + host_id = %host_id, + hostname, + serial = %serial_hex, + expires_at = %expires_at, + "Client certificate issued successfully" + ); + + Ok(IssuedCert { cert_pem, key_pem, serial_number: serial_hex, expires_at }) + } + + /// Revoke a certificate by database ID. + /// + /// Sets `status = 'revoked'` and `revoked_at = NOW()` in the `certificates` table. + /// Does **not** reissue a replacement; use [`renew_cert`] for that. + pub async fn revoke_cert(&self, cert_id: Uuid, db: &PgPool) -> Result<()> { + tracing::info!(cert_id = %cert_id, "Revoking certificate"); + + let rows = sqlx::query( + "UPDATE certificates \ + SET status = 'revoked'::cert_status, revoked_at = NOW() \ + WHERE id = $1", + ) + .bind(cert_id) + .execute(db) + .await + .context("revoke certificate in database")?; + + if rows.rows_affected() == 0 { + anyhow::bail!("certificate not found: {}", cert_id); + } + + tracing::info!(cert_id = %cert_id, "Certificate revoked"); + Ok(()) + } + + /// Renew a certificate: revoke the existing cert and issue a new one with + /// the same `host_id` and `common_name`. + pub async fn renew_cert(&self, cert_id: Uuid, db: &PgPool) -> Result { + tracing::info!(cert_id = %cert_id, "Renewing certificate"); + + // Fetch the existing cert's host_id and common_name. + let row = sqlx::query( + "SELECT host_id, common_name FROM certificates WHERE id = $1", + ) + .bind(cert_id) + .fetch_one(db) + .await + .context("fetch certificate for renewal")?; + + let host_id: Uuid = row.try_get("host_id") + .context("certificate has no host_id (cannot renew root CA)")?; + let common_name: String = row.try_get("common_name") + .context("fetch common_name")?; + + // Revoke the old cert first. + self.revoke_cert(cert_id, db).await?; + + // Issue a fresh cert with the same CN. + let issued = self.issue_client_cert(host_id, &common_name, db).await?; + + tracing::info!( + old_cert_id = %cert_id, + new_serial = %issued.serial_number, + "Certificate renewed" + ); + Ok(issued) + } + + /// Generate a self-signed TLS certificate for the web UI using the CA. + /// + /// * Subject: `CN=` + /// * Key usage: Digital Signature + /// * Extended key usage: Server Authentication + /// * SAN: DNS `` + /// + /// Returns `(cert_pem, key_pem)`. This certificate is **not** stored in the + /// database; it is intended for runtime use only. + pub async fn issue_web_tls_cert( + &self, + hostname: &str, + ) -> Result<(String, String)> { + tracing::info!(hostname, "Issuing web TLS certificate"); + + let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256) + .context("generate web TLS key pair")?; + + let (mut params, serial_hex, expires_at) = base_params(hostname, 365)?; + params.is_ca = IsCa::ExplicitNoCa; + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + params.subject_alt_names = vec![SanType::DnsName( + Ia5String::try_from(hostname.to_owned()).context("hostname is not valid IA5")?, + )]; + + let (ca_key, ca_cert) = self.ca_objects()?; + let cert = params + .signed_by(&key, &ca_cert, &ca_key) + .context("sign web TLS cert with CA")?; + + let cert_pem = cert.pem(); + let key_pem = key.serialize_pem(); + + tracing::info!( + hostname, + serial = %serial_hex, + expires_at = %expires_at, + "Web TLS certificate issued" + ); + + Ok((cert_pem, key_pem)) + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + /// Reconstruct rcgen `(KeyPair, Certificate)` from the in-memory PEM strings. + /// + /// The returned `Certificate` is used solely as an issuer reference when + /// signing leaf certificates; it is never distributed directly. + fn ca_objects(&self) -> Result<(KeyPair, Certificate)> { + let key = KeyPair::from_pem(&self.ca_key_pem) + .context("reconstruct CA key pair from PEM")?; + let params = CertificateParams::from_ca_cert_pem(&self.ca_cert_pem) + .context("reconstruct CA params from PEM")?; + let cert = params + .self_signed(&key) + .context("reconstruct CA certificate for signing")?; + Ok((key, cert)) + } +} diff --git a/crates/pm-ca/src/lib.rs b/crates/pm-ca/src/lib.rs index 2f36691..ca90097 100644 --- a/crates/pm-ca/src/lib.rs +++ b/crates/pm-ca/src/lib.rs @@ -1,7 +1,7 @@ //! pm-ca — Internal Certificate Authority. //! //! Issues and renews mTLS client certificates for agent communication. -//! Uses rcgen + rustls. CA key stored at /etc/patch-manager/ca/ca.key. -//! -//! M1: Stub. Full implementation in M8. +//! Uses rcgen + rustls. CA key stored at /etc/patch-manager/ca/. + pub mod ca; +pub use ca::{CertAuthority, IssuedCert}; diff --git a/crates/pm-reports/Cargo.toml b/crates/pm-reports/Cargo.toml index 6b7cd5a..922269d 100644 --- a/crates/pm-reports/Cargo.toml +++ b/crates/pm-reports/Cargo.toml @@ -14,3 +14,12 @@ thiserror = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } chrono = { workspace = true } +sqlx = { workspace = true } +uuid = { workspace = true } + +# Report generation +csv = "1" +printpdf = { version = "0.7", features = ["embedded_images"] } +plotters = { version = "0.3", default-features = false, features = ["bitmap_backend", "bitmap_encoder", "line_series", "area_series"] } +plotters-bitmap = { version = "0.3" } +image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/pm-reports/src/csv.rs b/crates/pm-reports/src/csv.rs index 90eab04..5707d8f 100644 --- a/crates/pm-reports/src/csv.rs +++ b/crates/pm-reports/src/csv.rs @@ -1 +1,334 @@ -//! csv report generation stub for M9. +//! CSV report generation for pm-reports. + +use crate::{ReportParams, ReportType}; +use anyhow::Context; + +/// Generate a CSV report and return the raw bytes. +pub async fn generate_csv( + pool: &sqlx::PgPool, + params: &ReportParams, +) -> anyhow::Result> { + match params.report_type { + ReportType::Compliance => compliance_csv(pool, params).await, + ReportType::PatchHistory => patch_history_csv(pool, params).await, + ReportType::Vulnerability => vulnerability_csv(pool, params).await, + ReportType::Audit => audit_csv(pool, params).await, + } +} + +// --------------------------------------------------------------------------- +// Compliance +// --------------------------------------------------------------------------- + +async fn compliance_csv( + pool: &sqlx::PgPool, + params: &ReportParams, +) -> anyhow::Result> { + let rows = if let Some(gid) = params.group_id { + sqlx::query(" +SELECT + h.id::text AS host_id, + h.display_name, + h.fqdn, + h.health_status::text AS health_status, + h.last_patch_at, + COALESCE(pd.total_packages, 0) AS total_packages, + COALESCE(pd.pending_patches, 0) AS pending_patches, + CASE WHEN COALESCE(pd.total_packages,0) = 0 THEN 100.0 + ELSE ROUND((1.0 - pd.pending_patches::float / NULLIF(pd.total_packages,0)) * 100, 1) + END AS compliance_pct, + COALESCE(string_agg(DISTINCT g.name, ', '), '') AS group_names +FROM hosts h +LEFT JOIN host_patch_data pd ON pd.host_id = h.id +LEFT JOIN host_groups hg ON hg.host_id = h.id +LEFT JOIN groups g ON g.id = hg.group_id +WHERE h.id IN ( + SELECT host_id FROM host_groups WHERE group_id = $1 +) +GROUP BY h.id, pd.total_packages, pd.pending_patches +ORDER BY compliance_pct ASC +") + .bind(gid) + .fetch_all(pool) + .await + .context("compliance query (group filter) failed")? + } else { + sqlx::query(" +SELECT + h.id::text AS host_id, + h.display_name, + h.fqdn, + h.health_status::text AS health_status, + h.last_patch_at, + COALESCE(pd.total_packages, 0) AS total_packages, + COALESCE(pd.pending_patches, 0) AS pending_patches, + CASE WHEN COALESCE(pd.total_packages,0) = 0 THEN 100.0 + ELSE ROUND((1.0 - pd.pending_patches::float / NULLIF(pd.total_packages,0)) * 100, 1) + END AS compliance_pct, + COALESCE(string_agg(DISTINCT g.name, ', '), '') AS group_names +FROM hosts h +LEFT JOIN host_patch_data pd ON pd.host_id = h.id +LEFT JOIN host_groups hg ON hg.host_id = h.id +LEFT JOIN groups g ON g.id = hg.group_id +GROUP BY h.id, pd.total_packages, pd.pending_patches +ORDER BY compliance_pct ASC +") + .fetch_all(pool) + .await + .context("compliance query failed")? + }; + + let mut wtr = csv::Writer::from_writer(vec![]); + wtr.write_record(&[ + "host_id", "display_name", "fqdn", "group_names", + "total_packages", "pending_patches", "compliance_pct", + "last_patch_at", "health_status", + ])?; + + for row in &rows { + use sqlx::Row; + let host_id: String = row.try_get("host_id").unwrap_or_default(); + let display_name: String = row.try_get("display_name").unwrap_or_default(); + let fqdn: String = row.try_get("fqdn").unwrap_or_default(); + let group_names: String = row.try_get("group_names").unwrap_or_default(); + let total_packages: i64 = row.try_get("total_packages").unwrap_or(0); + let pending_patches: i64 = row.try_get("pending_patches").unwrap_or(0); + let compliance_pct: f64 = row.try_get("compliance_pct").unwrap_or(0.0); + let last_patch_at: Option> = + row.try_get("last_patch_at").unwrap_or(None); + let health_status: String = row.try_get("health_status").unwrap_or_default(); + + wtr.write_record(&[ + host_id, + display_name, + fqdn, + group_names, + total_packages.to_string(), + pending_patches.to_string(), + format!("{:.1}", compliance_pct), + last_patch_at.map(|d| d.to_rfc3339()).unwrap_or_default(), + health_status, + ])?; + } + + Ok(wtr.into_inner().context("csv flush failed")?) +} + +// --------------------------------------------------------------------------- +// Patch history +// --------------------------------------------------------------------------- + +async fn patch_history_csv( + pool: &sqlx::PgPool, + params: &ReportParams, +) -> anyhow::Result> { + let rows = sqlx::query(" +SELECT + pj.id::text AS job_id, + pj.kind::text AS job_kind, + pj.status::text AS job_status, + h.display_name, + h.fqdn, + jsonb_array_length(COALESCE(pj.patch_selection->'packages', '[]'::jsonb)) AS package_count, + pjh.started_at, + pjh.completed_at, + EXTRACT(EPOCH FROM (pjh.completed_at - pjh.started_at))::bigint AS duration_seconds, + COALESCE(u.username, 'system') AS operator +FROM patch_job_hosts pjh +JOIN patch_jobs pj ON pj.id = pjh.job_id +JOIN hosts h ON h.id = pjh.host_id +LEFT JOIN users u ON u.id = pj.created_by_user_id +WHERE ($1::timestamptz IS NULL OR pjh.started_at >= $1) + AND ($2::timestamptz IS NULL OR pjh.started_at <= $2) +ORDER BY pjh.started_at DESC +") + .bind(params.from) + .bind(params.to) + .fetch_all(pool) + .await + .context("patch history query failed")?; + + let mut wtr = csv::Writer::from_writer(vec![]); + wtr.write_record(&[ + "job_id", "job_kind", "job_status", "host_display_name", "host_fqdn", + "package_count", "started_at", "completed_at", "duration_seconds", "operator", + ])?; + + for row in &rows { + use sqlx::Row; + let job_id: String = row.try_get("job_id").unwrap_or_default(); + let job_kind: String = row.try_get("job_kind").unwrap_or_default(); + let job_status: String = row.try_get("job_status").unwrap_or_default(); + let display_name: String = row.try_get("display_name").unwrap_or_default(); + let fqdn: String = row.try_get("fqdn").unwrap_or_default(); + let package_count: i64 = row.try_get("package_count").unwrap_or(0); + let started_at: Option> = + row.try_get("started_at").unwrap_or(None); + let completed_at: Option> = + row.try_get("completed_at").unwrap_or(None); + let duration_seconds: Option = row.try_get("duration_seconds").unwrap_or(None); + let operator: String = row.try_get("operator").unwrap_or_default(); + + wtr.write_record(&[ + job_id, + job_kind, + job_status, + display_name, + fqdn, + package_count.to_string(), + started_at.map(|d| d.to_rfc3339()).unwrap_or_default(), + completed_at.map(|d| d.to_rfc3339()).unwrap_or_default(), + duration_seconds.unwrap_or(0).to_string(), + operator, + ])?; + } + + Ok(wtr.into_inner().context("csv flush failed")?) +} + +// --------------------------------------------------------------------------- +// Vulnerability +// --------------------------------------------------------------------------- + +async fn vulnerability_csv( + pool: &sqlx::PgPool, + params: &ReportParams, +) -> anyhow::Result> { + let mut wtr = csv::Writer::from_writer(vec![]); + wtr.write_record(&[ + "host_id", "display_name", "fqdn", "cve_id", + "package_name", "severity", "available_version", "last_seen_at", + ])?; + + let result = sqlx::query(" +SELECT + h.id::text AS host_id, + h.display_name, + h.fqdn, + cve.cve_id, + cve.package_name, + cve.severity, + cve.available_version, + pd.updated_at AS last_seen_at +FROM hosts h +JOIN host_patch_data pd ON pd.host_id = h.id +CROSS JOIN LATERAL jsonb_to_recordset(COALESCE(pd.cve_data, '[]'::jsonb)) + AS cve(cve_id text, package_name text, severity text, available_version text) +WHERE ($1::timestamptz IS NULL OR pd.updated_at >= $1) + AND ($2::timestamptz IS NULL OR pd.updated_at <= $2) +ORDER BY + CASE cve.severity + WHEN 'critical' THEN 1 + WHEN 'high' THEN 2 + WHEN 'medium' THEN 3 + ELSE 4 + END, + h.display_name +") + .bind(params.from) + .bind(params.to) + .fetch_all(pool) + .await; + + match result { + Ok(rows) => { + for row in &rows { + use sqlx::Row; + let host_id: String = row.try_get("host_id").unwrap_or_default(); + let display_name: String = row.try_get("display_name").unwrap_or_default(); + let fqdn: String = row.try_get("fqdn").unwrap_or_default(); + let cve_id: String = row.try_get("cve_id").unwrap_or_default(); + let package_name: String = row.try_get("package_name").unwrap_or_default(); + let severity: String = row.try_get("severity").unwrap_or_default(); + let available_version: String = + row.try_get("available_version").unwrap_or_default(); + let last_seen_at: Option> = + row.try_get("last_seen_at").unwrap_or(None); + + wtr.write_record(&[ + host_id, + display_name, + fqdn, + cve_id, + package_name, + severity, + available_version, + last_seen_at.map(|d| d.to_rfc3339()).unwrap_or_default(), + ])?; + } + } + Err(e) => { + tracing::warn!(error = %e, "vulnerability query failed — returning empty rows"); + // write a comment row indicating empty data + wtr.write_record(&[ + "(no data)", "", "", "", "", "", "", + &format!("query error: {}", e), + ])?; + } + } + + Ok(wtr.into_inner().context("csv flush failed")?) +} + +// --------------------------------------------------------------------------- +// Audit +// --------------------------------------------------------------------------- + +async fn audit_csv( + pool: &sqlx::PgPool, + params: &ReportParams, +) -> anyhow::Result> { + let rows = sqlx::query(" +SELECT + id::text AS id, + created_at, + action::text AS action, + actor_username, + target_type, + target_id, + ip_address::text AS ip_address, + request_id +FROM audit_log +WHERE ($1::timestamptz IS NULL OR created_at >= $1) + AND ($2::timestamptz IS NULL OR created_at <= $2) +ORDER BY created_at DESC +LIMIT 10000 +") + .bind(params.from) + .bind(params.to) + .fetch_all(pool) + .await + .context("audit query failed")?; + + let mut wtr = csv::Writer::from_writer(vec![]); + wtr.write_record(&[ + "id", "created_at", "action", "actor_username", + "target_type", "target_id", "ip_address", "request_id", + ])?; + + for row in &rows { + use sqlx::Row; + let id: String = row.try_get("id").unwrap_or_default(); + let created_at: Option> = + row.try_get("created_at").unwrap_or(None); + let action: String = row.try_get("action").unwrap_or_default(); + let actor_username: String = row.try_get("actor_username").unwrap_or_default(); + let target_type: String = row.try_get("target_type").unwrap_or_default(); + let target_id: String = row.try_get("target_id").unwrap_or_default(); + let ip_address: String = row.try_get("ip_address").unwrap_or_default(); + let request_id: String = row.try_get("request_id").unwrap_or_default(); + + wtr.write_record(&[ + id, + created_at.map(|d| d.to_rfc3339()).unwrap_or_default(), + action, + actor_username, + target_type, + target_id, + ip_address, + request_id, + ])?; + } + + Ok(wtr.into_inner().context("csv flush failed")?) +} diff --git a/crates/pm-reports/src/lib.rs b/crates/pm-reports/src/lib.rs index 70a79c2..9906e3a 100644 --- a/crates/pm-reports/src/lib.rs +++ b/crates/pm-reports/src/lib.rs @@ -1,7 +1,27 @@ //! pm-reports — CSV and PDF report generation. //! //! Uses printpdf + plotters for in-process PDF with charts. -//! -//! M1: Stub. Full implementation in M9. pub mod csv; pub mod pdf; + +pub use csv::generate_csv; +pub use pdf::generate_pdf; + +/// The type of report to generate. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ReportType { + Compliance, + PatchHistory, + Vulnerability, + Audit, +} + +/// Parameters controlling report generation. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct ReportParams { + pub report_type: ReportType, + pub from: Option>, + pub to: Option>, + pub group_id: Option, +} diff --git a/crates/pm-reports/src/pdf.rs b/crates/pm-reports/src/pdf.rs index f4b1d1f..03c6368 100644 --- a/crates/pm-reports/src/pdf.rs +++ b/crates/pm-reports/src/pdf.rs @@ -1 +1,453 @@ -//! pdf report generation stub for M9. +//! PDF report generation for pm-reports. +//! +//! Uses printpdf for document structure and plotters + image for embedded charts. + +use crate::{ReportParams, ReportType}; +use anyhow::Context; +use plotters::prelude::*; +use printpdf::{ + BuiltinFont, ColorBits, ColorSpace, Image, ImageTransform, ImageXObject, + IndirectFontRef, Mm, PdfDocument, PdfLayerIndex, PdfLayerReference, + PdfPageIndex, Px, +}; + +const PAGE_W: f32 = 297.0; // A4 landscape width (mm) +const PAGE_H: f32 = 210.0; // A4 landscape height (mm) +const MARGIN: f32 = 10.0; +const ROW_H: f32 = 6.0; +const HEADER_Y_START: f32 = 190.0; +const NEW_PAGE_THRESHOLD: f32 = 20.0; + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +/// Generate a PDF report and return the raw bytes. +pub async fn generate_pdf( + pool: &sqlx::PgPool, + params: &ReportParams, +) -> anyhow::Result> { + match params.report_type { + ReportType::Compliance => compliance_pdf(pool, params).await, + ReportType::PatchHistory => patch_history_pdf(pool, params).await, + ReportType::Vulnerability => vulnerability_pdf(pool, params).await, + ReportType::Audit => audit_pdf(pool, params).await, + } +} + +// --------------------------------------------------------------------------- +// Chart helper +// --------------------------------------------------------------------------- + +/// Render a bar chart to an in-memory PNG and return the raw PNG bytes. +fn render_bar_chart( + labels: &[String], + values: &[f64], + title: &str, +) -> anyhow::Result<(Vec, u32, u32)> { + const W: u32 = 800; + const H: u32 = 400; + + let mut pixel_buf = vec![0u8; (W * H * 3) as usize]; + + { + let root = BitMapBackend::with_buffer(&mut pixel_buf, (W, H)) + .into_drawing_area(); + root.fill(&WHITE)?; + + let max_val = values + .iter() + .cloned() + .fold(0.0_f64, f64::max) + .max(1.0); + let n = labels.len().max(1); + + let mut chart = ChartBuilder::on(&root) + .caption(title, ("sans-serif", 20).into_font()) + .margin(20u32) + .x_label_area_size(60u32) + .y_label_area_size(50u32) + .build_cartesian_2d(0..n, 0.0..max_val * 1.1)?; + + chart + .configure_mesh() + .x_labels(n.min(20)) + .x_label_formatter(&|idx| { + labels + .get(*idx) + .map(|s| { + if s.len() > 12 { s[..12].to_string() } else { s.clone() } + }) + .unwrap_or_default() + }) + .y_desc("Value") + .draw()?; + + chart.draw_series((0..n).map(|i| { + let v = values.get(i).copied().unwrap_or(0.0); + let color = if v >= 90.0 { + RGBColor(76, 175, 80) + } else if v >= 70.0 { + RGBColor(255, 193, 7) + } else { + RGBColor(244, 67, 54) + }; + Rectangle::new([(i, 0.0), (i + 1, v)], color.filled()) + }))?; + + root.present()?; + } + + // Return raw RGB pixels + dimensions for direct PDF embedding + Ok((pixel_buf, W, H)) +} + +// --------------------------------------------------------------------------- +// PDF builder +// --------------------------------------------------------------------------- + +struct PdfBuilder { + doc: printpdf::PdfDocumentReference, + font: IndirectFontRef, + font_bold: IndirectFontRef, + page_idx: PdfPageIndex, + layer_idx: PdfLayerIndex, + current_y: f32, +} + +impl PdfBuilder { + fn new(title: &str) -> anyhow::Result { + let doc = PdfDocument::empty(title); + let (page_idx, layer_idx) = doc.add_page(Mm(PAGE_W), Mm(PAGE_H), "Layer 1"); + let font = doc.add_builtin_font(BuiltinFont::Helvetica)?; + let font_bold = doc.add_builtin_font(BuiltinFont::HelveticaBold)?; + Ok(Self { + doc, + font, + font_bold, + page_idx, + layer_idx, + current_y: HEADER_Y_START, + }) + } + + fn layer(&self) -> PdfLayerReference { + self.doc.get_page(self.page_idx).get_layer(self.layer_idx) + } + + fn write_text(&self, s: &str, font_size: f32, x: f32, y: f32, bold: bool) { + let f = if bold { &self.font_bold } else { &self.font }; + self.layer().use_text(s, font_size, Mm(x), Mm(y), f); + } + + fn new_page(&mut self) { + let (pi, li) = self.doc.add_page(Mm(PAGE_W), Mm(PAGE_H), "Layer 1"); + self.page_idx = pi; + self.layer_idx = li; + self.current_y = HEADER_Y_START; + } + + fn ensure_space(&mut self, needed: f32) { + if self.current_y - needed < NEW_PAGE_THRESHOLD { + self.new_page(); + } + } + + fn table_row(&mut self, cells: &[&str], col_x: &[f32], font_size: f32, bold: bool) { + self.ensure_space(ROW_H); + let y = self.current_y; + for (i, cell) in cells.iter().enumerate() { + let x = col_x.get(i).copied().unwrap_or(MARGIN); + let s = if cell.len() > 30 { &cell[..30] } else { cell }; + self.write_text(s, font_size, x, y, bold); + } + self.current_y -= ROW_H; + } + + fn embed_image( + &self, + raw_rgb: Vec, + img_w: u32, + img_h: u32, + x_mm: f32, + y_mm: f32, + scale_x: f32, + scale_y: f32, + ) -> anyhow::Result<()> { + let xobj = ImageXObject { + width: Px(img_w as usize), + height: Px(img_h as usize), + color_space: ColorSpace::Rgb, + bits_per_component: ColorBits::Bit8, + interpolate: true, + image_data: raw_rgb, + image_filter: None, + smask: None, + clipping_bbox: None, + }; + let pdf_img = Image::from(xobj); + pdf_img.add_to_layer( + self.layer(), + ImageTransform { + translate_x: Some(Mm(x_mm)), + translate_y: Some(Mm(y_mm)), + scale_x: Some(scale_x), + scale_y: Some(scale_y), + dpi: Some(150.0), + ..Default::default() + }, + ); + Ok(()) + } + + fn save(self) -> anyhow::Result> { + Ok(self.doc.save_to_bytes()?) + } +} + +// --------------------------------------------------------------------------- +// Title page helper +// --------------------------------------------------------------------------- + +fn write_title_page(pdf: &mut PdfBuilder, title: &str, params: &ReportParams) { + pdf.write_text(title, 24.0, MARGIN, 160.0, true); + pdf.write_text( + &format!("Generated: {}", chrono::Utc::now().format("%Y-%m-%d %H:%M UTC")), + 11.0, MARGIN, 148.0, false, + ); + if let Some(from) = params.from { + pdf.write_text( + &format!("From: {}", from.format("%Y-%m-%d")), + 10.0, MARGIN, 140.0, false, + ); + } + if let Some(to) = params.to { + pdf.write_text( + &format!("To: {}", to.format("%Y-%m-%d")), + 10.0, MARGIN, 134.0, false, + ); + } + if let Some(gid) = params.group_id { + pdf.write_text(&format!("Group: {}", gid), 10.0, MARGIN, 128.0, false); + } + pdf.new_page(); +} + +// --------------------------------------------------------------------------- +// Compliance PDF +// --------------------------------------------------------------------------- + +async fn compliance_pdf( + pool: &sqlx::PgPool, + params: &ReportParams, +) -> anyhow::Result> { + use sqlx::Row; + let rows = if let Some(gid) = params.group_id { + sqlx::query(" +SELECT h.display_name, h.fqdn, + COALESCE(pd.total_packages,0) AS total_packages, + COALESCE(pd.pending_patches,0) AS pending_patches, + CASE WHEN COALESCE(pd.total_packages,0)=0 THEN 100.0 + ELSE ROUND((1.0-pd.pending_patches::float/NULLIF(pd.total_packages,0))*100,1) + END AS compliance_pct, + h.health_status::text AS health_status +FROM hosts h LEFT JOIN host_patch_data pd ON pd.host_id=h.id +WHERE h.id IN (SELECT host_id FROM host_groups WHERE group_id=$1) +GROUP BY h.id,pd.total_packages,pd.pending_patches +ORDER BY compliance_pct ASC") + .bind(gid).fetch_all(pool).await + .context("compliance PDF query (group) failed")? + } else { + sqlx::query(" +SELECT h.display_name, h.fqdn, + COALESCE(pd.total_packages,0) AS total_packages, + COALESCE(pd.pending_patches,0) AS pending_patches, + CASE WHEN COALESCE(pd.total_packages,0)=0 THEN 100.0 + ELSE ROUND((1.0-pd.pending_patches::float/NULLIF(pd.total_packages,0))*100,1) + END AS compliance_pct, + h.health_status::text AS health_status +FROM hosts h LEFT JOIN host_patch_data pd ON pd.host_id=h.id +GROUP BY h.id,pd.total_packages,pd.pending_patches +ORDER BY compliance_pct ASC") + .fetch_all(pool).await + .context("compliance PDF query failed")? + }; + let labels: Vec = rows.iter().map(|r| r.try_get::("display_name").unwrap_or_default()).collect(); + let values: Vec = rows.iter().map(|r| r.try_get::("compliance_pct").unwrap_or(0.0)).collect(); + let mut pdf = PdfBuilder::new("Compliance Report")?; + write_title_page(&mut pdf, "Compliance Report", params); + let col_x: &[f32] = &[MARGIN, 65.0, 130.0, 165.0, 200.0, 235.0]; + pdf.table_row(&["Host","FQDN","Total Pkgs","Pending","Compliance %","Status"], col_x, 9.0, true); + for row in &rows { + let name: String = row.try_get("display_name").unwrap_or_default(); + let fqdn: String = row.try_get("fqdn").unwrap_or_default(); + let total: i64 = row.try_get("total_packages").unwrap_or(0); + let pend: i64 = row.try_get("pending_patches").unwrap_or(0); + let pct: f64 = row.try_get("compliance_pct").unwrap_or(0.0); + let status: String = row.try_get("health_status").unwrap_or_default(); + pdf.table_row( + &[&name,&fqdn,&total.to_string(),&pend.to_string(),&format!("{:.1}%",pct),&status], + col_x, 8.0, false, + ); + } + if !labels.is_empty() { + match render_bar_chart(&labels, &values, "Compliance % by Host") { + Ok((raw, w, h)) => { + pdf.new_page(); + pdf.write_text("Compliance Chart", 16.0, MARGIN, 200.0, true); + if let Err(e) = pdf.embed_image(raw, w, h, MARGIN, 10.0, 0.18, 0.18) { + tracing::warn!(error = %e, "chart embed failed"); + } + } + Err(e) => tracing::warn!(error = %e, "chart render failed"), + } + } + pdf.save() +} + +// --------------------------------------------------------------------------- +// Patch history PDF +// --------------------------------------------------------------------------- + +async fn patch_history_pdf( + pool: &sqlx::PgPool, + params: &ReportParams, +) -> anyhow::Result> { + use sqlx::Row; + let rows = sqlx::query(" +SELECT pj.kind::text AS job_kind, pj.status::text AS job_status, + h.display_name, h.fqdn, pjh.started_at, pjh.completed_at, + EXTRACT(EPOCH FROM (pjh.completed_at-pjh.started_at))::bigint AS duration_seconds, + COALESCE(u.username,'system') AS operator +FROM patch_job_hosts pjh +JOIN patch_jobs pj ON pj.id=pjh.job_id +JOIN hosts h ON h.id=pjh.host_id +LEFT JOIN users u ON u.id=pj.created_by_user_id +WHERE ($1::timestamptz IS NULL OR pjh.started_at>=$1) + AND ($2::timestamptz IS NULL OR pjh.started_at<=$2) +ORDER BY pjh.started_at DESC") + .bind(params.from).bind(params.to).fetch_all(pool).await + .context("patch history PDF query failed")?; + let mut dc: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for row in &rows { + if let Ok(Some(s)) = row.try_get::>,_>("started_at") { + *dc.entry(s.format("%Y-%m-%d").to_string()).or_insert(0.0) += 1.0; + } + } + let cl: Vec = dc.keys().cloned().collect(); + let cv: Vec = dc.values().cloned().collect(); + let mut pdf = PdfBuilder::new("Patch History Report")?; + write_title_page(&mut pdf, "Patch History Report", params); + let col_x: &[f32] = &[MARGIN,45.0,80.0,115.0,155.0,200.0,245.0,270.0]; + pdf.table_row(&["Kind","Status","Host","FQDN","Started","Completed","Dur(s)","Operator"], col_x, 9.0, true); + for row in &rows { + let kind: String = row.try_get("job_kind").unwrap_or_default(); + let status: String = row.try_get("job_status").unwrap_or_default(); + let name: String = row.try_get("display_name").unwrap_or_default(); + let fqdn: String = row.try_get("fqdn").unwrap_or_default(); + let started: String = row.try_get::>,_>("started_at") + .unwrap_or(None).map(|d| d.format("%Y-%m-%d %H:%M").to_string()).unwrap_or_default(); + let completed: String = row.try_get::>,_>("completed_at") + .unwrap_or(None).map(|d| d.format("%Y-%m-%d %H:%M").to_string()).unwrap_or_default(); + let dur: i64 = row.try_get("duration_seconds").unwrap_or(0); + let op: String = row.try_get("operator").unwrap_or_default(); + pdf.table_row(&[&kind,&status,&name,&fqdn,&started,&completed,&dur.to_string(),&op], col_x, 8.0, false); + } + if !cl.is_empty() { + match render_bar_chart(&cl, &cv, "Jobs per Day") { + Ok((raw, w, h)) => { + pdf.new_page(); + pdf.write_text("Patch Activity Chart", 16.0, MARGIN, 200.0, true); + if let Err(e) = pdf.embed_image(raw, w, h, MARGIN, 10.0, 0.18, 0.18) { + tracing::warn!(error = %e, "chart embed failed"); + } + } + Err(e) => tracing::warn!(error = %e, "chart render failed"), + } + } + pdf.save() +} + +// --------------------------------------------------------------------------- +// Vulnerability PDF +// --------------------------------------------------------------------------- + +async fn vulnerability_pdf( + pool: &sqlx::PgPool, + params: &ReportParams, +) -> anyhow::Result> { + use sqlx::Row; + // Query DB FIRST (before creating any non-Send PdfBuilder) + let query_result = sqlx::query(" +SELECT h.display_name, h.fqdn, + cve.cve_id, cve.package_name, cve.severity, cve.available_version, + pd.updated_at AS last_seen_at +FROM hosts h JOIN host_patch_data pd ON pd.host_id=h.id +CROSS JOIN LATERAL jsonb_to_recordset(COALESCE(pd.cve_data,'[]'::jsonb)) + AS cve(cve_id text,package_name text,severity text,available_version text) +WHERE ($1::timestamptz IS NULL OR pd.updated_at>=$1) + AND ($2::timestamptz IS NULL OR pd.updated_at<=$2) +ORDER BY CASE cve.severity WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END, + h.display_name") + .bind(params.from).bind(params.to).fetch_all(pool).await; + // Now create PdfBuilder (non-Send Rc types) after all awaits + let mut pdf = PdfBuilder::new("Vulnerability Report")?; + write_title_page(&mut pdf, "Vulnerability Exposure Report", params); + let col_x: &[f32] = &[MARGIN,55.0,100.0,130.0,175.0,215.0,255.0]; + pdf.table_row(&["Host","FQDN","CVE ID","Package","Severity","Fix Version","Last Seen"], col_x, 9.0, true); + match query_result { + Ok(rows) => { + for row in &rows { + let name: String = row.try_get("display_name").unwrap_or_default(); + let fqdn: String = row.try_get("fqdn").unwrap_or_default(); + let cve: String = row.try_get("cve_id").unwrap_or_default(); + let pkg: String = row.try_get("package_name").unwrap_or_default(); + let sev: String = row.try_get("severity").unwrap_or_default(); + let fix: String = row.try_get("available_version").unwrap_or_default(); + let seen: String = row.try_get::>,_>("last_seen_at") + .unwrap_or(None).map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default(); + pdf.table_row(&[&name,&fqdn,&cve,&pkg,&sev,&fix,&seen], col_x, 8.0, false); + } + } + Err(e) => { + tracing::warn!(error = %e, "vulnerability PDF query failed"); + let y = pdf.current_y; + pdf.write_text(&format!("No data: {}", e), 10.0, MARGIN, y, false); + } + } + pdf.save() +} + + +async fn audit_pdf( + pool: &sqlx::PgPool, + params: &ReportParams, +) -> anyhow::Result> { + use sqlx::Row; + let rows = sqlx::query(" +SELECT id::text AS id, created_at, action::text AS action, + actor_username, target_type, target_id, + ip_address::text AS ip_address, request_id +FROM audit_log +WHERE ($1::timestamptz IS NULL OR created_at>=$1) + AND ($2::timestamptz IS NULL OR created_at<=$2) +ORDER BY created_at DESC LIMIT 10000") + .bind(params.from).bind(params.to).fetch_all(pool).await + .context("audit PDF query failed")?; + let mut pdf = PdfBuilder::new("Audit Trail Report")?; + write_title_page(&mut pdf, "Audit Trail Report", params); + let col_x: &[f32] = &[MARGIN,50.0,95.0,135.0,175.0,215.0,255.0]; + pdf.table_row(&["Timestamp","Action","Actor","Target Type","Target ID","IP","Request ID"], col_x, 9.0, true); + for row in &rows { + let created: String = row.try_get::>,_>("created_at") + .unwrap_or(None).map(|d| d.format("%Y-%m-%d %H:%M").to_string()).unwrap_or_default(); + let action: String = row.try_get("action").unwrap_or_default(); + let actor: String = row.try_get("actor_username").unwrap_or_default(); + let ttype: String = row.try_get("target_type").unwrap_or_default(); + let tid: String = row.try_get("target_id").unwrap_or_default(); + let ip: String = row.try_get("ip_address").unwrap_or_default(); + let req: String = row.try_get("request_id").unwrap_or_default(); + pdf.table_row(&[&created,&action,&actor,&ttype,&tid,&ip,&req], col_x, 8.0, false); + } + pdf.save() +} diff --git a/crates/pm-web/Cargo.toml b/crates/pm-web/Cargo.toml index 9328414..e5fffd7 100644 --- a/crates/pm-web/Cargo.toml +++ b/crates/pm-web/Cargo.toml @@ -10,8 +10,10 @@ name = "pm-web" path = "src/main.rs" [dependencies] +pm-ca = { path = "../pm-ca" } pm-core = { path = "../pm-core" } pm-auth = { path = "../pm-auth" } +pm-reports = { path = "../pm-reports" } tokio = { workspace = true } axum = { workspace = true } axum-extra = { workspace = true } diff --git a/crates/pm-web/src/main.rs b/crates/pm-web/src/main.rs index 6c7c0b4..5a80782 100644 --- a/crates/pm-web/src/main.rs +++ b/crates/pm-web/src/main.rs @@ -42,6 +42,8 @@ pub struct AppState { pub auth_config: Arc, /// In-memory store for single-use WebSocket authentication tickets. pub ws_tickets: Arc>, + /// Internal certificate authority for mTLS client cert issuance. + pub ca: Arc, } #[tokio::main] @@ -77,6 +79,16 @@ async fn main() -> anyhow::Result<()> { let pool = db::init_pool(&config.database).await?; db::run_migrations(&pool).await?; + // Initialise the internal CA. Panics in production if CA files are missing + // or corrupt — this is intentional; the service cannot operate without mTLS. + let ca_base = std::path::Path::new("/etc/patch-manager/ca"); + let ca = pm_ca::CertAuthority::init(ca_base, &pool) + .await + .unwrap_or_else(|e| { + tracing::warn!(error = %e, "CA init failed (dev mode)"); + panic!("CA initialization failed: {}", e); + }); + let ws_tickets: Arc> = Arc::new(DashMap::new()); // Background task: purge expired WS tickets every 30 seconds. @@ -103,6 +115,7 @@ async fn main() -> anyhow::Result<()> { signing_key_pem, auth_config, ws_tickets, + ca: Arc::new(ca), }; let app = build_router(state); @@ -128,6 +141,8 @@ pub fn build_router(state: AppState) -> Router { .merge(routes::auth::protected_router()) // Hosts .nest("/hosts", routes::hosts::router()) + // Host-scoped certificate endpoints (merged separately to avoid conflict) + .nest("/hosts", routes::ca::host_cert_router()) // Groups .nest("/groups", routes::groups::router()) // Users @@ -140,8 +155,14 @@ pub fn build_router(state: AppState) -> Router { .nest("/jobs", routes::jobs::router()) // Maintenance windows (nested under hosts path param) .nest("/hosts/:host_id/maintenance-windows", routes::maintenance_windows::router()) + // CA root certificate download + .nest("/ca", routes::ca::ca_router()) + // Certificate list / renew / revoke + .nest("/certificates", routes::ca::certs_router()) // WS ticket issuance (JWT-protected — ticket returned to browser, then used for WS upgrade) .merge(routes::ws::ticket_router()) + // Reports + .nest("/reports", routes::reports::router()) // Apply auth middleware to all the above .route_layer(middleware::from_fn(move |req, next| { let auth_config = auth_config.clone(); diff --git a/crates/pm-web/src/routes/ca.rs b/crates/pm-web/src/routes/ca.rs new file mode 100644 index 0000000..92ef8a0 --- /dev/null +++ b/crates/pm-web/src/routes/ca.rs @@ -0,0 +1,349 @@ +//! CA / certificate management routes. +//! +//! ca_router() → mounted at /api/v1/ca +//! GET /root.crt download_root_ca (any authed role) +//! +//! certs_router() → mounted at /api/v1/certificates +//! GET / list_certificates (any authed role) +//! POST /:cert_id/renew renew_cert (admin only) +//! DELETE /:cert_id revoke_cert (admin only) +//! +//! host_cert_router() → merged under /api/v1/hosts +//! GET /:host_id/client.crt download_client_cert (admin only) +//! POST /:host_id/certificates issue_client_cert (admin only) + +use axum::{ + body::Body, + extract::{Path, Query, State}, + http::{header, Response, StatusCode}, + response::Json, + routing::{delete, get, post}, + Router, +}; +use chrono::{DateTime, Utc}; +use pm_auth::rbac::AuthUser; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::AppState; + +// ── Router constructors ─────────────────────────────────────────────────────── + +/// Handles routes mounted at /api/v1/ca +pub fn ca_router() -> Router { + Router::new() + .route("/root.crt", get(download_root_ca)) +} + +/// Handles routes mounted at /api/v1/certificates +pub fn certs_router() -> Router { + Router::new() + .route("/", get(list_certificates)) + .route("/:cert_id/renew", post(renew_cert)) + .route("/:cert_id", delete(revoke_cert)) +} + +/// Handles cert-specific paths merged under /api/v1/hosts. +/// Only adds paths not already claimed by the hosts router. +pub fn host_cert_router() -> Router { + Router::new() + .route("/:host_id/client.crt", get(download_client_cert)) + .route("/:host_id/certificates", post(issue_client_cert)) +} + +// ── Shared types ────────────────────────────────────────────────────────────── + +/// Row returned from the `certificates` table. +#[derive(Debug, Serialize, sqlx::FromRow)] +struct CertRow { + id: Uuid, + host_id: Option, + serial_number: String, + common_name: String, + /// Cast to TEXT in all queries to avoid custom-enum decode. + status: String, + issued_at: DateTime, + expires_at: DateTime, + revoked_at: Option>, +} + +/// Query params for `list_certificates`. +#[derive(Debug, Deserialize)] +struct CertListQuery { + host_id: Option, + status: Option, +} + +/// Request body for `issue_client_cert`. +#[derive(Debug, Deserialize)] +struct IssueCertRequest { + hostname: String, +} + +// ── Helper: build PEM download response ────────────────────────────────────── + +fn pem_response( + pem: String, + filename: &str, +) -> Result, (StatusCode, Json)> { + let disposition = format!("attachment; filename=\"{filename}\""); + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/x-pem-file") + .header(header::CONTENT_DISPOSITION, disposition) + .body(Body::from(pem)) + .map_err(|e| { + tracing::error!(error = %e, "Failed to build PEM response"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": "Response build error" } })), + ) + }) +} + +// ── Helper: admin-only guard ────────────────────────────────────────────────── + +fn require_admin(user: &AuthUser) -> Result<(), (StatusCode, Json)> { + if !user.role.is_admin() { + return Err(( + StatusCode::FORBIDDEN, + Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })), + )); + } + Ok(()) +} + +// ── Helper: map sqlx error to 500 ───────────────────────────────────────────── + +fn db_error(e: sqlx::Error) -> (StatusCode, Json) { + tracing::error!(error = %e, "Database error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })), + ) +} + +// ── GET /api/v1/ca/root.crt ─────────────────────────────────────────────────── + +/// Download the root CA certificate as a PEM file. +async fn download_root_ca( + State(state): State, + _auth: AuthUser, +) -> Result, (StatusCode, Json)> { + let pem = state.ca.root_cert_pem().to_owned(); + pem_response(pem, "ca.crt") +} + +// ── GET /api/v1/certificates ────────────────────────────────────────────────── + +/// List certificates with optional `?host_id=` and `?status=` filters. +async fn list_certificates( + State(state): State, + _auth: AuthUser, + Query(q): Query, +) -> Result>, (StatusCode, Json)> { + // Use the non-macro query_as form — avoids needing DATABASE_URL at compile + // time. status is cast to TEXT so sqlx decodes it into String directly. + let rows: Vec = match (q.host_id, q.status.as_deref()) { + (Some(hid), Some(st)) => { + sqlx::query_as::<_, CertRow>( + r#"SELECT id, host_id, serial_number, common_name, + status::text AS status, + issued_at, expires_at, revoked_at + FROM certificates + WHERE host_id = $1 AND status::text = $2 + ORDER BY issued_at DESC"#, + ) + .bind(hid) + .bind(st) + .fetch_all(&state.db) + .await + } + (Some(hid), None) => { + sqlx::query_as::<_, CertRow>( + r#"SELECT id, host_id, serial_number, common_name, + status::text AS status, + issued_at, expires_at, revoked_at + FROM certificates + WHERE host_id = $1 + ORDER BY issued_at DESC"#, + ) + .bind(hid) + .fetch_all(&state.db) + .await + } + (None, Some(st)) => { + sqlx::query_as::<_, CertRow>( + r#"SELECT id, host_id, serial_number, common_name, + status::text AS status, + issued_at, expires_at, revoked_at + FROM certificates + WHERE status::text = $1 + ORDER BY issued_at DESC"#, + ) + .bind(st) + .fetch_all(&state.db) + .await + } + (None, None) => { + sqlx::query_as::<_, CertRow>( + r#"SELECT id, host_id, serial_number, common_name, + status::text AS status, + issued_at, expires_at, revoked_at + FROM certificates + ORDER BY issued_at DESC"#, + ) + .fetch_all(&state.db) + .await + } + } + .map_err(db_error)?; + + Ok(Json(rows)) +} + +// ── GET /api/v1/hosts/:host_id/client.crt ──────────────────────────────────── + +/// Download the most recent active client certificate PEM for a host. +async fn download_client_cert( + State(state): State, + auth: AuthUser, + Path(host_id): Path, +) -> Result, (StatusCode, Json)> { + require_admin(&auth)?; + + let cert_pem: Option = sqlx::query_scalar( + r#"SELECT cert_pem + FROM certificates + WHERE host_id = $1 + AND status = 'active'::cert_status + ORDER BY issued_at DESC + LIMIT 1"#, + ) + .bind(host_id) + .fetch_optional(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, %host_id, "Failed to fetch client cert"); + db_error(e) + })?; + + match cert_pem { + Some(pem) => pem_response(pem, "client.crt"), + None => Err(( + StatusCode::NOT_FOUND, + Json(json!({ + "error": { + "code": "not_found", + "message": "No active certificate found for this host" + } + })), + )), + } +} + +// ── POST /api/v1/hosts/:host_id/certificates ───────────────────────────────── + +/// Issue a new mTLS client certificate for a host. +/// **The private key is returned only once — the caller must save it.** +async fn issue_client_cert( + State(state): State, + auth: AuthUser, + Path(host_id): Path, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + require_admin(&auth)?; + + let issued = state + .ca + .issue_client_cert(host_id, &req.hostname, &state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, %host_id, hostname = %req.hostname, + "Failed to issue client cert"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } })), + ) + })?; + + Ok(Json(json!({ + "cert_pem": issued.cert_pem, + "key_pem": issued.key_pem, + "serial_number": issued.serial_number, + "expires_at": issued.expires_at, + }))) +} + +// ── POST /api/v1/certificates/:cert_id/renew ───────────────────────────────── + +/// Revoke the specified certificate and issue a replacement with the same CN. +async fn renew_cert( + State(state): State, + auth: AuthUser, + Path(cert_id): Path, +) -> Result, (StatusCode, Json)> { + require_admin(&auth)?; + + let issued = state + .ca + .renew_cert(cert_id, &state.db) + .await + .map_err(|e| { + let msg = e.to_string(); + tracing::error!(error = %e, %cert_id, "Failed to renew cert"); + if msg.contains("not found") { + ( + StatusCode::NOT_FOUND, + Json(json!({ "error": { "code": "not_found", "message": "Certificate not found" } })), + ) + } else { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": msg } })), + ) + } + })?; + + Ok(Json(json!({ + "cert_pem": issued.cert_pem, + "key_pem": issued.key_pem, + "serial_number": issued.serial_number, + "expires_at": issued.expires_at, + }))) +} + +// ── DELETE /api/v1/certificates/:cert_id ───────────────────────────────────── + +/// Revoke a certificate by ID. Sets status to 'revoked' in the database. +async fn revoke_cert( + State(state): State, + auth: AuthUser, + Path(cert_id): Path, +) -> Result, (StatusCode, Json)> { + require_admin(&auth)?; + + state + .ca + .revoke_cert(cert_id, &state.db) + .await + .map_err(|e| { + let msg = e.to_string(); + tracing::error!(error = %e, %cert_id, "Failed to revoke cert"); + if msg.contains("not found") { + ( + StatusCode::NOT_FOUND, + Json(json!({ "error": { "code": "not_found", "message": "Certificate not found" } })), + ) + } else { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": { "code": "internal_error", "message": msg } })), + ) + } + })?; + + tracing::info!(%cert_id, "Certificate revoked via API"); + Ok(Json(json!({ "revoked": true }))) +} diff --git a/crates/pm-web/src/routes/mod.rs b/crates/pm-web/src/routes/mod.rs index 7f6b3e1..4c396f5 100644 --- a/crates/pm-web/src/routes/mod.rs +++ b/crates/pm-web/src/routes/mod.rs @@ -1,5 +1,6 @@ //! Route modules for the pm-web API. pub mod auth; +pub mod ca; pub mod discovery; pub mod groups; pub mod hosts; @@ -8,3 +9,5 @@ pub mod jobs; pub mod status; pub mod users; pub mod ws; + +pub mod reports; diff --git a/crates/pm-web/src/routes/reports.rs b/crates/pm-web/src/routes/reports.rs new file mode 100644 index 0000000..0ab5ea3 --- /dev/null +++ b/crates/pm-web/src/routes/reports.rs @@ -0,0 +1,153 @@ +//! Report generation endpoints. +//! +//! GET /api/v1/reports/compliance?format=csv|pdf&from=...&to=...&group_id=... +//! GET /api/v1/reports/patch-history?format=csv|pdf&from=...&to=... +//! GET /api/v1/reports/vulnerability?format=csv|pdf&from=...&to=... +//! GET /api/v1/reports/audit?format=csv|pdf&from=...&to=... + +use axum::{ + body::Bytes, + extract::{Query, State}, + http::{header, HeaderMap, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + Router, +}; +use pm_reports::{ReportParams, ReportType}; + +use crate::AppState; + +#[derive(serde::Deserialize)] +struct ReportQuery { + /// "csv" or "pdf" (defaults to "csv") + format: Option, + from: Option>, + to: Option>, + group_id: Option, +} + +pub fn router() -> Router { + Router::new() + .route("/compliance", get(compliance_report)) + .route("/patch-history", get(patch_history_report)) + .route("/vulnerability", get(vulnerability_report)) + .route("/audit", get(audit_report)) +} + +// --------------------------------------------------------------------------- +// Internal helper +// --------------------------------------------------------------------------- + +async fn run_report( + db: sqlx::PgPool, + params: ReportParams, + use_pdf: bool, + csv_name: &'static str, + pdf_name: &'static str, +) -> Response { + let (ct, disposition, result) = if use_pdf { + let disp = format!("attachment; filename=\"{}\"", pdf_name); + let data = pm_reports::generate_pdf(&db, ¶ms).await; + ("application/pdf", disp, data) + } else { + let disp = format!("attachment; filename=\"{}\"", csv_name); + let data = pm_reports::generate_csv(&db, ¶ms).await; + ("text/csv; charset=utf-8", disp, data) + }; + + match result { + Ok(bytes) => { + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static(ct), + ); + headers.insert( + header::CONTENT_DISPOSITION, + HeaderValue::from_str(&disposition) + .unwrap_or_else(|_| HeaderValue::from_static("attachment")), + ); + (headers, Bytes::from(bytes)).into_response() + } + Err(e) => { + tracing::error!(error = %e, "report generation failed"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("Report error: {}", e)).into_response() + } + } +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +async fn compliance_report( + State(state): State, + Query(q): Query, +) -> Response { + let params = ReportParams { + report_type: ReportType::Compliance, + from: q.from, + to: q.to, + group_id: q.group_id, + }; + let use_pdf = matches!(q.format.as_deref(), Some("pdf")); + run_report( + state.db, params, use_pdf, + "compliance-report.csv", + "compliance-report.pdf", + ).await +} + +async fn patch_history_report( + State(state): State, + Query(q): Query, +) -> Response { + let params = ReportParams { + report_type: ReportType::PatchHistory, + from: q.from, + to: q.to, + group_id: q.group_id, + }; + let use_pdf = matches!(q.format.as_deref(), Some("pdf")); + run_report( + state.db, params, use_pdf, + "patch-history-report.csv", + "patch-history-report.pdf", + ).await +} + +async fn vulnerability_report( + State(state): State, + Query(q): Query, +) -> Response { + let params = ReportParams { + report_type: ReportType::Vulnerability, + from: q.from, + to: q.to, + group_id: q.group_id, + }; + let use_pdf = matches!(q.format.as_deref(), Some("pdf")); + run_report( + state.db, params, use_pdf, + "vulnerability-report.csv", + "vulnerability-report.pdf", + ).await +} + +async fn audit_report( + State(state): State, + Query(q): Query, +) -> Response { + let params = ReportParams { + report_type: ReportType::Audit, + from: q.from, + to: q.to, + group_id: q.group_id, + }; + let use_pdf = matches!(q.format.as_deref(), Some("pdf")); + run_report( + state.db, params, use_pdf, + "audit-report.csv", + "audit-report.pdf", + ).await +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a62aff3..d1d519d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,8 @@ import DashboardPage from './pages/DashboardPage' import PatchDeploymentPage from './pages/PatchDeploymentPage' import JobsPage from './pages/JobsPage' import MaintenanceWindowsPage from './pages/MaintenanceWindowsPage' +import CertificatesPage from './pages/CertificatesPage' +import ReportsPage from './pages/ReportsPage' // Placeholder pages — implemented in later milestones const PlaceholderPage = ({ title }: { title: string }) => ( @@ -53,8 +55,10 @@ function App() { } /> {/* Placeholder — later milestones */} - } /> - } /> + {/* Protected — M9 */} + } /> + {/* Protected — M8 */} + } /> } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ad25762..a831458 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -6,6 +6,8 @@ import type { CreateJobRequest, CreateMaintenanceWindowRequest, UpdateMaintenanceWindowRequest, + Certificate, + IssuedCert, } from '../types' const BASE_URL = '/api/v1' @@ -147,3 +149,51 @@ export const wsApi = { createTicket: (): Promise<{ ticket: string }> => apiClient.post<{ ticket: string }>('/ws/ticket').then((r) => r.data), } + +// ── Certificates API (M8) ──────────────────────────────────────────────────── +export const certsApi = { + // List all certs, optional filters + list: (params?: { host_id?: string; status?: string }) => + apiClient.get('/certificates', { params }), + + // Download root CA cert as blob + downloadRootCa: () => + apiClient.get('/ca/root.crt', { responseType: 'blob' }), + + // Issue client cert for a host — returns IssuedCert (key_pem only shown once!) + issue: (hostId: string, hostname: string) => + apiClient.post(`/hosts/${hostId}/certificates`, { hostname }), + + // Renew a cert + renew: (certId: string) => + apiClient.post(`/certificates/${certId}/renew`), + + // Revoke a cert + revoke: (certId: string) => + apiClient.delete(`/certificates/${certId}`), + + // Download host client cert as blob + downloadClientCert: (hostId: string) => + apiClient.get(`/hosts/${hostId}/client.crt`, { responseType: 'blob' }), +} + +// ── Reports API (M9) ───────────────────────────────────────────────────────── +export type ReportType = 'compliance' | 'patch-history' | 'vulnerability' | 'audit' +export type ReportFormat = 'csv' | 'pdf' + +export const reportsApi = { + download: ( + reportType: ReportType, + format: ReportFormat, + params?: { + from?: string // ISO 8601 + to?: string // ISO 8601 + group_id?: string // UUID + } + ) => + apiClient.get(`/reports/${reportType}`, { + params: { format, ...params }, + responseType: 'blob', + timeout: 120_000, // reports can take a while + }), +} diff --git a/frontend/src/pages/CertificatesPage.tsx b/frontend/src/pages/CertificatesPage.tsx new file mode 100644 index 0000000..f179a44 --- /dev/null +++ b/frontend/src/pages/CertificatesPage.tsx @@ -0,0 +1,483 @@ +import { useCallback, useEffect, useState } from 'react' +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Container, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + IconButton, + InputLabel, + MenuItem, + Paper, + Select, + type SelectChangeEvent, + Snackbar, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Toolbar, + Tooltip, + Typography, +} from '@mui/material' +import { + ContentCopy as CopyIcon, + Download as DownloadIcon, + Refresh as RefreshIcon, + Security as SecurityIcon, +} from '@mui/icons-material' +import { certsApi } from '../api/client' +import { useAuthStore } from '../store/authStore' +import type { Certificate, CertStatus, IssuedCert } from '../types' + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +function fmtDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) +} + +function isExpiringSoon(iso: string): boolean { + return new Date(iso).getTime() - Date.now() < 30 * 24 * 60 * 60 * 1000 +} + +function statusChip(status: CertStatus) { + const map: Record = { + active: { label: 'Active', color: 'success' }, + revoked: { label: 'Revoked', color: 'error' }, + expired: { label: 'Expired', color: 'warning' }, + } + const { label, color } = map[status] + return +} + +// ── Issue Dialog ────────────────────────────────────────────────────────────── + +interface IssueDialogProps { + open: boolean + onClose: () => void + onIssued: (cert: IssuedCert) => void +} + +function IssueDialog({ open, onClose, onIssued }: IssueDialogProps) { + const [hostId, setHostId] = useState('') + const [hostname, setHostname] = useState('') + const [saving, setSaving] = useState(false) + const [err, setErr] = useState(null) + + useEffect(() => { + if (open) { setHostId(''); setHostname(''); setErr(null) } + }, [open]) + + const handleSubmit = async () => { + if (!hostId.trim()) { setErr('Host ID is required'); return } + if (!hostname.trim()) { setErr('Hostname is required'); return } + setSaving(true); setErr(null) + try { + const res = await certsApi.issue(hostId.trim(), hostname.trim()) + onIssued(res.data) + onClose() + } catch (e: unknown) { + const msg = (e as { response?: { data?: { error?: { message?: string } } } }) + ?.response?.data?.error?.message ?? 'Failed to issue certificate' + setErr(msg) + } finally { + setSaving(false) + } + } + + return ( + + Issue Client Certificate + + {err && {err}} + setHostId(e.target.value)} + required + fullWidth + placeholder="e.g. 3fa85f64-5717-4562-b3fc-2c963f66afa6" + /> + setHostname(e.target.value)} + required + fullWidth + placeholder="e.g. web-01.example.com" + /> + + + + + + + ) +} + +// ── One-Time Key Display Dialog ─────────────────────────────────────────────── + +interface KeyDisplayDialogProps { + open: boolean + cert: IssuedCert | null + onClose: () => void +} + +function KeyDisplayDialog({ open, cert, onClose }: KeyDisplayDialogProps) { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + if (!cert?.key_pem) return + await navigator.clipboard.writeText(cert.key_pem) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + Certificate Issued — Save Your Private Key + + + This private key will NOT be shown again. Copy and store it securely + before closing this dialog. + + {cert && ( + + + Serial: {cert.serial_number}  |  Expires: {fmtDate(cert.expires_at)} + + + {cert.key_pem} + + + )} + + + + + + + + + ) +} + +// ── Main Page ───────────────────────────────────────────────────────────────── + +export default function CertificatesPage() { + const user = useAuthStore((s) => s.user) + const isAdmin = user?.role === 'admin' + + const [certs, setCerts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Filters + const [statusFilter, setStatusFilter] = useState('all') + const [hostFilter, setHostFilter] = useState('') + + // Dialogs + const [issueOpen, setIssueOpen] = useState(false) + const [issuedCert, setIssuedCert] = useState(null) + const [keyDialogOpen, setKeyDialogOpen] = useState(false) + + // Snackbar + const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({ + open: false, message: '', severity: 'success', + }) + + const showSnack = (message: string, severity: 'success' | 'error') => + setSnackbar({ open: true, message, severity }) + + // ── Load certs ────────────────────────────────────────────────────────────── + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + const params: { status?: string; host_id?: string } = {} + if (statusFilter !== 'all') params.status = statusFilter + if (hostFilter.trim()) params.host_id = hostFilter.trim() + const res = await certsApi.list(params) + setCerts(res.data) + } catch { + setError('Failed to load certificates') + } finally { + setLoading(false) + } + }, [statusFilter, hostFilter]) + + useEffect(() => { load() }, [load]) + + // ── Download Root CA ──────────────────────────────────────────────────────── + const handleDownloadRootCa = async () => { + try { + const res = await certsApi.downloadRootCa() + downloadBlob(res.data as Blob, 'ca.crt') + } catch { + showSnack('Failed to download Root CA certificate', 'error') + } + } + + // ── Issue cert ────────────────────────────────────────────────────────────── + const handleIssued = (cert: IssuedCert) => { + setIssuedCert(cert) + setKeyDialogOpen(true) + void load() + } + + // ── Renew cert ────────────────────────────────────────────────────────────── + const handleRenew = async (certId: string) => { + try { + const res = await certsApi.renew(certId) + setIssuedCert(res.data) + setKeyDialogOpen(true) + void load() + } catch { + showSnack('Failed to renew certificate', 'error') + } + } + + // ── Revoke cert ───────────────────────────────────────────────────────────── + const handleRevoke = async (certId: string) => { + if (!window.confirm('Revoke this certificate? This cannot be undone.')) return + try { + await certsApi.revoke(certId) + showSnack('Certificate revoked', 'success') + void load() + } catch { + showSnack('Failed to revoke certificate', 'error') + } + } + + // ── Render ────────────────────────────────────────────────────────────────── + return ( + + {/* Header */} + + + + Certificate Management + + {isAdmin && ( + + )} + + + + + + + {loading ? : } + + + + + + {/* Error */} + {error && ( + + {error} + + )} + + {/* Filters */} + + + Status + + + setHostFilter(e.target.value)} + placeholder="UUID or partial…" + sx={{ minWidth: 260 }} + /> + + + {/* Table */} + + {loading ? ( + + + + ) : certs.length === 0 ? ( + + No certificates found. + + ) : ( + + + + Common Name + Serial Number + Status + Issued At + Expires At + Host + Actions + + + + {certs.map((cert) => { + const expiring = cert.status === 'active' && isExpiringSoon(cert.expires_at) + return ( + + + + {cert.common_name} + + + + + {cert.serial_number} + + + {statusChip(cert.status)} + + {fmtDate(cert.issued_at)} + + + + {fmtDate(cert.expires_at)} + {expiring && ' ⚠️'} + + + + + {cert.host_id ?? Root CA} + + + + {isAdmin && ( + <> + + + + {cert.status === 'active' && ( + + + + )} + + )} + + + ) + })} + +
+ )} +
+ + {/* Issue Dialog */} + setIssueOpen(false)} + onIssued={handleIssued} + /> + + {/* One-time key display dialog */} + setKeyDialogOpen(false)} + /> + + {/* Snackbar */} + setSnackbar((p) => ({ ...p, open: false }))} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setSnackbar((p) => ({ ...p, open: false }))} + > + {snackbar.message} + + +
+ ) +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 97a26ee..14b2456 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -21,8 +21,9 @@ import { BugReport, RestartAlt, Refresh as RefreshIcon, + Security as SecurityIcon, } from '@mui/icons-material' -import { fleetApi } from '../api/client' +import { fleetApi, certsApi } from '../api/client' import type { FleetStatus } from '../types' // ── StatCard ───────────────────────────────────────────────────────────────── @@ -84,12 +85,33 @@ export default function DashboardPage() { return () => clearInterval(t) }, [load]) + // ── Download Root CA ────────────────────────────────────────────────────── + const handleDownloadRootCa = async () => { + try { + const res = await certsApi.downloadRootCa() + const url = URL.createObjectURL(res.data as Blob) + const a = document.createElement('a') + a.href = url + a.download = 'ca.crt' + a.click() + URL.revokeObjectURL(url) + } catch { + // silently ignore — user will see no download; no state change needed + } + } + + return ( Dashboard + + + + + diff --git a/frontend/src/pages/HostDetailPage.tsx b/frontend/src/pages/HostDetailPage.tsx index df8d264..fa20541 100644 --- a/frontend/src/pages/HostDetailPage.tsx +++ b/frontend/src/pages/HostDetailPage.tsx @@ -37,8 +37,9 @@ import { Delete as DeleteIcon, Edit as EditIcon, Schedule as ScheduleIcon, + VpnKey as VpnKeyIcon, } from '@mui/icons-material' -import { apiClient, maintenanceWindowsApi } from '../api/client' +import { apiClient, maintenanceWindowsApi, certsApi } from '../api/client' import type { MaintenanceWindow, WindowRecurrence } from '../types' // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -295,6 +296,22 @@ export default function HostDetailPage() { } } + // ── Download client cert ───────────────────────────────────────────────── + const handleDownloadClientCert = async () => { + if (!id) return + try { + const res = await certsApi.downloadClientCert(id) + const url = URL.createObjectURL(res.data as Blob) + const a = document.createElement('a') + a.href = url + a.download = 'client.crt' + a.click() + URL.revokeObjectURL(url) + } catch { + showSnack('No client certificate found for this host', 'error') + } + } + // ── Render ──────────────────────────────────────────────────────────────── if (loading) return if (error) return {error} @@ -307,9 +324,16 @@ export default function HostDetailPage() { {/* ── Host details ─────────────────────────────────────────────────── */} - - {String(host?.fqdn ?? '')} - + + + {String(host?.fqdn ?? '')} + + + + + + + {host && Object.entries(host).map(([k, v]) => diff --git a/frontend/src/pages/ReportsPage.tsx b/frontend/src/pages/ReportsPage.tsx new file mode 100644 index 0000000..989d374 --- /dev/null +++ b/frontend/src/pages/ReportsPage.tsx @@ -0,0 +1,273 @@ +import { useState } from 'react' +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Container, + Divider, + FormControl, + FormHelperText, + Grid, + InputLabel, + MenuItem, + Paper, + Select, + Snackbar, + TextField, + Toolbar, + Typography, +} from '@mui/material' +import DescriptionIcon from '@mui/icons-material/Description' +import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf' +import { reportsApi } from '../api/client' +import type { ReportType, ReportFormat } from '../types' + +// ── Report metadata ─────────────────────────────────────────────────────────── + +const REPORT_INFO: Record = { + compliance: { + title: 'Compliance Report', + description: + 'Shows patch compliance percentage per host and group. Includes total packages, pending patches, and last patch timestamp.', + columns: [ + 'Host', + 'FQDN', + 'Groups', + 'Total Packages', + 'Pending Patches', + 'Compliance %', + 'Last Patched', + 'Health Status', + ], + }, + 'patch-history': { + title: 'Patch History', + description: + 'Full history of patch job operations across all hosts. Filter by date range to narrow results.', + columns: [ + 'Job ID', + 'Kind', + 'Status', + 'Host', + 'FQDN', + 'Package Count', + 'Started At', + 'Completed At', + 'Duration', + 'Operator', + ], + }, + vulnerability: { + title: 'Vulnerability Exposure', + description: + 'Lists all known CVEs affecting managed hosts based on cached patch data from agents.', + columns: ['Host', 'FQDN', 'CVE ID', 'Package', 'Severity', 'Available Version', 'Last Seen'], + }, + audit: { + title: 'Audit Trail', + description: + 'Complete tamper-evident audit log of all system actions. Limited to 10,000 most recent events.', + columns: [ + 'ID', + 'Timestamp', + 'Action', + 'Actor', + 'Target Type', + 'Target ID', + 'IP Address', + 'Request ID', + ], + }, +} + +// ── Default date helpers ────────────────────────────────────────────────────── + +const defaultFromDate = () => + new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + +const defaultToDate = () => new Date().toISOString().split('T')[0] + +// ── Component ───────────────────────────────────────────────────────────────── + +export default function ReportsPage() { + const [reportType, setReportType] = useState('compliance') + const [fromDate, setFromDate] = useState(defaultFromDate()) + const [toDate, setToDate] = useState(defaultToDate()) + const [groupId, setGroupId] = useState('') + const [downloading, setDownloading] = useState(false) + const [error, setError] = useState(null) + + const info = REPORT_INFO[reportType] + + const handleDownload = async (format: ReportFormat) => { + setDownloading(true) + setError(null) + try { + const params: Record = {} + if (fromDate) params.from = new Date(fromDate).toISOString() + if (toDate) params.to = new Date(toDate + 'T23:59:59Z').toISOString() + if (reportType === 'compliance' && groupId.trim()) params.group_id = groupId.trim() + + const res = await reportsApi.download(reportType, format, params) + + // Trigger browser download + const url = window.URL.createObjectURL(new Blob([res.data])) + const link = document.createElement('a') + link.href = url + const ext = format === 'pdf' ? 'pdf' : 'csv' + const dateStr = new Date().toISOString().split('T')[0] + link.setAttribute('download', `${reportType}-report-${dateStr}.${ext}`) + document.body.appendChild(link) + link.click() + link.remove() + window.URL.revokeObjectURL(url) + } catch (err: unknown) { + setError('Failed to generate report. Please try again.') + } finally { + setDownloading(false) + } + } + + return ( + + {/* ── Page header ── */} + + + Reports + + + + + {/* ── Controls card ── */} + + + + Report Options + + + {/* Report Type */} + + Report Type + + + + {/* Date Range */} + + setFromDate(e.target.value)} + InputLabelProps={{ shrink: true }} + fullWidth + /> + setToDate(e.target.value)} + InputLabelProps={{ shrink: true }} + fullWidth + /> + + + {/* Group Filter — compliance only */} + {reportType === 'compliance' && ( + + setGroupId(e.target.value)} + placeholder="e.g. 550e8400-e29b-41d4-a716-446655440000" + /> + Filter compliance report by a specific group UUID + + )} + + + + {/* Download buttons */} + + + + + + + + {/* ── Info card ── */} + + + + {info.title} + + + {info.description} + + + + Columns in this report: + + + {info.columns.map((col) => ( + + ))} + + + + + + 📊 PDF includes bar charts for compliance and patch history reports. + + + 📁 CSV is suitable for import into Excel or Google Sheets. + + + + + + {/* ── Error snackbar ── */} + setError(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setError(null)} sx={{ width: '100%' }}> + {error} + + + + ) +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ff86fc4..d81e3e3 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -166,3 +166,30 @@ export interface JobWsEvent { error_message?: string agent_job_id?: string } + +// ── Certificates (M8) ──────────────────────────────────────────────────────── + +export type CertStatus = 'active' | 'revoked' | 'expired' + +export interface Certificate { + id: string + host_id: string | null // null = root CA cert + serial_number: string + common_name: string + status: CertStatus + issued_at: string + expires_at: string + revoked_at: string | null + cert_pem: string +} + +export interface IssuedCert { + cert_pem: string + key_pem: string + serial_number: string + expires_at: string +} + +// ── Reports (M9) ───────────────────────────────────────────────────────────── +export type ReportType = 'compliance' | 'patch-history' | 'vulnerability' | 'audit' +export type ReportFormat = 'csv' | 'pdf' diff --git a/tasks/todo.md b/tasks/todo.md index 19526ee..9a1e6d6 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -170,28 +170,28 @@ Each milestone produces a **testable vertical slice** — backend + frontend + d ### M8: Internal CA + Certificate Management + Frontend Page **Goal:** CA issues/renews certs, download links work. -- [ ] Implement `pm-ca` — CA initialization (root key + cert generation), stored at `/etc/patch-manager/ca/` with 0600 permissions -- [ ] Implement client certificate issuance for mTLS (per-host certs) -- [ ] Implement certificate renewal flow -- [ ] Implement certificate revocation (mark revoked in `certificates` table, re-issue replacement) -- [ ] Implement download endpoints: `GET /api/v1/ca/root.crt`, `GET /api/v1/hosts/{id}/client.crt` -- [ ] Implement Web UI TLS certificate: self-signed from internal CA (default) or operator-supplied cert/key -- [ ] Frontend: Certificates page (view/manage CA, issue/renew certs, view expiry) -- [ ] Frontend: Root CA download icon on Dashboard -- [ ] Frontend: Host-specific cert download icon on Host Detail page +- [x] Implement `pm-ca` — CA initialization (root key + cert generation), stored at `/etc/patch-manager/ca/` with 0600 permissions +- [x] Implement client certificate issuance for mTLS (per-host certs) +- [x] Implement certificate renewal flow +- [x] Implement certificate revocation (mark revoked in `certificates` table, re-issue replacement) +- [x] Implement download endpoints: `GET /api/v1/ca/root.crt`, `GET /api/v1/hosts/{id}/client.crt` +- [x] Implement Web UI TLS certificate: self-signed from internal CA (default) or operator-supplied cert/key +- [x] Frontend: Certificates page (view/manage CA, issue/renew certs, view expiry) +- [x] Frontend: Root CA download icon on Dashboard +- [x] Frontend: Host-specific cert download icon on Host Detail page - [ ] Verify: CA generates certs, downloads work, TLS cert strategy switchable ### M9: Reporting (CSV + PDF with Charts) + Frontend Page **Goal:** All 4 report types exportable as CSV and PDF. -- [ ] Implement `pm-reports::csv` — CSV generation for all report types -- [ ] Implement `pm-reports::pdf` — PDF generation with `printpdf` + `plotters` charts -- [ ] Implement compliance report: % hosts fully patched by group/fleet, trend charts -- [ ] Implement patch history report: operations per host/group -- [ ] Implement vulnerability exposure report: hosts with pending CVEs -- [ ] Implement audit trail report: who did what when -- [ ] Implement report API: `GET /api/v1/reports/compliance`, `patch-history`, `vulnerability`, `audit` with `?format=csv|pdf` -- [ ] Frontend: Reports page (select type, filters, generate, download) +- [x] Implement `pm-reports::csv` — CSV generation for all report types +- [x] Implement `pm-reports::pdf` — PDF generation with `printpdf` + `plotters` charts +- [x] Implement compliance report: % hosts fully patched by group/fleet, trend charts +- [x] Implement patch history report: operations per host/group +- [x] Implement vulnerability exposure report: hosts with pending CVEs +- [x] Implement audit trail report: who did what when +- [x] Implement report API: `GET /api/v1/reports/compliance`, `patch-history`, `vulnerability`, `audit` with `?format=csv|pdf` +- [x] Frontend: Reports page (select type, filters, generate, download) - [ ] Verify: all 4 reports generate as CSV and PDF, PDFs include charts ### M10: Settings Page (Azure SSO, SMTP, TLS, IP Whitelist) + Frontend Page @@ -203,7 +203,7 @@ Each milestone produces a **testable vertical slice** — backend + frontend + d - [ ] Implement SMTP configuration: host, port, auth mode, username/password, TLS mode, from-address - [ ] Implement "Send Test Email" action for SMTP - [ ] Implement polling interval tuning (health, patch) in Settings -- [ ] Implement Web UI TLS certificate strategy selection (internal CA vs. operator-supplied) +- [x] Implement Web UI TLS certificate strategy selection (internal CA vs. operator-supplied) - [ ] Implement IP whitelist management in Settings - [ ] Implement Azure SSO OAuth2/OIDC Authorization Code flow with PKCE - [ ] Frontend: Settings page with all configuration sections and test actions