fix(chrony): allow IPv6 clients -- the allow list was IPv4-only #471

Merged
k.renner merged 1 commits from chrony-allow-ipv6 into master 2026-08-07 17:35:08 +00:00
Member

chrony_allowed_networks contained only 10.2.0.0/16, so the rendered chrony.conf permitted IPv4 clients and nothing else.

IPv6 is the primary address family here — ntp.srv.renner.uno resolves to fd40::123 — so an IPv4-only allow list refuses every client that uses the documented name.

This was live but invisible

The hosts carried allow fd00::/8 in chrony.conf for a long time without it ever being in this repository. Applying the role on 2026-08-07 rendered the template over the live config and dropped the line on all four NTP servers at once, taking internal NTP down over IPv6 until it was added here.

Same class of untracked host state as the anycast service address (#469, #470), and the same failure: config that exists only on the box survives right up until something re-renders it. Reading the drifted file was not enough — the rest of it needed diffing against what the template would produce, and I didn't do that.

Confirmed from the backup the role itself kept:

# /etc/chrony/chrony.conf.613631.2026-08-07@19:19:45~
16:allow 10.2.0.0/16
17:allow fd00::/8          <- present before, gone after

The play did not catch it

install-ntp-ha.yml reported the v6 master as "holds the IPv6 service address ... it is the v6 MASTER and is serving ntp.srv.renner.uno" throughout the outage. Address ownership was correct; the service was refusing clients.

It was caught by querying the service instead:

chronyd -Q "server ntp.srv.renner.uno iburst"
  -> No suitable source for synchronisation

and after the fix:

chronyd -Q "server ntp.srv.renner.uno iburst"
  -> System clock wrong by 0.001999 seconds (ignored)

with a control query against ptbtime1.ptb.de first, to establish that the method rather than the target was sound.

fe80::/10

Included as well: a client on the same segment may source its query from a link-local address, and without it chrony drops the request — which presents as the server being unreachable rather than as a policy refusal.

State

Already applied to all four NTP servers. ntp.srv.renner.uno is serving; jupiter is v6 MASTER, saturn/neptune/edge-1 are BACKUP. This PR is the code catching up with what was done to restore service.

🤖 Generated with Claude Code

`chrony_allowed_networks` contained only `10.2.0.0/16`, so the rendered `chrony.conf` permitted IPv4 clients and nothing else. IPv6 is the primary address family here — `ntp.srv.renner.uno` resolves to `fd40::123` — so an IPv4-only allow list refuses **every client that uses the documented name**. ## This was live but invisible The hosts carried `allow fd00::/8` in `chrony.conf` for a long time without it ever being in this repository. Applying the role on 2026-08-07 rendered the template over the live config and **dropped the line on all four NTP servers at once**, taking internal NTP down over IPv6 until it was added here. Same class of untracked host state as the anycast service address (#469, #470), and the same failure: config that exists only on the box survives right up until something re-renders it. Reading the drifted file was not enough — the rest of it needed diffing against what the template would produce, and I didn't do that. Confirmed from the backup the role itself kept: ``` # /etc/chrony/chrony.conf.613631.2026-08-07@19:19:45~ 16:allow 10.2.0.0/16 17:allow fd00::/8 <- present before, gone after ``` ## The play did not catch it `install-ntp-ha.yml` reported the v6 master as *"holds the IPv6 service address ... it is the v6 MASTER and is serving ntp.srv.renner.uno"* throughout the outage. Address ownership was correct; the service was refusing clients. It was caught by querying the service instead: ``` chronyd -Q "server ntp.srv.renner.uno iburst" -> No suitable source for synchronisation ``` and after the fix: ``` chronyd -Q "server ntp.srv.renner.uno iburst" -> System clock wrong by 0.001999 seconds (ignored) ``` with a control query against `ptbtime1.ptb.de` first, to establish that the method rather than the target was sound. ## fe80::/10 Included as well: a client on the same segment may source its query from a link-local address, and without it chrony drops the request — which presents as the server being unreachable rather than as a policy refusal. ## State Already applied to all four NTP servers. `ntp.srv.renner.uno` is serving; jupiter is v6 MASTER, saturn/neptune/edge-1 are BACKUP. This PR is the code catching up with what was done to restore service. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
claude-bot added 1 commit 2026-08-07 17:33:58 +00:00
fix(chrony): allow IPv6 clients -- the allow list was IPv4-only
ci/woodpecker/pr/pr-review Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
24ed2485c9
chrony_allowed_networks contained only 10.2.0.0/16, so the rendered
chrony.conf permitted IPv4 clients and nothing else. IPv6 is the primary
address family here: ntp.srv.renner.uno resolves to fd40::123, so an
IPv4-only allow list refuses every client that uses the documented name.

This was live but invisible. The hosts carried `allow fd00::/8` in
chrony.conf for a long time without it ever being in this repository.
Applying the role on 2026-08-07 rendered the template over the live config
and dropped the line on all four NTP servers at once, taking internal NTP
down over IPv6 until it was added here.

Same class of untracked host state as the anycast service address, and the
same failure: config that exists only on the box survives right up until
something re-renders it. Reading the drifted file is not enough -- the rest
of it has to be diffed against what the template would produce.

The outage was not caught by the play, which reported the v6 master as
"serving ntp.srv.renner.uno" throughout. It was caught by querying the
service:

  chronyd -Q "server ntp.srv.renner.uno iburst"
    -> No suitable source for synchronisation

and after the fix:

  chronyd -Q "server ntp.srv.renner.uno iburst"
    -> System clock wrong by 0.001999 seconds (ignored)

with a control query against ptbtime1.ptb.de to confirm the method rather
than the target was sound.

fe80::/10 is included as well: a client on the same segment may source its
query from a link-local address, and without it chrony drops the request,
which presents as the server being unreachable rather than as a policy
refusal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
k.renner merged commit eacc821222 into master 2026-08-07 17:35:08 +00:00
review-bot left a comment
Member

🤖 Automated review — model CLI default (complexity: complex), commit 24ed2485c9375da73e242dc2cbbb9b82c069a04e.

Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.


Summary

Adds fd00::/8 (ULA) and fe80::/10 (link-local) to chrony_allowed_networks in ansible/roles/chrony-ntp-server/defaults/main.yml, restoring IPv6 client access that was previously only present as untracked drift on the live hosts. The change is small, correctly scoped, and matches the one place this variable is consumed (templates/chrony.conf.j2:23-25, rendered via a plain {% for %} loop with no other transformation). No other file in the repo references chrony_allowed_networks or duplicates an IPv4-only assumption for NTP, and there's no separate firewall/nftables layer gating UDP/123 that would also need updating — the allow directive is the sole access control here. No blocking issues found.

🟡 Suggestions

  • fd00::/8 is broad — it permits any ULA-addressed host on any network, not just this fleet's addressing. The service VIP is fd40::123 (ansible/inventory/group_vars/ntp_cluster.yml:11) and clients are presumably numbered from a narrower ULA prefix (e.g. fd40::/16 or similar, based on the 10.2.0.0/16 IPv4 analog already in this list). If the actual client range is known and narrower, scoping to it would keep the allow list as tight as the IPv4 entry it sits next to, rather than opening to the entire ULA space (fc00::/7 minus fd00::/8's complement). Not a security issue in a homelab/private-network context, just an inconsistency in precision versus the existing IPv4 line.
  • The PR body's "fe80::/10" rationale (link-local queries from same-segment clients) is sound, but worth double-checking in the checkout: chrony_upstream_pools/chrony_upstream_servers sourcing and bindcmdaddress/cmdallow are IPv4/loopback-only and unaffected by this change — confirmed by reading templates/chrony.conf.j2:42-47, no action needed, just noting the blast radius is contained to the allow block as intended.

Tests

No test harness (molecule or otherwise) exists for Ansible roles anywhere in this repo, so the lack of tests here is consistent with repo convention, not a gap introduced by this PR. The change was validated operationally per the PR description (chronyd -Q against the service address before/after, on already-applied hosts), which is reasonable evidence for a config-only default change.

Recommendation

Looks reasonable — no blocking issues; comments above are optional.

🤖 **Automated review** — model `CLI default` (complexity: complex), commit `24ed2485c9375da73e242dc2cbbb9b82c069a04e`. > Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review. --- ## Summary Adds `fd00::/8` (ULA) and `fe80::/10` (link-local) to `chrony_allowed_networks` in `ansible/roles/chrony-ntp-server/defaults/main.yml`, restoring IPv6 client access that was previously only present as untracked drift on the live hosts. The change is small, correctly scoped, and matches the one place this variable is consumed (`templates/chrony.conf.j2:23-25`, rendered via a plain `{% for %}` loop with no other transformation). No other file in the repo references `chrony_allowed_networks` or duplicates an IPv4-only assumption for NTP, and there's no separate firewall/nftables layer gating UDP/123 that would also need updating — the `allow` directive is the sole access control here. No blocking issues found. ## 🟡 Suggestions - `fd00::/8` is broad — it permits any ULA-addressed host on any network, not just this fleet's addressing. The service VIP is `fd40::123` (`ansible/inventory/group_vars/ntp_cluster.yml:11`) and clients are presumably numbered from a narrower ULA prefix (e.g. `fd40::/16` or similar, based on the `10.2.0.0/16` IPv4 analog already in this list). If the actual client range is known and narrower, scoping to it would keep the allow list as tight as the IPv4 entry it sits next to, rather than opening to the entire ULA space (`fc00::/7` minus `fd00::/8`'s complement). Not a security issue in a homelab/private-network context, just an inconsistency in precision versus the existing IPv4 line. - The PR body's "fe80::/10" rationale (link-local queries from same-segment clients) is sound, but worth double-checking in the checkout: `chrony_upstream_pools`/`chrony_upstream_servers` sourcing and `bindcmdaddress`/`cmdallow` are IPv4/loopback-only and unaffected by this change — confirmed by reading `templates/chrony.conf.j2:42-47`, no action needed, just noting the blast radius is contained to the `allow` block as intended. ## Tests No test harness (molecule or otherwise) exists for Ansible roles anywhere in this repo, so the lack of tests here is consistent with repo convention, not a gap introduced by this PR. The change was validated operationally per the PR description (`chronyd -Q` against the service address before/after, on already-applied hosts), which is reasonable evidence for a config-only default change. ## Recommendation Looks reasonable — no blocking issues; comments above are optional. <!-- pr-review:sha=24ed2485c9375da73e242dc2cbbb9b82c069a04e -->
Sign in to join this conversation.