commit 473855e59a34302a7081e34ab9d9bb88b2f27617 Author: root Date: Sun May 24 18:21:04 2026 +0100 docs: README, changelog, contributing, security, deployment, operations, CI diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..165ea46 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,48 @@ +name: build + +on: + push: + branches: + - main + tags: + - 'v*' + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Forgejo registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: git.ankh-morpork.discworld.network + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: git.ankh-morpork.discworld.network/laurence/dns-traefik-sync + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix=sha- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7929413 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.pyo +*.keytab +*.ccache +/tmp/ +.env diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6418b40 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable changes to this project will be documented here. + +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [1.0.0] - 2026-05-24 + +### Added + +- Initial release +- Poll Traefik API for docker-provider routers with Host() rules +- Filter hostnames to configured DNS zone +- Create and remove A records via GSS-TSIG authenticated RFC 2136 dynamic DNS update +- Kerberos authentication using keytab (no passwords, no SSH) +- State tracking across poll cycles to minimise DNS writes +- Docker secret support for keytab +- Configurable via environment variables +- Debian bookworm-slim base image +- Runs as nobody diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..98328f8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing + +## Development setup + +Clone the repo and set up a venv: + + git clone https://git.ankh-morpork.discworld.network/laurence/dns-traefik-sync.git + cd dns-traefik-sync + python3 -m venv venv + source venv/bin/activate + +No external Python dependencies - stdlib only by design. + +## Testing locally + +Set environment variables to point at a test Traefik instance and DNS zone, then run: + + python3 dns_sync.py + +You will need a valid Kerberos ticket or keytab. For dry-run testing without a real Samba DC, +set DNS_SERVER to a non-existent host and observe that nsupdate errors are caught and logged cleanly. + +## Building the image + + docker build -t dns-traefik-sync:dev . + +## Releases + +Tag with vMAJOR.MINOR.PATCH - the Gitea Actions workflow will build and push automatically: + + git tag v1.0.1 + git push origin v1.0.1 + +Update CHANGELOG.md before tagging. + +## Code style + +- stdlib only, no third-party dependencies +- All configuration via environment variables +- Errors caught per-hostname, never crash the whole sync cycle +- Log every DNS write at INFO level diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fdb2e7f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM debian:bookworm-slim + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + python3 \ + krb5-user \ + dnsutils \ + ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY dns_sync.py . +COPY krb5.conf /etc/krb5.conf +RUN chmod +x dns_sync.py + +USER nobody +ENTRYPOINT ["python3", "/app/dns_sync.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..eab3ddf --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# dns-traefik-sync + +Automatic DNS registration for Traefik-managed containers using Kerberos-authenticated +RFC 2136 dynamic updates against a Samba Active Directory DNS server. + +Polls the Traefik API, extracts Host() rules from docker-provider routers, and +creates/removes A records in Samba AD DNS using GSS-TSIG authentication. +No SSH. No passwords. No broad privilege. + +## How it works + + Traefik API (/api/http/routers) + └── docker provider routers with Host() rules + └── filtered to target zone + └── diff against last known state + └── nsupdate -g (GSS-TSIG via Kerberos keytab) + └── Samba AD DNS (vetinari) + +On each poll cycle: + +- Fetches all routers from the Traefik API +- Filters to docker provider only, extracts hostnames matching the target zone +- Compares against state from the previous cycle +- Adds A records for new hostnames, removes A records for gone hostnames +- Renews the Kerberos ticket before each cycle + +## Requirements + +- Traefik with API enabled +- Samba AD DC with a service account in DnsAdmins +- Keytab for the service account stored as a Docker secret +- Docker Swarm mode (for secrets) + +## Configuration + +All configuration via environment variables: + +| Variable | Default | Description | +|------------------|------------------------------------|------------------------------------| +| TRAEFIK_API | http://192.168.88.2:8080 | Traefik API base URL | +| DNS_SERVER | 192.168.88.1 | Samba AD DNS server IP | +| DNS_ZONE | ankh-morpork.discworld.network | Zone to manage records in | +| TARGET_IP | 192.168.88.2 | IP address for created A records | +| TTL | 300 | TTL for created records (seconds) | +| POLL_INTERVAL | 60 | Poll interval (seconds) | +| KEYTAB | /run/secrets/dns-updater-keytab | Path to Kerberos keytab | +| KRB_PRINCIPAL | dns-updater@DISCWORLD.NETWORK | Kerberos principal | +| STATE_FILE | /tmp/dns_state.json | State persistence path | + +## Deployment + +See docs/deployment.md for full setup instructions. +See docs/operations.md for day-to-day operational guidance. + +## Security model + +- Service account scoped to DnsAdmins only +- Keytab stored as a Docker secret, never on disk +- GSS-TSIG: Kerberos mutual authentication, updates signed and verified by the KDC +- No SSH, no HTTP webhooks, no plaintext credentials +- Container runs as nobody + +## Limitations + +- A records only (no CNAME, no AAAA) +- Single target IP (all records point to the same host) +- Single zone +- On restart, re-syncs from Traefik - safe and idempotent + +## Changelog + +See CHANGELOG.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5b0d068 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,22 @@ +# Security + +## Reporting + +Raise a private issue in Forgejo or contact the repo owner directly. + +## Credentials + +- Never commit keytabs, .ccache files, or .env files - all are in .gitignore +- The keytab must be stored as a Docker secret only +- The service account (dns-updater) should have no other privileges beyond DnsAdmins +- Rotate the keytab periodically - see docs/operations.md + +## Threat model + +| Threat | Mitigation | +|---|---| +| Keytab compromise | Scoped to DnsAdmins only; rotate via samba-tool user setpassword | +| Container escape | Runs as nobody; no capabilities; no host mounts beyond the secret | +| DNS spoofing | GSS-TSIG: updates are Kerberos-signed and verified by the KDC | +| Unauthorised DNS writes | Only the dns-updater principal can authenticate with this keytab | +| Traefik API abuse | API is read-only; no writes to Traefik from this service | diff --git a/dns_sync.py b/dns_sync.py new file mode 100644 index 0000000..30e5ec5 --- /dev/null +++ b/dns_sync.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 + +import time +import re +import subprocess +import logging +import os +import json +from pathlib import Path + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s %(levelname)s %(message)s' +) +log = logging.getLogger(__name__) + +TRAEFIK_API = os.environ.get('TRAEFIK_API', 'http://192.168.88.2:8080') +DNS_SERVER = os.environ.get('DNS_SERVER', '192.168.88.1') +DNS_ZONE = os.environ.get('DNS_ZONE', 'ankh-morpork.discworld.network') +TARGET_IP = os.environ.get('TARGET_IP', '192.168.88.2') +TTL = int(os.environ.get('TTL', '300')) +POLL_INTERVAL = int(os.environ.get('POLL_INTERVAL', '60')) +KEYTAB = os.environ.get('KEYTAB', '/run/secrets/dns-updater-keytab') +KRB_PRINCIPAL = os.environ.get('KRB_PRINCIPAL', 'dns-updater@DISCWORLD.NETWORK') +STATE_FILE = os.environ.get('STATE_FILE', '/tmp/dns_state.json') + +def kinit(): + result = subprocess.run( + ['kinit', '-kt', KEYTAB, KRB_PRINCIPAL], + capture_output=True, text=True + ) + if result.returncode != 0: + raise RuntimeError(f"kinit failed: {result.stderr}") + log.info("Kerberos ticket obtained") + +def get_traefik_hostnames(): + import urllib.request + import urllib.error + try: + with urllib.request.urlopen(f"{TRAEFIK_API}/api/http/routers", timeout=10) as r: + routers = json.loads(r.read()) + except urllib.error.URLError as e: + log.error(f"Failed to reach Traefik API: {e}") + return set() + + hostnames = set() + for router in routers: + if router.get('provider') != 'docker': + continue + rule = router.get('rule', '') + for match in re.finditer(r'Host\(`([^`]+)`\)', rule): + host = match.group(1) + if host.endswith(DNS_ZONE): + hostnames.add(host) + return hostnames + +def nsupdate(commands): + input_data = f"gss-tsig\n{commands}\n" + result = subprocess.run( + ['nsupdate', '-g'], + input=input_data, capture_output=True, text=True + ) + if result.returncode != 0: + raise RuntimeError(f"nsupdate failed: {result.stderr}") + +def add_record(hostname): + log.info(f"Adding {hostname} -> {TARGET_IP}") + nsupdate(f"""server {DNS_SERVER} +zone {DNS_ZONE} +update add {hostname}. {TTL} A {TARGET_IP} +send""") + +def remove_record(hostname): + log.info(f"Removing {hostname}") + nsupdate(f"""server {DNS_SERVER} +zone {DNS_ZONE} +update delete {hostname}. A +send""") + +def load_state(): + try: + return set(json.loads(Path(STATE_FILE).read_text())) + except Exception: + return set() + +def save_state(hostnames): + Path(STATE_FILE).write_text(json.dumps(list(hostnames))) + +def sync(): + kinit() + current = get_traefik_hostnames() + previous = load_state() + + to_add = current - previous + to_remove = previous - current + + for h in to_add: + try: + add_record(h) + except Exception as e: + log.error(f"Failed to add {h}: {e}") + current.discard(h) + + for h in to_remove: + try: + remove_record(h) + except Exception as e: + log.error(f"Failed to remove {h}: {e}") + + save_state(current) + +if __name__ == '__main__': + log.info("dns-traefik-sync starting") + while True: + try: + sync() + except Exception as e: + log.error(f"Sync failed: {e}") + time.sleep(POLL_INTERVAL) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..67be072 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +version: "3.8" + +services: + dns-traefik-sync: + image: git.ankh-morpork.discworld.network/laurence/dns-traefik-sync:latest + environment: + TRAEFIK_API: "http://192.168.88.2:8080" + DNS_SERVER: "192.168.88.1" + DNS_ZONE: "ankh-morpork.discworld.network" + TARGET_IP: "192.168.88.2" + TTL: "300" + POLL_INTERVAL: "60" + KEYTAB: "/run/secrets/dns-updater-keytab" + KRB_PRINCIPAL: "dns-updater@DISCWORLD.NETWORK" + secrets: + - dns-updater-keytab + restart: unless-stopped + +secrets: + dns-updater-keytab: + external: true diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..ec35f53 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,89 @@ +# Deployment + +## Prerequisites + +- Samba AD DC +- Docker host joined to the AD domain +- Docker Swarm mode enabled +- Traefik running with API enabled + +--- + +## 1. Samba AD: Create service account and keytab + +On the Samba DC: + + sudo samba-tool user create dns-updater --random-password + sudo samba-tool group addmembers DnsAdmins dns-updater + sudo samba-tool domain exportkeytab /etc/samba/dns-updater.keytab \ + --principal=dns-updater@DISCWORLD.NETWORK + sudo kinit -kt /etc/samba/dns-updater.keytab dns-updater@DISCWORLD.NETWORK + klist + +--- + +## 2. Join the Docker host to the domain + + sudo apt install sssd-ad sssd-tools realmd adcli krb5-user \ + packagekit samba-common-bin libnss-sss libpam-sss + sudo realm join discworld.network -U administrator + sudo realm list + +--- + +## 3. Transfer keytab and create Docker secret + +On the DC make the keytab readable temporarily: + + sudo cp /etc/samba/dns-updater.keytab /tmp/dns-updater.keytab + sudo chown : /tmp/dns-updater.keytab + +On your local machine: + + scp @:/tmp/dns-updater.keytab . + scp dns-updater.keytab @:/tmp/ + shred -u dns-updater.keytab + +On the DC clean up: + + sudo shred -u /tmp/dns-updater.keytab + +On the Docker host: + + sudo docker secret create dns-updater-keytab /tmp/dns-updater.keytab + sudo shred -u /tmp/dns-updater.keytab + sudo docker secret ls + +--- + +## 4. Enable Docker Swarm + + sudo docker swarm init --advertise-addr + +--- + +## 5. Build and push the image + + git clone https://git.ankh-morpork.discworld.network/laurence/dns-traefik-sync.git + cd dns-traefik-sync + sudo docker build -t git.ankh-morpork.discworld.network/laurence/dns-traefik-sync:latest . + sudo docker login git.ankh-morpork.discworld.network + sudo docker push git.ankh-morpork.discworld.network/laurence/dns-traefik-sync:latest + +--- + +## 6. Deploy the stack + + sudo docker stack deploy -c docker-compose.yml dns-traefik-sync + sudo docker stack ps dns-traefik-sync + sudo docker service logs dns-traefik-sync_dns-traefik-sync + +--- + +## 7. Verify DNS records are being created + + dig @192.168.88.1 adguard.ankh-morpork.discworld.network + +Or on the DC: + + sudo samba-tool dns query 127.0.0.1 ankh-morpork.discworld.network @ ALL -U administrator diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..9b19bb0 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,78 @@ +# Operations + +## Checking service status + + sudo docker service ls + sudo docker stack ps dns-traefik-sync + sudo docker service logs dns-traefik-sync_dns-traefik-sync --follow + +## What healthy logs look like + + 2026-05-24 18:00:00 INFO dns-traefik-sync starting + 2026-05-24 18:00:00 INFO Kerberos ticket obtained + 2026-05-24 18:00:00 INFO Adding adguard.ankh-morpork.discworld.network -> 192.168.88.2 + 2026-05-24 18:01:00 INFO Kerberos ticket obtained + 2026-05-24 18:01:00 INFO No changes + +## Manually verifying DNS records + + dig @192.168.88.1 grafana.ankh-morpork.discworld.network A + sudo samba-tool dns query 127.0.0.1 ankh-morpork.discworld.network @ ALL -U administrator + +## Forcing a re-sync + + sudo docker service update --force dns-traefik-sync_dns-traefik-sync + +## Updating the image + + cd dns-traefik-sync + git pull + sudo docker build -t git.ankh-morpork.discworld.network/laurence/dns-traefik-sync:latest . + sudo docker push git.ankh-morpork.discworld.network/laurence/dns-traefik-sync:latest + sudo docker service update --image \ + git.ankh-morpork.discworld.network/laurence/dns-traefik-sync:latest \ + dns-traefik-sync_dns-traefik-sync + +## Keytab rotation + +On the Samba DC: + + sudo samba-tool user setpassword dns-updater --random-password + sudo samba-tool domain exportkeytab /etc/samba/dns-updater.keytab \ + --principal=dns-updater@DISCWORLD.NETWORK + +Update the Docker secret: + + sudo docker secret rm dns-updater-keytab + sudo docker secret create dns-updater-keytab /tmp/dns-updater.keytab + sudo shred -u /tmp/dns-updater.keytab + sudo docker stack deploy -c docker-compose.yml dns-traefik-sync + +## Removing a DNS record manually + + sudo samba-tool dns delete 127.0.0.1 ankh-morpork.discworld.network \ + A 192.168.88.2 -U administrator + +## Troubleshooting + +### kinit fails - no suitable keys + +Keytab is stale. Re-export on the DC: + + sudo samba-tool domain exportkeytab /etc/samba/dns-updater.keytab \ + --principal=dns-updater@DISCWORLD.NETWORK + +### nsupdate fails with REFUSED + +dns-updater not in DnsAdmins: + + sudo samba-tool group addmembers DnsAdmins dns-updater + +### nsupdate fails with NOTAUTH + +Wrong DNS_ZONE or DNS_SERVER environment variable. + +### Records not being created for a container + +Container must have a Host() rule in its Traefik label matching DNS_ZONE. +Internal and non-docker providers are ignored. diff --git a/krb5.conf b/krb5.conf new file mode 100644 index 0000000..c718ef4 --- /dev/null +++ b/krb5.conf @@ -0,0 +1,14 @@ +[libdefaults] + default_realm = DISCWORLD.NETWORK + dns_lookup_realm = false + dns_lookup_kdc = true + +[realms] + DISCWORLD.NETWORK = { + kdc = 192.168.88.1 + admin_server = 192.168.88.1 + } + +[domain_realm] + .discworld.network = DISCWORLD.NETWORK + discworld.network = DISCWORLD.NETWORK