Files
fips/packaging/openwrt-ipk/files/etc/init.d/fips-gateway
T
Johnathan Corgan 0f2ba939fd Iterate the gateway's UDP socket inodes from a variable
shellcheck reports SC2013 on the for loop over a command substitution in
gateway_dns_held_by, and the OpenWrt Package workflow fails its lint step on
it. Capture the awk output in a variable and iterate that instead; the words
the loop sees are the same.

cat stays in front of awk because busybox awk gives up on a missing
/proc/net/udp6, and a while-read loop at the end of the pipe would run in a
subshell under ash, where returning from the function is not possible.
2026-10-01 22:40:40 +00:00

374 lines
13 KiB
Bash
Executable File

#!/bin/sh /etc/rc.common
# FIPS outbound LAN gateway — procd init script for OpenWrt
#
# Not enabled by default. Enable with:
# service fips-gateway enable
# service fips-gateway start
#
# Requires: fips daemon running, gateway section in /etc/fips/fips.yaml,
# IPv6 forwarding enabled.
USE_PROCD=1
START=96
STOP=09
# procd runs "supervise" (below) rather than the gateway itself.
EXTRA_COMMANDS="supervise"
EXTRA_HELP="\tsupervise Run the gateway, pointing dnsmasq at it while it listens (run by procd)\n"
PROG=/usr/bin/fips-gateway
CONFIG=/etc/fips/fips.yaml
# Port the gateway DNS listens on when gateway.dns.listen is not set. Must
# match DEFAULT_DNS_LISTEN in the gateway's source; the scenario harness checks
# the two agree. The port actually used comes from gateway_dns_port.
GW_DNS_DEFAULT=5365
# Port the FIPS daemon DNS listens on.
DAEMON_DNS_PORT=5354
# Held while dnsmasq's .fips upstream is changed, so a stopping gateway's swap
# back to the daemon and a starting gateway's swap to itself cannot interleave.
DNS_LOCK=/var/lock/fips-gateway-dns.lock
# The pid of the gateway the current supervise runs, so an exiting instance
# can tell its successor's socket from anything else holding the port.
GW_PIDFILE=/var/run/fips-gateway.pid
# Seconds a stopping gateway gets after SIGTERM before it is killed; under
# procd's own 5 s, after which procd kills only the supervise shell.
GW_KILL_AFTER=3
# Left by the package's preinst when the gateway is not enabled; see below.
INSTALL_HOLD=/var/run/fips-gateway-install-hold
# Global-scope IPv6 prefix assigned to br-lan so Android/Chrome clients
# believe they have full IPv6 and actually send AAAA queries.
# Uses RFC 5180 (benchmarking) range — not routed on the public internet.
GLOBAL_PREFIX="2001:2:f1b5::1/64"
start_service() {
# OpenWrt's generic postinst enables and starts every init script a
# package ships. A package built from the SDK feed Makefile runs it, and
# its preinst leaves this hold unless the gateway was enabled, so that
# installing or upgrading the package does not switch the gateway on.
# Honoured once.
if [ -e "$INSTALL_HOLD" ]; then
rm -f "$INSTALL_HOLD"
disable
logger -t fips-gateway "left disabled and stopped by the package installation"
return 0
fi
# The gateway daemon exits when gateway.enabled is not true, so without
# this check starting a disabled gateway would still take dnsmasq's .fips
# upstream away from the daemon and point it at a port nothing listens on.
if [ "$(gateway_config_enabled)" != "true" ]; then
logger -t fips-gateway "gateway.enabled is not true in $CONFIG; not starting"
return 1
fi
# Apply gateway sysctls (proxy_ndp, IPv6 forwarding).
sysctl -p /etc/sysctl.d/fips-gateway.conf 2>/dev/null || true
# Load conntrack module for /proc/net/nf_conntrack.
modprobe nf_conntrack 2>/dev/null || true
# Add a global-scope IPv6 prefix to br-lan so Android/Chrome clients
# send AAAA queries (they suppress AAAA when only ULA addresses exist).
# Also set ra_default=2 so odhcpd advertises a default route even
# without upstream IPv6 — needed for clients to accept the prefix.
gateway_add_global_prefix
# Advertise the virtual IP pool via Router Advertisement so LAN clients
# learn a route to the pool automatically.
gateway_add_ra_route
# dnsmasq's .fips forwarding moves to the gateway only once the gateway
# holds its DNS port, and back to the daemon (5354) whenever the gateway
# exits, so a gateway that fails to start, before or after binding, does
# not leave LAN clients' .fips lookups going to a dead port. See supervise.
procd_open_instance
procd_set_param command /etc/init.d/fips-gateway supervise
procd_set_param respawn 3600 5 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
stop_service() {
# Restore dnsmasq .fips forwarding back to the daemon.
dns_locked dnsmasq_swap_fips_upstream "$DAEMON_DNS_PORT"
# Remove the RA route for the virtual IP pool.
gateway_remove_ra_route
# Remove the global prefix and ra_default override.
gateway_remove_global_prefix
}
reload_service() {
restart
}
# Run the gateway as procd's instance. dnsmasq's .fips upstream is pointed at
# the gateway's DNS port once the gateway itself has that port bound, and back
# at the daemon's port when the gateway exits for any reason: a config it
# cannot parse, a port it cannot bind, a failure after binding, a crash, or the
# SIGTERM procd sends to stop it. That SIGTERM is passed on so the gateway can
# remove its NAT table and routes, and a gateway still running $GW_KILL_AFTER
# seconds later is killed, because procd kills only this shell. The exit
# status is the gateway's, so procd's respawn sees the gateway's failures.
supervise() {
local port pid="" watcher rc stopping="" killer=""
port="$(gateway_dns_port)"
# Set before the gateway starts, so a stop that arrives first still
# reaches it.
trap 'stopping=1; gateway_stop' TERM INT
"$PROG" --config "$CONFIG" &
pid=$!
mkdir -p "${GW_PIDFILE%/*}" 2>/dev/null
echo "$pid" > "$GW_PIDFILE"
[ -n "$stopping" ] && gateway_stop
# Polled from a child of its own so this shell sits in wait and reaps the
# gateway the moment it exits; a polling loop here would see an unreaped
# gateway as still alive.
(
while kill -0 "$pid" 2>/dev/null; do
dns_locked gateway_dns_claim "$port" "$pid" && exit 0
sleep 1
done
exit 0
) &
watcher=$!
# A trapped signal ends wait early, so wait until the gateway is gone.
rc=0
wait "$pid" || rc=$?
while kill -0 "$pid" 2>/dev/null; do
rc=0
wait "$pid" || rc=$?
done
[ -n "$killer" ] && kill "$killer" 2>/dev/null
# The watcher has nothing left to do, and would otherwise sleep out its
# poll; any change it is part way through finishes under the lock first.
kill "$watcher" 2>/dev/null
wait "$watcher"
dns_locked gateway_dns_release "$port" "$pid"
[ "$(cat "$GW_PIDFILE" 2>/dev/null)" = "$pid" ] && rm -f "$GW_PIDFILE"
logger -t fips-gateway "gateway exited with status $rc"
return "$rc"
}
# supervise's stop: SIGTERM to the gateway now, SIGKILL if it is still running
# $GW_KILL_AFTER seconds later. Reads and sets supervise's pid and killer.
gateway_stop() {
[ -n "$pid" ] || return 0
kill -TERM "$pid" 2>/dev/null
if [ -z "$killer" ]; then
( sleep "$GW_KILL_AFTER"; kill -KILL "$pid" 2>/dev/null ) &
killer=$!
fi
return 0
}
# Run "$@" holding $DNS_LOCK. The lock is released when the subshell, and
# every process that inherited its descriptor, has exited.
dns_locked() {
mkdir -p "${DNS_LOCK%/*}" 2>/dev/null
(
flock 9 || logger -t fips-gateway "could not lock $DNS_LOCK; changing dnsmasq anyway"
"$@"
) 9>"$DNS_LOCK"
}
# Succeed when process $2 itself holds a UDP socket bound to port $1, on any
# address, IPv4 or IPv6: a socket inode from /proc/net/udp{,6} for that port
# is among the process's open descriptors. Another process holding the port,
# such as an mDNS responder on 5353, does not count.
gateway_dns_held_by() {
local hex inodes inode fd
[ -n "$2" ] || return 1
hex="$(printf '%04X' "$1")"
# cat rather than awk's own file arguments: busybox awk gives up on a
# missing /proc/net/udp6. A while-read loop on the pipe would run in a
# subshell under ash, where the return below could not leave this function.
inodes="$(cat /proc/net/udp /proc/net/udp6 2>/dev/null |
awk -v want=":$hex" 'substr($2, length($2) - 4) == want { print $10 }')"
for inode in $inodes; do
for fd in /proc/"$2"/fd/*; do
[ "$(readlink "$fd" 2>/dev/null)" = "socket:[$inode]" ] && return 0
done
done
return 1
}
# Point dnsmasq at the gateway's port $1 once gateway $2 holds it. Fails,
# changing nothing, until then.
gateway_dns_claim() {
gateway_dns_held_by "$1" "$2" || return 1
dnsmasq_swap_fips_upstream "$1"
logger -t fips-gateway "gateway DNS is listening on port $1; dnsmasq forwards .fips to it"
return 0
}
# Point dnsmasq back at the daemon after gateway $2 on port $1 has exited,
# unless the gateway procd started in its place (the pid in $GW_PIDFILE) holds
# the port already; that one claims dnsmasq itself.
gateway_dns_release() {
local next
next="$(cat "$GW_PIDFILE" 2>/dev/null)"
if [ -n "$next" ] && [ "$next" != "$2" ] && gateway_dns_held_by "$1" "$next"; then
return 0
fi
dnsmasq_swap_fips_upstream "$DAEMON_DNS_PORT"
return 0
}
# Extract the gateway "enabled" flag from fips.yaml.
# Prints the value indented under the top-level "gateway:" block, or nothing
# when there is no such block.
gateway_config_enabled() {
awk '/^gateway:/{found=1; next} found && /^[^ ]/{found=0} found && /enabled:/{gsub(/.*enabled:[[:space:]]*/, ""); gsub(/["'"'"']/, ""); print; exit}' "$CONFIG"
}
# Print the port the gateway's DNS listener will bind: the digits after the
# last ":" of the "listen:" value inside the top-level "gateway:" block of
# fips.yaml, or $GW_DNS_DEFAULT when there is no such line or its value does
# not end in a port. Commented lines are skipped, and "listen_port:" (a port
# forward key) does not match.
#
# Block-style YAML only: a flow-style "dns: {listen: ...}" reads as the
# default. The dnsmasq entry this port feeds is always ::1#<port>, so a
# gateway listening only on 127.0.0.1 is still not reachable through it.
gateway_dns_port() {
local port
port="$(awk '
/^[A-Za-z_]/ { top = $1 }
top != "gateway:" { next }
/^[[:space:]]*#/ { next }
/^[[:space:]]+listen:/ {
v = $0
sub(/^[[:space:]]+listen:[[:space:]]*/, "", v)
sub(/[[:space:]]+#.*$/, "", v)
gsub(/["'"'"']/, "", v)
sub(/[[:space:]]+$/, "", v)
n = split(v, part, ":")
if (n > 1 && part[n] ~ /^[0-9]+$/) print part[n]
exit
}
' "$CONFIG" 2>/dev/null)"
echo "${port:-$GW_DNS_DEFAULT}"
}
# Extract the gateway pool CIDR from fips.yaml.
# Looks for "pool:" indented under the top-level "gateway:" block.
gateway_pool_cidr() {
awk '/^gateway:/{found=1; next} found && /^[^ ]/{found=0} found && /pool:/{gsub(/.*pool:[[:space:]]*/, ""); gsub(/["'"'"']/, ""); print; exit}' "$CONFIG"
}
# Add an RA-advertised route for the virtual IP pool so LAN clients
# automatically learn how to reach virtual IPs.
gateway_add_ra_route() {
local pool
pool="$(gateway_pool_cidr)"
[ -z "$pool" ] && return 0
# Add a kernel route on br-lan (needed for RA to advertise it).
ip -6 route replace "$pool" dev br-lan proto static 2>/dev/null || true
# Add a UCI route6 entry for odhcpd to include in Router Advertisements.
# Remove any stale entry first.
gateway_remove_ra_route_uci
uci add dhcp route6 >/dev/null
uci set dhcp.@route6[-1].interface='lan'
uci set dhcp.@route6[-1].target="$pool"
uci commit dhcp
/etc/init.d/odhcpd restart 2>/dev/null || true
}
# Remove the RA route.
gateway_remove_ra_route() {
local pool
pool="$(gateway_pool_cidr)"
[ -z "$pool" ] && return 0
ip -6 route del "$pool" dev br-lan proto static 2>/dev/null || true
gateway_remove_ra_route_uci
/etc/init.d/odhcpd restart 2>/dev/null || true
}
# Remove any existing UCI route6 entries for the pool.
gateway_remove_ra_route_uci() {
local i=0
while uci -q get "dhcp.@route6[$i]" >/dev/null 2>&1; do
if [ "$(uci -q get "dhcp.@route6[$i].target")" = "$(gateway_pool_cidr)" ]; then
uci delete "dhcp.@route6[$i]"
uci commit dhcp
return 0
fi
i=$((i + 1))
done
}
# Add a global-scope IPv6 prefix to br-lan and enable RA default route.
gateway_add_global_prefix() {
# Add the global prefix via UCI (idempotent — remove first).
local current
current="$(uci -q get network.lan.ip6addr 2>/dev/null || echo "")"
if [ "$current" != "$GLOBAL_PREFIX" ]; then
uci set network.lan.ip6addr="$GLOBAL_PREFIX"
uci commit network
fi
# Force odhcpd to advertise a default route even without upstream IPv6.
local ra_default
ra_default="$(uci -q get dhcp.lan.ra_default 2>/dev/null || echo "")"
if [ "$ra_default" != "2" ]; then
uci set dhcp.lan.ra_default='2'
uci commit dhcp
fi
# Apply network change immediately (odhcpd restarted by gateway_add_ra_route).
/etc/init.d/network reload 2>/dev/null || true
}
# Remove the global prefix and ra_default override.
gateway_remove_global_prefix() {
local current
current="$(uci -q get network.lan.ip6addr 2>/dev/null || echo "")"
if [ "$current" = "$GLOBAL_PREFIX" ]; then
uci delete network.lan.ip6addr
uci commit network
/etc/init.d/network reload 2>/dev/null || true
fi
uci -q delete dhcp.lan.ra_default 2>/dev/null || true
uci commit dhcp
/etc/init.d/odhcpd restart 2>/dev/null || true
}
# Swap the dnsmasq .fips forwarding port via UCI and restart dnsmasq.
# $1 = target port number
dnsmasq_swap_fips_upstream() {
local port="$1"
local server
# Remove every loopback .fips forward, then add the one for $port. That
# covers the daemon's port, this gateway's, and a stale entry for any other
# local port, such as the old default 5353 or a changed gateway.dns.listen.
# A .fips forward to another host and servers for other domains are kept.
# uci prints a list on one line separated by spaces.
for server in $(uci -q get 'dhcp.@dnsmasq[0].server' 2>/dev/null); do
case "$server" in
"/fips/::1#"* | "/fips/127.0.0.1#"*)
uci -q del_list dhcp.@dnsmasq[0].server="$server" 2>/dev/null
;;
esac
done
uci add_list dhcp.@dnsmasq[0].server="/fips/::1#${port}"
uci commit dhcp
# Restart dnsmasq to pick up the change.
/etc/init.d/dnsmasq restart 2>/dev/null || true
}