Skip to content

Instantly share code, notes, and snippets.

@tonusoo
Created November 28, 2024 16:06
Show Gist options
  • Save tonusoo/ddc76363c66c2cdda7a1082f0c802e20 to your computer and use it in GitHub Desktop.
Save tonusoo/ddc76363c66c2cdda7a1082f0c802e20 to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
# Title : systemd-networkd-confgen
# Last modified date : 8.02.2024
# Author : Martin Tonusoo
# Description : Script manages systemd-networkd conf files
# with "[IPv6Prefix]" and "[IPv6SendRA]" configuration
# sections in /run/systemd/network/10-br0.network.d/
# directory. "[IPv6Prefix]" configuration is built based
# on the content of the files in /run/prefix-capture/
# directory and IPv6 default route. "[IPv6SendRA]" conf
# is based on the existence of the IPv6 default route
# and GUAs on LAN facing interface.
# Options :
# Notes : Script is meant to be run as an "exec" type systemd
# service.
#
# The script follows RFC 7084 (Basic Requirements for
# IPv6 Customer Edge Routers) and RFC 9096 (Improving
# the Reaction of Customer Edge Routers to IPv6
# Renumbering Events) where it makes sense and has a
# practical benefit.
#
# The script does not use logger or systemd-cat because
# occasionally the systemd-journald fails to associate
# the short-lived process sending the log message with
# the systemd service and thus the messages are not seen
# in the output of "journalctl -u <service>" or "systemctl
# status <service>". Two bug reports related to this
# issue can be seen below:
#
# https://bugzilla.redhat.com/show_bug.cgi?id=1714373
# https://bugzilla.redhat.com/show_bug.cgi?id=1426152
#
# As a workaround, the severity level is prepended to
# log messages. This preserves the journalctl capability
# to filter the messages based on the severity level and
# messages are colored based on the priority.
#
# /run/prefix-capture/ directory is created by
# "make-prefix-capture-dir" systemd service and files
# within that directory are managed by icmpv6-ra-capture
# and dhcpv6-pd-capture daemons. File names will be
# identical to Internet facing interfaces where the
# daemons are listening on, e.g "wan0" or "wwan0".
# Content of the files will be zero or more CSV lines
# containing prefix, valid lifetime of the prefix and
# preferred lifetime of the prefix.
make_prefixes_cfg() {
local isp_prefixes_file="$1"
local gw_int="$2"
local int="${isp_prefixes_file##*/}"
local func="${FUNCNAME[0]}"
local reload_needed plft assign isp_prfx
local isp_vlft isp_plft file h h1 h2 h3 h4
reload_needed=""
# No prefixes from ISP.
if [[ ! -s "$isp_prefixes_file" ]]; then
printf "<5>INFO: %s %s\n" \
"$func: no prefixes from ISP connected to $int." \
"Deprecate all prefixes related to $int." >&2
for file in "$conf_dir"/"$int"_*.conf; do
if grep -q "^PreferredLifetimeSec=0$" "$file"; then
printf "<7>DEBUG: %s %s\n" \
"$func: file ${file##*/} has PreferredLifetimeSec" \
"set to 0. Do not touch the file." >&2
continue
fi
printf "<5>INFO: %s %s\n" \
"$func: file ${file##*/} PreferredLifetimeSec" \
"set to 0 and Assign set to False." >&2
sed -i \
-e "s/PreferredLifetimeSec=7200/PreferredLifetimeSec=0/" \
-e "s/Assign=True/Assign=False/" \
"$file"
reload_needed="yes"
done
fi
# Add or update prefixes from ISP.
while IFS=, read -r isp_prfx isp_vlft isp_plft; do
plft="7200"
assign="True"
printf "<7>DEBUG: %s\n" "$func: processing $int prefix $isp_prfx" >&2
IFS=: read -r h1 h2 h3 h4 _ <<< "${isp_prfx%/*}"
# Length of the prefix part of the file name will be the same
# regardless of the prefix, e.g prefix 2001:db8:0:cc::/64 on
# wwan0 would get a file name wwan0_2001-0db8-0000-00cc.conf or
# 2001:db8::/64 on wwan0 would get a file name of
# wwan0_2001-0db8-0000-0000.conf
file=""
for h in "$h1" "$h2" "${h3:-0}" "${h4:-0}"; do
file="$file"$(printf "%04x-" "0x$h")
done
file="$conf_dir/${int}_${file%-}.conf"
if [[ -f "$file" ]]; then
if [[ "$int" != "$gw_int" ]]; then
if grep -q "^PreferredLifetimeSec=0$" "$file"; then
printf "<7>DEBUG: %s %s %s %s\n" \
"$func: ignoring $int prefix $isp_prfx" \
"as PreferredLifetimeSec in ${file##*/}" \
"is already set to 0 and v6 gateway isn't"\
"via $int" >&2
continue
else
printf "<7>DEBUG: %s %s %s\n" \
"$func: set the $int prefix $isp_prfx" \
"PreferredLifetimeSec in ${file##*/}" \
"to 0 as v6 gateway isn't via $int" >&2
plft="0"
assign="False"
fi
fi
printf "<5>INFO: %s: updating existing %s file\n" \
"$func" \
"${file##*/}" >&2
else
if [[ "$int" != "$gw_int" ]]; then
printf "<7>DEBUG: %s %s %s\n" \
"$func: ignoring $int prefix $isp_prfx" \
"as v6 gateway isn't via $int." \
"${file##*/} file not created." >&2
continue
fi
printf "<5>INFO: %s: creating a new %s file\n" \
"$func" \
"${file##*/}" >&2
fi
# Take into account a possibility that ISP advertises
# the prefix with "valid lifetime" or "preferred lifetime"
# set to zero.
if [[ "$isp_vlft" == 0 ]]; then
# Valid lifetime is always greater than or equal to the
# preferred lifetime.
printf "<7>DEBUG: %s %s %s\n" \
"$func: $int prefix $isp_prfx valid" \
"and preferred lifetimes were 0. Set the" \
"PreferredLifetimeSec in ${file##*/} to 0" >&2
plft="0"
assign="False"
elif [[ "$isp_plft" == 0 ]]; then
printf "<7>DEBUG: %s %s %s\n" \
"$func: $int prefix $isp_prfx" \
"preferred lifetime was 0. Set the" \
"PreferredLifetimeSec in ${file##*/} to 0" >&2
plft="0"
assign="False"
fi
printf "<7>DEBUG: %s %s\n" \
"$func: updating file ${file##*/}:" \
"Assign=$assign and PreferredLifetimeSec=$plft" >&2
cat <<-EOF > "$file"
[IPv6Prefix]
Assign=$assign
Prefix=$isp_prfx
ValidLifetimeSec=7200
PreferredLifetimeSec=$plft
EOF
reload_needed="yes"
done < "$isp_prefixes_file"
# Deprecate prefixes which are no longer advertised by ISP.
for file in "$conf_dir"/"$int"_*.conf; do
prefix=$(grep -oP "(?<=^Prefix=).*$" "$file")
if ! grep -q "$prefix" "$isp_prefixes_file"; then
printf "<5>INFO: %s %s\n" \
"$func: prefix $prefix no longer advertised by ISP." \
"Update the file ${file##*/}" >&2
if grep -q "^PreferredLifetimeSec=0$" "$file"; then
printf "<7>DEBUG: %s %s\n" \
"$func: file ${file##*/} has PreferredLifetimeSec" \
"set to 0. Do not touch the file." >&2
continue
fi
printf "<5>INFO: %s %s\n" \
"$func: file ${file##*/} PreferredLifetimeSec" \
"set to 0 and Assign set to False." >&2
sed -i \
-e "s/PreferredLifetimeSec=7200/PreferredLifetimeSec=0/" \
-e "s/Assign=True/Assign=False/" \
"$file"
reload_needed="yes"
fi
done
[[ -n "$reload_needed" ]] && reload_services
}
rm_systemd_networkd_conf() {
local func="${FUNCNAME[0]}"
local reload_needed file last_modified
reload_needed=""
for file in "$conf_dir"/*_*.conf; do
last_modified=$(( $(date +%s) - $(stat -c %Y "$file") ))
if (( last_modified > 7200 )); then
if grep -q "^PreferredLifetimeSec=0$" "$file"; then
# Deprecated prefix(preferred lifetime set to 0)
# has been announced for 2h. It's safe to stop
# advertising the prefix and remove the conf file.
printf "<5>INFO: %s: removing file %s\n" \
"$func" \
"${file##*/}" >&2
rm -f "$file"
reload_needed="yes"
else
# ISP has not announced the prefix for 2h. Deprecate
# the prefix. This will also update the mtime of the
# prefix file.
printf "<5>INFO: %s: file %s PreferredLifetimeSec set to 0\n" \
"$func" \
"${file##*/}" >&2
sed -i \
-e "s/PreferredLifetimeSec=7200/PreferredLifetimeSec=0/" \
-e "s/Assign=True/Assign=False/" \
"$file"
reload_needed="yes"
fi
fi
done
[[ -n "$reload_needed" ]] && reload_services
}
set_router_lifetime() {
local func="${FUNCNAME[0]}"
local router_lifetime default_route global_addr file
router_lifetime="600"
# Do not advertise itself as an IPv6 default router on LAN if
# there are no IPv6 default routes present.
# RFC 7084 section 4.1 G-4.
default_route=""
for _ in {1..5}; do
if read -r -n1 -d '' < <(ip -6 route show default 2>/dev/null); then
default_route="yes"
break
fi
sleep 0.01
done
if [[ -z "$default_route" ]]; then
printf "<7>DEBUG: %s %s\n" \
"$func: v6 default route is missing." \
"Set RouterLifetimeSec to 0." >&2
router_lifetime="0"
fi
# Do not advertise itself as an IPv6 default router on LAN if
# LAN facing interface has no global unicast addresses. This can
# happen for example if the ISPs routers advertise themselves as
# default routers while no prefixes are advertised.
# RFC 7084 section 4.3 L-4.
#
# According to "ip -6 monitor address" the "networkctl reload"
# removes and then readds the addresses. It can happen that the
# "ip -6 addr show ..." is executed within this few milliseconds
# long window. As a workaround, repeat the "ip -6 addr show"
# command up to five times with 10 ms breaks.
#
global_addr=""
for _ in {1..5}; do
if read -r -n1 -d '' < <(ip -6 addr show dev "$lan_int" \
scope global 2>/dev/null); then
global_addr="yes"
break
fi
sleep 0.01
done
if [[ -z "$global_addr" ]]; then
printf "<7>DEBUG: %s %s\n" \
"$func: No global unicast addresses on $lan_int." \
"Set RouterLifetimeSec to 0." >&2
router_lifetime="0"
fi
file="$conf_dir/router-lifetime.conf"
if ! grep -sq "^RouterLifetimeSec=${router_lifetime}$" "$file"; then
# router-lifetime.conf file is missing or
# RouterLifetimeSec parameter needs to be
# updated.
printf "<5>INFO: %s %s\n" \
"$func: RouterLifetimeSec in ${file##*/}" \
"is updated to $router_lifetime." >&2
cat <<-EOF > "$file"
[IPv6SendRA]
RouterLifetimeSec=$router_lifetime
EOF
reload_services
else
printf "<7>DEBUG: %s %s\n" \
"$func: RouterLifetimeSec=$router_lifetime." \
"${file##*/} not updated." >&2
fi
}
reload_services() {
local func="${FUNCNAME[0]}"
if systemctl is-active --quiet systemd-networkd.service; then
printf "<7>DEBUG: %s: networkctl reload\n" "$func" >&2
# systemd-networkd does nothing if the
# *.conf files modification time has
# not actually changed.
networkctl reload 2>/dev/null
reloaded="yes"
fi
}
shopt -s nullglob
# LAN facing interface.
lan_int="br0"
conf_dir="/run/systemd/network/10-$lan_int.network.d"
if [[ ! -d "$conf_dir" ]]; then
printf "<5>INFO: %s directory is missing. Creating.\n" "$conf_dir" >&2
mkdir -p "$conf_dir"
fi
while read -r event watched_dir int_file; do
reloaded=""
if [[ "$event" == "ATTRIB,ISDIR" ]]; then
# Systemd service(started by systemd timer) responsible
# for deprecating and removing the expired prefixes
# executed "touch /run/prefix-capture/".
rm_systemd_networkd_conf
set_router_lifetime
elif [[ "$event" == "CLOSE_WRITE,CLOSE" ]]; then
# icmpv6-ra-capture or dhcpv6-pd-capture daemon wrote to
# /run/prefix-capture/wan0 or /run/prefix-capture/wwan0
# file. Alternatively, the isp-switch executed
# "touch /run/prefix-capture/*wan*". The touch utility
# calls openat() with "O_WRONLY" flag and later close()
# under the hood and thus triggers the inotifywait listening
# for the "close_write" event.
printf "<5>INFO: Received update on interface %s\n" "$int_file" >&2
gw_int=$(ip -o -6 route get :: 2>/dev/null | grep -oP "(?<= dev )[^ ]+")
printf "<5>INFO: IPv6 default route %s %s\n" \
"${gw_int:+"interface "}is" \
"${gw_int:-"missing"}" >&2
make_prefixes_cfg "${watched_dir}/${int_file}" "$gw_int"
sleep 0.5
set_router_lifetime
fi
if [[ -n "$reloaded" ]]; then
# Short cooldown.
sleep 0.5
# At least one of the functions called the reload_services().
# Update the modification time of router-lifetime.conf and
# recall the reload_services(). This triggers systemd-networkd
# to resend the Router Advertisement message for robustness
# purposes. This means that LAN hosts receive two RA messages
# half a second apart with with identical information.
touch "$conf_dir/router-lifetime.conf"
reload_services
fi
done < <(
inotifywait --format "%e %w %f" -qme close_write,attrib /run/prefix-capture/
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment