Skip to content

Commit c27c216

Browse files
committed
Release v0.3.0: ICMP monitors, dependency topology, dead-man peers
ICMP (ping) monitors via unprivileged datagram sockets (rootless-Docker friendly, IPv4+IPv6). Dependency-aware alerting (depends_on) with root-cause/impact annotations and display groups. Mutual-surveillance dead-man peers ([health]/[[peers]]) with witness quorum, and a JSON /healthz. Status-page annotation moved below the caption and ungrouped sections spaced.
1 parent f0679d4 commit c27c216

9 files changed

Lines changed: 259 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.3.0] - 2026-06-08
11+
1012
### Added
1113

14+
- **ICMP (ping) monitors** (`kind = "icmp"`): `target` is a host or IP (no port),
15+
up = an echo reply within the timeout, latency = the round-trip time
16+
(`degraded_over_ms` applies). It uses an **unprivileged datagram socket**, so it
17+
works in rootless Docker without `CAP_NET_RAW` (the kernel's
18+
`net.ipv4.ping_group_range`, Docker's default, must cover the process); when no
19+
ICMP permission is available the monitor reports down with a clear reason rather
20+
than crashing. **IPv4 and IPv6** are both supported.
1221
- **Dependency-aware alerting** (`depends_on`) and **display groups** (`group`)
1322
on monitors. When a monitor goes down, the alert is annotated with topology
1423
context: `"caused by X"` if an upstream it depends on is also down (symptom),

Cargo.lock

Lines changed: 70 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ members = [
88
]
99

1010
[workspace.package]
11-
version = "0.2.4"
11+
version = "0.3.0"
1212
edition = "2024"
1313
license = "MIT"
1414
publish = false

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ The status page is at `http://localhost:8787/`. Put it behind your reverse proxy
6262
on whatever domain you like - Hora is self-contained and assumes nothing about who
6363
consumes it.
6464

65+
**ICMP (`kind = "icmp"`) monitors** use an unprivileged datagram socket, so they
66+
need no extra capability as long as the container's group id is within the
67+
kernel's `net.ipv4.ping_group_range` - Docker's default (`0 2147483647`) already
68+
covers the image's `10001` user, **including rootless Docker**. If your host
69+
narrows that range, either widen it
70+
(`--sysctl net.ipv4.ping_group_range="0 2147483647"`) or grant `--cap-add NET_RAW`;
71+
otherwise `icmp` monitors simply report down with a clear reason.
72+
6573
Secrets are best kept in the environment: any `${VAR}` in the config is replaced
6674
from the environment at load. So in the file:
6775

config.example.toml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,13 @@ default_retention_days = 90
107107
# at = "2026-06-07T12:00:00Z" # optional, RFC 3339
108108

109109
# --- Monitors -------------------------------------------------------------
110-
# kind = "http" (default), "tcp" or "push".
110+
# kind = "http" (default), "tcp", "icmp" or "push".
111111
# For http: target is a URL; up = 2xx (or expected_status if set).
112112
# For tcp: target is "host:port"; up = TCP connect succeeds.
113+
# For icmp: target is a host or IP (no port); up = ICMP echo reply. Uses an
114+
# unprivileged datagram socket, so it works in rootless Docker without
115+
# CAP_NET_RAW (the kernel net.ipv4.ping_group_range, Docker's default,
116+
# must cover the process). IPv4 and IPv6 are both supported.
113117
# For push: no target; the job calls POST /api/push/{id} (see below).
114118
# TLS certificate expiry is checked automatically for https:// monitors.
115119
#
@@ -163,6 +167,17 @@ interval_secs = 60
163167
timeout_secs = 5
164168
group = "infra"
165169

170+
# ICMP echo (ping) to a host or IP. No port; up = an echo reply within timeout.
171+
[[monitors]]
172+
id = "gateway"
173+
name = "Gateway"
174+
kind = "icmp"
175+
target = "192.0.2.1" # or a hostname, or an IPv6 address
176+
interval_secs = 30
177+
timeout_secs = 5
178+
group = "infra"
179+
# degraded_over_ms = 200 # optional; flag slow round-trips as degraded
180+
166181
# Heartbeat / push monitor: down if no ping arrives within `interval_secs`.
167182
# The job calls: curl -fsS -X POST \
168183
# "https://status.example.com/api/push/nightly-backup?token=${BACKUP_TOKEN}"

crates/hora-core/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ reqwest.workspace = true
1717
serde.workspace = true
1818
serde_json.workspace = true
1919
serde_json_path = "0.7.2"
20+
socket2 = "0.6.1"
2021
sqlx.workspace = true
22+
surge-ping = "0.8.4"
2123
tokio = { workspace = true, features = ["rt", "time", "net", "sync", "signal"] }
2224
tokio-rustls.workspace = true
2325
toml.workspace = true

crates/hora-core/src/config.rs

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,11 @@ pub enum Kind {
493493
#[default]
494494
Http,
495495
Tcp,
496+
/// ICMP echo (ping). Target is a host or IP (no port). Uses an unprivileged
497+
/// datagram socket, so it works in rootless Docker without `CAP_NET_RAW`
498+
/// (the kernel's `net.ipv4.ping_group_range` must cover the process, which is
499+
/// the Docker default). IPv4 and IPv6 are both supported.
500+
Icmp,
496501
/// Passive heartbeat: the monitored job pings `/api/push/{id}`; missing a
497502
/// heartbeat within the interval marks it down. No active probing.
498503
Push,
@@ -505,7 +510,8 @@ pub struct Monitor {
505510
pub name: String,
506511
#[serde(default)]
507512
pub kind: Kind,
508-
/// Probe target (URL for HTTP, `host:port` for TCP). Unused for push monitors.
513+
/// Probe target (URL for HTTP, `host:port` for TCP, host or IP for ICMP).
514+
/// Unused for push monitors.
509515
#[serde(default)]
510516
pub target: String,
511517
pub interval_secs: u64,
@@ -1000,6 +1006,23 @@ fn validate_monitor_io(monitor: &Monitor) -> anyhow::Result<()> {
10001006
"monitor {}: tcp target must be host:port",
10011007
monitor.id
10021008
),
1009+
Kind::Icmp => {
1010+
// A stray `:port` is a common mistake; an IPv6 literal parses as an IP
1011+
// and is exempt, so its colons are fine.
1012+
let has_port = monitor.target.parse::<std::net::IpAddr>().is_err()
1013+
&& monitor
1014+
.target
1015+
.rsplit_once(':')
1016+
.is_some_and(|(host, port)| !host.is_empty() && port.parse::<u16>().is_ok());
1017+
anyhow::ensure!(
1018+
!monitor.target.contains("://")
1019+
&& !monitor.target.contains('/')
1020+
&& !monitor.target.chars().any(char::is_whitespace)
1021+
&& !has_port,
1022+
"monitor {}: icmp target must be a bare host or IP (no scheme, path, or port)",
1023+
monitor.id
1024+
);
1025+
}
10031026
Kind::Push => {}
10041027
}
10051028
// A negative latency threshold would mark every check degraded/breached.
@@ -1866,4 +1889,44 @@ mod tests {
18661889
let error = validate(&config).unwrap_err().to_string();
18671890
assert!(error.contains("cycle"), "got: {error}");
18681891
}
1892+
1893+
#[test]
1894+
fn parses_icmp_monitor() {
1895+
let config = parse(
1896+
r#"
1897+
[page]
1898+
[server]
1899+
[[monitors]]
1900+
id = "host"
1901+
name = "Host"
1902+
kind = "icmp"
1903+
target = "192.0.2.1"
1904+
interval_secs = 30
1905+
"#,
1906+
);
1907+
assert_eq!(config.monitors[0].kind, Kind::Icmp);
1908+
// ICMP is not HTTPS, so no certificate check.
1909+
assert!(!config.monitors[0].checks_cert());
1910+
validate(&config).expect("valid icmp monitor");
1911+
}
1912+
1913+
#[test]
1914+
fn rejects_icmp_target_with_scheme_or_port() {
1915+
for bad in ["https://example.com", "1.2.3.4:443", "a/b"] {
1916+
let config = parse(&format!(
1917+
r#"
1918+
[page]
1919+
[server]
1920+
[[monitors]]
1921+
id = "host"
1922+
name = "Host"
1923+
kind = "icmp"
1924+
target = "{bad}"
1925+
interval_secs = 30
1926+
"#
1927+
));
1928+
let error = validate(&config).unwrap_err().to_string();
1929+
assert!(error.contains("bare host or IP"), "{bad} -> {error}");
1930+
}
1931+
}
18691932
}

0 commit comments

Comments
 (0)