rpki/src/cli/output.rs

420 lines
14 KiB
Rust

use std::io::BufWriter;
use std::path::Path;
use serde::Serialize;
use serde::ser::SerializeSeq;
use sha2::Digest;
use crate::audit::{
AspaOutput, AuditRunMeta, AuditWarning, QueryAuditManifest, ValidationEvent,
ValidationEventCounts, VrpOutput,
};
use crate::ccr::canonical_vrp_prefix;
use super::{PostValidationShared, RunStageTiming};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ReportJsonFormat {
Pretty,
Compact,
}
pub(super) fn write_json<T: Serialize>(
path: &Path,
report: &T,
format: ReportJsonFormat,
) -> Result<(), String> {
let f = std::fs::File::create(path)
.map_err(|e| format!("create report file failed: {}: {e}", path.display()))?;
let writer = BufWriter::new(f);
match format {
ReportJsonFormat::Pretty => serde_json::to_writer_pretty(writer, report),
ReportJsonFormat::Compact => serde_json::to_writer(writer, report),
}
.map_err(|e| format!("write report json failed: {e}"))?;
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct ReportJsonWriteTiming {
pub(super) build_ms: u64,
pub(super) write_ms: u64,
}
#[derive(Serialize)]
struct BorrowedAuditReportV2<'a> {
format_version: u32,
meta: AuditRunMeta,
policy: &'a crate::policy::Policy,
tree: BorrowedTreeSummary<'a>,
publication_points: &'a [crate::audit::PublicationPointAudit],
vrps: VrpReportSequence<'a>,
aspas: AspaReportSequence<'a>,
downloads: &'a [crate::audit::AuditDownloadEvent],
download_stats: &'a crate::audit::AuditDownloadStats,
repo_sync_stats: crate::audit::AuditRepoSyncStats,
#[serde(rename = "queryAudit", skip_serializing_if = "Option::is_none")]
query_audit: Option<QueryAuditManifest>,
}
#[derive(Serialize)]
struct BorrowedTreeSummary<'a> {
instances_processed: usize,
instances_failed: usize,
warnings: WarningReportSequence<'a>,
}
struct WarningReportSequence<'a>(&'a [crate::report::Warning]);
impl Serialize for WarningReportSequence<'_> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut seq = serializer.serialize_seq(Some(self.0.len()))?;
for warning in self.0 {
seq.serialize_element(&AuditWarning::from(warning))?;
}
seq.end()
}
}
struct VrpReportSequence<'a>(&'a [crate::validation::objects::Vrp]);
impl Serialize for VrpReportSequence<'_> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut seq = serializer.serialize_seq(Some(self.0.len()))?;
for vrp in self.0 {
seq.serialize_element(&VrpOutput {
asn: vrp.asn,
prefix: crate::audit::format_roa_ip_prefix(&vrp.prefix),
max_length: vrp.max_length,
})?;
}
seq.end()
}
}
struct AspaReportSequence<'a>(&'a [crate::validation::objects::AspaAttestation]);
impl Serialize for AspaReportSequence<'_> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut seq = serializer.serialize_seq(Some(self.0.len()))?;
for aspa in self.0 {
seq.serialize_element(&AspaOutput {
customer_as_id: aspa.customer_as_id,
provider_as_ids: aspa.provider_as_ids.clone(),
})?;
}
seq.end()
}
}
pub(super) fn write_report_json_from_shared(
path: &Path,
policy: &crate::policy::Policy,
validation_time: time::OffsetDateTime,
shared: &PostValidationShared,
format: ReportJsonFormat,
) -> Result<ReportJsonWriteTiming, String> {
use time::format_description::well_known::Rfc3339;
let build_started = std::time::Instant::now();
let validation_time_rfc3339_utc = validation_time
.to_offset(time::UtcOffset::UTC)
.format(&Rfc3339)
.expect("format validation_time");
let repo_sync_stats = super::build_repo_sync_stats(shared.publication_points.as_ref());
let query_audit = write_validation_events_sidecar(path, &validation_time_rfc3339_utc, shared)?;
let report = BorrowedAuditReportV2 {
format_version: 2,
meta: AuditRunMeta {
validation_time_rfc3339_utc,
},
policy,
tree: BorrowedTreeSummary {
instances_processed: shared.instances_processed,
instances_failed: shared.instances_failed,
warnings: WarningReportSequence(shared.tree_warnings.as_ref()),
},
publication_points: shared.publication_points.as_ref(),
vrps: VrpReportSequence(shared.vrps.as_ref()),
aspas: AspaReportSequence(shared.aspas.as_ref()),
downloads: shared.downloads.as_ref(),
download_stats: &shared.download_stats,
repo_sync_stats,
query_audit: Some(query_audit),
};
let build_ms = build_started.elapsed().as_millis() as u64;
let write_started = std::time::Instant::now();
write_json(path, &report, format)?;
Ok(ReportJsonWriteTiming {
build_ms,
write_ms: write_started.elapsed().as_millis() as u64,
})
}
fn write_validation_events_sidecar(
report_path: &Path,
validation_time: &str,
shared: &PostValidationShared,
) -> Result<QueryAuditManifest, String> {
let events_path = report_path.with_file_name("validation-events.jsonl");
if let Some(parent) = events_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create validation events parent failed: {e}"))?;
}
let mut writer = BufWriter::new(std::fs::File::create(&events_path).map_err(|e| {
format!(
"create validation events failed: {}: {e}",
events_path.display()
)
})?);
let mut seq = 0u64;
let mut hasher = sha2::Sha256::new();
emit_validation_events(validation_time, shared, &mut seq, &mut |event| {
let mut line = serde_json::to_vec(&event)
.map_err(|e| format!("serialize validation event failed: {e}"))?;
line.push(b'\n');
std::io::Write::write_all(&mut writer, &line)
.map_err(|e| format!("write validation event failed: {e}"))?;
hasher.update(&line);
Ok(())
})?;
std::io::Write::flush(&mut writer)
.map_err(|e| format!("flush validation events failed: {e}"))?;
let events_count = seq;
let events_sha256 = hex::encode(hasher.finalize());
Ok(QueryAuditManifest {
schema_version: 1,
status: "complete".to_string(),
events_path: events_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("validation-events.jsonl")
.to_string(),
events_count,
events_sha256,
writer_version: 1,
error: None,
})
}
fn emit_validation_events(
validation_time: &str,
shared: &PostValidationShared,
seq: &mut u64,
emit: &mut impl FnMut(ValidationEvent) -> Result<(), String>,
) -> Result<(), String> {
emit(next_event(seq, "run_summary", validation_time, |event| {
event.counts = Some(ValidationEventCounts {
objects: Some(
shared
.publication_points
.iter()
.map(|pp| pp.objects.len() as u64)
.sum(),
),
warnings: Some(
(shared.tree_warnings.len()
+ shared
.publication_points
.iter()
.map(|pp| pp.warnings.len())
.sum::<usize>()) as u64,
),
vrps: Some(shared.vrps.len() as u64),
aspas: Some(shared.aspas.len() as u64),
});
}))?;
for pp in shared.publication_points.iter() {
emit(next_event(
seq,
"publication_point",
validation_time,
|event| {
event.pp_node_id = pp.node_id;
event.pp_manifest_uri = Some(pp.manifest_rsync_uri.clone());
event.pp_rsync_base_uri = Some(pp.rsync_base_uri.clone());
event.repo_sync_phase = pp.repo_sync_phase.clone();
event.repo_terminal_state = Some(pp.repo_terminal_state.clone());
event.counts = Some(ValidationEventCounts {
objects: Some(pp.objects.len() as u64),
warnings: Some(pp.warnings.len() as u64),
vrps: None,
aspas: None,
});
},
))?;
for object in &pp.objects {
emit(next_event(seq, "object", validation_time, |event| {
event.pp_node_id = pp.node_id;
event.pp_manifest_uri = Some(pp.manifest_rsync_uri.clone());
event.object_uri = Some(object.rsync_uri.clone());
event.sha256 = Some(object.sha256_hex.clone());
event.object_type = Some(object.kind.clone());
event.result = Some(object.result.clone());
event.reason = object.detail.clone();
}))?;
}
for warning in &pp.warnings {
emit(next_event(seq, "warning", validation_time, |event| {
event.pp_node_id = pp.node_id;
event.pp_manifest_uri = Some(pp.manifest_rsync_uri.clone());
event.reason = Some(warning.message.clone());
}))?;
}
}
Ok(())
}
fn next_event(
seq: &mut u64,
event_type: &str,
validation_time: &str,
fill: impl FnOnce(&mut ValidationEvent),
) -> ValidationEvent {
*seq += 1;
let mut event = ValidationEvent {
schema_version: 1,
seq: *seq,
event_type: event_type.to_string(),
validation_time: validation_time.to_string(),
pp_node_id: None,
pp_manifest_uri: None,
pp_rsync_base_uri: None,
repo_sync_phase: None,
repo_terminal_state: None,
object_uri: None,
sha256: None,
object_type: None,
result: None,
reason: None,
counts: None,
};
fill(&mut event);
event
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct CompareViewTaskOutput {
pub(super) build_ms: Option<u64>,
pub(super) write_ms: Option<u64>,
}
pub(super) fn run_compare_view_task(
shared: &PostValidationShared,
vrps_csv_out_path: Option<&Path>,
vaps_csv_out_path: Option<&Path>,
trust_anchor: &str,
) -> Result<CompareViewTaskOutput, String> {
let mut build_ms = None;
let mut write_ms = None;
if let (Some(vrps_path), Some(vaps_path)) = (vrps_csv_out_path, vaps_csv_out_path) {
let started = std::time::Instant::now();
build_ms = Some(0);
write_direct_vrp_csv(vrps_path, shared.vrps.as_ref(), trust_anchor)?;
write_direct_vap_csv(vaps_path, shared.aspas.as_ref(), trust_anchor)?;
write_ms = Some(started.elapsed().as_millis() as u64);
eprintln!(
"wrote compare views: vrps={} vaps={}",
vrps_path.display(),
vaps_path.display()
);
}
Ok(CompareViewTaskOutput { build_ms, write_ms })
}
fn write_direct_vrp_csv(
path: &Path,
vrps: &[crate::validation::objects::Vrp],
trust_anchor: &str,
) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create parent dirs failed: {}: {e}", parent.display()))?;
}
let file = std::fs::File::create(path)
.map_err(|e| format!("create file failed: {}: {e}", path.display()))?;
let mut writer = BufWriter::new(file);
use std::io::Write;
let trust_anchor = trust_anchor.to_ascii_lowercase();
writeln!(writer, "ASN,IP Prefix,Max Length,Trust Anchor").map_err(|e| e.to_string())?;
for vrp in vrps {
writeln!(
writer,
"AS{},{},{},{}",
vrp.asn,
canonical_vrp_prefix(&vrp.prefix),
vrp.max_length,
trust_anchor
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
fn write_direct_vap_csv(
path: &Path,
aspas: &[crate::validation::objects::AspaAttestation],
trust_anchor: &str,
) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create parent dirs failed: {}: {e}", parent.display()))?;
}
let file = std::fs::File::create(path)
.map_err(|e| format!("create file failed: {}: {e}", path.display()))?;
let mut writer = BufWriter::new(file);
use std::io::Write;
let trust_anchor = trust_anchor.to_ascii_lowercase();
writeln!(writer, "Customer ASN,Providers,Trust Anchor").map_err(|e| e.to_string())?;
for aspa in aspas {
let mut providers = aspa.provider_as_ids.clone();
providers.sort_unstable();
providers.dedup();
let providers = providers
.into_iter()
.map(|asn| format!("AS{asn}"))
.collect::<Vec<_>>()
.join(";");
writeln!(
writer,
"AS{},{},{}",
aspa.customer_as_id, providers, trust_anchor
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
pub(super) fn write_stage_timing(
report_json_path: Option<&Path>,
stage_timing: &RunStageTiming,
) -> Result<(), String> {
if let Some(path) = report_json_path
&& let Some(parent) = path.parent()
{
let stage_timing_path = parent.join("stage-timing.json");
std::fs::write(
&stage_timing_path,
serde_json::to_vec_pretty(stage_timing).map_err(|e| e.to_string())?,
)
.map_err(|e| {
format!(
"write stage timing failed: {}: {e}",
stage_timing_path.display()
)
})?;
eprintln!("analysis: wrote {}", stage_timing_path.display());
}
Ok(())
}