#!/usr/bin/env bash
# route-config-sync.sh
# Version 1.4
# Snapshot live Linux routes, compare them with persistent network configuration,
# and stage desired configuration files WITHOUT modifying /etc or activating changes.
#
# Supported backends (auto-detected):
# - NetworkManager (stages an nmcli apply script)
# - systemd-networkd (stages .network.d route drop-ins)
# - Netplan (stages a route-only YAML overlay)
# - ifupdown / ifupdown2 (stages interfaces.d file)
# - RHEL/CentOS legacy network-scripts (stages route-<if> / route6-<if>)
#
# Requirements: bash, ip, awk, sed, grep, sort, comm, diff, find, cp, mkdir.
# Optional/backend-specific: nmcli, netplan, systemctl.
#
# This script never writes below /etc and never runs reload/apply/up/down commands.
set -Eeuo pipefail
IFS=$'\n\t'
PROG=${0##*/}
ORIGINAL_ARGS=("$@")
OUTDIR=""
BACKEND="auto"
INCLUDE_DYNAMIC=0
INCLUDE_DEFAULT=1
VERBOSE=0
DEBUG_LOG=""
usage() {
cat <<USAGE
Usage: $PROG -o OUTPUT_DIR [options]
Options:
-o, --output DIR Required. Store staged files and reports here.
-b, --backend NAME auto|networkmanager|networkd|netplan|ifupdown|network-scripts
--include-dynamic Also persist DHCP/RA routes (normally excluded).
--no-default Do not stage default routes.
-v, --verbose Detailed diagnostics to stderr and OUTPUT_DIR/debug.log.
-h, --help Show this help.
The output directory contains:
live-routes.all.txt Complete route snapshot for all tables/families.
live-routes.persist.txt Normalized routes selected for persistence.
permanent-routes.txt Normalized routes discovered in permanent config.
changes.txt ADD / UPDATE / DELETE summary.
diff.txt Unified diff of normalized permanent vs desired routes.
desired/ Backend-specific staged configuration.
APPLY-GUIDE.txt Distribution/backend-specific application guide.
debug.log Detailed trace when -v/--verbose is used.
USAGE
}
log() { printf '[%s] %s\n' "$PROG" "$*" >&2; }
debug() {
(( VERBOSE )) || return 0
local msg="[$PROG][DEBUG] $*"
printf '%s\n' "$msg" >&2
[[ -n "${DEBUG_LOG:-}" ]] && printf '%s\n' "$msg" >>"$DEBUG_LOG"
}
debug_section() {
(( VERBOSE )) || return 0
local title=$1
debug "----------------------------------------------------------------"
debug "$title"
debug "----------------------------------------------------------------"
}
debug_cmd() {
(( VERBOSE )) || return 0
local label=$1; shift
debug "COMMAND [$label]: $*"
local out rc
set +e
out=$("$@" 2>&1)
rc=$?
set -e
debug "COMMAND [$label] rc=$rc"
if [[ -n "$out" ]]; then
while IFS= read -r line; do debug " $line"; done <<<"$out"
else
debug " <no output>"
fi
return 0
}
die() { log "ERROR: $*"; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }
while (($#)); do
case "$1" in
-o|--output) [[ $# -ge 2 ]] || die "$1 requires a value"; OUTDIR=$2; shift 2 ;;
-b|--backend) [[ $# -ge 2 ]] || die "$1 requires a value"; BACKEND=$2; shift 2 ;;
--include-dynamic) INCLUDE_DYNAMIC=1; shift ;;
--no-default) INCLUDE_DEFAULT=0; shift ;;
-v|--verbose) VERBOSE=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown option: $1" ;;
esac
done
[[ -n "$OUTDIR" ]] || { usage >&2; exit 2; }
have ip || die "ip(8) from iproute2 is required"
case "$BACKEND" in
auto|networkmanager|networkd|netplan|ifupdown|network-scripts) ;;
*) die "unsupported backend: $BACKEND" ;;
esac
mkdir -p "$OUTDIR/desired"
OUTDIR=$(cd "$OUTDIR" && pwd -P)
TMP=$(mktemp -d "${TMPDIR:-/tmp}/${PROG}.XXXXXX")
trap 'rm -rf "$TMP"' EXIT
if (( VERBOSE )); then
DEBUG_LOG="$OUTDIR/debug.log"
: >"$DEBUG_LOG"
debug_section "Invocation"
printf -v _argv_q ' %q' "${ORIGINAL_ARGS[@]}"
debug "command line: $PROG${_argv_q:-}"
debug "effective uid: $(id -u 2>/dev/null || echo '?')"
debug "kernel: $(uname -srmo 2>/dev/null || uname -a 2>/dev/null || echo unknown)"
debug "PATH: $PATH"
debug "temporary directory: $TMP"
debug "output directory: $OUTDIR"
debug "requested backend: $BACKEND"
debug "include dynamic routes: $INCLUDE_DYNAMIC"
debug "include default routes: $INCLUDE_DEFAULT"
debug_cmd "ip version" ip -Version
debug_cmd "IPv4 addresses" ip -4 -o addr show
debug_cmd "IPv6 addresses" ip -6 -o addr show
debug_cmd "IPv4 policy rules" ip -4 rule show
debug_cmd "IPv6 policy rules" ip -6 rule show
have nmcli && debug_cmd "nmcli version" nmcli --version
fi
LIVE_ALL="$OUTDIR/live-routes.all.txt"
LIVE_NORM="$OUTDIR/live-routes.persist.txt"
PERM_NORM="$OUTDIR/permanent-routes.txt"
CHANGES="$OUTDIR/changes.txt"
DIFFOUT="$OUTDIR/diff.txt"
# Normalized record format (pipe-delimited):
# family|destination|via|dev|metric|table|type|proto
# family: 4 or 6. Empty optional fields are represented by '-'.
normalize_ip_route_line() {
local fam=$1 line=$2
awk -v fam="$fam" '
function val_after(k, i) { for (i=1;i<=NF;i++) if ($i==k && i<NF) return $(i+1); return "-" }
BEGIN { OFS="|" }
{
type="unicast"; dest=""; via="-"; dev="-"; metric="-"; table="main"; proto="-";
i=1;
if ($1 ~ /^(blackhole|unreachable|prohibit|throw|local|broadcast|multicast|anycast)$/) { type=$1; i=2 }
dest=$i;
if (dest=="default") dest=(fam==4 ? "0.0.0.0/0" : "::/0");
via=val_after("via"); dev=val_after("dev"); metric=val_after("metric");
table=val_after("table"); if (table=="-") table="main";
proto=val_after("proto");
print fam,dest,via,dev,metric,table,type,proto;
}' <<<"$line"
}
# Return success when a live route should be represented in persistent output.
# PERSIST_REASON is populated for verbose diagnostics.
PERSIST_REASON=""
persistable_live_line() {
local line=$1
PERSIST_REASON="included"
# Do not hard-code kernel-generated scope/link routes or special local routes.
if grep -Eq '(^| )proto kernel( |$)' <<<"$line"; then
PERSIST_REASON="excluded: proto kernel (connected/kernel-generated route)"
return 1
fi
if grep -Eq '^(local|broadcast|multicast|anycast) ' <<<"$line"; then
PERSIST_REASON="excluded: local/broadcast/multicast/anycast route"
return 1
fi
# DHCP/RA are intentionally dynamic unless explicitly requested.
if (( ! INCLUDE_DYNAMIC )) && grep -Eq '(^| )proto (dhcp|ra)( |$)' <<<"$line"; then
PERSIST_REASON="excluded: DHCP/RA-learned route (--include-dynamic not set)"
return 1
fi
if (( ! INCLUDE_DEFAULT )) && grep -Eq '^(default|0\.0\.0\.0/0|::/0)( |$)' <<<"$line"; then
PERSIST_REASON="excluded: default route (--no-default set)"
return 1
fi
return 0
}
snapshot_live() {
: >"$LIVE_ALL"; : >"$LIVE_NORM"
debug_section "Live route snapshot and persistence filtering"
local fam line norm count_all=0 count_keep=0 count_drop=0
for fam in 4 6; do
debug "collecting IPv${fam} routes: ip -${fam} -o route show table all"
while IFS= read -r line; do
[[ -n "$line" ]] || continue
((count_all+=1))
printf 'IPv%s %s\n' "$fam" "$line" >>"$LIVE_ALL"
if persistable_live_line "$line"; then
norm=$(normalize_ip_route_line "$fam" "$line")
printf '%s\n' "$norm" >>"$LIVE_NORM"
((count_keep+=1))
debug "LIVE INCLUDE IPv${fam}: $line"
debug " normalized: $norm"
else
((count_drop+=1))
debug "LIVE EXCLUDE IPv${fam}: $line"
debug " reason: $PERSIST_REASON"
fi
done < <(ip -"$fam" -o route show table all 2>/dev/null || true)
done
LC_ALL=C sort -u -o "$LIVE_NORM" "$LIVE_NORM"
debug "live route summary: total=$count_all selected=$count_keep excluded=$count_drop normalized_unique=$(wc -l <"$LIVE_NORM" | tr -d ' ')"
}
detect_backend() {
debug_section "Persistent network backend detection"
if [[ "$BACKEND" != auto ]]; then
debug "backend forced by command line: $BACKEND"
printf '%s\n' "$BACKEND"
return
fi
# Netplan is a front-end and should win detection when /etc/netplan is populated.
if [[ -d /etc/netplan ]] && find /etc/netplan -maxdepth 1 -type f \( -name '*.yaml' -o -name '*.yml' \) -print -quit 2>/dev/null | grep -q .; then
debug "selected netplan: /etc/netplan contains YAML configuration"
printf '%s\n' netplan; return
fi
debug "netplan not selected"
if have nmcli; then
local nm_running
nm_running=$(nmcli -t -f RUNNING general 2>&1 || true)
debug "NetworkManager probe 'nmcli -t -f RUNNING general': ${nm_running:-<empty>}"
if grep -qi '^running$' <<<"$nm_running"; then
debug "selected networkmanager: NetworkManager reports RUNNING"
printf '%s\n' networkmanager; return
fi
else
debug "NetworkManager not selected: nmcli not found"
fi
if [[ -d /etc/systemd/network ]] && find /etc/systemd/network -type f -name '*.network' -print -quit 2>/dev/null | grep -q .; then
debug "selected networkd: .network file found under /etc/systemd/network"
printf '%s\n' networkd; return
fi
debug "systemd-networkd not selected: no persistent .network file found"
if [[ -f /etc/network/interfaces || -d /etc/network/interfaces.d ]]; then
debug "selected ifupdown: /etc/network/interfaces or interfaces.d exists"
printf '%s\n' ifupdown; return
fi
debug "ifupdown not selected"
if [[ -d /etc/sysconfig/network-scripts ]] && find /etc/sysconfig/network-scripts -maxdepth 1 -type f -name 'ifcfg-*' -print -quit 2>/dev/null | grep -q .; then
debug "selected network-scripts: ifcfg-* found under /etc/sysconfig/network-scripts"
printf '%s\n' network-scripts; return
fi
debug "legacy network-scripts not selected"
die "could not detect a supported persistent network backend; use --backend"
}
# Normalize a route assembled from explicit fields.
emit_norm() {
local fam=$1 dst=$2 via=${3:--} dev=${4:--} metric=${5:--} table=${6:-main} type=${7:-unicast} proto=${8:--}
[[ "$dst" == default ]] && dst=$([[ "$fam" == 4 ]] && echo '0.0.0.0/0' || echo '::/0')
printf '%s|%s|%s|%s|%s|%s|%s|%s\n' "$fam" "$dst" "${via:--}" "${dev:--}" "${metric:--}" "${table:-main}" "${type:-unicast}" "${proto:--}"
}
normalize_nm_table() {
local table=${1:--}
case "$table" in
''|-|0|254|main) printf '%s\n' main ;;
*) printf '%s\n' "$table" ;;
esac
}
# Parse one NetworkManager ipvX.routes item in either of these common forms:
# 192.0.2.0/24 192.0.2.1 50 table=100
# { ip = 192.0.2.0/24, nh = 192.0.2.1, mt = 50, table = 100 }
parse_nm_route_item() {
local fam=$1 dev=$2 item=$3 default_metric=${4:--}
local dst='-' via='-' metric='-' table='main' type='unicast' token
item=$(sed 's/^[[:space:]]*//;s/[[:space:]]*$//' <<<"$item")
[[ -n "$item" ]] || return 0
if [[ "$item" == \{* ]]; then
dst=$(sed -nE 's/.*(^|[,[:space:]])ip[[:space:]]*=[[:space:]]*([^,}[:space:]]+).*/\2/p' <<<"$item")
via=$(sed -nE 's/.*(^|[,[:space:]])nh[[:space:]]*=[[:space:]]*([^,}[:space:]]+).*/\2/p' <<<"$item")
metric=$(sed -nE 's/.*(^|[,[:space:]])mt[[:space:]]*=[[:space:]]*(-?[0-9]+).*/\2/p' <<<"$item")
table=$(sed -nE 's/.*(^|[,[:space:]])table[[:space:]]*=[[:space:]]*([0-9]+|main).*/\2/p' <<<"$item")
[[ -n "$dst" ]] || dst='-'
[[ -n "$via" && "$via" != "0.0.0.0" && "$via" != "::" ]] || via='-'
[[ -n "$metric" && "$metric" != "-1" ]] || metric='-'
[[ -n "$table" ]] || table='main'
else
# Plain nmcli syntax. Attributes follow the optional metric as key=value tokens.
# Commas separating multiple routes are removed by the caller.
local -a fields=()
IFS=' ' read -r -a fields <<<"$item"
((${#fields[@]})) || return 0
dst=${fields[0]}
local i=1
if (( i < ${#fields[@]} )) && [[ ${fields[$i]} != *=* ]] && [[ ${fields[$i]} =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$|: ]]; then
via=${fields[$i]}; ((i+=1))
fi
if (( i < ${#fields[@]} )) && [[ ${fields[$i]} =~ ^-?[0-9]+$ ]]; then
metric=${fields[$i]}; ((i+=1))
[[ "$metric" != "-1" ]] || metric='-'
fi
while (( i < ${#fields[@]} )); do
token=${fields[$i]}
case "$token" in
table=*) table=${token#table=} ;;
type=*) type=${token#type=} ;;
esac
((i+=1))
done
fi
[[ "$dst" != '-' ]] || { debug "NM route parse warning: could not find destination in: $item"; return 0; }
[[ "$dst" == default ]] && dst=$([[ "$fam" == 4 ]] && echo '0.0.0.0/0' || echo '::/0')
table=$(normalize_nm_table "$table")
# NetworkManager applies ipvX.route-metric to static routes whose own metric
# is unset. Use that effective metric so persistent/live comparison matches
# what NetworkManager actually installs in the kernel.
[[ "$metric" != '-' || "$default_metric" == '-' ]] || metric=$default_metric
emit_norm "$fam" "$dst" "$via" "$dev" "$metric" "$table" "$type"
}
parse_nm_route_list() {
local fam=$1 dev=$2 raw=$3 default_metric=${4:--}
local rest item
[[ -n "$raw" && "$raw" != '--' ]] || return 0
# Newer nmcli may render each route as a { ... } object. Commas inside braces
# are fields, not route separators, so extract one brace object at a time.
if [[ "$raw" == *'{ '* || "$raw" == \{* ]]; then
rest=$raw
while [[ "$rest" == *'{'* && "$rest" == *'}'* ]]; do
item=${rest#*\{}
item=${item%%\}*}
parse_nm_route_item "$fam" "$dev" "{ $item }" "$default_metric"
rest=${rest#*\}}
done
return 0
fi
# Legacy/plain representation uses comma-separated route entries. nmcli -g
# also commonly prints one route per line, which this handles naturally.
while IFS= read -r item || [[ -n "$item" ]]; do
[[ -n "$item" ]] || continue
while [[ "$item" == *,* ]]; do
parse_nm_route_item "$fam" "$dev" "${item%%,*}" "$default_metric"
item=${item#*,}
done
parse_nm_route_item "$fam" "$dev" "$item" "$default_metric"
done <<<"$raw"
}
nm_get_prop() {
local uuid=$1 prop=$2 out rc errfile="$TMP/nm-get.err"
: >"$errfile"
set +e
out=$(nmcli -g "$prop" connection show uuid "$uuid" 2>"$errfile")
rc=$?
set -e
if (( rc != 0 )); then
debug "NM property query failed: uuid=$uuid property=$prop rc=$rc"
if [[ -s "$errfile" ]]; then
while IFS= read -r line; do debug " nmcli stderr: $line"; done <"$errfile"
fi
return 0
fi
printf '%s\n' "$out"
}
read_permanent_networkmanager() {
have nmcli || { debug 'nmcli missing; permanent NetworkManager route extraction skipped'; return; }
debug_section "NetworkManager persistent configuration extraction"
local active_map="$TMP/nm-active.txt"
local nmerr="$TMP/nm-active.err"
: >"$active_map"; : >"$nmerr"
# UUID,DEVICE is an ACTIVE connection view. It is substantially more portable
# than trying to request the profile property connection.interface-name as a
# list column, and it maps the persistent profile to the device that owns the
# live routes we are reconciling.
if ! nmcli -t -f UUID,DEVICE connection show --active >"$active_map" 2>"$nmerr"; then
debug "ERROR: unable to enumerate active NetworkManager profiles"
while IFS= read -r line; do debug " nmcli stderr: $line"; done <"$nmerr"
return 0
fi
if [[ ! -s "$active_map" ]]; then
debug "WARNING: NetworkManager returned no active UUID,DEVICE mappings"
debug_cmd "nmcli connection show --active" nmcli connection show --active
debug_cmd "nmcli device status" nmcli device status
return 0
fi
debug "active NetworkManager UUID,DEVICE mappings:"
while IFS= read -r line; do debug " $line"; done <"$active_map"
local uuid dev fam routes gateway rmetric rtable method table norm
while IFS=: read -r uuid dev; do
[[ -n "$uuid" && -n "$dev" && "$dev" != '--' ]] || { debug "skipping NM mapping with no usable device: uuid=${uuid:-<empty>} dev=${dev:-<empty>}"; continue; }
debug "NM profile: uuid=$uuid device=$dev"
for fam in 4 6; do
method=$(nm_get_prop "$uuid" "ipv${fam}.method")
gateway=$(nm_get_prop "$uuid" "ipv${fam}.gateway")
rmetric=$(nm_get_prop "$uuid" "ipv${fam}.route-metric")
rtable=$(nm_get_prop "$uuid" "ipv${fam}.route-table")
routes=$(nm_get_prop "$uuid" "ipv${fam}.routes")
gateway=$(sed 's/^[[:space:]]*//;s/[[:space:]]*$//' <<<"$gateway")
rmetric=$(sed 's/^[[:space:]]*//;s/[[:space:]]*$//' <<<"$rmetric")
rtable=$(sed 's/^[[:space:]]*//;s/[[:space:]]*$//' <<<"$rtable")
[[ "$gateway" != '--' ]] || gateway=''
[[ "$rmetric" != '--' && "$rmetric" != '-1' ]] || rmetric='-'
table=$(normalize_nm_table "${rtable:--}")
debug " ipv${fam}.method=${method:-<empty>}"
debug " ipv${fam}.gateway=${gateway:-<empty>}"
debug " ipv${fam}.route-metric=${rmetric:-<unset/auto>}"
debug " ipv${fam}.route-table=${rtable:-<empty>} -> normalized table=$table"
if [[ -n "$routes" && "$routes" != '--' ]]; then
debug " ipv${fam}.routes raw:"
while IFS= read -r line; do debug " $line"; done <<<"$routes"
else
debug " ipv${fam}.routes raw: <empty>"
fi
# NetworkManager's gateway property is persistent configuration for a
# default route. The previous version ignored it and therefore could
# incorrectly report an already-persistent default route as missing.
if [[ -n "$gateway" ]]; then
norm=$(emit_norm "$fam" "$([[ "$fam" == 4 ]] && echo '0.0.0.0/0' || echo '::/0')" "$gateway" "$dev" "$rmetric" "$table")
printf '%s\n' "$norm"
debug " PERMANENT default from ipv${fam}.gateway -> $norm"
fi
while IFS= read -r norm; do
[[ -n "$norm" ]] || continue
printf '%s\n' "$norm"
debug " PERMANENT ipv${fam}.routes -> $norm"
done < <(parse_nm_route_list "$fam" "$dev" "$routes" "$rmetric")
done
done <"$active_map"
}
read_permanent_networkd() {
local f section='' dst='' gw='-' metric='-' table='main' type='unicast' fam dev='-'
shopt -s nullglob
for f in /etc/systemd/network/*.network /etc/systemd/network/*.network.d/*.conf; do
[[ -r "$f" ]] || continue
dev=$(awk -F= '/^[[:space:]]*Name=/{gsub(/[[:space:]]/,"",$2); print $2; exit}' "$f"); dev=${dev:--}
while IFS= read -r raw || [[ -n "$raw" ]]; do
local line key val
line=${raw%%#*}; line=${line%%;*}; line=$(sed 's/^[[:space:]]*//;s/[[:space:]]*$//' <<<"$line")
[[ -n "$line" ]] || continue
if [[ "$line" =~ ^\[(.*)\]$ ]]; then
if [[ "$section" == Route && -n "$dst" ]]; then
fam=$([[ "$dst" == *:* || "$gw" == *:* ]] && echo 6 || echo 4); emit_norm "$fam" "$dst" "$gw" "$dev" "$metric" "$table" "$type"
fi
section=${BASH_REMATCH[1]}; dst=''; gw='-'; metric='-'; table='main'; type='unicast'; continue
fi
[[ "$section" == Route && "$line" == *=* ]] || continue
key=${line%%=*}; val=${line#*=}
case "$key" in
Destination) dst=$val ;;
Gateway) gw=$val ;;
Metric) metric=$val ;;
Table) table=$val ;;
Type) type=$val ;;
esac
done <"$f"
if [[ "$section" == Route && -n "$dst" ]]; then
fam=$([[ "$dst" == *:* || "$gw" == *:* ]] && echo 6 || echo 4); emit_norm "$fam" "$dst" "$gw" "$dev" "$metric" "$table" "$type"
fi
done
shopt -u nullglob
}
read_permanent_netplan() {
# Prefer netplan's merged view. Parse only explicit routes (not DHCP-learned routes).
have netplan || { debug 'netplan command missing; parsing YAML files best-effort'; }
local src="$TMP/netplan.txt"
if have netplan; then netplan get >"$src" 2>/dev/null || true
else cat /etc/netplan/*.yaml /etc/netplan/*.yml >"$src" 2>/dev/null || true; fi
awk '
function trim(s){gsub(/^[ \t]+|[ \t]+$/,"",s); gsub(/^"|"$/,"",s); return s}
function flush(){
if(to!="") {
fam=(index(to,":")||index(via,":"))?6:4; d=dev==""?"-":dev; v=via==""?"-":via; m=metric==""?"-":metric; t=table==""?"main":table;
if(to=="default") to=(fam==4?"0.0.0.0/0":"::/0");
print fam "|" to "|" v "|" d "|" m "|" t "|unicast|-";
}
to=via=metric=table="";
}
BEGIN{dev=""; inroutes=0; routeindent=-1}
/^[[:space:]]*[A-Za-z0-9_.:-]+:[[:space:]]*$/ {
match($0,/^[ ]*/); ind=RLENGTH; k=$0; sub(/^[ ]*/,"",k); sub(/:[ ]*$/,"",k);
if (ind>=4 && k!="routes" && k!="network" && k!="ethernets" && k!="wifis" && k!="bridges" && k!="bonds" && k!="vlans" && k!="tunnels") dev=k;
if (k=="routes") {inroutes=1; routeindent=ind; next}
if (inroutes && ind<=routeindent) {flush(); inroutes=0}
}
inroutes {
match($0,/^[ ]*/); ind=RLENGTH;
if(ind<=routeindent && $0 !~ /^[[:space:]]*$/){flush(); inroutes=0; next}
if($0 ~ /^[[:space:]]*-[[:space:]]*to:/){flush(); x=$0; sub(/^.*to:[[:space:]]*/,"",x); to=trim(x); next}
if($0 ~ /^[[:space:]]*to:/){flush(); x=$0; sub(/^.*to:[[:space:]]*/,"",x); to=trim(x); next}
if($0 ~ /^[[:space:]]*via:/){x=$0; sub(/^.*via:[[:space:]]*/,"",x); via=trim(x)}
if($0 ~ /^[[:space:]]*metric:/){x=$0; sub(/^.*metric:[[:space:]]*/,"",x); metric=trim(x)}
if($0 ~ /^[[:space:]]*table:/){x=$0; sub(/^.*table:[[:space:]]*/,"",x); table=trim(x)}
}
END{flush()}
' "$src"
}
read_permanent_ifupdown() {
local files=() f iface='' fam=4 line cmd
[[ -r /etc/network/interfaces ]] && files+=(/etc/network/interfaces)
while IFS= read -r f; do files+=("$f"); done < <(find /etc/network/interfaces.d -maxdepth 1 -type f 2>/dev/null | sort || true)
for f in "${files[@]}"; do
while IFS= read -r line || [[ -n "$line" ]]; do
line=${line%%#*}; line=$(sed 's/^[[:space:]]*//;s/[[:space:]]*$//' <<<"$line"); [[ -n "$line" ]] || continue
if [[ "$line" =~ ^iface[[:space:]]+([^[:space:]]+)[[:space:]]+(inet6?|inet)[[:space:]]+ ]]; then
iface=${BASH_REMATCH[1]}; [[ ${BASH_REMATCH[2]} == inet6 ]] && fam=6 || fam=4; continue
fi
if [[ "$line" =~ ^gateway[[:space:]]+(.+)$ && -n "$iface" ]]; then
emit_norm "$fam" "$([[ $fam == 4 ]] && echo '0.0.0.0/0' || echo '::/0')" "${BASH_REMATCH[1]}" "$iface"
fi
if [[ "$line" =~ ^(up|post-up|pre-up)[[:space:]]+(.+)$ ]]; then
cmd=${BASH_REMATCH[2]}
if grep -Eq '(^|[[:space:]])ip([[:space:]]+-[46])?[[:space:]]+route[[:space:]]+(add|replace)' <<<"$cmd"; then
cmd=${cmd#* route }; cmd=${cmd#add }; cmd=${cmd#replace }; cmd=$(sed 's/^[[:space:]]*//' <<<"$cmd")
[[ "$cmd" == *:* ]] && fam=6 || fam=4
normalize_ip_route_line "$fam" "$cmd"
fi
fi
done <"$f"
done
}
read_permanent_network_scripts() {
local f iface fam line
shopt -s nullglob
for f in /etc/sysconfig/network-scripts/route-* /etc/sysconfig/network-scripts/route6-*; do
[[ -r "$f" ]] || continue
iface=${f##*/route-}; fam=4
[[ ${f##*/} == route6-* ]] && { iface=${f##*/route6-}; fam=6; }
while IFS= read -r line || [[ -n "$line" ]]; do
line=${line%%#*}; line=$(sed 's/^[[:space:]]*//;s/[[:space:]]*$//' <<<"$line"); [[ -n "$line" ]] || continue
# iproute-style route files are the common modern legacy format.
if [[ "$line" != *=* ]]; then
[[ "$line" == *' dev '* ]] || line="$line dev $iface"
normalize_ip_route_line "$fam" "$line"
fi
done <"$f"
done
shopt -u nullglob
}
read_permanent() {
local b=$1 raw="$TMP/permanent.raw"
: >"$PERM_NORM"; : >"$raw"
debug_section "Reading persistent routes for backend: $b"
case "$b" in
networkmanager) read_permanent_networkmanager ;;
networkd) read_permanent_networkd ;;
netplan) read_permanent_netplan ;;
ifupdown) read_permanent_ifupdown ;;
network-scripts) read_permanent_network_scripts ;;
esac >"$raw"
LC_ALL=C sort -u "$raw" >"$PERM_NORM"
debug "persistent route extraction produced $(wc -l <"$PERM_NORM" | tr -d ' ') normalized route(s)"
if [[ -s "$PERM_NORM" ]]; then
while IFS= read -r line; do debug "PERMANENT NORMALIZED: $line"; done <"$PERM_NORM"
else
debug "WARNING: permanent-routes.txt is empty for detected backend '$b'"
case "$b" in
networkmanager)
debug_cmd "NetworkManager active profiles" nmcli -f NAME,UUID,TYPE,DEVICE connection show --active
debug_cmd "NetworkManager device status" nmcli device status
;;
networkd)
debug "persistent source files under /etc/systemd/network:"
find /etc/systemd/network -maxdepth 2 -type f \( -name '*.network' -o -name '*.conf' \) -print 2>/dev/null | while IFS= read -r line; do debug " $line"; done
;;
netplan)
debug "persistent source files under /etc/netplan:"
find /etc/netplan -maxdepth 1 -type f \( -name '*.yaml' -o -name '*.yml' \) -print 2>/dev/null | while IFS= read -r line; do debug " $line"; done
;;
ifupdown)
debug "checking /etc/network/interfaces and /etc/network/interfaces.d"
;;
network-scripts)
debug "checking /etc/sysconfig/network-scripts/route-* and route6-*"
;;
esac
fi
}
# Route identity is family + destination + device + table + route type. Protocol
# is deliberately NOT part of persistent equality: "proto static" is kernel/live
# metadata and is not a portable persistent route setting.
#
# NetworkManager special case:
# A persistent metric of '-' means ipvX.route-metric is automatic/unset (-1)
# after taking any per-route explicit metric into account. NetworkManager chooses
# the effective metric at activation time, so any live metric is an EFFECTIVE MATCH
# as long as the rest of the route matches. This prevents false UPDATE reports such
# as persistent "auto" versus live metric 100.
make_change_report() {
local oldk="$TMP/old.keys" newk="$TMP/new.keys"
local oldcmp="$TMP/old.compare" newcmp="$TMP/new.compare"
local detail="$TMP/comparison.details"
local backend_nm=0
[[ "$BACKEND" == networkmanager ]] && backend_nm=1
: >"$detail"
debug_section "Comparing persistent routes with selected live routes"
debug "comparison identity fields: family|destination|device|table|type (host destinations canonicalized to /32 or /128)"
debug "comparison value fields: family|destination|via|device|metric|table|type"
debug "ignored for equality: proto"
if (( backend_nm )); then
debug "NetworkManager metric policy: persistent metric '-' means AUTO and matches the effective live metric"
debug "NetworkManager explicit metric values still require an exact effective match"
else
debug "IPv4 metric policy: omitted metric '-' is compared as kernel metric 0"
fi
# key<TAB>canonical-record<TAB>full-record
# Keep the original persistent metric here. For NetworkManager '-' has semantic
# meaning (automatic) and must not be prematurely converted to zero.
awk -F'|' 'BEGIN{OFS="\t"} {
d=$2; if (index(d,"/")==0) { if ($1==4 && d ~ /^[0-9.]+$/) d=d"/32"; else if ($1==6 && d ~ /:/) d=d"/128" }
key=$1"|"d"|"$4"|"$6"|"$7;
rec=$1"|"d"|"$3"|"$4"|"$5"|"$6"|"$7;
print key,rec,$0
}' "$PERM_NORM" | LC_ALL=C sort >"$oldk"
awk -F'|' 'BEGIN{OFS="\t"} {
d=$2; if (index(d,"/")==0) { if ($1==4 && d ~ /^[0-9.]+$/) d=d"/32"; else if ($1==6 && d ~ /:/) d=d"/128" }
key=$1"|"d"|"$4"|"$6"|"$7;
rec=$1"|"d"|"$3"|"$4"|"$5"|"$6"|"$7;
print key,rec,$0
}' "$LIVE_NORM" | LC_ALL=C sort >"$newk"
if (( VERBOSE )); then
debug "persistent comparison rows ($(wc -l <"$oldk" | tr -d ' ')):"
if [[ -s "$oldk" ]]; then while IFS= read -r line; do debug " OLD $line"; done <"$oldk"; else debug " <none>"; fi
debug "live comparison rows ($(wc -l <"$newk" | tr -d ' ')):"
if [[ -s "$newk" ]]; then while IFS= read -r line; do debug " NEW $line"; done <"$newk"; else debug " <none>"; fi
fi
# IMPORTANT: use FILENAME==ARGV[1], not NR==FNR. With an EMPTY first file,
# NR==FNR remains true while reading the second file, which was the v1.2 bug
# that reversed ADD/DELETE classification.
awk -F'\t' -v backend_nm="$backend_nm" -v detail="$detail" '
function splitrec(s,a) { return split(s,a,"|") }
function effective_live_metric(fam,m) { return (fam==4 && m=="-") ? "0" : m }
function effective_old_metric(fam,m) { return (fam==4 && m=="-") ? "0" : m }
FILENAME==ARGV[1] { oldrec[$1]=$2; oldfull[$1]=$3; next }
FILENAME==ARGV[2] { newrec[$1]=$2; newfull[$1]=$3; next }
END {
for (k in newfull) {
if (!(k in oldfull)) {
print "ADD " newfull[k]
print "ADD|" k "|not present in persistent configuration" >> detail
continue
}
splitrec(oldrec[k],o); splitrec(newrec[k],n)
same_other=(o[1]==n[1] && o[2]==n[2] && o[3]==n[3] && o[4]==n[4] && o[6]==n[6] && o[7]==n[7])
auto_metric=(backend_nm && o[5]=="-")
metric_same=(auto_metric || effective_old_metric(o[1],o[5])==effective_live_metric(n[1],n[5]))
if (!same_other || !metric_same) {
print "UPDATE " oldfull[k] " => " newfull[k]
reason=""
if (!same_other) reason="gateway/table/type or another configurable route field differs"
else reason="metric differs"
print "UPDATE|" k "|" reason "|old_metric=" o[5] "|live_metric=" n[5] >> detail
} else if (auto_metric) {
print "MATCH_AUTO_METRIC|" k "|persistent metric=AUTO(-1/unset)|effective_live_metric=" effective_live_metric(n[1],n[5]) >> detail
} else {
print "MATCH|" k "|persistent and live configurable fields agree" >> detail
}
}
for (k in oldfull) {
if (!(k in newfull)) {
print "DELETE " oldfull[k]
print "DELETE|" k "|persistent route is absent from selected live routes" >> detail
}
}
}
' "$oldk" "$newk" | LC_ALL=C sort >"$CHANGES"
if (( VERBOSE )) && [[ -s "$detail" ]]; then
while IFS= read -r line; do debug "COMPARE: $line"; done < <(LC_ALL=C sort "$detail")
fi
# Build a unified diff which follows the same effective-comparison semantics.
# For a NetworkManager AUTO persistent metric, substitute the matching live metric
# in the OLD display row. This avoids a diff which contradicts changes.txt.
awk -F'\t' -v backend_nm="$backend_nm" -v oldout="$oldcmp" -v newout="$newcmp" '
function splitrec(s,a) { return split(s,a,"|") }
function live_m(fam,m) { return (fam==4 && m=="-") ? "0" : m }
function old_m(fam,m) { return (fam==4 && m=="-") ? "0" : m }
FILENAME==ARGV[1] { oldrec[$1]=$2; next }
FILENAME==ARGV[2] { newrec[$1]=$2; next }
END {
for (k in oldrec) {
splitrec(oldrec[k],o)
om=old_m(o[1],o[5])
if (backend_nm && o[5]=="-" && (k in newrec)) {
splitrec(newrec[k],n)
om=live_m(n[1],n[5])
}
print o[1]"|"o[2]"|"o[3]"|"o[4]"|"om"|"o[6]"|"o[7] >> oldout
}
for (k in newrec) {
splitrec(newrec[k],n)
nm=live_m(n[1],n[5])
print n[1]"|"n[2]"|"n[3]"|"n[4]"|"nm"|"n[6]"|"n[7] >> newout
}
}
' "$oldk" "$newk"
LC_ALL=C sort -u -o "$oldcmp" "$oldcmp"
LC_ALL=C sort -u -o "$newcmp" "$newcmp"
diff -u --label permanent-routes --label desired-current-routes "$oldcmp" "$newcmp" >"$DIFFOUT" || true
local adds updates deletes
adds=$(grep -c '^ADD ' "$CHANGES" 2>/dev/null || true)
updates=$(grep -c '^UPDATE ' "$CHANGES" 2>/dev/null || true)
deletes=$(grep -c '^DELETE ' "$CHANGES" 2>/dev/null || true)
debug "change report summary: ADD=$adds UPDATE=$updates DELETE=$deletes"
if [[ -s "$CHANGES" ]]; then
while IFS= read -r line; do debug "CHANGE: $line"; done <"$CHANGES"
else
debug "CHANGE: none"
fi
}
# Convert normalized records into backend-specific staged artifacts.
write_networkd() {
local base="$OUTDIR/desired/systemd-networkd" fam dst via dev metric table type file
mkdir -p "$base"
while IFS='|' read -r fam dst via dev metric table type _; do
[[ "$dev" != '-' ]] || continue
file="$base/${dev}.network.d/90-current-routes.conf"; mkdir -p "${file%/*}"
if [[ ! -e "$file" ]]; then
cat >"$file" <<HDR
# Generated by $PROG. Staged file only; NOT installed.
# To replace previously configured static routes, review existing [Route] sections.
HDR
fi
{
echo; echo '[Route]'; echo "Destination=$dst"
[[ "$via" != '-' ]] && echo "Gateway=$via"
[[ "$metric" != '-' ]] && echo "Metric=$metric"
[[ "$table" != main ]] && echo "Table=$table"
[[ "$type" != unicast ]] && echo "Type=$type"
} >>"$file"
done <"$LIVE_NORM"
}
write_netplan() {
local base="$OUTDIR/desired/netplan" file="$OUTDIR/desired/netplan/99-current-routes.yaml"
mkdir -p "$base"
cat >"$file" <<HDR
# Generated by $PROG. Staged route-only overlay; NOT installed/applied.
# Review interface type and merge semantics with your existing netplan before use.
network:
version: 2
ethernets:
HDR
local dev fam dst via metric table type
for dev in $(awk -F'|' '$4!="-"{print $4}' "$LIVE_NORM" | sort -u); do
printf ' %s:\n routes:\n' "$dev" >>"$file"
while IFS='|' read -r fam dst via rdev metric table type _; do
[[ "$rdev" == "$dev" ]] || continue
printf ' - to: "%s"\n' "$dst" >>"$file"
[[ "$via" != '-' ]] && printf ' via: "%s"\n' "$via" >>"$file"
[[ "$metric" != '-' ]] && printf ' metric: %s\n' "$metric" >>"$file"
[[ "$table" != main ]] && printf ' table: %s\n' "$table" >>"$file"
[[ "$type" != unicast ]] && printf ' type: %s\n' "$type" >>"$file"
done <"$LIVE_NORM"
done
}
write_ifupdown() {
local base="$OUTDIR/desired/ifupdown/interfaces.d" file="$OUTDIR/desired/ifupdown/interfaces.d/99-current-routes"
mkdir -p "$base"
{
echo "# Generated by $PROG. Staged file only; NOT installed."
echo "# Route commands are idempotent ('replace') and tied to the interface stanza."
} >"$file"
local dev fam dst via metric table type cmd
for dev in $(awk -F'|' '$4!="-"{print $4}' "$LIVE_NORM" | sort -u); do
# 'manual' is safe for a route-only supplemental stanza on classic ifupdown.
printf '\nauto %s\niface %s inet manual\n' "$dev" "$dev" >>"$file"
while IFS='|' read -r fam dst via rdev metric table type _; do
[[ "$rdev" == "$dev" ]] || continue
cmd="ip -$fam route replace"
[[ "$type" != unicast ]] && cmd+=" $type"
cmd+=" $dst"
[[ "$via" != '-' ]] && cmd+=" via $via"
cmd+=" dev $dev"
[[ "$metric" != '-' ]] && cmd+=" metric $metric"
[[ "$table" != main ]] && cmd+=" table $table"
printf ' post-up %s\n' "$cmd" >>"$file"
printf ' pre-down ip -%s route del %s dev %s%s 2>/dev/null || true\n' "$fam" "$dst" "$dev" "$([[ "$table" != main ]] && printf ' table %s' "$table" || true)" >>"$file"
done <"$LIVE_NORM"
done
}
write_network_scripts() {
local base="$OUTDIR/desired/network-scripts" fam dst via dev metric table type file line
mkdir -p "$base"
while IFS='|' read -r fam dst via dev metric table type _; do
[[ "$dev" != '-' ]] || continue
[[ "$fam" == 4 ]] && file="$base/route-$dev" || file="$base/route6-$dev"
[[ -e "$file" ]] || printf '# Generated by %s. Staged file only; NOT installed.\n' "$PROG" >"$file"
line=''; [[ "$type" != unicast ]] && line+="$type "; line+="$dst"
[[ "$via" != '-' ]] && line+=" via $via"
line+=" dev $dev"
[[ "$metric" != '-' ]] && line+=" metric $metric"
[[ "$table" != main ]] && line+=" table $table"
printf '%s\n' "$line" >>"$file"
done <"$LIVE_NORM"
}
write_networkmanager() {
local base="$OUTDIR/desired/NetworkManager" file="$OUTDIR/desired/NetworkManager/apply-routes.sh"
local active_map="$TMP/nm-writer-active.txt"
mkdir -p "$base"
cat >"$file" <<'HDR'
#!/usr/bin/env bash
# GENERATED STAGING HELPER. Review before running.
# It updates NetworkManager connection profiles, so the generator itself never runs it.
#
# Strategy: MINIMAL DELTA ONLY.
# - matching existing gateway/default-route configuration is left untouched;
# - matching existing ipv4.routes/ipv6.routes entries are left untouched;
# - ADD operations append only the missing route;
# - DELETE/UPDATE operations touch only the affected route/property;
# - there is intentionally NO blanket 'ipvX.gateway "" ipvX.routes ""'.
#
# The helper deliberately does NOT activate/reload/restart any connection.
set -euo pipefail
HDR
if ! have nmcli; then
echo '# ERROR: nmcli unavailable when generated.' >>"$file"
chmod +x "$file"
return
fi
if ! nmcli -t -f UUID,DEVICE connection show --active >"$active_map" 2>/dev/null; then
echo '# ERROR: could not enumerate active NetworkManager UUID,DEVICE mappings.' >>"$file"
chmod +x "$file"
return
fi
if [[ ! -s "$active_map" ]]; then
echo '# ERROR: NetworkManager reported no active connection profiles with devices.' >>"$file"
chmod +x "$file"
return
fi
debug_section "Generating staged NetworkManager minimal-delta helper"
declare -A uuid_for_dev=()
local uuid dev
while IFS=: read -r uuid dev; do
[[ -n "$uuid" && -n "$dev" && "$dev" != '--' ]] || continue
if [[ -n "${uuid_for_dev[$dev]:-}" && "${uuid_for_dev[$dev]}" != "$uuid" ]]; then
debug "WARNING: multiple active NM UUIDs seen for device $dev; keeping first=${uuid_for_dev[$dev]}, ignoring=$uuid"
continue
fi
uuid_for_dev[$dev]=$uuid
debug "NM writer mapping: device=$dev uuid=$uuid"
done <"$active_map"
nm_canonical_dst() {
local fam=$1 dst=$2
if [[ "$dst" != */* ]]; then
[[ "$fam" == 4 ]] && dst+="/32" || dst+="/128"
fi
printf '%s\n' "$dst"
}
# Render one normalized route as an nmcli ipvX.routes value. For IPv4, a live
# route whose metric is omitted has effective metric 0. It MUST be explicit in
# the profile, otherwise NetworkManager would apply ipv4.route-metric (possibly
# 99, 100, or an automatic device metric) and change the route we are persisting.
nm_route_value() {
local record=$1 fam dst via rdev metric table type proto route
IFS='|' read -r fam dst via rdev metric table type proto <<<"$record"
dst=$(nm_canonical_dst "$fam" "$dst")
route=$dst
[[ "$via" != '-' ]] && route+=" $via"
if [[ "$metric" != '-' ]]; then
route+=" $metric"
elif [[ "$fam" == 4 ]]; then
route+=" 0"
fi
[[ "$table" != main ]] && route+=" table=$table"
[[ "$type" != unicast ]] && route+=" type=$type"
printf '%s\n' "$route"
}
nm_is_default_record() {
local record=$1 fam dst _rest
IFS='|' read -r fam dst _rest <<<"$record"
[[ "$dst" == '0.0.0.0/0' || "$dst" == '::/0' || "$dst" == default ]]
}
# Determine whether an OLD default route is represented by ipvX.gateway instead
# of ipvX.routes. Only then is it appropriate to touch the gateway property.
nm_default_uses_gateway_property() {
local uuid=$1 record=$2 fam dst via rdev metric table type proto configured
IFS='|' read -r fam dst via rdev metric table type proto <<<"$record"
nm_is_default_record "$record" || return 1
[[ "$type" == unicast && "$table" == main && "$via" != '-' ]] || return 1
configured=$(nm_get_prop "$uuid" "ipv${fam}.gateway")
configured=$(sed 's/^[[:space:]]*//;s/[[:space:]]*$//' <<<"$configured")
[[ "$configured" == '--' ]] && configured=''
[[ -n "$configured" && "$configured" == "$via" ]]
}
emit_add() {
local record=$1 fam dst via rdev metric table type proto uuid route
IFS='|' read -r fam dst via rdev metric table type proto <<<"$record"
uuid=${uuid_for_dev[$rdev]:-}
if [[ -z "$uuid" ]]; then
printf '# ERROR: ADD skipped: no active NetworkManager profile mapped to device %q: %s\n' "$rdev" "$record" >>"$file"
debug "NM APPLY ADD skipped: no UUID for dev=$rdev record=$record"
return
fi
route=$(nm_route_value "$record")
printf '\n# ADD %s\n' "$record" >>"$file"
printf 'nmcli connection modify uuid %q +ipv%s.routes %q\n' "$uuid" "$fam" "$route" >>"$file"
debug "NM APPLY ADD: dev=$rdev uuid=$uuid ipv${fam}.routes += [$route]"
}
emit_delete() {
local record=$1 fam dst via rdev metric table type proto uuid route
IFS='|' read -r fam dst via rdev metric table type proto <<<"$record"
uuid=${uuid_for_dev[$rdev]:-}
if [[ -z "$uuid" ]]; then
printf '# ERROR: DELETE skipped: no active NetworkManager profile mapped to device %q: %s\n' "$rdev" "$record" >>"$file"
debug "NM APPLY DELETE skipped: no UUID for dev=$rdev record=$record"
return
fi
printf '\n# DELETE %s\n' "$record" >>"$file"
if nm_default_uses_gateway_property "$uuid" "$record"; then
printf '# Existing default is represented by ipv%s.gateway; remove only that property.\n' "$fam" >>"$file"
printf 'nmcli connection modify uuid %q ipv%s.gateway ""\n' "$uuid" "$fam" >>"$file"
debug "NM APPLY DELETE: dev=$rdev uuid=$uuid clear ipv${fam}.gateway only"
else
route=$(nm_route_value "$record")
printf 'nmcli connection modify uuid %q -ipv%s.routes %q\n' "$uuid" "$fam" "$route" >>"$file"
debug "NM APPLY DELETE: dev=$rdev uuid=$uuid ipv${fam}.routes -= [$route]"
fi
}
emit_update() {
local old=$1 new=$2 fam dst via rdev metric table type proto uuid oldroute newroute
IFS='|' read -r fam dst via rdev metric table type proto <<<"$old"
uuid=${uuid_for_dev[$rdev]:-}
if [[ -z "$uuid" ]]; then
printf '# ERROR: UPDATE skipped: no active NetworkManager profile mapped to device %q: %s => %s\n' "$rdev" "$old" "$new" >>"$file"
debug "NM APPLY UPDATE skipped: no UUID for dev=$rdev old=$old new=$new"
return
fi
newroute=$(nm_route_value "$new")
printf '\n# UPDATE %s\n# => %s\n' "$old" "$new" >>"$file"
if nm_default_uses_gateway_property "$uuid" "$old"; then
# A gateway-generated default is changed only when changes.txt says UPDATE.
# Migrate that single default to an explicit route. This avoids changing the
# profile-wide ipvX.route-metric, which could alter unrelated inherited routes.
printf '# Old default is represented by ipv%s.gateway. Clear ONLY that gateway and add the updated default explicitly.\n' "$fam" >>"$file"
printf 'nmcli connection modify uuid %q ipv%s.gateway ""\n' "$uuid" "$fam" >>"$file"
printf 'nmcli connection modify uuid %q +ipv%s.routes %q\n' "$uuid" "$fam" "$newroute" >>"$file"
debug "NM APPLY UPDATE default: dev=$rdev uuid=$uuid clear ipv${fam}.gateway then add [$newroute]"
else
oldroute=$(nm_route_value "$old")
printf 'nmcli connection modify uuid %q -ipv%s.routes %q\n' "$uuid" "$fam" "$oldroute" >>"$file"
printf 'nmcli connection modify uuid %q +ipv%s.routes %q\n' "$uuid" "$fam" "$newroute" >>"$file"
debug "NM APPLY UPDATE route: dev=$rdev uuid=$uuid remove [$oldroute], add [$newroute]"
fi
}
local line record old new
if [[ ! -s "$CHANGES" ]]; then
cat >>"$file" <<'NONE'
# No persistent NetworkManager route changes are required.
NONE
else
while IFS= read -r line; do
case "$line" in
'ADD '*)
record=${line#ADD }
emit_add "$record"
;;
'DELETE '*)
record=${line#DELETE }
emit_delete "$record"
;;
'UPDATE '*)
old=${line#UPDATE }
new=${old#* => }
old=${old%% => *}
emit_update "$old" "$new"
;;
*)
printf '# WARNING: unrecognized changes.txt line: %s\n' "$line" >>"$file"
debug "NM APPLY unrecognized change line: $line"
;;
esac
done <"$CHANGES"
fi
cat >>"$file" <<'FTR'
# Deliberately no "nmcli connection up", reload, or restart command here.
# Applying profile edits changes persistence only; activation can disrupt networking.
# Activate each affected connection only after independent review/maintenance planning.
FTR
chmod +x "$file"
}
write_desired() {
case "$1" in
networkmanager) write_networkmanager ;;
networkd) write_networkd ;;
netplan) write_netplan ;;
ifupdown) write_ifupdown ;;
network-scripts) write_network_scripts ;;
esac
}
detect_distribution() {
DIST_ID="unknown"
DIST_NAME="Unknown Linux"
DIST_VERSION=""
DIST_LIKE=""
if [[ -r /etc/os-release ]]; then
# os-release is shell-compatible by specification. Do not fail on missing keys.
local ID='' NAME='' VERSION_ID='' ID_LIKE=''
# shellcheck disable=SC1091
. /etc/os-release
DIST_ID=${ID:-unknown}
DIST_NAME=${PRETTY_NAME:-${NAME:-$DIST_ID}}
DIST_VERSION=${VERSION_ID:-}
DIST_LIKE=${ID_LIKE:-}
elif have lsb_release; then
DIST_ID=$(lsb_release -si 2>/dev/null | tr '[:upper:]' '[:lower:]' || echo unknown)
DIST_NAME=$(lsb_release -ds 2>/dev/null | sed 's/^"//;s/"$//' || echo "$DIST_ID")
DIST_VERSION=$(lsb_release -rs 2>/dev/null || true)
fi
}
distro_family() {
local ids=" $DIST_ID $DIST_LIKE "
case "$ids" in
*" debian "*|*" ubuntu "*) echo debian ;;
*" rhel "*|*" fedora "*|*" centos "*|*" rocky "*|*" almalinux "*) echo rhel ;;
*" suse "*|*" sles "*|*" opensuse "*) echo suse ;;
*" arch "*) echo arch ;;
*) echo generic ;;
esac
}
write_apply_guide() {
local guide="$OUTDIR/APPLY-GUIDE.txt" family
family=$(distro_family)
cat >"$guide" <<EOF
Route persistence application guide
===================================
Distribution detected: $DIST_NAME
Distribution ID: $DIST_ID${DIST_VERSION:+ $DIST_VERSION}
Distribution family: $family
Network backend: $BACKEND
Staged files: $OUTDIR/desired
IMPORTANT
---------
The generator DID NOT modify the running system. The commands below are a guide
for an administrator after reviewing changes.txt, diff.txt, and the staged files.
Before applying anything:
1. Keep an existing console/out-of-band session available on remote machines.
2. Review: cat '$CHANGES'
3. Review: cat '$DIFFOUT'
4. Back up every destination file that you are about to replace or augment.
5. Prefer a validation/test command before activating networking.
Detected ADD / UPDATE / DELETE operations:
EOF
if [[ -s "$CHANGES" ]]; then
sed 's/^/ /' "$CHANGES" >>"$guide"
else
echo ' No normalized route changes detected.' >>"$guide"
fi
cat >>"$guide" <<'EOF'
Application procedure
---------------------
EOF
case "$BACKEND" in
netplan)
cat >>"$guide" <<EOF
This host uses Netplan (common on Ubuntu and some Debian-derived systems).
Staged file:
$OUTDIR/desired/netplan/99-current-routes.yaml
Review and validate:
sudo install -m 600 '$OUTDIR/desired/netplan/99-current-routes.yaml' /etc/netplan/99-current-routes.yaml
sudo netplan generate
For a remote host, prefer the rollback-capable test when available:
sudo netplan try
After a successful test, make the generated configuration active:
sudo netplan apply
If validation fails, remove /etc/netplan/99-current-routes.yaml and restore any
backups before applying again. Netplan merges YAML files, so check that existing
route declarations do not conflict with this overlay.
EOF
;;
networkmanager)
cat >>"$guide" <<EOF
This host uses NetworkManager$( [[ "$family" == rhel ]] && printf ' (the normal stack on modern RHEL/Fedora-family systems)' || true ).
Staged helper:
$OUTDIR/desired/NetworkManager/apply-routes.sh
The helper modifies persistent NetworkManager connection profiles with nmcli.
It uses a MINIMAL-DELTA strategy: matching existing gateway/default routes and
matching static routes are left untouched. ADD appends only missing routes; DELETE
or UPDATE touches only the affected route/property. It never clears all
ipv4.routes/ipv6.routes and it does not rewrite an unchanged default route merely
to change its representation. It does NOT activate profiles itself. Review it
line-by-line first:
less '$OUTDIR/desired/NetworkManager/apply-routes.sh'
Optional profile backup before modification:
sudo mkdir -p /root/nmconnection-backup
sudo cp -a /etc/NetworkManager/system-connections/. /root/nmconnection-backup/ 2>/dev/null || true
Apply the persistent profile edits:
sudo '$OUTDIR/desired/NetworkManager/apply-routes.sh'
Check the stored route settings for EACH affected UUID:
nmcli -f ipv4.gateway,ipv4.route-metric,ipv4.routes,ipv6.gateway,ipv6.route-metric,ipv6.routes connection show uuid <UUID>
To activate without rebooting, bring up EACH affected connection individually:
sudo nmcli connection up uuid <UUID>
On a remote system, activation can interrupt connectivity. Rebooting during a
controlled maintenance window is often safer than cycling the active profile.
EOF
;;
networkd)
cat >>"$guide" <<EOF
This host uses systemd-networkd.
Staged drop-ins are beneath:
$OUTDIR/desired/systemd-networkd/
Each directory such as:
<interface>.network.d/90-current-routes.conf
must be installed under /etc/systemd/network/ with the same relative path.
Example for one generated drop-in:
sudo install -d /etc/systemd/network/<interface>.network.d
sudo install -m 644 \
'$OUTDIR/desired/systemd-networkd/<interface>.network.d/90-current-routes.conf' \
/etc/systemd/network/<interface>.network.d/90-current-routes.conf
Validate unit syntax/configuration where supported:
networkctl status <interface>
Reload networkd configuration:
sudo networkctl reload
Then reconfigure EACH affected link, one at a time:
sudo networkctl reconfigure <interface>
Fallback on systems without the required networkctl subcommands:
sudo systemctl restart systemd-networkd
A restart can disrupt remote access, so use it only from a safe maintenance path.
EOF
;;
ifupdown)
cat >>"$guide" <<EOF
This host uses ifupdown/ifupdown2 (most commonly Debian-family systems).
Staged file:
$OUTDIR/desired/ifupdown/interfaces.d/99-current-routes
First verify that /etc/network/interfaces contains a source/source-directory line
that includes /etc/network/interfaces.d. Then install the staged file:
sudo install -m 644 \
'$OUTDIR/desired/ifupdown/interfaces.d/99-current-routes' \
/etc/network/interfaces.d/99-current-routes
If ifupdown2 is installed, validate and reload with:
sudo ifquery --check -a
sudo ifreload -a
For classic ifupdown, there is no equally safe universal live reload. Validate the
file manually, then either cycle EACH affected interface from a console:
sudo ifdown <interface> && sudo ifup <interface>
or reboot during a maintenance window. Do not cycle the interface carrying your
remote administrative session unless you have an alternate access path.
EOF
;;
network-scripts)
cat >>"$guide" <<EOF
This host uses legacy RHEL/CentOS-style network-scripts route files.
Staged files are beneath:
$OUTDIR/desired/network-scripts/
Install each route-<interface> / route6-<interface> file into:
/etc/sysconfig/network-scripts/
Example:
sudo install -m 644 '$OUTDIR/desired/network-scripts/route-<interface>' \
/etc/sysconfig/network-scripts/route-<interface>
On systems where the legacy network service is still active, apply by cycling the
specific interface or restarting networking during a maintenance window, e.g.:
sudo ifdown <interface> && sudo ifup <interface>
Some later RHEL-family releases import or manage these profiles with NetworkManager.
If 'nmcli general status' shows NetworkManager is active, prefer migrating the route
settings into the corresponding NetworkManager connection rather than depending on
legacy network-scripts indefinitely.
EOF
;;
esac
case "$family" in
debian)
cat >>"$guide" <<'EOF'
Distribution notes: Debian / Ubuntu family
------------------------------------------
Common persistence stacks are Netplan (especially Ubuntu), NetworkManager, or
ifupdown. Use the backend-specific procedure above; do not mix methods for the
same interface unless the existing system configuration intentionally does so.
EOF
;;
rhel)
cat >>"$guide" <<'EOF'
Distribution notes: RHEL / Fedora / CentOS / Rocky / AlmaLinux family
---------------------------------------------------------------------
Modern releases normally use NetworkManager. Older installations can still have
/etc/sysconfig/network-scripts. Follow the detected backend above. Avoid manually
creating legacy route files when NetworkManager owns the connection unless that is
already the host's established configuration model.
EOF
;;
suse)
cat >>"$guide" <<'EOF'
Distribution notes: SUSE family
-------------------------------
SUSE systems may use NetworkManager or wicked. This script supports SUSE safely
when one of its supported backends is detected (for example NetworkManager or
systemd-networkd). Native wicked route-file generation is not implemented; if the
host actually uses wicked, do not install staged files generated for another stack.
EOF
;;
arch)
cat >>"$guide" <<'EOF'
Distribution notes: Arch family
-------------------------------
Arch commonly uses NetworkManager or systemd-networkd. Follow the detected backend
procedure above. The distribution itself does not imply which network daemon owns
the interface.
EOF
;;
generic)
cat >>"$guide" <<'EOF'
Distribution notes
------------------
The distribution was not mapped to a specific family. The network backend is more
important than the distribution for route persistence, so follow the backend-specific
procedure above and verify it against the host's local administration policy.
EOF
;;
esac
cat >>"$guide" <<EOF
Post-apply verification
-----------------------
After activation or reboot, compare the live routes with the desired snapshot:
ip -4 route show table all
ip -6 route show table all
You can also run this script again into a NEW output directory. If configuration and
live state agree, its changes.txt should contain no normalized route differences.
EOF
}
snapshot_live
BACKEND=$(detect_backend)
detect_distribution
log "detected distribution: $DIST_NAME ($DIST_ID${DIST_VERSION:+ $DIST_VERSION})"
log "detected backend: $BACKEND"
read_permanent "$BACKEND"
make_change_report
write_desired "$BACKEND"
write_apply_guide
cat >"$OUTDIR/README.txt" <<EOF2
Generated by: $PROG
Detected distribution: $DIST_NAME
Detected backend: $BACKEND
Generated at: $(date -Is 2>/dev/null || date)
NO SYSTEM FILES WERE MODIFIED.
Files:
live-routes.all.txt Full live route snapshot (audit source).
live-routes.persist.txt Routes selected for persistence.
permanent-routes.txt Routes parsed from permanent configuration.
changes.txt ADD / UPDATE / DELETE classification.
diff.txt Unified normalized diff.
desired/ Staged backend-specific files.
APPLY-GUIDE.txt Distribution/backend-specific application instructions.
debug.log Verbose diagnostic trace (only when -v was used).
Persistence policy:
- proto kernel and local/broadcast/multicast/anycast routes are not persisted.
- DHCP/RA routes are $( (( INCLUDE_DYNAMIC )) && echo included || echo excluded ).
- default routes are $( (( INCLUDE_DEFAULT )) && echo included || echo excluded ).
Caveats:
- Linux route syntax and distro network stacks have many extensions. Review output.
- Policy-routing rules (ip rule), multipath/nexthop objects, VRFs, source-specific
routes, NetworkManager route attributes, and arbitrary dispatcher scripts are
outside the safe automatic rewrite scope of this version.
- Netplan device type is staged under 'ethernets' as a conservative route-only
overlay. Move a device under wifis/bridges/bonds/vlans if your source config uses it.
- For NetworkManager, desired/apply-routes.sh is a staged MINIMAL-DELTA command
file rather than a copied keyfile. Existing matching gateway/routes are retained.
- NetworkManager ipvX.route-metric=-1 (automatic) is treated as an effective match
to the live metric when all other route fields match; it is not forced static.
- When adding an IPv4 live route whose kernel output omits a metric, the staged
NetworkManager route uses explicit metric 0 so it does not inherit route-metric.
EOF2
printf '\n=== Route configuration comparison ===\n'
printf 'Backend: %s\n' "$BACKEND"
printf 'Output: %s\n\n' "$OUTDIR"
if [[ -s "$CHANGES" ]]; then
cat "$CHANGES"
else
echo 'No normalized route changes detected.'
fi
printf '\nStaged files are under: %s/desired\n' "$OUTDIR"
printf 'Application guide: %s/APPLY-GUIDE.txt\n' "$OUTDIR"