First commit: check status without sending email
authorGreg Burri <greg.burri@gmail.com>
Fri, 29 Sep 2023 10:10:00 +0000 (12:10 +0200)
committerGreg Burri <greg.burri@gmail.com>
Fri, 29 Sep 2023 10:10:00 +0000 (12:10 +0200)
.cargo/config.toml [new file with mode: 0644]
.gitignore [new file with mode: 0644]
Cargo.toml [new file with mode: 0644]
deploy.nu [new file with mode: 0644]
src/config.rs [new file with mode: 0644]
src/main.rs [new file with mode: 0644]

diff --git a/.cargo/config.toml b/.cargo/config.toml
new file mode 100644 (file)
index 0000000..7f77502
--- /dev/null
@@ -0,0 +1,2 @@
+[target.x86_64-unknown-linux-musl]
+linker = "rust-lld"
diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..7529aa4
--- /dev/null
@@ -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 (file)
index 0000000..1600ba7
--- /dev/null
@@ -0,0 +1,23 @@
+[package]
+name = "staking_watchdog"
+version = "0.1.0"
+authors = ["Greg Burri <greg.burri@gmail.com>"]
+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 (file)
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 (file)
index 0000000..30a4990
--- /dev/null
@@ -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<String>,
+}
+
+impl Config {
+    pub fn default() -> Self {
+        Config { pub_keys: vec![] }
+    }
+
+    pub fn read(file_path: &str) -> Result<Config> {
+        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 (file)
index 0000000..fadc30d
--- /dev/null
@@ -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<reqwest::Error> 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(())
+}