Change the Kea-DHCP configuration via API from Ansible

Carsten Strotmann

2026/09/07

Ansible (https://github.com/ansible/ansible) is a popular automation tool to manage server configurations (among other things).

Below are two example Ansible playbook scripts that show how to make changes to the Kea-DHCP configuration via the Kea REST API.

Inventory

# The Kea DHCPv6 control API listens on [::1] (localhost only), so the playbook
# must run on the Kea server itself. When running it directly on that host:
[kea_dhcp6]
localhost ansible_connection=local

# For a remote Kea server, reach it over SSH instead and point kea_api_url at a
# control channel that is reachable from that host, e.g.:
# [kea_dhcp6]
# kea001a.dane.onl

Adding a DHCPv6 subnet

The Ansible playbook below adds a new DHCPv6 subnet to a Kea-DHCPv6 server. The Kea-Server must be able to write to it's own configuration file under /etc/kea/ for the changes to persist (else the changes are only in the running config and will be gone after a restart of the Kea-DHCP-Server service.

---
# ============================================================================
# Add an IPv6 subnet to a running Kea DHCPv6 server via its REST/Control API.
#
# Workflow:
#   1. config-get   - download the current in-memory configuration
#   2. (local)      - add a new subnet6 entry to the Dhcp6 object
#   3. config-set   - push the updated configuration back to the server
#   4. config-write - tell Kea to write the in-memory config to the filesystem
#
# The play is idempotent: if a subnet with the same prefix already exists it
# performs steps 3 and 4 as no-ops and reports "ok" instead of "changed".
#
# Run it (Kea API listens on [::1], so run on the Kea host):
#   ansible-playbook -i inventory.ini kea-add-subnet6.yml
#
# Override the subnet to add:
#   ansible-playbook -i inventory.ini kea-add-subnet6.yml \
#     -e '{"new_subnet": {"id": 3100, "subnet": "fd00:310::/64",
#          "pools": [{"pool": "fd00:310::1-fd00:310::ffff"}]}}'
#
# ============================================================================

- name: Add an IPv6 subnet to the running Kea DHCPv6 server
  hosts: kea_dhcp6
  gather_facts: false

  vars:
    # Kea DHCPv6 control channel (HTTP control agent or built-in HTTP listener).
    kea_api_url: "http://[::1]:8005/"

    # Where to save the downloaded configuration (a valid Kea config file).
    kea_config_backup: "{{ playbook_dir }}/kea-dhcp6.current.json"

    # File Kea should (re)write on config-write. Defaulting to the file the
    # server was started with makes the change survive a restart.
    # NOTE: Kea rewrites this file without comments and with its own formatting.
    # NOTE: the file must be writable by the Kea runtime user (e.g. _kea); if
    #       only the directory is owned by that user, point this at a *new*
    #       filename such as /etc/kea/kea-dhcp6.conf.new and swap it in.
    kea_config_file: "/etc/kea/kea-dhcp6.conf"

    # Run config-write even when nothing changed (e.g. to persist a previous
    # in-memory-only change). Pass -e force_persist=true to force it.
    force_persist: false

    # The subnet to add. Only "id" and "subnet" are required; Kea fills in the
    # remaining per-subnet defaults itself.
    new_subnet:
      id: 3000
      subnet: "fd00:300::/64"
      pools:
        - pool: "fd00:300::1-fd00:300::ffff"

  tasks:
    # ----- 1. Download the current configuration ---------------------------
    - name: Get the current Kea DHCPv6 configuration (config-get)
      ansible.builtin.uri:
        url: "{{ kea_api_url }}"
        method: POST
        body_format: json
        body:
          command: config-get
        return_content: true
      register: kea_config_get

    - name: Assert config-get succeeded
      ansible.builtin.assert:
        that:
          - kea_config_get.json is defined
          - kea_config_get.json[0].result == 0
        fail_msg: "config-get failed: {{ kea_config_get.content | default(kea_config_get) }}"

    - name: Save the downloaded configuration to a local backup file
      ansible.builtin.copy:
        content: "{{ kea_config_get.json[0].arguments | to_nice_json }}\n"
        dest: "{{ kea_config_backup }}"
        mode: "0640"
      delegate_to: localhost

    - name: Extract the Dhcp6 object
      ansible.builtin.set_fact:
        kea_dhcp6: "{{ kea_config_get.json[0].arguments.Dhcp6 }}"

    # ----- 2. Add the new subnet (locally) --------------------------------
    - name: Determine whether the subnet already exists
      ansible.builtin.set_fact:
        kea_subnet_present: >-
          {{
            (kea_dhcp6.subnet6 | default([]))
            | selectattr('subnet', 'equalto', new_subnet.subnet)
            | list | length > 0
          }}

    - name: Report that the subnet is already configured
      ansible.builtin.debug:
        msg: "Subnet {{ new_subnet.subnet }} already present - config-set / config-write will be skipped."
      when: kea_subnet_present

    - name: Fail if the requested subnet id is used by a different prefix
      ansible.builtin.assert:
        that:
          - >-
            (kea_dhcp6.subnet6 | default([]))
            | selectattr('id', 'equalto', new_subnet.id)
            | rejectattr('subnet', 'equalto', new_subnet.subnet)
            | list | length == 0
        fail_msg: "Subnet id {{ new_subnet.id }} is already used by another prefix."
      when: not kea_subnet_present

    - name: Build the updated Dhcp6 object with the new subnet appended
      ansible.builtin.set_fact:
        kea_dhcp6_new: >-
          {{
            kea_dhcp6 | combine({'subnet6': (kea_dhcp6.subnet6 | default([])) + [new_subnet]})
          }}
      when: not kea_subnet_present

    # ----- 3. Push the new configuration back -----------------------------
    - name: Apply the updated configuration in memory (config-set)
      ansible.builtin.uri:
        url: "{{ kea_api_url }}"
        method: POST
        body_format: json
        body:
          command: config-set
          arguments:
            Dhcp6: "{{ kea_dhcp6_new }}"
        return_content: true
      register: kea_config_set
      when: not kea_subnet_present
      changed_when: not kea_subnet_present

    - name: Assert config-set succeeded
      ansible.builtin.assert:
        that:
          - kea_config_set.json[0].result == 0
        fail_msg: "config-set failed: {{ kea_config_set.content | default(kea_config_set) }}"
      when: not kea_subnet_present

    # ----- 4. Make the change persistent ---------------------------------
    - name: Write the in-memory configuration to disk (config-write)
      ansible.builtin.uri:
        url: "{{ kea_api_url }}"
        method: POST
        body_format: json
        body:
          command: config-write
          arguments:
            filename: "{{ kea_config_file }}"
        return_content: true
      register: kea_config_write
      when: not kea_subnet_present or force_persist
      changed_when: not kea_subnet_present or force_persist

    - name: Assert config-write succeeded
      ansible.builtin.assert:
        that:
          - kea_config_write.json[0].result == 0
        fail_msg: >-
          config-write failed: {{ kea_config_write.json[0].text | default(kea_config_write.content) }}.
          The Kea runtime user must be able to write '{{ kea_config_file }}'
          (e.g. chown _kea:_kea, or set kea_config_file to a new path in a
          directory that user owns).
      when: not kea_subnet_present or force_persist

    - name: Show the persistence result
      ansible.builtin.debug:
        msg: "{{ kea_config_write.json[0].text }} ({{ kea_config_write.json[0].arguments.size }} bytes)"
      when: not kea_subnet_present or force_persist

Removing a DHCPv6 subnet

This Ansible playbook removes an DHCPv6 subnet from the Kea-DHCPv6 configuration. The subnet to be removed can either be selected by its ID or by the subnet address.

Examples:

$ ansible-playbook -i inventory.ini kea-del-subnet6.yml \
     -e del_subnet_prefix=fd00:300::/64

$ ansible-playbook -i inventory.ini kea-del-subnet6.yml \
     -e del_subnet_id=1000

The playbook:

---
# ============================================================================
# Delete an IPv6 subnet from a running Kea DHCPv6 server via its Control API.
#
# Workflow:
#   1. config-get   - download the current in-memory configuration
#   2. (local)      - remove the matching subnet6 entry from the Dhcp6 object
#   3. config-set   - push the updated configuration back to the server
#   4. config-write - tell Kea to write the in-memory config to the filesystem
#
# The play is idempotent: if no subnet matches it performs steps 3 and 4 as
# no-ops and reports "ok" instead of "changed".
#
# Run it (Kea API listens on [::1], so run on the Kea host):
#   ansible-playbook -i inventory.ini kea-del-subnet6.yml \
#     -e del_subnet_prefix=fd00:300::/64
#
# Or match by numeric id instead of prefix:
#   ansible-playbook -i inventory.ini kea-del-subnet6.yml -e del_subnet_id=3000
#
# config-write needs the Kea runtime user (_kea) to be able to write
# kea_config_file. If a run removed the subnet in memory but could not persist
# it, fix the permissions and re-run with -e force_persist=true.
# ============================================================================

- name: Delete an IPv6 subnet from the running Kea DHCPv6 server
  hosts: kea_dhcp6
  gather_facts: false

  vars:
    # Kea DHCPv6 control channel (HTTP control agent or built-in HTTP listener).
    kea_api_url: "http://[::1]:8005/"

    # Where to save the downloaded configuration (a valid Kea config file).
    kea_config_backup: "{{ playbook_dir }}/kea-dhcp6.current.json"

    # File Kea should (re)write on config-write. Defaulting to the file the
    # server was started with makes the change survive a restart.
    # NOTE: Kea rewrites this file without comments and with its own formatting.
    # NOTE: the file must be writable by the Kea runtime user (e.g. _kea); if
    #       only the directory is owned by that user, point this at a *new*
    #       filename such as /etc/kea/kea-dhcp6.conf.new and swap it in.
    kea_config_file: "/etc/kea/kea-dhcp6.conf"

    # Run config-write even when nothing changed. Pass -e force_persist=true.
    force_persist: false

    # Which subnet to delete. Set exactly one of these on the command line:
    #   -e del_subnet_prefix=fd00:300::/64      (match on the "subnet" field)
    #   -e del_subnet_id=3000                   (match on the numeric "id")
    del_subnet_prefix: ""
    del_subnet_id: ""

  tasks:
    - name: Validate that a subnet selector was given
      ansible.builtin.assert:
        that:
          - (del_subnet_prefix | string | length > 0) or (del_subnet_id | string | length > 0)
          - not ((del_subnet_prefix | string | length > 0) and (del_subnet_id | string | length > 0))
        fail_msg: "Set exactly one of del_subnet_prefix or del_subnet_id (-e ...)."

    # ----- 1. Download the current configuration ---------------------------
    - name: Get the current Kea DHCPv6 configuration (config-get)
      ansible.builtin.uri:
        url: "{{ kea_api_url }}"
        method: POST
        body_format: json
        body:
          command: config-get
        return_content: true
      register: kea_config_get

    - name: Assert config-get succeeded
      ansible.builtin.assert:
        that:
          - kea_config_get.json is defined
          - kea_config_get.json[0].result == 0
        fail_msg: "config-get failed: {{ kea_config_get.content | default(kea_config_get) }}"

    - name: Save the downloaded configuration to a local backup file
      ansible.builtin.copy:
        content: "{{ kea_config_get.json[0].arguments | to_nice_json }}\n"
        dest: "{{ kea_config_backup }}"
        mode: "0640"
      delegate_to: localhost

    - name: Extract the Dhcp6 object
      ansible.builtin.set_fact:
        kea_dhcp6: "{{ kea_config_get.json[0].arguments.Dhcp6 }}"

    # ----- 2. Remove the matching subnet (locally) -----------------------
    - name: Select the subnets that match the deletion request
      ansible.builtin.set_fact:
        kea_subnet_matches: >-
          {{
            (kea_dhcp6.subnet6 | default([]))
            | selectattr('subnet', 'equalto', del_subnet_prefix) | list
            if (del_subnet_prefix | string | length > 0)
            else (kea_dhcp6.subnet6 | default([]))
                 | selectattr('id', 'equalto', del_subnet_id | int) | list
          }}

    - name: Report that no matching subnet exists
      ansible.builtin.debug:
        msg: >-
          No subnet matches
          {{ ('prefix ' ~ del_subnet_prefix) if (del_subnet_prefix | length > 0)
             else ('id ' ~ del_subnet_id) }} -
          config-set / config-write will be skipped.
      when: kea_subnet_matches | length == 0

    - name: Show the subnet that will be deleted
      ansible.builtin.debug:
        msg: "Deleting subnet id={{ item.id }} prefix={{ item.subnet }}"
      loop: "{{ kea_subnet_matches }}"
      loop_control:
        label: "{{ item.subnet }}"
      when: kea_subnet_matches | length > 0

    - name: Build the updated Dhcp6 object without the matching subnet
      ansible.builtin.set_fact:
        kea_dhcp6_new: >-
          {{
            kea_dhcp6 | combine({'subnet6':
              ((kea_dhcp6.subnet6 | default([]))
               | rejectattr('subnet', 'equalto', del_subnet_prefix) | list)
              if (del_subnet_prefix | string | length > 0)
              else ((kea_dhcp6.subnet6 | default([]))
                    | rejectattr('id', 'equalto', del_subnet_id | int) | list)
            })
          }}
      when: kea_subnet_matches | length > 0

    # ----- 3. Push the new configuration back -----------------------------
    - name: Apply the updated configuration in memory (config-set)
      ansible.builtin.uri:
        url: "{{ kea_api_url }}"
        method: POST
        body_format: json
        body:
          command: config-set
          arguments:
            Dhcp6: "{{ kea_dhcp6_new }}"
        return_content: true
      register: kea_config_set
      when: kea_subnet_matches | length > 0
      changed_when: kea_subnet_matches | length > 0

    - name: Assert config-set succeeded
      ansible.builtin.assert:
        that:
          - kea_config_set.json[0].result == 0
        fail_msg: "config-set failed: {{ kea_config_set.content | default(kea_config_set) }}"
      when: kea_subnet_matches | length > 0

    # ----- 4. Make the change persistent ---------------------------------
    - name: Write the in-memory configuration to disk (config-write)
      ansible.builtin.uri:
        url: "{{ kea_api_url }}"
        method: POST
        body_format: json
        body:
          command: config-write
          arguments:
            filename: "{{ kea_config_file }}"
        return_content: true
      register: kea_config_write
      when: (kea_subnet_matches | length > 0) or force_persist
      changed_when: (kea_subnet_matches | length > 0) or force_persist

    - name: Assert config-write succeeded
      ansible.builtin.assert:
        that:
          - kea_config_write.json[0].result == 0
        fail_msg: >-
          config-write failed: {{ kea_config_write.json[0].text | default(kea_config_write.content) }}.
          The Kea runtime user must be able to write '{{ kea_config_file }}'
          (e.g. chown _kea:_kea, or set kea_config_file to a new path in a
          directory that user owns).
      when: (kea_subnet_matches | length > 0) or force_persist

    - name: Show the persistence result
      ansible.builtin.debug:
        msg: "{{ kea_config_write.json[0].text }} ({{ kea_config_write.json[0].arguments.size }} bytes)"
      when: (kea_subnet_matches | length > 0) or force_persist