Skip to content

Commit bffa58b

Browse files
committed
feat: dual stack probe if needed (ipv4+v6)
1 parent 33b5ac9 commit bffa58b

7 files changed

Lines changed: 412 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,23 @@ All notable changes to this project are documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Added
11+
12+
- **Dual-stack verification** (`dual_stack = true`): http, tcp and icmp
13+
monitors can probe IPv4 and IPv6 separately (concurrently) and require both
14+
families to pass - catching the service whose IPv6 has been silently dead
15+
behind a healthy IPv4, or the reverse. One broken family confirms down with
16+
the culprit named ("IPv6 failing: connection timed out (IPv4 ok)") and the
17+
surviving family's latency recorded; when both families answer, the recorded
18+
latency is the slower path's, so `degraded_over_ms` judges the worst case.
19+
Anonymous viewers see the collapsed category ("IPv6 failing (IPv4 ok)").
20+
Requires a hostname target (an IP literal has a single family), cannot be
21+
combined with `proxy`, and the probing host itself needs working IPv4 and
22+
IPv6. HTTP probes are steered per family by binding the client's local end
23+
to the family's unspecified address.
24+
825
## [0.4.1] - 2026-06-10
926

1027
Security hardening release. See [UPGRADES.md](UPGRADES.md) for the six

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ Named after the **Horai**, the Greek goddesses of the hours.
3636
annotated with root cause vs. symptom: _"caused by X"_ when an upstream it depends on
3737
is also down, or _"impacts: A, B, C"_ (the blast radius) when its upstreams are all
3838
healthy and it is the root cause. The dependency graph is validated acyclic at load.
39+
- **Dual-stack verification** - `dual_stack = true` probes IPv4 *and* IPv6
40+
separately and requires both: the classic silent failure is a service whose
41+
IPv6 has been dead for weeks behind a healthy IPv4 (or the reverse), invisible
42+
to every single-connection check. One broken family alerts with the culprit
43+
named - _"IPv6 failing: connection timed out (IPv4 ok)"_. Works for HTTP, TCP
44+
and ICMP monitors with a hostname target.
3945
- **Root-cause alert grouping** - when a database takes ten services down with it,
4046
you get **one notification** (the root cause, with its blast radius), not eleven:
4147
dependent monitors confirmed down within the grouping window fold into their

config.example.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,11 @@ depends_on = ["api"]
194194
# public_error_detail = true # optional; publish the raw failure reason (default:
195195
# # anonymous viewers see a safe category only)
196196
# cert_pin = "abc123..." # optional; SHA-256 of leaf public key (detects cert changes)
197+
# dual_stack = true # optional; probe IPv4 AND IPv6 and require both - catches
198+
# # the service whose IPv6 has been dead for weeks behind a
199+
# # healthy IPv4 ("IPv6 failing: connection timed out (IPv4
200+
# # ok)"). http/tcp/icmp with a hostname target; no proxy.
201+
# # The probing host itself needs working IPv4 and IPv6.
197202

198203
[[monitors]]
199204
id = "api"

crates/hora-core/src/config.rs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,14 @@ pub struct Monitor {
620620
/// record every raw probe result. Not applicable to push monitors.
621621
#[serde(default)]
622622
pub probe_retries: Option<u32>,
623+
/// Probe both address families and require both to pass - the classic
624+
/// silent failure is a service whose IPv6 has been dead for weeks behind a
625+
/// healthy IPv4. One broken family marks the monitor down, naming it in
626+
/// the reason ("IPv6 failing: connection timed out (IPv4 ok)"). For http,
627+
/// tcp and icmp monitors with a hostname target; not combinable with
628+
/// `proxy`. The probing host itself needs working IPv4 *and* IPv6.
629+
#[serde(default)]
630+
pub dual_stack: Option<bool>,
623631
/// Restrict this monitor's alerts to these channel names (e.g. `["ops"]`).
624632
/// Unset = every configured channel.
625633
#[serde(default)]
@@ -714,6 +722,7 @@ impl std::fmt::Debug for Monitor {
714722
.field("json_expected", &self.json_expected)
715723
.field("max_body_kb", &self.max_body_kb)
716724
.field("probe_retries", &self.probe_retries)
725+
.field("dual_stack", &self.dual_stack)
717726
.field("notify", &self.notify)
718727
.field("proxy", &self.proxy.as_deref().map(redact_url_credentials))
719728
.field("push_token", &self.push_token)
@@ -762,6 +771,12 @@ impl Monitor {
762771
Duration::from_secs(self.interval_secs)
763772
}
764773

774+
/// Whether this monitor probes both address families (default off).
775+
#[must_use]
776+
pub fn dual_stack(&self) -> bool {
777+
self.dual_stack.unwrap_or(false)
778+
}
779+
765780
/// Whether this monitor should have its TLS certificate expiry checked.
766781
#[must_use]
767782
pub fn checks_cert(&self) -> bool {
@@ -1344,10 +1359,49 @@ fn validate_monitor_io(monitor: &Monitor) -> anyhow::Result<()> {
13441359
monitor.id
13451360
);
13461361
}
1362+
if monitor.dual_stack() {
1363+
validate_dual_stack(monitor)?;
1364+
}
13471365
validate_schedule_and_slo(monitor)?;
13481366
Ok(())
13491367
}
13501368

1369+
/// `dual_stack` needs a probe that can be steered per address family: an
1370+
/// active kind, no proxy in the way, and a hostname target (an IP literal has
1371+
/// a single family by construction). Runs after the per-kind target checks,
1372+
/// so the target is already known to be well-shaped.
1373+
fn validate_dual_stack(monitor: &Monitor) -> anyhow::Result<()> {
1374+
anyhow::ensure!(
1375+
matches!(monitor.kind, Kind::Http | Kind::Tcp | Kind::Icmp),
1376+
"monitor {}: dual_stack requires an http, tcp or icmp monitor",
1377+
monitor.id
1378+
);
1379+
anyhow::ensure!(
1380+
monitor.proxy.is_none(),
1381+
"monitor {}: dual_stack cannot go through a proxy (only the proxy hop's family would be tested)",
1382+
monitor.id
1383+
);
1384+
let host = match monitor.kind {
1385+
Kind::Http => reqwest::Url::parse(&monitor.target)
1386+
.ok()
1387+
.and_then(|url| url.host_str().map(str::to_owned)),
1388+
Kind::Tcp => monitor
1389+
.target
1390+
.rsplit_once(':')
1391+
.map(|(host, _port)| host.to_owned()),
1392+
_ => Some(monitor.target.clone()),
1393+
};
1394+
// URL and tcp hosts may carry an IPv6 literal in brackets.
1395+
let host = host.unwrap_or_default();
1396+
let bare = host.trim_start_matches('[').trim_end_matches(']');
1397+
anyhow::ensure!(
1398+
bare.parse::<std::net::IpAddr>().is_err(),
1399+
"monitor {}: dual_stack requires a hostname target (an IP literal has a single address family)",
1400+
monitor.id
1401+
);
1402+
Ok(())
1403+
}
1404+
13511405
/// Validate the push `schedule`/`grace_secs` pair and the availability SLO
13521406
/// fields. The cron expression is parsed here so a typo fails at load, not at
13531407
/// the first missed heartbeat.
@@ -1708,6 +1762,59 @@ mod tests {
17081762
assert!(error.contains("require an http monitor"), "got: {error}");
17091763
}
17101764

1765+
#[test]
1766+
fn dual_stack_validation() {
1767+
let base = |body: &str| {
1768+
format!(
1769+
r#"
1770+
[page]
1771+
[server]
1772+
[[monitors]]
1773+
id = "x"
1774+
name = "X"
1775+
interval_secs = 60
1776+
dual_stack = true
1777+
{body}
1778+
"#
1779+
)
1780+
};
1781+
1782+
// Hostname targets pass for the three active kinds.
1783+
for body in [
1784+
"target = \"https://example.com\"",
1785+
"kind = \"tcp\"\ntarget = \"db.example.com:5432\"",
1786+
"kind = \"icmp\"\ntarget = \"gw.example.com\"",
1787+
] {
1788+
validate(&parse(&base(body))).expect("hostname target valid");
1789+
}
1790+
1791+
// An IP literal has a single family - rejected, brackets included.
1792+
for body in [
1793+
"target = \"https://192.0.2.1/health\"",
1794+
"target = \"https://[2001:db8::1]/health\"",
1795+
"kind = \"tcp\"\ntarget = \"192.0.2.1:5432\"",
1796+
"kind = \"icmp\"\ntarget = \"2001:db8::1\"",
1797+
] {
1798+
let error = validate(&parse(&base(body))).unwrap_err().to_string();
1799+
assert!(error.contains("requires a hostname"), "got: {error}");
1800+
}
1801+
1802+
// Wrong kind, and proxy combination.
1803+
let error = validate(&parse(&base("kind = \"dns\"\ntarget = \"example.com\"")))
1804+
.unwrap_err()
1805+
.to_string();
1806+
assert!(
1807+
error.contains("requires an http, tcp or icmp monitor"),
1808+
"got: {error}"
1809+
);
1810+
let error = validate(&parse(&base(
1811+
"target = \"https://example.com\"\nproxy = \"http://127.0.0.1:8080\"",
1812+
)))
1813+
.unwrap_err()
1814+
.to_string();
1815+
assert!(error.contains("cannot go through a proxy"), "got: {error}");
1816+
}
1817+
17111818
#[test]
17121819
fn maintenance_window_mutes_selected_monitor() {
17131820
let config = parse(

crates/hora-core/src/http.rs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use reqwest::redirect::Policy;
1313
///
1414
/// Returns an error if the proxy URL is invalid or the client cannot be built.
1515
pub fn client(proxy: Option<&str>) -> reqwest::Result<Client> {
16-
build(proxy, Policy::default())
16+
build(proxy, Policy::default(), None)
1717
}
1818

1919
/// Like [`client`], but never auto-follows redirects: probes follow them
@@ -26,13 +26,31 @@ pub fn client(proxy: Option<&str>) -> reqwest::Result<Client> {
2626
///
2727
/// Returns an error if the proxy URL is invalid or the client cannot be built.
2828
pub fn probe_client(proxy: Option<&str>) -> reqwest::Result<Client> {
29-
build(proxy, Policy::none())
29+
build(proxy, Policy::none(), None)
3030
}
3131

32-
fn build(proxy: Option<&str>, redirect: Policy) -> reqwest::Result<Client> {
32+
/// Like [`probe_client`], but restricted to one address family by binding the
33+
/// local end to `local` (the family's unspecified address): the connector then
34+
/// only attempts resolved addresses of that family. Used by dual-stack probes;
35+
/// never proxied - config validation rejects the combination, since through a
36+
/// proxy only the proxy hop's family would be tested.
37+
///
38+
/// # Errors
39+
///
40+
/// Returns an error if the client cannot be built.
41+
pub fn probe_client_family(local: std::net::IpAddr) -> reqwest::Result<Client> {
42+
build(None, Policy::none(), Some(local))
43+
}
44+
45+
fn build(
46+
proxy: Option<&str>,
47+
redirect: Policy,
48+
local: Option<std::net::IpAddr>,
49+
) -> reqwest::Result<Client> {
3350
let mut builder = Client::builder()
3451
.user_agent(concat!("hora/", env!("CARGO_PKG_VERSION")))
3552
.redirect(redirect)
53+
.local_address(local)
3654
// Backstop for requests without a per-request timeout (notifiers); probes
3755
// override this with the monitor's own timeout.
3856
.timeout(Duration::from_secs(15))

0 commit comments

Comments
 (0)