From daffee2cbf001dab13799f5b2b69330162491214 Mon Sep 17 00:00:00 2001
From: sarthurdev <965089+sarthurdev@users.noreply.github.com>
Date: Sun, 7 Jan 2024 09:24:10 +0100
Subject: dhcp: T3316: Move options to separate node and extend scopes

---
 interface-definitions/include/dhcp/option-v4.xml.i | 257 ++++++++++++++++++++
 .../include/version/dhcp-server-version.xml.i      |   2 +-
 interface-definitions/service_dhcp-server.xml.in   | 258 +--------------------
 python/vyos/kea.py                                 |  56 +++--
 python/vyos/template.py                            |  15 +-
 smoketest/scripts/cli/test_service_dhcp-server.py  | 132 ++++++++---
 src/migration-scripts/dhcp-server/8-to-9           |  69 ++++++
 7 files changed, 480 insertions(+), 309 deletions(-)
 create mode 100644 interface-definitions/include/dhcp/option-v4.xml.i
 create mode 100755 src/migration-scripts/dhcp-server/8-to-9

diff --git a/interface-definitions/include/dhcp/option-v4.xml.i b/interface-definitions/include/dhcp/option-v4.xml.i
new file mode 100644
index 000000000..bd6fc6043
--- /dev/null
+++ b/interface-definitions/include/dhcp/option-v4.xml.i
@@ -0,0 +1,257 @@
+<!-- include start from dhcp/option-v4.xml.i -->
+<node name="option">
+  <properties>
+    <help>DHCP option</help>
+  </properties>
+  <children>
+    #include <include/dhcp/captive-portal.xml.i>
+    #include <include/dhcp/domain-name.xml.i>
+    #include <include/dhcp/domain-search.xml.i>
+    #include <include/dhcp/ntp-server.xml.i>
+    #include <include/name-server-ipv4.xml.i>
+    <leafNode name="bootfile-name">
+      <properties>
+        <help>Bootstrap file name</help>
+        <constraint>
+          <regex>[[:ascii:]]{1,253}</regex>
+        </constraint>
+      </properties>
+    </leafNode>
+    <leafNode name="bootfile-server">
+      <properties>
+        <help>Server from which the initial boot file is to be loaded</help>
+        <valueHelp>
+          <format>ipv4</format>
+          <description>Bootfile server IPv4 address</description>
+        </valueHelp>
+        <valueHelp>
+          <format>hostname</format>
+          <description>Bootfile server FQDN</description>
+        </valueHelp>
+        <constraint>
+          <validator name="ipv4-address"/>
+          <validator name="fqdn"/>
+        </constraint>
+      </properties>
+    </leafNode>
+    <leafNode name="bootfile-size">
+      <properties>
+        <help>Bootstrap file size</help>
+        <valueHelp>
+          <format>u32:1-16</format>
+          <description>Bootstrap file size in 512 byte blocks</description>
+        </valueHelp>
+        <constraint>
+          <validator name="numeric" argument="--range 1-16"/>
+        </constraint>
+      </properties>
+    </leafNode>
+    <leafNode name="client-prefix-length">
+      <properties>
+        <help>Specifies the clients subnet mask as per RFC 950. If unset, subnet declaration is used.</help>
+        <valueHelp>
+          <format>u32:0-32</format>
+          <description>DHCP client prefix length must be 0 to 32</description>
+        </valueHelp>
+        <constraint>
+          <validator name="numeric" argument="--range 0-32"/>
+        </constraint>
+        <constraintErrorMessage>DHCP client prefix length must be 0 to 32</constraintErrorMessage>
+      </properties>
+    </leafNode>
+    <leafNode name="default-router">
+      <properties>
+        <help>IP address of default router</help>
+        <valueHelp>
+          <format>ipv4</format>
+          <description>Default router IPv4 address</description>
+        </valueHelp>
+        <constraint>
+          <validator name="ipv4-address"/>
+        </constraint>
+      </properties>
+    </leafNode>
+    <leafNode name="ip-forwarding">
+      <properties>
+        <help>Enable IP forwarding on client</help>
+        <valueless/>
+      </properties>
+    </leafNode>
+    <leafNode name="ipv6-only-preferred">
+      <properties>
+        <help>Disable IPv4 on IPv6 only hosts (RFC 8925)</help>
+        <valueHelp>
+          <format>u32</format>
+          <description>Seconds</description>
+        </valueHelp>
+        <constraint>
+          <validator name="numeric" argument="--range 0-4294967295"/>
+        </constraint>
+        <constraintErrorMessage>Seconds must be between 0 and 4294967295 (49 days)</constraintErrorMessage>
+      </properties>
+    </leafNode>
+    <leafNode name="pop-server">
+      <properties>
+        <help>IP address of POP3 server</help>
+        <valueHelp>
+          <format>ipv4</format>
+          <description>POP3 server IPv4 address</description>
+        </valueHelp>
+        <constraint>
+          <validator name="ipv4-address"/>
+        </constraint>
+        <multi/>
+      </properties>
+    </leafNode>
+    <leafNode name="server-identifier">
+      <properties>
+        <help>Address for DHCP server identifier</help>
+        <valueHelp>
+          <format>ipv4</format>
+          <description>DHCP server identifier IPv4 address</description>
+        </valueHelp>
+        <constraint>
+          <validator name="ipv4-address"/>
+        </constraint>
+      </properties>
+    </leafNode>
+    <leafNode name="smtp-server">
+      <properties>
+        <help>IP address of SMTP server</help>
+        <valueHelp>
+          <format>ipv4</format>
+          <description>SMTP server IPv4 address</description>
+        </valueHelp>
+        <constraint>
+          <validator name="ipv4-address"/>
+        </constraint>
+        <multi/>
+      </properties>
+    </leafNode>
+    <tagNode name="static-route">
+      <properties>
+        <help>Classless static route destination subnet</help>
+        <valueHelp>
+          <format>ipv4net</format>
+          <description>IPv4 address and prefix length</description>
+        </valueHelp>
+        <constraint>
+          <validator name="ipv4-prefix"/>
+        </constraint>
+      </properties>
+      <children>
+        <leafNode name="next-hop">
+          <properties>
+            <help>IP address of router to be used to reach the destination subnet</help>
+            <valueHelp>
+              <format>ipv4</format>
+              <description>IPv4 address of router</description>
+            </valueHelp>
+            <constraint>
+              <validator name="ip-address"/>
+            </constraint>
+          </properties>
+        </leafNode>
+      </children>
+    </tagNode >
+    <leafNode name="tftp-server-name">
+      <properties>
+        <help>TFTP server name</help>
+        <valueHelp>
+          <format>ipv4</format>
+          <description>TFTP server IPv4 address</description>
+        </valueHelp>
+        <valueHelp>
+          <format>hostname</format>
+          <description>TFTP server FQDN</description>
+        </valueHelp>
+        <constraint>
+          <validator name="ipv4-address"/>
+          <validator name="fqdn"/>
+        </constraint>
+      </properties>
+    </leafNode>
+    <leafNode name="time-offset">
+      <properties>
+        <help>Client subnet offset in seconds from Coordinated Universal Time (UTC)</help>
+        <valueHelp>
+          <format>[-]N</format>
+          <description>Time offset (number, may be negative)</description>
+        </valueHelp>
+        <constraint>
+          <regex>-?[0-9]+</regex>
+        </constraint>
+        <constraintErrorMessage>Invalid time offset value</constraintErrorMessage>
+      </properties>
+    </leafNode>
+    <leafNode name="time-server">
+      <properties>
+        <help>IP address of time server</help>
+        <valueHelp>
+          <format>ipv4</format>
+          <description>Time server IPv4 address</description>
+        </valueHelp>
+        <constraint>
+          <validator name="ipv4-address"/>
+        </constraint>
+        <multi/>
+      </properties>
+    </leafNode>
+    <leafNode name="time-zone">
+      <properties>
+        <help>Time zone to send to clients. Uses RFC4833 options 100 and 101</help>
+        <completionHelp>
+          <script>timedatectl list-timezones</script>
+        </completionHelp>
+        <constraint>
+          <validator name="timezone" argument="--validate"/>
+        </constraint>
+      </properties>
+    </leafNode>
+    <node name="vendor-option">
+      <properties>
+        <help>Vendor Specific Options</help>
+      </properties>
+      <children>
+        <node name="ubiquiti">
+          <properties>
+            <help>Ubiquiti specific parameters</help>
+          </properties>
+          <children>
+            <leafNode name="unifi-controller">
+              <properties>
+                <help>Address of UniFi controller</help>
+                <valueHelp>
+                  <format>ipv4</format>
+                  <description>IP address of UniFi controller</description>
+                </valueHelp>
+                <constraint>
+                  <validator name="ipv4-address"/>
+                </constraint>
+              </properties>
+            </leafNode>
+          </children>
+        </node>
+      </children>
+    </node>
+    <leafNode name="wins-server">
+      <properties>
+        <help>IP address for Windows Internet Name Service (WINS) server</help>
+        <valueHelp>
+          <format>ipv4</format>
+          <description>WINS server IPv4 address</description>
+        </valueHelp>
+        <constraint>
+          <validator name="ipv4-address"/>
+        </constraint>
+        <multi/>
+      </properties>
+    </leafNode>
+    <leafNode name="wpad-url">
+      <properties>
+        <help>Web Proxy Autodiscovery (WPAD) URL</help>
+      </properties>
+    </leafNode>
+  </children>
+</node>
+<!-- include end -->
diff --git a/interface-definitions/include/version/dhcp-server-version.xml.i b/interface-definitions/include/version/dhcp-server-version.xml.i
index cc84ea8b9..d83172e72 100644
--- a/interface-definitions/include/version/dhcp-server-version.xml.i
+++ b/interface-definitions/include/version/dhcp-server-version.xml.i
@@ -1,3 +1,3 @@
 <!-- include start from include/version/dhcp-server-version.xml.i -->
-<syntaxVersion component='dhcp-server' version='8'></syntaxVersion>
+<syntaxVersion component='dhcp-server' version='9'></syntaxVersion>
 <!-- include end -->
diff --git a/interface-definitions/service_dhcp-server.xml.in b/interface-definitions/service_dhcp-server.xml.in
index 8e13f9372..a5cee62d1 100644
--- a/interface-definitions/service_dhcp-server.xml.in
+++ b/interface-definitions/service_dhcp-server.xml.in
@@ -89,12 +89,9 @@
                   <valueless/>
                 </properties>
               </leafNode>
-              #include <include/dhcp/domain-name.xml.i>
-              #include <include/dhcp/domain-search.xml.i>
-              #include <include/dhcp/ntp-server.xml.i>
+              #include <include/dhcp/option-v4.xml.i>
               #include <include/generic-description.xml.i>
               #include <include/generic-disable-node.xml.i>
-              #include <include/name-server-ipv4.xml.i>
               <tagNode name="subnet">
                 <properties>
                   <help>DHCP subnet for shared network</help>
@@ -108,73 +105,9 @@
                   <constraintErrorMessage>Invalid IPv4 subnet definition</constraintErrorMessage>
                 </properties>
                 <children>
-                  <leafNode name="bootfile-name">
-                    <properties>
-                      <help>Bootstrap file name</help>
-                      <constraint>
-                        <regex>[[:ascii:]]{1,253}</regex>
-                      </constraint>
-                    </properties>
-                  </leafNode>
-                  <leafNode name="bootfile-server">
-                    <properties>
-                      <help>Server from which the initial boot file is to be loaded</help>
-                      <valueHelp>
-                        <format>ipv4</format>
-                        <description>Bootfile server IPv4 address</description>
-                      </valueHelp>
-                      <valueHelp>
-                        <format>hostname</format>
-                        <description>Bootfile server FQDN</description>
-                      </valueHelp>
-                      <constraint>
-                        <validator name="ipv4-address"/>
-                        <validator name="fqdn"/>
-                      </constraint>
-                    </properties>
-                  </leafNode>
-                  <leafNode name="bootfile-size">
-                    <properties>
-                      <help>Bootstrap file size</help>
-                      <valueHelp>
-                        <format>u32:1-16</format>
-                        <description>Bootstrap file size in 512 byte blocks</description>
-                      </valueHelp>
-                      <constraint>
-                        <validator name="numeric" argument="--range 1-16"/>
-                      </constraint>
-                    </properties>
-                  </leafNode>
-                  #include <include/dhcp/captive-portal.xml.i>
-                  <leafNode name="client-prefix-length">
-                    <properties>
-                      <help>Specifies the clients subnet mask as per RFC 950. If unset, subnet declaration is used.</help>
-                      <valueHelp>
-                        <format>u32:0-32</format>
-                        <description>DHCP client prefix length must be 0 to 32</description>
-                      </valueHelp>
-                      <constraint>
-                        <validator name="numeric" argument="--range 0-32"/>
-                      </constraint>
-                      <constraintErrorMessage>DHCP client prefix length must be 0 to 32</constraintErrorMessage>
-                    </properties>
-                  </leafNode>
-                  <leafNode name="default-router">
-                    <properties>
-                      <help>IP address of default router</help>
-                      <valueHelp>
-                        <format>ipv4</format>
-                        <description>Default router IPv4 address</description>
-                      </valueHelp>
-                      <constraint>
-                        <validator name="ipv4-address"/>
-                      </constraint>
-                    </properties>
-                  </leafNode>
-                  #include <include/dhcp/domain-name.xml.i>
-                  #include <include/dhcp/domain-search.xml.i>
+                  #include <include/dhcp/option-v4.xml.i>
                   #include <include/generic-description.xml.i>
-                  #include <include/name-server-ipv4.xml.i>
+                  #include <include/generic-disable-node.xml.i>
                   <leafNode name="exclude">
                     <properties>
                       <help>IP address to exclude from DHCP lease range</help>
@@ -188,12 +121,6 @@
                       <multi/>
                     </properties>
                   </leafNode>
-                  <leafNode name="ip-forwarding">
-                    <properties>
-                      <help>Enable IP forwarding on client</help>
-                      <valueless/>
-                    </properties>
-                  </leafNode>
                   <leafNode name="lease">
                     <properties>
                       <help>Lease timeout in seconds</help>
@@ -208,45 +135,6 @@
                     </properties>
                     <defaultValue>86400</defaultValue>
                   </leafNode>
-                  #include <include/dhcp/ntp-server.xml.i>
-                  <leafNode name="pop-server">
-                    <properties>
-                      <help>IP address of POP3 server</help>
-                      <valueHelp>
-                        <format>ipv4</format>
-                        <description>POP3 server IPv4 address</description>
-                      </valueHelp>
-                      <constraint>
-                        <validator name="ipv4-address"/>
-                      </constraint>
-                      <multi/>
-                    </properties>
-                  </leafNode>
-                  <leafNode name="server-identifier">
-                    <properties>
-                      <help>Address for DHCP server identifier</help>
-                      <valueHelp>
-                        <format>ipv4</format>
-                        <description>DHCP server identifier IPv4 address</description>
-                      </valueHelp>
-                      <constraint>
-                        <validator name="ipv4-address"/>
-                      </constraint>
-                    </properties>
-                  </leafNode>
-                  <leafNode name="smtp-server">
-                    <properties>
-                      <help>IP address of SMTP server</help>
-                      <valueHelp>
-                        <format>ipv4</format>
-                        <description>SMTP server IPv4 address</description>
-                      </valueHelp>
-                      <constraint>
-                        <validator name="ipv4-address"/>
-                      </constraint>
-                      <multi/>
-                    </properties>
-                  </leafNode>
                   <tagNode name="range">
                     <properties>
                       <help>DHCP lease range</help>
@@ -256,6 +144,7 @@
                       <constraintErrorMessage>Invalid range name, may only be alphanumeric, dot and hyphen</constraintErrorMessage>
                     </properties>
                     <children>
+                      #include <include/dhcp/option-v4.xml.i>
                       <leafNode name="start">
                         <properties>
                           <help>First IP address for DHCP lease range</help>
@@ -291,6 +180,8 @@
                       <constraintErrorMessage>Invalid static mapping hostname</constraintErrorMessage>
                     </properties>
                     <children>
+                      #include <include/dhcp/option-v4.xml.i>
+                      #include <include/generic-description.xml.i>
                       #include <include/generic-disable-node.xml.i>
                       <leafNode name="ip-address">
                         <properties>
@@ -308,143 +199,6 @@
                       #include <include/interface/duid.xml.i>
                     </children>
                   </tagNode>
-                  <tagNode name="static-route">
-                    <properties>
-                      <help>Classless static route destination subnet</help>
-                      <valueHelp>
-                        <format>ipv4net</format>
-                        <description>IPv4 address and prefix length</description>
-                      </valueHelp>
-                      <constraint>
-                        <validator name="ipv4-prefix"/>
-                      </constraint>
-                    </properties>
-                    <children>
-                      <leafNode name="next-hop">
-                        <properties>
-                          <help>IP address of router to be used to reach the destination subnet</help>
-                          <valueHelp>
-                            <format>ipv4</format>
-                            <description>IPv4 address of router</description>
-                          </valueHelp>
-                          <constraint>
-                            <validator name="ip-address"/>
-                          </constraint>
-                        </properties>
-                      </leafNode>
-                    </children>
-                  </tagNode >
-                  <leafNode name="ipv6-only-preferred">
-                    <properties>
-                      <help>Disable IPv4 on IPv6 only hosts (RFC 8925)</help>
-                      <valueHelp>
-                        <format>u32</format>
-                        <description>Seconds</description>
-                      </valueHelp>
-                      <constraint>
-                        <validator name="numeric" argument="--range 0-4294967295"/>
-                      </constraint>
-                      <constraintErrorMessage>Seconds must be between 0 and 4294967295 (49 days)</constraintErrorMessage>
-                    </properties>
-                  </leafNode>
-                  <leafNode name="tftp-server-name">
-                    <properties>
-                      <help>TFTP server name</help>
-                      <valueHelp>
-                        <format>ipv4</format>
-                        <description>TFTP server IPv4 address</description>
-                      </valueHelp>
-                      <valueHelp>
-                        <format>hostname</format>
-                        <description>TFTP server FQDN</description>
-                      </valueHelp>
-                      <constraint>
-                        <validator name="ipv4-address"/>
-                        <validator name="fqdn"/>
-                      </constraint>
-                    </properties>
-                  </leafNode>
-                  <leafNode name="time-offset">
-                    <properties>
-                      <help>Client subnet offset in seconds from Coordinated Universal Time (UTC)</help>
-                      <valueHelp>
-                        <format>[-]N</format>
-                        <description>Time offset (number, may be negative)</description>
-                      </valueHelp>
-                      <constraint>
-                        <regex>-?[0-9]+</regex>
-                      </constraint>
-                      <constraintErrorMessage>Invalid time offset value</constraintErrorMessage>
-                    </properties>
-                  </leafNode>
-                  <leafNode name="time-server">
-                    <properties>
-                      <help>IP address of time server</help>
-                      <valueHelp>
-                        <format>ipv4</format>
-                        <description>Time server IPv4 address</description>
-                      </valueHelp>
-                      <constraint>
-                        <validator name="ipv4-address"/>
-                      </constraint>
-                      <multi/>
-                    </properties>
-                  </leafNode>
-                  <leafNode name="time-zone">
-                    <properties>
-                      <help>Time zone to send to clients. Uses RFC4833 options 100 and 101</help>
-                      <completionHelp>
-                        <script>timedatectl list-timezones</script>
-                      </completionHelp>
-                      <constraint>
-                        <validator name="timezone" argument="--validate"/>
-                      </constraint>
-                    </properties>
-                  </leafNode>
-                  <node name="vendor-option">
-                    <properties>
-                      <help>Vendor Specific Options</help>
-                    </properties>
-                    <children>
-                      <node name="ubiquiti">
-                        <properties>
-                          <help>Ubiquiti specific parameters</help>
-                        </properties>
-                        <children>
-                          <leafNode name="unifi-controller">
-                            <properties>
-                              <help>Address of UniFi controller</help>
-                              <valueHelp>
-                                <format>ipv4</format>
-                                <description>IP address of UniFi controller</description>
-                              </valueHelp>
-                              <constraint>
-                                <validator name="ipv4-address"/>
-                              </constraint>
-                            </properties>
-                          </leafNode>
-                        </children>
-                      </node>
-                    </children>
-                  </node>
-                  <leafNode name="wins-server">
-                    <properties>
-                      <help>IP address for Windows Internet Name Service (WINS) server</help>
-                      <valueHelp>
-                        <format>ipv4</format>
-                        <description>WINS server IPv4 address</description>
-                      </valueHelp>
-                      <constraint>
-                        <validator name="ipv4-address"/>
-                      </constraint>
-                      <multi/>
-                    </properties>
-                  </leafNode>
-                  <leafNode name="wpad-url">
-                    <properties>
-                      <help>Web Proxy Autodiscovery (WPAD) URL</help>
-                    </properties>
-                  </leafNode>
                 </children>
               </tagNode>
             </children>
diff --git a/python/vyos/kea.py b/python/vyos/kea.py
index 819fe16a9..2ca73044b 100644
--- a/python/vyos/kea.py
+++ b/python/vyos/kea.py
@@ -92,17 +92,28 @@ def kea_parse_options(config):
         options.append({'name': 'pcode', 'data': tz_string})
         options.append({'name': 'tcode', 'data': config['time_zone']})
 
+    unifi_controller = dict_search_args(config, 'vendor_option', 'ubiquiti', 'unifi_controller')
+    if unifi_controller:
+        options.append({
+            'name': 'unifi-controller',
+            'data': unifi_controller,
+            'space': 'ubnt'
+        })
+
     return options
 
 def kea_parse_subnet(subnet, config):
     out = {'subnet': subnet}
-    options = kea_parse_options(config)
+    options = []
+
+    if 'option' in config:
+        out['option-data'] = kea_parse_options(config['option'])
 
-    if 'bootfile_name' in config:
-        out['boot-file-name'] = config['bootfile_name']
+        if 'bootfile_name' in config['option']:
+            out['boot-file-name'] = config['option']['bootfile_name']
 
-    if 'bootfile_server' in config:
-        out['next-server'] = config['bootfile_server']
+        if 'bootfile_server' in config['option']:
+            out['next-server'] = config['option']['bootfile_server']
 
     if 'lease' in config:
         out['valid-lifetime'] = int(config['lease'])
@@ -112,7 +123,20 @@ def kea_parse_subnet(subnet, config):
         pools = []
         for num, range_config in config['range'].items():
             start, stop = range_config['start'], range_config['stop']
-            pools.append({'pool': f'{start} - {stop}'})
+            pool = {
+                'pool': f'{start} - {stop}'
+            }
+
+            if 'option' in range_config:
+                pool['option-data'] = kea_parse_options(range_config['option'])
+
+                if 'bootfile_name' in range_config['option']:
+                    pool['boot-file-name'] = range_config['option']['bootfile_name']
+
+                if 'bootfile_server' in range_config['option']:
+                    pool['next-server'] = range_config['option']['bootfile_server']
+
+            pools.append(pool)
         out['pools'] = pools
 
     if 'static_mapping' in config:
@@ -134,19 +158,17 @@ def kea_parse_subnet(subnet, config):
             if 'ip_address' in host_config:
                 reservation['ip-address'] = host_config['ip_address']
 
-            reservations.append(reservation)
-        out['reservations'] = reservations
+            if 'option' in host_config:
+                reservation['option-data'] = kea_parse_options(host_config['option'])
 
-    unifi_controller = dict_search_args(config, 'vendor_option', 'ubiquiti', 'unifi_controller')
-    if unifi_controller:
-        options.append({
-            'name': 'unifi-controller',
-            'data': unifi_controller,
-            'space': 'ubnt'
-        })
+                if 'bootfile_name' in host_config['option']:
+                    reservation['boot-file-name'] = host_config['option']['bootfile_name']
 
-    if options:
-        out['option-data'] = options
+                if 'bootfile_server' in host_config['option']:
+                    reservation['next-server'] = host_config['option']['bootfile_server']
+
+            reservations.append(reservation)
+        out['reservations'] = reservations
 
     return out
 
diff --git a/python/vyos/template.py b/python/vyos/template.py
index 29ea0889b..c0c09f690 100644
--- a/python/vyos/template.py
+++ b/python/vyos/template.py
@@ -842,15 +842,22 @@ def kea_shared_network_json(shared_networks):
             'authoritative': ('authoritative' in config),
             'subnet4': []
         }
-        options = kea_parse_options(config)
+
+        if 'option' in config:
+            network['option-data'] = kea_parse_options(config['option'])
+
+            if 'bootfile_name' in config['option']:
+                network['boot-file-name'] = config['option']['bootfile_name']
+
+            if 'bootfile_server' in config['option']:
+                network['next-server'] = config['option']['bootfile_server']
 
         if 'subnet' in config:
             for subnet, subnet_config in config['subnet'].items():
+                if 'disable' in subnet_config:
+                    continue
                 network['subnet4'].append(kea_parse_subnet(subnet, subnet_config))
 
-        if options:
-            network['option-data'] = options
-
         out.append(network)
 
     return dumps(out, indent=4)
diff --git a/smoketest/scripts/cli/test_service_dhcp-server.py b/smoketest/scripts/cli/test_service_dhcp-server.py
index bf0c09965..99ac406cd 100755
--- a/smoketest/scripts/cli/test_service_dhcp-server.py
+++ b/smoketest/scripts/cli/test_service_dhcp-server.py
@@ -97,10 +97,10 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase):
 
         pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet]
         # we use the first subnet IP address as default gateway
-        self.cli_set(pool + ['default-router', router])
-        self.cli_set(pool + ['name-server', dns_1])
-        self.cli_set(pool + ['name-server', dns_2])
-        self.cli_set(pool + ['domain-name', domain_name])
+        self.cli_set(pool + ['option', 'default-router', router])
+        self.cli_set(pool + ['option', 'name-server', dns_1])
+        self.cli_set(pool + ['option', 'name-server', dns_2])
+        self.cli_set(pool + ['option', 'domain-name', domain_name])
 
         # check validate() - No DHCP address range or active static-mapping set
         with self.assertRaises(ConfigSessionError):
@@ -165,29 +165,26 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase):
 
         pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet]
         # we use the first subnet IP address as default gateway
-        self.cli_set(pool + ['default-router', router])
-        self.cli_set(pool + ['name-server', dns_1])
-        self.cli_set(pool + ['name-server', dns_2])
-        self.cli_set(pool + ['domain-name', domain_name])
-        self.cli_set(pool + ['ip-forwarding'])
-        self.cli_set(pool + ['smtp-server', smtp_server])
-        self.cli_set(pool + ['pop-server', smtp_server])
-        self.cli_set(pool + ['time-server', time_server])
-        self.cli_set(pool + ['tftp-server-name', tftp_server])
+        self.cli_set(pool + ['option', 'default-router', router])
+        self.cli_set(pool + ['option', 'name-server', dns_1])
+        self.cli_set(pool + ['option', 'name-server', dns_2])
+        self.cli_set(pool + ['option', 'domain-name', domain_name])
+        self.cli_set(pool + ['option', 'ip-forwarding'])
+        self.cli_set(pool + ['option', 'smtp-server', smtp_server])
+        self.cli_set(pool + ['option', 'pop-server', smtp_server])
+        self.cli_set(pool + ['option', 'time-server', time_server])
+        self.cli_set(pool + ['option', 'tftp-server-name', tftp_server])
         for search in search_domains:
-            self.cli_set(pool + ['domain-search', search])
-        self.cli_set(pool + ['bootfile-name', bootfile_name])
-        self.cli_set(pool + ['bootfile-server', bootfile_server])
-        self.cli_set(pool + ['wpad-url', wpad])
-        self.cli_set(pool + ['server-identifier', server_identifier])
+            self.cli_set(pool + ['option', 'domain-search', search])
+        self.cli_set(pool + ['option', 'bootfile-name', bootfile_name])
+        self.cli_set(pool + ['option', 'bootfile-server', bootfile_server])
+        self.cli_set(pool + ['option', 'wpad-url', wpad])
+        self.cli_set(pool + ['option', 'server-identifier', server_identifier])
 
-        self.cli_set(pool + ['static-route', '10.0.0.0/24', 'next-hop', '192.0.2.1'])
-        self.cli_set(pool + ['ipv6-only-preferred', ipv6_only_preferred])
-        self.cli_set(pool + ['time-zone', 'Europe/London'])
+        self.cli_set(pool + ['option', 'static-route', '10.0.0.0/24', 'next-hop', '192.0.2.1'])
+        self.cli_set(pool + ['option', 'ipv6-only-preferred', ipv6_only_preferred])
+        self.cli_set(pool + ['option', 'time-zone', 'Europe/London'])
 
-        # check validate() - No DHCP address range or active static-mapping set
-        with self.assertRaises(ConfigSessionError):
-            self.cli_commit()
         self.cli_set(pool + ['range', '0', 'start', range_0_start])
         self.cli_set(pool + ['range', '0', 'stop', range_0_stop])
 
@@ -281,16 +278,81 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase):
         # Check for running process
         self.assertTrue(process_named_running(PROCESS_NAME))
 
+    def test_dhcp_single_pool_options_scoped(self):
+        shared_net_name = 'SMOKE-2'
+
+        range_0_start = inc_ip(subnet, 10)
+        range_0_stop  = inc_ip(subnet, 20)
+
+        range_router = inc_ip(subnet, 5)
+        range_dns_1 = inc_ip(subnet, 6)
+        range_dns_2 = inc_ip(subnet, 7)
+
+        shared_network = base_path + ['shared-network-name', shared_net_name]
+        pool = shared_network + ['subnet', subnet]
+        # we use the first subnet IP address as default gateway
+        self.cli_set(shared_network + ['option', 'default-router', router])
+        self.cli_set(shared_network + ['option', 'name-server', dns_1])
+        self.cli_set(shared_network + ['option', 'name-server', dns_2])
+        self.cli_set(shared_network + ['option', 'domain-name', domain_name])
+
+        self.cli_set(pool + ['range', '0', 'start', range_0_start])
+        self.cli_set(pool + ['range', '0', 'stop', range_0_stop])
+        self.cli_set(pool + ['range', '0', 'option', 'default-router', range_router])
+        self.cli_set(pool + ['range', '0', 'option', 'name-server', range_dns_1])
+        self.cli_set(pool + ['range', '0', 'option', 'name-server', range_dns_2])
+
+        # commit changes
+        self.cli_commit()
+
+        config = read_file(KEA4_CONF)
+        obj = loads(config)
+
+        self.verify_config_value(obj, ['Dhcp4', 'shared-networks'], 'name', shared_net_name)
+        self.verify_config_value(obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'subnet', subnet)
+        self.verify_config_value(obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'valid-lifetime', 86400)
+        self.verify_config_value(obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'max-valid-lifetime', 86400)
+
+        # Verify shared-network options
+        self.verify_config_object(
+                obj,
+                ['Dhcp4', 'shared-networks', 0, 'option-data'],
+                {'name': 'domain-name', 'data': domain_name})
+        self.verify_config_object(
+                obj,
+                ['Dhcp4', 'shared-networks', 0, 'option-data'],
+                {'name': 'domain-name-servers', 'data': f'{dns_1}, {dns_2}'})
+        self.verify_config_object(
+                obj,
+                ['Dhcp4', 'shared-networks', 0, 'option-data'],
+                {'name': 'routers', 'data': router})
+
+        # Verify range options
+        self.verify_config_object(
+                obj,
+                ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'pools', 0, 'option-data'],
+                {'name': 'domain-name-servers', 'data': f'{range_dns_1}, {range_dns_2}'})
+        self.verify_config_object(
+                obj,
+                ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'pools', 0, 'option-data'],
+                {'name': 'routers', 'data': range_router})
+
+        # Verify pool
+        self.verify_config_value(obj, ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'pools'], 'pool', f'{range_0_start} - {range_0_stop}')
+
+        # Check for running process
+        self.assertTrue(process_named_running(PROCESS_NAME))
+
     def test_dhcp_single_pool_static_mapping(self):
         shared_net_name = 'SMOKE-2'
         domain_name = 'private'
 
         pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet]
         # we use the first subnet IP address as default gateway
-        self.cli_set(pool + ['default-router', router])
-        self.cli_set(pool + ['name-server', dns_1])
-        self.cli_set(pool + ['name-server', dns_2])
-        self.cli_set(pool + ['domain-name', domain_name])
+        self.cli_set(pool + ['option', 'default-router', router])
+        self.cli_set(pool + ['option', 'name-server', dns_1])
+        self.cli_set(pool + ['option', 'name-server', dns_2])
+        self.cli_set(pool + ['option', 'domain-name', domain_name])
 
         # check validate() - No DHCP address range or active static-mapping set
         with self.assertRaises(ConfigSessionError):
@@ -365,9 +427,9 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase):
 
             pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet]
             # we use the first subnet IP address as default gateway
-            self.cli_set(pool + ['default-router', router])
-            self.cli_set(pool + ['name-server', dns_1])
-            self.cli_set(pool + ['domain-name', domain_name])
+            self.cli_set(pool + ['option', 'default-router', router])
+            self.cli_set(pool + ['option', 'name-server', dns_1])
+            self.cli_set(pool + ['option', 'domain-name', domain_name])
             self.cli_set(pool + ['lease', lease_time])
 
             self.cli_set(pool + ['range', '0', 'start', range_0_start])
@@ -448,7 +510,7 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase):
         range_0_stop  = inc_ip(subnet, 20)
 
         pool = base_path + ['shared-network-name', 'EXCLUDE-TEST', 'subnet', subnet]
-        self.cli_set(pool + ['default-router', router])
+        self.cli_set(pool + ['option', 'default-router', router])
         self.cli_set(pool + ['exclude', router])
         self.cli_set(pool + ['range', '0', 'start', range_0_start])
         self.cli_set(pool + ['range', '0', 'stop', range_0_stop])
@@ -490,7 +552,7 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase):
         range_0_start_excl = inc_ip(exclude_addr, 1)
 
         pool = base_path + ['shared-network-name', 'EXCLUDE-TEST-2', 'subnet', subnet]
-        self.cli_set(pool + ['default-router', router])
+        self.cli_set(pool + ['option', 'default-router', router])
         self.cli_set(pool + ['exclude', exclude_addr])
         self.cli_set(pool + ['range', '0', 'start', range_0_start])
         self.cli_set(pool + ['range', '0', 'stop', range_0_stop])
@@ -535,7 +597,7 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase):
         range_0_stop  = '10.0.250.255'
 
         pool = base_path + ['shared-network-name', 'RELAY', 'subnet', relay_subnet]
-        self.cli_set(pool + ['default-router', relay_router])
+        self.cli_set(pool + ['option', 'default-router', relay_router])
         self.cli_set(pool + ['range', '0', 'start', range_0_start])
         self.cli_set(pool + ['range', '0', 'stop', range_0_stop])
 
@@ -572,7 +634,7 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase):
 
         pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet]
         # we use the first subnet IP address as default gateway
-        self.cli_set(pool + ['default-router', router])
+        self.cli_set(pool + ['option', 'default-router', router])
 
         # check validate() - No DHCP address range or active static-mapping set
         with self.assertRaises(ConfigSessionError):
diff --git a/src/migration-scripts/dhcp-server/8-to-9 b/src/migration-scripts/dhcp-server/8-to-9
new file mode 100755
index 000000000..908420c18
--- /dev/null
+++ b/src/migration-scripts/dhcp-server/8-to-9
@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2024 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 dhcp options under new option node
+
+import sys
+import re
+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', 'shared-network-name']
+config = ConfigTree(config_file)
+
+if not config.exists(base):
+    # Nothing to do
+    sys.exit(0)
+
+option_nodes = ['bootfile-name', 'bootfile-server', 'bootfile-size', 'captive-portal',
+                'client-prefix-length', 'default-router', 'domain-name', 'domain-search',
+                'name-server', 'ip-forwarding', 'ipv6-only-preferred', 'ntp-server',
+                'pop-server', 'server-identifier', 'smtp-server', 'static-route',
+                'tftp-server-name', 'time-offset', 'time-server', 'time-zone',
+                'vendor-option', 'wins-server', 'wpad-url']
+
+for network in config.list_nodes(base):
+    for option in option_nodes:
+        if config.exists(base + [network, option]):
+            config.set(base + [network, 'option'])
+            config.copy(base + [network, option], base + [network, 'option', option])
+            config.delete(base + [network, option])
+
+    if config.exists(base + [network, 'subnet']):
+        for subnet in config.list_nodes(base + [network, 'subnet']):
+            base_subnet = base + [network, 'subnet', subnet]
+            
+            for option in option_nodes:
+                if config.exists(base + [network, 'subnet', subnet, option]):
+                    config.set(base + [network, 'subnet', subnet, 'option'])
+                    config.copy(base + [network, 'subnet', subnet, option], base + [network, 'subnet', subnet, 'option', option])
+                    config.delete(base + [network, 'subnet', subnet, option])
+
+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)
-- 
cgit v1.2.3


From 74ddb29c6c9ce31450234e77fd39c73d0d51c3c5 Mon Sep 17 00:00:00 2001
From: sarthurdev <965089+sarthurdev@users.noreply.github.com>
Date: Sun, 7 Jan 2024 21:41:23 +0100
Subject: dhcp: T3316: Fix `listen-address` handling and add `listen-interface`
 as supported by Kea

---
 data/templates/dhcp-server/kea-dhcp4.conf.j2           |  8 ++++++++
 .../include/listen-interface-multi-broadcast.xml.i     | 18 ++++++++++++++++++
 interface-definitions/service_dhcp-server.xml.in       |  1 +
 python/vyos/template.py                                | 17 +++++++++++++++++
 python/vyos/utils/network.py                           |  4 ++--
 smoketest/scripts/cli/test_service_dhcp-server.py      |  9 +++++++--
 src/conf_mode/service_dhcp-server.py                   |  9 ++++++++-
 7 files changed, 61 insertions(+), 5 deletions(-)
 create mode 100644 interface-definitions/include/listen-interface-multi-broadcast.xml.i

diff --git a/data/templates/dhcp-server/kea-dhcp4.conf.j2 b/data/templates/dhcp-server/kea-dhcp4.conf.j2
index 6ab13ab27..629fa952a 100644
--- a/data/templates/dhcp-server/kea-dhcp4.conf.j2
+++ b/data/templates/dhcp-server/kea-dhcp4.conf.j2
@@ -1,8 +1,16 @@
 {
     "Dhcp4": {
         "interfaces-config": {
+{% if listen_address is vyos_defined %}
+            "interfaces": {{ listen_address | kea_address_json }},
+            "dhcp-socket-type": "udp",
+{% elif listen_interface is vyos_defined %}
+            "interfaces": {{ listen_interface | tojson }},
+            "dhcp-socket-type": "raw",
+{% else %}
             "interfaces": [ "*" ],
             "dhcp-socket-type": "raw",
+{% endif %}
             "service-sockets-max-retries": 5,
             "service-sockets-retry-wait-time": 5000
         },
diff --git a/interface-definitions/include/listen-interface-multi-broadcast.xml.i b/interface-definitions/include/listen-interface-multi-broadcast.xml.i
new file mode 100644
index 000000000..b3d5a3ecc
--- /dev/null
+++ b/interface-definitions/include/listen-interface-multi-broadcast.xml.i
@@ -0,0 +1,18 @@
+<!-- include start from listen-interface-multi-broadcast.xml.i -->
+<leafNode name="listen-interface">
+  <properties>
+    <help>Interface for DHCP Relay Agent to listen for requests</help>
+    <completionHelp>
+      <script>${vyos_completion_dir}/list_interfaces --broadcast</script>
+    </completionHelp>
+    <valueHelp>
+      <format>txt</format>
+      <description>Interface name</description>
+    </valueHelp>
+    <constraint>
+      #include <include/constraint/interface-name.xml.i>
+    </constraint>
+    <multi/>
+  </properties>
+</leafNode>
+<!-- include end -->
diff --git a/interface-definitions/service_dhcp-server.xml.in b/interface-definitions/service_dhcp-server.xml.in
index a5cee62d1..27485b6d4 100644
--- a/interface-definitions/service_dhcp-server.xml.in
+++ b/interface-definitions/service_dhcp-server.xml.in
@@ -74,6 +74,7 @@
             </properties>
           </leafNode>
           #include <include/listen-address-ipv4.xml.i>
+          #include <include/listen-interface-multi-broadcast.xml.i>
           <tagNode name="shared-network-name">
             <properties>
               <help>Name of DHCP shared network</help>
diff --git a/python/vyos/template.py b/python/vyos/template.py
index c0c09f690..1368f1f61 100644
--- a/python/vyos/template.py
+++ b/python/vyos/template.py
@@ -786,6 +786,23 @@ def range_to_regex(num_range):
     regex = range_to_regex(num_range)
     return f'({regex})'
 
+@register_filter('kea_address_json')
+def kea_address_json(addresses):
+    from json import dumps
+    from vyos.utils.network import is_addr_assigned
+
+    out = []
+
+    for address in addresses:
+        ifname = is_addr_assigned(address, return_ifname=True)
+
+        if not ifname:
+            continue
+
+        out.append(f'{ifname}/{address}')
+
+    return dumps(out)
+
 @register_filter('kea_failover_json')
 def kea_failover_json(config):
     from json import dumps
diff --git a/python/vyos/utils/network.py b/python/vyos/utils/network.py
index 997ee6309..b782e0bd8 100644
--- a/python/vyos/utils/network.py
+++ b/python/vyos/utils/network.py
@@ -308,7 +308,7 @@ def is_ipv6_link_local(addr):
 
     return False
 
-def is_addr_assigned(ip_address, vrf=None) -> bool:
+def is_addr_assigned(ip_address, vrf=None, return_ifname=False) -> bool | str:
     """ Verify if the given IPv4/IPv6 address is assigned to any interface """
     from netifaces import interfaces
     from vyos.utils.network import get_interface_config
@@ -323,7 +323,7 @@ def is_addr_assigned(ip_address, vrf=None) -> bool:
             continue
 
         if is_intf_addr_assigned(interface, ip_address):
-            return True
+            return interface if return_ifname else True
 
     return False
 
diff --git a/smoketest/scripts/cli/test_service_dhcp-server.py b/smoketest/scripts/cli/test_service_dhcp-server.py
index 99ac406cd..ef6191fb1 100755
--- a/smoketest/scripts/cli/test_service_dhcp-server.py
+++ b/smoketest/scripts/cli/test_service_dhcp-server.py
@@ -32,6 +32,7 @@ CTRL_PROCESS_NAME = 'kea-ctrl-agent'
 KEA4_CONF = '/run/kea/kea-dhcp4.conf'
 KEA4_CTRL = '/run/kea/dhcp4-ctrl-socket'
 base_path = ['service', 'dhcp-server']
+interface = 'dum8765'
 subnet = '192.0.2.0/25'
 router = inc_ip(subnet, 1)
 dns_1 = inc_ip(subnet, 2)
@@ -46,11 +47,11 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase):
         cls.cli_delete(cls, base_path)
 
         cidr_mask = subnet.split('/')[-1]
-        cls.cli_set(cls, ['interfaces', 'dummy', 'dum8765', 'address', f'{router}/{cidr_mask}'])
+        cls.cli_set(cls, ['interfaces', 'dummy', interface, 'address', f'{router}/{cidr_mask}'])
 
     @classmethod
     def tearDownClass(cls):
-        cls.cli_delete(cls, ['interfaces', 'dummy', 'dum8765'])
+        cls.cli_delete(cls, ['interfaces', 'dummy', interface])
         super(TestServiceDHCPServer, cls).tearDownClass()
 
     def tearDown(self):
@@ -95,6 +96,8 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase):
         range_1_start = inc_ip(subnet, 40)
         range_1_stop  = inc_ip(subnet, 50)
 
+        self.cli_set(base_path + ['listen-interface', interface])
+
         pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet]
         # we use the first subnet IP address as default gateway
         self.cli_set(pool + ['option', 'default-router', router])
@@ -116,6 +119,7 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase):
         config = read_file(KEA4_CONF)
         obj = loads(config)
 
+        self.verify_config_value(obj, ['Dhcp4', 'interfaces-config'], 'interfaces', [interface])
         self.verify_config_value(obj, ['Dhcp4', 'shared-networks'], 'name', shared_net_name)
         self.verify_config_value(obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'subnet', subnet)
         self.verify_config_value(obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'valid-lifetime', 86400)
@@ -607,6 +611,7 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase):
         config = read_file(KEA4_CONF)
         obj = loads(config)
 
+        self.verify_config_value(obj, ['Dhcp4', 'interfaces-config'], 'interfaces', [f'{interface}/{router}'])
         self.verify_config_value(obj, ['Dhcp4', 'shared-networks'], 'name', 'RELAY')
         self.verify_config_value(obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'subnet', relay_subnet)
 
diff --git a/src/conf_mode/service_dhcp-server.py b/src/conf_mode/service_dhcp-server.py
index 7ebc560ba..329e18993 100755
--- a/src/conf_mode/service_dhcp-server.py
+++ b/src/conf_mode/service_dhcp-server.py
@@ -31,6 +31,7 @@ from vyos.utils.file import chmod_775
 from vyos.utils.file import makedir
 from vyos.utils.file import write_file
 from vyos.utils.process import call
+from vyos.utils.network import interface_exists
 from vyos.utils.network import is_subnet_connected
 from vyos.utils.network import is_addr_assigned
 from vyos import ConfigError
@@ -294,12 +295,18 @@ def verify(dhcp):
         else:
             raise ConfigError(f'listen-address "{address}" not configured on any interface')
 
-
     if not listen_ok:
         raise ConfigError('None of the configured subnets have an appropriate primary IP address on any\n'
                           'broadcast interface configured, nor was there an explicit listen-address\n'
                           'configured for serving DHCP relay packets!')
 
+    if 'listen_address' in dhcp and 'listen_interface' in dhcp:
+        raise ConfigError(f'Cannot define listen-address and listen-interface at the same time')
+
+    for interface in (dict_search('listen_interface', dhcp) or []):
+        if not interface_exists(interface):
+            raise ConfigError(f'listen-interface "{interface}" does not exist')
+
     return None
 
 def generate(dhcp):
-- 
cgit v1.2.3


From 0cd74e0795eaa9d71c9546cb6a4766661ffb6026 Mon Sep 17 00:00:00 2001
From: sarthurdev <965089+sarthurdev@users.noreply.github.com>
Date: Tue, 9 Jan 2024 22:43:35 +0100
Subject: dhcp: T5912: Fix hostfile not written for new leases

---
 src/system/on-dhcp-event.sh | 33 ++++++++++++++++++++++-----------
 1 file changed, 22 insertions(+), 11 deletions(-)

diff --git a/src/system/on-dhcp-event.sh b/src/system/on-dhcp-event.sh
index 03574bdc3..3534fe601 100755
--- a/src/system/on-dhcp-event.sh
+++ b/src/system/on-dhcp-event.sh
@@ -15,28 +15,39 @@ if [ $# -lt 1 ]; then
 fi
 
 action=$1
-client_name=$LEASE4_HOSTNAME
-client_ip=$LEASE4_ADDRESS
-client_mac=$LEASE4_HWADDR
 hostsd_client="/usr/bin/vyos-hostsd-client"
 
 case "$action" in
-  lease4_renew|lease4_recover) # add mapping for new/recovered lease address
-    if [ -z "$client_name" ]; then
-        logger -s -t on-dhcp-event "Client name was empty, using MAC \"$client_mac\" instead"
-        client_name=$(echo "host-$client_mac" | tr : -)
-    fi
-
-    $hostsd_client --add-hosts "$client_name,$client_ip" --tag "dhcp-server-$client_ip" --apply
+  lease4_renew|lease4_recover)
     exit 0
     ;;
 
   lease4_release|lease4_expire|lease4_decline) # delete mapping for released/declined address
+    client_ip=$LEASE4_ADDRESS
     $hostsd_client --delete-hosts --tag "dhcp-server-$client_ip" --apply
     exit 0
     ;;
 
-  leases4_committed) # nothing to do
+  leases4_committed) # process committed leases (added/renewed/recovered)
+    for ((i = 0; i < $LEASES4_SIZE; i++)); do
+      client_ip_var="LEASES4_AT${i}_ADDRESS"
+      client_mac_var="LEASES4_AT${i}_HWADDR"
+      client_name_var="LEASES4_AT${i}_HOSTNAME"
+      client_subnet_id_var="LEASES4_AT${i}_SUBNET_ID"
+
+      client_ip=${!client_ip_var}
+      client_mac=${!client_mac_var}
+      client_name=${!client_name_var}
+      client_subnet_id=${!client_subnet_id_var}
+
+      if [ -z "$client_name" ]; then
+          logger -s -t on-dhcp-event "Client name was empty, using MAC \"$client_mac\" instead"
+          client_name=$(echo "host-$client_mac" | tr : -)
+      fi
+
+      $hostsd_client --add-hosts "$client_name,$client_ip" --tag "dhcp-server-$client_ip" --apply
+    done
+
     exit 0
     ;;
 
-- 
cgit v1.2.3


From 39bf15289ca10ff5b61eb4070292ffb13f53e94e Mon Sep 17 00:00:00 2001
From: sarthurdev <965089+sarthurdev@users.noreply.github.com>
Date: Tue, 9 Jan 2024 22:44:16 +0100
Subject: dhcp: T3316: Workaround to append domain suffix to hostfile entries

---
 python/vyos/kea.py          |  4 ++--
 src/system/on-dhcp-event.sh | 32 ++++++++++++++++++++++++++++++++
 2 files changed, 34 insertions(+), 2 deletions(-)

diff --git a/python/vyos/kea.py b/python/vyos/kea.py
index 2ca73044b..3d8cf3637 100644
--- a/python/vyos/kea.py
+++ b/python/vyos/kea.py
@@ -25,7 +25,7 @@ from vyos.template import netmask_from_cidr
 from vyos.utils.dict import dict_search_args
 from vyos.utils.file import file_permissions
 from vyos.utils.file import read_file
-from vyos.utils.process import cmd
+from vyos.utils.process import run
 
 kea4_options = {
     'name_server': 'domain-name-servers',
@@ -315,7 +315,7 @@ def _ctrl_socket_command(path, command, args=None):
         return None
 
     if file_permissions(path) != '0775':
-        cmd(f'sudo chmod 775 {path}')
+        run(f'sudo chmod 775 {path}')
 
     with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
         sock.connect(path)
diff --git a/src/system/on-dhcp-event.sh b/src/system/on-dhcp-event.sh
index 3534fe601..e1a9f1884 100755
--- a/src/system/on-dhcp-event.sh
+++ b/src/system/on-dhcp-event.sh
@@ -17,6 +17,32 @@ fi
 action=$1
 hostsd_client="/usr/bin/vyos-hostsd-client"
 
+get_subnet_domain_name () {
+  python3 <<EOF
+from vyos.kea import kea_get_active_config
+from vyos.utils.dict import dict_search_args
+
+config = kea_get_active_config('4')
+shared_networks = dict_search_args(config, 'arguments', f'Dhcp4', 'shared-networks')
+
+found = False
+
+if shared_networks:
+  for network in shared_networks:
+    for subnet in network[f'subnet4']:
+      if subnet['id'] == $1:
+        for option in subnet['option-data']:
+          if option['name'] == 'domain-name':
+            print(option['data'])
+            found = True
+
+        if not found:
+          for option in network['option-data']:
+            if option['name'] == 'domain-name':
+              print(option['data'])
+EOF
+}
+
 case "$action" in
   lease4_renew|lease4_recover)
     exit 0
@@ -45,6 +71,12 @@ case "$action" in
           client_name=$(echo "host-$client_mac" | tr : -)
       fi
 
+      client_domain=$(get_subnet_domain_name $client_subnet_id)
+
+      if [ -n "$client_domain" ]; then
+        client_name="$client_name.$client_domain"
+      fi
+
       $hostsd_client --add-hosts "$client_name,$client_ip" --tag "dhcp-server-$client_ip" --apply
     done
 
-- 
cgit v1.2.3


From 41913f4d1d63ddd39d9125b0140b8a33449c2cfb Mon Sep 17 00:00:00 2001
From: sarthurdev <965089+sarthurdev@users.noreply.github.com>
Date: Tue, 9 Jan 2024 22:48:55 +0100
Subject: dhcp: T5787: Prevent duplicate IP addresses on static mappings

---
 smoketest/scripts/cli/test_service_dhcp-server.py | 7 +++++++
 src/conf_mode/service_dhcp-server.py              | 6 ++++++
 2 files changed, 13 insertions(+)

diff --git a/smoketest/scripts/cli/test_service_dhcp-server.py b/smoketest/scripts/cli/test_service_dhcp-server.py
index ef6191fb1..6f24d40ec 100755
--- a/smoketest/scripts/cli/test_service_dhcp-server.py
+++ b/smoketest/scripts/cli/test_service_dhcp-server.py
@@ -375,6 +375,13 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase):
             self.cli_commit()
         self.cli_delete(pool + ['static-mapping', 'client1', 'duid'])
 
+        # cannot have mappings with duplicate IP addresses
+        with self.assertRaises(ConfigSessionError):
+            self.cli_set(pool + ['static-mapping', 'dupe1', 'mac', '00:50:00:00:00:01'])
+            self.cli_set(pool + ['static-mapping', 'dupe1', 'ip-address', inc_ip(subnet, 10)])
+            self.cli_commit()
+        self.cli_delete(pool + ['static-mapping', 'dupe1'])
+
         # commit changes
         self.cli_commit()
 
diff --git a/src/conf_mode/service_dhcp-server.py b/src/conf_mode/service_dhcp-server.py
index 329e18993..ceaba019e 100755
--- a/src/conf_mode/service_dhcp-server.py
+++ b/src/conf_mode/service_dhcp-server.py
@@ -223,6 +223,7 @@ def verify(dhcp):
 
             if 'static_mapping' in subnet_config:
                 # Static mappings require just a MAC address (will use an IP from the dynamic pool if IP is not set)
+                used_ips = []
                 for mapping, mapping_config in subnet_config['static_mapping'].items():
                     if 'ip_address' in mapping_config:
                         if ip_address(mapping_config['ip_address']) not in ip_network(subnet):
@@ -234,6 +235,11 @@ def verify(dhcp):
                             raise ConfigError(f'Either MAC address or Client identifier (DUID) is required for '
                                               f'static mapping "{mapping}" within shared-network "{network}, {subnet}"!')
 
+                        if mapping_config['ip_address'] in used_ips:
+                            raise ConfigError(f'Configured IP address for static mapping "{mapping}" exists on another static mapping')
+
+                        used_ips.append(mapping_config['ip_address'])
+
             # There must be one subnet connected to a listen interface.
             # This only counts if the network itself is not disabled!
             if 'disable' not in network_config:
-- 
cgit v1.2.3