No description
  • Python 54.7%
  • Shell 39.4%
  • Makefile 5.9%
Find a file
Josh Quinlan 9da5b29c98 Publish live staging DNS to ClouDNS + fix octoDNS publish path
- zones: replace placeholder IPs with the live staging infra; add the staging.*
  record set (account/imap/smtp/jmap/pop3/webmail/autoconfig/mta-sts.staging,
  mxN.staging, node-N.staging, idp-1.staging, admin/api/ca.mail.staging), real
  ed25519 DKIM, SPF/DMARC/TLS-RPT/SRV; drop apex NS (ClouDNS owns delegation)
  and placeholder AAAA (no v6 ingress). _mta-sts deferred (enforce mode).
- config.yaml/config.ci.yaml: octodns-cloudns 0.0.16 takes a numeric sub-auth-id
  via auth_id + sub_auth:true (no sub_auth_user kwarg); ownership txt_name must
  be a single label (_owner) — a dotted value silently filtered every real
  record out of the plan
- roles/octodns: pass the CLOUDNS_* env to the validate step too (prod config
  instantiates the ClouDNS provider)
- ptr-manifest: real staging node IPs -> mxN.staging.albient.cloud + idp-1
2026-06-24 17:20:34 +01:00
.github/workflows docs: record contract, README, architecture, runbooks + CI 2026-06-23 21:36:46 +01:00
artifacts/dns feat: ansible wrapper, ingest/acme/audit scripts, make targets 2026-06-23 21:32:15 +01:00
config Publish live staging DNS to ClouDNS + fix octoDNS publish path 2026-06-24 17:20:34 +01:00
docs fix(dns): use one canonical internal-CA name (ca.mail.albient.cloud) 2026-06-24 13:07:06 +01:00
inventories chore(secrets): add encrypted per-env secret bundles (dev/staging/prod) 2026-06-24 12:52:38 +01:00
playbooks feat: ansible wrapper, ingest/acme/audit scripts, make targets 2026-06-23 21:32:15 +01:00
roles/octodns Publish live staging DNS to ClouDNS + fix octoDNS publish path 2026-06-24 17:20:34 +01:00
scripts fix(scripts): verify prints success instead of aborting on no placeholders 2026-06-24 01:01:03 +01:00
zones Publish live staging DNS to ClouDNS + fix octoDNS publish path 2026-06-24 17:20:34 +01:00
.gitignore feat: scaffold ansible-dns with octoDNS config and worked zones 2026-06-23 21:28:07 +01:00
.pre-commit-config.yaml feat: scaffold ansible-dns with octoDNS config and worked zones 2026-06-23 21:28:07 +01:00
.sops.yaml chore(secrets): set the real age recipient (replaces placeholders) 2026-06-24 12:52:37 +01:00
.yamllint chore(dns): yamllint-ignore encrypted secrets.sops.yaml bundles 2026-06-24 00:53:32 +01:00
ansible.cfg chore: modern stdout callback (drop removed community.general.yaml) 2026-06-24 14:01:33 +01:00
Makefile feat: ansible wrapper, ingest/acme/audit scripts, make targets 2026-06-23 21:32:15 +01:00
ptr-manifest.yaml Publish live staging DNS to ClouDNS + fix octoDNS publish path 2026-06-24 17:20:34 +01:00
README.md feat: reconcile zones + contract to the two-domain split 2026-06-23 22:17:43 +01:00
requirements.txt feat: scaffold ansible-dns with octoDNS config and worked zones 2026-06-23 21:28:07 +01:00
requirements.yml feat: scaffold ansible-dns with octoDNS config and worked zones 2026-06-23 21:28:07 +01:00

ansible-dns

DNS-as-code for Albient — a UK hosting provider that prioritises privacy, data sovereignty and open source. This repo manages all of Albient's DNS (infrastructure zones + customer mail domains) declaratively with octoDNS, publishing to ClouDNS, orchestrated and gated by Ansible.

octoDNS is the source of truth. ClouDNS is just a publish target — isolated to a single provider block so it can be swapped (Route53, PowerDNS, Knot, deSEC, …) without touching any zone data.


Principles

  • Dry-run first. Every apply is preceded by a visible plan. octodns-sync runs without --doit by default; applying requires an explicit, double-confirmed gate.
  • Idempotent & reviewable. Zones are plain YAML; the git diff is the change record. CI posts the computed DNS diff on every PR.
  • Sovereign / open-source. No proprietary control plane; provider-specific bits are isolated and replaceable.
  • Secrets never in plaintext. ClouDNS credentials live SOPS-encrypted (age), decrypted only at runtime and exported as env vars.

The seam with ansible-mailstack

The sibling albient.mailstack collection deploys the Stalwart-based HA mail platform. It does not manage DNS — it emits the DNS records the mail platform needs as a YAML artifact. This repo consumes that artifact and publishes it.

ansible-mailstack  ──emit──►  artifacts/dns/<env>-mailstack-records.yml
                                        │  scripts/ingest_contract.py (idempotent merge)
                                        ▼
ansible-dns  zones/*.yaml  ──►  octoDNS  ──►  ClouDNS

The full schema is in docs/record-contract.md (reconciled with the mailstack's docs/dns-contract.md, v1). It covers MX, SPF, DKIM (with rotation selectors), DMARC, MTA-STS, TLS-RPT, autoconfig/autodiscover + the full SRV set, CAA, optional DANE/TLSA, ACME DNS-01 challenge handling, and PTR/rDNS auditing.

Layout

config/
  config.yaml        octoDNS: ClouDNS target (prod) + no-op dump + processors
  config.ci.yaml     same zones, no-op target ONLY (no creds) — used by CI
zones/
  albient.cloud.yaml   infra zone (mx1-3, mail floating IP, mta-sts, egress…)
  example.com.yaml     worked customer mail zone — every contract record
roles/octodns/        venv install, SOPS decrypt, validate, gated octodns-sync
playbooks/
  sync.yml           dry-run (default) / gated apply
  ingest.yml         merge the mailstack contract into zones/
scripts/
  ingest_contract.py idempotent contract → zones/ + ptr-manifest merge
  acme_dns01.py      ClouDNS DNS-01 present/cleanup hook (ephemeral)
  audit_rdns.py      live PTR vs ptr-manifest.yaml drift audit
inventories/{prod,staging,dev}/   localhost + per-env SOPS secrets
ptr-manifest.yaml     authoritative expected-PTR list (rDNS lives at Hetzner)
docs/                 record-contract, architecture, runbooks, samples

Quick start

make venv                 # build the isolated octoDNS venv
make lint                 # yamllint
make validate             # octodns-validate (no network)
make plan                 # dry-run plan against the no-op target (CI-safe, no creds)

Dry-run vs apply

Default is always dry-run. Two equivalent paths:

# octoDNS directly
make plan-prod                          # dry-run against ClouDNS (needs CLOUDNS_* env)
make apply ENV=prod CONFIRM=1           # APPLY (octodns-sync --doit)

# Ansible wrapper (recommended — handles SOPS + the apply interlock)
ansible-playbook playbooks/sync.yml -e octodns_environment=prod          # DRY-RUN
ansible-playbook playbooks/sync.yml \
  -e octodns_environment=prod -e octodns_apply=true -e octodns_apply_confirm_env=prod

The role refuses to apply unless both octodns_apply=true and octodns_apply_confirm_env equals the active environment — a typo-proof interlock against changing the wrong environment. Staging uses config.ci.yaml (no-op target, no creds).

Add / update a customer mail domain

  1. ansible-mailstack emits artifacts/dns/<env>-mailstack-records.yml.
  2. make ingest ENV=prod (or ansible-playbook playbooks/ingest.yml) merges it into zones/<domain>.yaml — idempotent, no hand-editing.
  3. Review the zones/ git diff, open a PR; CI shows the DNS plan.
  4. Merge, then make apply ENV=prod CONFIRM=1 (or the gated playbook).

See docs/add-a-domain.md.

Secrets (SOPS + age)

ClouDNS auth-id / auth-password (and optional sub-auth-user) live in inventories/<env>/group_vars/all/secrets.sops.yaml, SOPS-encrypted with age. The .example files show the plaintext shape. Decrypted only at runtime by the octodns role; never logged. Key handling/rotation: docs/runbooks/secret-rotation.md.

pre-commit runs gitleaks, detect-private-key, and a local sops-encrypted-check that blocks committing any *.sops.yaml that isn't encrypted.

CI/CD

.github/workflows/ci.yml on every PR: yamllintoctodns-validateoctodns-sync dry-run against the no-op target → posts the DNS diff as a PR comment, plus an idempotency check of the ingest script and a full pre-commit run. No ClouDNS credentials and no live API calls in CI.

ClouDNS specifics, DNSSEC & gotchas

See docs/architecture.md for TTLs, ALIAS/ANAME at apex, API rate limits, DNSSEC (provider-managed DS), and how to swap providers.

Status

Area State
octoDNS config (prod + CI), processors implemented, validated
Infra zone + worked customer zone implemented, validated
Ansible role + sync/ingest playbooks implemented
ingest_contract.py (idempotent, contract v1) implemented, tested
acme_dns01.py, audit_rdns.py implemented (no live calls without creds)
CI, pre-commit, SOPS rules implemented (placeholder age recipients)
Live ClouDNS apply / real credentials not done — no creds yet, by design