Created
November 28, 2024 16:06
-
-
Save tonusoo/ddc76363c66c2cdda7a1082f0c802e20 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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