initial version

This commit is contained in:
Armaël Guéneau 2025-04-11 17:18:43 +02:00
commit 79ec95913a
4 changed files with 2500 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

2256
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

13
Cargo.toml Normal file
View file

@ -0,0 +1,13 @@
[package]
name = "webmonitor"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.97"
consul = "0.4.2"
df-consul = "0.3.5"
reqwest = "0.12.15"
structopt = "0.3.26"
tokio = { version = "1", features = ["full"] }
tracing = "0.1.41"

230
src/main.rs Normal file
View file

@ -0,0 +1,230 @@
use df_consul::Consul;
use reqwest::ClientBuilder;
use std::error::Error;
use std::fmt;
use std::net::{IpAddr, SocketAddr};
use std::time::{Duration, Instant};
use structopt::StructOpt;
use tokio::task::JoinSet;
use tracing::*;
#[derive(StructOpt, Debug)]
#[structopt(name = "webmonitor")]
pub struct Opt {
/// Address of consul server
#[structopt(
long = "consul-addr",
env = "WEBMONITOR_CONSUL_HOST",
default_value = "http://127.0.0.1:8500"
)]
pub consul_addr: String,
/// CA certificate for Consul server with TLS
#[structopt(long = "consul-ca-cert", env = "WEBMONITOR_CONSUL_CA_CERT")]
pub consul_ca_cert: Option<String>,
/// Skip TLS verification for Consul
#[structopt(
long = "consul-tls-skip-verify",
env = "WEBMONITOR_CONSUL_TLS_SKIP_VERIFY"
)]
pub consul_tls_skip_verify: bool,
/// Client certificate for Consul server with TLS
#[structopt(long = "consul-client-cert", env = "WEBMONITOR_CONSUL_CLIENT_CERT")]
pub consul_client_cert: Option<String>,
/// Client key for Consul server with TLS
#[structopt(long = "consul-client-key", env = "WEBMONITOR_CONSUL_CLIENT_KEY")]
pub consul_client_key: Option<String>,
/// DNS provider
#[structopt(
long = "domain",
env = "WEBMONITOR_DOMAIN",
default_value = "deuxfleurs.fr"
)]
pub domain: String,
}
#[derive(Debug)]
#[allow(dead_code)]
struct GarageNode {
node: String,
site: Option<String>,
ip: String,
port: u16,
public_ipv4: Option<String>,
public_ipv6: Option<String>,
}
impl GarageNode {
fn has_public_addr(&self, addr: SocketAddr) -> bool {
match addr {
SocketAddr::V4(a) => self.public_ipv4 == Some(a.ip().to_string()),
SocketAddr::V6(a) => self.public_ipv6 == Some(a.ip().to_string()),
}
}
}
async fn garage_nodes(consul: &Consul) -> anyhow::Result<Vec<GarageNode>> {
let services = consul
.health_service_instances("garage-web", None)
.await?
.into_inner();
let mut nodes = Vec::new();
for s in services {
nodes.push(GarageNode {
node: s.node.node,
site: s.node.meta.get("site").cloned(),
ip: s.service.address,
port: s.service.port,
public_ipv4: s.node.meta.get("public_ipv4").cloned(),
public_ipv6: s.node.meta.get("public_ipv6").cloned(),
})
}
Ok(nodes)
}
#[derive(Clone, Copy)]
enum EndpointKind {
Public,
Internal,
}
use EndpointKind::*;
#[derive(Clone, Copy)]
struct Endpoint {
addr: SocketAddr,
kind: EndpointKind,
}
#[derive(Debug)]
#[allow(dead_code)]
enum CheckError {
WrongStatusCode(reqwest::StatusCode),
RequestPrepare(reqwest::Error),
RequestError(reqwest::Error),
}
use CheckError::*;
impl fmt::Display for CheckError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
WrongStatusCode(s) => write!(f, "wrong status code: {s}"),
RequestPrepare(e) => write!(f, "when preparing request: {e}"),
RequestError(e) => write!(f, "when sending request: {:?}", e.source()),
}
}
}
type CheckResult = Result<(), CheckError>;
async fn check_endpoint(domain: &str, e: Endpoint) -> CheckResult {
let client = ClientBuilder::new()
.timeout(Duration::new(15, 0))
.resolve_to_addrs(domain, &[e.addr])
.build()
.map_err(|e| RequestError(e))?;
let url = match e.kind {
Public => format!("https://{domain}"),
Internal => format!("http://{domain}"),
};
let req = client.get(url).build().map_err(|e| RequestError(e))?;
let resp = client.execute(req).await;
match resp {
Ok(resp) if resp.status() == reqwest::StatusCode::OK => Ok(()),
Ok(resp) => Err(WrongStatusCode(resp.status())),
Err(e) => Err(RequestError(e)),
}
}
async fn check_endpoints(domain: &str, es: &[Endpoint]) -> Vec<(Endpoint, CheckResult, Duration)> {
let mut set = JoinSet::new();
for e in es {
let e = *e;
let domain = domain.to_string();
set.spawn(async move {
let start_t = Instant::now();
let res = check_endpoint(&domain, e).await;
let duration = Instant::now().duration_since(start_t);
(e, res, duration)
});
}
set.join_all().await
}
fn print_result(nodes: &[GarageNode], (e, res, duration): &(Endpoint, CheckResult, Duration)) {
match e.addr {
// approximate padding to align with v6 addresses
SocketAddr::V4(_) => print!("{} ", e.addr),
SocketAddr::V6(_) => print!("{} ", e.addr),
};
print!("\t");
// display a human-friendly site or node name
let human_name = match e.kind {
Public => nodes
.iter()
.find(|n| n.has_public_addr(e.addr))
.and_then(|n| n.site.clone()),
Internal => nodes
.iter()
.find(|n| n.ip == e.addr.ip().to_string())
.map(|n| n.node.clone()),
};
print!("[{}]\t", human_name.unwrap_or(String::from("??")));
match res {
Ok(()) => print!("OK"),
Err(e) => print!("ERROR: {e}"),
}
println!("\t({:.2}s)", duration.as_secs_f32());
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let opt = Opt::from_args();
info!("Starting webmonitor");
let consul_config = df_consul::Config {
addr: opt.consul_addr.clone(),
ca_cert: opt.consul_ca_cert.clone(),
tls_skip_verify: opt.consul_tls_skip_verify,
client_cert: opt.consul_client_cert.clone(),
client_key: opt.consul_client_key.clone(),
};
let consul = df_consul::Consul::new(consul_config, "").expect("Cannot build Consul");
loop {
let garage_nodes = garage_nodes(&consul).await?;
let public_endpoints: Vec<Endpoint> = tokio::net::lookup_host((opt.domain.as_ref(), 0))
.await?
.map(|addr| Endpoint { addr, kind: Public })
.collect();
let internal_endpoints: Vec<Endpoint> = garage_nodes
.iter()
.map(|n| Endpoint {
addr: SocketAddr::from((n.ip.parse::<IpAddr>().unwrap(), n.port)),
kind: Internal,
})
.collect();
let (public_res, internal_res) = tokio::join! {
check_endpoints(&opt.domain, &public_endpoints),
check_endpoints(&opt.domain, &internal_endpoints),
};
println!("--- public endpoints ---");
public_res
.iter()
.for_each(|res| print_result(&garage_nodes, res));
println!("--- internal endpoints ---");
internal_res
.iter()
.for_each(|res| print_result(&garage_nodes, res));
tokio::time::sleep(Duration::new(60, 0)).await;
}
}