Files
fips/testing/docker/entrypoint.sh
T
Johnathan Corgan 65b92ed777 Derive the gateway's LAN interface in its container at every start
The gateway test wrote lan_interface: eth1 into the gateway config before
any container existed, on the assumption that Docker attaches the LAN
network as eth1. Docker does not promise that order, at the first start or
at later ones, and a wrong name that exists passes the gateway's startup
check. The LAN masquerade for inbound port forwards and the proxy NDP
entries for virtual IPs then went on the wrong interface, and nothing in
the suite noticed.

The gateway container's entrypoint now finds the interface holding the
gateway's LAN address and writes the gateway's config from it before
fips-gateway starts, so every docker start and restart re-derives it. The
config the suite writes carries only a placeholder.

The suite checks the running gateway's interface against its own
derivation after the first start and after each later start, checks that
the LAN masquerade and the proxy NDP entry are on it, and checks that a
restarted gateway's config carries the ttl and grace settings the later
phases rely on.

inject-config passes its values to Python as arguments rather than
splicing them into the program, and a failed config write now fails the
writer instead of reporting success.

The output parsers are checked against canned tool output by a new
selftest subcommand, which the suite also runs first as Phase 0.
2026-09-26 18:59:43 +00:00

346 lines
12 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# Unified entrypoint for FIPS test containers.
#
# Mode is selected via FIPS_TEST_MODE environment variable:
# default — dnsmasq + sshd + iperf3 + http server + fips
# chaos — above + TCP ECN + ethernet interface wait
# sidecar — generate config from env + iptables isolation + fips
# tor-socks5 — dnsmasq + sshd + fips (tor daemon is separate)
# tor-directory — dnsmasq + tor + wait for .onion hostname + fips
set -e
MODE="${FIPS_TEST_MODE:-default}"
CONFIG="/etc/fips/fips.yaml"
# ── Common: dnsmasq ──────────────────────────────────────────────────────
start_dnsmasq() {
dnsmasq
}
# ── Common: background services (sshd, iperf3, http) ────────────────────
start_services() {
/usr/sbin/sshd
iperf3 -s -D
python3 -m http.server 8000 -d /root -b :: &>/dev/null &
}
# ── Chaos: TCP ECN + ethernet wait ──────────────────────────────────────
enable_ecn() {
sysctl -w net.ipv4.tcp_ecn=1 >/dev/null 2>&1 || true
}
wait_for_ethernet() {
# If config references ethernet transports, wait for interfaces to appear.
# Veth pairs are created from the host after the container starts.
local eth_ifaces=""
if grep -q 'ethernet:' "$CONFIG" 2>/dev/null; then
eth_ifaces=$(grep '^\s*interface:' "$CONFIG" \
| sed 's/.*interface:\s*//' \
| tr -d ' ' || true)
fi
if [ -n "$eth_ifaces" ]; then
echo "Waiting for Ethernet interfaces: $eth_ifaces"
local deadline=$((SECONDS + 30))
local all_found=false
while [ $SECONDS -lt $deadline ]; do
all_found=true
for iface in $eth_ifaces; do
if [ ! -e "/sys/class/net/$iface" ]; then
all_found=false
break
fi
done
if $all_found; then
echo "All Ethernet interfaces ready"
break
fi
sleep 0.2
done
if ! $all_found; then
echo "WARNING: Timed out waiting for Ethernet interfaces"
fi
fi
}
# ── Sidecar: config generation + iptables isolation ─────────────────────
generate_sidecar_config() {
FIPS_NSEC="${FIPS_NSEC:?FIPS_NSEC is required}"
FIPS_UDP_BIND="${FIPS_UDP_BIND:-0.0.0.0:2121}"
FIPS_TUN_MTU="${FIPS_TUN_MTU:-1280}"
FIPS_PEER_TRANSPORT="${FIPS_PEER_TRANSPORT:-udp}"
mkdir -p /etc/fips
local peers_section=""
if [ -n "$FIPS_PEER_NPUB" ] && [ -n "$FIPS_PEER_ADDR" ]; then
FIPS_PEER_ALIAS="${FIPS_PEER_ALIAS:-peer}"
peers_section=" - npub: \"${FIPS_PEER_NPUB}\"
alias: \"${FIPS_PEER_ALIAS}\"
addresses:
- transport: ${FIPS_PEER_TRANSPORT}
addr: \"${FIPS_PEER_ADDR}\"
connect_policy: auto_connect"
fi
cat > "$CONFIG" <<EOF
node:
identity:
nsec: "${FIPS_NSEC}"
tun:
enabled: true
name: fips0
mtu: ${FIPS_TUN_MTU}
dns:
enabled: true
transports:
udp:
bind_addr: "${FIPS_UDP_BIND}"
mtu: 1472
tcp: {}
peers:
${peers_section:- []}
EOF
echo "Generated $CONFIG"
}
apply_iptables_isolation() {
# Only FIPS transport (UDP 2121, TCP 443) may use eth0.
# All other eth0 traffic is dropped. fips0 and loopback unrestricted.
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o eth0 -p udp --dport 2121 -j ACCEPT
iptables -A OUTPUT -o eth0 -p udp --sport 2121 -j ACCEPT
iptables -A INPUT -i eth0 -p udp --dport 2121 -j ACCEPT
iptables -A INPUT -i eth0 -p udp --sport 2121 -j ACCEPT
iptables -A OUTPUT -o eth0 -p tcp --dport 443 -j ACCEPT
iptables -A INPUT -i eth0 -p tcp --sport 443 -j ACCEPT
iptables -A OUTPUT -o eth0 -j DROP
iptables -A INPUT -i eth0 -j DROP
ip6tables -A OUTPUT -o lo -j ACCEPT
ip6tables -A INPUT -i lo -j ACCEPT
ip6tables -A OUTPUT -o fips0 -j ACCEPT
ip6tables -A INPUT -i fips0 -j ACCEPT
ip6tables -A OUTPUT -o eth0 -j DROP
ip6tables -A INPUT -i eth0 -j DROP
echo "iptables isolation rules applied"
}
# ── Tor directory mode: start tor + wait for hostname ────────────────────
start_tor_directory() {
local hidden_service_dir="/var/lib/tor/fips_onion_service"
local is_directory=false
if grep -qE '^\s+mode:\s+"directory"' "$CONFIG" 2>/dev/null; then
is_directory=true
fi
if [ "$is_directory" = true ]; then
mkdir -p "$hidden_service_dir"
chmod 700 "$hidden_service_dir"
fi
echo "Starting Tor daemon..."
tor -f /etc/tor/torrc &
if [ "$is_directory" = true ]; then
local hostname_file="${hidden_service_dir}/hostname"
echo "Waiting for Tor to create ${hostname_file}..."
for i in $(seq 1 120); do
if [ -f "$hostname_file" ]; then
echo "Tor hostname file ready after ${i}s: $(cat "$hostname_file")"
break
fi
sleep 1
done
if [ ! -f "$hostname_file" ]; then
echo "FATAL: Tor did not create hostname file within 120s"
exit 1
fi
fi
}
# ── Gateway: LAN interface and config copy ──────────────────────────────
# Print the one interface holding the IPv6 address $1. Docker may attach the
# LAN network after the container starts, so poll for up to 15 s. The rule is
# the one lan_iface uses in testing/static/scripts/gateway-test.sh: addresses
# compare as addresses, and an @ifN suffix is dropped from the name.
lanif_find() {
local name
for _ in $(seq 1 30); do
if name=$(ip -6 -o addr show | python3 -c '
import ipaddress, sys
want = ipaddress.ip_address(sys.argv[1])
holders = set()
for line in sys.stdin:
f = line.split()
if len(f) < 4 or f[2] != "inet6":
continue
try:
addr = ipaddress.ip_interface(f[3]).ip
except ValueError:
continue
if addr == want:
holders.add(f[1].split("@")[0])
if len(holders) != 1:
sys.exit(1)
print(holders.pop())
' "$1"); then
echo "$name"
return 0
fi
sleep 0.5
done
echo "FATAL: no single interface holds $1" >&2
ip -6 -o addr show >&2
return 1
}
# Write config $2 to $3 with its lan_interface set to $1. The source is a
# read-only bind mount, so fips-gateway reads this copy instead. The image has
# no YAML parser, so the edit is line-based, and it is refused unless the
# source has exactly one lan_interface line and the result names $1 on
# exactly one. A failure leaves $3 as it was.
gwconf_write() {
local iface="$1" src="$2" dest="$3"
local key='^[[:space:]]*lan_interface:'
local n
if ! [[ "$iface" =~ ^[A-Za-z0-9_.-]{1,15}$ ]]; then
rm -f "$dest.tmp"
echo "FATAL: invalid interface name $iface" >&2
return 1
fi
n=$(grep -cE "$key" "$src") || n=0
if [ "$n" -ne 1 ]; then
rm -f "$dest.tmp"
echo "FATAL: $src has $n lan_interface lines" >&2
return 1
fi
# The file carries the node's nsec. The umask is set in a subshell so it
# does not reach the daemons this script starts next.
if ! ( umask 077 && sed -E "s/^([[:space:]]*lan_interface:).*/\1 $iface/" "$src" > "$dest.tmp" ); then
rm -f "$dest.tmp"
echo "FATAL: could not write $dest.tmp" >&2
return 1
fi
n=$(grep -cE "$key" "$dest.tmp") || n=0
if [ "$n" -ne 1 ] || ! grep -qE "^[[:space:]]*lan_interface: ${iface//./\\.}\$" "$dest.tmp"; then
rm -f "$dest.tmp"
echo "FATAL: $dest.tmp does not hold exactly one lan_interface: $iface line" >&2
return 1
fi
mv -f "$dest.tmp" "$dest" || { rm -f "$dest.tmp"; return 1; }
return 0
}
# ── Mode dispatch ────────────────────────────────────────────────────────
case "$MODE" in
default)
start_dnsmasq
start_services
exec fips --config "$CONFIG"
;;
chaos)
enable_ecn
start_dnsmasq
start_services
wait_for_ethernet
exec fips --config "$CONFIG"
;;
sidecar)
generate_sidecar_config
apply_iptables_isolation
start_dnsmasq
exec fips --config "$CONFIG"
;;
tor-socks5)
start_dnsmasq
/usr/sbin/sshd
exec fips --config "$CONFIG"
;;
tor-directory)
start_dnsmasq
start_tor_directory
echo "Starting FIPS daemon..."
exec fips --config "$CONFIG"
;;
gateway)
# No dnsmasq — gateway DNS replaces it on port 53
start_services
# The LAN interface is the one holding the gateway's LAN address,
# derived at every start: Docker does not promise which ethN the LAN
# network gets, and a restart can change it. The config's own
# lan_interface is a placeholder; fips-gateway reads a copy that
# names the derived interface.
if [ -z "${FIPS_GW_LAN_ADDR:-}" ]; then
echo "FATAL: FIPS_GW_LAN_ADDR is not set"
exit 1
fi
LAN_IF=$(lanif_find "$FIPS_GW_LAN_ADDR") || exit 1
echo "LAN interface: $LAN_IF holds $FIPS_GW_LAN_ADDR"
gwconf_write "$LAN_IF" "$CONFIG" /etc/fips/gateway.yaml || exit 1
# Ensure IPv6 is enabled on the LAN interface (may inherit host
# default). The address was found on it before this runs only
# because compose sets net.ipv6.conf.default.disable_ipv6=0 for this
# service, so keep that sysctl if this one moves.
sysctl -w "net.ipv6.conf.${LAN_IF}.disable_ipv6=0" >/dev/null 2>&1 || true
sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1 || true
sysctl -w net.ipv6.conf.all.proxy_ndp=1 >/dev/null 2>&1 || true
# Start fips in background (gateway needs fips0)
fips --config "$CONFIG" &
# Wait for fips0 TUN device
for i in $(seq 1 30); do
[ -e /sys/class/net/fips0 ] && break
sleep 1
done
if [ ! -e /sys/class/net/fips0 ]; then
echo "FATAL: fips0 did not appear within 30s"
exit 1
fi
# Wait for the daemon's DNS responder to bind [::1]:5354 before
# exec'ing fips-gateway. The gateway binary's startup probe is
# bounded (5 attempts × 1s with retry); this harness wait is the
# belt to that suspenders so we get deterministic CI behaviour
# on slow runners. Bounded to ~30 seconds; if the daemon
# really never binds DNS, the gateway's own probe will report
# the definitive error after this wait expires.
for i in $(seq 1 30); do
if dig @::1 -p 5354 +tries=1 +time=1 test.fips >/dev/null 2>&1; then
echo "Daemon DNS ready (waited ~${i}s)"
break
fi
if [ "$i" -eq 30 ]; then
echo "WARNING: daemon DNS did not respond within ~30s; proceeding with gateway startup"
fi
sleep 1
done
echo "fips0 ready, starting gateway"
exec fips-gateway --config /etc/fips/gateway.yaml --log-level debug
;;
*)
echo "Unknown FIPS_TEST_MODE: $MODE"
echo "Valid modes: default, chaos, sidecar, tor-socks5, tor-directory, gateway"
exit 1
;;
esac