diff --git a/hieradata/common.yaml b/hieradata/common.yaml index 68d3648..5a8ba80 100644 --- a/hieradata/common.yaml +++ b/hieradata/common.yaml @@ -208,6 +208,14 @@ vault::disable_mlock: false profiles::dns::base::nameservers: - 198.18.19.16 profiles::dns::master::basedir: '/var/named/sources' + +# dns::updater nsupdates host records to the k8s authoritative write endpoint +# (bind-authoritative-primary). 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::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/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..d3eb438 100644 --- a/site/profiles/manifests/dns/record.pp +++ b/site/profiles/manifests/dns/record.pp @@ -1,4 +1,9 @@ -# defines the base record that will be exported +# profiles::dns::record +# +# Declares a DNS record for this host. The record is written to the local +# dns-updater records file (profiles::dns::updater), which nsupdates it to the +# authoritative DNS server. This replaces the old flow that exported a +# @@concat::fragment to the puppet DNS master. define profiles::dns::record ( String $record, Enum[ @@ -13,11 +18,14 @@ 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 + + # 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), } } diff --git a/site/profiles/manifests/dns/updater.pp b/site/profiles/manifests/dns/updater.pp new file mode 100644 index 0000000..65d985c --- /dev/null +++ b/site/profiles/manifests/dns/updater.pp @@ -0,0 +1,111 @@ +# profiles::dns::updater +# +# Applies this host's DNS records to the authoritative DNS server via TSIG +# nsupdate, replacing the old exported-resources -> master zone-file flow. +# +# profiles::dns::record fragments are assembled into $records_file; a systemd +# .path unit watches that file and runs dns-update.service (nsupdate) whenever +# it changes. nsupdate comes from bind-utils (installed via bind::updater in +# profiles::dns::base). +# +# Inert until $key_secret is set (the shared TSIG key that the k8s +# bind-authoritative zones allow-update with): the records file is still +# assembled, but the updater service is not managed, so nodes are safe before +# the key is provisioned. +class profiles::dns::updater ( + 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', +) { + + $state_file = "${state_dir}/applied" + $key_file = "${config_dir}/key" + + file { $state_dir: + ensure => directory, + owner => 'root', + group => 'root', + mode => '0755', + } + + # 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 %>"; +};