From 88fcb97ad1d8263ae17a8c831676fbbe429b318e Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sun, 12 Jul 2026 22:23:18 +1000 Subject: [PATCH] dns: nsupdate host records to the authoritative server (#475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the exported-resources → puppet DNS master zone-file flow with per-host RFC2136 dynamic updates against the k8s **bind-authoritative** write endpoint (198.18.200.9). The master no longer manages zone files. ## Design Each node assembles its DNS records into a local concat file; a systemd `.path` unit watches it and runs `dns-update` (nsupdate) on change — exactly the watch-a-file model requested. ## Changes - **profiles::dns::updater** (new): concat records file + TSIG key file + `dns-update` script + `dns-update.service` (oneshot) + `dns-update.path` (watcher). The script sends only the delta since last run and deletes removed records, grouped per zone. - **profiles::dns::record**: writes a local concat fragment (`zone|name|type|ttl|value`) instead of exporting `@@concat::fragment` to the master. - **profiles::dns::base**: includes `profiles::dns::updater` (all nodes). - **hiera**: `profiles::dns::updater` server/key_name/algorithm in common.yaml. ## Inert until keyed The updater does nothing until `profiles::dns::updater::key_secret` (TSIG) is set in eyaml — records are assembled but not applied, so nodes are safe before the key exists. ## Prerequisites (k8s side, separate) 1. The `bind-authoritative` zones must set `dynamicUpdate: true` + an `updateKeyRef` (a client-update BindTSIGKey) so they accept these updates. 2. The TSIG key must be shared: the operator-generated key value goes into eyaml here (or the planned Vault-sync feature bridges it). ## Validated puppet parser/epp validate, puppet-lint, and a functional test of the generated per-zone nsupdate message (replace + delete-removed). Reviewed-on: https://git.unkin.net/unkin/puppet-prod/pulls/475 Co-authored-by: Ben Vincent Co-committed-by: Ben Vincent --- hieradata/common.eyaml | 1 + hieradata/common.yaml | 16 +++ modules/libs/lib/facter/dns_records.rb | 88 ++++++++++++ site/profiles/manifests/dns/base.pp | 5 +- site/profiles/manifests/dns/record.pp | 33 ++++- site/profiles/manifests/dns/updater.pp | 127 ++++++++++++++++++ .../templates/dns/dns-update.path.epp | 10 ++ .../templates/dns/dns-update.service.epp | 9 ++ site/profiles/templates/dns/dns-update.sh.epp | 56 ++++++++ site/profiles/templates/dns/tsig-key.epp | 5 + 10 files changed, 343 insertions(+), 7 deletions(-) create mode 100644 modules/libs/lib/facter/dns_records.rb create mode 100644 site/profiles/manifests/dns/updater.pp create mode 100644 site/profiles/templates/dns/dns-update.path.epp create mode 100644 site/profiles/templates/dns/dns-update.service.epp create mode 100644 site/profiles/templates/dns/dns-update.sh.epp create mode 100644 site/profiles/templates/dns/tsig-key.epp diff --git a/hieradata/common.eyaml b/hieradata/common.eyaml index 1742572..6b8c489 100644 --- a/hieradata/common.eyaml +++ b/hieradata/common.eyaml @@ -6,3 +6,4 @@ profiles::consul::token::node_editor::secret_id: ENC[PKCS7,MIIBmQYJKoZIhvcNAQcDo profiles::consul::server::acl_tokens_initial_management: ENC[PKCS7,MIIBmQYJKoZIhvcNAQcDoIIBijCCAYYCAQAxggEhMIIBHQIBADAFMAACAQEwDQYJKoZIhvcNAQEBBQAEggEAi1UH7AZirJ1PdxWy+KEgS5ufm0wbn2xy9rkg14hKYpcVjBa4pOZpSLMGMiiUpBIqBytDMZM4ezYa/luktpkBImJbM/TE16beGtsacQGA+9eZk2Tihs9GR2qbAQiu5lLITiDlwNnf0GeWdqHM8CTeD68DczQF320d9U14/k6pG/7z+w/MGLcjsQoSuOFTm42JVn1BI46t1CYSCHMXQc/9Tfs+FzI+vumohI8DxAYBIuyzU5HBX/MntAsvD/yixMJS1pZL9WwgqZJC/wK34rVRB39DpxWf/WROrI+WLuSJwr7WBjaeF9Ju+89WKCgsI53EWhFTj8GgDZm/jqPoE478NjBcBgkqhkiG9w0BBwEwHQYJYIZIAWUDBAEqBBAoACRzJdQKNYXZv6cghFIIgDAzB81DMcuY815nb8POtZpiA06jT/068AoZmSctHoFK/zW9tY229N5r1Tb+WHElqLk=] profiles::consul::server::acl_tokens_default: ENC[PKCS7,MIIBmQYJKoZIhvcNAQcDoIIBijCCAYYCAQAxggEhMIIBHQIBADAFMAACAQEwDQYJKoZIhvcNAQEBBQAEggEAh4Ag95xgkIZHL0gP9OLnZauih0dB1/2l9Jzw8mP3OiIv7fw23otHYONlS3Emtj7oxW8MKcZGKDCzwCT6T2p+V5wx1n15wr2J+FmL24VbclJwrMPQ4AdgP359B9h21uoyo7Zdy7RuuvLfkU1fWXbs3SeWbi2HJs1Ed1/oI1jzr3OgwMbVtbyzd1VuAXeZ9bHQG3IA8z+w/k5m61th0HTyHjw7eldQulbohDuwv545z9axHEoHKCRT2a3ZwBufV2ST6Dm3g9GERzXE9Adp9DQC5adqM74wfsujOMLK2QFJSSIOj2uCs1CpEnrNrQ8zjP3fudM2z3l7KdSHZazEamCSxTBcBgkqhkiG9w0BBwEwHQYJYIZIAWUDBAEqBBBY/Tn9tzEKYc5dxnzP2rP7gDBWKgVP3lf2T4Q0WPQt3ns0E6RUSO6OtBegb/5qDyohY2nsDeJTnMKOYzYt/J1PhnY=] profiles::consul::server::acl_tokens_replication: ENC[PKCS7,MIIBmQYJKoZIhvcNAQcDoIIBijCCAYYCAQAxggEhMIIBHQIBADAFMAACAQEwDQYJKoZIhvcNAQEBBQAEggEAEzTIxbaAR/TZYnC671aBhiahsfwf3ieyeSHpD5hQm40sMEF/fXlEDijq9i2ykPikm94074j1Uo4HNzv/V9GTf0NSC/64t61jJ/Ya3QKa5f/36+DcRj3lcETsSIyhWwmU+E+zkY8I2r68MtXAuvSoMSMZdpgWSkPx/3FFgZlrsg//bzDu69jS9cx4UK582N3A6QN4Uy/qwYtJcm+2iTQlqqgGRWGqnSgTirxhegxPbJGWTDoAEpAL4/DyF5/hqcUn6mgoSfAsHF3loPHOqN30lG+9o0THWJ9B8Gf4W/1X2UWA/avmUnqBnumGoz0p7AYdNgpW+qLl2rk4lyGYa4kvQjBcBgkqhkiG9w0BBwEwHQYJYIZIAWUDBAEqBBD9KSLH3Pn/1EIzgVJM+8VOgDDpKWzHfSVsfUMyTD0XIRPGclTdmxqCnsdhKNqmUfSjkYIf2nI9rQtSK6n42TYuO4k=] +profiles::dns::updater::key_secret: ENC[PKCS7,MIIBmQYJKoZIhvcNAQcDoIIBijCCAYYCAQAxggEhMIIBHQIBADAFMAACAQEwDQYJKoZIhvcNAQEBBQAEggEAJ4GrN3W4zGfVygfji1MeGyAlmlFN45TVP5st6b66jSKeDYNg9BwdnXahUzkmKvFIYdPWyvXqJdvuyUNz7lWr1dw/pTNHrvqUKSvI0C5TOwmtrTwXtTvucDGs7c7FZDfgUxvLs/Vrp0ZdW3C81QXWNBI8vBfVZyiegPNq+5mX7mtcrFE7jZj1g1qwpg/30lrlCguPyXqnmdtBa7VAyU2Al1UZwkrKIYSAOb9GTnvM6bk5L26pW/NXGj+ffacwf58b4AiKCak5lhoCIInVnlogPuThxULN4RF+MYAVIlgwk159CR1abjWjioxvmP7XgjQUwUuTlgoLh9fDZa0amDeFZTBcBgkqhkiG9w0BBwEwHQYJYIZIAWUDBAEqBBBEb1BTjhuTN9wGrxc2rdQVgDCctNdt72UR/Hj4wteK7XIX6fF43+7FYKBZnPtyWIWalwDniH//H3gfsJ+ULqSb82w=] diff --git a/hieradata/common.yaml b/hieradata/common.yaml index 68d3648..e56f1ac 100644 --- a/hieradata/common.yaml +++ b/hieradata/common.yaml @@ -146,6 +146,8 @@ lookup_options: strategy: deep profiles::etcd::node::initial_cluster_token: convert_to: Sensitive + profiles::dns::updater::key_secret: + convert_to: Sensitive sysctl::base::values: merge: strategy: deep @@ -208,6 +210,20 @@ vault::disable_mlock: false profiles::dns::base::nameservers: - 198.18.19.16 profiles::dns::master::basedir: '/var/named/sources' + +# dns record publishing. During the k8s cutover both methods run; set +# manage_export false once k8s is authoritative. +# - export: legacy exported-resources -> puppet DNS master +# - nsupdate: RFC2136 to the k8s bind-authoritative write endpoint (.9), +# inert until the TSIG key is set in eyaml: +# profiles::dns::updater::key_secret: ENC[...] +# (must match the key the bind-authoritative zones allow-update +# with; algorithm hmac-sha256) +profiles::dns::updater::manage_export: true +profiles::dns::updater::manage_nsupdate: true +profiles::dns::updater::server: '198.18.200.9' +profiles::dns::updater::key_name: 'client-update' +profiles::dns::updater::key_algorithm: 'hmac-sha256' #profiles::dns::base::ns_role: 'roles::infra::dns::resolver' #profiles::dns::base::use_ns: 'region' profiles::consul::server::members_role: roles::infra::storage::consul diff --git a/modules/libs/lib/facter/dns_records.rb b/modules/libs/lib/facter/dns_records.rb new file mode 100644 index 0000000..6b92db6 --- /dev/null +++ b/modules/libs/lib/facter/dns_records.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +# lib/facter/dns_records.rb +# +# Reports this host's expected DNS records (assembled by profiles::dns::updater +# into its records file) versus what is currently deployed on the authoritative +# server, so puppet can detect drift and re-apply. +# +# Structured value: +# { server, count, expected => [{zone,fqdn,type,ttl,value}], in_sync, +# drift => [{...,deployed => [...]}] } + +# Helpers for the dns_records fact. +module DnsRecordsFact + RECORDS_FILE = '/var/lib/dns-updater/records' + SERVER_FILE = '/var/lib/dns-updater/server' + + module_function + + # normalise a value for comparison: strip, drop trailing dot, downcase + def norm(value) + value.to_s.strip.chomp('.').downcase + end + + def server + File.exist?(SERVER_FILE) ? File.read(SERVER_FILE).strip : nil + end + + # a name relative to a zone (or @) as a fully-qualified name + def to_fqdn(name, zone) + return "#{zone}." if name.to_s.empty? || name == '@' + + "#{name}.#{zone}." + end + + # parse one "zone|name|type|ttl|value" line into a record hash (nil to skip) + def parse_line(line) + line = line.strip + return nil if line.empty? || line.start_with?('#') + + zone, name, type, ttl, value = line.split('|', 5) + return nil unless zone && type && value + + { 'zone' => zone, 'fqdn' => to_fqdn(name, zone), 'type' => type, 'ttl' => ttl, 'value' => value } + end + + # parse the records file into record hashes + def expected + return [] unless File.exist?(RECORDS_FILE) + + File.readlines(RECORDS_FILE).filter_map { |line| parse_line(line) } + end + + # the values currently deployed for a record, per the authoritative server + def deployed(record, srv) + cmd = ['dig', '+short', '+time=2', '+tries=1'] + cmd << "@#{srv}" if srv && !srv.empty? + cmd += [record['fqdn'], record['type']] + out = Facter::Core::Execution.execute(cmd.join(' '), on_fail: '') + out.to_s.split("\n").map { |line| norm(line) }.reject(&:empty?) + end + + def report + srv = server + exp = expected + drift = exp.filter_map do |record| + dep = deployed(record, srv) + record.merge('deployed' => dep) unless dep.include?(norm(record['value'])) + end + { 'server' => srv, 'count' => exp.length, 'expected' => exp, 'in_sync' => drift.empty?, 'drift' => drift } + end +end + +Facter.add(:dns_records) do + confine kernel: 'Linux' + setcode do + File.exist?(DnsRecordsFact::RECORDS_FILE) ? DnsRecordsFact.report : nil + end +end + +# Convenience boolean for `if $facts['dns_records_insync']` guards. +Facter.add(:dns_records_insync) do + confine kernel: 'Linux' + setcode do + v = Facter.value(:dns_records) + v.nil? ? nil : v['in_sync'] + end +end diff --git a/site/profiles/manifests/dns/base.pp b/site/profiles/manifests/dns/base.pp index 0e5e51f..1b16473 100644 --- a/site/profiles/manifests/dns/base.pp +++ b/site/profiles/manifests/dns/base.pp @@ -11,9 +11,12 @@ class profiles::dns::base ( Optional[String] $ns_role = undef, ){ - # install bind_utils + # install bind_utils (provides nsupdate) include bind::updater + # assemble the host's DNS records and nsupdate them to the authoritative server + include profiles::dns::updater + # if ns_role is set, find all hosts matching that enc_role $nameserver_array = $ns_role ? { undef => $nameservers, diff --git a/site/profiles/manifests/dns/record.pp b/site/profiles/manifests/dns/record.pp index 53dc887..fa54dea 100644 --- a/site/profiles/manifests/dns/record.pp +++ b/site/profiles/manifests/dns/record.pp @@ -1,4 +1,10 @@ -# defines the base record that will be exported +# profiles::dns::record +# +# Declares a DNS record for this host. Publishes it via either or both methods, +# controlled by profiles::dns::updater's toggles (both on during cutover): +# - nsupdate: a local concat fragment consumed by profiles::dns::updater, +# which nsupdates it to the authoritative server. +# - export: the legacy @@concat::fragment exported to the puppet DNS master. define profiles::dns::record ( String $record, Enum[ @@ -13,11 +19,26 @@ define profiles::dns::record ( String $value, String $zone, Integer $order, - Stdlib::AbsolutePath $basedir = lookup('profiles::dns::master::basedir'), + Integer $ttl = 300, ) { - @@concat::fragment { "${zone}_${name}": - target => "${basedir}/${zone}.conf", - content => "${record} IN ${type} ${value}\n", - order => $order, + include profiles::dns::updater + + # new: local records file consumed by the nsupdate service + if $profiles::dns::updater::manage_nsupdate { + # zone|name|type|ttl|value (parsed by the dns-update script) + concat::fragment { "dns-record-${name}": + target => $profiles::dns::updater::records_file, + content => "${zone}|${record}|${type}|${ttl}|${value}\n", + order => sprintf('%03d', $order), + } + } + + # legacy: export the fragment to the puppet DNS master + if $profiles::dns::updater::manage_export { + @@concat::fragment { "${zone}_${name}": + target => "${profiles::dns::updater::master_basedir}/${zone}.conf", + content => "${record} IN ${type} ${value}\n", + order => $order, + } } } diff --git a/site/profiles/manifests/dns/updater.pp b/site/profiles/manifests/dns/updater.pp new file mode 100644 index 0000000..f2523fb --- /dev/null +++ b/site/profiles/manifests/dns/updater.pp @@ -0,0 +1,127 @@ +# profiles::dns::updater +# +# Publishes this host's DNS records. Two methods, independently toggled so both +# can run during the k8s cutover (profiles::dns::record honours the same flags): +# +# - nsupdate ($manage_nsupdate): assemble the records into a local file and +# nsupdate them to the k8s authoritative write endpoint via a systemd .path +# unit that watches the file. Inert until $key_secret (TSIG) is set. +# - export ($manage_export): the legacy exported-resources flow to the puppet +# DNS master. Kept during cutover; disable once k8s is authoritative. +# +# nsupdate comes from bind-utils (installed via bind::updater in +# profiles::dns::base). +class profiles::dns::updater ( + Boolean $manage_nsupdate = true, + Boolean $manage_export = true, + String $server = '198.18.200.9', + String $key_name = 'client-update', + String $key_algorithm = 'hmac-sha256', + Optional[Sensitive[String]] $key_secret = undef, + Integer $default_ttl = 300, + Stdlib::AbsolutePath $records_file = '/var/lib/dns-updater/records', + Stdlib::AbsolutePath $state_dir = '/var/lib/dns-updater', + Stdlib::AbsolutePath $config_dir = '/etc/dns-updater', + Stdlib::AbsolutePath $master_basedir = lookup('profiles::dns::master::basedir'), +) { + + $state_file = "${state_dir}/applied" + $server_file = "${state_dir}/server" + $key_file = "${config_dir}/key" + + if $manage_nsupdate { + + file { $state_dir: + ensure => directory, + owner => 'root', + group => 'root', + mode => '0755', + } + + # Server address, read by the dns_records fact for drift detection. + file { $server_file: + ensure => file, + owner => 'root', + group => 'root', + mode => '0644', + content => "${server}\n", + require => File[$state_dir], + } + + # Records file, assembled from profiles::dns::record fragments. + concat { $records_file: + ensure => present, + owner => 'root', + group => 'root', + mode => '0644', + ensure_newline => true, + warn => false, + require => File[$state_dir], + } + + concat::fragment { 'dns-update-header': + target => $records_file, + content => "# Managed by puppet (profiles::dns::record): zone|name|type|ttl|value\n", + order => '00', + } + + if $key_secret =~ Undef { + notify { 'dns-updater-inert': + message => 'profiles::dns::updater: key_secret unset; records assembled but not applied.', + loglevel => 'info', + } + } else { + file { $config_dir: + ensure => directory, + owner => 'root', + group => 'root', + mode => '0700', + } + + file { $key_file: + ensure => file, + owner => 'root', + group => 'root', + mode => '0600', + show_diff => false, + content => Sensitive(epp('profiles/dns/tsig-key.epp', { + 'name' => $key_name, + 'algorithm' => $key_algorithm, + 'secret' => $key_secret.unwrap, + })), + } + + file { '/usr/local/bin/dns-update': + ensure => file, + owner => 'root', + group => 'root', + mode => '0755', + content => epp('profiles/dns/dns-update.sh.epp', { + 'server' => $server, + 'key_file' => $key_file, + 'records_file' => $records_file, + 'state_file' => $state_file, + }), + } + + systemd::unit_file { 'dns-update.service': + content => epp('profiles/dns/dns-update.service.epp', { 'script' => '/usr/local/bin/dns-update' }), + } + + # The .path unit watches the records file and triggers the service. + systemd::unit_file { 'dns-update.path': + content => epp('profiles/dns/dns-update.path.epp', { 'records_file' => $records_file }), + active => true, + enable => true, + } + + # Also apply within the puppet run whenever the records change. + exec { 'dns-update-apply': + command => '/usr/local/bin/dns-update', + refreshonly => true, + subscribe => Concat[$records_file], + require => [File['/usr/local/bin/dns-update'], File[$key_file]], + } + } + } +} diff --git a/site/profiles/templates/dns/dns-update.path.epp b/site/profiles/templates/dns/dns-update.path.epp new file mode 100644 index 0000000..a842bec --- /dev/null +++ b/site/profiles/templates/dns/dns-update.path.epp @@ -0,0 +1,10 @@ +<%- | String $records_file | -%> +[Unit] +Description=Watch the DNS records file and apply changes + +[Path] +PathModified=<%= $records_file %> +Unit=dns-update.service + +[Install] +WantedBy=multi-user.target diff --git a/site/profiles/templates/dns/dns-update.service.epp b/site/profiles/templates/dns/dns-update.service.epp new file mode 100644 index 0000000..7705360 --- /dev/null +++ b/site/profiles/templates/dns/dns-update.service.epp @@ -0,0 +1,9 @@ +<%- | String $script | -%> +[Unit] +Description=Apply host DNS records via nsupdate +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=<%= $script %> diff --git a/site/profiles/templates/dns/dns-update.sh.epp b/site/profiles/templates/dns/dns-update.sh.epp new file mode 100644 index 0000000..9001b8d --- /dev/null +++ b/site/profiles/templates/dns/dns-update.sh.epp @@ -0,0 +1,56 @@ +<%- | String $server, String $key_file, String $records_file, String $state_file | -%> +#!/bin/bash +# Managed by puppet (profiles::dns::updater). Applies this host's records to the +# authoritative DNS server via TSIG nsupdate. Only the delta since the last +# successful run is sent; removed records are deleted. +set -euo pipefail + +SERVER="<%= $server %>" +KEYFILE="<%= $key_file %>" +RECORDS="<%= $records_file %>" +STATE="<%= $state_file %>" + +[ -f "$RECORDS" ] || exit 0 +touch "$STATE" + +# Format per line: zone|name|type|ttl|value (name is relative to zone, or @). +desired="$(grep -vE '^[[:space:]]*(#|$)' "$RECORDS" | sort -u || true)" +applied="$(grep -vE '^[[:space:]]*(#|$)' "$STATE" 2>/dev/null | sort -u || true)" + +[ "$desired" = "$applied" ] && exit 0 + +fqdn() { # name zone + if [ -z "$1" ] || [ "$1" = "@" ]; then printf '%s.' "$2"; else printf '%s.%s.' "$1" "$2"; fi +} + +msg="$(mktemp)" +trap 'rm -f "$msg"' EXIT +printf 'server %s\n' "$SERVER" >> "$msg" + +# Process per zone so each UPDATE message targets a single zone. +zones="$(printf '%s\n%s\n' "$desired" "$applied" | cut -d'|' -f1 | sort -u | grep -v '^$' || true)" +for zone in $zones; do + printf 'zone %s.\n' "$zone" >> "$msg" + # Additions/updates: replace the RRset for every desired record in this zone. + printf '%s\n' "$desired" | awk -F'|' -v z="$zone" 'NF>=5 && $1==z' | \ + while IFS='|' read -r z name type ttl value; do + f="$(fqdn "$name" "$z")" + printf 'update delete %s %s\n' "$f" "$type" >> "$msg" + printf 'update add %s %s %s %s\n' "$f" "$ttl" "$type" "$value" >> "$msg" + done + # Deletions: records present last run but gone now. + comm -23 <(printf '%s\n' "$applied") <(printf '%s\n' "$desired") | \ + awk -F'|' -v z="$zone" 'NF>=5 && $1==z' | \ + while IFS='|' read -r z name type ttl value; do + f="$(fqdn "$name" "$z")" + printf 'update delete %s %s %s\n' "$f" "$type" "$value" >> "$msg" + done + printf 'send\n' >> "$msg" +done + +if nsupdate -k "$KEYFILE" "$msg"; then + printf '%s\n' "$desired" > "$STATE" +else + echo "dns-update: nsupdate to ${SERVER} failed" >&2 + exit 1 +fi diff --git a/site/profiles/templates/dns/tsig-key.epp b/site/profiles/templates/dns/tsig-key.epp new file mode 100644 index 0000000..fa49f2d --- /dev/null +++ b/site/profiles/templates/dns/tsig-key.epp @@ -0,0 +1,5 @@ +<%- | String $name, String $algorithm, String $secret | -%> +key "<%= $name %>" { + algorithm <%= $algorithm %>; + secret "<%= $secret %>"; +};