Re: Prefix Delegation Hook Script [v2]
Timo Sigurdsson
Mon Feb 11 23:47:35 2019
Hi Roy,
Hi List,
here's a second version of my prefix delegation hook script, which takes into account the comments I got so far on the questions I had [1].
Functionally, nothing is changed, but the script should be more robust in case the DHCPv6 numbers its messages oddly (see changes below).
If I get any more feedback on the questions and suggestions I sent in a separate mail [2], I might send another version.
But in general, I think the script should work in most scenarios and use cases as it is.
Changes from original submission:
1) Loop over both indexes (ordinals) in the prefix variable name in order to take care of cases in which the DHCPv6 server numbers its messages in an odd fashion. Now the variables new_dhcp6_ia_pd1_prefix1 to new_dhcp6_ia_pd5_prefix5 are tested.
2) Make the use of grep somewhat more consistent and drop the -s option where it's not needed.
3) Minor comment and style changes
Cheers,
Timo
[1] https://roy.marples.name/archives/dhcpcd-discuss/0002281.html
[2] https://roy.marples.name/archives/dhcpcd-discuss/0002282.html
# This shell script handles changes of delegated DHCPv6 prefixes (IA_PD) and allows to perform custom actions whenever the prefix changes.
# It is designed to do everything "in one run", if possible, in order to avoid repeated restarts/reloads of dependent services, etc.
# USAGE:
# This script is designed to be sourced from dhcpcd-run-hooks (dhcpcd).
# Place it either inside the dhcpcd-hooks directory or save it as /etc/dhcpcd.exit-hook.
# For this script to be useful, adjust the customizable functions in the section "CUSTOMIZABLE FUNCTIONS" below.
# By default, this script only logs errors to syslog. If dhcpcd's debug option is enabled, debugging information is logged as well.
# LIMITATIONS:
# 1) This script only works with global unicast (GUA) or unique local unicast (ULA) prefixes of a length between 48-64 bits.
# 2) It only checks the first five prefixes in the first five DHCPv6 messages at a time.
# 3) This script checks if a prefix stored in a lease file is orphaned, i.e. its expiration went unnoticed for some reason (uncommon, yet possible, e.g. during a power outage).
# A prefix is considered orphaned if no interface has an address assigned that matches the range of the prefix and if there is no route for the prefix.
# In such a case an error message will be logged (at the log level error), but no further action is taken.
# REQUIREMENTS:
# This script avoids "bashisms" and has been tested to work with dash (Debian Almquist Shell), so it should be fairly portable.
# On top of that, a few GNU utilities are used: cp, cut, grep, mkdir, rm, sed
# As this script was written and tested on Linux, the "ip" command (iproute2) is used to retrieve the IPv6 addresses of network interfaces matching a given prefix.
# It would be possible, though, to replace the ip command in this script with e.g. ifconfig and use the function fn_address_in_prefix_range in order to check whether the address matches the prefix.
# CONFIGURATION VARIABLES:
#
# Define a directory for custom lease files (this script will put files there to keep track of prefix changes). Best not to mix this up with where dhcpcd puts its lease files.
# Using a temporary file system ensures that a prefix that gets reassigned after a reboot is actually considered new and will trigger the function fn_custom_service_actions (see below).
# The detection of orphaned leases, however, only works across reboots if this directory is placed on a non-temporary file system.
# Defaults to /run/dhcpcd-run-hooks if undefined. The directory will be created if it doesn't exist. Omit the trailing slash.
IA_PD_LEASE_DIR="/run/dhcpcd-run-hooks"
# Choose whether the function fn_custom_service_actions (see below) should be skipped if the interface is down when a lease expires or dhcpcd stops.
# This may be useful e.g. to avoid restarting/reloading services when there's no internet connectivity.
# Set this to 1 in order to skip fn_custom_service_actions if interface is down.
IA_PD_SKIP_SERVICE_ACTIONS_WHEN_DOWN=0
# CUSTOMIZABLE FUNCTIONS:
#
# The following five functions allow performing custom actions after a prefix or subnet changed.
# The simplest use would be to restart or reload a service that depends on the changed IPv6 address information, e.g. the firewall.
# In this case, only the first function needs to be customized and the rest can be kept as is.
# The other four functions allow configuration changes per each prefix and subnet, e.g. adding/removing a firewall rule for the changed prefix.
# The actions are only performed if the lease actually changed. When the lease is merely renewed and matches the previously configured addresses, nothing is done.
#
# Nomenclature:
# The term prefix in this context refers to the IPv6 prefix received via DHCPv6 in the IA_PD option, e.g.: 2001:db8:8bd::/48
# The term subnet refers to a subnet derived from the prefix which dhcpcd assigns to another interface, e.g.: 2001:db8:8bd:1::1/64
# fn_custom_service_actions is called after new prefixes or subnets were configured or old ones expired.
# Customize it to restart or reload any system service that depends on the prefix/subnet information.
# This function will always be called after the other four custom functions (which may alter a service's configuration)!
fn_custom_service_actions() {
# dhcpcd-run-hooks provides a built-in function "service" that can be used to control a service.
# However, any other command may be used as well.
#
# Examples (uncomment and adjust the examples or put your own commands here):
#service firewall restart
#service ipv6-ra-daemon reload
# Keep the last line, so the function doesn't fail if no customizations were made.
[ "$syslog_debug" = "true" ] && syslog debug "Triggered function: fn_custom_service_actions" || true
}
# fn_custom_config_new_prefix is called after a new prefix has been leased.
# Customize it to make configuration changes to services that depend on the prefix information.
# If multiple prefixes are obtained at the same time (e.g. GUA+ULA), the function is called once per prefix!
fn_custom_config_new_prefix() {
# The variable $1 holds the new prefix including the length, e.g.: 2001:db8:8bd::/48
# The variable $2 holds the name of the interface which requested/received the prefix, e.g.: eth0
#
# Example (put your own commands here):
#/usr/local/bin/my_firewall_helper add_rule "$1" "$2"
# Keep the last line, so the function doesn't fail if no customizations were made.
[ "$syslog_debug" = "true" ] && syslog debug "Triggered function: fn_custom_config_new_prefix $1 $2" || true
}
# fn_custom_config_expired_prefix is called after an old prefix has expired.
# Customize it to make configuration changes to services that depend on the prefix information.
# Usually, this means to undo what fn_custon_new_prefix_config did before.
# If multiple prefixes are obtained at the same time (e.g. GUA+ULA), the function is called once per prefix!
fn_custom_config_expired_prefix() {
# The variable $1 holds the expired prefix including the length, e.g.: 2001:db8:8bd::/48
# The variable $2 holds the name of the interface which previously requested/received the expired prefix, e.g.: eth0
#
# Example (put your own commands here):
#/usr/local/bin/my_firewall_helper remove_rule "$1" "$2"
# Keep the last line, so the function doesn't fail if no customizations were made.
[ "$syslog_debug" = "true" ] && syslog debug "Triggered function: fn_custom_config_expired_prefix $1 $2" || true
}
# fn_custom_config_new_subnet is called after a new subnet has been assigned to an interface.
# Customize it to make configuration changes to services that depend on the subnet information.
# If subnets are delegated to multiple interfaces, the function is called once per subnet!
fn_custom_config_new_subnet() {
# The variable $1 holds the new subnet including the length, e.g.: 2001:db8:8bd:1::1/64
# The variable $2 holds the name of the interface to which the new subnet was assigned, e.g.: eth0
#
# Example (put your own commands here):
#/usr/local/bin/my_ipv6-ra-daemon_helper add_subnet "$1" "$2"
# Keep the last line, so the function doesn't fail if no customizations were made.
[ "$syslog_debug" = "true" ] && syslog debug "Triggered function: fn_custom_config_new_subnet $1 $2" || true
}
# fn_custom_config_expired_subnet is called after an old subnet that was assigned to an interface expired.
# Customize it to make configuration changes to services that depend on the prefix information.
# Usually, this means to undo what fn_custon_new_subnet_config did before.
# If subnets are delegated to multiple interfaces, the function is called once per subnet!
fn_custom_config_expired_subnet() {
# The variable $1 holds the expired subnet including the length, e.g.: 2001:db8:8bd:1::1/64
# The variable $2 holds the name of the interface to which the expired subnet was assigned, e.g.: eth0
#
# Example (put your own commands here):
# /usr/local/bin/my_ipv6-ra-daemon_helper remove_subnet "$1" "$2"
# Keep the last line, so the function doesn't fail if no customizations were made.
[ "$syslog_debug" = "true" ] && syslog debug "Triggered function: fn_custom_config_expired_subnet $1 $2" || true
}
#
# FROM HERE ON, NO CUSTOMIZATION SHOULD BE NECESSARY
# __________________________________________________
#
fn_new_lease() {
# Test if the lease is new (it's considered new if it's not already stored in the lease file for the interface)
# If the lease is new, it's written to the lease file for the interface
# The first argument is the lease to test
# The second argument is the interface
# Returns 0 if lease is new and 1 if it's already processed (or empty)
[ -n "$1" ] && [ -n "$2" ] || return 1
local lease_file
if [ "$reason" = "DELEGATED6" ] || [ "$interface" != "$2" ]; then
lease_file="${IA_PD_LEASE_DIR}/${ia_pd_common_name}-subnet.${2}"
else
lease_file="${IA_PD_LEASE_DIR}/${ia_pd_common_name}-prefix.${2}"
fi
if [ ! -d "$IA_PD_LEASE_DIR" ] && ! mkdir -p "$IA_PD_LEASE_DIR" ; then
syslog err "Error: Failed to create directory ${IA_PD_LEASE_DIR}. This error is fatal!"
return 1
elif [ ! -w "$IA_PD_LEASE_DIR" ]; then
syslog err "Error: Directory ${IA_PD_LEASE_DIR} is not writable. This error is fatal!"
return 1
fi
if [ ! -s "$lease_file" ] || ! grep -q "^${1}\$" "$lease_file" ; then
# Write new prefix to lease file
echo "$1" >> "$lease_file"
return 0
fi
return 1
}
fn_expired_lease() {
# Test if the lease is expired (it's expired if it's still stored in the lease file for the interface)
# If the lease is expired, it's removed from the lease file for the interface
# The first argument is the lease to test
# Returns 0 if lease is expired and 1 if it's already processed (or empty)
[ -n "$1" ] || return 1
local lease_file
lease_file="${IA_PD_LEASE_DIR}/${ia_pd_common_name}-prefix.${interface}"
if [ -s "$lease_file" ] && grep -q "^${1}\$" "$lease_file" ; then
# Delete prefix from lease file
sed -i "\\#^${1}\$#d" "$lease_file"
return 0
fi
return 1
}
fn_valid_prefix() {
# Validate the prefix to be a global unicast or unique local unicast prefix with a length between 48 and 64 bits
# The first argument is the prefix and the second argument the length
# Returns 0 if the prefix is valid
[ -n "$1" ] && [ -n "$2" ] || return 1
if [ "$2" -lt "48" ] || [ "$2" -gt "64" ] || (! echo "$1" | grep -qiE '^([23][0-9a-f]|f[cd])[0-9a-f]{2}(:[0-9a-f]{1,4}){0,3}::$'); then
return 1
fi
return 0
}
fn_normalize_ipv6() {
# Expand empty (zero) hextets in an IPv6 prefix or address, lowercase output and remove superfluous zeros in hextets
# The first argument is the prefix or address that should be expanded
# This function assumes that the input was validated before
[ -n "$1" ] || return 1
local colons omitted_zeros insert_zeros normalized_ipv6
if echo "$1" | grep -q '::' ; then
colons="$(echo "$1" | sed 's/[^:]//g')"
omitted_zeros="$(echo '::::::::' | sed "s/${colons}//")"
insert_zeros="$(echo "$omitted_zeros" | sed 's/:/0:/g')"
# Append another zero if the prefix/address ends with ::
if echo "$1" | grep -qE '::(/|$)' ; then
insert_zeros="${insert_zeros}0"
fi
normalized_ipv6="$(echo "$1" | sed "s/::/:${insert_zeros}/")"
else
normalized_ipv6="$1"
fi
echo "$normalized_ipv6" | sed -E 's/:0{1,3}([^:/])/:\1/g' | sed -E 's/[A-F]/\L&/g'
}
fn_address_in_prefix_range() {
# Check if an IPv6 address is in the range of a prefix
# The first argument is the address and the second argument the prefix
# Returns 0 if the address is in the range of the prefix
[ -n "$1" ] && [ -n "$2" ] || return 1
local address_length prefix_length normalized_address normalized_prefix address_value address_value_dec \
prefix_value prefix_value_dec max_subnets prefix_range_start prefix_range_end
# Compare the length of the address and prefix first
address_length="$(echo "$1" | cut -s -d'/' -f2)"
prefix_length="$(echo "$2" | cut -s -d'/' -f2)"
([ -z "$address_length" ] || [ -z "$prefix_length" ] || [ "$address_length" -lt "$prefix_length" ]) && return 1
# Normalize input to allow for a simpler comparison
normalized_address="$(fn_normalize_ipv6 "$1")"
[ -z "$normalized_address" ] && return 1
normalized_prefix="$(fn_normalize_ipv6 "$2")"
[ -z "$normalized_prefix" ] && return 1
# Compare the first three hextets
if [ "$(echo "$normalized_address" | cut -d':' -f1-3)" = "$(echo "$normalized_prefix" | cut -d':' -f1-3)" ]; then
# Get decimal values of the fourth hextet
address_value="$(echo "$normalized_address" | cut -d':' -f4)"
address_value_dec="$(printf '%d' "0x${address_value}")"
prefix_value="$(echo "$normalized_prefix" | cut -d':' -f4)"
prefix_value_dec="$(printf '%d' "0x${prefix_value}")"
# Calculate the decimal range of the prefix
max_subnets="$((1<<(64-prefix_length)))"
prefix_range_start="$(( (prefix_value_dec/max_subnets)*max_subnets ))"
prefix_range_end="$((prefix_range_start+max_subnets-1))"
# Return 0 if the address is in the range of the prefix
if [ "$address_value_dec" -ge "$prefix_range_start" ] && [ "$address_value_dec" -le "$prefix_range_end" ]; then
return 0
else
return 1
fi
else
return 1
fi
}
fn_determine_new_subnets() {
# Determine which interfaces got assigned subnets from the delegated prefix
# The first argument is the prefix and the second argument the length
[ -n "$1" ] && [ -n "$2" ] || return 1
if ! fn_valid_prefix "$1" "$2" ; then
# NOTE: A failure of this function isn't critical, since the subnets will be detected in the DELEGATED6 events, too,
# but it will lead to the function fn_custom_service_actions being triggered more often than necessary.
syslog err "Error: Prefix ${1}/${2} either isn't a global or unique local unicast address or has an unsuitable length."
return 1
fi
local subnet_interface subnet_address
for subnet_interface in $interface_order ; do
([ "$subnet_interface" = "$interface" ] || [ "$subnet_interface" = "lo" ]) && continue
# Test if the interface address is a subnet of the prefix
# Limit grep to one match as there should only be one address per prefix on the interface
# The regular expression doesn't need to be airtight here since ip should only return valid IPv6 addresses anyway
subnet_address="$(ip -6 -o addr show dev "$subnet_interface" scope global to "${1}/${2}" 2>/dev/null | grep -m1 -oiP '(?<=[[:blank:]])([23][0-9a-f]|f[cd])[0-9a-f]{2}(:{1,2}[0-9a-f]{0,4}){1,7}/[1-9][0-9]{1,2}(?=[[:blank:]])')"
if [ "$subnet_address" ]; then
[ "$syslog_debug" = "true" ] && syslog debug "Delegated new subnet $subnet_address to interface ${subnet_interface}."
fn_new_lease "$subnet_address" "$subnet_interface"
fn_custom_config_new_subnet "$subnet_address" "$subnet_interface"
fi
done
}
fn_determine_expired_subnets() {
# Determine which interfaces had assigned subnets from the expired delegated prefix
# The first argument is the prefix and the second argument the length
[ -n "$1" ] && [ -n "$2" ] || return 1
if ! fn_valid_prefix "$1" "$2" ; then
# NOTE: A failure of this function isn't necessarily critical, but it means that the function fn_custom_config_expired_subnet
# isn't triggered and that expired subnets possibly remain in any configuration changed by fn_custom_config_new_subnet.
syslog err "Error: Prefix ${1}/${2} either isn't a global or unique local unicast address or has an unsuitable length."
return 1
fi
local subnet_interface lease_file subnet_address
for subnet_interface in $interface_order ; do
([ "$subnet_interface" = "$interface" ] || [ "$subnet_interface" = "lo" ]) && continue
lease_file="${IA_PD_LEASE_DIR}/${ia_pd_common_name}-subnet.${subnet_interface}"
if [ -s "$lease_file" ]; then
# Read the ip addresses from the interface's subnet lease file and test if the address is a subnet of the expired prefix
# Make a temporary copy of the file in order to not read from and write to the file at the same time
cp "$lease_file" "${lease_file}.tmp"
while IFS= read -r subnet_address ; do
if fn_address_in_prefix_range "$subnet_address" "${1}/${2}" ; then
[ "$syslog_debug" = "true" ] && syslog debug "Delegated subnet $subnet_address on interface $subnet_interface expired."
# Remove the address from the lease file
sed -i "\\#^${subnet_address}\$#d" "$lease_file"
fn_custom_config_expired_subnet "$subnet_address" "$subnet_interface"
# There shouldn't be another subnet of the same prefix on this interface, so stop here
break
fi
done < "${lease_file}.tmp"
rm "${lease_file}.tmp"
fi
done
}
fn_orphaned_leases() {
# Check if the lease file contains orphaned leases
local lease_file leased_prefix
lease_file="${IA_PD_LEASE_DIR}/${ia_pd_common_name}-prefix.${interface}"
if [ -s "$lease_file" ]; then
while IFS= read -r leased_prefix ; do
# If the ip command returns nothing here, it means the prefix is not in use anymore (not assigned to any interface and no route for it)
if (! ip -6 -o addr show scope global to "$leased_prefix" 2>/dev/null | grep -q ':') && (! ip -6 -o route show to "$leased_prefix" 2>/dev/null | grep -q ':'); then
syslog err "Error: The prefix $leased_prefix seems orphaned! No interface address or route matches the prefix! Remove the prefix and its subnets from the lease files and any service's configuration they might have been added to."
fi
done < "$lease_file"
fi
}
[ -z "$IA_PD_LEASE_DIR" ] || IA_PD_LEASE_DIR="/run/dhcpcd-run-hooks"
# Files in the lease directory will start with this string
ia_pd_common_name="dhcpv6"
ia_pd_trigger_service_actions=0
case "$reason" in
BOUND6|RENEW6|REBIND6|REBOOT6)
# Check for orphaned prefixes, before processing new ones (just in case an expiration event was somehow missed)
fn_orphaned_leases
for ia_pd_message_no in 1 2 3 4 5; do
for ia_pd_prefix_no in 1 2 3 4 5; do
eval "ia_pd_prefix=\$new_dhcp6_ia_pd${ia_pd_message_no}_prefix${ia_pd_prefix_no}"
eval "ia_pd_prefix_length=\$new_dhcp6_ia_pd${ia_pd_message_no}_prefix${ia_pd_prefix_no}_length"
# Ignore the prefix if it's all zeros (::)
if [ -n "$ia_pd_prefix" ] && [ "$ia_pd_prefix" != "::" ] && [ -n "$ia_pd_prefix_length" ] && fn_new_lease "${ia_pd_prefix}/${ia_pd_prefix_length}" "$interface" ; then
[ "$syslog_debug" = "true" ] && syslog debug "Received new prefix ${ia_pd_prefix}/${ia_pd_prefix_length}."
ia_pd_trigger_service_actions=1
fn_custom_config_new_prefix "${ia_pd_prefix}/${ia_pd_prefix_length}" "$interface"
fn_determine_new_subnets "$ia_pd_prefix" "$ia_pd_prefix_length"
fi
done
done
[ "$ia_pd_trigger_service_actions" -eq 1 ] && fn_custom_service_actions
;;
DELEGATED6)
# Usually, nothing is done here, since all the magic happens in the BOUND6|RENEW6|REBIND6|REBOOT6 events already
# But if an interface wasn't ready when the lease was obtained, the delegation will happen when the interface comes up later, so take care of this here
if [ -n "$new_delegated_dhcp6_prefix" ]; then
for ia_pd_delegated_subnet in $new_delegated_dhcp6_prefix ; do
if fn_new_lease "$ia_pd_delegated_subnet" "$interface" ; then
[ "$syslog_debug" = "true" ] && syslog debug "Delegated new subnet $ia_pd_delegated_subnet to interface ${interface}."
ia_pd_trigger_service_actions=1
fn_custom_config_new_subnet "$ia_pd_delegated_subnet" "$interface"
fi
done
[ "$ia_pd_trigger_service_actions" -eq 1 ] && fn_custom_service_actions
fi
;;
EXPIRE6|STOP6)
for ia_pd_message_no in 1 2 3 4 5; do
for ia_pd_prefix_no in 1 2 3 4 5; do
eval "ia_pd_prefix=\$old_dhcp6_ia_pd${ia_pd_message_no}_prefix${ia_pd_prefix_no}"
eval "ia_pd_prefix_length=\$old_dhcp6_ia_pd${ia_pd_message_no}_prefix${ia_pd_prefix_no}_length"
if [ -n "$ia_pd_prefix" ] && [ -n "$ia_pd_prefix_length" ] && fn_expired_lease "${ia_pd_prefix}/${ia_pd_prefix_length}" ; then
[ "$syslog_debug" = "true" ] && syslog debug "Old prefix ${ia_pd_prefix}/${ia_pd_prefix_length} expired."
ia_pd_trigger_service_actions=1
fn_custom_config_expired_prefix "${ia_pd_prefix}/${ia_pd_prefix_length}" "$interface"
fn_determine_expired_subnets "$ia_pd_prefix" "$ia_pd_prefix_length"
fi
done
done
# Trigger fn_custom_service_actions when a lease expired (that hasn't been processed before) or when dhcpcd stops
# But don't do anything if the interface is down and the option IA_PD_SKIP_SERVICE_ACTIONS_WHEN_DOWN is enabled
if ([ "$ia_pd_trigger_service_actions" -eq 1 ] || [ "$reason" = "STOP6" ]) && ([ "$if_up" = "true" ] || [ "$IA_PD_SKIP_SERVICE_ACTIONS_WHEN_DOWN" != 1 ]); then
fn_custom_service_actions
fi
;;
esac
# Always return true (the last operation may have been an if-clause with a condition that evaluated to false)
true
Archive administrator: postmaster@marples.name