diff --git a/Cargo.lock b/Cargo.lock index 3dfb6e760..4f8b6f4d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,189 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags 2.8.0", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-http" +version = "3.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d48f96fc3003717aeb9856ca3d02a8c7de502667ad76eeacd830b48d2e91fac4" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "ahash 0.8.8", + "base64 0.22.0", + "bitflags 2.8.0", + "brotli", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "futures-core", + "h2 0.3.26", + "http 0.2.9", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand", + "sha1", + "smallvec 1.13.2", + "tokio", + "tokio-util", + "tracing", + "zstd 0.13.3", +] + +[[package]] +name = "actix-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" +dependencies = [ + "quote 1.0.35", + "syn 2.0.96", +] + +[[package]] +name = "actix-router" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.9", + "regex", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24eda4e2a6e042aa4e55ac438a2ae052d3b5da0ecf83d7411e1a368946925208" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca2549781d8dd6d75c40cf6b6051260a2cc2f3c62343d761a969a0640646894" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio 1.0.3", + "socket2 0.5.5", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b894941f818cfdc7ccc4b9e60fa7e53b5042a2e8567270f9147d5591893373a" +dependencies = [ + "futures-core", + "paste", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9180d76e5cc7ccbc4d60a506f2c727730b154010262df5b910eb17dbe4b8cb38" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "actix-web-codegen", + "ahash 0.8.8", + "bytes", + "bytestring", + "cfg-if", + "cookie", + "derive_more", + "encoding_rs", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec 1.13.2", + "socket2 0.5.5", + "time", + "url", +] + +[[package]] +name = "actix-web-codegen" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" +dependencies = [ + "actix-router", + "proc-macro2 1.0.93", + "quote 1.0.35", + "syn 2.0.96", +] + [[package]] name = "addr2line" version = "0.21.0" @@ -105,6 +288,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42cd52102d3df161c77a887b608d7a4897d7cc112886a9537b738a887a03aaff" dependencies = [ "cfg-if", + "getrandom", "once_cell", "version_check", "zerocopy", @@ -119,6 +303,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "alloc-test" version = "0.1.1" @@ -1010,7 +1209,7 @@ dependencies = [ "aws-smithy-types", "bytes", "fastrand 2.3.0", - "h2 0.3.24", + "h2 0.3.26", "http 0.2.9", "http-body 0.4.5", "http-body 1.0.1", @@ -1405,6 +1604,27 @@ dependencies = [ "piper", ] +[[package]] +name = "brotli" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74f7971dbd9326d58187408ab83117d8ac1bb9c17b085fdacd1cf2f598719b6b" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fa05ad7d803d413eb8380983b092cbbaf9a85f151b871360e7b00cd7060b37" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bs58" version = "0.4.0" @@ -1464,6 +1684,15 @@ dependencies = [ "either", ] +[[package]] +name = "bytestring" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e465647ae23b2823b0753f50decb2d5a86d2bb2cac04788fafd1f80e45378e5f" +dependencies = [ + "bytes", +] + [[package]] name = "cbc" version = "0.1.2" @@ -1815,6 +2044,17 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.3" @@ -2713,6 +2953,22 @@ dependencies = [ "libc", ] +[[package]] +name = "error-sink-service" +version = "0.1.0" +dependencies = [ + "actix-web", + "anyhow", + "base64 0.22.0", + "bs58 0.5.0", + "chrono", + "env_logger", + "log", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "etcetera" version = "0.8.0" @@ -3457,9 +3713,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.3.24" +version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb2c4422095b67ee78da96fbb51a4cc413b3b25883c7717ff7ca1ab31022c9c9" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" dependencies = [ "bytes", "fnv", @@ -3800,7 +4056,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2 0.3.24", + "h2 0.3.26", "http 0.2.9", "http-body 0.4.5", "httparse", @@ -4156,6 +4412,12 @@ dependencies = [ "xmltree", ] +[[package]] +name = "impl-more" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" + [[package]] name = "indexmap" version = "1.9.3" @@ -4440,6 +4702,12 @@ dependencies = [ "log", ] +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + [[package]] name = "lazy_static" version = "1.4.0" @@ -4967,6 +5235,17 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + [[package]] name = "local-ip-address" version = "0.6.1" @@ -4979,6 +5258,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + [[package]] name = "lock_api" version = "0.4.11" @@ -5262,7 +5547,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-bindgen-test", "web-sys", - "zstd", + "zstd 0.12.4", ] [[package]] @@ -6782,9 +7067,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.27" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" [[package]] name = "platforms" @@ -7491,7 +7776,7 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2 0.3.24", + "h2 0.3.26", "http 0.2.9", "http-body 0.4.5", "hyper 0.14.27", @@ -11151,7 +11436,16 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a27595e173641171fc74a1232b7b1c7a7cb6e18222c11e9dfb9888fa424c53c" dependencies = [ - "zstd-safe", + "zstd-safe 6.0.6", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe 7.2.3", ] [[package]] @@ -11164,11 +11458,20 @@ dependencies = [ "zstd-sys", ] +[[package]] +name = "zstd-safe" +version = "7.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3051792fbdc2e1e143244dc28c60f73d8470e93f3f9cbd0ead44da5ed802722" +dependencies = [ + "zstd-sys", +] + [[package]] name = "zstd-sys" -version = "2.0.9+zstd.1.5.5" +version = "2.0.14+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e16efa8a874a0481a574084d34cc26fdb3b99627480f785888deb6386506656" +checksum = "8fb060d4926e4ac3a3ad15d864e99ceb5f343c6b34f5bd6d81ae6ed417311be5" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index de5acf974..a4c4ab1d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ members = [ "tools/fuzzing", "tools/archive-breadcrumb-compare", "tools/heartbeats-processor", + "tools/error-sink-service", "tools/webrtc-sniffer", "producer-dashboard", diff --git a/node/common/src/service/block_producer/mod.rs b/node/common/src/service/block_producer/mod.rs index da23afdb2..6a82da3f4 100644 --- a/node/common/src/service/block_producer/mod.rs +++ b/node/common/src/service/block_producer/mod.rs @@ -1,6 +1,6 @@ mod vrf_evaluator; -use std::sync::Arc; +use std::{io::Write, sync::Arc}; use ledger::proofs::{ block::BlockParams, generate_block_proof, provers::BlockProver, @@ -107,13 +107,30 @@ fn prover_loop( let res = prove(provers, &mut input, &keypair, false); if let Err(error) = &res { openmina_core::error!(message = "Block proof failed", error = format!("{error:?}")); - if let Err(error) = dump_failed_block_proof_input(block_hash.clone(), input, error) { - openmina_core::error!( - message = "Failure when dumping failed block proof inputs", - error = format!("{error}") - ); + let submission_url = std::env::var("OPENMINA_ERROR_SINK_SERVICE_URL").ok(); + if let Some(submission_url) = submission_url { + if let Err(error) = submit_failed_block_proof_input( + block_hash.clone(), + input, + error, + &submission_url, + ) { + openmina_core::error!( + message = "Failed to submit failed block proof", + error = format!("{error}") + ); + } + } else { + if let Err(error) = dump_failed_block_proof_input(block_hash.clone(), input, error) + { + openmina_core::error!( + message = "Failure when dumping failed block proof inputs", + error = format!("{error}") + ); + } } } + // TODO: error must include the input let res = res.map_err(|err| err.to_string()); let _ = event_sender.send(BlockProducerEvent::BlockProve(block_hash, res).into()); } @@ -181,11 +198,20 @@ impl node::service::BlockProducerService for crate::NodeService { } } -fn dump_failed_block_proof_input( +/// Represents the destination for failed block proof data +pub enum BlockProofOutputDestination { + /// Save the proof data to a file in the debug directory + FilesystemDump, + /// Submit the proof data to an external service via HTTP + ErrorService(String), +} + +fn handle_failed_block_proof_input( block_hash: StateHash, mut input: Box, error: &anyhow::Error, -) -> std::io::Result<()> { + destination: BlockProofOutputDestination, +) -> anyhow::Result<()> { use ledger::proofs::transaction::ProofError; use rsa::Pkcs1v15Encrypt; @@ -229,7 +255,7 @@ kGqG7QLzSPjAtP/YbUponwaD+t+A0kBg0hV4hhcJOkPeA2NOi04K93bz3HuYCVRe let error_str = error.to_string(); - let input = DumpBlockProof { + let input_data = DumpBlockProof { input, key: encrypted_producer_private_key, error: error_str.as_bytes().to_vec(), @@ -239,15 +265,89 @@ kGqG7QLzSPjAtP/YbUponwaD+t+A0kBg0hV4hhcJOkPeA2NOi04K93bz3HuYCVRe }, }; - let debug_dir = openmina_core::get_debug_dir(); - let filename = debug_dir - .join(format!("failed_block_proof_input_{block_hash}.binprot")) - .to_string_lossy() - .to_string(); - openmina_core::warn!(message = "Dumping failed block proof.", filename = filename); - std::fs::create_dir_all(&debug_dir)?; - let mut file = std::fs::File::create(&filename)?; - input.binprot_write(&mut file)?; - file.sync_all()?; - Ok(()) + // Serialize the data + let mut buffer = Vec::new(); + input_data.binprot_write(&mut buffer)?; + + // Handle the data according to the destination + match destination { + BlockProofOutputDestination::FilesystemDump => { + let debug_dir = openmina_core::get_debug_dir(); + let filename = debug_dir + .join(format!("failed_block_proof_input_{block_hash}.binprot")) + .to_string_lossy() + .to_string(); + openmina_core::warn!(message = "Dumping failed block proof.", filename = filename); + std::fs::create_dir_all(&debug_dir)?; + let mut file = std::fs::File::create(&filename)?; + file.write_all(&buffer)?; + file.sync_all()?; + Ok(()) + } + BlockProofOutputDestination::ErrorService(url) => { + use reqwest::blocking::Client; + + openmina_core::warn!( + message = "Submitting failed block proof to external service.", + block_hash = format!("{block_hash}"), + url = url + ); + + let client = Client::new(); + let response = client + .post(&url) + .header("Content-Type", "application/octet-stream") + .body(buffer) + .send()?; + + // Check if the request was successful + if response.status().is_success() { + openmina_core::info!( + message = "Successfully submitted failed block proof.", + block_hash = format!("{block_hash}"), + status = response.status().as_u16() + ); + Ok(()) + } else { + let error_message = format!( + "Failed to submit block proof: HTTP error {}", + response.status() + ); + openmina_core::error!( + message = "Failed to submit block proof", + block_hash = format!("{block_hash}"), + status = response.status().as_u16() + ); + Err(anyhow::anyhow!(error_message)) + } + } + } +} + +fn dump_failed_block_proof_input( + block_hash: StateHash, + input: Box, + error: &anyhow::Error, +) -> std::io::Result<()> { + handle_failed_block_proof_input( + block_hash, + input, + error, + BlockProofOutputDestination::FilesystemDump, + ) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)) +} + +pub fn submit_failed_block_proof_input( + block_hash: StateHash, + input: Box, + error: &anyhow::Error, + submission_url: &str, +) -> anyhow::Result<()> { + handle_failed_block_proof_input( + block_hash, + input, + error, + BlockProofOutputDestination::ErrorService(submission_url.to_string()), + ) } diff --git a/node/common/src/service/builder.rs b/node/common/src/service/builder.rs index 586ee9d4d..8906d2b7f 100644 --- a/node/common/src/service/builder.rs +++ b/node/common/src/service/builder.rs @@ -26,6 +26,7 @@ use crate::{ use super::{ archive::{config::ArchiveStorageOptions, ArchiveService}, block_producer::BlockProducerService, + error_sink::ErrorSinkService, }; pub struct NodeServiceCommonBuilder { @@ -41,6 +42,7 @@ pub struct NodeServiceCommonBuilder { p2p: Option, gather_stats: bool, rpc: RpcService, + error_sink: Option, } #[derive(thiserror::Error, Debug, Clone)] @@ -59,6 +61,7 @@ impl NodeServiceCommonBuilder { rng: StdRng::from_seed(rng_seed), event_sender, event_receiver: event_receiver.into(), + error_sink: None, ledger_manager: None, block_producer: None, archive: None, @@ -86,6 +89,12 @@ impl NodeServiceCommonBuilder { self } + pub fn error_sink_init(&mut self, error_sink_url: String) -> &mut Self { + let error_sink = ErrorSinkService::start(error_sink_url); + self.error_sink = Some(error_sink); + self + } + pub fn block_producer_init( &mut self, keypair: AccountSecretKey, @@ -144,6 +153,7 @@ impl NodeServiceCommonBuilder { snark_block_proof_verify: NodeService::snark_block_proof_verifier_spawn( self.event_sender, ), + error_sink: self.error_sink, ledger_manager, block_producer: self.block_producer, // initialized in state machine. diff --git a/node/common/src/service/error_sink.rs b/node/common/src/service/error_sink.rs new file mode 100644 index 000000000..b42260479 --- /dev/null +++ b/node/common/src/service/error_sink.rs @@ -0,0 +1,94 @@ +use node::core::{channels::mpsc, thread}; + +pub struct ErrorSinkService { + error_report_sender: mpsc::TrackedUnboundedSender<(String, Vec)>, +} + +impl ErrorSinkService { + pub fn new(error_report_sender: mpsc::TrackedUnboundedSender<(String, Vec)>) -> Self { + Self { + error_report_sender, + } + } + + pub fn start(_url: String) -> Self { + let (error_report_sender, error_report_receiver) = mpsc::unbounded_channel(); + + thread::Builder::new() + .name("openmina_error_sink".to_owned()) + .spawn(move || { + error_sink_loop(error_report_receiver); + }) + .unwrap(); + + ErrorSinkService::new(error_report_sender) + } + + pub fn pending_reports(&self) -> usize { + self.error_report_sender.len() + } + + pub fn submit_error_report( + &self, + category: &str, + payload: Vec, + ) -> Result<(), mpsc::SendError<(String, Vec)>> { + self.error_report_sender + .tracked_send((category.to_string(), payload)) + } +} + +fn error_sink_loop(mut rx: mpsc::TrackedUnboundedReceiver<(String, Vec)>) { + while let Some(msg) = rx.blocking_recv() { + let (category, payload) = msg.0; + openmina_core::debug!( + message = "Processing error report", + category = category, + data_size = payload.len() + ); + + let submission_url = std::env::var("OPENMINA_ERROR_SINK_SERVICE_URL").ok(); + + if let Some(url) = submission_url { + if let Err(err) = submit_error_report(&category, &payload, &url) { + openmina_core::error!( + message = "Failed to submit error report", + category = category, + error = format!("{}", err) + ); + } else { + openmina_core::debug!( + message = "Successfully submitted error report", + category = category + ); + } + } else { + openmina_core::warn!( + message = "No error sink URL configured, skipping report submission", + category = category + ); + } + } +} + +fn submit_error_report(category: &str, payload: &[u8], url: &str) -> anyhow::Result<()> { + // TODO: Implement the actual submission logic to the external service + // This would likely use reqwest or a similar HTTP client to send the data + + todo!("Implement error report submission to external service"); + + // Example implementation might look like: + // let client = reqwest::blocking::Client::new(); + // let response = client + // .post(url) + // .header("Content-Type", "application/octet-stream") + // .header("X-Error-Category", category) + // .body(data.to_vec()) + // .send()?; + // + // if response.status().is_success() { + // Ok(()) + // } else { + // Err(anyhow::anyhow!("Failed with status: {}", response.status())) + // } +} diff --git a/node/common/src/service/mod.rs b/node/common/src/service/mod.rs index a5981adca..623d6e93f 100644 --- a/node/common/src/service/mod.rs +++ b/node/common/src/service/mod.rs @@ -3,6 +3,7 @@ pub use event_receiver::*; pub mod archive; pub mod block_producer; +pub mod error_sink; pub mod p2p; pub mod record; pub mod replay; diff --git a/node/common/src/service/service.rs b/node/common/src/service/service.rs index 2daa4fb9b..5dbb299b1 100644 --- a/node/common/src/service/service.rs +++ b/node/common/src/service/service.rs @@ -20,6 +20,7 @@ use crate::rpc::RpcReceiver; use super::{ archive::ArchiveService, block_producer::BlockProducerService, + error_sink::ErrorSinkService, p2p::webrtc_with_libp2p::P2pServiceCtx, replay::ReplayerState, rpc::{RpcSender, RpcService}, @@ -41,6 +42,8 @@ pub struct NodeService { pub snark_block_proof_verify: mpsc::TrackedUnboundedSender, + pub error_sink: Option, + pub ledger_manager: LedgerManager, pub snark_worker: Option, pub block_producer: Option, @@ -118,6 +121,7 @@ impl NodeService { event_receiver: mpsc::unbounded_channel().1.into(), snark_block_proof_verify: mpsc::unbounded_channel().0, ledger_manager: LedgerManager::spawn(Default::default()), + error_sink: None, snark_worker: None, block_producer: None, archive: None, @@ -187,6 +191,25 @@ impl redux::TimeService for NodeService { } } +impl node::service::ErrorSinkService for NodeService { + fn submit_error_report_payload(&mut self, category: &str, payload: Vec) { + if let Some(error_sink) = &self.error_sink { + if let Err(err) = error_sink.submit_error_report(category, payload) { + openmina_core::error!( + message = "Failed to queue error report", + category = category, + error = format!("{}", err) + ); + } + } else { + openmina_core::warn!( + message = "Error sink not initialized, dropping error report", + category = category + ); + } + } +} + impl node::service::EventSourceService for NodeService { fn next_event(&mut self) -> Option { self.event_receiver.try_next() diff --git a/node/native/src/node/builder.rs b/node/native/src/node/builder.rs index 40546f261..06e9f21ff 100644 --- a/node/native/src/node/builder.rs +++ b/node/native/src/node/builder.rs @@ -190,6 +190,12 @@ impl NodeBuilder { Ok(self) } + /// Set up error sink. + pub fn error_sink(&mut self, error_sink_url: String) -> &mut Self { + self.service.error_sink_init(error_sink_url); + self + } + /// Set up block producer. pub fn block_producer( &mut self, diff --git a/node/native/src/service/builder.rs b/node/native/src/service/builder.rs index f3c1b271b..ab4c84b20 100644 --- a/node/native/src/service/builder.rs +++ b/node/native/src/service/builder.rs @@ -45,6 +45,11 @@ impl NodeServiceBuilder { self } + pub fn error_sink_init(&mut self, error_sink_url: String) -> &mut Self { + self.common.error_sink_init(error_sink_url); + self + } + pub fn block_producer_init( &mut self, keypair: AccountSecretKey, diff --git a/node/src/action_kind.rs b/node/src/action_kind.rs index d01e8d4cc..c4b32b82d 100644 --- a/node/src/action_kind.rs +++ b/node/src/action_kind.rs @@ -112,6 +112,7 @@ pub enum ActionKind { BlockProducerBlockInject, BlockProducerBlockInjected, BlockProducerBlockProduced, + BlockProducerBlockProveError, BlockProducerBlockProveInit, BlockProducerBlockProvePending, BlockProducerBlockProveSuccess, @@ -127,6 +128,7 @@ pub enum ActionKind { BlockProducerWonSlotTransactionsSuccess, BlockProducerWonSlotWait, BlockProducerEffectfulBlockProduced, + BlockProducerEffectfulBlockProveError, BlockProducerEffectfulBlockProveInit, BlockProducerEffectfulBlockProveSuccess, BlockProducerEffectfulBlockUnprovenBuild, @@ -738,7 +740,7 @@ pub enum ActionKind { } impl ActionKind { - pub const COUNT: u16 = 628; + pub const COUNT: u16 = 630; } impl std::fmt::Display for ActionKind { @@ -1016,6 +1018,7 @@ impl ActionKindGet for BlockProducerAction { Self::BlockProveInit => ActionKind::BlockProducerBlockProveInit, Self::BlockProvePending => ActionKind::BlockProducerBlockProvePending, Self::BlockProveSuccess { .. } => ActionKind::BlockProducerBlockProveSuccess, + Self::BlockProveError { .. } => ActionKind::BlockProducerBlockProveError, Self::BlockProduced => ActionKind::BlockProducerBlockProduced, Self::BlockInject => ActionKind::BlockProducerBlockInject, Self::BlockInjected => ActionKind::BlockProducerBlockInjected, @@ -1038,6 +1041,7 @@ impl ActionKindGet for BlockProducerEffectfulAction { Self::BlockUnprovenBuild => ActionKind::BlockProducerEffectfulBlockUnprovenBuild, Self::BlockProveInit => ActionKind::BlockProducerEffectfulBlockProveInit, Self::BlockProveSuccess => ActionKind::BlockProducerEffectfulBlockProveSuccess, + Self::BlockProveError { .. } => ActionKind::BlockProducerEffectfulBlockProveError, Self::BlockProduced { .. } => ActionKind::BlockProducerEffectfulBlockProduced, } } diff --git a/node/src/block_producer/block_producer_actions.rs b/node/src/block_producer/block_producer_actions.rs index e1b14e9d8..9f071d014 100644 --- a/node/src/block_producer/block_producer_actions.rs +++ b/node/src/block_producer/block_producer_actions.rs @@ -61,6 +61,9 @@ pub enum BlockProducerAction { BlockProveSuccess { proof: Arc, }, + BlockProveError { + error: String, + }, BlockProduced, #[action_event(level = trace)] BlockInject, @@ -183,7 +186,8 @@ impl redux::EnablingCondition for BlockProducerAction { BlockProducerCurrentState::BlockUnprovenBuilt { .. } ) }), - BlockProducerAction::BlockProveSuccess { .. } => { + BlockProducerAction::BlockProveSuccess { .. } + | BlockProducerAction::BlockProveError { .. } => { state.block_producer.with(false, |this| { matches!( this.current, diff --git a/node/src/block_producer/block_producer_reducer.rs b/node/src/block_producer/block_producer_reducer.rs index a00ccb6d1..cd21e25bb 100644 --- a/node/src/block_producer/block_producer_reducer.rs +++ b/node/src/block_producer/block_producer_reducer.rs @@ -283,6 +283,37 @@ impl BlockProducerEnabled { let dispatcher = state_context.into_dispatcher(); dispatcher.push(BlockProducerEffectfulAction::BlockProveSuccess); } + BlockProducerAction::BlockProveError { error } => { + let current_state = std::mem::take(&mut state.current); + + if let BlockProducerCurrentState::BlockProvePending { + won_slot, + chain, + block, + block_hash, + .. + } = current_state + { + state.current = BlockProducerCurrentState::BlockProveError { + time: meta.time(), + won_slot, + chain, + block, + block_hash, + error: error.clone(), + }; + } else { + bug_condition!("Invalid state for `BlockProducerAction::BlockProveError` expected: `BlockProducerCurrentState::BlockProvePending`, found: {:?}", current_state); + } + + let dispatcher = state_context.into_dispatcher(); + dispatcher.push(BlockProducerEffectfulAction::BlockProveError { + error: error.clone(), + }); + dispatcher.push(BlockProducerAction::WonSlotDiscard { + reason: super::BlockProducerWonSlotDiscardReason::BlockProofError, + }); + } BlockProducerAction::BlockProduced => { let current_state = std::mem::take(&mut state.current); diff --git a/node/src/block_producer/block_producer_state.rs b/node/src/block_producer/block_producer_state.rs index 9463a50b8..e1ab9bc11 100644 --- a/node/src/block_producer/block_producer_state.rs +++ b/node/src/block_producer/block_producer_state.rs @@ -119,6 +119,15 @@ pub enum BlockProducerCurrentState { block_hash: v2::StateHash, proof: Arc, }, + BlockProveError { + time: redux::Timestamp, + won_slot: BlockProducerWonSlot, + /// Chain that we are extending. + chain: Vec, + block: BlockWithoutProof, + block_hash: v2::StateHash, + error: String, + }, Produced { time: redux::Timestamp, won_slot: BlockProducerWonSlot, @@ -140,6 +149,7 @@ pub enum BlockProducerWonSlotDiscardReason { BestTipStakingLedgerDifferent, BestTipGlobalSlotHigher, BestTipSuperior, + BlockProofError, } impl BlockProducerState { @@ -245,6 +255,7 @@ impl BlockProducerCurrentState { | Self::BlockUnprovenBuilt { .. } | Self::BlockProvePending { .. } | Self::BlockProveSuccess { .. } + | Self::BlockProveError { .. } | Self::Produced { .. } => false, } } @@ -263,6 +274,7 @@ impl BlockProducerCurrentState { | Self::BlockUnprovenBuilt { won_slot, .. } | Self::BlockProvePending { won_slot, .. } | Self::BlockProveSuccess { won_slot, .. } + | Self::BlockProveError { won_slot, .. } | Self::Produced { won_slot, .. } | Self::Injected { won_slot, .. } => Some(won_slot), } @@ -282,6 +294,7 @@ impl BlockProducerCurrentState { | Self::BlockUnprovenBuilt { chain, .. } | Self::BlockProvePending { chain, .. } | Self::BlockProveSuccess { chain, .. } + | Self::BlockProveError { chain, .. } | Self::Produced { chain, .. } | Self::Injected { chain, .. } => Some(chain), } @@ -356,6 +369,7 @@ impl BlockProducerCurrentState { | Self::WonSlotDiscarded { .. } | Self::WonSlot { .. } | Self::WonSlotWait { .. } + | Self::BlockProveError { .. } | Self::Injected { .. } => false, Self::WonSlotProduceInit { .. } | Self::WonSlotTransactionsGet { .. } diff --git a/node/src/block_producer_effectful/block_producer_effectful_actions.rs b/node/src/block_producer_effectful/block_producer_effectful_actions.rs index 0089e0e6c..a1ce4e869 100644 --- a/node/src/block_producer_effectful/block_producer_effectful_actions.rs +++ b/node/src/block_producer_effectful/block_producer_effectful_actions.rs @@ -17,6 +17,9 @@ pub enum BlockProducerEffectfulAction { BlockUnprovenBuild, BlockProveInit, BlockProveSuccess, + BlockProveError { + error: String, + }, BlockProduced { block: ArcBlockWithHash, }, diff --git a/node/src/block_producer_effectful/block_producer_effectful_effects.rs b/node/src/block_producer_effectful/block_producer_effectful_effects.rs index fa36cb02c..eb43765ac 100644 --- a/node/src/block_producer_effectful/block_producer_effectful_effects.rs +++ b/node/src/block_producer_effectful/block_producer_effectful_effects.rs @@ -215,6 +215,11 @@ pub fn block_producer_effects( } store.dispatch(BlockProducerAction::BlockProduced); } + BlockProducerEffectfulAction::BlockProveError { error } => { + store + .service + .submit_error_report("blockProofFailure", error); + } BlockProducerEffectfulAction::WonSlotDiscard { reason } => { if let Some(stats) = store.service.stats() { stats.block_producer().discarded(meta.time(), reason); diff --git a/node/src/error_sink/error_sink_service.rs b/node/src/error_sink/error_sink_service.rs new file mode 100644 index 000000000..2b478b812 --- /dev/null +++ b/node/src/error_sink/error_sink_service.rs @@ -0,0 +1,21 @@ +pub trait ErrorPayload { + fn to_payload(&self) -> Vec; +} + +impl ErrorPayload for String { + fn to_payload(&self) -> Vec { + self.as_bytes().to_vec() + } +} + +pub trait ErrorSinkService { + fn submit_error_report_payload(&mut self, category: &str, payload: Vec); + + fn submit_error_report(&mut self, category: &str, error: E) + where + E: ErrorPayload, + { + let payload = error.to_payload(); + self.submit_error_report_payload(category, payload); + } +} diff --git a/node/src/event_source/event_source_effects.rs b/node/src/event_source/event_source_effects.rs index d9d54a0e4..f79f97598 100644 --- a/node/src/event_source/event_source_effects.rs +++ b/node/src/event_source/event_source_effects.rs @@ -458,9 +458,9 @@ pub fn event_source_effects(store: &mut Store, action: EventSourc } }, BlockProducerEvent::BlockProve(block_hash, res) => match res { - Err(err) => todo!( - "error while trying to produce block proof for block {block_hash} - {err}" - ), + Err(error) => { + store.dispatch(BlockProducerAction::BlockProveError { error }); + } Ok(proof) => { if store .state() diff --git a/node/src/lib.rs b/node/src/lib.rs index 2a96fae39..10a10769c 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -35,6 +35,7 @@ pub mod stats; pub mod block_producer; pub mod block_producer_effectful; pub mod daemon_json; +pub mod error_sink; pub mod event_source; pub mod external_snark_worker; pub mod external_snark_worker_effectful; diff --git a/node/src/service.rs b/node/src/service.rs index b09f56102..2e461a0bb 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -1,5 +1,6 @@ pub use crate::block_producer_effectful::vrf_evaluator_effectful::BlockProducerVrfEvaluatorService; pub use crate::block_producer_effectful::BlockProducerService; +pub use crate::error_sink::ErrorSinkService; pub use crate::event_source::EventSourceService; pub use crate::external_snark_worker_effectful::ExternalSnarkWorkerService; pub use crate::ledger::LedgerService; @@ -33,6 +34,7 @@ pub trait Service: + ExternalSnarkWorkerService + RpcService + ArchiveService + + ErrorSinkService { fn queues(&mut self) -> Queues; fn stats(&mut self) -> Option<&mut Stats>; diff --git a/node/testing/src/service/mod.rs b/node/testing/src/service/mod.rs index 673bc5f8c..7c5974872 100644 --- a/node/testing/src/service/mod.rs +++ b/node/testing/src/service/mod.rs @@ -340,6 +340,12 @@ impl redux::TimeService for NodeTestingService { } } +impl node::service::ErrorSinkService for NodeTestingService { + fn submit_error_report_payload(&mut self, _category: &str, _data: Vec) { + // TODO: log or store on disk? + } +} + impl node::event_source::EventSourceService for NodeTestingService { fn next_event(&mut self) -> Option { None diff --git a/tools/error-sink-service/.dockerignore b/tools/error-sink-service/.dockerignore new file mode 100644 index 000000000..a8ccbccd3 --- /dev/null +++ b/tools/error-sink-service/.dockerignore @@ -0,0 +1,5 @@ +target/ +dumps/ +Dockerfile +docker-compose.yml +.dockerignore diff --git a/tools/error-sink-service/.gitignore b/tools/error-sink-service/.gitignore new file mode 100644 index 000000000..0386e7164 --- /dev/null +++ b/tools/error-sink-service/.gitignore @@ -0,0 +1,2 @@ +dumps/ +reports/ diff --git a/tools/error-sink-service/Cargo.toml b/tools/error-sink-service/Cargo.toml new file mode 100644 index 000000000..fe3a29e34 --- /dev/null +++ b/tools/error-sink-service/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "error-sink-service" +version = "0.1.0" +edition = "2021" +description = "A service to collect and store error reports from OpenMina nodes" + +[dependencies] +actix-web = "4.3" +log = "0.4" +env_logger = "0.11" +chrono = "0.4" +uuid = { version = "1", features = ["v4"] } +anyhow = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +base64 = "0.22" +bs58 = "0.5.0" diff --git a/tools/error-sink-service/Dockerfile b/tools/error-sink-service/Dockerfile new file mode 100644 index 000000000..aa7e0a170 --- /dev/null +++ b/tools/error-sink-service/Dockerfile @@ -0,0 +1,54 @@ +# Build Stage +FROM rust:bookworm AS builder + +WORKDIR /usr/src/app + +# Copy Cargo.toml and Cargo.lock +COPY Cargo.toml ./ + +# Create a dummy main.rs to build dependencies +RUN mkdir -p src && echo 'fn main() {}' > src/main.rs + +# Build dependencies +RUN cargo build --release + +# Remove the dummy files +RUN rm -rf src + +# Copy the actual source code +COPY src ./src + +RUN ls -la src; cat src/main.rs + +# Build the actual application +RUN cargo build --release + +# Runtime Stage +FROM debian:bookworm-slim + +WORKDIR /app + +# Install necessary runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Copy the binary from the build stage +COPY --from=builder /usr/src/app/target/release/error-sink-service . + +# Create directory for reports (using "reports" instead of "dumps") +RUN mkdir -p /app/reports + +# Set environment variables +ENV ERROR_SINK_PORT=8080 +ENV ERROR_SINK_DIR=/app/reports +ENV ERROR_SINK_VERIFY_SIGNATURES=true +ENV RUST_LOG=info + +# Expose the service port +EXPOSE 8080 + +# Set the volume for persisting reports +VOLUME /app/reports + +# Run the binary +CMD ["./error-sink-service"] diff --git a/tools/error-sink-service/README.md b/tools/error-sink-service/README.md new file mode 100644 index 000000000..5ce4c2f80 --- /dev/null +++ b/tools/error-sink-service/README.md @@ -0,0 +1,78 @@ +# Error Sink Service + +A simple HTTP service for collecting and cataloging error reports from OpenMina nodes. + +## Usage + +### Running the service + +```bash +# Basic usage with default settings +cargo run + +# Specify a custom port and storage directory +ERROR_SINK_PORT=9090 ERROR_SINK_DIR=/path/to/reports cargo run + +# Disable signature verification +ERROR_SINK_VERIFY_SIGNATURES=false cargo run +``` + +### Environment variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `ERROR_SINK_PORT` | HTTP port to listen on | `8080` | +| `ERROR_SINK_DIR` | Directory to store reports | `./reports` | +| `ERROR_SINK_VERIFY_SIGNATURES` | Enable/disable signature verification | `true` | + +### Docker usage + +```bash +docker run -p 8080:8080 -v ./reports:/app/reports openmina/error-sink-service +``` + +## API Endpoints + +### `POST /error-report` + +Submit a new error report. The endpoint accepts JSON data with the following structure: + +```json +{ + "submitter": "B62qrPN5Y5yq8kGE3FbVKbGTdTAJNdtNtB5sNVpxyRwWGcDEhpMzc8g", + "category": "blockProofFailure", + "data": "base64-encoded-binary-data", + "signature": "base64-encoded-signature" +} +``` + +Field descriptions: +- `submitter`: Valid base58-encoded Mina public key of the submitting entity +- `category`: Classification of the error type (string, must be one of the valid categories) +- `data`: Base64-encoded binary data containing the error report +- `signature`: Base64-encoded cryptographic signature of the data, created using the private key corresponding to the submitter public key + +Example submission: + +```bash +curl -X POST -H "Content-Type: application/json" -d '{ + "submitter": "B62qrPN5Y5yq8kGE3FbVKbGTdTAJNdtNtB5sNVpxyRwWGcDEhpMzc8g", + "category": "blockProofFailure", + "data": "SGVsbG8gV29ybGQ=", + "signature": "7mXGPhek8iTKjKrYbg7G9U2X5Bk8P5HBDSdwMCJYdPoE5MvJ9Rdho2C4xNe7LcDNPJM9Lrb3r8CpQyUrSS7bDtvm1ZrqZgL" +}' http://localhost:8080/error-report +``` + +## File Storage Format + +Error reports are stored with descriptive filenames using the following format: +``` +{category}-{submitter_public_key}_{timestamp}_{uuid}.report +``` + +For example: +``` +blockProofFailure-B62qrPN5Y5yq8kGE3FbVKbGTdTAJNdtNtB5sNVpxyRwWGcDEhpMzc8g_20230405-123015_550e8400-e29b-41d4-a716-446655440000.report +``` + + diff --git a/tools/error-sink-service/docker-compose.yml b/tools/error-sink-service/docker-compose.yml new file mode 100644 index 000000000..06117b120 --- /dev/null +++ b/tools/error-sink-service/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.8' + +services: + error-sink-service: + build: . + container_name: error-sink-service + ports: + - "8080:8080" + volumes: + - ./reports:/app/reports + environment: + - ERROR_SINK_PORT=8080 + - ERROR_SINK_DIR=/app/reports + - ERROR_SINK_VERIFY_SIGNATURES=true + - RUST_LOG=info + restart: unless-stopped diff --git a/tools/error-sink-service/src/main.rs b/tools/error-sink-service/src/main.rs new file mode 100644 index 000000000..9d9c10cf4 --- /dev/null +++ b/tools/error-sink-service/src/main.rs @@ -0,0 +1,376 @@ +use actix_web::middleware::{DefaultHeaders, Logger}; +use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer, Result}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use chrono::Utc; +use log::{error, info}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fs::{self, File}; +use std::io::Write; +use std::path::PathBuf; +use uuid::Uuid; + +const DEFAULT_PORT: u16 = 8080; +const DEFAULT_REPORTS_DIR: &str = "reports"; +const MINA_PUBLIC_KEY_LENGTH: usize = 44; + +// List of valid error report categories +const VALID_CATEGORIES: [&str; 1] = ["blockProofFailure"]; + +// Context string for signature verification +// const SIGNATURE_CONTEXT: &[u8] = b"OpenminaErrorReport"; + +#[derive(Clone)] +struct ServerConfig { + port: u16, + reports_dir: PathBuf, + verify_signatures: bool, + valid_categories: HashSet, +} + +/// Represents the JSON structure for an error report submission +#[derive(Deserialize, Serialize)] +struct ErrorReport { + submitter: String, // Base58 encoded Mina public key + category: String, + data: String, // Base64 encoded binary data + signature: String, // Base64 encoded cryptographic signature +} + +fn verify_signature( + public_key_bs58: &str, + _message: &[u8], + signature_b64: &str, +) -> Result { + if let Err(e) = bs58::decode(public_key_bs58).into_vec() { + return Err(format!("Invalid public key encoding: {}", e)); + } + + if let Err(e) = BASE64.decode(signature_b64) { + return Err(format!("Invalid signature encoding: {}", e)); + } + + // Stub implementation - always return true + // TODO: Replace with actual signature verification + Ok(true) +} + +/// Handles POST requests to the /error-report endpoint +async fn handle_error_report( + payload: web::Json, + data: web::Data, +) -> Result { + if !validate_base58_pubkey(&payload.submitter) { + error!("Invalid base58 public key format: {}", payload.submitter); + return Err(actix_web::error::ErrorBadRequest( + "Invalid submitter public key format", + )); + } + + if !data.valid_categories.contains(&payload.category) { + error!("Invalid error category: {}", payload.category); + return Err(actix_web::error::ErrorBadRequest("Invalid error category.")); + } + + let now = Utc::now(); + let uuid = Uuid::new_v4(); + let timestamp = now.format("%Y%m%d-%H%M%S").to_string(); + + let reports_dir = &data.reports_dir; + + if !reports_dir.exists() { + fs::create_dir_all(reports_dir).map_err(|e| { + error!("Failed to create reports directory: {}", e); + actix_web::error::ErrorInternalServerError("Failed to create reports directory") + })?; + } + + // Use the category directly since it's already validated + let file_name = format!( + "{}/{}-{}_{}_{}.report", + reports_dir.display(), + payload.category, // No need to sanitize - it's from our valid list + payload.submitter, + timestamp, + uuid + ); + + let data_bytes = BASE64.decode(&payload.data).map_err(|e| { + error!("Failed to decode base64 data: {}", e); + actix_web::error::ErrorBadRequest("Invalid base64 data") + })?; + + if data.verify_signatures { + // Verify the signature using the submitter's public key and the data + match verify_signature(&payload.submitter, &data_bytes, &payload.signature) { + Ok(true) => { + info!( + "Signature verification successful for submitter: {}", + payload.submitter + ); + } + Ok(false) => { + error!( + "Signature verification failed for submitter: {}", + payload.submitter + ); + return Err(actix_web::error::ErrorBadRequest( + "Invalid signature: verification failed", + )); + } + Err(e) => { + error!("Signature verification error: {}", e); + return Err(actix_web::error::ErrorBadRequest( + "Signature verification error", + )); + } + } + } + + let mut file = File::create(&file_name).map_err(|e| { + error!("Failed to create report file: {}", e); + actix_web::error::ErrorInternalServerError("Failed to create report file") + })?; + + file.write_all(&data_bytes).map_err(|e| { + error!("Failed to write to report file: {}", e); + actix_web::error::ErrorInternalServerError("Failed to write data to file") + })?; + + info!("Saved error report to: {}", file_name); + + Ok(HttpResponse::Created() + .append_header(("Location", file_name.clone())) + .body(format!("Report saved as {}", file_name))) +} + +/// Validate that a string is a proper base58 encoded Mina public key +fn validate_base58_pubkey(pubkey: &str) -> bool { + if pubkey.len() != MINA_PUBLIC_KEY_LENGTH { + return false; + } + + bs58::decode(pubkey).into_vec().is_ok() +} + +/// Structure to hold error report file information +struct ReportFileInfo { + filename: String, + submitter: String, // Extracted from filename + category: String, // Extracted from filename + size: u64, + timestamp: String, +} + +/// Extract submitter and category from filename +fn extract_info_from_filename(filename: &str) -> (String, String) { + let parts: Vec<&str> = filename.split('-').collect(); + + if parts.len() >= 2 { + let category = parts[0].to_string(); + + // The submitter is now between the first '-' and the first '_' + if parts.len() > 1 { + let remaining = parts[1..].join("-"); // Rejoin in case category had hyphens + let submitter_parts: Vec<&str> = remaining.split('_').collect(); + + if !submitter_parts.is_empty() { + return (category, submitter_parts[0].to_string()); + } + } + + return (category, "Unknown".to_string()); + } + + ("Unknown".to_string(), "Unknown".to_string()) +} + +/// Retrieves and renders a list of all error reports +async fn index(data: web::Data) -> Result { + let entries = fs::read_dir(&data.reports_dir).map_err(|e| { + error!("Failed to read reports directory: {}", e); + actix_web::error::ErrorInternalServerError("Failed to read reports directory") + })?; + + let mut files = Vec::new(); + for entry in entries.flatten() { + if let Some(file_name) = entry.file_name().to_str() { + if file_name.ends_with(".report") { + let metadata = entry.metadata().ok(); + let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0); + let modified = metadata + .and_then(|m| m.modified().ok()) + .map(|t| { + chrono::DateTime::::from(t) + .format("%Y-%m-%d %H:%M:%S") + .to_string() + }) + .unwrap_or_else(|| "Unknown".to_string()); + + let (category, submitter) = extract_info_from_filename(file_name); + + files.push(ReportFileInfo { + filename: file_name.to_string(), + submitter, + category, + size, + timestamp: modified, + }); + } + } + } + + // Sort by timestamp (newest first) + files.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + + let mut html = String::from( + " + + + Error Reports Index + + + +

Error Reports Index

+ + + + + + + + + ", + ); + + for file in files { + let size_str = if file.size < 1024 { + format!("{} B", file.size) + } else if file.size < 1024 * 1024 { + format!("{:.2} KB", file.size as f64 / 1024.0) + } else { + format!("{:.2} MB", file.size as f64 / (1024.0 * 1024.0)) + }; + + html.push_str(&format!( + " + + + + + + + + ", + file.category, file.submitter, file.filename, size_str, file.timestamp, file.filename + )); + } + + html.push_str( + " +
CategorySubmitterFile NameSizeDateActions
{}{}{}{}{}Download
+ +", + ); + + Ok(HttpResponse::Ok().content_type("text/html").body(html)) +} + +/// Handles download requests for specific report files +async fn download_file(req: HttpRequest, data: web::Data) -> Result { + let file_name = req.match_info().get("filename").unwrap(); + let file_path = data.reports_dir.join(file_name); + + if !file_path.exists() || !file_path.is_file() || !file_path.starts_with(&data.reports_dir) { + return Ok(HttpResponse::NotFound().body("File not found")); + } + + let content = fs::read(&file_path).map_err(|e| { + error!("Failed to read file {}: {}", file_path.display(), e); + actix_web::error::ErrorInternalServerError("Failed to read file") + })?; + + Ok(HttpResponse::Ok() + .content_type("application/octet-stream") + .append_header(( + "Content-Disposition", + format!("attachment; filename=\"{}\"", file_name), + )) + .body(content)) +} + +/// Initialize the server configuration from environment variables +fn init_config() -> ServerConfig { + let port = std::env::var("ERROR_SINK_PORT") + .ok() + .and_then(|p| p.parse::().ok()) + .unwrap_or(DEFAULT_PORT); + + let reports_dir = + std::env::var("ERROR_SINK_DIR").unwrap_or_else(|_| DEFAULT_REPORTS_DIR.to_string()); + + // Option to disable signature verification (enabled by default) + let verify_signatures = std::env::var("ERROR_SINK_VERIFY_SIGNATURES") + .map(|v| v != "0" && v.to_lowercase() != "false") + .unwrap_or(true); + + let valid_categories: HashSet = + VALID_CATEGORIES.iter().map(|&s| s.to_string()).collect(); + + ServerConfig { + port, + reports_dir: PathBuf::from(reports_dir), + verify_signatures, + valid_categories, + } +} + +#[actix_web::main] +async fn main() -> anyhow::Result<()> { + env_logger::init_from_env(env_logger::Env::default().default_filter_or("info")); + + let config = init_config(); + + if !config.reports_dir.exists() { + fs::create_dir_all(&config.reports_dir)?; + } + + info!("Starting error sink service on port {}", config.port); + info!("Storing error reports in: {}", config.reports_dir.display()); + info!("Valid error categories: {:?}", config.valid_categories); + + let config_data = web::Data::new(config.clone()); + + HttpServer::new(move || { + App::new() + .app_data(config_data.clone()) + .app_data( + web::JsonConfig::default() + .limit(100 * 1024 * 1024) // 100MB limit for JSON payload + .error_handler(|err, _| { + error!("JSON parsing error: {}", err); + actix_web::error::ErrorBadRequest(err) + }), + ) + .wrap(DefaultHeaders::new().add(("X-Error-Sink", "OpenMina"))) + .wrap(Logger::default()) + .service(web::resource("/error-report").route(web::post().to(handle_error_report))) + .service(web::resource("/").route(web::get().to(index))) + .service(web::resource("/download/{filename}").route(web::get().to(download_file))) + .default_service(web::route().to(HttpResponse::NotFound)) + }) + .bind(("0.0.0.0", config.port))? + .run() + .await?; + + Ok(()) +}