Kea, Ansible & FreeBSD

Posted on Sep 14, 2021

I've been testing ISC Kea DHCP at home for some time now, mostly due to $REASONS. The only downside so far (aside from learning a tool that is easily replaced by dnsmasq) has been it's inability to use subnet specific domain suffixes, something supported by both the old ISC DHCP implementation and tools like dnsmasq.

Update 2023: The above is incorrect, isc-kea has absolutely no issues handling subnet specific domain suffixes. I just hadn't configured the d2 server correctly! Also, remember to set hostname for any static reservations.

Why is this even necessary, then? In my case, I keep my home network split up into a couple of zones (one for clients, one for services, one for iot and one for management) which I use different subdomains for. One zone uses services.home.arpa, another is clients.home.arpa, et c.

After debugging and testing kea in this configuration for quite a while I gave up on having a working internal dns mapping for my dhcp clients (RFC2136). I looked into setting up multiple kea instances on my home router (running NixOS), but I'm still not very good at nix so I just accepted that I had to manually setup multiple instances of kea, one for each zone.

Just before starting setting up these instances I realized that this was a great excus…opportunity to use Ansible! So I wrote yet another custom role for this.

It might be worth noting that below is an example and might not compile, all variables (subnets et c) needs checking to make it work.

Setup

I run FreeBSD (also due to $REASONS) on a few machines and decided that I wanted to use this machine for DHCP as well. This machine handles several network interfaces, both 'real' and virtual.

I haven't automated setting up my jails yet, so I manually created a couple of jails, one for each zone. Some of these zones have multiple subnets so I made sure to include all relevant interfaces.

  # iocage create -r 13.0-RELEASE -b -n keas vnet=on boot=on bpf=on defaultrouter=192.168.100.254 ip4_addr="vnet0|192.168.100.1/24,vnet1|192.168.110.0/29,vnet2|192.168.120.0/29" resolver="nameserver 192.168.100.254" interfaces="vnet0:bridge0,vnet1|bridge110,vnet2|bridge120"

  # iocage create -r 13.0-RELEASE -b -n keai vnet=on boot=on bpf=on defaultrouter=192.168.200.254 ip4_addr="vnet0|192.168.200.1/24" resolver="nameserver 192.168.200.254" interfaces="vnet0:bridge200"

bpf (and vnet) is necessary for kea to work properly, so make sure to enable that.

In case my /etc/rc.conf is relevant, it looks something like below. It's paraphrased but you get the idea.

  # re0 carries a lot of tagged vlans (only tagged, nothing untagged, no
  # "dual mode").  Please don't mix tagged and untagged traffic on one
  # interface as it might bite you, see:
  # https://forums.FreeBSD.org/threads/bridge-epair-not-passing-through-tagged-vlan-traffic-between-host-and-vnet-jail.71646/post-437147
  ifconfig_re0="up"
  cloned_interfaces="vlan100 bridge100 vlan200 bridge200"
  ifconfig_vlan100="vlan 100 vlandev re0 up"
  ifconfig_vlan200="vlan 200 vlandev re0 up"
  ifconfig_bridge100="addm vlan100 up"
  ifconfig_bridge200="addm vlan200 up"

I then copy in my public ssh key to each jail (into /root/.ssh/authorized_keys), edit /etc/ssh/sshd_config to allow root login with public keys and start ssh. (I should really automate this)

Playbook - variables

The playbook was fairly straight forward to write, but I learned a neat trick (merging configurations) which made it feel nice and pretty.

I've setup four different Kea instances so I put my default config on a group level and have overridden specifics on host level. Had I been more serious I'd set the defaults on the role instead. :-)

This is more or less a translation of the example configuration (in json) into yaml.

  kea_dhcp4_default_config:
    Dhcp4:
      dhcp-ddns:
        enable-updates: true
        qualifying-suffix: "home.arpa."

      interfaces-config:
        interfaces: []

      lease-database:
        name: "/var/db/kea/dhcp4.leases"
        persist: true
        type: "memfile"

      loggers:
        - name: "kea-dhcp4"
          severity: "INFO"
          output_options:
            - output: "/var/log/kea-dhcp4.log"
              maxsize: 1048576
              maxver: 8

      rebind-timer: 2000
      renew-timer: 1000
      valid-lifetime: 4000

      subnet4: []

  kea_d2_default_config:
    DhcpDdns:
      dns-server-timeout: 100
      ip-address: "127.0.0.1"
      ncr-format: "JSON"
      ncr-protocol: "UDP"
      port: 53001

      loggers:
        - name: "kea-dhcp-ddns"
          severity: "INFO"
          output_options:
            - output: "/var/log/kea-dhcp-ddns.log"
              maxsize: 1048576
              maxver: 8

      forward-ddns: {}
      reverse-ddns: {}
      tsig-keys: {}

And then I override each value as necessary on a host level, note that this is kea_dhcp4_config and not kea_dhcp4_default_config:

  kea_dhcp4_config:
    Dhcp4:
      dhcp-ddns:
        qualifying-suffix: "clients.home.arpa."

      interfaces-config:
        interfaces:
          - epair0b
          - epair1b

      subnet4:
        - subnet: "192.168.100.0/24"
          option-data:
            - data: "192.168.100.254"
              name: routers
            - data: "192.168.100.254"
              name: domain-name-servers
            - data: "clients.home.arpa."
              name: domain-name
          pools:
            - pool: "192.168.100.100 - 192.168.100.200"
          reservations: "{{ kea_reservations.vlan100 }}"


  kea_d2_config:
    DhcpDdns:
      forward-ddns:
        ddns-domains:
          - name: "clients.home.arpa."
            key-name: "tsigkey."
            dns-servers:
              - hostname: ""
                ip-address: "192.168.200.2"
                port: 53

      reverse-ddns:
        ddns-domains:
          - name: "100.168.192.in-addr.arpa."
            key-name: "tsigkey."
            dns-servers:
              - hostname: ""
                ip-address: "192.168.200.2"
                port: 53

      tsig-keys:
        - "{{ kea_tsig_keys.tsigkey }}"

These variables (default and host level) will be merged in a task.

Playbook - handlers

  ---
  - name: 'kea reload'
    ansible.builtin.service:
      name: 'kea'
      state: 'reloaded'

Playbook - templates

  # kea-dhcp4.conf.j2
  {% if kea_dhcp4_config %}
  {{ kea_dhcp4_config | to_nice_json }}
  {% endif %}
  # kea-dhcp-ddns.conf.j2
  {% if kea_d2_config %}
  {{ kea_d2_config | to_nice_json }}
  {% endif %}

Playbook - tasks

This is pretty sloppy, as there are multiple parameters that should be variable instead. I especially like two things here - the configuration merges (which I hadn't encountered before) and the template validation. The latter prevents me from breaking a working, running configuration which feels nice.

  ---
  - name: Install dependencies
    ansible.builtin.package:
      name: "{{ item }}"
      state: present
    with_items:
      - kea

  - name: Enable service
    ansible.builtin.service:
      name: "kea"
      enabled: yes

  - name: Merge kea dhcp4 configuration between defaults and custom
    set_fact:
      kea_dhcp4_config: "{{ kea_dhcp4_default_config | combine( kea_dhcp4_config, recursive=True ) }}"

  - name: Merge kea ddns (d2) configuration between defaults and custom
    set_fact:
      kea_d2_config: "{{ kea_d2_default_config | combine( kea_d2_config, recursive=True ) }}"

  - name: Install dhcp4 configuration
    ansible.builtin.template:
      src: "kea-dhcp4.conf.j2"
      dest: "/usr/local/etc/kea/kea-dhcp4.conf"
      validate: "kea-dhcp4 -t %s"
    notify:
      - 'kea reload'

  - name: Install d2 configuration
    ansible.builtin.template:
      src: "kea-dhcp-ddns.conf.j2"
      dest: "/usr/local/etc/kea/kea-dhcp-ddns.conf"
      validate: "kea-dhcp-ddns -t %s"
    notify:
      - 'kea reload'

  - name: Make sure d2 is enabled in keactrl.conf
    ansible.builtin.lineinfile:
      path: "/usr/local/etc/kea/keactrl.conf"
      regexp: "^dhcp_ddns="
      line: "dhcp_ddns=yes"
    notify:
      - 'kea reload'

  - name: Make sure dhcp4 is enabled in keactrl.conf
    ansible.builtin.lineinfile:
      path: "/usr/local/etc/kea/keactrl.conf"
      regexp: "^dhcp4="
      line: "dhcp4=yes"
    notify:
      - 'kea reload'

  - name: Make sure dhcp6 is disabled in keactrl.conf
    ansible.builtin.lineinfile:
      path: "/usr/local/etc/kea/keactrl.conf"
      regexp: "^dhcp6="
      line: "dhcp6=no"
    notify:
      - 'kea reload'

  - name: Make sure kea is running (if first setup)
    ansible.builtin.service:
      name: "kea"
      state: started

Playbook - secrets

Two parts, reservations (per subnet) and tsig keys. The latter are used for authenticating with my authoritative DNS server (RFC2136).

  kea_tsig_keys:
    tsigkeyname:
      name: "tsigkeyname"
      algorithm: "goes-here"
      secret: "hello world"

  kea_reservations:
    vlan100:
      - hw-address: "00:11:22:33:44:55"
        ip-address: "192.168.100.10"
      - hw-address: "00:11:22:33:44:55"
        ip-address: "192.168.100.11"
    vlan200:
      - hw-address: "00:11:22:33:44:55"
        ip-address: "192.168.200.10"
      - hw-address: "00:11:22:33:44:55"
        ip-address: "192.168.200.11"

Conclusion

I like ansible.