use std::{collections::HashMap, sync::Arc, time::Duration};
use nfrp_common::{config::ClientConfig, util};
use tokio::{io::AsyncWriteExt, net::TcpStream};
use tracing::{info, warn};
#[derive(Debug, Clone, Copy, Default)]
struct State {
healthy: bool,
consecutive_failures: u32,
}
impl State {
fn new() -> Self {
Self {
healthy: true,
consecutive_failures: 0,
}
}
}
pub struct Monitor {
states: std::sync::Mutex<HashMap<String, State>>,
}
impl Monitor {
pub fn start(cfg: &ClientConfig) -> Arc<Self> {
let m = Arc::new(Self {
states: std::sync::Mutex::new(HashMap::new()),
});
for p in &cfg.proxies {
if p.health_check_type.is_empty() {
continue;
}
let wire = p.name.clone();
m.lock().insert(wire.clone(), State::new());
let mon = m.clone();
let p = p.clone();
let interval = Duration::from_secs(p.health_check_interval_s.max(1));
let timeout = Duration::from_secs(p.health_check_timeout_s.max(1));
tokio::spawn(async move {
info!(proxy = %p.name, kind = %p.health_check_type, "健康检查已启动");
loop {
tokio::time::sleep(interval).await;
let ok = match p.health_check_type.as_str() {
"http" => probe_http(&p.local_addr, &p.health_check_url, timeout).await,
_ => probe_tcp(&p.local_addr, timeout).await,
};
mon.record(&wire, &p.name, ok, p.health_check_max_failed.max(1));
}
});
}
Arc::clone(&m)
}
pub fn is_healthy(&self, proxy: &str) -> bool {
self.lock().get(proxy).map(|s| s.healthy).unwrap_or(true)
}
fn record(&self, wire: &str, name: &str, ok: bool, max_failed: u32) {
let mut g = self.lock();
let st = g.entry(wire.to_string()).or_default();
let was = st.healthy;
if ok {
st.consecutive_failures = 0;
st.healthy = true;
} else {
st.consecutive_failures = st.consecutive_failures.saturating_add(1);
if st.consecutive_failures >= max_failed {
st.healthy = false;
}
}
if was && !st.healthy {
warn!(
proxy = %name,
failed = st.consecutive_failures,
"健康检查连续失败,已停止为该代理提供服务"
);
} else if !was && st.healthy {
info!(proxy = %name, "健康检查恢复,重新提供服务");
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, State>> {
self.states.lock().unwrap_or_else(|e| e.into_inner())
}
}
async fn probe_tcp(addr: &str, timeout: Duration) -> bool {
let Ok(resolved) = util::resolve_addr(addr).await else {
return false;
};
tokio::time::timeout(timeout, TcpStream::connect(resolved))
.await
.map(|r| r.is_ok())
.unwrap_or(false)
}
async fn probe_http(addr: &str, path: &str, timeout: Duration) -> bool {
let Ok(resolved) = util::resolve_addr(addr).await else {
return false;
};
let fut = async {
let mut s = TcpStream::connect(resolved).await.ok()?;
let path = if path.is_empty() { "/" } else { path };
let req = format!("GET {path} HTTP/1.1\r\nHost: healthcheck\r\nConnection: close\r\n\r\n");
s.write_all(req.as_bytes()).await.ok()?;
let mut buf = [0u8; 64];
let n = tokio::io::AsyncReadExt::read(&mut s, &mut buf).await.ok()?;
let head = String::from_utf8_lossy(&buf[..n]).to_string();
let code = head.split_whitespace().nth(1)?.parse::<u16>().ok()?;
Some(code < 400)
};
tokio::time::timeout(timeout, fut)
.await
.unwrap_or(Some(false))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
fn mon() -> Monitor {
Monitor {
states: std::sync::Mutex::new(HashMap::new()),
}
}
#[test]
fn unknown_proxy_is_healthy() {
assert!(mon().is_healthy("whatever"));
}
#[test]
fn needs_several_failures_before_giving_up() {
let m = mon();
m.record("p", "p", true, 3);
assert!(m.is_healthy("p"));
m.record("p", "p", false, 3);
assert!(m.is_healthy("p"), "一次抖动不该让后端下线");
m.record("p", "p", false, 3);
assert!(m.is_healthy("p"));
m.record("p", "p", false, 3);
assert!(!m.is_healthy("p"), "连续失败到阈值必须下线");
}
#[test]
fn one_success_clears_the_streak() {
let m = mon();
m.record("p", "p", false, 2);
m.record("p", "p", false, 2);
assert!(!m.is_healthy("p"));
m.record("p", "p", true, 2);
assert!(m.is_healthy("p"), "恢复后必须立刻重新服务");
m.record("p", "p", false, 2);
assert!(m.is_healthy("p"));
m.record("p", "p", false, 2);
assert!(!m.is_healthy("p"));
}
#[tokio::test]
async fn tcp_probe_detects_dead_ports() {
assert!(!probe_tcp("127.0.0.1:1", Duration::from_millis(200)).await);
}
#[tokio::test]
async fn tcp_probe_detects_live_ports() {
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
tokio::spawn(async move {
loop {
if l.accept().await.is_err() {
break;
}
}
});
assert!(probe_tcp(&addr.to_string(), Duration::from_secs(2)).await);
}
#[tokio::test]
async fn http_probe_rejects_bad_status() {
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((mut s, _)) = l.accept().await {
use tokio::io::AsyncReadExt;
let mut buf = [0u8; 1024];
let _ = s.read(&mut buf).await;
let _ = s
.write_all(b"HTTP/1.1 500 Internal Server Error\r\n\r\n")
.await;
}
});
assert!(
!probe_http(&addr.to_string(), "/", Duration::from_secs(2)).await,
"500 不能算健康"
);
}
#[tokio::test]
async fn http_probe_accepts_2xx() {
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((mut s, _)) = l.accept().await {
use tokio::io::AsyncReadExt;
let mut buf = [0u8; 1024];
let _ = s.read(&mut buf).await;
let _ = s
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
.await;
}
});
assert!(probe_http(&addr.to_string(), "/healthz", Duration::from_secs(2)).await);
}
}