diff options
| -rw-r--r-- | data/templates/dhcp-server/kea-dhcp4.conf.j2 | 8 | ||||
| -rw-r--r-- | interface-definitions/include/dhcp/option-v4.xml.i | 257 | ||||
| -rw-r--r-- | interface-definitions/include/listen-interface-multi-broadcast.xml.i | 18 | ||||
| -rw-r--r-- | interface-definitions/include/version/dhcp-server-version.xml.i | 2 | ||||
| -rw-r--r-- | interface-definitions/service_dhcp-server.xml.in | 259 | ||||
| -rw-r--r-- | python/vyos/kea.py | 60 | ||||
| -rw-r--r-- | python/vyos/template.py | 32 | ||||
| -rw-r--r-- | python/vyos/utils/network.py | 4 | ||||
| -rwxr-xr-x | smoketest/scripts/cli/test_service_dhcp-server.py | 148 | ||||
| -rwxr-xr-x | src/conf_mode/service_dhcp-server.py | 15 | ||||
| -rwxr-xr-x | src/migration-scripts/dhcp-server/8-to-9 | 69 | ||||
| -rwxr-xr-x | src/system/on-dhcp-event.sh | 65 | 
12 files changed, 610 insertions, 327 deletions
| 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/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/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/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..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> @@ -89,12 +90,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 +106,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 +122,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 +136,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 +145,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 +181,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 +200,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..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', @@ -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 @@ -293,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/python/vyos/template.py b/python/vyos/template.py index 29ea0889b..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 @@ -842,15 +859,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/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 bf0c09965..6f24d40ec 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,12 +96,14 @@ 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 + ['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): @@ -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) @@ -165,29 +169,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 +282,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): @@ -309,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() @@ -365,9 +438,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 +521,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 +563,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 +608,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]) @@ -545,6 +618,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) @@ -572,7 +646,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/conf_mode/service_dhcp-server.py b/src/conf_mode/service_dhcp-server.py index 7ebc560ba..ceaba019e 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 @@ -222,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): @@ -233,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: @@ -294,12 +301,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): 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) diff --git a/src/system/on-dhcp-event.sh b/src/system/on-dhcp-event.sh index 03574bdc3..e1a9f1884 100755 --- a/src/system/on-dhcp-event.sh +++ b/src/system/on-dhcp-event.sh @@ -15,28 +15,71 @@ 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 +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 -    $hostsd_client --add-hosts "$client_name,$client_ip" --tag "dhcp-server-$client_ip" --apply +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      ;;    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 + +      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 +      exit 0      ;; | 
