A modern, observable, API-first authoritative DNS server written in safe Rust.
NebulaDNS replaces 2001-era daemons (TinyDNS / djbdns) and 40-year-old C codebases (BIND) with a single small binary that ships with metrics, a control plane, and a Kubernetes operator — so the next failure is detected in seconds, not by customers.
- Why another DNS server? See the competitive analysis.
- Try it in 2 minutes? Jump to Quickstart.
- Landing page preview? Open
index.htmlin a browser.
- Status
- Features
- Quickstart
- Why NebulaDNS
- Using this DNS (ns1 / ns2) for incidents and failover
- Architecture
- Observability
- Operations
- Security
- Repository layout
- Contributing
- License
M1 (Wire + Zone) — the server answers real DNS queries over UDP and TCP from a TOML zone file and exposes labelled Prometheus metrics for every query.
| Milestone | Scope | State |
|---|---|---|
| M0 | Workspace, CI, /livez, /readyz, /metrics, systemd unit, Docker image, Helm chart skeleton, nebula-wire fuzz harness |
✅ Done |
| M1 | Full RFC 1035 codec, name compression, RR types, TOML zone loader, UDP + TCP listeners, data-plane metrics | ✅ Done |
| M2 | Wildcards, CNAME chasing, glue, RFC 2308 negative responses, EDNS negotiation, RRL | Next |
| M3 | AXFR / IXFR / NOTIFY / TSIG + daily interop matrix | Planned |
| M4 | Online DNSSEC signing (Ed25519 / ECDSA / RSA), HSM + KMS | Planned |
| M5 | Full REST API, gRPC, Swagger, CLI expansion, audit log | Planned |
| M6 | Propagation verifier + deploy gate | Planned |
| M7 | HA + multi-region replication (Raft + async log-ship) | Planned |
| M8 | Kubernetes operator + CRDs, ExternalDNS, CoreDNS drop-in | Planned |
| M9 | React dashboard | Planned |
Full milestone schedule in PROJECT_PROMPT.md §15.
| Feature | Detail |
|---|---|
| RFC 1035 wire codec | Header, name (with compression + cycle detection), question, RR, message. Strict parsing — errors like QdCountMismatch are explicit variants, not silent corruption. |
| RR types | A, AAAA, NS, CNAME, SOA, MX, TXT, PTR, SRV, CAA; unknown types pass through verbatim (good-secondary behaviour). |
| EDNS(0) | OPT pseudo-RR decoded and re-emitted; DO bit parsed. |
| RFC 4035 flag bits | AD and CD bits recognised (caught the first time dig queried us). |
| Zone loader | Native TOML schema with deny_unknown_fields; hash-indexed RRsets with case-insensitive lookup. |
| Authoritative answer path | NOERROR with answers / NODATA / NXDOMAIN + SOA / REFUSED for out-of-zone. |
| UDP + TCP listeners | tokio-based; RFC 7766 length-prefix framing on TCP; UDP truncation with TC=1 when responses exceed 512 bytes. |
| Zone registry | arc-swap-backed lock-free snapshot; readers never block; zones replaced atomically. |
| Control plane | /livez, /readyz, /api/v1/version on a separate bind. Authenticated PUT /api/v1/zones/{zone}/records for fast CNAME/A/AAAA/TXT changes (fail-closed bearer token, default TTL 5s, hash-chained audit log). |
| Metrics | Always-on Prometheus with compile-time-bounded labels (proto/qtype/rcode). Counter + histogram + drop reasons. Zero hot-path allocation. |
| Logging | Structured JSON via tracing; configurable filter. |
| systemd integration | Type=notify with sd_notify READY + watchdog; graceful SIGTERM shutdown. |
| Container | Distroless non-root image, multi-arch, ~25 MB compressed. Built-in HEALTHCHECK. |
| CLI | nebulactl version / nebulactl health. |
| Fuzz harness | Four cargo-fuzz targets (header / question / name / message). 60 s smoke in CI on every PR. |
| Property tests | proptest roundtrip for header, question, message. |
| Workspace hygiene | #![forbid(unsafe_code)] in every crate. Clippy -D warnings with pedantic + nursery. cargo deny, cargo audit, rustfmt in CI. |
| Feature | Detail |
|---|---|
| Helm chart | StatefulSet, headless Service, LB Service (DNS), PodDisruptionBudget, NetworkPolicy, ServiceMonitor, PrometheusRule, helm test smoke pod. Zones mounted via ConfigMap from values.yaml. |
| Docker Compose | NebulaDNS + Prometheus + Grafana (pre-provisioned dashboard) + optional BIND 9.18 peer. One-shot smoke profile runs dig + curl assertions. |
| Ansible deploy | Idempotent role with staged binary install, pre-flip smoke test, atomic symlink swap, CAP_NET_BIND_SERVICE grant, config validation, health probe, old-binary pruning. |
| Rollback | ansible-playbook rollback.yml flips the symlink back to the previously staged binary — no downloads, seconds per host. |
| Release pipeline | deploy.yml: test gate → cross-compiled musl build (amd64 + arm64) → Ansible deploy with --check --diff preview in production. |
| Docker test workflow | test-docker.yml: builds the image, spins up compose, runs real dig queries from the runner, scrapes /metrics. |
| k3s test workflow | test-k3s.yml: spins up k3d, loads the image, helm install, helm test, port-forward + dig. |
| CI gates | Fmt, clippy, nextest, cargo deny, cargo audit, fuzz-smoke, helm lint + kubeconform, hadolint, shellcheck, ansible-lint + syntax-check. |
The fastest way to see NebulaDNS serve real traffic. Includes Prometheus, Grafana with a pre-provisioned dashboard, and a dig-based smoke container.
git clone https://github.com/nebuladns/nebuladns.git
cd nebuladns
docker compose -f deploy/docker/compose/docker-compose.yml up --build -d
# Real DNS over UDP and TCP
dig @127.0.0.1 -p 5353 www.example.com A +short
# 192.0.2.10
# 192.0.2.11
dig @127.0.0.1 -p 5353 +tcp example.com SOA +short
dig @127.0.0.1 -p 5353 nope.example.com A | grep status: # NXDOMAIN
dig @127.0.0.1 -p 5353 foo.not-our-zone.test A | grep status: # REFUSED
# Metrics + dashboards
curl -s http://127.0.0.1:9090/metrics | grep nebula_dns_queries
open http://127.0.0.1:3000 # Grafana (admin / admin)
open http://127.0.0.1:9091 # Prometheus
# Automated smoke suite (dig + /metrics assertions). Exits non-zero on failure.
docker compose -f deploy/docker/compose/docker-compose.yml --profile smoke run --rm smoke
# Teardown
docker compose -f deploy/docker/compose/docker-compose.yml down -vZone writes are fail-closed: they require Authorization: Bearer … matching
NEBULA_API_TOKEN (or api.token_sha256). Default TTL is 5 seconds so resolver
caches expire quickly. Apex CNAME and CNAME-alongside-other-types are rejected.
export NEBULA_API_TOKEN=please-use-a-long-random-secret
# Restart nebuladns so it picks up the token, then:
curl -sS -X PUT http://127.0.0.1:8080/api/v1/zones/example.com/records \
-H "Authorization: Bearer $NEBULA_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: failover-1" \
-d '{"name":"app","type":"CNAME","value":"west.example.net.","ttl":5}'
dig @127.0.0.1 -p 15353 app.example.com CNAME +ttl
# Automated workflow (spawns a throwaway server, stdlib only):
cargo build -p nebula-server --bin nebuladns
python3 tests/python/test_fast_record.py --bin target/debug/nebuladnsSee docs/decisions/0002-fast-record-api.md.
# Basic install (from the repo — OCI chart publishing lands with v1.0).
helm install nebuladns deploy/helm/nebuladns \
--namespace nebuladns --create-namespace
# Or with a real zone inlined in values:
cat <<'EOF' > /tmp/values.yaml
zones:
- name: "example.com"
content: |
origin = "example.com."
default_ttl = 300
[soa]
mname = "ns1.example.com."
rname = "hostmaster.example.com."
serial = 2026041701
refresh = 10800
retry = 3600
expire = 604800
minimum = 300
[[records]]
name = "www"
type = "A"
value = "192.0.2.10"
EOF
helm install nebuladns deploy/helm/nebuladns \
-n nebuladns --create-namespace -f /tmp/values.yaml
# Smoke
helm test nebuladns -n nebuladns --logs
# Probe
kubectl -n nebuladns port-forward svc/nebuladns 15353:53 9090:9090 &
dig +tcp @127.0.0.1 -p 15353 www.example.com A +short
curl -s http://127.0.0.1:9090/metrics | headFor Docker + k3s end-to-end tests, the GitHub Actions workflows are already wired:
Hardened systemd unit with watchdog + sd_notify. Automated via Ansible.
# One-time host prep (on the target, as root):
adduser --disabled-password --gecos "NebulaDNS deploy" deploy
echo "<your SSH pub key>" >> /home/deploy/.ssh/authorized_keys
echo 'deploy ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/deploy-nebuladns
# On your machine (or GitHub runner):
cd deploy/ansible
ansible-galaxy collection install -r requirements.yml
# Copy inventory.example.ini → inventory.ini, edit hosts, then:
ansible-playbook -i inventory.ini playbook.yml \
-e nebuladns_version=v0.1.0 \
-e @environments/staging.yml
# Rollback in seconds (no downloads, symlink flip only):
ansible-playbook -i inventory.ini rollback.ymlSee deploy/ansible/README.md for secrets, environments, and
the full release pipeline.
git clone https://github.com/nebuladns/nebuladns.git
cd nebuladns
cargo build --release --bin nebuladns --bin nebulactl
./target/release/nebuladns --config config/nebuladns.demo.toml &
dig @127.0.0.1 -p 15353 www.example.com A +short
./target/release/nebulactl healthRequires Rust stable 1.80+ (pinned via rust-toolchain.toml).
nebula-mcp exposes NebulaDNS admin operations to Claude (Desktop, Code, or any other
MCP-aware client) as a stdio server. Read-only tools are active today against M0/M1;
the full zone / deploy / secondaries tool set lights up once the M5 REST surface ships.
cargo build --release --bin nebula-mcpRegister it with Claude Desktop in ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"nebuladns": {
"command": "/absolute/path/to/target/release/nebula-mcp",
"env": {
"NEBULA_API": "127.0.0.1:8080",
"NEBULA_FAILOVER_API": "127.0.0.1:9119",
"NEBULA_MCP_ALLOW_WRITES": "0"
}
}
}
}Or add it to Claude Code: claude mcp add nebuladns /absolute/path/to/nebula-mcp.
Mutating tools (create_zone, replace_zone, add_records, set_record,
delete_record, rollback_zone, force_notify, trigger_dnssec_rollover, deploy,
and the failover_* write tools) refuse to run unless NEBULA_MCP_ALLOW_WRITES=1 is
set — a per-process kill switch that prevents the model from changing DNS state in
read-only sessions.
Failover tools talk to nebula-dns-failover
(NEBULA_FAILOVER_API, default 127.0.0.1:9119):
| Tool | Gate | Effect |
|---|---|---|
failover_status |
read | POP health + per-hostname serving state / override |
failover_force_secondary |
write | Pin hostname to secondary (sticky) |
failover_force_primary |
write | Pin hostname to primary (sticky) |
failover_clear_override |
write | Back to health-driven policy |
failover_reconcile |
write | Probe + reconcile now |
Write tools also need NEBULA_FAILOVER_TOKEN (the controller's FAILOVER_API_TOKEN).
Overrides persist until cleared, so the tool descriptions tell the model to confirm
with an operator before pinning.
We ran TinyDNS (djbdns 1.05, released 2001) behind a CDN. In 22 days we had two
production outages on real customer traffic. Both traced to the same root cause:
djbdns 1.05 sends AXFR responses with QDCOUNT=0, which BIND 9.18+ rejects as FORMERR.
Our downstream secondaries silently upgraded to BIND 9.18, and our zone-transfer
redundancy eroded invisibly for 13 months until only one working agent remained.
There was no /metrics, no API, no post-deploy verification, no way to see that half
the fleet had stopped transferring. Engineers had to SSH into six servers to
reconstruct state during the incident.
We evaluated every serious authoritative DNS server in production use today. Every single one has a structural limitation NebulaDNS is designed to overcome:
| Server | Critical limitation | NebulaDNS answer |
|---|---|---|
| TinyDNS / djbdns 1.05 | No DNSSEC, no IXFR, no TSIG, QDCOUNT=0 wire bug, no metrics, no API, unmaintained |
Full RFC-conformant wire, modern Rust safety, API-first, /metrics native, TSIG/DNSSEC built in |
| BIND 9 | Monolithic C with long CVE history, named.conf is file-only and fragile, rndc limited, statistics-channels is XML/JSON without Prometheus, no built-in UI |
Single small binary, memory-safe Rust, /metrics native, REST + gRPC API, integrated UI, unified DNSSEC management |
| Knot DNS | No native REST API (control is knotc CLI), no built-in UI, no propagation verification |
REST + gRPC + CLI + UI from day one, propagation verifier native |
| NSD | Intentionally minimal: no dynamic updates, no online DNSSEC signing, no API, no UI, no geo-routing | Full-featured while retaining NSD-class performance via Rust |
| PowerDNS Auth | API is incomplete (many operations still require SQL backend edits), multiple moving parts, no native propagation gate | API covers 100% of operations, propagation verifier built-in, single binary |
| CoreDNS | Not a full-fledged auth server for internet-facing zones, DNSSEC primitive, Go GC tail latency at high QPS | First-class authoritative + transfer + DNSSEC; drop-in replacement as cluster DNS; consistent low-tail latency (no GC) |
| Route 53 / Cloudflare DNS | Vendor lock-in, cost at scale, no self-hosted option | Self-hosted with the same UX quality; optionally integrates as a secondary |
The common pattern across every open-source alternative: the control plane is an afterthought bolted onto a 1990s-era data plane. NebulaDNS inverts this — the API is the server, and the data plane is a first-class implementation of the API's contract.
Most importantly: no alternative ships a first-class propagation gate. That single primitive — "this change has reached all required downstreams; OK to proceed" — is the feature that would have prevented both of our incidents. It's core to NebulaDNS from M6.
Live fleet (co-located on diy-tax-return-infra cloud edge workers):
| Name | Host | IP |
|---|---|---|
| ns1 | cloud001 | 72.62.211.28 |
| ns2 | vps002 | 85.190.106.88 |
Apex nebuladns.net stays on Cloudflare (anycast). NebulaDNS is authoritative for
zones you NS-delegate to ns1/ns2 (starter zone: demo.nebuladns.net). Operational
Ansible + SSH live in the sibling checkout diy-tax-return-infra
(docs/runbooks/nebuladns.md, ansible/nebuladns.yml). Product-side companion:
docs/runbooks/NsSetupAndCloudflare.md.
DNS outages are usually not “the nameserver binary died” — they are wrong answers, stale caches, or a single POP still advertised after it is dead. This stack separates three jobs so each has a clear fix path:
| Job | Who owns it | Why it matters in an incident |
|---|---|---|
| Apex / public NS anycast | Cloudflare | Resolvers keep finding the zone even if one of our VPs is down |
| Authoritative answers for delegated zones | NebulaDNS on ns1 + ns2 | Two independent authoritative hosts; dig either IP to see ground truth without Cloudflare cache |
| App hostname traffic shift (lon1 ↔ lon2) | nebula-dns-failover rewriting Cloudflare A/CNAME |
Health probes move clients off a dead edge in ~TTL (often 60–90s), fail-open keeps last-good RRset |
Do not confuse ns1/ns2 with lon1/lon2. ns* are DNS servers; lon* are HTTP edge POPs. Failover rewrites records that point at POPs — it does not replace Cloudflare as the zone’s public nameservers (unless you deliberately cut over to Option A).
Best default for app failover today: provider.type = "cloudflare" on the failover
controller (see docs/runbooks/DnsFailover.md). Use
provider.type = "nebuladns" when the zone’s public NS are ns1/ns2.
Live CNAME failover test (ns1/ns2): https://test.failover.nebuladns.net/
— demo page shows which POP you landed on, live DoH CNAME + TTL, lon1/lon2
/healthz, and the failover story. DNS: TTL 30, CNAME lon1.pop0.uk ↔
lon2.pop0.uk via nebula-dns-failover on both NS hosts. Agent skill:
.cursor/skills/dns-level-failover/SKILL.md.
Reference pilot: abtesting.fictionally.org.
1. Point a name at NebulaDNS (delegated child)
- Upsert Cloudflare glue via infra script (reads the wslproxy Cloudflare token
from k3s1
wslproxy-system/wslproxy-settings):python3 diy-tax-return-infra/scripts/nebuladns-cloudflare-dns.py— creates Ans1/ns2(DNS only) and NSdemo→ ns1 + ns2. - Put the zone TOML on both hosts (Ansible sync) and reload.
# Ground truth — bypass Cloudflare and public recursion
dig @72.62.211.28 www.demo.nebuladns.net A +norecurse
dig @85.190.106.88 www.demo.nebuladns.net A +norecurse
# What the world sees after delegation + TTL
dig www.demo.nebuladns.net A +trace2. During a DNS / edge incident
# Is NebulaDNS up on both NS?
curl -sf http://127.0.0.1:18080/livez # on each host (admin is localhost-only)
dig @72.62.211.28 demo.nebuladns.net SOA +short
dig @85.190.106.88 demo.nebuladns.net SOA +short
# App hostname: what does Cloudflare currently return?
dig +short app.example.com A
# Failover controller (control host, not the POP under test)
curl -sf http://127.0.0.1:9119/v1/status
# Break-glass: pin to secondary / clear override — see DnsFailover.md3. Change a record fast (failover or hotfix)
- Preferred for public app names on Cloudflare: let
nebula-dns-failoverrewrite managed A/CNAME via the Cloudflare API (marker-owned records only). - On a Nebula-authoritative zone:
PUT /api/v1/zones/{zone}/recordswith a short TTL (default 5s) — see Fast record changes. Reload (systemctl reload nebuladns) reverts in-memory API writes to the zone file; the failover controller re-applies on its next tick.
4. Redeploy / roll back NS hosts
# From diy-tax-return-infra/ansible (existing cloud_workers SSH keys)
ansible-playbook nebuladns.yml -e nebuladns_version=dev-<sha>
# Or stop during a bad deploy:
ansible nebuladns_hosts -b -m systemd -a 'name=nebuladns state=stopped'Only after both NS answer SOA for nebuladns.net and you want Cloudflare as
registrar only. Switch the zone to custom nameservers ns1/ns2, then run failover with
provider.type = "nebuladns". Until then, keep the split model: Cloudflare apex +
Nebula delegated zones + Cloudflare-backed failover.
┌──────────────────────────────────────────┐
│ React Web UI (v1.0) │
│ Vite + TS + Tanstack Query │
└───────────────────┬──────────────────────┘
│ HTTPS (OpenAPI)
┌───────────────────▼──────────────────────┐
│ Control Plane API │
│ axum (REST) + tonic (gRPC) │
│ AuthN: mTLS + OIDC AuthZ: RBAC │
└───────┬──────────────────┬───────────────┘
│ │
┌──────────────▼──┐ ┌───────▼────────────┐
│ Zone Manager │ │ Propagation │
│ validate │ │ Verifier │
│ sign (DNSSEC) │ │ polls secondaries │
│ atomic commit │ │ confirms SOA │
└────────┬────────┘ └────────────────────┘
│
┌────────────────▼─────────────────────────┐
│ Zone Store (content-addressed) │
│ sled / redb / pluggable │
└────────────────┬─────────────────────────┘
│
┌────────────────▼─────────────────────────┐
│ DNS Data Plane │
│ tokio + io_uring (Linux ≥ 5.19) │
│ UDP · TCP · DoT · DoH · DoQ │
│ AXFR / IXFR / NOTIFY / TSIG │
│ DNSSEC online signer │
└──────────────────────────────────────────┘
Observability spine: tracing → OpenTelemetry → Prometheus + Loki + Tempo
| Crate | Purpose |
|---|---|
nebula-wire |
RFC 1035 wire codec. #![forbid(unsafe_code)], fuzz-tested. |
nebula-proto |
High-level DNS message types (no I/O). |
nebula-zone |
TOML zone loader + indexed in-memory zone. |
nebula-signer |
DNSSEC online signing + key management (M4). |
nebula-store |
Storage abstraction + embedded backends (M5). |
nebula-transfer |
AXFR / IXFR / NOTIFY / TSIG (M3). |
nebula-server |
DNS data plane (tokio) + nebuladns binary. |
nebula-api |
REST (axum) + gRPC (tonic) + OpenAPI. |
nebula-verify |
Propagation verifier (M6). |
nebula-metrics |
Always-on Prometheus registry. |
nebula-cli |
nebulactl admin CLI. |
nebula-mcp |
Model Context Protocol server. Exposes admin ops to Claude / any MCP client. |
nebula-dns-failover |
Health-aware DNS failover controller (probe POPs → rewrite managed A/CNAME). See crates/nebula-dns-failover/README.md. |
nebula-testutil |
Test harness + reference-peer drivers. |
Metrics are always on in production. No "enable metrics" flag exists — there is no scenario where observability competes with performance. Cardinality is bounded at compile time via enum-typed labels.
# Wire / query pipeline — live today
nebula_dns_queries_total{proto,qtype,rcode}
nebula_dns_query_duration_seconds_bucket{proto,qtype,rcode,le}
nebula_dns_dropped_total{reason}
nebula_build_info{version,commit,rustc,target} 1
# Failover controller (nebula-dns-failover, :9119) — live today
nebula_dns_failover_state{hostname} # 1 primary … 4 degraded (see HELP)
nebula_dns_failover_pop_up{pop} # 0 | 1
nebula_dns_failover_transitions_total{hostname,from,to,reason}
nebula_dns_failover_dns_apply_total{hostname,result}
nebula_dns_failover_probe_latency_milliseconds{pop}
nebula_dns_failover_last_success_unixtime
# Transfers (M3)
nebula_axfr_attempts_total{peer,zone,direction,result}
nebula_axfr_last_success_timestamp_seconds{peer,zone}
nebula_peer_version_info{peer,software,version} # the 1326 signal
# Propagation verifier (M6)
nebula_zone_propagation_converged{zone} # 0 | 1
# Runtime (planned)
nebula_process_resident_memory_bytes
nebula_memory_hot_path_allocations_total # MUST stay at 0
NebulaDNS Overview (uid nebuladns-overview) covers every live metric above:
- Overview: instances up/down, query rate, p99, SERVFAIL / NXDOMAIN / REFUSED ratios, drops.
- Traffic: by protocol, query type, rcode and instance.
- Latency: percentiles, heatmap, p99 by query type and by instance.
- Errors and drops: non-NOERROR responses, drops by reason.
- Fleet: per-instance table (version, up, load, p99, error ratio) and scrape health.
- Failover controller: serving state, POP health, probe latency, state changes, apply results, transition history.
Variables (data source, job, instance, protocol, query type, rcode, failover job, hostname, POP) drive every panel. Annotations mark restarts and failover state changes.
- Docker Compose: provisioned automatically at http://localhost:3000.
- Kubernetes:
--set grafanaDashboard.enabled=truerenders a ConfigMap for the Grafana sidecar (kube-prometheus-stack). - Source of truth:
deploy/docker/compose/grafana/dashboards/overview.json. The chart ships a copy indeploy/helm/nebuladns/dashboards/; CI fails if they drift.
deploy/prometheus/nebuladns.rules.yml holds 14 alerts. Each one links a runbook in
docs/runbooks/.
| Area | Alerts |
|---|---|
| Availability | NebulaDNSDown (page), NebulaDNSAllInstancesDown (page), NebulaDNSTargetsMissing, NebulaDNSVersionDrift |
| Answers | NebulaDNSServfailRatioHigh (page), NebulaDNSRefusedRatioHigh, NebulaDNSLatencyHigh, NebulaDNSDropsHigh |
| Failover | NebulaDNSFailoverControllerDown (page), NebulaDNSFailoverApplyFailing (page), NebulaDNSFailoverBothPopsDown (page), NebulaDNSFailoverPopDown, NebulaDNSFailoverFlapping, NebulaDNSFailoverManualOverrideStale |
Alerts not marked page are tickets.
- Plain Prometheus (VM hosts): add the file under
rule_files:. Scrape jobs neednebuladnsanddns-failoverin their names. - Docker Compose: loaded automatically; see http://localhost:9091/alerts.
- Kubernetes:
--set prometheusRule.enabled=true. The chart embeds a copy, and CI fails if it drifts. - Tests:
promtoolunit tests indeploy/prometheus/tests/run in CI. - Slack routing:
deploy/alertmanager/sends pages (with@here) and tickets to separate Slack channels. It's included in the Compose rig.
- systemd unit:
deploy/systemd/nebuladns.service—Type=notify, watchdog 30 s,ProtectSystem=strict,AmbientCapabilities=CAP_NET_BIND_SERVICE, seccomp filter,MemoryDenyWriteExecute=true. - Graceful shutdown: SIGTERM/SIGINT drains connections, signals
STOPPING=1, lets in-flight requests finish, then exits cleanly. - Zone reload:
systemctl reload nebuladns(SIGHUP) re-reads the[[zones]]list and zone files and swaps them in atomically, with no dropped queries. A bad zone file is logged and the previous zones keep serving. Binds, logging and API settings still need a restart. Record-API writes are in-memory only, so a reload reverts them;nebula-dns-failoverre-applies its overrides on its next tick. - Rollback: atomic binary symlink swap — MTTR ≈ seconds per host.
- Zero-downtime restart: supported via
nebuladns.socket(socket activation).
- Memory-safe by default:
#![forbid(unsafe_code)]in every crate. Exceptions require a written justification and a fuzz harness. - Continuous fuzzing: 4
cargo-fuzztargets (header / question / name / message) run for 60 s on every PR; ClusterFuzzLite / OSS-Fuzz integration tracked for post-GA. - Supply chain:
cargo denyin CI with a whitelist of licenses (Apache-2.0, MIT, BSD-, ISC, Unicode-, CC0, Zlib, MPL-2.0).cargo auditfor advisory database checks. - Signed artifacts (planned): SBOMs (CycloneDX + SPDX) + cosign keyless via GitHub OIDC + SLSA-3 provenance on every release.
- Reporting: see
SECURITY.md— GPG key + 90-day coordinated disclosure.
nebuladns/
├── crates/ # 11 Cargo crates (see "Crate layout" above)
├── config/ # example + compose + demo configs
├── zones/ # example zone files
├── deploy/
│ ├── docker/ # Dockerfile + compose rig + Grafana dashboards
│ ├── helm/nebuladns/ # Helm chart (StatefulSet, Service, PDB, tests/)
│ ├── systemd/ # hardened .service + .socket + sysusers/tmpfiles
│ ├── ansible/ # role + playbook + environments + rollback
│ ├── prometheus/ # alerting rules (template in M6)
│ ├── grafana/ # upstream dashboards (template in M6)
│ └── terraform/ # Terraform module (placeholder)
├── docs/
│ ├── architecture.md # one-page system view
│ ├── decisions/ # ADRs — toolchain, storage, consensus
│ └── runbooks/ # one per alert
├── ui/ # React dashboard (M9)
├── xtask/ # dev-loop tasks (`cargo xtask ci|fmt|clippy|fuzz|bench`)
├── .github/workflows/ # ci, deploy, test-docker, test-k3s
├── index.html # landing page (open directly in a browser)
├── PROJECT_PROMPT.md # full planning prompt — the source of truth for intent
└── README.md # you are here
- Every feature ships with a test, a metric, and a doc section. No exceptions.
- API-first: every operator-visible behaviour must be an API call before any CLI or UI work merges.
- Every Prometheus alert has a runbook in
docs/runbooks/. CI enforces the link. - Every non-obvious design choice needs an ADR in
docs/decisions/.
Dev loop:
cargo xtask ci # fmt + clippy + tests + deny
cargo xtask fuzz wire # smoke-fuzz the wire codec
cargo xtask bench # criterion benchmarksConventional Commits required (feat(scope): …, fix(scope): …). See
CONTRIBUTING.md for the full guide.
Apache-2.0. See also SECURITY.md,
CODE_OF_CONDUCT.md, and
PROJECT_PROMPT.md (the planning document that serves as the
canonical source of design intent).
Incidents 1273 and 1326 cannot recur in the same form. That's the bar.