diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 0d903015..6d8d17fd 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -65,3 +65,6 @@ cryptoki = "0.10" secrecy = "0.8" jsonwebtoken = "9.3" rsa = "0.9" + +[build-dependencies] +chrono = "0.4.19" diff --git a/crates/core/build.rs b/crates/core/build.rs new file mode 100644 index 00000000..0c717e4c --- /dev/null +++ b/crates/core/build.rs @@ -0,0 +1,147 @@ +use chrono::{SecondsFormat, TimeZone, Utc}; +use std::{cmp::Ordering, collections::HashSet, env, process::Command}; + +fn command_output(program: &str, args: &[&str]) -> Result { + let output = Command::new(program) + .args(args) + .output() + .map_err(|e| format!("could not execute command `{program} {}`: {e}", args.join(" ")))?; + + let mut value = String::from_utf8(output.stdout) + .map_err(|e| format!("could not parse output as utf-8: {e}"))?; + // https://stackoverflow.com/a/55041833 + if value.ends_with('\n') { + value.pop(); + if value.ends_with('\r') { + value.pop(); + } + }; + + Ok(value) +} + +fn set_env_if_needed(env: &str, value: &Result) { + if env::var(env).is_ok() { + return; + } + + match value { + Ok(v) => println!("cargo:rustc-env={env}={v}"), + Err(e) => { + // debug + if cfg!(debug_assertions) { + println!("cargo::warning=could not set env {env}: {e}") + } + // release + else { + println!("carg::error=env {env} not set for release: {e}"); + } + } + } +} + +fn branch_name_from_ref(branch_ref: &str) -> Option<&str> { + // local branch + if let Some(branch_name) = branch_ref.strip_prefix("refs/heads/") { + Some(branch_name) + } + // remote branche + else if let Some(remote_ref) = branch_ref.strip_prefix("refs/remotes/") { + let branch_name = &remote_ref[(remote_ref.find('/')? + 1)..]; + if branch_name != "HEAD" { + Some(branch_name) + } else { + None + } + } else { + None + } +} + +fn epoch_to_iso_timestamp(epoch: i64) -> String { + Utc.timestamp_opt(epoch, 0).unwrap().to_rfc3339_opts(SecondsFormat::Secs, true) +} + +fn main() -> Result<(), Box> { + // re-run if files change + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=.git/HEAD"); + println!("cargo:rerun-if-changed=.git/refs/heads"); + + // re-run if env change + println!("cargo:rerun-if-env-changed=BUILD_COMMIT_HASH"); + println!("cargo:rerun-if-env-changed=BUILD_COMMIT_TIMESTAMP_ISO"); + println!("cargo:rerun-if-env-changed=BUILD_COMMIT_LABELS"); + println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH"); + + let commit_hash_result = command_output("git", &["log", "-1", "--format=%H"]); + set_env_if_needed("BUILD_COMMIT_HASH", &commit_hash_result); + + set_env_if_needed( + "BUILD_COMMIT_TIMESTAMP_ISO", + &command_output("git", &["log", "-1", "--format=%ct"]) + .map(|epoch| epoch_to_iso_timestamp(epoch.parse().unwrap())), + ); + + // TODO: get commit labels + let mut commit_label_set = HashSet::new(); + if let Ok(commit_hash) = &commit_hash_result { + let branch_refs = command_output( + "git", + &["branch", "--points-at", commit_hash, "--format=%(refname)", "--all"], + )?; + for branch_ref in branch_refs.lines() { + let Some(branch_name) = branch_name_from_ref(branch_ref) else { + continue; + }; + if !branch_name.is_empty() { + commit_label_set.insert(("branch", branch_name.to_string())); + } + } + + let tags = command_output( + "git", + &["tag", "--points-at", commit_hash, "--format=%(refname:short)"], + )?; + for tag in tags.lines() { + if !tag.is_empty() { + commit_label_set.insert(("tag", tag.to_string())); + } + } + } + + let mut commit_labels = commit_label_set.into_iter().collect::>(); + commit_labels.sort_unstable_by(|a, b| { + if a.0 != b.0 { + a.1.cmp(&b.1) + } + // tags before branches + else if a.0 == "tag" { + Ordering::Less + } else { + Ordering::Greater + } + }); + + set_env_if_needed( + "BUILD_COMMIT_LABELS", + &Ok(commit_labels + .into_iter() + .map(|(label_type, value)| match label_type { + "tag" => value.to_string(), + _ => format!("{label_type}:{value}"), + }) + .collect::>() + .join(" ")), + ); + + // https://reproducible-builds.org/docs/source-date-epoch/#rust + let build_timestamp_iso = epoch_to_iso_timestamp( + option_env!("SOURCE_DATE_EPOCH") + .map(|epoch| epoch.parse().unwrap()) + .unwrap_or_else(|| Utc::now().timestamp()), + ); + println!("cargo:rustc-env=BUILD_TIMESTAMP_ISO={build_timestamp_iso}"); + + Ok(()) +} diff --git a/crates/core/src/app_state.rs b/crates/core/src/app_state.rs index 5ff2857c..dca21621 100644 --- a/crates/core/src/app_state.rs +++ b/crates/core/src/app_state.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::sync::Arc; +use chrono::{DateTime, Utc}; use tokio::sync::Mutex; use crate::common_types::EvmAddress; @@ -56,6 +57,8 @@ impl RelayersAllowedForRandom { } pub struct AppState { + /// Timestamp when server was started + pub started_at: DateTime, /// Database client with connection pooling pub db: Arc, /// EVM blockchain provider connections diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index ad8842c0..b610e5b6 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -13,6 +13,7 @@ mod provider; pub use provider::create_retry_client; pub mod relayer; pub mod safe_proxy; +pub mod server; pub use safe_proxy::{SafeProxyError, SafeProxyManager, SafeTransaction}; pub use yaml::{ read, ApiConfig, AwsKmsSigningProviderConfig, GasProviders, NetworkSetupConfig, diff --git a/crates/core/src/server/api/mod.rs b/crates/core/src/server/api/mod.rs new file mode 100644 index 00000000..99926f58 --- /dev/null +++ b/crates/core/src/server/api/mod.rs @@ -0,0 +1,13 @@ +use std::sync::Arc; + +use axum::{routing::get, Router}; + +use crate::app_state::AppState; + +mod server_info; + +use server_info::get_server_info; + +pub fn create_server_routes() -> Router> { + Router::new().route("/info", get(get_server_info)) +} diff --git a/crates/core/src/server/api/server_info.rs b/crates/core/src/server/api/server_info.rs new file mode 100644 index 00000000..4b8ca211 --- /dev/null +++ b/crates/core/src/server/api/server_info.rs @@ -0,0 +1,22 @@ +use std::sync::Arc; + +use axum::{extract::State, Json}; +use chrono::{SecondsFormat, Utc}; + +use crate::{ + app_state::AppState, + server::{build_info, ServerInfo}, +}; + +/// Returns detailed server info. +pub async fn get_server_info(State(state): State>) -> Json> { + Json(ServerInfo { + started_at_timestamp_iso: state.started_at.to_rfc3339_opts(SecondsFormat::Secs, true), + uptime_seconds: (Utc::now() - state.started_at).num_seconds(), + + commit_hash: build_info::BUILD_COMMIT_HASH, + commit_timestamp_iso: build_info::BUILD_COMMIT_TIMESTAMP_ISO, + commit_labels: build_info::BUILD_COMMIT_LABELS.split(" ").collect(), + build_timestamp_iso: build_info::BUILD_TIMESTAMP_ISO, + }) +} diff --git a/crates/core/src/server/build_info.rs b/crates/core/src/server/build_info.rs new file mode 100644 index 00000000..925aeef2 --- /dev/null +++ b/crates/core/src/server/build_info.rs @@ -0,0 +1,16 @@ +pub const BUILD_COMMIT_HASH: &str = match option_env!("BUILD_COMMIT_HASH") { + Some(v) => v, + None => "0000000000000000000000000000000000000000", +}; + +pub const BUILD_COMMIT_TIMESTAMP_ISO: &str = match option_env!("BUILD_COMMIT_TIMESTAMP_ISO") { + Some(v) => v, + None => "1970-01-01T00:00:00.000Z", +}; + +pub const BUILD_COMMIT_LABELS: &str = match option_env!("BUILD_COMMIT_LABELS") { + Some(v) => v, + None => "", +}; + +pub const BUILD_TIMESTAMP_ISO: &str = env!("BUILD_TIMESTAMP_ISO"); diff --git a/crates/core/src/server/mod.rs b/crates/core/src/server/mod.rs new file mode 100644 index 00000000..f96fdf58 --- /dev/null +++ b/crates/core/src/server/mod.rs @@ -0,0 +1,7 @@ +mod api; +pub use api::create_server_routes; + +pub mod build_info; + +mod types; +pub use types::*; diff --git a/crates/core/src/server/types/mod.rs b/crates/core/src/server/types/mod.rs new file mode 100644 index 00000000..5102868e --- /dev/null +++ b/crates/core/src/server/types/mod.rs @@ -0,0 +1,2 @@ +mod server_info; +pub use server_info::ServerInfo; diff --git a/crates/core/src/server/types/server_info.rs b/crates/core/src/server/types/server_info.rs new file mode 100644 index 00000000..f01e4257 --- /dev/null +++ b/crates/core/src/server/types/server_info.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ServerInfo<'a> { + /// The iso timestamp of when the server was started + #[serde(rename = "startedAtTimestampIso")] + pub started_at_timestamp_iso: String, + + /// The number of seconds the server has been running for + #[serde(rename = "uptimeSeconds")] + pub uptime_seconds: i64, + + /// The hash of the commit used to build the server + #[serde(rename = "commitHash")] + pub commit_hash: &'a str, + + /// The iso timestamp of the commit used to build the server + #[serde(rename = "commitTimestampIso")] + pub commit_timestamp_iso: &'a str, + + /// The labels of the commit used to build the server + /// sorted list with tags first, then branches + /// branches are prefixed with `branch:` + #[serde(rename = "commitLabels")] + pub commit_labels: Vec<&'a str>, + + /// The iso timestamp when the server was built + #[serde(rename = "buildTimestampIso")] + pub build_timestamp_iso: &'a str, +} diff --git a/crates/core/src/startup.rs b/crates/core/src/startup.rs index 2eee0a77..f8cdbb68 100644 --- a/crates/core/src/startup.rs +++ b/crates/core/src/startup.rs @@ -4,6 +4,7 @@ use crate::background_tasks::run_background_tasks; use crate::common_types::EvmAddress; use crate::gas::{BlobGasOracleCache, GasOracleCache}; use crate::network::{create_network_routes, ChainId}; +use crate::server::create_server_routes; use crate::shared::HttpError; use crate::webhooks::WebhookManager; use crate::yaml::{AllOrOneOrManyAddresses, ApiKey, NetworkPermissionsConfig, ReadYamlError}; @@ -37,6 +38,7 @@ use axum::{ routing::get, Json, Router, }; +use chrono::Utc; use dotenv::dotenv; use rustls::crypto::ring::default_provider; use rustls::crypto::CryptoProvider; @@ -207,6 +209,7 @@ async fn start_api( .collect(); let app_state = Arc::new(AppState { + started_at: Utc::now(), db: db.clone(), evm_providers: providers, gas_oracle_cache, @@ -250,6 +253,7 @@ async fn start_api( .nest("/networks", create_network_routes()) .nest("/relayers", create_relayer_routes()) .nest("/transactions", create_transactions_routes()) + .nest("/server", create_server_routes()) .nest("/signing", create_signing_routes()); let app = Router::new() diff --git a/documentation/rrelayer/docs/pages/changelog.mdx b/documentation/rrelayer/docs/pages/changelog.mdx index 935c6476..425c3b93 100644 --- a/documentation/rrelayer/docs/pages/changelog.mdx +++ b/documentation/rrelayer/docs/pages/changelog.mdx @@ -6,6 +6,7 @@ ### Features +- feat: add `/server/info` endpoint to report build info and uptime - feat: upgrade alloy dependencies to version 2 for rust consumers --- diff --git a/documentation/rrelayer/docs/pages/integration/api/authentication.mdx b/documentation/rrelayer/docs/pages/integration/api/authentication.mdx index 6f08a94e..bacece1f 100644 --- a/documentation/rrelayer/docs/pages/integration/api/authentication.mdx +++ b/documentation/rrelayer/docs/pages/integration/api/authentication.mdx @@ -138,6 +138,37 @@ curl https://your-rrelayer.com/health This endpoint does not require authentication and returns a simple "healthy" string when the server is operational. +## Server Info + +Get server info like the build commit, build timestamp, uptime, etc. + +### Endpoint + +``` +GET /server/info +``` + +### Response + +```json +{ + "startedAtTimestampIso": "2026-07-22T16:40:34Z", + "uptimeSeconds": 49, + "commitHash": "645fbf4b56f51774619d59360f5525988e7af073", + "commitTimestampIso": "2026-07-02T12:06:48Z", + "commitLabels": ["branch:master"], + "buildTimestampIso": "2026-07-22T16:40:07Z" +} +``` + +### Example + +```bash +curl https://your-rrelayer.com/server/info +``` + +This endpoint does not require authentication. + ## Rate Limiting Headers When using rate limiting, include the rate limit key header: