#!/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" 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")" if [ -n "$renewal_time" ]; then option_data "name:dhcp-renewal-time" "data:string=$renewal_time" $always elif [ -n "$always" ]; then option_data "name:dhcp-renewal-time" "data:string=$s_renewal_time" $always fi ### 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 < "$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 "$@"