Why I Don’t Link DHCP + DNS (3 ways to update DNS)
Today I’m tackling an issue that gets a lot of network engineers really hung up - in the old days, pushing DHCP data into DNS seemed like a good idea, but now it’s just garbage all around. What are some solutions you can use instead? Today I have three solutions - mDNS (multicast DNS), pushing DNS configs as part of automation (Ansible example), and pushing DNS from the host itself using the DNS protocol (nsupdate).
Contents⌗
Video⌗
Multicast DNS⌗
This method is the easiest, and everyone with a small network should be using this method. It’s installed by default on most clients, but on Debian / Proxmox VE, here’s what you need to do:
apt install avahi-daemon -y
You also need to edit /etc/nsswitch.conf to fix IPv6 not being resolved (remove ‘4’ from ‘mdns4_minimal’):
--- a/etc/nsswitch.conf.bak
+++ b/etc/nsswitch.conf
@@ -9,7 +9,7 @@ group: files systemd
shadow: files systemd
gshadow: files systemd
-hosts: files mdns4_minimal [NOTFOUND=return] dns
+hosts: files mdns_minimal [NOTFOUND=return] dns
networks: files
Ansible Node Provisioning⌗
Once your business gets big enough to have your own servers (on-prem or virtual), not just SaaS solutions, you are probably ready to graduate from flat network mDNS to running your own DNS server. I like to push my server configs to DNS as part of the provisioning process. Here’s a basic Ansible playbook that I wrote which does exactly that:
- name: Provision Proxmox VM and register IPv6 DNS record
hosts: localhost
# proxmox host, api keys, .. go in a vars file
# or group vars if you prefer
vars_files:
- vars.yml
connection: local
gather_facts: false
vars:
# config of the new VM
template_vmid: 903 #This is a VM template using cloud-init and already configured
new_hostname: "testing"
new_cores: 4
new_memory: 2048
boot_disk_size: "64G"
boot_disk_device: "scsi0"
# IPv6 prefix of the network the VM is on
ipv6_prefix: "2a0f:b240:1001:67"
tasks:
# Get next available VMID
- name: Get next available VMID from Proxmox
ansible.builtin.uri:
url: "https://{{ proxmox_host }}:8006/api2/json/cluster/nextid"
method: GET
headers:
Authorization: "PVEAPIToken={{ proxmox_user }}!{{ proxmox_token_id }}={{ vault_proxmox_password }}"
validate_certs: "{{ proxmox_verify_ssl }}"
status_code: 200
register: nextid_result
- name: Set new_vmid
ansible.builtin.set_fact:
new_vmid: "{{ nextid_result.json.data | int }}"
- name: Show allocated VMID
ansible.builtin.debug:
msg: "Proxmox allocated VMID: {{ new_vmid }}"
# Clone template vmid to new vmid
- name: Clone VM {{ template_vmid }} to {{ new_vmid }}
ansible.builtin.uri:
url: "https://{{ proxmox_host }}:8006/api2/json/nodes/{{ proxmox_node }}/qemu/{{ template_vmid }}/clone"
method: POST
headers:
Authorization: "PVEAPIToken={{ proxmox_user }}!{{ proxmox_token_id }}={{ proxmox_password }}"
body_format: json
body:
newid: "{{ new_vmid }}"
name: "{{ new_hostname }}"
full: 1
validate_certs: "{{ proxmox_verify_ssl }}"
status_code: 200
register: clone_result
- name: Wait for clone task to complete
ansible.builtin.uri:
url: "https://{{ proxmox_host }}:8006/api2/json/nodes/{{ proxmox_node }}/tasks/{{ clone_result.json.data }}/status"
method: GET
headers:
Authorization: "PVEAPIToken={{ proxmox_user }}!{{ proxmox_token_id }}={{ proxmox_password }}"
validate_certs: "{{ proxmox_verify_ssl }}"
register: task_status
until: task_status.json.data.status == 'stopped'
retries: 30
delay: 1
# Get VM's MAC address to compute EUI64, add prefix, add to DNS
# net0 will look like:
# virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0
- name: Fetch VM config from Proxmox API
ansible.builtin.uri:
url: "https://{{ proxmox_host }}:8006/api2/json/nodes/{{ proxmox_node }}/qemu/{{ new_vmid }}/config"
method: GET
headers:
Authorization: "PVEAPIToken={{ proxmox_user }}!{{ proxmox_token_id }}={{ vault_proxmox_password }}"
validate_certs: "{{ proxmox_verify_ssl }}"
return_content: true
status_code: 200
register: vm_config_raw
- name: Parse MAC address from net0
ansible.builtin.set_fact:
mac_address: >-
{{
vm_config_raw.json.data.net0
| regex_search('([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})', '\1')
| first
| upper
}}
- name: Show extracted MAC address
ansible.builtin.debug:
msg: "MAC address: {{ mac_address }}"
# We need to flip a bit to go from MAC to EUI64, and that's actually
# surprisingly hard to do in Jinja
# so here's a filter plugin I found, we just need bitwise xor here:
# https://eengstrom.github.io/musings/add-bitwise-operations-to-ansible-jinja2
- name: Compute EUI-64 IPv6 address
ansible.builtin.set_fact:
ipv6_addr: >-
{{
(
ipv6_prefix ~ ':' ~
('%02X' % (mac_octets[0] | int(base=16) | bitwise_xor(0x02))) ~ mac_octets[1] ~ ':' ~
mac_octets[2] ~ 'ff:' ~
'fe' ~ mac_octets[3] ~ ':' ~
mac_octets[4] ~ mac_octets[5]
) | ansible.utils.ipaddr('address')
}}
vars:
mac_octets: "{{ mac_address.split(':') }}"
- name: Show computed IPv6 address
ansible.builtin.debug:
msg: "EUI-64 IPv6: {{ ipv6_addr }}"
- name: Add AAAA record to DNS
ansible.builtin.uri:
url: "{{ technitium_url }}/api/zones/records/add"
method: POST
headers:
Content-Type: "application/x-www-form-urlencoded"
body_format: form-urlencoded
body:
token: "{{ technitium_token }}"
domain: "{{ new_hostname }}.{{ dns_zone }}"
zone: "{{ dns_zone }}"
type: AAAA
ipAddress: "{{ ipv6_addr }}"
ttl: "{{ dns_ttl }}"
overwrite: "true"
ptr: "true"
status_code: 200
validate_certs: "{{ technitium_verify_ssl }}"
register: dns_result
- name: Show DNS API response
ansible.builtin.debug:
msg: "{{ dns_result.json }}"
# Write out everything we just did
- name: Provisioning complete
ansible.builtin.debug:
msg:
- "VM {{ new_vmid }} ({{ new_hostname }}) created on node {{ proxmox_node }}"
- "MAC: {{ mac_address }}"
- "IPv6 (EUI-64): {{ ipv6_addr }}"
- "DNS AAAA record: {{ new_hostname }}.{{ dns_zone }} → {{ ipv6_addr }}"
You also need a vars.yml (and possibly vault.yml) to store these commonly used constants like the Proxmox host, usernames, passwords, … but that’s beyond the scope of this tutorial
Dynamic DNS (nsupdate)⌗
Third option is to let hosts push their own updates to DNS, using nsupdate (RFC 2136). Again, I’m using Debian as my example OS here.
The update process for rfc 2136 is basically:
- Host does a DNS command to update its own records (this runs over normal DNS - port 53 - nothing special)
- Host signs its command with a unique transaction signature (TSIG), which is a shared secret - we need a unique secret PER HOST if we want this to be secure and not let any host change any other host’s DNS name
- DNS server validates that the TSIG is correct
- DNS server validates that this TSIG is allowed to access these records
- DNS server commits update
So, we first need to add a new TSIG in Technitium. In the web UI it’s under Settings -> TSIG -> Add, if you leave the key blank it will generate a random value. Create a new key with the name of your specific host here.
Now, we need to create a key file as root (I used /etc/tsig.key for this) - obviously use your own secret here and your own key name!
key "host-testing" {
algorithm hmac-sha256;
secret "OCop85LAjLvdkf7J+3mar+uvTjufHQtiZozRp7z8VIg=";
};
Next, we need to setup the system. We need to install bind9-dnsutils on Debian, which gets us the nsupdate utility. The utility is actually not very useful at all on its own, so I wrote a script in Bash which gets all of the local IPs with hostname -I, splits them into A and AAAA records, and builds the list of commands for nsupdate. Put this script in /usr/local/bin/ddns and don’t forget to chmod +x:
#!/bin/bash
HOST="$(hostname)"
FQDN="$HOST.apalrd.fi"
{
# Update your server and zone here
echo "server polaris.apalrd.fi"
echo "zone apalrd.fi"
# Remove existing records
echo "update delete $FQDN A"
echo "update delete $FQDN AAAA"
# Add all IPv4 addresses (skip loopback)
for ip in $(hostname -I); do
if [[ "$ip" != *:* && "$ip" != 127.* ]]; then
echo "update add $FQDN 300 A $ip"
fi
done
# Add all IPv6 addresses (skip loopback + link-local)
for ip in $(hostname -I); do
if [[ "$ip" == *:* && "$ip" != ::1 && "$ip" != fe80::* ]]; then
echo "update add $FQDN 300 AAAA $ip"
fi
done
#it will send this all as one dns packet
echo "send"
} | nsupdate -k /etc/tsig.key
Now we just need to call this periodically. I’ve set it up with a systemd timer that runs hourly, plus every time the system starts up.
First, the systemd service (which actually does the updating) - put this in /etc/systemd/system/ddns.service:
[Unit]
Description=Update dynamic DNS via nsupdate
[Service]
Type=oneshot
ExecStart=/usr/local/bin/ddns
Second, the timer (which calls the service periodically) - put this in /etc/systemd/system/ddns.timer:
[Unit]
Description=Run dynamic dns periodically
[Timer]
#Just after boot
OnBootSec=2min
#Every 15mins after that
OnUnitActiveSec=15min
Persistent=true
[Install]
WantedBy=timers.target
And enable the timer (but not the service!):
systemctl daemon-reload
systemctl enable --now ddns.timer
There may be a better way to integrate with the network events, but this is the simplest thing I have to show