NetworkManager-driven Cloudflare DNS updates for LAN hosts.
CFLAN reconciles one Cloudflare A record with a machine's active IPv4 address when a NetworkManager interface comes up. It is designed for root-owned configuration supplied from the host's root/volume area; it does not create, persist, or print credentials.
- Linux with NetworkManager dispatcher support
- Python 3.10 or later
- A Cloudflare API token limited to Zone / DNS / Edit for the target zone
- Optional: SOPS available and configured for root when using encrypted configuration
CFLAN searches these root-volume paths in order:
/cflan_vars.yaml— preferred plaintext name/cflan_sops_vars.yaml— preferred SOPS-encrypted name/vars.yaml— supported legacy alias/sops_vars.yaml— supported legacy SOPS alias
CFLAN_CONFIG can override the path for a controlled deployment. The existing /vars.yaml and /sops_vars.yaml contract remains supported; no secret is copied into another directory by the updater.
Example structure (do not commit a real token):
cf_token: "replace-with-a-Cloudflare-API-token"
cf_domain_name: "example.com"
# Optional full FQDN; defaults to <hostname>.<cf_domain_name>
# cf_record_name: "host.example.com"
# Optional; 1 means Cloudflare automatic TTL
# cf_ttl: 1
# Optional; defaults to false to avoid proxying a LAN address
# cf_proxied: falseThe existing installation model is preserved: place one configuration file beside the scripts, then run the root-only installer.
git clone https://github.com/makeitworkcloud/cflan.git
cd cflan
sudo python3 install.pyThe installer copies set_dns.py to /etc/NetworkManager/dispatcher.d/set_dns with mode 0700. It copies the first configuration filename it finds in the priority listed above to its matching root-volume path with mode 0600.
A Cloudflare DNS record containing an RFC1918 address is useful only for clients that can route to that LAN. CFLAN does not make a private address reachable from the public Internet.
set_dns.py --dry-run [--config PATH] is a non-mutating preflight check. It validates root-volume configuration selection and parsing, the resolved local IPv4 address, the dispatcher positional arguments (interface/action) when present, and the derived FQDN, then prints the intended reconciliation and exits successfully without constructing a Cloudflare client or performing any Cloudflare API call.
A dry run never constructs a Cloudflare client or calls the Cloudflare API, so it does not prove the API token is valid. When the selected configuration is SOPS-encrypted (cflan_sops_vars.yaml or sops_vars.yaml), the dry run does invoke SOPS locally to decrypt it, so it exercises SOPS and key availability for the invoking user without writing plaintext to disk. It does not install or execute the actual NetworkManager dispatcher hook. Normal behavior is unchanged when --dry-run is omitted.
- Only NetworkManager
upevents update DNS; other dispatcher events are skipped. - The interface IPv4 address must equal the resolved primary host IPv4 address.
- Loopback, multicast, unspecified, and malformed addresses are rejected before any API call.
- Exactly one matching zone and zero or one matching A record are required. Duplicate records fail closed.
- Existing records are updated with Cloudflare PATCH rather than delete-and-recreate, preserving the record and avoiding an avoidable DNS gap.
- SOPS plaintext exists only in the updater process memory.
CFLAN uses a GitHub-Release-only CD lifecycle with an enforced changelog:
- Every pull request must update CHANGELOG.md with a reader-ready, user-facing note under
## Unreleased; a dedicated CI job fails any pull request that does not change the changelog. Whenversioninpyproject.tomlis increased, the accumulated Unreleased notes are promoted into a## [<version>] - YYYY-MM-DDsection in the same commit. - A successful
ciworkflow run for a push tomainautomatically builds the wheel and sdist and creates a GitHub Release taggedv<version>from theversionfield inpyproject.toml, attaching the built distributions and aSHA256SUMSchecksum file, when no release for that version exists yet. - The release body is exactly the
## [<version>]section ofCHANGELOG.md, extracted at release time and passed togh release create --notes-file. If that heading or its content is absent, the CD job fails closed before any tag or release is created. If a release for the tag already exists the job skips cleanly; if the tag exists without a release the job fails closed and never moves or reuses the tag for a different revision. - No PyPI publishing is performed; GitHub Releases are the only distribution channel.
- A GitHub Release records that artifacts were published for a CI-tested revision; it is not installed-host validation and does not prove installation, host, or DNS behavior.
python -m pip install '.[dev]'
python -m pytest --cov
ruff check .
ruff format --check .
mypy set_dns.py
python -m buildCI runs formatting/linting hooks, unit tests and coverage on Python 3.10–3.13, mypy, a wheel build/install smoke test, and changelog enforcement for pull requests. Unit tests do not contact Cloudflare or invoke NetworkManager.
See CONTRIBUTING.md and SECURITY.md before opening an issue or pull request.
CFLAN is licensed under GPL-3.0.