zonegit

module
v0.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 23, 2026 License: Apache-2.0

README

zonegit

CI Go Reference Go Report Card Release License

Git semantics for authoritative DNS.

zonegit is a content-addressed, version-controlled object model for authoritative DNS zones. Every change is an immutable commit; the live zone is just a pointer to the latest commit on a branch. That single inversion gives you log, diff, blame, time-travel reads, and branch-based rollout — operations that today's authoritative DNS servers (BIND, Knot, PowerDNS, Route 53) don't expose at all.

Status

v0.3 — public preview. Single zone, single process, no replication. v0.3 adds the four things you'd actually want before pointing a production secondary at this:

  • Canary serving (zonegitd --canary canary:20): a stable subnet-bucket router that sends X% of traffic to a canary branch and snaps it back with one ref move. See Canary serving below.
  • AXFR: respond to full-zone transfer requests, so any existing BIND / Knot / PowerDNS secondary can slave off zonegitd.
  • Time-travel daemon (zonegitd --at HEAD~5): pin the server to a historical commit and dig against the past, not just the current branch tip.
  • Auto-incrementing SOA serial: changes that touch any non-SOA RRset bump the apex SOA serial automatically, so existing IXFR/NOTIFY pipelines pick up changes the way they always have.

Plus: a cached, polling snapshotter (the daemon no longer opens Badger per packet), a Prometheus /metrics endpoint, Ed25519 commit signing (zonegit sign-commit / verify), and a PR-style verb pair (zonegit propose / approve / review). The on-disk format and public Go API are still not stable; expect breakage between minor versions until v1.0.

Demo

The repo ships with an end-to-end demo that builds both binaries, imports a real zonefile, serves it on 127.0.0.1:15353, mutates a record, and shows the change reflected live — without a reload, an SOA bump, or restarting the daemon.

make demo

Below is roughly what you should see.

1. Initialise a repo and import a zonefile
$ zonegit --repo ./.zonegit init foo.com.
initialised zonegit repo at ./.zonegit (zone: foo.com.)

$ cat foo.com.zone
$ORIGIN foo.com.
$TTL 300
@   IN SOA ns1.foo.com. admin.foo.com. 1 7200 3600 1209600 300
    IN NS  ns1.foo.com.
ns1 IN A   10.0.0.1
api IN A   1.2.3.4
www IN CNAME api.foo.com.

$ zonegit --repo ./.zonegit --zone foo.com. import foo.com.zone -m "initial import"
[main 4f1c2a9] initial import
 5 RRsets imported
2. Serve it, query it
$ zonegitd --repo ./.zonegit --zone foo.com. --listen 127.0.0.1:15353 &

$ dig @127.0.0.1 -p 15353 +short api.foo.com. A
1.2.3.4

$ dig @127.0.0.1 -p 15353 +short www.foo.com. A
api.foo.com.
1.2.3.4
3. Change a record. The daemon picks it up on the next packet.
$ zonegit --repo ./.zonegit --zone foo.com. \
    set api.foo.com. A 300 9.9.9.9 -m "failover to DR site"
[main 7c2af3b] failover to DR site

$ dig @127.0.0.1 -p 15353 +short api.foo.com. A
9.9.9.9

No reload, no SOA dance, no daemon restart. The server reopens its read-only Badger handle per query and sees the new HEAD.

4. Inspect the history
$ zonegit --repo ./.zonegit log
commit 7c2af3b  Chandan Kumar  2026-04-25 22:13  failover to DR site
commit 4f1c2a9  Chandan Kumar  2026-04-25 22:08  initial import

$ zonegit --repo ./.zonegit diff HEAD~1 HEAD
~ api  A   1.2.3.4 -> 9.9.9.9

$ zonegit --repo ./.zonegit blame api.foo.com. A
api.foo.com. A  9.9.9.9   <- 7c2af3b  Chandan Kumar  "failover to DR site"
5. Time-travel
$ zonegit --repo ./.zonegit show api.foo.com. A HEAD~1
api.foo.com. 300 IN A 1.2.3.4

That last query — "what did this name resolve to N commits ago?" — is the one that no DNS tool shipping today can answer.

6. Branch and merge (v0.2+)

Create a canary branch, edit it, then fast-forward merge into main. The daemon picks up the new tip on the very next dig — no restart.

$ zonegit --repo ./.zonegit branch canary
$ zonegit --repo ./.zonegit checkout canary

$ zonegit --repo ./.zonegit --zone foo.com. \
    set api.foo.com. A 300 7.7.7.7 -m "canary: api -> 7.7.7.7"
[canary b4e10c8] canary: api -> 7.7.7.7

# daemon is still on --branch main, so dig still returns 9.9.9.9
$ dig @127.0.0.1 -p 15353 +short api.foo.com. A
9.9.9.9

$ zonegit --repo ./.zonegit checkout main
$ zonegit --repo ./.zonegit --zone foo.com. merge canary
Fast-forward to b4e10c8.

$ dig @127.0.0.1 -p 15353 +short api.foo.com. A
7.7.7.7
7. Revert
$ zonegit --repo ./.zonegit --zone foo.com. revert HEAD
Reverted as c353b7b

$ dig @127.0.0.1 -p 15353 +short api.foo.com. A
9.9.9.9
8. Reset
$ zonegit --repo ./.zonegit --zone foo.com. reset --hard HEAD~1
HEAD is now at b4e10c8

$ dig @127.0.0.1 -p 15353 +short api.foo.com. A
7.7.7.7

The full demo (including branch isolation, merge, revert, reset, canary routing, time-travel, AXFR, and the PR-style propose/approve flow) runs end-to-end via make demo.

9. Canary serving by client subnet

Send 20% of traffic to a canary branch — selected by a stable hash of the client /24 — and snap it back to 100% main with one ref move.

$ zonegitd --repo ./.zonegit --zone foo.com. \
    --listen 127.0.0.1:15353 \
    --branch main --canary canary:20 --canary-salt "api-rollout" &

# 20% of /24s land on canary; the other 80% land on main.
$ for ip in 10.{1..20}.0.1; do dig +short +subnet=$ip/24 \
    @127.0.0.1 -p 15353 api.foo.com. A; done | sort | uniq -c
     16 9.9.9.9       # main
      4 7.7.7.7       # canary

# Rollback is a ref move: zero packets dropped.
$ zonegit --repo ./.zonegit reset --hard main

Per-rule match counts ship out the /metrics endpoint (zonegit_dns_queries_total{qtype,rcode} plus the active-branch info gauge) so a Grafana dashboard sees the cohort split in real time.

10. AXFR — serve secondaries like any other authority
$ dig @127.0.0.1 -p 15353 +tcp foo.com. AXFR
foo.com.   300  IN  SOA  ns1.foo.com. admin.foo.com. 2 7200 3600 1209600 300
foo.com.   300  IN  NS   ns1.foo.com.
ns1.foo.com. 300 IN  A    10.0.0.1
api.foo.com. 300 IN  A    9.9.9.9
www.foo.com. 300 IN  CNAME api.foo.com.
foo.com.   300  IN  SOA  ns1.foo.com. admin.foo.com. 2 7200 3600 1209600 300

Any standard BIND / Knot / PowerDNS secondary can transfer foo.com from 127.0.0.1 and stay in sync via its usual refresh loop. Because the apex SOA serial auto-increments on every commit that touches the zone, IXFR-style polling Just Works (we don't ship IXFR yet, so secondaries re-AXFR; that's a v4 optimisation).

11. Signed commits
$ zonegit keygen ~/.zonegit/zonegit.pub ~/.zonegit/zonegit.key
$ zonegit --repo ./.zonegit sign-commit HEAD --key ~/.zonegit/zonegit.key
signed 7c2af3b -> 9e10c2d
$ zonegit --repo ./.zonegit verify HEAD --key ~/.zonegit/zonegit.pub --chain
OK     9e10c2d  failover to DR site
OK     4f1c2a9  initial import

The signature lives in a reserved header in the commit object (pkg/object/commit.go), so signed and unsigned commits share storage byte-for-byte except for that single line.

12. PR-style change review
$ zonegit --repo ./.zonegit propose api-failover --from main
proposal "api-failover" created from 7c2af3b (HEAD now on api-failover)

$ zonegit --repo ./.zonegit set api.foo.com. A 300 9.9.9.9 -m "failover api"
[api-failover a31b07d] failover api

$ zonegit --repo ./.zonegit review api-failover --into main
proposal "api-failover" vs main — 2 change(s):
  ~ api A
  ~ @  SOA       # auto-bumped serial

$ zonegit --repo ./.zonegit approve api-failover --into main
Approved "api-failover": fast-forward to a31b07d on main.

The verb names exist purely to make ServiceNow / change-management conversations feel native. Underneath it's branch + checkout, diff a..b, and checkout main; merge.

What's coming next (talking points)

These are real items on the roadmap and the surface that supports them is already in place. The code is not.

  • DNSSEC — DNSKEY, RRSIG, NSEC/NSEC3 live in the apex like any other RRset, so they're already storable as Blobs and reachable by the resolver. v4 adds signing on the write path (zonegit sign-zone --ksk ... --zsk ...) and verification on the resolver. KSK/ZSK rollover becomes "branch + ref move".
  • Replication — branches are content-addressed pointers, so a pull replica is "give me every reachable object from refs/heads/main that I don't already have". The wire protocol is dumb: it walks the Merkle DAG. v5 ships a pull mode; multi-master with per-branch ownership is v6.
  • Multi-zone — today, one repo = one zone. The object model is already zone-blind (the zone name is just persisted metadata), so v5 reshapes the on-disk layout to refs/heads/<zone>/<branch> and unlocks one daemon serving many zones.
  • CoreDNS pluginpkg/resolve.Handle is already the seam. A CoreDNS plugin is ~200 LoC of glue wrapping that function and registering it with the Corefile parser. Listed at v6 because the authority story has to be airtight first.

Install

Pre-built binaries

Download the latest release for your platform from the releases page.

From source

Requires Go 1.24+.

go install github.com/ckumar392/zonegit/cmd/zonegit@latest
go install github.com/ckumar392/zonegit/cmd/zonegitd@latest

Or clone and build:

git clone https://github.com/ckumar392/zonegit.git
cd zonegit
make build      # produces ./bin/zonegit and ./bin/zonegitd

How it works

zonegit models a zone as a Merkle DAG of immutable objects:

Object What it holds
Blob One canonicalised RRset (one (name, type) coordinate).
Tree A directory of labels mapping to subtrees and RRset blobs.
Commit A snapshot of the zone tree, with parent links and metadata.
Tag / Ref Named pointers into the commit graph.

Names with identical content hash to identical blobs, so equivalent zones share storage. Subtree hashes mean diff skips unchanged branches in O(changes), not O(zone size). Commits chain by parent hash, so the history is verifiable end-to-end.

The full design is in docs/OBJECT_MODEL.md.

Repository layout

cmd/
  zonegit/      CLI entry point
  zonegitd/     Authoritative DNS server entry point
pkg/
  store/        Storage interface + Badger and in-memory backends
  object/       Blob / Tree / Commit / Tag, canonical encoding, hashing
  zone/         Bridge between miekg/dns RRs and the object model
  refs/         Branches, HEAD, reflog, atomic compare-and-swap
  history/      log, diff, blame
  merge/        Three-way tree merge with conflict classification
  resolve/      DNS query path
  repo/         Public Go API
docs/           Design documentation
scripts/        Development helpers

Build and test

make build       # build both binaries into ./bin
make test        # unit tests
make test-race   # tests with the race detector
make bench       # benchmarks (5 runs per case; benchstat-friendly)
make lint        # golangci-lint (or go vet as a fallback)
make demo        # end-to-end demo
make help        # list all targets

Benchmarks

Run:

make bench

To compare two benchmark runs with benchstat:

go test -run=^$ -bench=. -benchmem -count=5 ./... > old.txt
go test -run=^$ -bench=. -benchmem -count=5 ./... > new.txt
benchstat old.txt new.txt

Benchmark numbers are indicative and environment-dependent; treat them as regression signals, not strict performance contracts.

Documentation

Contributing

Contributions are welcome. See CONTRIBUTING.md for the development setup, the change-proposal process, and the good first issue list. Released versions are tracked in CHANGELOG.md.

License

Licensed under the Apache License, Version 2.0.

Directories

Path Synopsis
cmd
zonegit command
zonegitd command
Command zonegitd is the authoritative DNS responder backed by a zonegit repository.
Command zonegitd is the authoritative DNS responder backed by a zonegit repository.
pkg
dnssec
Package dnssec provides DNSSEC keypair management and RRSIG generation for zonegit zones.
Package dnssec provides DNSSEC keypair management and RRSIG generation for zonegit zones.
history
Package history provides read-only views over the commit DAG: log (commits in parent order), diff (RRset changes between trees), blame (who last touched a specific RRset), and walk-at (point-in-time).
Package history provides read-only views over the commit DAG: log (commits in parent order), diff (RRset changes between trees), blame (who last touched a specific RRset), and walk-at (point-in-time).
merge
Package merge implements structural 3-way merge over zone trees for the `zonegit merge` operation.
Package merge implements structural 3-way merge over zone trees for the `zonegit merge` operation.
object
Package object defines the four immutable content-addressable object kinds that constitute a zonegit repository: Blob, Tree, Commit, Tag.
Package object defines the four immutable content-addressable object kinds that constitute a zonegit repository: Blob, Tree, Commit, Tag.
repo
Package repo is the public Go API of zonegit.
Package repo is the public Go API of zonegit.
resolve
Package resolve is the DNS query path.
Package resolve is the DNS query path.
route
Package route implements the per-query branch selection used by zonegitd's canary feature.
Package route implements the per-query branch selection used by zonegitd's canary feature.
sign
Package sign provides Ed25519 sign/verify primitives for zonegit commits and tags.
Package sign provides Ed25519 sign/verify primitives for zonegit commits and tags.
store
Package store defines the only persistence seam in zonegit.
Package store defines the only persistence seam in zonegit.
store/memstore
Package memstore is an in-memory implementation of store.Storage used for tests, demos, and embedded scenarios where durability is not required.
Package memstore is an in-memory implementation of store.Storage used for tests, demos, and embedded scenarios where durability is not required.
zone
Package zone bridges miekg/dns RR types and pkg/object Blob payloads.
Package zone bridges miekg/dns RR types and pkg/object Blob payloads.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL