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

Video Thumbnail

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