Skip to content

Commit ae73387

Browse files
calliclesclaude
andcommitted
Stop one resolver's invented TTL from setting the TTL advisory
`estimated_ttl` took the max reported TTL across majority rows. Reported TTLs are countdowns, so the max is the right estimator when every resolver reports the record's real remaining lifetime — but some public resolvers return values unrelated to the authoritative record, and a single one of them owned the headline: one resolver reporting 8423s made a 300s zone read "TTL ≈ 2h23m — lower the TTL first". The estimate is now the longest report within 4x the fleet's 90th percentile, which keeps the max's semantics while bounding what a liar can do: it cannot move the percentile, and no more than a tenth of the reports can ever be rejected. Under ten samples the percentile degenerates to the max, so small fleets behave exactly as before. Rejected reports aren't dropped silently — a cache claiming a much longer lease is a real propagation hazard — so `estimated_ttl` returns them with their resolver index and both front ends name the resolver: the TUI footer note and the `--once` note line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5458b5e commit ae73387

4 files changed

Lines changed: 316 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
2424
TUI — instead of filling the table with one identical error per resolver,
2525
which read like a network outage.
2626
([#37](https://github.com/514-labs/dnsglobe/pull/37))
27+
- The TTL note no longer lets a single resolver speak for the zone. It used to
28+
report the longest TTL any agreeing resolver returned, so one resolver
29+
handing back an invented 8423s turned a 300s zone into "TTL ≈ 2h23m" and
30+
advised lowering a TTL that was already low. The estimate now ignores
31+
reports wildly out of line with the rest of the fleet (never more than a
32+
tenth of it), and any resolver that reported one is named on the note line
33+
with what it claims — that cache really will serve the old answer after a
34+
change, and it is worth knowing which one it is.
35+
([#XX](https://github.com/514-labs/dnsglobe/pull/XX))
2736

2837
## [0.4.0] - 2026-07-11
2938

src/app.rs

Lines changed: 199 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ const HISTORY_CAP: usize = 32;
2525
/// record change (the "drop TTL to 30s a day before migrating" practice).
2626
pub const ADVISORY_TTL: u32 = 3600;
2727

28+
/// How far above the fleet's 90th percentile a reported TTL has to sit before
29+
/// it stops counting as the same record's countdown. Honest countdowns for one
30+
/// record all live in `(0, configured_ttl]`, so a 4× gap can't come from
31+
/// caching timing — it's a resolver reporting a number of its own invention.
32+
const TTL_OUTLIER_FACTOR: u32 = 4;
33+
2834
pub const RECORD_TYPES: &[RecordType] = &[
2935
RecordType::A,
3036
RecordType::AAAA,
@@ -131,6 +137,31 @@ struct Observation {
131137
at: Instant,
132138
}
133139

140+
/// One resolver's reported TTL, attributable back to that resolver.
141+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142+
pub struct TtlReport {
143+
/// Index into `App::resolvers`, so callers can name the resolver.
144+
pub index: usize,
145+
/// The TTL it reported, in seconds.
146+
pub ttl: u32,
147+
}
148+
149+
/// What the fleet's reported TTLs say about the zone's configured TTL.
150+
#[derive(Debug, Clone, PartialEq, Eq)]
151+
pub struct TtlEstimate {
152+
/// Best estimate of the zone's configured TTL, in seconds: the longest
153+
/// countdown reported by a resolver whose number the rest of the fleet
154+
/// corroborates.
155+
pub ttl: u32,
156+
/// How many majority rows reported a TTL at all (`ttl` plus `outliers`
157+
/// were drawn from these).
158+
pub samples: usize,
159+
/// Reports too far above the fleet to be this record's countdown, longest
160+
/// first. Not an error to surface and forget: such a cache keeps serving
161+
/// the old answer well past the zone's stated lifetime.
162+
pub outliers: Vec<TtlReport>,
163+
}
164+
134165
/// Why a resolver is still serving a non-majority answer.
135166
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136167
pub enum TtlVerdict {
@@ -835,22 +866,69 @@ impl App {
835866
(now > deadline).then_some(TtlVerdict::PastTtl)
836867
}
837868

838-
/// Estimated configured TTL: the max reported TTL across majority rows.
839-
/// A resolver that just refetched reports (nearly) the full configured
840-
/// value, so the max over the fleet is within seconds of the zone's TTL.
841-
pub fn estimated_ttl(&self, summary: &Summary) -> Option<u32> {
842-
self.rows
869+
/// Estimate the zone's configured TTL from what the majority rows report.
870+
///
871+
/// A reported TTL is a *countdown*, not the configured value: a resolver
872+
/// that just refetched reports (nearly) the full TTL, one halfway through
873+
/// its cache entry reports half of it. So the longest report is the best
874+
/// estimate — but only among resolvers reporting this record's countdown.
875+
/// Some public resolvers hand back numbers unrelated to the authoritative
876+
/// record (fixed floors, or values of their own invention), and taking a
877+
/// plain max let one of them speak for the zone: a single resolver
878+
/// reporting 8423s turned a 300s zone into "TTL ≈ 2h23m".
879+
///
880+
/// So the max is taken over reports within `TTL_OUTLIER_FACTOR` of the
881+
/// fleet's 90th percentile, and anything above that is returned separately
882+
/// for the caller to attribute. Skipping a tenth of the fleet is what
883+
/// bounds the damage: a handful of liars can't move the percentile, and by
884+
/// construction no more than a tenth of the reports can be rejected. A
885+
/// fleet where *most* resolvers fabricate the same long TTL is beyond what
886+
/// resolver-side data can settle — dnsglobe never talks to the
887+
/// authoritative servers, so there is no ground truth to fall back on.
888+
pub fn estimated_ttl(&self, summary: &Summary) -> Option<TtlEstimate> {
889+
let mut reports: Vec<TtlReport> = self
890+
.rows
843891
.iter()
844892
.enumerate()
845893
.filter(|&(i, _)| summary.majority_rows[i])
846-
.filter_map(|(_, row)| match row {
894+
.filter_map(|(index, row)| match row {
847895
RowState::Done {
848896
result: QueryResult::Records { min_ttl, .. },
849897
..
850-
} => Some(*min_ttl),
898+
} => Some(TtlReport {
899+
index,
900+
ttl: *min_ttl,
901+
}),
851902
_ => None,
852903
})
853-
.max()
904+
.collect();
905+
let samples = reports.len();
906+
if samples == 0 {
907+
return None;
908+
}
909+
910+
reports.sort_unstable_by_key(|r| std::cmp::Reverse(r.ttl));
911+
// Longest report left after skipping the top tenth. Under ten samples
912+
// that tenth is empty and this is just the max, which is what we want:
913+
// a percentile over a handful of resolvers is too thin to convict any
914+
// of them of lying.
915+
let bulk = reports[samples / 10].ttl;
916+
let cutoff = bulk.saturating_mul(TTL_OUTLIER_FACTOR);
917+
// A zero cutoff means most of the fleet is at the end of its countdown
918+
// (or reports no TTL at all); every other report would then look like
919+
// an outlier, so fall back to the plain max.
920+
let outliers = if cutoff == 0 {
921+
0
922+
} else {
923+
reports.iter().take_while(|r| r.ttl > cutoff).count()
924+
};
925+
Some(TtlEstimate {
926+
// `bulk` is never above the cutoff, so a non-outlier always
927+
// remains to speak for the zone.
928+
ttl: reports[outliers].ttl,
929+
samples,
930+
outliers: reports[..outliers].to_vec(),
931+
})
854932
}
855933

856934
/// Worst-case wait until every non-majority cache must have refetched:
@@ -1437,11 +1515,15 @@ mod tests {
14371515
assert_eq!(round.indices, vec![0, 2, 3]);
14381516
}
14391517

1440-
#[test]
1441-
fn estimated_ttl_is_max_over_majority_rows_only() {
1442-
let mut app = app_with_answers(&[&["x"], &["x"], &["y"]]);
1443-
let ttls = [300u32, 3600, 999_999];
1444-
for (row, ttl) in app.rows.iter_mut().zip(ttls) {
1518+
/// One agreeing row per TTL, so every row lands in the majority.
1519+
fn app_agreeing_with_ttls(ttls: &[u32]) -> App {
1520+
let mut app = app_with_answers(&vec![&["x"] as &[&str]; ttls.len()]);
1521+
set_ttls(&mut app, ttls);
1522+
app
1523+
}
1524+
1525+
fn set_ttls(app: &mut App, ttls: &[u32]) {
1526+
for (row, &ttl) in app.rows.iter_mut().zip(ttls) {
14451527
if let RowState::Done {
14461528
result: QueryResult::Records { min_ttl, .. },
14471529
..
@@ -1450,9 +1532,111 @@ mod tests {
14501532
*min_ttl = ttl;
14511533
}
14521534
}
1535+
}
1536+
1537+
#[test]
1538+
fn estimated_ttl_is_max_over_majority_rows_only() {
1539+
let mut app = app_with_answers(&[&["x"], &["x"], &["y"]]);
1540+
set_ttls(&mut app, &[300, 3600, 999_999]);
1541+
let summary = app.summary();
1542+
let est = app.estimated_ttl(&summary).unwrap();
1543+
// The differing row's huge TTL must not leak into the estimate, and
1544+
// under ten samples the longest agreeing report stands unchallenged.
1545+
assert_eq!(est.ttl, 3600);
1546+
assert_eq!(est.samples, 2);
1547+
assert!(est.outliers.is_empty());
1548+
}
1549+
1550+
#[test]
1551+
fn one_fabricated_ttl_does_not_set_the_estimate() {
1552+
// The reported case: 32 resolvers on a 300s zone, plus one reporting
1553+
// 8423s. The headline must stay with the zone, well under the
1554+
// advisory threshold, and the liar must be named instead.
1555+
let mut ttls = vec![124u32; 16];
1556+
ttls.extend([300u32; 15]);
1557+
ttls.push(430);
1558+
ttls.push(8423);
1559+
let app = app_agreeing_with_ttls(&ttls);
1560+
let summary = app.summary();
1561+
let est = app.estimated_ttl(&summary).unwrap();
1562+
assert_eq!(est.ttl, 430);
1563+
assert!(est.ttl < ADVISORY_TTL);
1564+
assert_eq!(est.samples, 33);
1565+
assert_eq!(
1566+
est.outliers,
1567+
vec![TtlReport {
1568+
index: 32,
1569+
ttl: 8423
1570+
}]
1571+
);
1572+
}
1573+
1574+
#[test]
1575+
fn a_genuinely_long_ttl_still_triggers_the_advisory() {
1576+
// A 1-day zone sampled mid-countdown: every report is a fraction of
1577+
// 86400, and the freshest ones sit at the full value. Nothing here is
1578+
// an outlier, and the estimate must clear ADVISORY_TTL.
1579+
let mut ttls: Vec<u32> = (0..32).map(|i| 86_400 - i * 2_500).collect();
1580+
ttls.push(86_400);
1581+
let app = app_agreeing_with_ttls(&ttls);
1582+
let summary = app.summary();
1583+
let est = app.estimated_ttl(&summary).unwrap();
1584+
assert_eq!(est.ttl, 86_400);
1585+
assert!(est.ttl >= ADVISORY_TTL);
1586+
assert!(est.outliers.is_empty());
1587+
}
1588+
1589+
#[test]
1590+
fn outlier_rejection_is_capped_at_a_tenth_of_the_fleet() {
1591+
// Five resolvers agreeing on a long TTL out of twenty are a fifth of
1592+
// the fleet: too many to dismiss as fabrications, so they set the
1593+
// estimate rather than being explained away.
1594+
let mut ttls = vec![300u32; 15];
1595+
ttls.extend([86_400u32; 5]);
1596+
let app = app_agreeing_with_ttls(&ttls);
1597+
let summary = app.summary();
1598+
let est = app.estimated_ttl(&summary).unwrap();
1599+
assert_eq!(est.ttl, 86_400);
1600+
assert!(est.outliers.is_empty());
1601+
}
1602+
1603+
#[test]
1604+
fn estimated_ttl_needs_a_majority_row_with_records() {
1605+
// Nothing queried yet.
1606+
let app = App::new("example.com".into());
1607+
assert_eq!(app.estimated_ttl(&app.summary()), None);
1608+
1609+
// Answered, but nothing that carries a TTL: no majority, no estimate.
1610+
let mut app = App::new("example.com".into());
1611+
app.rows = vec![
1612+
RowState::Done {
1613+
result: QueryResult::Error("timeout".into()),
1614+
elapsed: Duration::from_secs(3),
1615+
at: Instant::now(),
1616+
ecs_honored: None,
1617+
},
1618+
RowState::Done {
1619+
result: QueryResult::ServFail,
1620+
elapsed: Duration::from_millis(10),
1621+
at: Instant::now(),
1622+
ecs_honored: None,
1623+
},
1624+
];
1625+
assert_eq!(app.estimated_ttl(&app.summary()), None);
1626+
}
1627+
1628+
#[test]
1629+
fn a_fleet_at_the_end_of_its_countdown_falls_back_to_the_max() {
1630+
// Reported TTLs are countdowns, so a fleet polled just before expiry
1631+
// reports zeros. Zero times anything is zero: without a fallback every
1632+
// non-zero report would look like an outlier.
1633+
let mut ttls = vec![0u32; 32];
1634+
ttls.push(300);
1635+
let app = app_agreeing_with_ttls(&ttls);
14531636
let summary = app.summary();
1454-
// The differing row's huge TTL must not leak into the estimate.
1455-
assert_eq!(app.estimated_ttl(&summary), Some(3600));
1637+
let est = app.estimated_ttl(&summary).unwrap();
1638+
assert_eq!(est.ttl, 300);
1639+
assert!(est.outliers.is_empty());
14561640
}
14571641

14581642
#[test]

src/main.rs

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -643,11 +643,27 @@ fn print_round(app: &App, summary: &app::Summary, multi: bool) {
643643
&& summary.responding > 0
644644
&& summary.agree == summary.responding
645645
&& let Some(est) = app.estimated_ttl(summary)
646-
&& est >= app::ADVISORY_TTL
647646
{
648-
println!(
649-
"note: TTL ≈ {} — planning a record change? lower the TTL first, then wait one old-TTL period before switching.",
650-
app::fmt_secs(u64::from(est))
651-
);
647+
if est.ttl >= app::ADVISORY_TTL {
648+
println!(
649+
"note: TTL ≈ {} — planning a record change? lower the TTL first, then wait one old-TTL period before switching.",
650+
app::fmt_secs(u64::from(est.ttl))
651+
);
652+
}
653+
// A resolver reporting far more than the fleet isn't counted in the
654+
// estimate above, but it's worth naming: that cache serves the old
655+
// answer for as long as it claims, whatever the zone says.
656+
for outlier in &est.outliers {
657+
let resolver = &app.resolvers[outlier.index];
658+
println!(
659+
"note: {} ({}) reports ttl={} where {} of {} resolvers report ttl<={} — that cache will serve the old answer long past the zone's TTL.",
660+
resolver.name,
661+
resolver.location,
662+
outlier.ttl,
663+
est.samples - est.outliers.len(),
664+
est.samples,
665+
est.ttl,
666+
);
667+
}
652668
}
653669
}

0 commit comments

Comments
 (0)