|
| 1 | +//! `hora doctor`: runtime environment diagnostics - the companion of |
| 2 | +//! `hora check`. The config can be perfectly valid while the *environment* |
| 3 | +//! can't honour it: no IPv6 route for a `dual_stack` monitor, an ICMP socket |
| 4 | +//! forbidden by `net.ipv4.ping_group_range` in rootless Docker, a busy listen |
| 5 | +//! port, an unreachable resolver. Each finding says whether the current |
| 6 | +//! config actually needs the capability, so a failure is actionable, not |
| 7 | +//! noise. |
| 8 | +
|
| 9 | +use std::time::{Duration, Instant}; |
| 10 | + |
| 11 | +use crate::config::{Config, Kind}; |
| 12 | + |
| 13 | +/// Severity of one finding. `Fail` means a capability the *current config |
| 14 | +/// needs* is missing - `hora doctor` exits non-zero on any of these. |
| 15 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 16 | +pub enum Status { |
| 17 | + Ok, |
| 18 | + /// Worth knowing, not blocking (e.g. the listen port is busy because the |
| 19 | + /// daemon is already running, or IPv6 is absent but nothing needs it). |
| 20 | + Warn, |
| 21 | + Fail, |
| 22 | +} |
| 23 | + |
| 24 | +impl Status { |
| 25 | + #[must_use] |
| 26 | + pub fn label(self) -> &'static str { |
| 27 | + match self { |
| 28 | + Self::Ok => "ok", |
| 29 | + Self::Warn => "warn", |
| 30 | + Self::Fail => "FAIL", |
| 31 | + } |
| 32 | + } |
| 33 | +} |
| 34 | + |
| 35 | +/// One diagnostic finding. |
| 36 | +#[derive(Debug)] |
| 37 | +pub struct Finding { |
| 38 | + pub name: &'static str, |
| 39 | + pub status: Status, |
| 40 | + pub detail: String, |
| 41 | +} |
| 42 | + |
| 43 | +impl Finding { |
| 44 | + fn new(name: &'static str, status: Status, detail: impl Into<String>) -> Self { |
| 45 | + Self { |
| 46 | + name, |
| 47 | + status, |
| 48 | + detail: detail.into(), |
| 49 | + } |
| 50 | + } |
| 51 | +} |
| 52 | + |
| 53 | +/// Run every diagnostic against `config`'s requirements. |
| 54 | +pub async fn run(config: &Config) -> Vec<Finding> { |
| 55 | + vec![ |
| 56 | + database(config).await, |
| 57 | + listen_port(config).await, |
| 58 | + ip_route("ipv4", "8.8.8.8:53", true, config), |
| 59 | + ip_route( |
| 60 | + "ipv6", |
| 61 | + "[2001:4860:4860::8888]:53", |
| 62 | + needs_ipv6(config), |
| 63 | + config, |
| 64 | + ), |
| 65 | + icmp_socket(config), |
| 66 | + dns_resolver().await, |
| 67 | + ] |
| 68 | +} |
| 69 | + |
| 70 | +/// Whether any monitor needs working IPv6 on the probing host. |
| 71 | +fn needs_ipv6(config: &Config) -> bool { |
| 72 | + config |
| 73 | + .monitors |
| 74 | + .iter() |
| 75 | + .any(super::config::Monitor::dual_stack) |
| 76 | +} |
| 77 | + |
| 78 | +/// The database: openable and writable when it exists; merely announced when |
| 79 | +/// it does not (the daemon creates it on first start - doctor must not). |
| 80 | +async fn database(config: &Config) -> Finding { |
| 81 | + let path = &config.server.database_path; |
| 82 | + if path != ":memory:" && !path.starts_with("file:") && !std::path::Path::new(path).exists() { |
| 83 | + return Finding::new( |
| 84 | + "database", |
| 85 | + Status::Ok, |
| 86 | + format!("{path} does not exist yet - created on first start"), |
| 87 | + ); |
| 88 | + } |
| 89 | + let options = sqlx::sqlite::SqliteConnectOptions::new() |
| 90 | + .filename(path) |
| 91 | + .busy_timeout(Duration::from_secs(2)); |
| 92 | + let pool = match sqlx::sqlite::SqlitePoolOptions::new() |
| 93 | + .max_connections(1) |
| 94 | + .connect_with(options) |
| 95 | + .await |
| 96 | + { |
| 97 | + Ok(pool) => pool, |
| 98 | + Err(err) => return Finding::new("database", Status::Fail, format!("{path}: {err}")), |
| 99 | + }; |
| 100 | + // A write-lock probe without writing anything: BEGIN IMMEDIATE takes the |
| 101 | + // reserved lock (fails on a read-only mount), ROLLBACK releases it. |
| 102 | + let writable = sqlx::raw_sql("BEGIN IMMEDIATE; ROLLBACK;") |
| 103 | + .execute(&pool) |
| 104 | + .await; |
| 105 | + pool.close().await; |
| 106 | + match writable { |
| 107 | + Ok(_) => Finding::new("database", Status::Ok, format!("{path} is writable")), |
| 108 | + Err(err) => Finding::new( |
| 109 | + "database", |
| 110 | + Status::Fail, |
| 111 | + format!("{path} not writable: {err}"), |
| 112 | + ), |
| 113 | + } |
| 114 | +} |
| 115 | + |
| 116 | +/// The listen address: bindable, or busy (which usually just means the daemon |
| 117 | +/// is already running - a warning, not a failure). |
| 118 | +async fn listen_port(config: &Config) -> Finding { |
| 119 | + let bind = &config.server.bind; |
| 120 | + match tokio::net::TcpListener::bind(bind).await { |
| 121 | + Ok(_listener) => Finding::new("listen", Status::Ok, format!("{bind} is free")), |
| 122 | + Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => Finding::new( |
| 123 | + "listen", |
| 124 | + Status::Warn, |
| 125 | + format!("{bind} already in use - is Hora already running?"), |
| 126 | + ), |
| 127 | + Err(err) => Finding::new("listen", Status::Fail, format!("cannot bind {bind}: {err}")), |
| 128 | + } |
| 129 | +} |
| 130 | + |
| 131 | +/// Whether the host has a route for one IP family. `UdpSocket::connect` only |
| 132 | +/// consults the routing table - no packet is sent - so this is instant and |
| 133 | +/// quiet. The classic catch: Docker's default bridge networks have no IPv6, |
| 134 | +/// which silently breaks `dual_stack` monitors. |
| 135 | +fn ip_route(name: &'static str, probe_addr: &str, needed: bool, config: &Config) -> Finding { |
| 136 | + let local = if name == "ipv6" { |
| 137 | + "[::]:0" |
| 138 | + } else { |
| 139 | + "0.0.0.0:0" |
| 140 | + }; |
| 141 | + let routed = std::net::UdpSocket::bind(local) |
| 142 | + .and_then(|socket| socket.connect(probe_addr)) |
| 143 | + .is_ok(); |
| 144 | + match (routed, needed) { |
| 145 | + (true, _) => Finding::new(name, Status::Ok, "route to the public internet"), |
| 146 | + (false, true) => { |
| 147 | + let dual = config |
| 148 | + .monitors |
| 149 | + .iter() |
| 150 | + .filter(|monitor| monitor.dual_stack()) |
| 151 | + .count(); |
| 152 | + Finding::new( |
| 153 | + name, |
| 154 | + Status::Fail, |
| 155 | + format!( |
| 156 | + "no route - {dual} dual_stack monitor(s) need it \ |
| 157 | + (in Docker, enable IPv6 on the network or use host networking)" |
| 158 | + ), |
| 159 | + ) |
| 160 | + } |
| 161 | + (false, false) => Finding::new(name, Status::Warn, "no route (no monitor needs it)"), |
| 162 | + } |
| 163 | +} |
| 164 | + |
| 165 | +/// The unprivileged ICMP datagram socket - exactly what the icmp probes open. |
| 166 | +/// In rootless Docker this hinges on `net.ipv4.ping_group_range` covering the |
| 167 | +/// process's gid (or `CAP_NET_RAW`). |
| 168 | +fn icmp_socket(config: &Config) -> Finding { |
| 169 | + let needed = config |
| 170 | + .monitors |
| 171 | + .iter() |
| 172 | + .filter(|monitor| monitor.kind == Kind::Icmp) |
| 173 | + .count(); |
| 174 | + let ping_config = surge_ping::Config::builder() |
| 175 | + .kind(surge_ping::ICMP::V4) |
| 176 | + .sock_type_hint(socket2::Type::DGRAM) |
| 177 | + .build(); |
| 178 | + match surge_ping::Client::new(&ping_config) { |
| 179 | + Ok(_client) => Finding::new("icmp", Status::Ok, "unprivileged datagram socket available"), |
| 180 | + Err(err) if needed > 0 => Finding::new( |
| 181 | + "icmp", |
| 182 | + Status::Fail, |
| 183 | + format!( |
| 184 | + "socket unavailable ({err}) - {needed} icmp monitor(s) need it; \ |
| 185 | + widen net.ipv4.ping_group_range or grant CAP_NET_RAW" |
| 186 | + ), |
| 187 | + ), |
| 188 | + Err(err) => Finding::new( |
| 189 | + "icmp", |
| 190 | + Status::Warn, |
| 191 | + format!("socket unavailable ({err}) (no icmp monitor configured)"), |
| 192 | + ), |
| 193 | + } |
| 194 | +} |
| 195 | + |
| 196 | +/// The system resolver, exercised with a real lookup - the probes' DNS path. |
| 197 | +async fn dns_resolver() -> Finding { |
| 198 | + let resolver = match hickory_resolver::TokioResolver::builder_tokio() |
| 199 | + .map(hickory_resolver::ResolverBuilder::build) |
| 200 | + { |
| 201 | + Ok(Ok(resolver)) => resolver, |
| 202 | + Ok(Err(err)) | Err(err) => { |
| 203 | + return Finding::new("dns", Status::Fail, format!("resolver setup failed: {err}")); |
| 204 | + } |
| 205 | + }; |
| 206 | + let started = Instant::now(); |
| 207 | + match tokio::time::timeout(Duration::from_secs(5), resolver.lookup_ip("example.com.")).await { |
| 208 | + Ok(Ok(_)) => Finding::new( |
| 209 | + "dns", |
| 210 | + Status::Ok, |
| 211 | + format!( |
| 212 | + "system resolver answered in {}ms", |
| 213 | + started.elapsed().as_millis() |
| 214 | + ), |
| 215 | + ), |
| 216 | + Ok(Err(err)) => Finding::new("dns", Status::Fail, format!("lookup failed: {err}")), |
| 217 | + Err(_elapsed) => Finding::new("dns", Status::Fail, "lookup timed out (5s)".to_owned()), |
| 218 | + } |
| 219 | +} |
| 220 | + |
| 221 | +#[cfg(test)] |
| 222 | +mod tests { |
| 223 | + use super::*; |
| 224 | + |
| 225 | + fn config(toml: &str) -> Config { |
| 226 | + crate::config::parse(toml).expect("config") |
| 227 | + } |
| 228 | + |
| 229 | + #[tokio::test] |
| 230 | + async fn missing_database_is_announced_not_created() { |
| 231 | + let config = config( |
| 232 | + r#" |
| 233 | + [page] |
| 234 | + [server] |
| 235 | + database_path = "/tmp/hora-doctor-test-does-not-exist.db" |
| 236 | + "#, |
| 237 | + ); |
| 238 | + let finding = database(&config).await; |
| 239 | + assert_eq!(finding.status, Status::Ok); |
| 240 | + assert!(finding.detail.contains("created on first start")); |
| 241 | + // Doctor must be side-effect free. |
| 242 | + assert!(!std::path::Path::new("/tmp/hora-doctor-test-does-not-exist.db").exists()); |
| 243 | + } |
| 244 | + |
| 245 | + #[test] |
| 246 | + fn ip_route_severity_depends_on_need() { |
| 247 | + // A guaranteed-unroutable family: probing a v6 address from a v4-only |
| 248 | + // socket fails at connect; with no dual_stack monitor it only warns. |
| 249 | + let quiet = config( |
| 250 | + r#" |
| 251 | + [page] |
| 252 | + [server] |
| 253 | + [[monitors]] |
| 254 | + id = "web" |
| 255 | + name = "Web" |
| 256 | + target = "https://example.com" |
| 257 | + interval_secs = 60 |
| 258 | + "#, |
| 259 | + ); |
| 260 | + let finding = ip_route("ipv6", "0.0.0.1:1", false, &quiet); |
| 261 | + // Whatever the host's networking, "not needed" can never be a Fail. |
| 262 | + assert_ne!(finding.status, Status::Fail); |
| 263 | + } |
| 264 | +} |
0 commit comments