Skip to content
academia.sh

Lesson 07 / 16

DNS Management

Counting the zone file as an inventory, the takeover surface dangling records leave behind, and measuring time to live as a decision: the propagation window, rollback speed, and query load.

Published Updated

In this lesson

  • Compares the records in the zone file against the live inventory to derive the number of dangling records
  • Compares time-to-live values jointly across the propagation window, rollback speed, and query load
  • Shows with numbers the cost of lowering the TTL before a planned change and the window created by forgetting to lower it
Contents

The request’s path is set up: the connection terminated at some point, headers were forwarded, the response came back from a cache. Across this whole chain, one question never gets asked — which name did the request arrive at, and who owns that name?

The name comes before the machine. A client knows the name, not the address; it learns the address from someone else and keeps the answer it learned for a while. These two sentences turn domain records from a configuration detail into a surface worth measuring. This lesson counts that surface along two axes: the zone file is an inventory, and the inventory’s drift from reality leaves a countable opening; time to live, meanwhile, is not a default left over from setup but a decision that sets three values at once.

What the record types are — address, canonical name, mail exchange, name server, text — was established in the Computer Networks course. Here the types are referred to by name and not redefined; what gets measured is not the type’s definition but the record’s upkeep and lifespan.

A Zone Is an Inventory

A zone is the full set of records an authoritative server is responsible for. On the operations side, it is something more: every target written into the zone points at something once assumed to exist. Systems get migrated, trials get shut down, contracts with outside services end — the record stays.

The program below builds the regional measurement network’s zone as a model. The network is fictional: a collector that gathers water-meter readings, a validator, and billing and work-order services. The model keeps two lists apart: the targets written in the zone and the addresses that are genuinely up.

DR1. The zone is under a single authoritative set and records are managed by hand. DR2. The live inventory is kept independent of the zone; no process reconciles the two lists.

// Model: the fictional zone of the regional measurement network. Not a real
// zone; a data structure carrying each record's name, type, TTL, and target.
const ZONE = [
  { name: 'measurenet.invalid',             type: 'NS',    ttl: 86400, target: 'ns1.measurenet.invalid' },
  { name: 'measurenet.invalid',             type: 'NS',    ttl: 86400, target: 'ns2.measurenet.invalid' },
  { name: 'measurenet.invalid',             type: 'A',     ttl: 3600,  target: '198.51.100.10' },
  { name: 'measurenet.invalid',             type: 'MX',    ttl: 3600,  target: 'mail.measurenet.invalid' },
  { name: 'measurenet.invalid',             type: 'TXT',   ttl: 3600,  target: '(text)' },
  { name: 'www.measurenet.invalid',         type: 'CNAME', ttl: 3600,  target: 'measurenet.invalid' },
  { name: 'mail.measurenet.invalid',        type: 'A',     ttl: 3600,  target: '198.51.100.20' },
  { name: 'ns1.measurenet.invalid',         type: 'A',     ttl: 86400, target: '198.51.100.2' },
  { name: 'ns2.measurenet.invalid',         type: 'A',     ttl: 86400, target: '198.51.100.3' },
  { name: 'collector.measurenet.invalid',   type: 'A',     ttl: 300,   target: '198.51.100.11' },
  { name: 'validator.measurenet.invalid',   type: 'A',     ttl: 300,   target: '198.51.100.12' },
  { name: 'billing.measurenet.invalid',     type: 'A',     ttl: 3600,  target: '198.51.100.13' },
  { name: 'workorder.measurenet.invalid',   type: 'A',     ttl: 3600,  target: '198.51.100.14' },
  { name: 'field.measurenet.invalid',       type: 'CNAME', ttl: 300,   target: 'workorder.measurenet.invalid' },
  { name: 'dashboard.measurenet.invalid',   type: 'CNAME', ttl: 3600,  target: 'legacy-dashboard.measurenet.invalid' },
  { name: 'rehearsal.measurenet.invalid',   type: 'A',     ttl: 86400, target: '198.51.100.71' },
  { name: 'trial.measurenet.invalid',       type: 'A',     ttl: 86400, target: '198.51.100.72' },
  { name: 'report.measurenet.invalid',      type: 'CNAME', ttl: 3600,  target: 'temp.external.invalid' },
];

// Live inventory: addresses that are genuinely up right now.
const LIVE_ADDRESSES = new Set([
  '198.51.100.2', '198.51.100.3', '198.51.100.10', '198.51.100.11',
  '198.51.100.12', '198.51.100.13', '198.51.100.14', '198.51.100.20',
]);
const IN_ZONE = 'measurenet.invalid';

function resolve(name, depth = 0) {
  if (depth > 8) return { status: 'loop' };
  if (!name.endsWith(IN_ZONE)) return { status: 'out-of-zone' };
  const address = ZONE.find((k) => k.name === name && k.type === 'A');
  if (address) {
    return LIVE_ADDRESSES.has(address.target)
      ? { status: 'live', address: address.target }
      : { status: 'dead-address', address: address.target };
  }
  const alias = ZONE.find((k) => k.name === name && k.type === 'CNAME');
  if (alias) return resolve(alias.target, depth + 1);
  return { status: 'no-record' };
}

const typeDistribution = {};
const ttlDistribution = {};
for (const k of ZONE) {
  typeDistribution[k.type] = (typeDistribution[k.type] || 0) + 1;
  ttlDistribution[k.ttl] = (ttlDistribution[k.ttl] || 0) + 1;
}
console.log('total records    :', ZONE.length);
console.log('distinct names   :', new Set(ZONE.map((k) => k.name)).size);
console.log('type distribution:', Object.entries(typeDistribution).map(([t, n]) => `${t}=${n}`).join(' '));
console.log('ttl distribution :', Object.keys(ttlDistribution).map(Number).sort((a, b) => a - b)
  .map((t) => `${t}s=${ttlDistribution[t]}`).join(' '));

const targeted = ZONE.filter((k) => k.type !== 'TXT');
const dangling = [];
for (const k of targeted) {
  const s = /^\d/.test(k.target)
    ? (LIVE_ADDRESSES.has(k.target) ? { status: 'live' } : { status: 'dead-address' })
    : resolve(k.target);
  if (s.status !== 'live') dangling.push({ ...k, status: s.status });
}

console.log('\ndangling records:', dangling.length, '/', targeted.length);
for (const k of dangling) {
  console.log(`  ${k.name.padEnd(30)} ${k.type.padEnd(5)} -> ${k.target.padEnd(37)} ${k.status}`);
}
const claimable = dangling.filter((k) => k.status === 'no-record' || k.status === 'out-of-zone');
console.log('\nempty target someone else could claim:', claimable.length);
console.log('of these, targets outside the zone   :', dangling.filter((k) => k.status === 'out-of-zone').length);
total records    : 18
distinct names   : 14
type distribution: NS=2 A=10 MX=1 TXT=1 CNAME=4
ttl distribution : 300s=3 3600s=9 86400s=6

dangling records: 4 / 17
  dashboard.measurenet.invalid   CNAME -> legacy-dashboard.measurenet.invalid   no-record
  rehearsal.measurenet.invalid   A     -> 198.51.100.71                         dead-address
  trial.measurenet.invalid       A     -> 198.51.100.72                         dead-address
  report.measurenet.invalid      CNAME -> temp.external.invalid                 out-of-zone

empty target someone else could claim: 2
of these, targets outside the zone   : 1

This is the first item the default leaves open: four of the seventeen targeted records point at something that no longer exists. Two point at a dead address; those are just noise — resolution ends and no connection is made. The other two are different, and they are the ones that actually earn the name dangling record: the record points at an empty name, and that name belongs to no one. Whoever claims the name first, a subdomain under measurenet.invalid starts showing their content. This is the record being taken over; from the browser’s point of view, the content is still under the measurement network’s name. A record pointing outside the zone is heavier still, because claiming that name requires touching none of the measurement network’s own systems.

The zone itself shows a second surface in the count: fourteen distinct names. Every name written in the zone makes the network’s internal structure readable — which services exist, which environment stands apart. Record names are a reconnaissance resource, and this item cannot be closed; a zone exists to be published.

Time to Live Is a Decision

Every record has a time to live, and most records go untouched during setup. Yet this single number sets three separate things at once: a change’s propagation window (how long the stale answer stays in circulation), a bad change’s rollback speed, and the query load landing on the authoritative server. The three don’t improve in the same direction — lower the TTL and the first two win while the third loses.

DR3. Two hundred forty resolver caches request the record continuously; at the moment of change, the age of each cached copy is uniformly distributed within the TTL. DR4. The load landing on the authoritative server comes only from cache refreshes.

// Model: a TTL sweep. At the moment of change, the age of each cached copy
// is uniform on [0, ttl); the stale answer stays in circulation for the rest.
const CACHES = 240;            // fictional resolver cache count
const SEED = 4242;             // generator seed, visible on purpose

function prng(seed) {
  let x = seed % 2147483647;
  return () => { x = (x * 48271) % 2147483647; return (x - 1) / 2147483646; };
}

function window(ttl) {
  const rand = prng(SEED);
  const remaining = [];
  for (let i = 0; i < CACHES; i++) remaining.push(Math.round(ttl * (1 - rand())));
  remaining.sort((a, b) => a - b);
  const pct = (p) => remaining[Math.min(remaining.length - 1, Math.floor(remaining.length * p))];
  const staleAfter = (t) => remaining.filter((k) => k > t).length;
  return {
    p50: pct(0.50), p95: pct(0.95), longest: remaining[remaining.length - 1],
    stillOld5min: staleAfter(300), stillOld1h: staleAfter(3600),
    dailyQueries: Math.round(CACHES * (86400 / ttl)),
  };
}

const duration = (s) => (s < 90 ? `${s}s` : s < 5400 ? `${(s / 60).toFixed(0)}min` : `${(s / 3600).toFixed(1)}h`);

console.log('cache count:', CACHES, '| seed:', SEED);
console.log('ttl      propagation p50  propagation p95  longest   still old after 5min  still old after 1h  daily queries');
for (const ttl of [60, 300, 900, 3600, 21600, 86400]) {
  const o = window(ttl);
  console.log(
    duration(ttl).padEnd(9) + duration(o.p50).padEnd(17) + duration(o.p95).padEnd(17) +
    duration(o.longest).padEnd(10) + `${o.stillOld5min}/${CACHES}`.padEnd(22) +
    `${o.stillOld1h}/${CACHES}`.padEnd(20) + String(o.dailyQueries),
  );
}
cache count: 240 | seed: 4242
ttl      propagation p50  propagation p95  longest   still old after 5min  still old after 1h  daily queries
60s      29s              55s              60s       0/240                 0/240               345600
5min     2min             5min             5min      0/240                 0/240               69120
15min    7min             14min            15min     156/240               0/240               23040
60min    29min            55min            60min     215/240               0/240               5760
6.0h     2.9h             5.5h             6.0h      233/240               202/240             960
24.0h    11.6h            22.0h            23.9h     239/240               230/240             240

The table reads as one line of trade-off. When an address change happens within a one-day TTL, 230 of the caches still return the stale answer an hour later; the longest wait runs to roughly twenty-four hours. At a five-minute TTL, the same change finishes within five minutes, at a cost of 69,120 queries a day instead of 240 — 288 times as many. The values in between are linear: halve the TTL and the window halves, the query count doubles.

The rule that follows from this isn’t the TTL itself but the record’s likelihood of changing. Name server records don’t change for years; a long TTL is the right call for them and it lowers the query load. Address records are subject to being moved. In the model’s zone, the TTL distribution doesn’t reflect this distinction at all: nine records sit at one hour, six sit at one day, and among them are both name server records that never change and movable trial addresses.

Before a Planned Change

The TTL is lowered before a change. Why this works, and why it stops working when done halfway, can both be counted.

DR5. The field request rate is 8 per second and is spread evenly across caches. DR6. At the moment of change, the new answer is served from all authoritative servers at once; replication delay between authoritative servers is not counted in this model.

// Model: the cost of lowering the TTL before a planned address change. On the
// time axis, 0 is the moment of change; leadWindow is the lead time, in seconds.
const CACHES = 240, SEED = 4242, OLD_TTL = 3600, LOW_TTL = 300, REQUEST_RATE = 8;

function prng(seed) {
  let x = seed % 2147483647;
  return () => { x = (x * 48271) % 2147483647; return (x - 1) / 2147483646; };
}

function scenario(name, leadWindow, ttlLowered) {
  const rand = prng(SEED);
  const remaining = [];
  for (let i = 0; i < CACHES; i++) {
    let untilExpiry = -leadWindow + rand() * OLD_TTL;
    while (untilExpiry <= 0) untilExpiry += ttlLowered ? LOW_TTL : OLD_TTL;
    remaining.push(Math.round(untilExpiry));
  }
  remaining.sort((a, b) => a - b);
  const pct = (p) => remaining[Math.floor(remaining.length * p)] ?? remaining[remaining.length - 1];
  return {
    name, leadWindow, p50: pct(0.5), p95: pct(0.95),
    misrouted: Math.round(remaining.reduce((a, b) => a + b, 0) * (REQUEST_RATE / CACHES)),
    extraQueries: ttlLowered ? Math.round(CACHES * leadWindow * (1 / LOW_TTL - 1 / OLD_TTL)) : 0,
    rollbackTtl: ttlLowered ? LOW_TTL : OLD_TTL,
  };
}

const duration = (s) => (s < 90 ? `${s}s` : s < 5400 ? `${Math.round(s / 60)}min` : `${(s / 3600).toFixed(1)}h`);
const scenarios = [
  scenario('ttl untouched', 0, false),
  scenario('lowered 30min early', 1800, true),
  scenario('lowered 24h early', 86400, true),
];

console.log('request rate:', REQUEST_RATE, 'req/s | caches:', CACHES, '| seed:', SEED);
console.log('scenario              lead window  extra queries  propagation p50  propagation p95  misrouted        rollback');
for (const s of scenarios) {
  console.log(
    s.name.padEnd(22) + (s.leadWindow ? duration(s.leadWindow) : '-').padEnd(13) +
    (s.extraQueries ? String(s.extraQueries) : '-').padEnd(15) + duration(s.p50).padEnd(17) +
    duration(s.p95).padEnd(17) + String(s.misrouted).padEnd(17) + duration(s.rollbackTtl),
  );
}

const neverRestoredDaily = Math.round(CACHES * 86400 * (1 / LOW_TTL - 1 / OLD_TTL));
console.log('\nextra daily authoritative queries if the ttl is never raised back:', neverRestoredDaily);
console.log('unplanned rollback window (p95) if lowering the ttl is forgotten:', duration(scenarios[0].p95));
request rate: 8 req/s | caches: 240 | seed: 4242
scenario              lead window  extra queries  propagation p50  propagation p95  misrouted        rollback
ttl untouched         -            -              32min            57min            14812            60min
lowered 30min early   30min        1320           5min             27min            4292             5min
lowered 24h early     24.0h        63360          2min             5min             1142             5min

extra daily authoritative queries if the ttl is never raised back: 63360
unplanned rollback window (p95) if lowering the ttl is forgotten: 57min

The middle row is the real lesson. When the TTL is lowered thirty minutes ahead, the job is only half done: part of the caches refreshed within those thirty minutes and picked up the short TTL, the rest still hold the one-hour copy. The result is a win at p50 but a twenty-seven-minute tail at p95. The short TTL only settles everywhere once a full period of the old TTL has passed; if the lead window is shorter than the old TTL, the lowering stays on paper.

The cost has two items. A twenty-four-hour lead window brings 63,360 extra authoritative queries, and those queries are paid whether the change happens or not. The second is sneakier: if the TTL isn’t raised back after the change, the same 63,360 queries keep getting paid every day — and no one treats it as an incident, because nothing breaks.

The cost of forgetting to lower it shows up in the unplanned case. When a bad record gets published, the rollback speed is whatever TTL is in effect at that moment: with a one-hour TTL, 14,812 requests go to the wrong address; with a five-minute TTL, 1,142. The 13,670 requests in between are the result not of a domain setting but of a decision nobody made.

The New Surface Closing Brings

Both steps carry their own surface. Cleaning up dangling records closes four items, but if some job still calls the rehearsal or trial names from somewhere, it stops two legitimate flows; that is why cleanup calls for a query count before deleting, and that count lives not in the zone but in the resolver logs. If an automation that can write to the zone is set up to keep the inventory reconciled continuously, a new privileged identity appears: whatever can write a record to the zone is the authority for that name.

Lowering the TTL has a measurable surface too. A five-minute TTL leaves only a five-minute buffer when the authoritative server becomes unreachable; at a one-day TTL, the same outage would have gone unnoticed for hours. Closing the propagation window makes the authoritative server’s availability critical.

Summary

  • The zone file is an inventory; four of the model’s seventeen targeted records point at a target that no longer exists, and two point at an empty name and so are open to takeover.
  • The fourteen distinct names written in the zone are a reconnaissance surface, and this item cannot be closed; a zone exists to be published.
  • Time to live alone sets three things: the propagation window, the rollback speed, and the query load. Dropping from a day to five minutes pulls the window from 22 hours to 5 minutes and multiplies the query count by 288.
  • When the lead window is shorter than the old TTL, the lowering stays half-done: with a thirty-minute lead window, p95 is still 27 minutes.
  • Forgetting to raise the TTL back means 63,360 extra queries a day; forgetting to lower it means 14,812 misrouted requests in an unplanned rollback.
  • Closing has its own surface: inventory automation brings a new identity with write access to the zone, and a short TTL brings only a five-minute buffer against an authoritative-server outage.

Next Step

This lesson measured where the name points. The same name does one more job beyond pointing: messages get sent under it. The regional measurement network sends notification emails to field teams, and those messages carry the name measurenet.invalid. Nothing says the message carrying that name actually came from that name — by default, sending a message in a name’s name doesn’t require owning that name. The next lesson measures sender verification as a surface: the authorized-sender list and the signature record are run against a fictional set of messages, and the count is taken of how many forged messages are caught, how many get through, and how many legitimate messages are mistakenly dropped.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close