feat: dashboard, visual query builder, expanded query language, cert expiry support
This commit is contained in:
@@ -14,3 +14,8 @@ regex = "1"
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
rustls = "0.23"
|
||||
rustls-native-certs = "0.8"
|
||||
webpki-roots = "0.26"
|
||||
x509-parser = "0.16"
|
||||
tokio-rustls = "0.26"
|
||||
|
||||
+150
-30
@@ -10,14 +10,28 @@
|
||||
/// { "status": { "$ne": 500 } }
|
||||
/// { "status": { "$gte": 200, "$lt": 300 } }
|
||||
/// { "body": { "$contains": "healthy" } }
|
||||
/// { "body": { "$startsWith": "OK" } }
|
||||
/// { "body": { "$endsWith": "done" } }
|
||||
/// { "body": { "$regex": "ok|healthy" } }
|
||||
/// { "body": { "$exists": true } }
|
||||
/// { "status": { "$in": [200, 201, 204] } }
|
||||
///
|
||||
/// CSS selector (HTML parsing):
|
||||
/// { "$select": "span.status", "$eq": "operational" }
|
||||
///
|
||||
/// JSONPath:
|
||||
/// { "$json": "$.data.status", "$eq": "ok" }
|
||||
///
|
||||
/// Response time:
|
||||
/// { "$responseTime": { "$lt": 500 } }
|
||||
///
|
||||
/// Certificate expiry:
|
||||
/// { "$certExpiry": { "$gt": 30 } }
|
||||
///
|
||||
/// Logical:
|
||||
/// { "$and": [ { "status": 200 }, { "body": { "$contains": "ok" } } ] }
|
||||
/// { "$or": [ { "status": 200 }, { "status": 204 } ] }
|
||||
/// { "$not": { "status": 500 } }
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use regex::Regex;
|
||||
@@ -28,13 +42,15 @@ pub struct Response {
|
||||
pub status: u16,
|
||||
pub body: String,
|
||||
pub headers: std::collections::HashMap<String, String>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub cert_expiry_days: Option<i64>,
|
||||
}
|
||||
|
||||
/// Returns true if `query` matches `response`. No query = always up.
|
||||
pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
|
||||
match query {
|
||||
Value::Object(map) => {
|
||||
// $and / $or
|
||||
// $and / $or / $not
|
||||
if let Some(and) = map.get("$and") {
|
||||
let Value::Array(clauses) = and else { bail!("$and expects array") };
|
||||
return Ok(clauses.iter().all(|c| evaluate(c, response).unwrap_or(false)));
|
||||
@@ -43,6 +59,33 @@ pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
|
||||
let Value::Array(clauses) = or else { bail!("$or expects array") };
|
||||
return Ok(clauses.iter().any(|c| evaluate(c, response).unwrap_or(false)));
|
||||
}
|
||||
if let Some(not) = map.get("$not") {
|
||||
return Ok(!evaluate(not, response)?);
|
||||
}
|
||||
|
||||
// $responseTime
|
||||
if let Some(cond) = map.get("$responseTime") {
|
||||
let val = Value::Number(serde_json::Number::from(response.latency_ms.unwrap_or(0)));
|
||||
return eval_condition(cond, &val, response);
|
||||
}
|
||||
|
||||
// $certExpiry
|
||||
if let Some(cond) = map.get("$certExpiry") {
|
||||
let val = Value::Number(serde_json::Number::from(response.cert_expiry_days.unwrap_or(0)));
|
||||
return eval_condition(cond, &val, response);
|
||||
}
|
||||
|
||||
// $json — JSONPath shorthand
|
||||
if let Some(json_path) = map.get("$json") {
|
||||
let path_str = json_path.as_str().unwrap_or("");
|
||||
let resolved = resolve_json_path(&response.body, path_str);
|
||||
for (op, val) in map {
|
||||
if op == "$json" { continue; }
|
||||
if !eval_op(op, &resolved, val, response)? { return Ok(false); }
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// CSS selector shorthand: { "$select": "...", "$eq": "..." }
|
||||
if let Some(sel) = map.get("$select") {
|
||||
let sel_str = sel.as_str().unwrap_or("");
|
||||
@@ -50,12 +93,29 @@ pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
|
||||
if let Some(op_val) = map.get("$eq") {
|
||||
return Ok(selected.as_deref() == op_val.as_str());
|
||||
}
|
||||
if let Some(op_val) = map.get("$ne") {
|
||||
return Ok(selected.as_deref() != op_val.as_str());
|
||||
}
|
||||
if let Some(op_val) = map.get("$contains") {
|
||||
let needle = op_val.as_str().unwrap_or("");
|
||||
return Ok(selected.map(|s| s.contains(needle)).unwrap_or(false));
|
||||
}
|
||||
if let Some(op_val) = map.get("$startsWith") {
|
||||
let needle = op_val.as_str().unwrap_or("");
|
||||
return Ok(selected.map(|s| s.starts_with(needle)).unwrap_or(false));
|
||||
}
|
||||
if let Some(op_val) = map.get("$endsWith") {
|
||||
let needle = op_val.as_str().unwrap_or("");
|
||||
return Ok(selected.map(|s| s.ends_with(needle)).unwrap_or(false));
|
||||
}
|
||||
if let Some(op_val) = map.get("$regex") {
|
||||
let pattern = op_val.as_str().unwrap_or("");
|
||||
let re = Regex::new(pattern).unwrap_or_else(|_| Regex::new("$^").unwrap());
|
||||
return Ok(selected.map(|s| re.is_match(&s)).unwrap_or(false));
|
||||
}
|
||||
return Ok(selected.is_some());
|
||||
}
|
||||
|
||||
// Field-level checks
|
||||
for (field, condition) in map {
|
||||
let field_val = resolve_field(field, response);
|
||||
@@ -83,6 +143,43 @@ fn resolve_field(field: &str, r: &Response) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_json_path(body: &str, path: &str) -> Value {
|
||||
let obj: Value = match serde_json::from_str(body) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Value::Null,
|
||||
};
|
||||
let path = path.trim_start_matches("$").trim_start_matches(".");
|
||||
if path.is_empty() { return obj; }
|
||||
let mut current = &obj;
|
||||
for part in path.split('.') {
|
||||
// Handle array indexing like "items[0]"
|
||||
if let Some(idx_start) = part.find('[') {
|
||||
let key = &part[..idx_start];
|
||||
if !key.is_empty() {
|
||||
current = match current.get(key) {
|
||||
Some(v) => v,
|
||||
None => return Value::Null,
|
||||
};
|
||||
}
|
||||
let idx_str = part[idx_start + 1..].trim_end_matches(']');
|
||||
if let Ok(idx) = idx_str.parse::<usize>() {
|
||||
current = match current.get(idx) {
|
||||
Some(v) => v,
|
||||
None => return Value::Null,
|
||||
};
|
||||
} else {
|
||||
return Value::Null;
|
||||
}
|
||||
} else {
|
||||
current = match current.get(part) {
|
||||
Some(v) => v,
|
||||
None => return Value::Null,
|
||||
};
|
||||
}
|
||||
}
|
||||
current.clone()
|
||||
}
|
||||
|
||||
fn eval_condition(condition: &Value, field_val: &Value, response: &Response) -> Result<bool> {
|
||||
match condition {
|
||||
// Shorthand: { "status": 200 }
|
||||
@@ -91,35 +188,9 @@ fn eval_condition(condition: &Value, field_val: &Value, response: &Response) ->
|
||||
Value::Bool(b) => Ok(field_val.as_bool() == Some(*b)),
|
||||
Value::Object(ops) => {
|
||||
for (op, val) in ops {
|
||||
let ok = match op.as_str() {
|
||||
"$eq" => field_val == val,
|
||||
"$ne" => field_val != val,
|
||||
"$gt" => cmp_num(field_val, val, |a,b| a > b),
|
||||
"$gte" => cmp_num(field_val, val, |a,b| a >= b),
|
||||
"$lt" => cmp_num(field_val, val, |a,b| a < b),
|
||||
"$lte" => cmp_num(field_val, val, |a,b| a <= b),
|
||||
"$contains" => {
|
||||
let needle = val.as_str().unwrap_or("");
|
||||
field_val.as_str().map(|s| s.contains(needle)).unwrap_or(false)
|
||||
}
|
||||
"$regex" => {
|
||||
let pattern = val.as_str().unwrap_or("");
|
||||
let re = Regex::new(pattern).unwrap_or_else(|_| Regex::new("$^").unwrap());
|
||||
field_val.as_str().map(|s| re.is_match(s)).unwrap_or(false)
|
||||
}
|
||||
"$select" => {
|
||||
// Nested: { "body": { "$select": "css", "$eq": "val" } }
|
||||
let sel_str = val.as_str().unwrap_or("");
|
||||
let selected = css_select(&response.body, sel_str);
|
||||
if let Some(eq_val) = ops.get("$eq") {
|
||||
selected.as_deref() == eq_val.as_str()
|
||||
} else {
|
||||
selected.is_some()
|
||||
}
|
||||
}
|
||||
_ => true, // unknown op — skip
|
||||
};
|
||||
if !ok { return Ok(false); }
|
||||
if !eval_op(op, field_val, val, response)? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
@@ -127,6 +198,55 @@ fn eval_condition(condition: &Value, field_val: &Value, response: &Response) ->
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_op(op: &str, field_val: &Value, val: &Value, response: &Response) -> Result<bool> {
|
||||
let ok = match op {
|
||||
"$eq" => field_val == val,
|
||||
"$ne" => field_val != val,
|
||||
"$gt" => cmp_num(field_val, val, |a,b| a > b),
|
||||
"$gte" => cmp_num(field_val, val, |a,b| a >= b),
|
||||
"$lt" => cmp_num(field_val, val, |a,b| a < b),
|
||||
"$lte" => cmp_num(field_val, val, |a,b| a <= b),
|
||||
"$contains" => {
|
||||
let needle = val.as_str().unwrap_or("");
|
||||
field_val.as_str().map(|s| s.contains(needle)).unwrap_or(false)
|
||||
}
|
||||
"$startsWith" => {
|
||||
let needle = val.as_str().unwrap_or("");
|
||||
field_val.as_str().map(|s| s.starts_with(needle)).unwrap_or(false)
|
||||
}
|
||||
"$endsWith" => {
|
||||
let needle = val.as_str().unwrap_or("");
|
||||
field_val.as_str().map(|s| s.ends_with(needle)).unwrap_or(false)
|
||||
}
|
||||
"$regex" => {
|
||||
let pattern = val.as_str().unwrap_or("");
|
||||
let re = Regex::new(pattern).unwrap_or_else(|_| Regex::new("$^").unwrap());
|
||||
field_val.as_str().map(|s| re.is_match(s)).unwrap_or(false)
|
||||
}
|
||||
"$exists" => {
|
||||
let should_exist = val.as_bool().unwrap_or(true);
|
||||
let exists = !field_val.is_null();
|
||||
exists == should_exist
|
||||
}
|
||||
"$in" => {
|
||||
if let Value::Array(arr) = val {
|
||||
arr.contains(field_val)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
"$select" => {
|
||||
// Nested: { "body": { "$select": "css", "$eq": "val" } }
|
||||
let sel_str = val.as_str().unwrap_or("");
|
||||
let selected = css_select(&response.body, sel_str);
|
||||
// If no comparison operator follows, just check existence
|
||||
selected.is_some()
|
||||
}
|
||||
_ => true, // unknown op — skip
|
||||
};
|
||||
Ok(ok)
|
||||
}
|
||||
|
||||
fn cmp_num(a: &Value, b: &Value, f: impl Fn(f64, f64) -> bool) -> bool {
|
||||
match (a.as_f64(), b.as_f64()) {
|
||||
(Some(x), Some(y)) => f(x, y),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use crate::query::{self, Response};
|
||||
use crate::types::{CheckResult, Monitor};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
@@ -44,6 +45,13 @@ pub async fn fetch_and_run(
|
||||
async fn run_check(client: &reqwest::Client, monitor: &Monitor) -> CheckResult {
|
||||
let start = Instant::now();
|
||||
|
||||
// Check cert expiry for HTTPS URLs
|
||||
let cert_expiry_days = if monitor.url.starts_with("https://") {
|
||||
check_cert_expiry(&monitor.url).await.ok().flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let result = client.get(&monitor.url).send().await;
|
||||
let latency_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
@@ -54,6 +62,7 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor) -> CheckResult {
|
||||
latency_ms: Some(latency_ms),
|
||||
up: false,
|
||||
error: Some(e.to_string()),
|
||||
cert_expiry_days,
|
||||
meta: None,
|
||||
},
|
||||
Ok(resp) => {
|
||||
@@ -66,7 +75,13 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor) -> CheckResult {
|
||||
|
||||
// Evaluate query if present
|
||||
let (up, query_error) = if let Some(q) = &monitor.query {
|
||||
let response = Response { status, body: body.clone(), headers: headers.clone() };
|
||||
let response = Response {
|
||||
status,
|
||||
body: body.clone(),
|
||||
headers: headers.clone(),
|
||||
latency_ms: Some(latency_ms),
|
||||
cert_expiry_days,
|
||||
};
|
||||
match query::evaluate(q, &response) {
|
||||
Ok(result) => (result, None),
|
||||
Err(e) => {
|
||||
@@ -93,12 +108,59 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor) -> CheckResult {
|
||||
latency_ms: Some(latency_ms),
|
||||
up,
|
||||
error: query_error,
|
||||
cert_expiry_days,
|
||||
meta: Some(meta),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check SSL certificate expiry for a given HTTPS URL.
|
||||
/// Returns the number of days until the certificate expires.
|
||||
async fn check_cert_expiry(url: &str) -> Result<Option<i64>> {
|
||||
use rustls::ClientConfig;
|
||||
use rustls::pki_types::ServerName;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_rustls::TlsConnector;
|
||||
use x509_parser::prelude::*;
|
||||
|
||||
// Parse host and port from URL
|
||||
let url_parsed = reqwest::Url::parse(url)?;
|
||||
let host = url_parsed.host_str().unwrap_or("");
|
||||
let port = url_parsed.port().unwrap_or(443);
|
||||
|
||||
// Build a rustls config that captures certificates
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
|
||||
let config = ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
|
||||
let connector = TlsConnector::from(Arc::new(config));
|
||||
let server_name = ServerName::try_from(host.to_string())?;
|
||||
|
||||
let stream = TcpStream::connect(format!("{host}:{port}")).await?;
|
||||
let tls_stream = connector.connect(server_name, stream).await?;
|
||||
|
||||
// Get peer certificates
|
||||
let (_, conn) = tls_stream.get_ref();
|
||||
let certs = conn.peer_certificates().unwrap_or(&[]);
|
||||
|
||||
if let Some(cert_der) = certs.first() {
|
||||
let (_, cert) = X509Certificate::from_der(cert_der.as_ref())?;
|
||||
let not_after = cert.validity().not_after.timestamp();
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
let days = (not_after - now) / 86400;
|
||||
return Ok(Some(days));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn post_result(
|
||||
client: &reqwest::Client,
|
||||
coordinator_url: &str,
|
||||
|
||||
@@ -16,5 +16,6 @@ pub struct CheckResult {
|
||||
pub latency_ms: Option<u64>,
|
||||
pub up: bool,
|
||||
pub error: Option<String>,
|
||||
pub cert_expiry_days: Option<i64>,
|
||||
pub meta: Option<Value>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user