From: Greg Burri Date: Fri, 29 Sep 2023 10:10:00 +0000 (+0200) Subject: First commit: check status without sending email X-Git-Url: http://git.euphorik.ch/index.cgi?a=commitdiff_plain;h=bd25470e4d4c104ab661fe6212f56179d9a32b32;p=stakingWatchdog.git First commit: check status without sending email --- bd25470e4d4c104ab661fe6212f56179d9a32b32 diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..7f77502 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-unknown-linux-musl] +linker = "rust-lld" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7529aa4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +config.ron +Cargo.lock +deploy-to-pn50.nu diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1600ba7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "staking_watchdog" +version = "0.1.0" +authors = ["Greg Burri "] +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +reqwest = { version = "0.11", features = [ + "blocking", + "json", +], default-features = false } +anyhow = "1.0" +itertools = "0.11" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +ron = "0.8" # Rust object notation, to load configuration files. + +[profile.release] +codegen-units = 1 +lto = true +panic = 'abort' diff --git a/deploy.nu b/deploy.nu new file mode 100644 index 0000000..a7305c9 --- /dev/null +++ b/deploy.nu @@ -0,0 +1,32 @@ +def main [host: string, destination: string, ssh_key: path] { + let ssh_args = [-i $ssh_key $host] + let scp_args = [-r -i $ssh_key] + let target = "x86_64-unknown-linux-musl" + let app_name = "staking_watchdog" + let build = "debug" # "debug" or "release". + + def invoke_ssh [command: string] { + let args = $ssh_args ++ $command + print $"Executing: ssh ($args)" + ssh $args + } + + def copy_ssh [source: string, destination: string] { + let args = $scp_args ++ [$source $"($host):($destination)"] + print $"Executing: scp ($args)" + scp $args + } + + # Don't know how to dynamically pass variable arguments. + if $build == "release" { + cargo build --target $target --release + } else { + cargo build --target $target + } + + # invoke_ssh [sudo systemctl stop $app_name] + copy_ssh ./target/($target)/($build)/($app_name) $destination + invoke_ssh $"chmod u+x ($destination)/($app_name)" + # invoke_ssh [sudo systemctl start $app_name] + print "Deployment finished" +} \ No newline at end of file diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..30a4990 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,32 @@ +use std::{fs::File, time}; + +use anyhow::Result; +use ron::{ + de::from_reader, + ser::{to_writer_pretty, PrettyConfig}, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Config { + pub pub_keys: Vec, +} + +impl Config { + pub fn default() -> Self { + Config { pub_keys: vec![] } + } + + pub fn read(file_path: &str) -> Result { + match File::open(file_path) { + Ok(file) => from_reader(file).map_err(|e| e.into()), + // The file doesn't exit -> create it with default values. + Err(_) => { + let file = File::create(file_path)?; + let default_config = Config::default(); + to_writer_pretty(file, &default_config, PrettyConfig::new())?; + Ok(default_config) + } + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..fadc30d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,185 @@ +/* + * API Reference: https://ethereum.github.io/beacon-APIs/ + */ + +#![cfg_attr(debug_assertions, allow(unused_variables, unused_imports, dead_code))] + +use std::{ + fs, + net::{IpAddr, Ipv4Addr}, + thread, + time::{self, Duration}, +}; + +use anyhow::{Context, Result}; +use reqwest::StatusCode; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::config::Config; + +mod config; +// mod error; + +const FILE_CONF: &str = "config.ron"; +const CHECK_PERIOD: Duration = Duration::from_secs(5); // 5s. +const EMAIL_RESEND_PERIOD: Duration = Duration::from_secs(12 * 60 * 60); // 12h. +const BASE_URI: &str = "http://localhost:5052/eth/v1/"; + +fn main() -> Result<()> { + println!("Staking Watchdog"); + + let config = Config::read(FILE_CONF)?; + + println!("Configuration: {:?}", config); + + let mut time_last_email_send = time::Instant::now() - EMAIL_RESEND_PERIOD; + + loop { + let time_beginning_loop = time::Instant::now(); + + if let Err(error) = check_validators(&config.pub_keys) { + println!("Error: {:?}", error); + if time::Instant::now() - time_last_email_send >= EMAIL_RESEND_PERIOD { + // Send e-mail. + println!("Sending email..."); + + time_last_email_send = time::Instant::now(); + } + } + + let elapsed = time::Instant::now() - time_beginning_loop; + + if elapsed < CHECK_PERIOD { + let to_wait = CHECK_PERIOD - elapsed; + thread::sleep(to_wait); + } + } +} + +#[derive(Debug)] +enum CheckError { + HttpError(String), + NotSync, + InvalidSyncStatus, + NodeHavingIssues, + UnknownCodeFromHealthCheck(u16), + ReqwestError(reqwest::Error), + ValidatorError { pub_key: String, message: String }, + ValidatorStatusError { pub_key: String, message: String }, +} + +impl From for CheckError { + fn from(value: reqwest::Error) -> Self { + CheckError::ReqwestError(value) + } +} + +#[derive(Deserialize, Debug)] +struct JsonValidatorState { + data: JsonValidatorStateData, +} + +#[derive(Deserialize, Debug)] +struct JsonValidatorStateData { + status: String, +} + +#[derive(Deserialize, Debug)] +struct JsonError { + code: u16, + message: String, +} + +fn check_validators(pub_keys: &[String]) -> std::result::Result<(), CheckError> { + let url = BASE_URI; + let client = reqwest::blocking::Client::new(); + + let request_health = client + .get(format!("{url}node/health")) + .header("accept", "application/json"); + match request_health.send() { + Ok(resp) => { + println!("{resp:?}"); + match resp.status().as_u16() { + 200 => (), + 206 => return Err(CheckError::NotSync), + 400 => return Err(CheckError::InvalidSyncStatus), + 503 => return Err(CheckError::NodeHavingIssues), + code => return Err(CheckError::UnknownCodeFromHealthCheck(code)), + } + } + Err(error) => { + println!("{error:?}"); + return Err(CheckError::HttpError(error.to_string())); + } + } + + return Err(CheckError::NotSync); + + for pub_key in pub_keys { + let request = client + .get(format!("{url}beacon/states/head/validators/0x{pub_key}")) + .header("accept", "application/json"); + match request.send() { + Ok(resp) => { + println!("{resp:?}"); + match resp.status().as_u16() { + 200 => { + let json: JsonValidatorState = resp.json()?; + // println!("JSON:\n{:?}", json); // For Debug. + if json.data.status != "active_ongoing" { + return Err(CheckError::ValidatorStatusError { + pub_key: pub_key.clone(), + message: format!("Status: {}", json.data.status), + }); + } + } + code => { + let json: JsonError = resp.json()?; + // println!("JSON:\n{:?}", json); // For Debug. + return Err(CheckError::ValidatorError { + pub_key: pub_key.clone(), + message: format!( + "Http error code: {}, message: {}", + code, json.message + ), + }); + } + } + } + Err(error) => { + println!("{error:?}"); + return Err(CheckError::ValidatorError { + pub_key: pub_key.clone(), + message: error.to_string(), + }); + } + } + } + + // match request_builder + // .header("Authorization", format!("Apikey {}", api_key)) + // .send() + // { + // Ok(resp) => { + // if resp.status().is_success() { + // let content = resp.text().unwrap(); + // Ok(serde_json::from_str(&content).unwrap()) + // } else { + // Err(Box::new(Error { + // message: format!("Request unsuccessful to {}: {:#?}", &url, resp), + // })) + // } + // } + // Err(error) => Err(Box::new(Error { + // message: format!("Error during request to {}: {:?}", &url, error), + // })), + // } + + // 1) Check health. + + // 2) Check each validators. + + Ok(()) +}