Files
fips/packaging/openwrt-ipk/files/usr/bin/fips-mesh-setup

842 lines
34 KiB
Bash
Executable File

#!/bin/sh
# fips-mesh-setup — configure open 802.11s mesh interfaces for FIPS backhaul.
#
# Usage:
# fips-mesh-setup <radio> [mesh-id] e.g. fips-mesh-setup radio1
# fips-mesh-setup remove [radio] no radio: remove all instances
# fips-mesh-setup zone add existing mesh interfaces to the
# fips_mesh firewall zone
# fips-mesh-setup close drop IP traffic arriving on them
# until the firewall next reloads
# fips-mesh-setup rates give existing mesh interfaces that
# have no basic_rate the mesh's rates
#
# Creates a mesh-point interface on the given radio and leaves everything
# above L2 to FIPS. Run once per radio: dual-band routers can mesh on both
# bands at once (2.4 GHz reaches further, 5 GHz carries more). Note this is
# failover, not multipath — FIPS keeps one active link per peer; the other
# band stands by and reconnects the peer if the active link dies.
#
# - encryption 'none' — the mesh is OPEN on purpose. FIPS's Noise IK
# handshake authenticates and encrypts every peer link, so SAE would
# only duplicate that (and on ath10k it forces the slower raw Tx/Rx
# firmware mode). A stranger can form an 802.11s peering AND a FIPS
# peer link — the Noise handshake authenticates each link (no
# impersonation of another identity, no MITM), it does not gate who
# may peer. Admission is open up to the daemon's max-peers cap.
# - mesh_fwding '0' — disables 802.11s HWMP forwarding so each mesh
# link is a plain L2 neighbor link. FIPS is the routing layer; two
# routing layers would fight.
# - basic_rate '6000 12000 24000' — mesh points peer only when their basic
# rates match, and OpenWrt releases differ when none is set: 24.10 joins
# through iw with the radio's (by default 6, 12 and 24 Mb/s on 2.4 GHz),
# 25.12 with the kernel's (1, 2, 5.5 and 11 on 2.4 GHz). Setting them makes
# 24.10 and later join alike. 22.03 and 23.05 pass no basic rates to a mesh
# join, so on 2.4 GHz they keep the kernel's and do not peer with these
# routers. A basic_rate already on the interface is kept.
# - firewall zone 'fips_mesh' — the interfaces get a firewall zone of
# their own, which rejects all IP input and forwarding, FIPS UDP and
# mDNS included. The mesh is open, and an interface in no zone meets
# the router's default input policy, which is ACCEPT on OpenWrt 22.03,
# so anyone in radio range would reach router services. The FIPS
# Ethernet transport is not IP and does not pass the firewall. An
# interface already in another zone is left there, with a warning.
#
# Interfaces are named per radio index (radio0 -> fips-mesh0, radio1 ->
# fips-mesh1) and are intentionally NOT bridged into br-lan: the FIPS
# Ethernet transport binds each directly and runs discovery beacons over it.
#
# The shipped /etc/fips/fips.yaml carries 'mesh0' and 'mesh1' entries under
# 'transports.ethernet' bound to these names, enabled and marked
# 'optional: true'. The daemon treats a named interface that is not there as
# ABSENT rather than as a start failure: it waits, binds the moment the
# interface appears, and unbinds again when it goes away. So this helper
# rewrites no fips.yaml and restarts no daemon: besides the interface, it
# changes only the fips_mesh firewall zone and reloads the firewall, and the
# rates subcommand sets basic_rate on mesh interfaces. 'optional: true' is what keeps a stock install that never runs this
# script from reporting Degraded for an interface it was never going to have.
# See docs/how-to/set-up-80211s-mesh-backhaul.md for the full guide.
DEFAULT_MESH_ID="fips-mesh"
BASIC_RATES="6000 12000 24000"
CONFIG="/etc/fips/fips.yaml"
FWCOPY=/tmp/fips-mesh-firewall.uci
# The firewall tools are in the system directories, which a caller's PATH
# may lack; appended, so a caller's own wrapper still comes first.
PATH="$PATH:/usr/sbin:/usr/bin:/sbin:/bin"
# Whether $CONFIG carries an enabled 'mesh<idx>' transports.ethernet block.
# Reports only; the daemon owns the binding. Returns:
# 0 present 1 no config file 2 no such block
mesh_config_present() {
idx="$1"
[ -f "$CONFIG" ] || return 1
grep -q "^ mesh$idx:" "$CONFIG" || return 2
return 0
}
usage() {
echo "Usage: fips-mesh-setup <radio> [mesh-id]" >&2
echo " fips-mesh-setup remove [radio]" >&2
echo " fips-mesh-setup zone" >&2
echo " fips-mesh-setup close" >&2
echo " fips-mesh-setup rates" >&2
echo "Radios on this device:" >&2
uci show wireless 2>/dev/null | sed -n "s/^wireless\.\([^.]*\)=wifi-device$/ \1/p" >&2
exit 1
}
# List the UCI section names of fips-managed mesh wifi-ifaces.
mesh_sections() {
uci show wireless 2>/dev/null | sed -n "s/^wireless\.\(fips_mesh[^.=]*\)=wifi-iface$/\1/p"
}
# ---------------------------------------------------------------------------
# Firewall: the fips_mesh zone
# ---------------------------------------------------------------------------
# The zone holds each interface by its device name, so it is in the ruleset
# from the moment the firewall loads, before netifd brings the radio up. It is
# created once and is the operator's from then on: nothing here rewrites its
# options. fw4's reload exits 0 even when the new ruleset fails to load, so
# the loaded ruleset is read instead.
# Print a warning, and log it.
mesh_say() {
logger -t fips-mesh-setup -p daemon.warn "$1" 2>/dev/null
echo "fips-mesh-setup: $1"
}
# Whether firewall section $1 is disabled.
mesh_off() {
case "$(uci -q get "firewall.$1.enabled")" in
0 | off | false | no) return 0 ;;
esac
return 1
}
# The input policy that applies to firewall section $1, upper-cased. fw4
# ignores case and also takes any leading part of a policy's name.
mesh_zone_input() {
local p
p="$(uci -q get "firewall.$1.input")"
[ -n "$p" ] || p="$(uci -q get "firewall.@defaults[0].input")"
# Ranges, not [:lower:]: busybox tr has no character classes.
# shellcheck disable=SC2018,SC2019
p="$(echo "${p:-DROP}" | tr a-z A-Z)"
case "$p" in
A | AC | ACC | ACCE | ACCEP) p=ACCEPT ;;
esac
echo "$p"
}
# The section name of firewall zone $1 (@zone[N]); uci names an anonymous
# section cfg followed by hex digits.
mesh_section() {
uci -q show "firewall.$1" | sed -n '1s/^firewall\.\([^.=]*\)=zone$/\1/p'
}
# Whether enabled zone section $1 holds interface $2 by device, by a device
# wildcard, or by its network $3. The mesh's network section carries no
# device option, so a network entry is matched by its name.
mesh_holds() {
local d n
mesh_off "$1" && return 1
for d in $(uci -q get "firewall.$1.device"); do
case "$d" in
"$2") return 0 ;;
+) ;;
*+) case "$2" in "${d%+}"*) return 0 ;; esac ;;
esac
done
[ -n "$3" ] || return 1
for n in $(uci -q get "firewall.$1.network"); do
[ "$n" = "$3" ] && return 0
done
return 1
}
# Print the section of the first zone other than the section fips_mesh that
# holds interface $1 (network $2). Returns 1 when none does.
mesh_heldby() {
local z=0 s
while uci -q get "firewall.@zone[$z]" >/dev/null; do
s="$(mesh_section "@zone[$z]")"
if [ -n "$s" ] && [ "$s" != fips_mesh ] && mesh_holds "$s" "$1" "$2"; then
echo "$s"
return 0
fi
z=$((z + 1))
done
return 1
}
# A zone section's name for a message, with the section when it is anonymous.
mesh_zone_label() {
local n
n="$(uci -q get "firewall.$1.name")"
case "$1" in
cfg[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) echo "${n:-?} (firewall.$1)" ;;
*) echo "${n:-$1}" ;;
esac
}
# Say that interface $1 is left in zone section $2.
mesh_held_say() {
local label p
label="$(mesh_zone_label "$2")"
p="$(mesh_zone_input "$2")"
if [ "$p" = ACCEPT ]; then
mesh_say "$1 is in firewall zone $label, whose input policy is ACCEPT, so router services are reachable from anyone in radio range of the mesh; it stays in that zone"
else
mesh_say "$1 is in firewall zone $label, whose input policy is $p; it stays in that zone"
fi
}
# Print why the section fips_mesh cannot be created or used as the mesh's
# zone. Returns 1 when nothing stands in the way.
mesh_zone_conflict() {
local t z=0 s want
t="$(uci -q get firewall.fips_mesh)"
if [ -n "$t" ] && [ "$t" != zone ]; then
echo "firewall section fips_mesh exists and is not a zone"
return 0
fi
want="$(uci -q get firewall.fips_mesh.name)"
[ -n "$want" ] || want=fips_mesh
while uci -q get "firewall.@zone[$z]" >/dev/null; do
s="$(mesh_section "@zone[$z]")"
if [ "$s" != fips_mesh ] && [ "$(uci -q get "firewall.@zone[$z].name")" = "$want" ]; then
echo "a firewall zone named $want already exists (firewall.$s)"
return 0
fi
z=$((z + 1))
done
return 1
}
# Whether interface $1 is listed in the zone.
mesh_listed() {
local d
for d in $(uci -q get firewall.fips_mesh.device); do
[ "$d" = "$1" ] && return 0
done
return 1
}
# Stage interface $1 in the zone, creating the zone when it is missing. Sets
# staged to 1 when it changed anything.
mesh_zone_add() {
if [ "$(uci -q get firewall.fips_mesh)" != zone ]; then
uci set firewall.fips_mesh=zone
uci set firewall.fips_mesh.name=fips_mesh
uci set firewall.fips_mesh.input=REJECT
uci set firewall.fips_mesh.output=ACCEPT
uci set firewall.fips_mesh.forward=REJECT
uci set firewall.fips_mesh.auto_helper=0
staged=1
fi
mesh_listed "$1" && return 0
uci add_list "firewall.fips_mesh.device=$1"
staged=1
}
# Save the staged configuration $1 (firewall unless given) and check that the
# file now holds it. On a full overlay uci commit can exit 0 after writing a
# truncated or empty file and dropping the staged changes. The staged
# configuration is first exported to /tmp/fips-mesh-$1.uci ($FWCOPY for the
# firewall), unless an earlier run in this boot left a copy there, and is
# removed once saved. Returns 0 when saved, 1 when it could not be saved and
# is still staged, and 2 when the file no longer holds it.
mesh_commit() {
local cfg="${1:-firewall}" copy want now
copy="/tmp/fips-mesh-$cfg.uci"
want="$copy.staged"
now="$copy.now"
uci -q export "$cfg" > "$want" 2>/dev/null || return 1
[ -e "$copy" ] || cp "$want" "$copy" 2>/dev/null
uci commit "$cfg" || return 1
if uci -q export "$cfg" > "$now" 2>/dev/null && cmp -s "$want" "$now"; then
rm -f "$want" "$now" "$copy"
return 0
fi
rm -f "$want" "$now"
return 2
}
# What a failed save with mesh_commit status $1 means.
mesh_save_why() {
if [ "$1" = 2 ]; then
echo "the firewall configuration could not be saved and /etc/config/firewall was left damaged, which the next firewall reload or reboot loads"
else
echo "the firewall configuration could not be saved"
fi
}
# The remedy for a failed save with mesh_commit status $1; $2 is the command
# to run again.
mesh_save_fix() {
if [ "$1" = 2 ]; then
echo "free space on the overlay, then, before the router reboots, run 'uci import firewall < $FWCOPY' and '/etc/init.d/firewall reload'"
else
echo "free space on the overlay, then run '$2' again"
fi
}
# Whether fw4 is running: nft is found and its state file or table exists.
mesh_fw_running() {
command -v nft >/dev/null 2>&1 &&
{ [ -e /var/run/fw4.state ] || nft list table inet fw4 >/dev/null 2>&1; }
}
# Whether the loaded input and forward chains send each interface after the
# zone name $1 to that zone's chains.
mesh_fw_loaded() {
local zone="$1" c l d
shift
for c in input forward; do
l="$(nft list chain inet fw4 "$c" 2>/dev/null)" || return 1
for d in "$@"; do
printf '%s\n' "$l" | grep -F "\"$d\"" | grep -qF "jump ${c}_$zone " || return 1
done
done
return 0
}
# Drop IP traffic arriving on each interface at the head of the loaded input
# and forward chains, until the firewall next reloads. A chain already holding
# the drop is left alone. Returns 1 when a rule could not be inserted.
mesh_fw_close() {
local c d ok=0
for c in input forward; do
for d in "$@"; do
nft list chain inet fw4 "$c" 2>/dev/null |
grep -qF "iifname \"$d\" drop comment \"fips-mesh-reload-failed\"" && continue
nft insert rule inet fw4 "$c" iifname "$d" drop comment '"fips-mesh-reload-failed"' >/dev/null 2>&1 || ok=1
done
done
return "$ok"
}
# Reload the firewall and check that the zone named $2 holds each interface
# after it in the loaded ruleset. With $1 0, nothing was saved: check first,
# and reload only when the check fails. Sets fwnote to loaded, dropped or
# unloaded. Returns 1 only when an interface could not be closed.
mesh_fw_apply() {
local reload="$1" zone="$2" state=0
shift 2
fwnote=unloaded
if ! mesh_fw_running; then
if [ "$#" -gt 0 ] && { [ "$MODE" = add ] || [ "$reload" = 1 ]; }; then
mesh_say "the fw4 firewall is not loaded, so nothing filters IP traffic arriving on $* until it starts; the $zone zone is in the configuration and applies then"
fi
return 0
fi
[ -e /var/run/fw4.state ] && state=1
if [ "$reload" = 0 ]; then
[ "$#" -gt 0 ] || return 0
if mesh_fw_loaded "$zone" "$@"; then
fwnote=loaded
return 0
fi
fi
/etc/init.d/firewall reload >/dev/null 2>&1
[ "$#" -gt 0 ] || return 0
if ! nft list table inet fw4 >/dev/null 2>&1; then
mesh_say "the fw4 firewall is not loaded, so nothing filters IP traffic arriving on $* until it loads; run 'fw4 check' to see why"
return 0
fi
if mesh_fw_loaded "$zone" "$@"; then
fwnote=loaded
return 0
fi
if mesh_fw_close "$@"; then
fwnote=dropped
if [ "$state" = 0 ]; then
mesh_say "the firewall reload failed because fw4's state file is missing, so IP traffic arriving on $* is dropped until the firewall reloads cleanly; run '/etc/init.d/firewall restart'"
else
mesh_say "the firewall reload failed, so IP traffic arriving on $* is dropped until the firewall reloads cleanly; run 'fw4 check' to see why"
fi
return 0
fi
mesh_say "the firewall reload failed and $* could not be closed, so router services may be reachable from anyone in radio range of the mesh until the firewall reloads cleanly; run 'fw4 check' to see why"
return 1
}
# Stage the zone for the interfaces given (those no other zone holds), save,
# then reload and check. Sets zoned to the interfaces the check applies to,
# and saved to mesh_commit's status (0 when nothing was to save). Returns 1
# when the save failed, 2 when an interface could not be closed.
mesh_fw_setup() {
local d zone
staged=0
saved=0
zoned=""
fwnote=unloaded
for d in "$@"; do
mesh_zone_add "$d"
done
zone="$(uci -q get firewall.fips_mesh.name)"
[ -n "$zone" ] || zone=fips_mesh
if [ "$#" -gt 0 ]; then
if mesh_off fips_mesh; then
mesh_say "the fips_mesh firewall zone is disabled, so $* is in no firewall zone and meets the default input policy; on releases whose default input policy is ACCEPT, such as OpenWrt 22.03, router services are reachable from anyone in radio range of the mesh"
else
zoned="$*"
if [ "$(mesh_zone_input fips_mesh)" = ACCEPT ]; then
mesh_say "firewall zone $zone, which holds $*, has input policy ACCEPT, so router services are reachable from anyone in radio range of the mesh"
fi
fi
fi
# Changes left staged by an earlier run whose save failed count too.
[ -n "$(uci -q changes firewall)" ] && staged=1
if [ "$staged" = 1 ]; then
mesh_commit
saved=$?
if [ "$saved" != 0 ]; then
# shellcheck disable=SC2086
if [ -n "$zoned" ] && command -v nft >/dev/null 2>&1 &&
nft list table inet fw4 >/dev/null 2>&1 && mesh_fw_close $zoned; then
fwnote=dropped
fi
return 1
fi
fi
# shellcheck disable=SC2086
mesh_fw_apply "$staged" "$zone" $zoned || return 2
return 0
}
# Wait up to five seconds for the interfaces given to go: wifi reload returns
# before netifd takes them down. Names any still present.
mesh_gone() {
local i=0 d left
while :; do
left=""
for d in "$@"; do
[ -e "/sys/class/net/$d" ] && left="$left $d"
done
[ -z "$left" ] && return 0
[ "$i" -ge 5 ] && break
sleep 1
i=$((i + 1))
done
mesh_say "${left# } is still present after the wifi reload, and is in no firewall zone from the firewall reload until it goes"
return 1
}
# ---------------------------------------------------------------------------
# zone — add every existing mesh interface to the fips_mesh zone. The
# package's setup runs it at upgrade, so mesh interfaces made by earlier
# packages, which put them in no zone, are closed. Exits 1 only when the
# firewall configuration could not be saved.
# ---------------------------------------------------------------------------
if [ "$1" = "zone" ]; then
MODE=zone
SECTIONS="$(mesh_sections)"
[ -n "$SECTIONS" ] || exit 0
ZONE_IFS=""
for section in $SECTIONS; do
ifname="$(uci -q get "wireless.$section.ifname")"
if [ -z "$ifname" ]; then
mesh_say "wireless section $section has no ifname, so it is not added to the fips_mesh firewall zone"
continue
fi
if held="$(mesh_heldby "$ifname" "$(uci -q get "wireless.$section.network")")"; then
mesh_held_say "$ifname" "$held"
continue
fi
ZONE_IFS="$ZONE_IFS $ifname"
done
ZONE_IFS="${ZONE_IFS# }"
if [ -n "$ZONE_IFS" ] && why="$(mesh_zone_conflict)"; then
mesh_say "$why; the mesh interfaces $ZONE_IFS are in no firewall zone"
exit 0
fi
# shellcheck disable=SC2086
mesh_fw_setup $ZONE_IFS
if [ "$?" = 1 ]; then
if [ "$fwnote" = dropped ]; then
mesh_say "$(mesh_save_why "$saved"), so the mesh interfaces $ZONE_IFS are not in the fips_mesh firewall zone, and are closed to IP traffic until the firewall next reloads; $(mesh_save_fix "$saved" "fips-mesh-setup zone")"
else
mesh_say "$(mesh_save_why "$saved"), so the mesh interfaces $ZONE_IFS are not in the fips_mesh firewall zone and could not be closed; $(mesh_save_fix "$saved" "fips-mesh-setup zone")"
fi
exit 1
fi
exit 0
fi
# ---------------------------------------------------------------------------
# close — drop IP traffic arriving on every mesh interface no other zone
# holds, in the loaded ruleset only, until the firewall next reloads. Changes
# no configuration. The package's setup runs it when it could not save the
# firewall configuration.
# ---------------------------------------------------------------------------
if [ "$1" = "close" ]; then
CLOSE_IFS=""
for section in $(mesh_sections); do
ifname="$(uci -q get "wireless.$section.ifname")"
[ -n "$ifname" ] || continue
mesh_heldby "$ifname" "$(uci -q get "wireless.$section.network")" >/dev/null && continue
CLOSE_IFS="$CLOSE_IFS $ifname"
done
CLOSE_IFS="${CLOSE_IFS# }"
[ -n "$CLOSE_IFS" ] || exit 0
# shellcheck disable=SC2086
if command -v nft >/dev/null 2>&1 && nft list table inet fw4 >/dev/null 2>&1 &&
mesh_fw_close $CLOSE_IFS; then
mesh_say "the firewall configuration could not be saved, so the mesh interfaces $CLOSE_IFS are closed to IP traffic until the firewall next reloads"
else
mesh_say "the firewall configuration could not be saved, and the mesh interfaces $CLOSE_IFS could not be closed to IP traffic; on releases whose default input policy is ACCEPT, such as OpenWrt 22.03, router services may be reachable from anyone in radio range of the mesh"
fi
exit 0
fi
# ---------------------------------------------------------------------------
# rates — give every mesh interface that has no basic_rate the mesh's basic
# rates. The package's setup runs it at upgrade, so mesh interfaces made by
# earlier packages, which set none, peer across OpenWrt releases. A basic_rate
# already set is the operator's and is kept. Saves the wireless configuration
# but does not reload the radios itself: the rates apply at the next reboot or
# network reload, which wifi reload and a LuCI apply make, as can the fips
# gateway's start or stop. Exits 1 only when the wireless configuration could not be saved.
# ---------------------------------------------------------------------------
if [ "$1" = "rates" ]; then
SET_IFS=""
# Rates left staged by an earlier run whose save failed are saved too.
STAGED=" $(uci -q changes wireless | sed -n "s/^wireless\.\(fips_mesh[^.]*\)\.basic_rate='$BASIC_RATES'\$/\1/p" | tr '\n' ' ')"
for section in $(mesh_sections); do
ifname="$(uci -q get "wireless.$section.ifname")"
rates="$(uci -q get "wireless.$section.basic_rate")"
if [ -z "$rates" ]; then
uci set "wireless.$section.basic_rate=$BASIC_RATES"
SET_IFS="$SET_IFS ${ifname:-$section}"
elif [ "$rates" = "$BASIC_RATES" ]; then
case "$STAGED" in
*" $section "*) SET_IFS="$SET_IFS ${ifname:-$section}" ;;
esac
else
mesh_say "${ifname:-$section} keeps its basic_rate '$rates'; it peers only with mesh points whose basic rates are the same, and fips-mesh-setup gives new mesh interfaces '$BASIC_RATES'"
fi
done
SET_IFS="${SET_IFS# }"
[ -n "$SET_IFS" ] || exit 0
mesh_commit wireless
saved=$?
if [ "$saved" = 2 ]; then
mesh_say "the wireless configuration could not be saved and /etc/config/wireless was left damaged, which the next network reload or reboot loads; free space on the overlay, then, before the router reboots, run 'uci import wireless < /tmp/fips-mesh-wireless.uci'"
exit 1
elif [ "$saved" != 0 ]; then
mesh_say "the wireless configuration could not be saved, so the mesh interfaces keep the basic rates they had; free space on the overlay, then run 'fips-mesh-setup rates' again"
exit 1
fi
echo "fips-mesh-setup: set basic_rate '$BASIC_RATES' on $SET_IFS; it applies at the next reboot or network reload, which 'wifi reload' and a LuCI apply make, as can the fips gateway's start or stop, and from then on they peer only with mesh points whose basic rates are the same, which a router set up by an older fips package may not have until its package is upgraded, and a router on OpenWrt 22.03 or 23.05 does not have on 2.4 GHz"
echo "fips-mesh-setup: when you reach routers over the mesh, upgrade and reload them one at a time, farthest first, so the routers you reach them through keep their peerings until last"
exit 0
fi
# ---------------------------------------------------------------------------
# remove [radio] — delete the wireless and network sections created below,
# and take the interfaces out of the fips_mesh zone
# ---------------------------------------------------------------------------
if [ "$1" = "remove" ]; then
MODE=remove
if [ -n "$2" ]; then
SECTIONS="fips_mesh_$(printf '%s' "$2" | tr -c 'a-zA-Z0-9_' '_')"
else
SECTIONS="$(mesh_sections)"
fi
[ -n "$SECTIONS" ] || {
echo "No fips mesh instances configured."
exit 0
}
FWZONE=1
t="$(uci -q get firewall.fips_mesh)"
if [ -n "$t" ] && [ "$t" != zone ]; then
mesh_say "firewall section fips_mesh exists and is not a zone; leaving the firewall configuration alone"
FWZONE=0
fi
REMOVED=""
for section in $SECTIONS; do
ifname="$(uci -q get "wireless.$section.ifname")"
uci -q delete "wireless.$section"
uci -q delete "network.$section"
if [ "$FWZONE" = 1 ] && [ -n "$ifname" ] && mesh_listed "$ifname"; then
REMOVED="$REMOVED $ifname"
fi
# The fips.yaml block stays as it is. The daemon notices the
# interface going away, unbinds, and waits for it — silently,
# because the block is marked 'optional: true'.
echo "Removed ${ifname:-$section}."
done
REMOVED="${REMOVED# }"
uci commit wireless
uci commit network
# 'wifi reload' re-applies the whole wireless config, so it briefly drops
# every client AP on all radios (a few seconds) — expected on remove.
wifi reload
if [ "$FWZONE" = 1 ]; then
# An interface keeps its zone until it is gone. Nothing is staged
# before then, because fw4 renders staged changes too, and a firewall
# reload that the radio reload sets off would apply them.
# shellcheck disable=SC2086
[ -n "$REMOVED" ] && mesh_gone $REMOVED
for d in $REMOVED; do
uci -q del_list "firewall.fips_mesh.device=$d"
done
# Delete the zone once this run has taken the last interface out
# of it. An empty zone this run took nothing from is left alone.
if [ -n "$REMOVED" ] &&
[ "$(uci -q get firewall.fips_mesh)" = zone ] &&
[ -z "$(uci -q get firewall.fips_mesh.device)" ] &&
[ -z "$(uci -q get firewall.fips_mesh.network)" ]; then
uci -q delete firewall.fips_mesh
fi
if [ -n "$(uci -q changes firewall)" ]; then
mesh_commit
saved=$?
if [ "$saved" != 0 ]; then
mesh_say "$(mesh_save_why "$saved"), so the firewall was not reloaded; $(mesh_save_fix "$saved" "fips-mesh-setup remove")"
elif mesh_fw_running; then
/etc/init.d/firewall reload >/dev/null 2>&1
for d in $REMOVED; do
mesh_heldby "$d" "" >/dev/null && continue
if nft list chain inet fw4 input 2>/dev/null | grep -qF "\"$d\""; then
mesh_say "the firewall reload failed, so the old rules for $d stay loaded until the firewall reloads cleanly; run 'fw4 check' to see why"
fi
done
fi
fi
fi
echo "No fips restart needed — the daemon unbinds the interface itself."
exit 0
fi
MODE=add
RADIO="$1"
MESH_ID="${2:-$DEFAULT_MESH_ID}"
[ -n "$RADIO" ] || usage
if [ "$(uci -q get "wireless.$RADIO")" != "wifi-device" ]; then
echo "Error: '$RADIO' is not a wifi-device in /etc/config/wireless." >&2
usage
fi
# One instance per radio: section fips_mesh_<radio>, netdev fips-mesh<N>
# where N is the radio's trailing index (radio0 -> fips-mesh0). For radios
# named without a trailing number, fall back to the first free index.
SECTION="fips_mesh_$(printf '%s' "$RADIO" | tr -c 'a-zA-Z0-9_' '_')"
IDX="$(printf '%s' "$RADIO" | sed -n 's/.*[^0-9]\([0-9]\{1,\}\)$/\1/p')"
[ -n "$IDX" ] || IDX="$(printf '%s' "$RADIO" | sed -n 's/^\([0-9]\{1,\}\)$/\1/p')"
if [ -z "$IDX" ]; then
IDX=0
while uci show wireless 2>/dev/null | grep -q "\.ifname='fips-mesh$IDX'"; do
IDX=$((IDX + 1))
done
fi
MESH_IFNAME="fips-mesh$IDX"
# Refuse a name collision from another radio's instance (e.g. two radios
# whose names end in the same digit) rather than silently hijacking it.
OWNER="$(uci show wireless 2>/dev/null \
| sed -n "s/^wireless\.\(fips_mesh[^.=]*\)\.ifname='$MESH_IFNAME'$/\1/p")"
if [ -n "$OWNER" ] && [ "$OWNER" != "$SECTION" ]; then
echo "Error: $MESH_IFNAME is already used by section '$OWNER'." >&2
echo "Remove it first: fips-mesh-setup remove" >&2
exit 1
fi
# ---------------------------------------------------------------------------
# Driver capability check (advisory — config below is harmless either way)
# ---------------------------------------------------------------------------
if command -v iw >/dev/null 2>&1; then
if ! iw list 2>/dev/null | grep -q "\* mesh point"; then
echo "Warning: no radio on this device advertises 'mesh point' support" >&2
echo "(iw list | grep 'mesh point'). The interface may fail to come up." >&2
fi
fi
# ---------------------------------------------------------------------------
# Firewall first, so the interface never exists outside its zone. A radio
# without a trailing index can get a new name on a re-run; the old name stays
# in the zone until the interface has been renamed.
# ---------------------------------------------------------------------------
OLD_IFNAME="$(uci -q get "wireless.$SECTION.ifname")"
FW_IFS=""
if HELD="$(mesh_heldby "$MESH_IFNAME" "$SECTION")"; then
mesh_held_say "$MESH_IFNAME" "$HELD"
else
HELD=""
if why="$(mesh_zone_conflict)"; then
mesh_say "$why; the mesh was not created, since $MESH_IFNAME would be in no firewall zone"
exit 1
fi
FW_IFS="$MESH_IFNAME"
fi
# shellcheck disable=SC2086
mesh_fw_setup $FW_IFS
case $? in
1)
# On a re-add the interface is already up; mesh_fw_setup has dropped IP
# traffic arriving on it.
mesh_say "$(mesh_save_why "$saved"), so the mesh was not created; $(mesh_save_fix "$saved" "fips-mesh-setup $*")"
exit 1
;;
2)
mesh_say "$MESH_IFNAME could not be closed, so the mesh was not created"
exit 1
;;
esac
# ---------------------------------------------------------------------------
# Wireless: open 802.11s mesh point, HWMP forwarding off
# ---------------------------------------------------------------------------
# A basic_rate the section already has is the operator's, and is kept.
RATES="$(uci -q get "wireless.$SECTION.basic_rate")"
uci -q delete "wireless.$SECTION"
uci set "wireless.$SECTION=wifi-iface"
uci set "wireless.$SECTION.device=$RADIO"
uci set "wireless.$SECTION.mode=mesh"
uci set "wireless.$SECTION.mesh_id=$MESH_ID"
uci set "wireless.$SECTION.encryption=none"
uci set "wireless.$SECTION.mesh_fwding=0"
uci set "wireless.$SECTION.basic_rate=${RATES:-$BASIC_RATES}"
uci set "wireless.$SECTION.ifname=$MESH_IFNAME"
uci set "wireless.$SECTION.network=$SECTION"
# Radios ship disabled on fresh OpenWrt installs; a disabled radio would
# leave the mesh interface down with no error anywhere visible.
if [ "$(uci -q get "wireless.$RADIO.disabled")" = "1" ]; then
echo "Note: enabling $RADIO (was disabled)."
uci -q delete "wireless.$RADIO.disabled"
fi
# The mesh inherits the radio's channel, and mesh points only peer on the
# same channel. 'auto' lets each router pick its own — the classic silent
# non-peering cause — so surface the setting loudly.
CHANNEL="$(uci -q get "wireless.$RADIO.channel")"
BAND="$(uci -q get "wireless.$RADIO.band")"
if [ -z "$CHANNEL" ] || [ "$CHANNEL" = "auto" ]; then
echo "Warning: $RADIO channel is '${CHANNEL:-unset}' — each router may" >&2
echo "auto-select a different channel and mesh points only peer on the" >&2
echo "same one. Pin the same channel on every backhaul router, e.g.:" >&2
echo " uci set wireless.$RADIO.channel='36' && uci commit wireless && wifi reload" >&2
fi
# A client (sta) interface on the same radio follows its upstream AP's
# channel and drags every other interface with it — a mesh pinned to a
# different channel silently never joins, and does not recover when the
# STA disconnects.
for s in $(uci show wireless 2>/dev/null | sed -n "s/^wireless\.\([^.]*\)\.mode='sta'$/\1/p"); do
if [ "$(uci -q get "wireless.$s.device")" = "$RADIO" ]; then
echo "Warning: $RADIO also carries client interface '$s' (mode 'sta')." >&2
echo "The whole radio follows that STA's upstream channel — a mesh" >&2
echo "pinned to a different channel stays down silently. Align the" >&2
echo "mesh channel with the upstream AP, or put the mesh on a radio" >&2
echo "without a STA (a roaming uplink is incompatible with a" >&2
echo "fixed-channel mesh on the same radio)." >&2
fi
done
# ---------------------------------------------------------------------------
# Network: unmanaged interface so netifd brings the netdev up. No IP config —
# the FIPS Ethernet transport speaks raw frames on it.
# ---------------------------------------------------------------------------
uci -q delete "network.$SECTION"
uci set "network.$SECTION=interface"
uci set "network.$SECTION.proto=none"
uci commit wireless
uci commit network
# 'wifi reload' re-applies the whole wireless config, so it briefly drops
# every client AP on all radios (a few seconds) — expected when adding a mesh.
wifi reload
# The interface has its new name now, so the old one leaves the zone. The
# loaded rule for it matches nothing and goes at the next reload.
STATUS=0
if [ -n "$OLD_IFNAME" ] && [ "$OLD_IFNAME" != "$MESH_IFNAME" ] &&
[ "$(uci -q get firewall.fips_mesh)" = zone ] && mesh_listed "$OLD_IFNAME"; then
uci -q del_list "firewall.fips_mesh.device=$OLD_IFNAME"
mesh_commit
saved=$?
if [ "$saved" != 0 ]; then
mesh_say "$(mesh_save_why "$saved"), so the mesh was created and its old interface name $OLD_IFNAME is still listed in the fips_mesh firewall zone; $(mesh_save_fix "$saved" "fips-mesh-setup $*")"
STATUS=1
fi
fi
# Report whether the shipped fips.yaml still carries the matching transport.
# It ships enabled, so this is a check, not an edit.
mesh_config_present "$IDX"
case $? in
0) TRANSPORT_NOTE="The mesh$IDX transport in $CONFIG binds '$MESH_IFNAME'; the
daemon picks the interface up on its own." ;;
1) TRANSPORT_NOTE="No $CONFIG found — add a transports.ethernet entry binding
interface '$MESH_IFNAME' by hand." ;;
*) TRANSPORT_NOTE="No 'mesh$IDX' entry in $CONFIG — add a transports.ethernet
entry binding interface '$MESH_IFNAME' by hand (copy the mesh0 block)." ;;
esac
# What the firewall step did, for the closing message.
ZONE_NAME="$(uci -q get firewall.fips_mesh.name)"
ZONE_NAME="${ZONE_NAME:-fips_mesh}"
if [ -n "$HELD" ]; then
FW_NOTE="$MESH_IFNAME stays in firewall zone $(mesh_zone_label "$HELD"), as you configured it."
elif [ -z "$zoned" ]; then
FW_NOTE="$MESH_IFNAME is listed in the fips_mesh firewall zone, which is disabled,
so it is in no firewall zone."
elif [ "$(mesh_zone_input fips_mesh)" = ACCEPT ]; then
FW_NOTE="$MESH_IFNAME is in firewall zone $ZONE_NAME, whose input policy is ACCEPT."
else
case "$fwnote" in
loaded) FW_NOTE="$MESH_IFNAME is in firewall zone $ZONE_NAME, which rejects all IP input
and forwarding (FIPS UDP and mDNS included). The FIPS Ethernet transport is
not IP and is not affected." ;;
dropped) FW_NOTE="$MESH_IFNAME is in firewall zone $ZONE_NAME in the configuration; until the
firewall reloads cleanly, IP traffic arriving on it is dropped." ;;
*) FW_NOTE="$MESH_IFNAME is in firewall zone $ZONE_NAME in the configuration, which
applies when the firewall loads." ;;
esac
fi
cat <<EOF
Created open 802.11s mesh '$MESH_ID' as $MESH_IFNAME on $RADIO \
(band ${BAND:-?}, channel ${CHANNEL:-auto}).
$FW_NOTE
ALL routers in this backhaul must share this mesh ID AND channel
(per band). On a dual-band router, run fips-mesh-setup for the other
radio too — second band is a standby path (failover, not multipath).
They must also share the basic rates, basic_rate '${RATES:-$BASIC_RATES}'.
A router set up by an older fips package may join with other rates and
not peer with this one until its package is upgraded. OpenWrt 22.03
and 23.05 ignore basic_rate on a mesh and, on 2.4 GHz, join with
1, 2, 5.5 and 11 Mb/s, so they do not peer with this router there.
Next steps:
1. $TRANSPORT_NOTE
No restart: the daemon binds an interface when it appears and
rebinds it if it goes away. Watch it happen with:
fipsctl show transports
2. Verify L2 peering with a second FIPS router in range:
iw dev $MESH_IFNAME station dump
and the FIPS link on top of it:
fipsctl show peers
Run 'fips-mesh-setup remove' to undo all instances, or
'fips-mesh-setup remove $RADIO' for just this one.
EOF
exit "$STATUS"