From d95200e96763e4a7ed02577b1b177c84abb77838 Mon Sep 17 00:00:00 2001
From: sarthurdev <965089+sarthurdev@users.noreply.github.com>
Date: Fri, 16 Dec 2022 11:41:33 +0100
Subject: dhcp: T3316: Migrate dhcp/dhcpv6 server to Kea

---
 src/conf_mode/dhcp_server.py                       | 134 +++++++++++++++------
 src/conf_mode/dhcpv6_server.py                     |  42 +++++--
 src/conf_mode/system-login.py                      |   2 +-
 .../system/kea-ctrl-agent.service.d/override.conf  |   9 ++
 .../kea-dhcp4-server.service.d/override.conf       |   7 ++
 .../kea-dhcp6-server.service.d/override.conf       |   7 ++
 src/migration-scripts/dhcp-server/6-to-7           |  87 +++++++++++++
 src/migration-scripts/dhcpv6-server/1-to-2         |  86 +++++++++++++
 src/op_mode/clear_dhcp_lease.py                    |  41 ++++---
 src/op_mode/dhcp.py                                | 120 +++++++++---------
 src/system/on-dhcp-event.sh                        |  14 +--
 src/systemd/isc-dhcp-server6.service               |  24 ----
 12 files changed, 416 insertions(+), 157 deletions(-)
 create mode 100644 src/etc/systemd/system/kea-ctrl-agent.service.d/override.conf
 create mode 100644 src/etc/systemd/system/kea-dhcp4-server.service.d/override.conf
 create mode 100644 src/etc/systemd/system/kea-dhcp6-server.service.d/override.conf
 create mode 100755 src/migration-scripts/dhcp-server/6-to-7
 create mode 100755 src/migration-scripts/dhcpv6-server/1-to-2
 delete mode 100644 src/systemd/isc-dhcp-server6.service

(limited to 'src')

diff --git a/src/conf_mode/dhcp_server.py b/src/conf_mode/dhcp_server.py
index ac7d95632..66f7c8057 100755
--- a/src/conf_mode/dhcp_server.py
+++ b/src/conf_mode/dhcp_server.py
@@ -21,10 +21,16 @@ from ipaddress import ip_network
 from netaddr import IPAddress
 from netaddr import IPRange
 from sys import exit
+from time import sleep
 
 from vyos.config import Config
+from vyos.pki import wrap_certificate
+from vyos.pki import wrap_private_key
 from vyos.template import render
 from vyos.utils.dict import dict_search
+from vyos.utils.dict import dict_search_args
+from vyos.utils.file import chmod_775
+from vyos.utils.file import write_file
 from vyos.utils.process import call
 from vyos.utils.process import run
 from vyos.utils.network import is_subnet_connected
@@ -33,8 +39,14 @@ from vyos import ConfigError
 from vyos import airbag
 airbag.enable()
 
-config_file = '/run/dhcp-server/dhcpd.conf'
-systemd_override = r'/run/systemd/system/isc-dhcp-server.service.d/10-override.conf'
+ctrl_config_file = '/run/kea/kea-ctrl-agent.conf'
+ctrl_socket = '/run/kea/dhcp4-ctrl-socket'
+config_file = '/run/kea/kea-dhcp4.conf'
+lease_file = '/config/dhcp4.leases'
+
+ca_cert_file = '/run/kea/kea-failover-ca.pem'
+cert_file = '/run/kea/kea-failover.pem'
+cert_key_file = '/run/kea/kea-failover-key.pem'
 
 def dhcp_slice_range(exclude_list, range_dict):
     """
@@ -130,6 +142,9 @@ def get_config(config=None):
                         dhcp['shared_network_name'][network]['subnet'][subnet].update(
                                 {'range' : new_range_dict})
 
+    if dict_search('failover.certificate', dhcp):
+        dhcp['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) 
+
     return dhcp
 
 def verify(dhcp):
@@ -166,13 +181,6 @@ def verify(dhcp):
                     if 'next_hop' not in route_option:
                         raise ConfigError(f'DHCP static-route "{route}" requires router to be defined!')
 
-            # DHCP failover needs at least one subnet that uses it
-            if 'enable_failover' in subnet_config:
-                if 'failover' not in dhcp:
-                    raise ConfigError(f'Can not enable failover for "{subnet}" in "{network}".\n' \
-                                      'Failover is not configured globally!')
-                failover_ok = True
-
             # Check if DHCP address range is inside configured subnet declaration
             if 'range' in subnet_config:
                 networks = []
@@ -249,14 +257,34 @@ def verify(dhcp):
         raise ConfigError(f'At least one shared network must be active!')
 
     if 'failover' in dhcp:
-        if not failover_ok:
-            raise ConfigError('DHCP failover must be enabled for at least one subnet!')
-
         for key in ['name', 'remote', 'source_address', 'status']:
             if key not in dhcp['failover']:
                 tmp = key.replace('_', '-')
                 raise ConfigError(f'DHCP failover requires "{tmp}" to be specified!')
 
+        if len({'certificate', 'ca_certificate'} & set(dhcp['failover'])) == 1:
+            raise ConfigError(f'DHCP secured failover requires both certificate and CA certificate')
+
+        if 'certificate' in dhcp['failover']:
+            cert_name = dhcp['failover']['certificate']
+
+            if cert_name not in dhcp['pki']['certificate']:
+                raise ConfigError(f'Invalid certificate specified for DHCP failover')
+
+            if not dict_search_args(dhcp['pki']['certificate'], cert_name, 'certificate'):
+                raise ConfigError(f'Invalid certificate specified for DHCP failover')
+
+            if not dict_search_args(dhcp['pki']['certificate'], cert_name, 'private', 'key'):
+                raise ConfigError(f'Missing private key on certificate specified for DHCP failover')
+
+        if 'ca_certificate' in dhcp['failover']:
+            ca_cert_name = dhcp['failover']['ca_certificate']
+            if ca_cert_name not in dhcp['pki']['ca']:
+                raise ConfigError(f'Invalid CA certificate specified for DHCP failover')
+
+            if not dict_search_args(dhcp['pki']['ca'], ca_cert_name, 'certificate'):
+                raise ConfigError(f'Invalid CA certificate specified for DHCP failover')
+
     for address in (dict_search('listen_address', dhcp) or []):
         if is_addr_assigned(address):
             listen_ok = True
@@ -278,43 +306,71 @@ def generate(dhcp):
     if not dhcp or 'disable' in dhcp:
         return None
 
-    # Please see: https://vyos.dev/T1129 for quoting of the raw
-    # parameters we can pass to ISC DHCPd
-    tmp_file = '/tmp/dhcpd.conf'
-    render(tmp_file, 'dhcp-server/dhcpd.conf.j2', dhcp,
-           formater=lambda _: _.replace("&quot;", '"'))
-    # XXX: as we have the ability for a user to pass in "raw" options via VyOS
-    # CLI (see T3544) we now ask ISC dhcpd to test the newly rendered
-    # configuration
-    tmp = run(f'/usr/sbin/dhcpd -4 -q -t -cf {tmp_file}')
-    if tmp > 0:
-        if os.path.exists(tmp_file):
-            os.unlink(tmp_file)
-        raise ConfigError('Configuration file errors encountered - check your options!')
-
-    # Now that we know that the newly rendered configuration is "good" we can
-    # render the "real" configuration
-    render(config_file, 'dhcp-server/dhcpd.conf.j2', dhcp,
-           formater=lambda _: _.replace("&quot;", '"'))
-    render(systemd_override, 'dhcp-server/10-override.conf.j2', dhcp)
-
-    # Clean up configuration test file
-    if os.path.exists(tmp_file):
-        os.unlink(tmp_file)
+    dhcp['lease_file'] = lease_file
+    dhcp['machine'] = os.uname().machine
+
+    if not os.path.exists(lease_file):
+        write_file(lease_file, '', user='_kea', group='vyattacfg', mode=0o755)
+
+    for f in [cert_file, cert_key_file, ca_cert_file]:
+        if os.path.exists(f):
+            os.unlink(f)
+
+    if 'failover' in dhcp:
+        if 'certificate' in dhcp['failover']:
+            cert_name = dhcp['failover']['certificate']
+            cert_data = dhcp['pki']['certificate'][cert_name]['certificate']
+            key_data = dhcp['pki']['certificate'][cert_name]['private']['key']
+            write_file(cert_file, wrap_certificate(cert_data), user='_kea', mode=0o600)
+            write_file(cert_key_file, wrap_private_key(key_data), user='_kea', mode=0o600)
+
+            dhcp['failover']['cert_file'] = cert_file
+            dhcp['failover']['cert_key_file'] = cert_key_file
+
+        if 'ca_certificate' in dhcp['failover']:
+            ca_cert_name = dhcp['failover']['ca_certificate']
+            ca_cert_data = dhcp['pki']['ca'][ca_cert_name]['certificate']
+            write_file(ca_cert_file, wrap_certificate(ca_cert_data), user='_kea', mode=0o600)
+
+            dhcp['failover']['ca_cert_file'] = ca_cert_file
+
+    render(ctrl_config_file, 'dhcp-server/kea-ctrl-agent.conf.j2', dhcp)
+    render(config_file, 'dhcp-server/kea-dhcp4.conf.j2', dhcp)
 
     return None
 
 def apply(dhcp):
-    call('systemctl daemon-reload')
-    # bail out early - looks like removal from running config
+    services = ['kea-ctrl-agent', 'kea-dhcp4-server', 'kea-dhcp-ddns-server']
+
     if not dhcp or 'disable' in dhcp:
-        call('systemctl stop isc-dhcp-server.service')
+        for service in services:
+            call(f'systemctl stop {service}.service')
+
         if os.path.exists(config_file):
             os.unlink(config_file)
 
         return None
 
-    call('systemctl restart isc-dhcp-server.service')
+    for service in services:
+        action = 'restart'
+
+        if service == 'kea-dhcp-ddns-server' and 'dynamic_dns_update' not in dhcp:
+            action = 'stop'
+
+        if service == 'kea-ctrl-agent' and 'failover' not in dhcp:
+            action = 'stop'
+
+        call(f'systemctl {action} {service}.service')
+
+    # op-mode needs ctrl socket permission change
+    i = 0
+    while not os.path.exists(ctrl_socket):
+        if i > 15:
+            break
+        i += 1
+        sleep(1)
+    chmod_775(ctrl_socket)
+
     return None
 
 if __name__ == '__main__':
diff --git a/src/conf_mode/dhcpv6_server.py b/src/conf_mode/dhcpv6_server.py
index 427001609..73a708ff5 100755
--- a/src/conf_mode/dhcpv6_server.py
+++ b/src/conf_mode/dhcpv6_server.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 #
-# Copyright (C) 2018-2022 VyOS maintainers and contributors
+# Copyright (C) 2018-2023 VyOS maintainers and contributors
 #
 # This program is free software; you can redistribute it and/or modify
 # it under the terms of the GNU General Public License version 2 or later as
@@ -19,18 +19,23 @@ import os
 from ipaddress import ip_address
 from ipaddress import ip_network
 from sys import exit
+from time import sleep
 
 from vyos.config import Config
 from vyos.template import render
 from vyos.template import is_ipv6
 from vyos.utils.process import call
+from vyos.utils.file import chmod_775
+from vyos.utils.file import write_file
 from vyos.utils.dict import dict_search
 from vyos.utils.network import is_subnet_connected
 from vyos import ConfigError
 from vyos import airbag
 airbag.enable()
 
-config_file = '/run/dhcp-server/dhcpdv6.conf'
+config_file = '/run/kea/kea-dhcp6.conf'
+ctrl_socket = '/run/kea/dhcp6-ctrl-socket'
+lease_file = '/config/dhcp6.leases'
 
 def get_config(config=None):
     if config:
@@ -110,17 +115,20 @@ def verify(dhcpv6):
 
             # Prefix delegation sanity checks
             if 'prefix_delegation' in subnet_config:
-                if 'start' not in subnet_config['prefix_delegation']:
-                    raise ConfigError('prefix-delegation start address not defined!')
+                if 'prefix' not in subnet_config['prefix_delegation']:
+                    raise ConfigError('prefix-delegation prefix not defined!')
 
-                for prefix, prefix_config in subnet_config['prefix_delegation']['start'].items():
-                    if 'stop' not in prefix_config:
-                        raise ConfigError(f'Stop address of delegated IPv6 prefix range "{prefix}" '\
+                for prefix, prefix_config in subnet_config['prefix_delegation']['prefix'].items():
+                    if 'delegated_length' not in prefix_config:
+                        raise ConfigError(f'Delegated IPv6 prefix length for "{prefix}" '\
                                           f'must be configured')
 
                     if 'prefix_length' not in prefix_config:
                         raise ConfigError('Length of delegated IPv6 prefix must be configured')
 
+                    if prefix_config['prefix_length'] > prefix_config['delegated_length']:
+                        raise ConfigError('Length of delegated IPv6 prefix must be within parent prefix')
+
             # Static mappings don't require anything (but check if IP is in subnet if it's set)
             if 'static_mapping' in subnet_config:
                 for mapping, mapping_config in subnet_config['static_mapping'].items():
@@ -168,12 +176,18 @@ def generate(dhcpv6):
     if not dhcpv6 or 'disable' in dhcpv6:
         return None
 
-    render(config_file, 'dhcp-server/dhcpdv6.conf.j2', dhcpv6)
+    dhcpv6['lease_file'] = lease_file
+    dhcpv6['machine'] = os.uname().machine
+
+    if not os.path.exists(lease_file):
+        write_file(lease_file, '', user='_kea', group='vyattacfg', mode=0o755)
+
+    render(config_file, 'dhcp-server/kea-dhcp6.conf.j2', dhcpv6)
     return None
 
 def apply(dhcpv6):
     # bail out early - looks like removal from running config
-    service_name = 'isc-dhcp-server6.service'
+    service_name = 'kea-dhcp6-server.service'
     if not dhcpv6 or 'disable' in dhcpv6:
         # DHCP server is removed in the commit
         call(f'systemctl stop {service_name}')
@@ -182,6 +196,16 @@ def apply(dhcpv6):
         return None
 
     call(f'systemctl restart {service_name}')
+
+    # op-mode needs ctrl socket permission change
+    i = 0
+    while not os.path.exists(ctrl_socket):
+        if i > 15:
+            break
+        i += 1
+        sleep(1)
+    chmod_775(ctrl_socket)
+
     return None
 
 if __name__ == '__main__':
diff --git a/src/conf_mode/system-login.py b/src/conf_mode/system-login.py
index 87a269499..3bee961fc 100755
--- a/src/conf_mode/system-login.py
+++ b/src/conf_mode/system-login.py
@@ -330,7 +330,7 @@ def apply(login):
             if tmp: command += f" --home '{tmp}'"
             else: command += f" --home '/home/{user}'"
 
-            command += f' --groups frr,frrvty,vyattacfg,sudo,adm,dip,disk {user}'
+            command += f' --groups frr,frrvty,vyattacfg,sudo,adm,dip,disk,_kea {user}'
             try:
                 cmd(command)
 
diff --git a/src/etc/systemd/system/kea-ctrl-agent.service.d/override.conf b/src/etc/systemd/system/kea-ctrl-agent.service.d/override.conf
new file mode 100644
index 000000000..0f5bf801e
--- /dev/null
+++ b/src/etc/systemd/system/kea-ctrl-agent.service.d/override.conf
@@ -0,0 +1,9 @@
+[Unit]
+After=
+After=vyos-router.service
+
+[Service]
+ExecStart=
+ExecStart=/usr/sbin/kea-ctrl-agent -c /run/kea/kea-ctrl-agent.conf
+AmbientCapabilities=CAP_NET_BIND_SERVICE
+CapabilityBoundingSet=CAP_NET_BIND_SERVICE
diff --git a/src/etc/systemd/system/kea-dhcp4-server.service.d/override.conf b/src/etc/systemd/system/kea-dhcp4-server.service.d/override.conf
new file mode 100644
index 000000000..682e5bbce
--- /dev/null
+++ b/src/etc/systemd/system/kea-dhcp4-server.service.d/override.conf
@@ -0,0 +1,7 @@
+[Unit]
+After=
+After=vyos-router.service
+
+[Service]
+ExecStart=
+ExecStart=/usr/sbin/kea-dhcp4 -c /run/kea/kea-dhcp4.conf
diff --git a/src/etc/systemd/system/kea-dhcp6-server.service.d/override.conf b/src/etc/systemd/system/kea-dhcp6-server.service.d/override.conf
new file mode 100644
index 000000000..cb33fc057
--- /dev/null
+++ b/src/etc/systemd/system/kea-dhcp6-server.service.d/override.conf
@@ -0,0 +1,7 @@
+[Unit]
+After=
+After=vyos-router.service
+
+[Service]
+ExecStart=
+ExecStart=/usr/sbin/kea-dhcp6 -c /run/kea/kea-dhcp6.conf
diff --git a/src/migration-scripts/dhcp-server/6-to-7 b/src/migration-scripts/dhcp-server/6-to-7
new file mode 100755
index 000000000..ccf385a30
--- /dev/null
+++ b/src/migration-scripts/dhcp-server/6-to-7
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+# T3316: Migrate to Kea
+#        - global-parameters will not function
+#        - shared-network-parameters will not function
+#        - subnet-parameters will not function
+#        - static-mapping-parameters will not function
+#        - host-decl-name is on by default, option removed
+#        - ping-check no longer supported
+#        - failover is default enabled on all subnets that exist on failover servers
+
+import sys
+from vyos.configtree import ConfigTree
+
+if (len(sys.argv) < 2):
+    print("Must specify file name!")
+    sys.exit(1)
+
+file_name = sys.argv[1]
+
+with open(file_name, 'r') as f:
+    config_file = f.read()
+
+base = ['service', 'dhcp-server']
+config = ConfigTree(config_file)
+
+if not config.exists(base):
+    # Nothing to do
+    sys.exit(0)
+
+if config.exists(base + ['host-decl-name']):
+    config.delete(base + ['host-decl-name'])
+
+if config.exists(base + ['global-parameters']):
+    config.delete(base + ['global-parameters'])
+
+if config.exists(base + ['shared-network-name']):
+    for network in config.list_nodes(base + ['shared-network-name']):
+        base_network = base + ['shared-network-name', network]
+
+        if config.exists(base_network + ['ping-check']):
+            config.delete(base_network + ['ping-check'])
+
+        if config.exists(base_network + ['shared-network-parameters']):
+            config.delete(base_network +['shared-network-parameters'])
+
+        if not config.exists(base_network + ['subnet']):
+            continue
+
+        # Run this for every specified 'subnet'
+        for subnet in config.list_nodes(base_network + ['subnet']):
+            base_subnet = base_network + ['subnet', subnet]
+
+            if config.exists(base_subnet + ['enable-failover']):
+                config.delete(base_subnet + ['enable-failover'])
+
+            if config.exists(base_subnet + ['ping-check']):
+                config.delete(base_subnet + ['ping-check'])
+
+            if config.exists(base_subnet + ['subnet-parameters']):
+                config.delete(base_subnet + ['subnet-parameters'])
+
+            if config.exists(base_subnet + ['static-mapping']):
+                for mapping in config.list_nodes(base_subnet + ['static-mapping']):
+                    if config.exists(base_subnet + ['static-mapping', mapping, 'static-mapping-parameters']):
+                        config.delete(base_subnet + ['static-mapping', mapping, 'static-mapping-parameters'])
+
+try:
+    with open(file_name, 'w') as f:
+        f.write(config.to_string())
+except OSError as e:
+    print("Failed to save the modified config: {}".format(e))
+    exit(1)
diff --git a/src/migration-scripts/dhcpv6-server/1-to-2 b/src/migration-scripts/dhcpv6-server/1-to-2
new file mode 100755
index 000000000..cc5a8900a
--- /dev/null
+++ b/src/migration-scripts/dhcpv6-server/1-to-2
@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+# T3316: Migrate to Kea
+# - Kea was meant to have support for key "prefix-highest" under PD which would allow an address range
+#   However this seems to have never been implemented. A conversion to prefix length is needed (where possible).
+#   Ref: https://lists.isc.org/pipermail/kea-users/2022-November/003686.html
+# - Remove prefix temporary value, convert to multi leafNode (https://kea.readthedocs.io/en/kea-2.2.0/arm/dhcp6-srv.html#dhcpv6-server-limitations)
+
+import sys
+from vyos.configtree import ConfigTree
+from vyos.utils.network import ipv6_prefix_length
+
+if (len(sys.argv) < 1):
+    print("Must specify file name!")
+    sys.exit(1)
+
+file_name = sys.argv[1]
+
+with open(file_name, 'r') as f:
+    config_file = f.read()
+
+base = ['service', 'dhcpv6-server', 'shared-network-name']
+config = ConfigTree(config_file)
+
+if not config.exists(base):
+    # Nothing to do
+    exit(0)
+
+for network in config.list_nodes(base):
+    if not config.exists(base + [network, 'subnet']):
+        continue
+
+    for subnet in config.list_nodes(base + [network, 'subnet']):
+        # Delete temporary value under address-range prefix, convert tagNode to leafNode multi
+        if config.exists(base + [network, 'subnet', subnet, 'address-range', 'prefix']):
+            prefix_base = base + [network, 'subnet', subnet, 'address-range', 'prefix']
+            prefixes = config.list_nodes(prefix_base)
+            
+            config.delete(prefix_base)
+
+            for prefix in prefixes:
+                config.set(prefix_base, value=prefix, replace=False)
+
+        if config.exists(base + [network, 'subnet', subnet, 'prefix-delegation', 'prefix']):
+            prefix_base = base + [network, 'subnet', subnet, 'prefix-delegation', 'prefix']
+
+            config.set(prefix_base)
+            config.set_tag(prefix_base)
+
+            for start in config.list_nodes(base + [network, 'subnet', subnet, 'prefix-delegation', 'start']):
+                path = base + [network, 'subnet', subnet, 'prefix-delegation', 'start', start]
+
+                delegated_length = config.return_value(path + ['prefix-length'])
+                stop = config.return_value(path + ['stop'])
+
+                prefix_length = ipv6_prefix_length(start, stop)
+
+                # This range could not be converted into a simple prefix length and must be skipped
+                if not prefix_length:
+                    continue
+
+                config.set(prefix_base + [start, 'delegated-length'], value=delegated_length)
+                config.set(prefix_base + [start, 'prefix-length'], value=prefix_length)
+
+            config.delete(base + [network, 'subnet', subnet, 'prefix-delegation', 'start'])
+
+try:
+    with open(file_name, 'w') as f:
+        f.write(config.to_string())
+except OSError as e:
+    print("Failed to save the modified config: {}".format(e))
+    exit(1)
diff --git a/src/op_mode/clear_dhcp_lease.py b/src/op_mode/clear_dhcp_lease.py
index f372d3af0..2c95a2b08 100755
--- a/src/op_mode/clear_dhcp_lease.py
+++ b/src/op_mode/clear_dhcp_lease.py
@@ -1,20 +1,34 @@
 #!/usr/bin/env python3
+#
+# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library.  If not, see <http://www.gnu.org/licenses/>.
 
 import argparse
 import re
 
-from isc_dhcp_leases import Lease
-from isc_dhcp_leases import IscDhcpLeases
-
 from vyos.configquery import ConfigTreeQuery
+from vyos.kea import kea_parse_leases
 from vyos.utils.io import ask_yes_no
 from vyos.utils.process import call
 from vyos.utils.commit import commit_in_progress
 
+# TODO: Update to use Kea control socket command "lease4-del"
 
 config = ConfigTreeQuery()
 base = ['service', 'dhcp-server']
-lease_file = '/config/dhcpd.leases'
+lease_file = '/config/dhcp4.leases'
 
 
 def del_lease_ip(address):
@@ -25,8 +39,7 @@ def del_lease_ip(address):
     """
     with open(lease_file, encoding='utf-8') as f:
         data = f.read().rstrip()
-        lease_config_ip = '{(?P<config>[\s\S]+?)\n}'
-        pattern = rf"lease {address} {lease_config_ip}"
+        pattern = rf"^{address},[^\n]+\n"
         # Delete lease for ip block
         data = re.sub(pattern, '', data)
 
@@ -38,15 +51,13 @@ def is_ip_in_leases(address):
     """
     Return True if address found in the lease file
     """
-    leases = IscDhcpLeases(lease_file)
+    leases = kea_parse_leases(lease_file)
     lease_ips = []
-    for lease in leases.get():
-        lease_ips.append(lease.ip)
-    if address not in lease_ips:
-        print(f'Address "{address}" not found in "{lease_file}"')
-        return False
-    return True
-
+    for lease in leases:
+        if address == lease['address']:
+            return True
+    print(f'Address "{address}" not found in "{lease_file}"')
+    return False
 
 if not config.exists(base):
     print('DHCP-server not configured!')
@@ -75,4 +86,4 @@ if __name__ == '__main__':
         exit(1)
     else:
         del_lease_ip(address)
-        call('systemctl restart isc-dhcp-server.service')
+        call('systemctl restart kea-dhcp4-server.service')
diff --git a/src/op_mode/dhcp.py b/src/op_mode/dhcp.py
index d6b8aa0b8..bd2c522ca 100755
--- a/src/op_mode/dhcp.py
+++ b/src/op_mode/dhcp.py
@@ -21,7 +21,6 @@ import typing
 from datetime import datetime
 from glob import glob
 from ipaddress import ip_address
-from isc_dhcp_leases import IscDhcpLeases
 from tabulate import tabulate
 
 import vyos.opmode
@@ -29,6 +28,9 @@ import vyos.opmode
 from vyos.base import Warning
 from vyos.configquery import ConfigTreeQuery
 
+from vyos.kea import kea_get_active_config
+from vyos.kea import kea_get_pool_from_subnet_id
+from vyos.kea import kea_parse_leases
 from vyos.utils.dict import dict_search
 from vyos.utils.file import read_file
 from vyos.utils.process import cmd
@@ -77,67 +79,62 @@ def _get_raw_server_leases(family='inet', pool=None, sorted=None, state=[], orig
     Get DHCP server leases
     :return list
     """
-    lease_file = '/config/dhcpdv6.leases' if family == 'inet6' else '/config/dhcpd.leases'
+    lease_file = '/config/dhcp6.leases' if family == 'inet6' else '/config/dhcp4.leases'
     data = []
-    leases = IscDhcpLeases(lease_file).get()
+    leases = kea_parse_leases(lease_file)
 
     if pool is None:
         pool = _get_dhcp_pools(family=family)
-        aux = False
     else:
         pool = [pool]
-        aux = True
-
-    ## Search leases for every pool
-    for pool_name in pool:
-        for lease in leases:
-            if lease.sets.get('shared-networkname', '') == pool_name or lease.sets.get('shared-networkname', '') == '':
-            #if lease.sets.get('shared-networkname', '') == pool_name:
-                data_lease = {}
-                data_lease['ip'] = lease.ip
-                data_lease['state'] = lease.binding_state
-                #data_lease['pool'] = pool_name if lease.sets.get('shared-networkname', '') != '' else 'Fail-Over Server'
-                data_lease['pool'] = lease.sets.get('shared-networkname', '')
-                data_lease['end'] = lease.end.timestamp() if lease.end else None
-                data_lease['origin'] = 'local' if data_lease['pool'] != '' else 'remote'
-
-                if family == 'inet':
-                    data_lease['mac'] = lease.ethernet
-                    data_lease['start'] = lease.start.timestamp()
-                    data_lease['hostname'] = lease.hostname
-
-                if family == 'inet6':
-                    data_lease['last_communication'] = lease.last_communication.timestamp()
-                    data_lease['iaid_duid'] = _format_hex_string(lease.host_identifier_string)
-                    lease_types_long = {'na': 'non-temporary', 'ta': 'temporary', 'pd': 'prefix delegation'}
-                    data_lease['type'] = lease_types_long[lease.type]
-
-                data_lease['remaining'] = '-'
-
-                if lease.end:
-                    data_lease['remaining'] = lease.end - datetime.utcnow()
-
-                    if data_lease['remaining'].days >= 0:
-                        # substraction gives us a timedelta object which can't be formatted with strftime
-                        # so we use str(), split gets rid of the microseconds
-                        data_lease['remaining'] = str(data_lease["remaining"]).split('.')[0]
-
-                # Do not add old leases
-                if data_lease['remaining'] != '' and data_lease['state'] != 'free':
-                    if not state or data_lease['state'] in state or state == 'all':
-                        if not origin or data_lease['origin'] in origin:
-                            if not aux or (aux and data_lease['pool'] == pool_name):
-                                data.append(data_lease)
-
-                # deduplicate
-                checked = []
-                for entry in data:
-                    addr = entry.get('ip')
-                    if addr not in checked:
-                        checked.append(addr)
-                    else:
-                        idx = _find_list_of_dict_index(data, key='ip', value=addr)
-                        data.pop(idx)
+
+    inet_suffix = '6' if family == 'inet6' else '4'
+    active_config = kea_get_active_config(inet_suffix)
+
+    for lease in leases:
+        data_lease = {}
+        data_lease['ip'] = lease['address']
+        lease_state_long = {'0': 'active', '1': 'rejected', '2': 'expired'}
+        data_lease['state'] = lease_state_long[lease['state']]
+        data_lease['pool'] = kea_get_pool_from_subnet_id(active_config, inet_suffix, lease['subnet_id']) if active_config else '-'
+        data_lease['end'] = lease['expire_timestamp'].timestamp() if lease['expire_timestamp'] else None
+        data_lease['origin'] = 'local' # TODO: Determine remote in HA
+
+        if family == 'inet':
+            data_lease['mac'] = lease['hwaddr']
+            data_lease['start'] = lease['start_timestamp']
+            data_lease['hostname'] = lease['hostname']
+
+        if family == 'inet6':
+            data_lease['last_communication'] = lease['start_timestamp']
+            data_lease['iaid_duid'] = _format_hex_string(lease['duid'])
+            lease_types_long = {'0': 'non-temporary', '1': 'temporary', '2': 'prefix delegation'}
+            data_lease['type'] = lease_types_long[lease['lease_type']]
+
+        data_lease['remaining'] = '-'
+
+        if lease['expire']:
+            data_lease['remaining'] = lease['expire_timestamp'] - datetime.utcnow()
+
+            if data_lease['remaining'].days >= 0:
+                # substraction gives us a timedelta object which can't be formatted with strftime
+                # so we use str(), split gets rid of the microseconds
+                data_lease['remaining'] = str(data_lease["remaining"]).split('.')[0]
+
+        # Do not add old leases
+        if data_lease['remaining'] != '' and data_lease['pool'] in pool and data_lease['state'] != 'free':
+            if not state or data_lease['state'] in state:
+                data.append(data_lease)
+
+        # deduplicate
+        checked = []
+        for entry in data:
+            addr = entry.get('ip')
+            if addr not in checked:
+                checked.append(addr)
+            else:
+                idx = _find_list_of_dict_index(data, key='ip', value=addr)
+                data.pop(idx)
 
     if sorted:
         if sorted == 'ip':
@@ -154,7 +151,7 @@ def _get_formatted_server_leases(raw_data, family='inet'):
             ipaddr = lease.get('ip')
             hw_addr = lease.get('mac')
             state = lease.get('state')
-            start = lease.get('start')
+            start = lease.get('start').timestamp()
             start =  _utc_to_local(start).strftime('%Y/%m/%d %H:%M:%S')
             end = lease.get('end')
             end =  _utc_to_local(end).strftime('%Y/%m/%d %H:%M:%S') if end else '-'
@@ -171,7 +168,7 @@ def _get_formatted_server_leases(raw_data, family='inet'):
         for lease in raw_data:
             ipaddr = lease.get('ip')
             state = lease.get('state')
-            start = lease.get('last_communication')
+            start = lease.get('last_communication').timestamp()
             start =  _utc_to_local(start).strftime('%Y/%m/%d %H:%M:%S')
             end = lease.get('end')
             end =  _utc_to_local(end).strftime('%Y/%m/%d %H:%M:%S')
@@ -282,10 +279,9 @@ def show_server_leases(raw: bool, family: ArgFamily, pool: typing.Optional[str],
                        sorted: typing.Optional[str], state: typing.Optional[ArgState],
                        origin: typing.Optional[ArgOrigin] ):
     # if dhcp server is down, inactive leases may still be shown as active, so warn the user.
-    v = '6' if family == 'inet6' else ''
-    service_name = 'DHCPv6' if family == 'inet6' else 'DHCP'
-    if not is_systemd_service_running(f'isc-dhcp-server{v}.service'):
-        Warning(f'{service_name} server is configured but not started. Data may be stale.')
+    v = '6' if family == 'inet6' else '4'
+    if not is_systemd_service_running(f'kea-dhcp{v}-server.service'):
+        Warning('DHCP server is configured but not started. Data may be stale.')
 
     v = 'v6' if family == 'inet6' else ''
     if pool and pool not in _get_dhcp_pools(family=family):
diff --git a/src/system/on-dhcp-event.sh b/src/system/on-dhcp-event.sh
index 49e53d7e1..7b25bf338 100755
--- a/src/system/on-dhcp-event.sh
+++ b/src/system/on-dhcp-event.sh
@@ -15,20 +15,20 @@ if [ $# -lt 5 ]; then
 fi
 
 action=$1
-client_name=$2
-client_ip=$3
-client_mac=$4
-domain=$5
+client_name=$LEASE4_HOSTNAME
+client_ip=$LEASE4_ADDRESS
+client_mac=$LEASE4_HWADDR
+domain=$(echo "$client_name" | cut -d"." -f2-)
 hostsd_client="/usr/bin/vyos-hostsd-client"
 
 case "$action" in
-  commit) # add mapping for new lease
+  leases4_renew|lease4_recover) # add mapping for new lease
     if [ -z "$client_name" ]; then
         logger -s -t on-dhcp-event "Client name was empty, using MAC \"$client_mac\" instead"
         client_name=$(echo "client-"$client_mac | tr : -)
     fi
 
-    if [ "$domain" == "..YYZ!" ]; then
+    if [ -z "$domain" ]; then
         client_fqdn_name=$client_name
         client_search_expr=$client_name
     else
@@ -39,7 +39,7 @@ case "$action" in
     exit 0
     ;;
 
-  release) # delete mapping for released address
+  lease4_release|lease4_expire) # delete mapping for released address)
     $hostsd_client --delete-hosts --tag "dhcp-server-$client_ip" --apply
     exit 0
     ;;
diff --git a/src/systemd/isc-dhcp-server6.service b/src/systemd/isc-dhcp-server6.service
deleted file mode 100644
index 1345c5fc5..000000000
--- a/src/systemd/isc-dhcp-server6.service
+++ /dev/null
@@ -1,24 +0,0 @@
-[Unit]
-Description=ISC DHCP IPv6 server
-Documentation=man:dhcpd(8)
-RequiresMountsFor=/run
-ConditionPathExists=/run/dhcp-server/dhcpdv6.conf
-After=vyos-router.service
-
-[Service]
-Type=forking
-WorkingDirectory=/run/dhcp-server
-RuntimeDirectory=dhcp-server
-RuntimeDirectoryPreserve=yes
-Environment=PID_FILE=/run/dhcp-server/dhcpdv6.pid CONFIG_FILE=/run/dhcp-server/dhcpdv6.conf LEASE_FILE=/config/dhcpdv6.leases
-PIDFile=/run/dhcp-server/dhcpdv6.pid
-ExecStartPre=/bin/sh -ec '\
-touch ${LEASE_FILE}; \
-chown nobody:nogroup ${LEASE_FILE}* ; \
-chmod 664 ${LEASE_FILE}* ; \
-/usr/sbin/dhcpd -6 -t -T -q -user dhcpd -group nogroup -pf ${PID_FILE} -cf ${CONFIG_FILE} -lf ${LEASE_FILE} '
-ExecStart=/usr/sbin/dhcpd -6 -q -user dhcpd -group nogroup -pf ${PID_FILE} -cf ${CONFIG_FILE} -lf ${LEASE_FILE}
-Restart=always
-
-[Install]
-WantedBy=multi-user.target
-- 
cgit v1.2.3