kea: importer for legacy isc-dhcp-v4 UCI configs
authorPhilip Prindeville <redacted>
Sat, 13 Sep 2025 17:34:48 +0000 (11:34 -0600)
committerPhilip Prindeville <redacted>
Thu, 9 Apr 2026 23:36:46 +0000 (17:36 -0600)
Now that ISC-DHCP is EOLs, users might want to transparently
to the functionality of Kea.  This supports most of the
functionality of ISC-DHCP for v4.

Signed-off-by: Philip Prindeville <redacted>
net/kea/Makefile
net/kea/files/dhcp4.sh [new file with mode: 0755]

index 6689a632ed8c6251a81d7afbf9617082a48bd088..b6238dd554f8f31d84afecee164ffe99c965a4e3 100644 (file)
@@ -10,7 +10,7 @@ include $(TOPDIR)/rules.mk
 
 PKG_NAME:=kea
 PKG_VERSION:=3.0.2
-PKG_RELEASE:=5
+PKG_RELEASE:=6
 
 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.xz
 PKG_SOURCE_URL:=https://ftp.isc.org/isc/kea/$(PKG_VERSION)
@@ -42,6 +42,7 @@ Package/kea-ctrl/conffiles = $(Package/kea/conffiles)
 Package/kea-dhcp4/conffiles = $(Package/kea/conffiles)
 Package/kea-dhcp6/conffiles = $(Package/kea/conffiles)
 Package/kea-dhcp-ddns/conffiles = $(Package/kea/conffiles)
+Package/kea-dhcp4-helper/conffiles = /etc/config/dhcp
 
 ###### *************************************************************************
 define Package/kea
@@ -73,7 +74,7 @@ define Package/kea-ctrl
        $(call Package/kea/Default)
        TITLE+=Control
        DEPENDS:=+procps-ng +procps-ng-ps +kea-dhcp4 \
-       +IPV6:kea-dhcp6 +kea-dhcp-ddns
+               +IPV6:kea-dhcp6 +kea-dhcp-ddns
 endef
 define Package/kea-ctrl/description
        Tool to start, stop, reconfigure, and report status for the Kea servers.
@@ -89,6 +90,17 @@ define Package/kea-dhcp4/description
     The DHCPv4 server process. This process responds to DHCPv4 queries from clients.
 endef
 
+###### *************************************************************************
+define Package/kea-dhcp4-helper
+       $(call Package/kea/Default)
+       TITLE+=DHCP Server v4 importer of legacy UCI configs and Bind helper
+       DEPENDS:=@PACKAGE_bind-server +kea-dhcp4 +bind-rndc +bind-client
+endef
+define Package/kea-dhcp4-helper/description
+    The DHCPv4 UCI importer. This helper imports legacy UCI configs and then
+    primes local DNS zones in Bind.
+endef
+
 ###### *************************************************************************
 define Package/kea-dhcp6
        $(call Package/kea/Default)
@@ -206,6 +218,11 @@ define Package/kea-dhcp4/install
        $(CP) $(PKG_INSTALL_DIR)/etc/kea/kea-dhcp4.conf $(1)/etc/kea/
 endef
 
+define Package/kea-dhcp4-helper/install
+       $(INSTALL_DIR) $(1)/usr/lib/kea/importers
+       $(INSTALL_BIN) ./files/dhcp4.sh $(1)/usr/lib/kea/importers/
+endef
+
 define Package/kea-dhcp6/install
        $(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/kea
        $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/sbin/kea-dhcp6 $(1)/usr/sbin/kea-dhcp6
@@ -260,8 +277,6 @@ define Package/kea-uci/install
        $(INSTALL_DIR) $(1)/etc/config $(1)/etc/init.d
        $(INSTALL_CONF) ./files/kea.config $(1)/etc/config/kea
        $(INSTALL_BIN) ./files/kea.init $(1)/etc/init.d/kea
-       $(INSTALL_DIR) $(1)/usr/lib/kea/importers
-       $(INSTALL_BIN) ./files/dhcp4.sh $(1)/usr/lib/kea/importers/
 endef
 
 define Package/kea-uci/conffiles
@@ -272,6 +287,7 @@ $(eval $(call HostBuild))
 $(eval $(call BuildPackage,kea-libs))
 $(eval $(call BuildPackage,kea-ctrl))
 $(eval $(call BuildPackage,kea-dhcp4))
+$(eval $(call BuildPackage,kea-dhcp4-helper))
 $(eval $(call BuildPackage,kea-dhcp6))
 $(eval $(call BuildPackage,kea-dhcp-ddns))
 $(eval $(call BuildPackage,kea-admin))
diff --git a/net/kea/files/dhcp4.sh b/net/kea/files/dhcp4.sh
new file mode 100755 (executable)
index 0000000..46735f2
--- /dev/null
@@ -0,0 +1,1014 @@
+#!/bin/sh
+
+NL=$'\n'
+WS=$'[\t ]'
+TTL=3600
+PREFIX="update add"
+
+prog="$(basename $0)"
+
+dyndir=/var/run/dhcp
+keadir=/var/lib/kea
+
+session_key_name=local-ddns
+
+getvar() {
+       local __dest="$1" _var="$2"
+       eval "export -n -- \"$__dest=\${$_var}\""
+}
+
+setvar() {
+       local __dest="$1" _val="$2"
+       eval "export -n -- \"$__dest=$_val\""
+}
+
+#### delete me -- these should all be in jshn.sh
+
+json_add_names() {
+       local _name
+
+       for _name in "$@"; do
+               json_push_string "$_name"
+       done
+}
+
+#### delete me
+
+time2seconds() {
+       local _var="$1" _timestring="$2"
+       local _multiplier _number _suffix
+
+       _suffix="${_timestring//[0-9 ]}"
+       _number="${_timestring%%$_suffix}"
+       [ "$_number$_suffix" != "$_timestring" ] && return 1
+       case "$_suffix" in
+               "" | s)
+                       _multiplier=1
+                       ;;
+               m)
+                       _multiplier=60
+                       ;;
+               h)
+                       _multiplier=3600
+                       ;;
+               d)
+                       _multiplier=86400
+                       ;;
+               w)
+                       _multiplier=604800
+                       ;;
+               *)
+                       return 1
+                       ;;
+       esac
+
+       setvar "$_var" "$((_number * _multiplier))"
+}
+
+explode_dotted() {
+       local _var="$1" _val="${2//\./ }"
+
+       setvar "$_var" "$_val"
+}
+
+is_decimal() {
+       local _val="$1"
+
+       [ -z "${_val//[0-9]/}" ]
+}
+
+is_hex() {
+       local _val="$1"
+
+       [ -z "${_val//[0-9a-f]/}" ]
+}
+
+trim() {
+       local _var="$1" _str="$2" _prev
+
+       while true; do
+               _prev="$_str"
+               _str="${_str%%$WS}"
+               [ "$_str" = "$_prev" ] && break
+       done
+       while true; do
+               _prev="$_str"
+               _str="${_str##$WS}"
+               [ "$_str" = "$_prev" ] && break
+       done
+
+       setvar "$_var" "$_str"
+}
+
+mangle() {
+       local _var="$1" _name="${2//[^A-Za-z0-9]/_}"
+
+       setvar "$_var" "$_name"
+}
+
+rfc1918_prefix() {
+       local _var="$1" _subnet="${2%/*}" _exploded
+       explode_dotted _exploded "$_subnet"
+       set -- $_exploded
+
+       case "$1.$2" in
+       10.*)
+               setvar "$_var" "$1" ;;
+       172.1[6789]|172.2[0-9]|172.3[01]|192.168)
+               setvar "$_var" "$1.$2" ;;
+       *)
+               setvar "$_var" "" ;;
+       esac
+}
+
+no_ipv6() {
+       [ -n "$(named-checkconf -px \
+               | sed -r -ne '1N; N; /^\tlisten-on-v6  ?\{\n\t\t"none";\n\t\};$/{ p; q; }; D')" ]
+}
+
+subnet_of() {
+       local _var="$1" _ip
+       str2ip _ip "$2" || return 1
+       local _ifname _pfx _start _end
+
+       for _ifname in $dhcp_ifs; do
+               mangle _pfx "$_ifname"
+
+               getvar _start "${_pfx}_start"
+               getvar _end "${_pfx}_end"
+
+               if [ $_start -le $_ip ] && [ $_ip -le $_end ]; then
+                       setvar "$_var" "$_ifname"
+                       return 0
+               fi
+       done
+       return 1
+}
+
+# duplicated from dnsmasq init script
+hex_to_hostid() {
+       local _var="$1"
+       local _hex="${2#0x}" # strip optional "0x" prefix
+
+       if ! is_hex "$_hex"; then
+               echo "Invalid hostid: $_hex" >&2
+               return 1
+       fi
+
+       # convert into host id
+       setvar "$_var" "$(
+               printf "%0x:%0x" \
+               $(((0x$_hex >> 16) % 65536)) \
+               $(( 0x$_hex        % 65536))
+               )"
+
+       return 0
+}
+
+update() {
+       local _lhs="$1" _family="$2" _type="$3"
+       shift 3
+
+       [ $dynamicdns -eq 1 ] && \
+               echo -e "$PREFIX" "$_lhs $_family $_type $@\nsend" >> "$dyn_file"
+}
+
+rev_str() {
+       local _var="$1" _str="$2" _delim="$3"
+       local _frag _result=""
+
+       for _frag in ${_str//$_delim/ }; do
+               prepend _result "$_frag" "$_delim"
+       done
+
+       setvar "$_var" "$_result"
+}
+
+write_empty_zone() {
+       local zpath
+       zpath="$1"
+
+       cat > "$zpath" <<\EOF
+;
+; BIND empty zone created by Kea dhcp4.sh plugin
+;
+$TTL   604800
+@      IN      SOA     localhost. root.localhost. (
+                            1         ; Serial
+                       604800         ; Refresh
+                        86400         ; Retry
+                       419200         ; Expire
+                       604800 )       ; Negative Cache TTL
+;
+@      IN      NS      localhost.
+EOF
+}
+
+create_empty_zone() {
+       local zone error zpath command
+       zone="$1"
+       zpath="$dyndir/db.$zone"
+
+       if [ ! -d "$dyndir" ]; then
+               mkdir -p "$dyndir" || return 1
+               chown bind:bind "$dyndir" || return 1
+       fi
+
+       write_empty_zone "$zpath"
+       chown bind:bind "$zpath" || return 1
+       chmod 0664 "$zpath" || return 1
+
+       # if the zone doesn't exist, or a RFC-1918 in-addr.arpa zone, then
+       # we need to add it, otherwise we need to modify it.
+       if ! rndc zonestatus $zone >/dev/null 2>&1; then
+               command="addzone"
+       else
+               command="modzone"
+       fi
+
+       case "$zone" in
+       10.in-addr.arpa|1[6789].172.in-addr.arpa|2[0-9].172.in-addr.arpa|3[01].172.in-addr.arpa|168.192.in-addr.arpa)
+               command="addzone" ;;
+       esac
+
+       if ! error=$(rndc $command $zone "{
+               type primary;
+               file \"$zpath\";
+               update-policy {
+                       grant $session_key_name zonesub any;
+               };
+       };" 2>&1); then
+               case "$error" in
+                       *"already exists"*)
+                               ;;
+                       *)
+                               logger -s -p info -t "$prog" "Failed to add zone $zone: $error"
+                               return 1
+                               ;;
+               esac
+       fi
+}
+
+option_def() {
+       local name="$1" code="$2" type="$3"
+
+       case "$type" in
+       binary|boolean|empty|fqdn|ipv4-address|ipv6-address|ipv6-prefix|psid|string|tuple|uint8|uint16|uint32|int8|int16|int32)
+               ;;
+       record)
+               echo "Not yet supported: $type" >&2
+               exit 1
+               ;;
+       *)
+               echo "Unknown option type: $type" >&2
+               exit 1
+               ;;
+       esac
+
+       if ! json_get_type type "option-def"; then
+               json_add_array "option-def"
+       else
+               json_select "option-def"
+       fi
+       json_add_object
+       json_add_fields "name:string=$name" "code:int=$code" "type:string=$type"
+       json_close_object
+
+       json_select ".."                # option-def
+}
+
+option_data() {
+       local arg value type
+
+       # if the option-data array doesn't exist, create it since
+       # this is the first time through. otherwise, select it.
+       if ! json_get_type type "option-data"; then
+               json_add_array "option-data"
+       else
+               json_select "option-data"
+       fi
+
+       json_add_object
+
+       while [ $# -ge 1 ]; do
+               arg="$1"
+               shift
+
+               case "$arg" in
+               name:*)
+                       value="${arg#name:}"
+                       json_add_string "name" "$value"
+                       ;;
+               space:*)
+                       value="${arg#space:}"
+                       json_add_string "space" "$value"
+                       ;;
+               code:*)
+                       value="${arg#code:}"
+                       if is_decimal "$value"; then
+                               json_add_int "code" $value
+                       else
+                               echo "Bad code '$value' in DHCP options" >&2
+                       fi
+                       ;;
+               csv-format:true)
+                       json_add_boolean "csv-format" 1
+                       ;;
+               csv-format:false)
+                       json_add_boolean "csv-format" 0
+                       ;;
+               data:*)
+                       value="$arg"
+                       json_add_fields "$value"
+                       ;;
+               always-send:true)
+                       json_add_boolean "always-send" 1
+                       ;;
+               *)
+                       echo "Unexpected argument '$arg' to option_data" >&2
+                       ;;
+               esac
+       done
+
+       json_close_object
+
+       json_select ..                  # option-data
+}
+
+is_force_send() {
+       local forced="$1" option="$2"
+       list_contains forced "$option" && echo "always-send:true"
+}
+
+append_routes() {
+       local tuple
+       local network prefix router subnet
+
+       trim tuple "$1"
+
+       subnet="${tuple%%$WS*}"
+
+       network="${subnet%/[0-9]*}"
+
+       prefix="${subnet#*/}"
+
+       router="${tuple#${subnet}$WS}"
+
+       append routes "$subnet - $router" ", "
+}
+
+append_dhcp_options() {
+       local tuple="$1"
+
+       # strip redundant "option:" prefix
+       tuple="${tuple#option:}"
+
+       local tag="${tuple%%,*}"
+       local values="${tuple#$tag,}"
+
+       case "$tag" in
+       routers|time-servers|name-servers|domain-name-servers|log-servers|static-routes|ntp-servers|domain-search)
+               option_data "name:$tag" "data:string=$values"
+               ;;
+       dhcp-renewal-time)
+               if ! is_decimal "$values"; then
+                       echo "Expected a decimal integer: $tag" >&2
+                       exit 1
+               fi
+               ## option_data "name:$tag" "data:int=$values"
+               option_data "name:$tag" "data:string=$values"
+               ;;
+       *)
+               echo "Unhandled option: $tag" >&2
+               ;;
+       esac
+}
+
+static_cname_add() {
+       local cfg="$1"
+       local cname target
+
+       config_get cname "$cfg" "cname"
+       [ -n "$cname" ] || return 0
+       config_get target "$cfg" "target"
+       [ -n "$target" ] || return 0
+
+       case "$target" in
+       *.*)
+               ;;
+       *)
+               target="$target.$g_domain"
+               ;;
+       esac
+
+       update "$cname.$g_domain." IN CNAME "$target."
+}
+
+static_cnames() {
+       config_foreach static_cname_add cname "$@"
+}
+
+static_domain_add() {
+       local cfg="$1"
+       local name ip ips revip octets
+
+       config_get name "$cfg" "name"
+       [ -n "$name" ] || return 0
+       config_get ip "$cfg" "ip"
+       [ -n "$ip" ] || return 0
+
+       ips="$ip"
+       for ip in $ips; do
+               rev_str revip "$ip" "."
+
+               update "$name.$g_domain." IN A "$ip"
+               rfc1918_prefix octets "$ip"
+               [ -n "$octets" ] && \
+                       update "$revip.in-addr.arpa." IN PTR "$name.$g_domain."
+       done
+}
+
+static_domains() {
+       config_foreach static_domain_add domain "$@"
+}
+
+static_mxhost_add() {
+       local cfg="$1"
+       local h_domain relay pref
+
+       config_get h_domain "$cfg" "domain"
+       [ -n "$h_domain" ] || return 0
+       config_get relay "$cfg" "relay"
+       [ -n "$relay" ] || return 0
+       config_get pref "$cfg" "pref"
+       [ -n "$pref" ] || return 0
+
+       case "$relay" in
+       *.*)
+               ;;
+       *)
+               relay="$relay.$g_domain"
+               ;;
+       esac
+
+       if [ "$h_domain" = "@" ]; then
+               update "$g_domain." IN MX "$pref" "$relay."
+       else
+               update "$h_domain.$g_domain." IN MX "$pref" "$relay."
+       fi
+}
+
+static_mxhosts() {
+       config_foreach static_mxhost_add mxhost "$@"
+}
+
+static_srvhost_add() {
+       local cfg="$1"
+       local srv target port priority weight
+
+       config_get srv "$cfg" "srv"
+       [ -n "$srv" ] || return 0
+       config_get target "$cfg" "target"
+       [ -n "$target" ] || return 0
+       config_get port "$cfg" "port"
+       [ -n "$port" ] || return 0
+       config_get priority "$cfg" "priority"
+       [ -n "$priority" ] || return 0
+       config_get weight "$cfg" "weight"
+       [ -n "$weight" ] || return 0
+
+       case "$target" in
+       *.*)
+               ;;
+       *)
+               target="$target.$g_domain"
+               ;;
+       esac
+
+       update "$srv.$g_domain." IN SRV "$priority" "$weight" "$port" "$target."
+}
+
+static_srvhosts() {
+       config_foreach static_srvhost_add srvhost "$@"
+}
+
+static_host_add() {
+       local cfg="$1"
+       local broadcast hostid id macn macs mac name net ip ips revip leasetime
+       local h_domain s_domain defaultroute renewal_time s_renewal_time
+       local h_gateway s_gateway
+       local force_send always index
+
+       config_get macs "$cfg" "mac"
+       [ -n "$macs" ] || return 0
+       config_get name "$cfg" "name"
+       [ -n "$name" ] || return 0
+       config_get ip "$cfg" "ip"
+       [ -n "$ip" ] || return 0
+
+       # needs to match a provisioned subnet
+       local ifname pfx
+       if ! subnet_of ifname "$ip"; then
+               echo "$name's address $ip doesn't match any subnet" >&2
+               return 1
+       fi
+       mangle pfx "$ifname"
+       getvar net "${pfx}_ifname"
+       getvar index "${net}_subnet4_index"
+
+       local h_gateway s_gateway
+       getvar s_gateway "${pfx}_gateway"
+
+       config_get_bool broadcast "$cfg" "broadcast" 0
+       config_get dns "$cfg" "dns"
+       config_get h_gateway "$cfg" "gateway" "$s_gateway"
+       config_get leasetime "$cfg" "leasetime"
+       if [ -n "$leasetime" ]; then
+               time2seconds leasetime "$leasetime" || return 1
+       fi
+
+       config_get hostid "$cfg" "hostid"
+       if [ -n "$hostid" ]; then
+               hex_to_hostid hostid "$hostid" || return 1
+       fi
+
+       local s_defaultroute
+       getvar s_defaultroute "${pfx}_defaultroute"
+
+       # if provisioned, otherwise default to subnet value
+       config_get_bool defaultroute "$cfg" "default_route" $s_defaultroute
+
+       config_get force_send "$cfg" "force_send"
+       force_send="${force_send//,/ }"
+
+       local s_domain
+       getvar s_domain "${pfx}_domain"
+
+       config_get h_domain "$cfg" "domain" "$s_domain"
+
+       getvar s_renewal_time "${pfx}_renewal_time"
+
+       config_get renewal_time "$cfg" "renewal_time" "$s_renewal_time"
+
+       json_select "$index"            # why "$index" and not "$pfx"?
+       json_select "reservations"
+
+       #       rebinding-time)
+
+       macn=0
+       for mac in $macs; do
+               macn=$(( macn + 1 ))
+       done
+
+       for mac in $macs; do
+               local secname="$name"
+               if [ $macn -gt 1 ]; then
+                       secname="${name}-${mac//:}"
+               fi
+
+               json_add_object "$mac"
+
+               json_add_fields "hostname:string=$name" "hw-address:string=$mac" "ip-address:string=$ip"
+
+               [ -n "$hostid" ] && json_add_fields "client-id:string=$hostid"
+
+               ### redundant...
+               always="$(is_force_send "$force_send" "hostname")"
+               option_data "name:host-name" "data:string=$name" $always
+
+               local routes=
+               config_list_foreach "$cfg" "routes" append_routes
+
+               always="$(is_force_send "$force_send" "routes")"
+               if [ -n "$routes" -o -n "$always" ]; then
+                       option_data "name:classless-static-route" "code:121" "data:string=$routes" $always
+               fi
+
+               always="$(is_force_send "$force_send" "domain-name")"
+               if [ "$h_domain" != "$s_domain" -o -n "$always" ]; then
+                       option_data "name:domain-name" "data:string=$h_domain" $always
+               fi
+
+               always="$(is_force_send "$force_send" "fqdn")"
+               [ -n "$always" ] && option_data "name:host-name" "data:string=$name.$h_domain" $always
+
+               if [ -n "$dns" ]; then
+                       always="$(is_force_send "$force_send" "domain-name-servers")"
+                       option_data "name:domain-name-servers" "data:string=$dns" $always
+               fi
+
+               if [ "$h_gateway" != "$s_gateway" -a $defaultroute -eq 1 ]; then
+                       always="$(is_force_send "$force_send" "routers")"
+                       option_data "name:routers" "data:string=$h_gateway" $always
+               fi
+
+               always="$(is_force_send "$force_send" "renewal-time")"
+               ## option_data "name:dhcp-renewal-time" "data:int=$renewal_time" $always
+               option_data "name:dhcp-renewal-time" "data:string=$renewal_time" $always
+
+               ### need special handling for list dhcp_option 'option:xxx,yyy'
+               config_list_foreach "$cfg" "dhcp_option" append_dhcp_options
+
+               # other options here
+               ### always-broadcast
+               ### default-lease-time
+               ### max-lease-time
+
+               json_close_object       # $mac
+       done
+
+       json_select ..                  # reservations
+       json_select ..                  # $index
+
+       ips="$ip"
+       for ip in $ips; do
+               rev_str revip "$ip" "."
+
+               update "$name.$h_domain." IN A "$ip"
+               update "$revip.in-addr.arpa." IN PTR "$name.$h_domain."
+       done
+}
+
+static_hosts() {
+       config_foreach static_host_add host "$@"
+}
+
+gen_dhcp_subnet() {
+       local cfg="$1" index
+
+       json_add_object "$cfg"
+
+       json_get_index index
+
+       subnet4_id=$((subnet4_id + 1))
+       json_add_int "id" $subnet4_id
+       setvar "${cfg}_subnet4_id" "$subnet4_id"
+
+       setvar "${cfg}_subnet4_index" "$index"
+
+       json_add_fields "subnet:string=$NETWORK/$PREFIX"
+
+       if [ -n "$START" ] && [ -n "$END" ]; then
+               json_add_array "pools"
+               json_add_object
+               json_add_fields "pool:string=$START - $END"
+               json_close_object
+               json_close_array        # pools
+       fi
+
+       if [ -n "$leasetime" ]; then
+               json_add_fields "valid-lifetime:int=$leasetime" "max-valid-lifetime:int=$leasetime"
+       fi
+
+       option_data "name:subnet-mask" "data:string=$NETMASK"
+
+       if [ -n "$BROADCAST" ] && [ "$BROADCAST" != "0.0.0.0" ]; then
+               option_data "name:broadcast-address" "data:string=$BROADCAST"
+       fi
+
+       if [ $defaultroute -eq 1 ]; then
+               option_data "name:routers" "data:string=$gateway"
+       fi
+
+       if [ -n "$DNS" ]; then
+               option_data "name:domain-name-servers" "data:string=$DNS"
+       fi
+
+       if [ "$s_domain" != "$g_domain" ]; then
+               option_data "name:domain-name" "data:string=$s_domain"
+       fi
+
+       [ -n "$ntp_servers" ] && option_data "name:ntp-servers" "data:string=$ntp_servers"
+
+       [ -n "$routes" ] && option_data "name:classless-ipv4-route" "code:121" "csv-format:false" "data:string=$routes"
+
+       if [ $dynamicdhcp -eq 0 ]; then
+
+               if [ $authoritative -eq 1 ]; then
+                       # see:
+                       # https://gitlab.isc.org/isc-projects/kea/-/issues/4110
+                       # echo " deny unknown-clients;"
+                       :
+               else
+                       # echo " ignore unknown-clients;"
+                       json_add_array "client-classes"
+                       json_add_object
+                       json_add_fields "name:string=DROP" "test:string=not(member('KNOWN'))"
+                       json_close_object
+                       json_close_array        # client-classes
+               fi
+       fi
+
+       config_list_foreach "$cfg" "dhcp_option" append_dhcp_options
+
+       json_add_array "reservations"
+       json_close_array                        # reservations
+
+       json_close_object               # $cfg
+}
+
+dhcpd_add() {
+       local cfg="$1"
+       local dhcp6range="::"
+       local dynamicdhcp defaultroute dnsserv dnsserver end
+       local gateway ifname ignore ntp_servers
+       local leasetime
+       local limit net netmask networkid octets pfx proto
+       local routes start subnet s_domain s_renewal_time
+       local IP NETMASK BROADCAST NETWORK PREFIX DNS START END
+
+       config_get_bool ignore "$cfg" "ignore" 0
+
+       [ $ignore -eq 1 ] && return 0
+
+       config_get net "$cfg" "interface"
+       [ -n "$net" ] || return 0
+
+       config_get start "$cfg" "start"
+       config_get limit "$cfg" "limit"
+
+       case "$start:$limit" in
+       :)
+               ;;
+       :*|*:)
+               echo "In pool $cfg start/limit must be used together" >&2
+               return 0
+               ;;
+       *:*)
+               # In Kea, this is done implicitly by not having a pool
+               # for unknown clients defined.
+               if [ $boot_unknown_clients -eq 1 ]; then
+                       echo "To not boot unknown clients, remove the pool start and limit for $cfg" >&2
+               fi
+               ;;
+       esac
+
+       network_get_subnet subnet "$net" || return 0
+       network_get_device ifname "$net" || return 0
+       network_get_protocol proto "$net" || return 0
+
+       mangle pfx "$ifname"
+
+       setvar "${pfx}_ifname" "$net"
+
+       # only operate on statically provisioned interfaces
+       [ "$proto" != "static" ] && return 0
+
+       append dhcp_ifs "$ifname"
+
+       rfc1918_prefix octets "$subnet"
+
+       [ -n "$octets" ] && append rfc1918_nets "$octets"
+
+       config_get_bool dynamicdhcp "$cfg" "dynamicdhcp" 1
+
+       config_get_bool defaultroute "$cfg" "default_route" 1
+       setvar "${pfx}_defaultroute" $defaultroute
+
+       ipcalc -d $subnet $start $limit
+
+       setvar "${pfx}_start" "$NETWORK"
+       setvar "${pfx}_end" "$BROADCAST"
+
+       ip2str IP "$IP"
+       ip2str NETMASK "$NETMASK"
+       ip2str NETWORK "$NETWORK"
+       ip2str BROADCAST "$BROADCAST"
+       [ -n "${START:+x}" ] && ip2str START "$START"
+       [ -n "${END:+x}" ] && ip2str END "$END"
+
+       config_get netmask "$cfg" "netmask" "$NETMASK"
+       NETMASK="$netmask"
+
+       config_get s_domain "$cfg" "domain" "$g_domain"
+       setvar "${pfx}_domain" "$s_domain"
+
+       config_get ntp_servers "$cfg" "ntp_servers" ""
+
+       config_get s_renewal_time "$cfg" "renewal_time"
+       if [ -n "$s_renewal_time" ]; then
+               time2seconds s_renewal_time "$s_renewal_time" || exit 1
+       else
+               s_renewal_time="$g_renewal_time"
+       fi
+       setvar "${pfx}_renewal_time" "$s_renewal_time"
+
+       config_get leasetime "$cfg" "leasetime"
+       if [ -n "$leasetime" ]; then
+               time2seconds leasetime "$leasetime" || return 1
+               setvar "${pfx}_leasetime" "$leasetime"
+       fi
+
+       if network_get_dnsserver dnsserver "$net" ; then
+               for dnsserv in $dnsserver; do
+                       append DNS "$dnsserv" ","
+               done
+       else
+               DNS="$IP"
+       fi
+
+       if ! network_get_gateway gateway "$net" ; then
+               gateway="$IP"
+       fi
+       setvar "${pfx}_gateway" $gateway
+
+       routes=
+       config_list_foreach "$cfg" "routes" append_routes
+
+       gen_dhcp_subnet "$cfg"
+}
+
+general_config() {
+       local always_broadcast log_facility
+       local default_lease_time max_lease_time intf
+
+       config_get_bool always_broadcast "isc_dhcpd" "always_broadcast" 0
+       config_get_bool authoritative "isc_dhcpd" "authoritative" 1
+       config_get_bool boot_unknown_clients "isc_dhcpd" "boot_unknown_clients" 1
+       config_get default_lease_time "isc_dhcpd" "default_lease_time" 3600
+
+       config_get max_lease_time "isc_dhcpd" "max_lease_time" 86400
+
+       config_get g_renewal_time "isc_dhcpd" "renewal_time"
+
+       config_get log_facility "isc_dhcpd" "log_facility"
+
+       config_get g_domain "isc_dhcpd" "domain"
+
+       config_get_bool dynamicdns "isc_dhcpd" dynamicdns 0
+
+       time2seconds default_lease_time "$default_lease_time" || return 1
+       time2seconds max_lease_time "$max_lease_time" || return 1
+
+       if [ -n "$g_renewal_time" ]; then
+               time2seconds g_renewal_time "$g_renewal_time" || return 1
+       else
+               g_renewal_time=$((default_lease_time / 2))
+       fi
+
+       setvar g_max_lease_time "$max_lease_time"
+       setvar g_lease_time "$default_lease_time"
+       setvar g_renewal_time "$g_renewal_time"
+
+       json_add_object "lease-database"
+       json_add_string "type" "memfile"
+       json_add_boolean "persist" 1
+       json_add_string "name" "$keadir/kea-leases4.csv"
+       json_add_int "lfc-interval" 900
+       json_add_int "max-row-errors" 1
+       json_close_object
+
+       json_add_object "interfaces-config"
+       json_add_array "interfaces"
+       # will populate later
+       json_close_array                # interfaces
+
+       json_add_boolean "re-detect" 0
+       json_add_string "dhcp-socket-type" "raw"
+       json_add_string "outbound-interface" "same-as-inbound"
+       json_close_object               # interfaces-config
+
+       ## option_def "renew-timer" 58 "uint32"
+
+       [ $authoritative -eq 1 ] && json_add_boolean "authoritative" "1"
+
+       json_add_boolean "ip-reservations-unique" "0"
+
+       if [ $dynamicdns -eq 1 ]; then
+               json_add_fields "ddns-qualifying-suffix:string=$g_domain." "ddns-send-updates:boolean=1"
+       fi
+
+       json_add_fields "valid-lifetime:int=$default_lease_time" "max-valid-lifetime:int=$g_max_lease_time" "renew-timer:int=$g_renewal_time"
+
+       option_data "name:domain-name" "data:string=$g_domain"
+
+       ### see:
+       ### https://gitlab.isc.org/isc-projects/kea/-/issues/241
+       if [ $always_broadcast -eq 1 ]; then
+               echo "This option is deprecated and being ignored: always-broadcast" >&2
+       fi
+}
+
+write_zones() {
+       if [ $dynamicdns -eq 1 ]; then
+               rndc freeze
+
+               create_empty_zone "$g_domain"
+
+               local mynet
+
+               for mynet in $rfc1918_nets; do
+                       rev_str mynet "$mynet" "."
+                       create_empty_zone "$mynet.in-addr.arpa"
+               done
+
+               rndc thaw
+       fi
+
+       rm -f /tmp/resolv.conf
+       echo "# This file is generated by the DHCPD service" > /tmp/resolv.conf
+       [ -n "$g_domain" ] && echo "domain $g_domain" >> /tmp/resolv.conf
+       echo "nameserver 127.0.0.1" >> /tmp/resolv.conf
+}
+
+main() {
+       # values parsed by general_config that we need to persist
+       # for subsequent subnet and host configurations
+       local dhcp_ifs= dynamicdns authoritative boot_unknown_clients
+       local g_domain g_renewal_time g_max_lease_time g_lease_time
+       local rfc1918_nets=""
+
+       local config_file="$1"
+
+       if [ ! -f /etc/config/dhcp ]; then
+               return 0
+       fi
+
+       local dyn_file="$(mktemp -u /tmp/dhcpd.XXXXXX)"
+
+       . /lib/functions.sh
+       . /lib/functions/ipv4.sh
+       . /lib/functions/network.sh
+       . /usr/share/libubox/jshn.sh
+
+       mkdir -p "$keadir"
+
+       config_load dhcp
+
+       json_init
+       json_add_object "Dhcp4"
+
+       general_config
+
+       if [ $dynamicdns -eq 1 ]; then
+               cat <<EOF > "$dyn_file"
+; Generated by $prog at $(date)
+
+ttl $TTL
+
+EOF
+       fi
+
+       local subnet4_id=0
+       json_add_array "subnet4"
+
+       config_foreach dhcpd_add dhcp
+
+       static_hosts
+
+       json_close_array        # subnet4
+
+       json_add_array "host-reservation-identifiers"
+       json_add_names "hw-address" "client-id"
+       json_close_array        # host-reservation-identifiers
+
+       # json_add_string "reservation-mode" "global"
+
+       json_add_boolean "reservations-in-subnet" 1
+
+       # plug the interfaces back in
+       json_select "interfaces-config"
+       json_select "interfaces"
+       json_add_names $dhcp_ifs
+       json_select ..
+       json_select ..
+
+       json_close_object       # Dhcp4
+
+       # the rest just generate DNS records
+       static_cnames
+
+       static_domains
+
+       static_mxhosts
+
+       static_srvhosts
+
+       write_zones
+
+       # not running on any interfaces
+       [ -z "$dhcp_ifs" ] && return 1
+
+       rfc1918_nets="${rfc1918_nets// /$NL}"
+       rfc1918_nets="$(echo "$rfc1918_nets" | sort -V | uniq)"
+       rfc1918_nets="${rfc1918_nets//$NL/ }"
+
+       if [ $dynamicdns -eq 1 ]; then
+               local args=
+
+               no_ipv6 && args="-4"
+
+               nsupdate -l -v $args "$dyn_file"
+
+       fi
+
+       rm -f "$dyn_file"
+
+       json_pretty
+       json_dump | sed 's/\t/  /g' > "$config_file"
+
+       return 0
+}
+
+main "$@"
+
git clone https://git.99rst.org/PROJECT