docs: README, changelog, contributing, security, deployment, operations, CI
Some checks are pending
build / build (push) Waiting to run

This commit is contained in:
root 2026-05-24 18:21:04 +01:00
commit 473855e59a
12 changed files with 551 additions and 0 deletions

View file

@ -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 }}

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
__pycache__/
*.pyc
*.pyo
*.keytab
*.ccache
/tmp/
.env

23
CHANGELOG.md Normal file
View file

@ -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

41
CONTRIBUTING.md Normal file
View file

@ -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

17
Dockerfile Normal file
View file

@ -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"]

72
README.md Normal file
View file

@ -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

22
SECURITY.md Normal file
View file

@ -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 |

119
dns_sync.py Normal file
View file

@ -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)

21
docker-compose.yml Normal file
View file

@ -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

89
docs/deployment.md Normal file
View file

@ -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 <youradmin>:<youradmin> /tmp/dns-updater.keytab
On your local machine:
scp <dcadmin>@<dc>:/tmp/dns-updater.keytab .
scp dns-updater.keytab <dockeradmin>@<dockerhost>:/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 <docker-host-ip>
---
## 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

78
docs/operations.md Normal file
View file

@ -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 \
<hostname> 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.

14
krb5.conf Normal file
View file

@ -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