case studies

BareProxy Drift Watcher Catches a DNS Move That Went Wrong

Moving a domain to a new DNS host is one of those jobs that looks done when the website loads. The nameservers changed, the A record is right, the page comes up. A week later someone notices that half the mail is bouncing because one MX record didn’t make it across, or that certificate renewals fail because the new zone has a CAA record naming the wrong CA.

This case walks a small set of domains through exactly that kind of move and runs the drift watcher before, during and after. It used the released 0.16.0 binary for Linux.

A note on the network

The sandbox this was recorded in has no working public DNS. Every name comes back NXDOMAIN, and outgoing HTTPS goes through an intercepting proxy, so real records and certificates were out of reach. The case uses a small stand-in instead, written for it and separate from the project’s own test stubs: a DNS resolver and an HTTPS server on 127.0.0.1, answering for six names under the reserved .test domain, with certificates from two test CAs. The watcher talks to it exactly as it would to a real resolver and real servers. No real domain was checked.

The watch

global
  state /work/state

drift acme
  domains acme.test www.acme.test shop.acme.test blog.acme.test
  domains api.acme.test status.otherco.test
  resolver 127.0.0.1:56839
  port 53711
  ca /work/ca-bundle.pem
  timeout 2s

port points the TLS checks at the stand-in server, and ca trusts the two test CAs. On a real setup both lines go away.

Check 1, the baseline

The first run records what it finds and doesn’t call anything a change. It still reports problems that are already there, and this set had three:

ALERT      blog.acme.test  no Strict-Transport-Security header on https://blog.acme.test/
ALERT      shop.acme.test  certificate ends in 8 days (2026-11-09 10:41 UTC), inside the 21-day window
ALERT      status.otherco.test  no CAA records here or on its parent names, so any CA may issue a certificate for it
Result: 3 alerts, 0 unchecked
(exit code 4)

Each domain also got a “first look, recorded” note listing every record and the certificate it saw. It took 0.1 seconds.

Check 2, after the move

Between the first and second checks, acme.test moved to a new DNS host. The new zone was typed in by hand and two things went wrong: the second MX record was left out, and the CAA record names a different CA. The shop certificate was renewed, by the second CA this time. Two more things were set up to test the watcher’s caution. One name, api.acme.test, gave a wrong A answer once and then the right one, the way a stale resolver or a stray load balancer node would. And the resolver was told to drop every question about the MX record of www.acme.test, so that lookup would time out.

ALERT      acme.test  MX changed
           before: 10 mx1.mailhost.test, 20 mx2.mailhost.test
           after:  10 mx1.mailhost.test
ALERT      acme.test  nameservers moved: ns1.dnshost-a.test, ns2.dnshost-a.test -> ns1.cheapdns.test, ns2.cheapdns.test
ALERT      acme.test  CAA changed
           before: 0 issue "old-ca.test"
           after:  0 issue "new-ca.test"
ALERT      blog.acme.test  no Strict-Transport-Security header on https://blog.acme.test/
ALERT      shop.acme.test  certificate changed and its issuer too: Acme Old Test CA, Case Study -> Second Test CA, Case Study
           before: shop.acme.test, issued by Acme Old Test CA, Case Study, ends 2026-11-09, sha256 d9472bbe74d7b2e7
           after:  shop.acme.test, issued by Second Test CA, Case Study, ends 2027-01-29, sha256 141642c5cbfbe491
ALERT      status.otherco.test  no CAA records here or on its parent names, so any CA may issue a certificate for it
UNCHECKED  www.acme.test  MX: lookup failed twice (timeout); last known (none) kept
NOTE       api.acme.test  A: one lookup showed 127.0.0.9, the next showed the known 127.0.0.1 again; not reported
Result: 6 alerts, 1 unchecked
(exit code 4)

(The nameserver alert also prints its before and after lines; they are trimmed here.)

Everything planted was found, and each item landed where it belonged. The MX alert shows exactly which record went missing. The nameserver move is called a move. The renewed certificate is reported once, with the issuer change spelled out, and the expiry alert from check 1 is gone because the new certificate runs to January.

The two traps behaved as designed. The flapping A record was asked a second time, came back right, and became a note with no alert. The dropped MX lookup was tried twice, then reported as unchecked, and the last known value was kept. The watcher didn’t call it unchanged. Those two timeouts account for the run taking 4.0 seconds, with timeout 2s.

Check 3, nothing changed

ALERT      blog.acme.test  no Strict-Transport-Security header on https://blog.acme.test/
ALERT      status.otherco.test  no CAA records here or on its parent names, so any CA may issue a certificate for it
OK         4 domains with nothing to report
Result: 2 alerts, 0 unchecked
(exit code 4)

The changes from check 2 alerted once and became the new known state. The two standing problems keep alerting on every run until they are fixed, which is the point of calling them standing.

Check 4, fixed

HSTS was turned on for the blog, a CAA record was added to otherco.test, and the resolver answered normally again:

OK         6 domains with nothing to report
Result: 0 alerts, 0 unchecked

Exit code 0. As JSON, run through jq, the same check reads {"watch":"acme","domains":6,"clean":6,"alerts":0,"unchecked":0}.

The history

bareproxy drift --history printed the six first looks and then the four changes from check 2, each with its time and its before and after. The flap and the timeout aren’t in it, because neither was a change. That makes the history a clean record of what really moved, which is what you want when someone asks “when did our MX records change?”

Over the five runs the resolver answered 240 questions and dropped two, the two MX lookups for www.acme.test in check 2.

What it shows, and what it doesn’t

The watcher caught each mistake in the move, kept quiet about the noise, and said clearly what it couldn’t see. The exit codes did the right thing for a CI job on every run.

It also shows the limits of a stand-in. A real resolver caches, so a real change shows up only after the old record’s time to live runs out, and the stand-in has no cache. Real-world runs against a public resolver are still on the list.

The script, the stand-in and the full log are in the repository: drift-watch.sh, lib/fakenet.py and drift-watch.log. The use examples post shows the watcher in CI and inside a running server.