Files
fips/packaging/pfsense/fips.sh
T
fr34akyandJohnathan Corgan 429d77731b add a pfSense package
pfSense is FreeBSD underneath, but the FreeBSD package does not work
there, failing in three silent ways. pfSense runs only
/usr/local/etc/rc.d/*.sh at boot and re-runs them when WAN gets a new
address, so a suffixless rc script never starts; unbound.conf is
generated from config.xml with no conf.d, so a drop-in is never read;
and on a firewall where the default-on "Allow IPv6" has been turned off,
unbound is then generated with do-ip6: no and a responder on ::1 is
unreachable. So this ships fips.sh, wires the fips. zone into the DNS
Resolver through config.xml, and binds the responder on 127.0.0.1 for
robustness against that last case.

The rc script is plain sh: what pfSense imposes is the .sh name and that
a re-run leave a running daemon alone and exit 0. It identifies the
daemon by process name and recovers an orphaned daemon(8) supervisor
found via fstat, since a locked empty pidfile makes daemon(8) report
pid -1. The DNS setup is a manual step, never run from post-install, and
validates the merged options with unbound-checkconf (pfSense's
test_unbound_config) before touching config.xml, so a bad merge cannot
take DNS from every client behind the firewall. The daemon runs under
daemon(8) -H so newsyslog can rotate its log by signalling a reopen.

Packages link statically by default: pfSense runs a FreeBSD base that
cannot be obtained to link against. A firmware upgrade keeps the package
(pfSense-upgrade removes only pfSense-pkg-*; confirmed on a live Plus
26.03.1 -> 26.07 upgrade, aarch64 — the package survived and the daemon
restarted at boot. That is a minor, FreeBSD 16 -> 16 change; the
cross-major compat case is still only source-reasoned). aarch64 is
refused, where a static binary faults at posix_spawn.

The mechanics the two builders share — version derivation, the stage
layout, the manifest fields, the @sample scripts and pkg create — live
in packaging/common/pkg-lib.sh, which both source; the FreeBSD package
is byte-identical before and after that extraction.

One ABI can serve more than one product: CE 2.9 and Plus 26.x on Intel
are both FreeBSD:16:amd64 with a byte-identical artifact, named
...-ce2.9-plus26-amd64.pkg. The pfSense package is built and checked in
its own CI job — separate from the FreeBSD package, and not a dependency
of the release job, so a pfSense-only failure reds that job alone and is
never a release asset. It is kept as a workflow artifact until it has
been installed on a real pfSense box. CI produces the CE 2.8.1
(FreeBSD:15:amd64) package; CE 2.9, Plus 26.x Intel and ARM need a
FreeBSD 16 build host the CI does not have, and ARM stays
build-it-yourself because rustup ships no toolchain for it.

testing/check-pfsense-pkg.sh validates a built package on any FreeBSD
host and runs in that CI job: contents, modes, a positive boot-script
lifecycle against a stub daemon, php -l and a fips_strip_block unit test
of the config.xml helper. Installing on a real pfSense box, and the
firmware-upgrade behaviour, are covered only by an aarch64 hardware run
and pfSense-upgrade's source; the README records what is and is not
tested.

Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com>
2026-09-12 14:41:38 +00:00

303 lines
12 KiB
Bash
Executable File

#!/bin/sh
# fips.sh - start/stop the FIPS mesh daemon on pfSense.
#
# pfSense does not drive rc.d(8) the way stock FreeBSD does. At the end
# of boot, rc.start_packages globs /usr/local/etc/rc.d/*.sh and runs
# each match as `<script> start`, backgrounded, with output appended to
# /tmp/bootup_messages. The same glob runs again whenever WAN gets a
# new address (rc.newwanip -> "service reload packages" -> rc.start_packages),
# so `start` here must be idempotent: a re-run must neither fork a second
# daemon nor disturb the one running. A link flap on a static-address
# interface does not reach rc.newwanip; an address change does. Either
# way the daemon handles medium changes itself (src/node/netmon/), so
# there is nothing to restart when an interface moves.
#
# Two consequences shape this script:
#
# - The `.sh` suffix is what makes pfSense run it at all. The stock
# FreeBSD package installs `/usr/local/etc/rc.d/fips` with no
# suffix, which pfSense would never start.
# - Plain sh rather than rc.subr. rc.subr would also work here — it
# reads rc.conf.d/<name> as well as /etc/rc.conf, and a script may
# default its own rcvar to YES — so this is a choice, not a need.
# What pfSense actually imposes is the `.sh` name and that `start`
# be re-run while the daemon is up: at boot, and again whenever WAN
# gets a new address (rc.newwanip -> "service reload packages" ->
# /etc/rc.start_packages). A re-run must leave a healthy daemon
# alone and exit 0 quietly; keeping that in plain sh makes exactly
# what happens on a re-run visible in this file. The enable knob
# lives in fips.conf beside the daemon's own configuration.
#
# Usage: fips.sh start|stop|restart|status|onestart
# onestart starts regardless of fips_enable (mirrors rc.subr's one*).
#
# See packaging/pfsense/README.md for installation and caveats.
set -u
fips_conf="/usr/local/etc/fips/fips.conf"
# shellcheck source=/dev/null
[ -r "$fips_conf" ] && . "$fips_conf"
: "${fips_enable:=YES}"
: "${fips_config:=/usr/local/etc/fips/fips.yaml}"
: "${fips_logfile:=/var/log/fips.log}"
: "${fips_flags:=}"
procname="/usr/local/bin/fips"
daemon_title="fips"
newsyslog_src="/usr/local/etc/fips/fips.newsyslog"
runtime_dir="/var/run/fips"
pidfile="${runtime_dir}/fips.pid"
# daemon(8) supervises the child; keeping its pid separately means stop
# can reap the supervisor too instead of leaving it parented to init.
supervisor_pidfile="${runtime_dir}/daemon.pid"
daemon_err="${runtime_dir}/daemon.err"
log() {
echo "fips: $*"
# Also to syslog: at boot and on package reloads stdout lands in
# /tmp/bootup_messages, which nobody watches; operators read the
# system log.
logger -t fips -p daemon.notice -- "$*" 2>/dev/null || :
}
is_enabled() {
case "$fips_enable" in
[Yy][Ee][Ss] | [Tt][Rr][Uu][Ee] | [Oo][Nn] | 1) return 0 ;;
esac
return 1
}
# Echoes the running daemon's pid and returns 0, or returns 1.
#
# Existence and identity are both settled by ps: a bare kill -0 would
# fail with EPERM for a caller who does not own the daemon, and would
# not catch a stale pidfile whose number has been recycled by an
# unrelated process — which would make `start` a silent no-op forever.
#
# The read is deliberately NOT guarded with `|| return 1`. daemon(8)
# writes the pid with no trailing newline, so read reaches EOF and
# returns non-zero having nonetheless read the value. Guarding it made
# this function report "not running" for a perfectly healthy daemon,
# which in turn made status always wrong, stop find nothing to stop,
# restart never come back up, and every start orphan another supervisor.
running_pid() {
[ -r "$pidfile" ] || return 1
_pid=""
read -r _pid < "$pidfile" 2>/dev/null || :
case "${_pid:-}" in
'' | *[!0-9]*) return 1 ;;
esac
[ "$(ps -p "$_pid" -o comm= 2>/dev/null)" = "fips" ] || return 1
echo "$_pid"
return 0
}
# The daemon resolves its control socket to /var/run/fips when the
# directory exists, so it must be there before the daemon starts.
# root:fips 0750 lets members of the fips group run fipsctl and fipstop
# without root; the group is created by the package post-install. Fall
# back to 0755 where the group is absent (e.g. a source install).
prepare_runtime_dir() {
# Log rotation. pfSense writes /etc/newsyslog.conf itself, with an
# include of /var/etc/newsyslog.conf.d/*, and on a box with RAM disks
# /var/etc is rebuilt at every boot — so a rotation entry installed
# there by pkg would be gone after a reboot. Re-copy it here instead:
# start runs after pfSense has set up /var/etc, and the copy is cheap.
if [ -d /var/etc/newsyslog.conf.d ] && [ -r "$newsyslog_src" ]; then
install -m 0644 "$newsyslog_src" /var/etc/newsyslog.conf.d/fips.conf 2>/dev/null || :
fi
if pw groupshow fips >/dev/null 2>&1; then
install -d -m 0750 -o root -g fips "$runtime_dir"
else
install -d -m 0755 "$runtime_dir"
fi
return 0
}
# Launch daemon(8), leaving its diagnostics in $daemon_err for the caller.
#
# stderr goes to a file rather than through a command substitution: the
# supervisor daemon(8) leaves behind inherits the pipe and never closes
# it, so `out=$(daemon ...)` blocks until the daemon exits — which is to
# say for ever, and on pfSense that would hang rc.start_packages at boot.
# shellcheck disable=SC2086 # fips_flags is deliberately unquoted:
# it carries zero or more separate arguments from fips.conf, and
# quoting it would pass the lot as one argument.
start_daemon() {
: > "$daemon_err" 2>/dev/null || :
# -H: close and reopen the -o log file on SIGHUP, which is how the
# newsyslog.conf.d entry signals a rotation (its pid_file is the
# supervisor's -P file). Without it the daemon writes into the rotated,
# eventually-unlinked inode.
/usr/sbin/daemon -H -p "$pidfile" -P "$supervisor_pidfile" -t "$daemon_title" \
-o "$fips_logfile" \
"$procname" --config "$fips_config" ${fips_flags} 2>>"$daemon_err"
}
# Whatever daemon(8) last complained about, as one line.
daemon_error() {
[ -r "$daemon_err" ] && tr '\n' ' ' < "$daemon_err" | sed 's/ */ /g; s/ $//'
return 0
}
do_start() {
if pid=$(running_pid); then
log "already running as pid ${pid}"
return 0
fi
if [ ! -x "$procname" ]; then
log "ERROR: ${procname} is missing or not executable"
return 1
fi
if [ ! -r "$fips_config" ]; then
log "ERROR: config ${fips_config} is missing or unreadable"
return 1
fi
prepare_runtime_dir
if start_daemon; then
log "started (log: ${fips_logfile})"
return 0
fi
# daemon(8) refused. The case worth recovering from is an orphaned
# supervisor holding the pidfile lock, left behind by a child that
# died during startup — running_pid() has already established that no
# daemon of ours is alive, so nothing that matters owns that lock.
#
# daemon(8) names the offending pid ("process already running, pid:
# N"), so kill exactly that, after confirming it is a daemon(8).
# Earlier versions swept by process title instead, which was worse
# than the problem twice over: done before every start it killed the
# supervisor that had just been started (pfSense re-enters start on
# every interface event, and daemon(8) forwards the signal to its
# child, so the daemon shut down cleanly seconds after starting with
# nothing in the log to say why); and done after any failure it could
# kill an unrelated daemon(8) that merely shared the title.
case "$(daemon_error)" in
*"already running"*) ;;
*)
log "ERROR: daemon(8) failed to start ${procname}"
[ -n "$(daemon_error)" ] && log "ERROR: $(daemon_error)"
return 1
;;
esac
# daemon(8) names the pid it read from the pidfile. For a locked file
# that holds no pid — the orphan case — pidfile_read yields -1, so
# "pid: -1" is what it prints, and parsing that leads nowhere. Ask the
# kernel instead: the supervisor keeps the pidfile open to hold the
# lock, and fstat(1) names the process and its command.
stale="$(fstat "$pidfile" 2>/dev/null | awk 'NR > 1 && $2 == "daemon" { print $3; exit }')"
if [ -z "$stale" ]; then
log "ERROR: daemon(8) says the pidfile is locked, but no daemon(8) holds ${pidfile}"
log "ERROR: $(daemon_error)"
holder="$(fstat "$pidfile" 2>/dev/null | awk 'NR > 1 { print $2 " (pid " $3 ")"; exit }')"
[ -n "$holder" ] && log "ERROR: it is held by ${holder}; not touching it"
return 1
fi
log "clearing an orphaned supervisor (pid ${stale}) and retrying"
kill -TERM "$stale" 2>/dev/null
i=0
while [ "$i" -lt 5 ] && kill -0 "$stale" 2>/dev/null; do
sleep 1
i=$((i + 1))
done
rm -f "$supervisor_pidfile" "$pidfile"
if start_daemon; then
log "started after clearing an orphaned supervisor (log: ${fips_logfile})"
return 0
fi
log "ERROR: daemon(8) failed to start ${procname}"
[ -n "$(daemon_error)" ] && log "ERROR: $(daemon_error)"
return 1
}
do_stop() {
pid=$(running_pid) || {
log "not running"
# Nothing is running, but daemon(8) may be: clear it either way.
stop_supervisor
rm -f "$pidfile"
return 0
}
kill -TERM "$pid" 2>/dev/null
i=0
while [ "$i" -lt 15 ]; do
kill -0 "$pid" 2>/dev/null || break
sleep 1
i=$((i + 1))
done
if kill -0 "$pid" 2>/dev/null; then
log "WARNING: pid ${pid} did not exit within 15s; sending SIGKILL"
kill -KILL "$pid" 2>/dev/null
sleep 1
fi
stop_supervisor
rm -f "$pidfile"
log "stopped"
return 0
}
# daemon(8) exits once its child does, so this is normally a no-op. It
# matters in two cases that are not no-ops at all.
#
# After a SIGKILL the supervisor can outlive the child. And when the
# child dies during startup, daemon(8) can be left holding a pidfile it
# created and locked but never wrote a pid into — an *empty* file. A
# `read` from an empty file returns non-zero, so guarding the read with
# `|| return` skips both the kill and the cleanup, which leaves an
# orphaned supervisor holding the lock. Every later start then fails with
# daemon(8)'s "process already running", naming a pid that is not the
# daemon and cannot be stopped through this script. On pfSense, where
# start is re-run on every interface event, that is permanent.
#
# So: never early-return. Recovering an orphan the pidfile cannot name is
# do_start's job, from the pid daemon(8) itself reports.
stop_supervisor() {
if [ -r "$supervisor_pidfile" ]; then
_sup=""
read -r _sup < "$supervisor_pidfile" 2>/dev/null || :
case "${_sup:-}" in
'' | *[!0-9]*) ;;
*) kill -TERM "$_sup" 2>/dev/null ;;
esac
fi
rm -f "$supervisor_pidfile"
return 0
}
case "${1:-}" in
start)
if ! is_enabled; then
# Boot and every interface event land here; stay quiet about
# a deliberate opt-out rather than filling bootup_messages.
exit 0
fi
do_start
;;
onestart)
do_start
;;
stop | onestop)
do_stop
;;
restart | onerestart)
do_stop
do_start
;;
status | onestatus)
if pid=$(running_pid); then
log "running as pid ${pid}"
exit 0
fi
log "not running"
exit 1
;;
*)
echo "usage: $0 start|stop|restart|status|onestart" >&2
exit 64
;;
esac