dns-update is a Go service that keeps one hostname's A and AAAA records
aligned with the host's current egress IPv4/IPv6 addresses.
The current implementation targets Cloudflare through its DNS Records API and is structured so additional providers can be added behind the same internal provider interface.
The release and deployment story is now cross-platform:
- Linux ships native
.deband.rpmpackages plus systemd units. - macOS ships release archives plus a native
launchdhelper. - Windows ships release archives plus a native Task Scheduler helper.
Linux packages also install the dns-update(1) man page; its source lives at
docs/dns-update.1.
Current GitHub Actions workflow status:
- CI:
- CodeQL:
- Dependabot Updates:
- Dependency Graph:
- Dependency Review:
- OSV Scanner:
- Nightly:
- Package Validation:
- Release:
- Scheduler Integration:
- Scorecard:
- Systemd Integration:
- zizmor:
On each run, the service:
- Fetches probe responses from:
probe.ipv4_url(defaulthttps://4.ip.omsab.net/)probe.ipv6_url(defaulthttps://6.ip.omsab.net/)
- Parses responses in
ip=...format. - Validates returned addresses by family:
- IPv4 probe must yield a valid IPv4 or
ip=none - IPv6 probe must yield a valid IPv6 or
ip=none - A probe failure aborts the run; only explicit
ip=nonemeans that record family should be absent
- IPv4 probe must yield a valid IPv4 or
- Reads the current provider-side records for
record.name. - Compares desired vs current DNS state:
- If already matching, exits without update unless
-force-pushis set. - If
-force-pushis set, reapplies the matching DNS state so the provider receives a refresh update even when the observed egress IPs have not changed. - If different, applies only the required record create/update/delete operations.
- If
-deleteis set, skips egress probing and deletes only the selected managed record families forrecord.name.
- If already matching, exits without update unless
- Re-reads provider state and verifies the final result.
- Retries transient probe and provider failures with bounded exponential backoff, jitter, and hard attempt/delay limits.
dns-update assumes it is the sole writer for the managed hostname in
record.name.
- If another controller, script, or human can update the same name concurrently, the outcome is effectively last-writer-wins between reconciliations.
- The post-apply verification step detects divergence after mutation, but it does not provide a provider-side compare-and-swap or distributed lock.
- Keep one owner for the managed hostname, even if the wider DNS zone has other automation.
The app reads JSON config with this schema:
record.name(required): FQDN.record.zone(required): FQDN.record.namemust be either this exact zone apex or a true subdomain within it.record.ttl_seconds(required): positive integer TTL for created records. For Cloudflare this must be1(automatic) or between30and86400.probe.ipv4_url(optional): defaults tohttps://4.ip.omsab.net/. Overrides must keep this host or use a loopback orlocalhosttest endpoint.probe.ipv6_url(optional): defaults tohttps://6.ip.omsab.net/. Overrides must keep this host or use a loopback orlocalhosttest endpoint.probe.timeout(optional): Go duration string, defaults to10s.probe.allow_insecure_http(optional): defaults tofalse. HTTP probe URLs are only accepted for loopback orlocalhosttest endpoints.provider.type(required): currentlycloudflare.provider.timeout(optional): Go duration string, defaults to10s.provider.cloudflare.zone_id(required): Cloudflare zone ID for the managed zone.provider.cloudflare.api_token_file(required): file containing only the Cloudflare API token.provider.cloudflare.base_url(optional): defaults tohttps://api.cloudflare.com/client/v4/. Overrides are limited to the default Cloudflare API host or loopback orlocalhosttest endpoints.provider.cloudflare.proxied(optional): whether Cloudflare should proxy the managed A/AAAA records. Defaults tofalse.
See config.example.json for a complete sample. The shipped sample shows the
full schema, but placeholder values and the token-file path should be adjusted
for the deployment that will actually run dns-update.
The app reads record, probe, and provider settings from the JSON config file. Runtime options have a separate small override surface.
Runtime settings:
-configorDNS_UPDATE_CONFIG-deleteon the command line to delete managed records instead of reconciling to observed egress IPs. Bare-deletedeletes bothAandAAAA;-delete=a,-delete=aaaa, and-delete=bothare also accepted-dry-runorDNS_UPDATE_DRY_RUN-force-pushon the command line to refresh matching records even when nothing drifted-verboseorDNS_UPDATE_VERBOSE-timeoutorDNS_UPDATE_TIMEOUTDNS_UPDATE_PROVIDER_CLOUDFLARE_API_TOKEN_FILEto override onlyprovider.cloudflare.api_token_file, which is primarily useful for systemd credentials
CLI-only introspection settings:
-validate-configloads and validates the assembled configuration, printsconfig is valid, and exits without contacting Cloudflare-print-effective-configloads and validates the assembled configuration, prints the fully assembled effective configuration as JSON, and exits without contacting Cloudflare-validate-configand-print-effective-configare mutually exclusive- Both introspection modes still validate local provider prerequisites such as the Cloudflare token-file path
Record and provider settings otherwise come from JSON config file fields.
Behavior notes:
- If
-configorDNS_UPDATE_CONFIGis set, that path is required and must exist. - If neither is set, the app first looks for
config.jsonin the current working directory, then/etc/dns-update/config.json. - Built-in defaults still apply for optional unset values such as probe URLs, timeouts, and the Cloudflare base URL.
-deleteis intentionally CLI-only. There is no config-file or environment variable equivalent for destructive record deletion.-deleteis compatible with-dry-run.-deleteand-force-pushare mutually exclusive.
- The codebase keeps the dependency surface intentionally small and prefers reviewed packages over broad frameworks.
- No inline secrets in config; store the Cloudflare API token in a separate file.
- On Unix-like systems, restrict the token file permissions (for example
chmod 600). - On Unix-like systems, keep the token file in a non-writable directory; the app rejects token paths whose parent directory is writable by group or other users.
- Windows deployments rely on NTFS ACLs instead of Unix owner/group/other mode bits for token-file directory privacy, and the app rejects token paths whose file ACL grants read/write access to other users or whose parent directory grants write access to other users.
- The token file itself must not be a symlink. Deeper configured path components are rejected if they are symlinks, and on Unix-like systems the token file is opened without following symlinks, then revalidated at read time.
- Use HTTPS probe URLs unless
probe.allow_insecure_httpis explicitly needed. - Probe URL overrides are restricted to the shipped
4.ip.omsab.netand6.ip.omsab.nethosts or loopback orlocalhosttest endpoints. - Enabling
probe.allow_insecure_httpexpands that risk further by allowing on-path tampering of probe responses, so HTTP is restricted to loopback orlocalhosttest endpoints. - Scope the Cloudflare token to the single zone being managed.
- Cloudflare record reads are filtered to the managed hostname instead of listing the full zone.
- Overriding
provider.cloudflare.base_urlchanges where the Cloudflare bearer token is sent, so the app accepts only the default Cloudflare API host or loopback orlocalhosttest endpoints. - Probe and provider HTTP clients use a fixed custom user-agent, ignore ambient
proxy environment variables, and apply bounded retries that honor
Retry-Afterwhen present.
Build and test with a patched Go toolchain. The module now requires Go 1.26.2.
Runtime dependencies are deliberately narrow:
github.com/cloudflare/cloudflare-go/v6for the Cloudflare DNS APIgolang.org/x/sync/errgroupfor structured concurrencygithub.com/google/go-cmp/cmpis used in tests only
There is no separate golang.org/x/time/rate dependency in the current build;
outbound request pacing is handled by the code in this repository.
Because the config requires provider.cloudflare.zone_id, the app does not need
to discover the zone through the Cloudflare API. For minimum privilege, create a
Cloudflare API token that is limited to the target zone and grants only DNS edit
capability for that zone.
Build the binary:
go build ./cmd/dns-updateRun one reconciliation cycle:
./dns-update -config /etc/dns-update/config.jsonOn a host that uses the packaged layout, dns-update without -config will
also pick up /etc/dns-update/config.json automatically when there is no
config.json in the current working directory.
Cap the entire run, including retries and backoff:
./dns-update -config /etc/dns-update/config.json -timeout 30sPreview planned changes without applying them:
./dns-update -config /etc/dns-update/config.json -dry-runPreview deletion of both managed address-record families without mutating DNS:
./dns-update -config /etc/dns-update/config.json -dry-run -deleteDelete only the managed IPv4 record family:
./dns-update -config /etc/dns-update/config.json -delete=aForce a refresh even when the current DNS records already match the observed egress IPs:
./dns-update -config /etc/dns-update/config.json -force-pushCombine the two flags to preview the forced update without mutating DNS:
./dns-update -config /etc/dns-update/config.json -dry-run -force-pushValidate that the assembled configuration is accepted:
./dns-update -config /etc/dns-update/config.json -validate-configPrint the effective configuration after JSON loading plus runtime overrides:
DNS_UPDATE_PROVIDER_CLOUDFLARE_API_TOKEN_FILE=/etc/dns-update/cloudflare.token \
./dns-update -config /etc/dns-update/config.json -print-effective-configIf /etc/dns-update/config.json was copied from the packaged sample without
editing provider.cloudflare.api_token_file, direct CLI runs outside the
systemd unit need either that JSON field updated to
/etc/dns-update/cloudflare.token or the environment override shown above.
The binary itself runs one reconciliation cycle per invocation. Periodic execution is handled by the native scheduler for each operating system:
- Linux: systemd service plus timer under
deploy/systemd/ - macOS:
launchdLaunchDaemonhelper underdeploy/launchd/ - Windows: Task Scheduler helper under
deploy/windows/
Each release archive also includes the deploy/ tree so the scheduler helpers
travel with the binary on non-Linux systems.
-force-push is intentionally not part of the default scheduler configuration.
Use it for explicit refresh runs when you need the provider to see an update
even though the managed records already match the current egress IPs.
-delete is also intentionally not part of the default scheduler
configuration. It is a one-shot destructive operator action, not a steady-state
reconciliation mode.
Example hardened systemd units live in deploy/systemd/.
deploy/systemd/dns-update.serviceruns one reconciliation with a locked-downDynamicUser, no ambient capabilities, a read-only filesystem view, and a private systemd credential for the Cloudflare token.deploy/systemd/dns-update.timerstarts the service immediately at boot or enable time, reruns it on five-minute clock boundaries, keeps future runs queued even if an early service start is skipped, and withPersistent=yescatches up one missed run after downtime.deploy/systemd/dns-update.envshows how to override runtime options such asDNS_UPDATE_TIMEOUTwithout editing the unit.
The service expects:
/usr/bin/dns-update/etc/dns-update/config.json/etc/dns-update/cloudflare.token
The token is mounted into the service with LoadCredential= and exposed to the
binary through
DNS_UPDATE_PROVIDER_CLOUDFLARE_API_TOKEN_FILE=%d/cloudflare.token, so the
credential never needs to be stored in the JSON config path used by the unit.
On some systems the runtime credential file may appear with a read-only mode
such as 0400 or 0440; that is expected for systemd-managed credentials and
does not require any manual chmod under /run/credentials/.
See deploy/systemd/README.md for installation steps.
Use deploy/launchd/install-launchd-job.sh to install a LaunchDaemon for
system-wide scheduled execution on macOS.
- default binary path:
/usr/local/bin/dns-update - default config path:
/usr/local/etc/dns-update/config.json - default token path:
/usr/local/etc/dns-update/cloudflare.token - default log path:
/var/log/dns-update.log
Example:
sudo ./deploy/launchd/install-launchd-job.sh \
--binary /usr/local/bin/dns-update \
--config /usr/local/etc/dns-update/config.json \
--token /usr/local/etc/dns-update/cloudflare.token \
--interval 300 \
--log /var/log/dns-update.logThe helper writes /Library/LaunchDaemons/com.dns-update.plist by default,
runs once at load with RunAtLoad, and repeats with StartInterval. See
deploy/launchd/README.md for the full install and update flow.
Use deploy/windows/register-scheduled-task.ps1 to register a recurring task
that runs dns-update as SYSTEM.
- suggested binary path:
C:\Program Files\dns-update\dns-update.exe - suggested config path:
C:\ProgramData\dns-update\config.json - suggested token path:
C:\ProgramData\dns-update\credentials\cloudflare.token - suggested log path:
C:\ProgramData\dns-update\dns-update.log
Example:
.\deploy\windows\register-scheduled-task.ps1 `
-TaskName "dns-update" `
-BinaryPath "C:\Program Files\dns-update\dns-update.exe" `
-ConfigPath "C:\ProgramData\dns-update\config.json" `
-TokenPath "C:\ProgramData\dns-update\credentials\cloudflare.token" `
-LogPath "C:\ProgramData\dns-update\dns-update.log" `
-IntervalMinutes 5The helper uses the native ScheduledTasks PowerShell API and replaces any
existing task with the same name. See deploy/windows/README.md for install
and removal details.
Native package metadata lives in:
debian/for Debian-family buildspackaging/rpm/dns-update.specfor RPM-family buildsdeploy/systemd/for the shared Linux systemd units and env file used by both manual installs and native packages
Linux package builds install:
/usr/bin/dns-update- the
dns-update(1)man page under the distro-standardman1path /etc/dns-update/dns-update.env/etc/dns-update/config.example.jsonas a shipped sample that is not loaded by default/etc/dns-update/cloudflare.token.exampleas a shipped placeholder token file- distro-standard systemd units for
dns-update.serviceanddns-update.timer
Packaged binaries are intentionally shipped without self-unpacking compression
so they remain compatible with the hardened systemd unit, including
MemoryDenyWriteExecute=yes.
Build helpers:
./packaging/build-deb.sh
./packaging/build-rpm.sh
./packaging/build-packages.shThose wrappers build and sign the default package targets:
amd64rpi32rpi64
Package artifacts are written under:
out/packages/deb/<target>/out/packages/rpm/<target>/
Each package is signed with cosign sign-blob, with a Sigstore bundle written
next to the artifact as *.sigstore.json. Package builds do not embed native
Debian or RPM repository signatures.
GitHub's Release workflow is separate from the native package scripts. It
publishes a full signed cross-platform release asset set under out/release/:
- Linux
.debpackages foramd64,arm64, andarmhf - Linux
.rpmpackages forx86_64,aarch64, andarmv7hl - Linux archive builds for
amd64,arm64, andarmv7 - macOS archive builds for
amd64andarm64 - Windows archive builds for
amd64andarm64
Each published artifact also has an adjacent *.sigstore.json bundle.
Before enabling the packaged timer, create:
/etc/dns-update/config.json/etc/dns-update/cloudflare.token
The packaged /etc/dns-update/config.example.json and
/etc/dns-update/cloudflare.token.example are there as starting points only.
The packaged systemd service overrides only
provider.cloudflare.api_token_file and reads the live token through a
credential-backed /etc/dns-update/cloudflare.token. If you copy the sample
config unchanged and want to run the binary directly outside the unit, either
update that JSON field to /etc/dns-update/cloudflare.token or export
DNS_UPDATE_PROVIDER_CLOUDFLARE_API_TOKEN_FILE=/etc/dns-update/cloudflare.token
for that command.
See packaging/README.md for package build requirements and notes.
Use ./packaging/verify-artifacts.sh ... to verify a package against its
adjacent Sigstore bundle.
macOS and Windows release archives also include native scheduler helpers under:
deploy/launchddeploy/windows
See CHANGELOG.md for public release history.
go test ./... runs the normal unit and integration suite and also enforces
repository-level quality gates:
- a coverage check that fails unless total statement coverage across
./...is exactly100.0% - a curated mutation suite that copies the repository into temporary workspaces, applies compile-preserving mutants, and requires the test suite to kill each mutant
- a generated-agent parity check that fails unless the tracked Codex, Claude,
and Gemini projections match
docs/agents/** - a public-repo hygiene check that rejects tracked detritus, local checkout, temp, or evidence paths, and banned non-public references
Regenerate the tracked agent projections with:
go run ./cmd/agentdocgenThe mutation and coverage skip environment variables are only for the nested subprocesses launched by those tests and normally should remain unset during regular use.
GitHub Actions is split into four lanes:
CIis the fast PR gate. It checks PR reviewability limits, YAML style, GitHub Actions syntax, Go formatting, module hygiene, shell syntax, shell lint, Go lint, reachable vulnerabilities,go vet,go test, andgo build ./....Package Validationbuilds the cross-platform release archives on pull requests and validates package/archive payloads onmain.Nightlyruns the expensive repository-level quality gates, longer fuzzing, and full release-artifact reproducibility checks.Releaserebuilds tagged artifacts, generates an SBOM, signs the artifacts, emits provenance and SBOM attestations, verifies the signatures and attestations, attaches the full asset set to a draft GitHub release, and only then publishes.
GitHub Actions additionally runs the dedicated Systemd Integration workflow
to validate the installed Linux timer/service flow on:
- Debian Stable
- Debian Unstable
- Ubuntu Stable
- Ubuntu Unstable
- Fedora Stable
- Fedora Unstable
GitHub Actions also runs native scheduler integration tests on main and the
daily schedule for:
- macOS
launchd - Windows Task Scheduler
Those scheduler tests validate real scheduled execution rather than only manual service starts:
- Linux waits for a later timer-fired
dns-update.servicesuccess after an initial skipped activation. - macOS runs an install-time
-validate-configpreflight and then proves a laterlaunchd-fired invocation runs without validation-only mode. - Windows runs an install-time
-ValidateConfigpreflight asSYSTEMand then proves a later Task Scheduler invocation runs without validation-only mode.
Google-style package comments live alongside the code in:
cmd/dns-updateinternal/appinternal/configinternal/egressinternal/httpclientinternal/providerinternal/provider/cloudflareinternal/retryinternal/securefile
Runnable examples are available in:
internal/configfor config loading and validationinternal/providerfor plan construction
For contribution flow and public-repo policy, see:
See:
This repository is licensed under the Apache License 2.0. See LICENSE.