Private
Public Access
1
0

feat(M8+M9): CA certificates page + Reporting CSV/PDF with charts

This commit is contained in:
2026-04-23 18:56:11 +00:00
parent a5d52ffab0
commit 7b7fac315e
22 changed files with 3210 additions and 70 deletions

542
Cargo.lock generated
View File

@ -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"

View File

@ -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" }

View File

@ -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 }

View File

@ -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<Utc>,
}
// ---------------------------------------------------------------------------
// 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> {
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<Utc>)> {
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<Self> {
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=<hostname>`
/// * 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<IssuedCert> {
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<IssuedCert> {
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=<hostname>`
/// * Key usage: Digital Signature
/// * Extended key usage: Server Authentication
/// * SAN: DNS `<hostname>`
///
/// 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))
}
}

View File

@ -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};

View File

@ -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"] }

View File

@ -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<Vec<u8>> {
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<Vec<u8>> {
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<chrono::DateTime<chrono::Utc>> =
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<Vec<u8>> {
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<chrono::DateTime<chrono::Utc>> =
row.try_get("started_at").unwrap_or(None);
let completed_at: Option<chrono::DateTime<chrono::Utc>> =
row.try_get("completed_at").unwrap_or(None);
let duration_seconds: Option<i64> = 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<Vec<u8>> {
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<chrono::DateTime<chrono::Utc>> =
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<Vec<u8>> {
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<chrono::DateTime<chrono::Utc>> =
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")?)
}

View File

@ -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<chrono::DateTime<chrono::Utc>>,
pub to: Option<chrono::DateTime<chrono::Utc>>,
pub group_id: Option<uuid::Uuid>,
}

View File

@ -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<Vec<u8>> {
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<u8>, 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<Self> {
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<u8>,
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<Vec<u8>> {
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<Vec<u8>> {
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<String> = rows.iter().map(|r| r.try_get::<String,_>("display_name").unwrap_or_default()).collect();
let values: Vec<f64> = rows.iter().map(|r| r.try_get::<f64,_>("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<Vec<u8>> {
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<String,f64> = std::collections::BTreeMap::new();
for row in &rows {
if let Ok(Some(s)) = row.try_get::<Option<chrono::DateTime<chrono::Utc>>,_>("started_at") {
*dc.entry(s.format("%Y-%m-%d").to_string()).or_insert(0.0) += 1.0;
}
}
let cl: Vec<String> = dc.keys().cloned().collect();
let cv: Vec<f64> = 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::<Option<chrono::DateTime<chrono::Utc>>,_>("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::<Option<chrono::DateTime<chrono::Utc>>,_>("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<Vec<u8>> {
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::<Option<chrono::DateTime<chrono::Utc>>,_>("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<Vec<u8>> {
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::<Option<chrono::DateTime<chrono::Utc>>,_>("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()
}

View File

@ -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 }

View File

@ -42,6 +42,8 @@ pub struct AppState {
pub auth_config: Arc<AuthConfig>,
/// In-memory store for single-use WebSocket authentication tickets.
pub ws_tickets: Arc<DashMap<String, WsTicket>>,
/// Internal certificate authority for mTLS client cert issuance.
pub ca: Arc<pm_ca::CertAuthority>,
}
#[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<DashMap<String, WsTicket>> = 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();

View File

@ -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<AppState> {
Router::new()
.route("/root.crt", get(download_root_ca))
}
/// Handles routes mounted at /api/v1/certificates
pub fn certs_router() -> Router<AppState> {
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<AppState> {
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<Uuid>,
serial_number: String,
common_name: String,
/// Cast to TEXT in all queries to avoid custom-enum decode.
status: String,
issued_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
revoked_at: Option<DateTime<Utc>>,
}
/// Query params for `list_certificates`.
#[derive(Debug, Deserialize)]
struct CertListQuery {
host_id: Option<Uuid>,
status: Option<String>,
}
/// 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<Response<Body>, (StatusCode, Json<Value>)> {
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<Value>)> {
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<Value>) {
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<AppState>,
_auth: AuthUser,
) -> Result<Response<Body>, (StatusCode, Json<Value>)> {
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<AppState>,
_auth: AuthUser,
Query(q): Query<CertListQuery>,
) -> Result<Json<Vec<CertRow>>, (StatusCode, Json<Value>)> {
// 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<CertRow> = 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<AppState>,
auth: AuthUser,
Path(host_id): Path<Uuid>,
) -> Result<Response<Body>, (StatusCode, Json<Value>)> {
require_admin(&auth)?;
let cert_pem: Option<String> = 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<AppState>,
auth: AuthUser,
Path(host_id): Path<Uuid>,
Json(req): Json<IssueCertRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
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<AppState>,
auth: AuthUser,
Path(cert_id): Path<Uuid>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
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<AppState>,
auth: AuthUser,
Path(cert_id): Path<Uuid>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
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 })))
}

View File

@ -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;

View File

@ -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<String>,
from: Option<chrono::DateTime<chrono::Utc>>,
to: Option<chrono::DateTime<chrono::Utc>>,
group_id: Option<uuid::Uuid>,
}
pub fn router() -> Router<AppState> {
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, &params).await;
("application/pdf", disp, data)
} else {
let disp = format!("attachment; filename=\"{}\"", csv_name);
let data = pm_reports::generate_csv(&db, &params).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<AppState>,
Query(q): Query<ReportQuery>,
) -> 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<AppState>,
Query(q): Query<ReportQuery>,
) -> 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<AppState>,
Query(q): Query<ReportQuery>,
) -> 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<AppState>,
Query(q): Query<ReportQuery>,
) -> 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
}

View File

@ -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() {
<Route path="/maintenance" element={<RequireAuth><MaintenanceWindowsPage /></RequireAuth>} />
{/* Placeholder — later milestones */}
<Route path="/reports" element={<RequireAuth><PlaceholderPage title="Reports" /></RequireAuth>} />
<Route path="/certificates" element={<RequireAuth><PlaceholderPage title="Certificates" /></RequireAuth>} />
{/* Protected — M9 */}
<Route path="/reports" element={<RequireAuth><ReportsPage /></RequireAuth>} />
{/* Protected — M8 */}
<Route path="/certificates" element={<RequireAuth><CertificatesPage /></RequireAuth>} />
<Route path="/settings" element={<RequireAuth><PlaceholderPage title="Settings" /></RequireAuth>} />
<Route path="*" element={<PlaceholderPage title="404 Not Found" />} />

View File

@ -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<Certificate[]>('/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<IssuedCert>(`/hosts/${hostId}/certificates`, { hostname }),
// Renew a cert
renew: (certId: string) =>
apiClient.post<IssuedCert>(`/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
}),
}

View File

@ -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<CertStatus, { label: string; color: 'success' | 'error' | 'warning' }> = {
active: { label: 'Active', color: 'success' },
revoked: { label: 'Revoked', color: 'error' },
expired: { label: 'Expired', color: 'warning' },
}
const { label, color } = map[status]
return <Chip label={label} color={color} size="small" />
}
// ── 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<string | null>(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 (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Issue Client Certificate</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
{err && <Alert severity="error">{err}</Alert>}
<TextField
label="Host ID (UUID)"
value={hostId}
onChange={(e) => setHostId(e.target.value)}
required
fullWidth
placeholder="e.g. 3fa85f64-5717-4562-b3fc-2c963f66afa6"
/>
<TextField
label="Hostname"
value={hostname}
onChange={(e) => setHostname(e.target.value)}
required
fullWidth
placeholder="e.g. web-01.example.com"
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={saving}>Cancel</Button>
<Button variant="contained" onClick={handleSubmit} disabled={saving}>
{saving ? <CircularProgress size={20} /> : 'Issue'}
</Button>
</DialogActions>
</Dialog>
)
}
// ── 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 (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>Certificate Issued Save Your Private Key</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
<Alert severity="warning">
<strong>This private key will NOT be shown again.</strong> Copy and store it securely
before closing this dialog.
</Alert>
{cert && (
<Box>
<Typography variant="caption" color="text.secondary">
Serial: {cert.serial_number} &nbsp;|&nbsp; Expires: {fmtDate(cert.expires_at)}
</Typography>
<Box
component="pre"
sx={{
mt: 1,
p: 2,
bgcolor: 'grey.100',
borderRadius: 1,
fontSize: 12,
overflow: 'auto',
maxHeight: 320,
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{cert.key_pem}
</Box>
</Box>
)}
</DialogContent>
<DialogActions>
<Tooltip title={copied ? 'Copied!' : 'Copy private key to clipboard'}>
<Button startIcon={<CopyIcon />} onClick={handleCopy} variant="outlined">
{copied ? 'Copied!' : 'Copy Key'}
</Button>
</Tooltip>
<Button variant="contained" onClick={onClose}>I Have Saved the Key</Button>
</DialogActions>
</Dialog>
)
}
// ── Main Page ─────────────────────────────────────────────────────────────────
export default function CertificatesPage() {
const user = useAuthStore((s) => s.user)
const isAdmin = user?.role === 'admin'
const [certs, setCerts] = useState<Certificate[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Filters
const [statusFilter, setStatusFilter] = useState<string>('all')
const [hostFilter, setHostFilter] = useState<string>('')
// Dialogs
const [issueOpen, setIssueOpen] = useState(false)
const [issuedCert, setIssuedCert] = useState<IssuedCert | null>(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 (
<Container maxWidth="xl" sx={{ mt: 3, mb: 6 }}>
{/* Header */}
<Toolbar disableGutters sx={{ mb: 3 }}>
<SecurityIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>
Certificate Management
</Typography>
{isAdmin && (
<Button
variant="outlined"
startIcon={<SecurityIcon />}
onClick={() => setIssueOpen(true)}
sx={{ mr: 1 }}
>
Issue Client Certificate
</Button>
)}
<Tooltip title="Download Root CA">
<Button
variant="contained"
startIcon={<DownloadIcon />}
onClick={handleDownloadRootCa}
sx={{ mr: 1 }}
>
Download Root CA
</Button>
</Tooltip>
<Tooltip title="Refresh">
<span>
<IconButton onClick={load} disabled={loading}>
{loading ? <CircularProgress size={20} /> : <RefreshIcon />}
</IconButton>
</span>
</Tooltip>
</Toolbar>
{/* Error */}
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{/* Filters */}
<Box display="flex" gap={2} sx={{ mb: 3 }} flexWrap="wrap">
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>Status</InputLabel>
<Select
label="Status"
value={statusFilter}
onChange={(e: SelectChangeEvent) => setStatusFilter(e.target.value)}
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="active">Active</MenuItem>
<MenuItem value="revoked">Revoked</MenuItem>
<MenuItem value="expired">Expired</MenuItem>
</Select>
</FormControl>
<TextField
size="small"
label="Filter by Host ID"
value={hostFilter}
onChange={(e) => setHostFilter(e.target.value)}
placeholder="UUID or partial…"
sx={{ minWidth: 260 }}
/>
</Box>
{/* Table */}
<Paper variant="outlined">
{loading ? (
<Box display="flex" justifyContent="center" py={6}>
<CircularProgress />
</Box>
) : certs.length === 0 ? (
<Box p={4}>
<Alert severity="info">No certificates found.</Alert>
</Box>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Common Name</TableCell>
<TableCell>Serial Number</TableCell>
<TableCell>Status</TableCell>
<TableCell>Issued At</TableCell>
<TableCell>Expires At</TableCell>
<TableCell>Host</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{certs.map((cert) => {
const expiring = cert.status === 'active' && isExpiringSoon(cert.expires_at)
return (
<TableRow key={cert.id} hover>
<TableCell>
<Typography variant="body2" fontWeight={500}>
{cert.common_name}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 12 }}>
{cert.serial_number}
</Typography>
</TableCell>
<TableCell>{statusChip(cert.status)}</TableCell>
<TableCell>
<Typography variant="body2">{fmtDate(cert.issued_at)}</Typography>
</TableCell>
<TableCell>
<Typography
variant="body2"
sx={{ color: expiring ? 'error.main' : 'inherit', fontWeight: expiring ? 600 : 400 }}
>
{fmtDate(cert.expires_at)}
{expiring && ' ⚠️'}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: 11 }}>
{cert.host_id ?? <em>Root CA</em>}
</Typography>
</TableCell>
<TableCell align="right">
{isAdmin && (
<>
<Tooltip title="Renew certificate">
<Button
size="small"
variant="outlined"
sx={{ mr: 1 }}
onClick={() => handleRenew(cert.id)}
>
Renew
</Button>
</Tooltip>
{cert.status === 'active' && (
<Tooltip title="Revoke certificate">
<Button
size="small"
variant="outlined"
color="error"
onClick={() => handleRevoke(cert.id)}
>
Revoke
</Button>
</Tooltip>
)}
</>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</Paper>
{/* Issue Dialog */}
<IssueDialog
open={issueOpen}
onClose={() => setIssueOpen(false)}
onIssued={handleIssued}
/>
{/* One-time key display dialog */}
<KeyDisplayDialog
open={keyDialogOpen}
cert={issuedCert}
onClose={() => setKeyDialogOpen(false)}
/>
{/* Snackbar */}
<Snackbar
open={snackbar.open}
autoHideDuration={4000}
onClose={() => setSnackbar((p) => ({ ...p, open: false }))}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity={snackbar.severity}
onClose={() => setSnackbar((p) => ({ ...p, open: false }))}
>
{snackbar.message}
</Alert>
</Snackbar>
</Container>
)
}

View File

@ -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 (
<Container maxWidth="xl" sx={{ mt: 3 }}>
<Toolbar disableGutters sx={{ mb: 3 }}>
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>
Dashboard
</Typography>
<Tooltip title="Download Root CA">
<IconButton onClick={handleDownloadRootCa}>
<SecurityIcon />
</IconButton>
</Tooltip>
<Tooltip title="Refresh">
<span>
<IconButton onClick={load} disabled={loading}>

View File

@ -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 <Box display="flex" justifyContent="center" mt={8}><CircularProgress /></Box>
if (error) return <Container sx={{ mt: 4 }}><Alert severity="error">{error}</Alert></Container>
@ -307,9 +324,16 @@ export default function HostDetailPage() {
{/* ── Host details ─────────────────────────────────────────────────── */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h5" fontWeight={700} mb={2}>
{String(host?.fqdn ?? '')}
</Typography>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
<Typography variant="h5" fontWeight={700}>
{String(host?.fqdn ?? '')}
</Typography>
<Tooltip title="Download mTLS Client Certificate">
<IconButton onClick={handleDownloadClientCert} color="primary">
<VpnKeyIcon />
</IconButton>
</Tooltip>
</Box>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={2}>
{host && Object.entries(host).map(([k, v]) =>

View File

@ -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<ReportType, { title: string; description: string; columns: string[] }> = {
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<ReportType>('compliance')
const [fromDate, setFromDate] = useState<string>(defaultFromDate())
const [toDate, setToDate] = useState<string>(defaultToDate())
const [groupId, setGroupId] = useState<string>('')
const [downloading, setDownloading] = useState(false)
const [error, setError] = useState<string | null>(null)
const info = REPORT_INFO[reportType]
const handleDownload = async (format: ReportFormat) => {
setDownloading(true)
setError(null)
try {
const params: Record<string, string> = {}
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 (
<Container maxWidth="xl" sx={{ mt: 3 }}>
{/* ── Page header ── */}
<Toolbar disableGutters sx={{ mb: 3 }}>
<Typography variant="h5" fontWeight={700}>
Reports
</Typography>
</Toolbar>
<Grid container spacing={3}>
{/* ── Controls card ── */}
<Grid size={{ xs: 12, md: 4 }}>
<Paper variant="outlined" sx={{ p: 3 }}>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 2 }}>
Report Options
</Typography>
{/* Report Type */}
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel id="report-type-label">Report Type</InputLabel>
<Select
labelId="report-type-label"
value={reportType}
label="Report Type"
onChange={(e) => setReportType(e.target.value as ReportType)}
>
<MenuItem value="compliance">Compliance Report</MenuItem>
<MenuItem value="patch-history">Patch History</MenuItem>
<MenuItem value="vulnerability">Vulnerability Exposure</MenuItem>
<MenuItem value="audit">Audit Trail</MenuItem>
</Select>
</FormControl>
{/* Date Range */}
<Box sx={{ display: 'flex', gap: 1.5, mb: 2 }}>
<TextField
label="From"
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
InputLabelProps={{ shrink: true }}
fullWidth
/>
<TextField
label="To"
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
InputLabelProps={{ shrink: true }}
fullWidth
/>
</Box>
{/* Group Filter — compliance only */}
{reportType === 'compliance' && (
<FormControl fullWidth sx={{ mb: 2 }}>
<TextField
label="Group ID (optional)"
value={groupId}
onChange={(e) => setGroupId(e.target.value)}
placeholder="e.g. 550e8400-e29b-41d4-a716-446655440000"
/>
<FormHelperText>Filter compliance report by a specific group UUID</FormHelperText>
</FormControl>
)}
<Divider sx={{ my: 2 }} />
{/* Download buttons */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Button
variant="contained"
fullWidth
startIcon={
downloading ? <CircularProgress size={20} color="inherit" /> : <DescriptionIcon />
}
onClick={() => handleDownload('csv')}
disabled={downloading}
>
Download CSV
</Button>
<Button
variant="outlined"
fullWidth
startIcon={
downloading ? <CircularProgress size={20} color="inherit" /> : <PictureAsPdfIcon />
}
onClick={() => handleDownload('pdf')}
disabled={downloading}
>
Download PDF
</Button>
</Box>
</Paper>
</Grid>
{/* ── Info card ── */}
<Grid size={{ xs: 12, md: 8 }}>
<Paper variant="outlined" sx={{ p: 3 }}>
<Typography variant="h6" fontWeight={600} sx={{ mb: 1 }}>
{info.title}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{info.description}
</Typography>
<Typography variant="subtitle2" fontWeight={600}>
Columns in this report:
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1 }}>
{info.columns.map((col) => (
<Chip key={col} label={col} size="small" />
))}
</Box>
<Divider sx={{ my: 2 }} />
<Typography variant="body2" sx={{ mb: 0.5 }}>
📊 PDF includes bar charts for compliance and patch history reports.
</Typography>
<Typography variant="body2">
📁 CSV is suitable for import into Excel or Google Sheets.
</Typography>
</Paper>
</Grid>
</Grid>
{/* ── Error snackbar ── */}
<Snackbar
open={!!error}
autoHideDuration={6000}
onClose={() => setError(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="error" onClose={() => setError(null)} sx={{ width: '100%' }}>
{error}
</Alert>
</Snackbar>
</Container>
)
}

View File

@ -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'

View File

@ -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