feat(M8+M9): CA certificates page + Reporting CSV/PDF with charts
This commit is contained in:
542
Cargo.lock
generated
542
Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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" }
|
||||
|
||||
|
||||
@ -14,3 +14,12 @@ 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 }
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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"] }
|
||||
|
||||
@ -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")?)
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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();
|
||||
|
||||
349
crates/pm-web/src/routes/ca.rs
Normal file
349
crates/pm-web/src/routes/ca.rs
Normal 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 })))
|
||||
}
|
||||
@ -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;
|
||||
|
||||
153
crates/pm-web/src/routes/reports.rs
Normal file
153
crates/pm-web/src/routes/reports.rs
Normal 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, ¶ms).await;
|
||||
("application/pdf", disp, data)
|
||||
} else {
|
||||
let disp = format!("attachment; filename=\"{}\"", csv_name);
|
||||
let data = pm_reports::generate_csv(&db, ¶ms).await;
|
||||
("text/csv; charset=utf-8", disp, data)
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(bytes) => {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static(ct),
|
||||
);
|
||||
headers.insert(
|
||||
header::CONTENT_DISPOSITION,
|
||||
HeaderValue::from_str(&disposition)
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("attachment")),
|
||||
);
|
||||
(headers, Bytes::from(bytes)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "report generation failed");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("Report error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn compliance_report(
|
||||
State(state): State<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
|
||||
}
|
||||
@ -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" />} />
|
||||
|
||||
@ -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
|
||||
}),
|
||||
}
|
||||
|
||||
483
frontend/src/pages/CertificatesPage.tsx
Normal file
483
frontend/src/pages/CertificatesPage.tsx
Normal 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} | 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>
|
||||
)
|
||||
}
|
||||
@ -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}>
|
||||
|
||||
@ -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}>
|
||||
<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]) =>
|
||||
|
||||
273
frontend/src/pages/ReportsPage.tsx
Normal file
273
frontend/src/pages/ReportsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user