DHCP Static Mappings not in DNS until leased

Not sure if this one is by design or not, but I find it frustrating.

When the system boots the DHCP static mappings are not written to /etc/hosts so they do not resolve via DNS. It’s not until the client requests the lease are they written to hosts. This means that I need to duplicate the entries in System static-host-mappings for them to resolve immediately.

I wrote this post-boot hook script to write them to the hosts file as the DHCP server would so that they’re updated correctly when leased at a later time.

/config/scripts/vyos-postconfig-bootup.script:

#!/bin/vbash
# This script is executed at boot time after VyOS configuration is fully applied.
# Any modifications required to work around unfixed bugs
# or use services not available through the VyOS CLI system can be placed here.

source /opt/vyatta/etc/functions/script-template
run show dhcp server static-mappings | sed 1,2d |
while read POOL SUBNET NAME IP MAC DUID DESCRIPTION; do
  vyos-hostsd-client --tag dhcp-server-$IP --add-hosts $NAME.mydomain.name,$IP
done
vyos-hostsd-client --apply
2 Likes

Thanks for the script - I too have been wondering if it’s a bug that static-mapping hosts aren’t automatically added to the /etc/hosts until they are leased.

I did modify your script, since my network has a bunch of different DHCP pools, most with different domain names. Here is the modified script

#!/bin/vbash
# This script is executed at boot time after VyOS configuration is fully applied.
# Any modifications required to work around unfixed bugs
# or use services not available through the VyOS CLI system can be placed here.
source /opt/vyatta/etc/functions/script-template

config=$(mktemp /tmp/config.XXXXX)
trap "rm -f $config" INT TERM EXIT
run show configuration json > $config

run show dhcp server static-mappings | sed 1,2d |
while read POOL SUBNET NAME IP MAC DUID DESCRIPTION; do
    domain=$(jq ".service.\"dhcp-server\".\"shared-network-name\".$POOL.option.\"domain-name\"" $config | sed 's/\"//g')
    vyos-hostsd-client --tag dhcp-server-$IP --add-hosts $NAME.$domain,$IP
done
vyos-hostsd-client --apply

The modifications do make the assumption that the domain name will be an option of the dhcp-server shared-network, such as:

service {
    dhcp-server {
        shared-network-name LAN {
            option {
                domain-name "lan.example.com"
            }

A small mod taking the default system domain-name as a substitute if non provided in dhcp-server config, also providing the same for dhcpv6-server (but using domain-search as domain-option is not available):

#!/bin/sh
# This script is executed at boot time after VyOS configuration is fully applied.
# Any modifications required to work around unfixed bugs
# or use services not available through the VyOS CLI system can be placed here.

source /opt/vyatta/etc/functions/script-template

config=$(mktemp /tmp/config.XXXXX)
trap "rm -f $config" INT TERM EXIT
run show configuration json > $config

system_domain=$(jq ".system.\"domain-name\"" $config | sed 's/\"//g')

# Add static mappings to hostsd for DNS resolution (dhcp-server)
run show dhcp server static-mappings | sed 1,2d |
while read POOL SUBNET NAME IP MAC DUID DESCRIPTION; do
    domain=$(jq ".service.\"dhcp-server\".\"shared-network-name\".$POOL.option.\"domain-name\"" $config | sed 's/\"//g')
    if [ -z "$domain" ]; then
        if [ -z "$system_domain" ]; then
            echo "No domain-name option found for $POOL, skipping $NAME"
            continue
        else
            domain=$system_domain
            vyos-hostsd-client --tag dhcp-server-$IP --add-hosts $NAME.$domain,$IP
        fi
    else
        vyos-hostsd-client --tag dhcp-server-$IP --add-hosts $NAME.$domain,$IP
    fi
done
vyos-hostsd-client --apply

# Add static mappings to hostsd for DNS resolution (dhcpv6-server)
run show dhcpv6 server static-mapping | sed 1,2d |
while read POOL SUBNET NAME IP DUID DESCRIPTION; do
    domains=($(jq ".service.\"dhcpv6-server\".\"shared-network-name\".LAN.option.\"domain-search\" | join(\" \")" $config | sed 's/\"//g'))
    if [ ${#domains[@]} -gt 1 ]; then
        for domain in ${domains[@]}; do
            vyos-hostsd-client --tag dhcpv6-server-$IP --add-hosts $NAME.$domain,$IP
        done
    else
        if [ ${#domains[@]} -eq 1 ]; then
            domain=${domains[0]}
        elif [ -z "${domains}" ]; then
            domain=$(jq ".service.\"dhcpv6-server\".\"shared-network-name\".$POOL.option.\"domain-search\"" $config | sed 's/\"//g')
        fi
        if [ -z "${domain}" ]; then
            if [ -z "$system_domain" ]; then
                echo "No domain-search option found for $POOL, skipping $NAME"
                continue
            else
                domain=$system_domain
            fi
        fi
        vyos-hostsd-client --tag dhcpv6-server-$IP --add-hosts $NAME.$domain,$IP
    fi
done
vyos-hostsd-client --apply

Ha, show dhcpv6 server static-mappings doesn’t report hostname nor ipv6-address field (showing N/A instead) : dhcpv6-server: T5992: Fix op-mode Kea DHCP lease output by nvandamme · Pull Request #4221 · vyos/vyos-1x

A partial solution can leverage python vyos API for kea using:

from typing import Any, NotRequired, TypedDict
from vyos.kea import (
    kea_get_leases,
    kea_get_active_config,
    kea6_parse_options,
    kea6_options,
)
import pprint
from vyos.utils.dict import dict_search_args
from vyos.configquery import ConfigTreeQuery

# Get the current configuration
config6 = ConfigTreeQuery()

# Get system domain-name
domain_name = config6.value(f"system domain-name")


class KeaOption(
    TypedDict(
        "KeaOption",
        {
            "name": str,
            "code": int,
            "data": str,
            "always-send": bool,
            "csv-format": bool,
            "never-send": bool,
            "space": str,
        },
    )
):
    pass


def parse_options(options: list[KeaOption]):
    result = {}
    for option in options:
        name = option.get("name", None)
        if not name:
            name = option.get("code", None)
        if not name:
            continue
        if option["name"] in kea6_options:
            name = kea6_options[option["name"]]
        if "," in option["data"]:
            result[name] = [x.strip() for x in option["data"].split(",")]
        else:
            result[name] = option["data"]
    return result


def get_domains(
    options: dict[str | int, Any], alt_domains: list[str] | str | None = None
) -> list[str]:
    domains: list[str] = []
    if 'domain-search' in options:
        if isinstance(options['domain-search'], str):
            domains.append(options['domain-search'])
        else:
            domains.extend(options['domain-search'])
    if "domain-name" in options:
        domains.append(options["domain-name"])
    if domain_name and domain_name not in domains:
        domains.append(domain_name)
    if alt_domains is not None:
        if isinstance(alt_domains, str):
            if ',' in alt_domains:
                alt_domains = [x.strip() for x in alt_domains.split(",")]
            elif ' ' in alt_domains:
                alt_domains = [x.strip() for x in alt_domains.split(" ")]
            else:
                alt_domains = [alt_domains]
        for alt in alt_domains:
            if alt not in domains:
                domains.append(alt)
    return domains


# DCHPv4

## Get active configuration
config4 = kea_get_active_config("4")

## Get shared-networks
shared_networks4 = dict_search_args(config4, "arguments", f"Dhcp4", "shared-networks")
leases4 = kea_get_leases("4") or []

## Extract shared-networks and subnets properties
if shared_networks4:
    print("DCHPv4 Shared Networks:")
    for network in shared_networks4:
        options = parse_options(network["option-data"])
        domains = get_domains(options)
        print(f"\t{network['name']} options {options} domain {domains}")
        for subnet in network[f"subnet4"]:
            sub_options = parse_options(subnet["option-data"])
            sub_domains = get_domains(sub_options, domains)
            print(f"\t\t{subnet['subnet']} options {sub_options} domains {sub_domains}")
            sub_pools = subnet.get("pools", [])
            print(f"\t\t\tPools:")
            for pool in sub_pools:
                pool_options = parse_options(pool["option-data"])
                pool_domains = get_domains(pool_options, sub_domains)
                print(
                    f"\t\t\t\t{pool['pool']} options {pool_options} domains {pool_domains}"
                )
            reservations = subnet.get("reservations", [])
            if len(reservations) > 0:
                print(f"\t\t\tReservations:")
                for reservation in reservations:
                    mac = reservation.get("hw-address", "-")
                    duid = reservation.get("duid", "-")
                    hostname = reservation.get("hostname", "-")
                    ip_addresses = reservation.get("ip-addresses", [])
                    ip_address = reservation.get("ip-address", None)
                    reservation_options = parse_options(reservation["option-data"])
                    for ip in ip_addresses:
                        print(
                            f"\t\t\t\t{ip} {hostname} {mac} {duid} options {reservation_options}"
                        )
                    if ip_address:
                        print(
                            f"\t\t\t\t{ip_address} {hostname} {mac} {duid} options {reservation_options}"
                        )
            if len(leases4) > 0:
                has_leases = False
                for lease in leases4:
                    if lease["subnet-id"] == subnet["id"]:
                        if not has_leases:
                            print(f"\t\t\tLeases:")
                        has_leases = True
                        hostname = lease.get("hostname", None)
                        if not hostname:
                            hostname = "-"
                        if hostname[-1] == ".":
                            hostname = hostname[:-1]
                        print(f"\t\t\t\t{lease['ip-address']}\t{hostname}")

# DCHPv6

## Get active configuration
config6 = kea_get_active_config("6")

## Get shared-networks
shared_networks6 = dict_search_args(config6, "arguments", f"Dhcp6", "shared-networks")
leases6 = kea_get_leases("6") or []

## Extract shared-networks and subnets properties
if shared_networks6:
    print("DCHPv6 Shared Networks:")
    for network in shared_networks6:
        options = parse_options(network["option-data"])
        domains = get_domains(options)
        print(f"\t{network['name']} options {options} domain {domains}")
        for subnet in network[f"subnet6"]:
            sub_options = parse_options(subnet["option-data"])
            sub_domains = get_domains(sub_options, domains)
            print(f"\t\t{subnet['subnet']} options {sub_options} domains {sub_domains}")
            sub_pools = subnet.get("pools", [])
            print(f"\t\t\tPools:")
            for pool in sub_pools:
                pool_options = parse_options(pool["option-data"])
                pool_domains = get_domains(pool_options, sub_domains)
                print(
                    f"\t\t\t\t{pool['pool']} options {pool_options} domains {pool_domains}"
                )
            reservations = subnet.get("reservations", [])
            if len(reservations) > 0:
                print(f"\t\t\tReservations:")
                for reservation in reservations:
                    mac = reservation.get("hw-address", "-")
                    duid = reservation.get("duid", "-")
                    hostname = reservation.get("hostname", "-")
                    ip_addresses = reservation.get("ip-addresses", [])
                    reservation_options = parse_options(reservation["option-data"])
                    reservation_domains = get_domains(reservation_options, pool_domains)
                    for ip in ip_addresses:
                        print(
                            f"\t\t\t\t{ip} {hostname} {mac} {duid} options {reservation_options} domains {reservation_domains}"
                        )
            if len(leases6) > 0:
                has_leases = False
                for lease in leases6:
                    if lease["subnet-id"] == subnet["id"]:
                        if not has_leases:
                            print(f"\t\t\tLeases:")
                        has_leases = True
                        hostname = lease.get("hostname", None)
                        if not hostname:
                            hostname = "-"
                        if hostname[-1] == ".":
                            hostname = hostname[:-1]
                        print(f"\t\t\t\t{lease['ip-address']}\t{hostname}")

Also, dhcpv6 leases doesn’t support hostfile-update yet…