Files
fips/packaging/pfsense/fips-dns-setup
T
Johnathan Corgan 13c53785ad Correct the pfSense firmware-upgrade statement in the packaging docs
packaging/README.md and the pfSense builder's header said a firmware
upgrade removes the package, and the fips-dns-setup comment said the
same of everything under /usr/local. pfSense-upgrade reinstalls only
pfSense-pkg-* packages, and a live Plus 26.03.1 to 26.07 upgrade kept
this one, as the pfSense README, the post-install banner and pkg-descr
already say. A major base change still calls for the package built for
the new base.
2026-09-30 14:34:04 +00:00

225 lines
9.5 KiB
Bash
Executable File

#!/bin/sh
# fips-dns-setup - route .fips DNS queries to the FIPS daemon on pfSense.
#
# pfSense generates /var/unbound/unbound.conf from config.xml on every
# apply, and unbound.inc emits no conf.d include and no generic drop-in
# mechanism - the include: lines it writes name specific generated files
# (access_lists.conf, host_entries.conf, domainoverrides.conf, ...).
# So the stock FreeBSD fips-dns-setup, which drops a file into
# /var/unbound/conf.d/, configures nothing here: the file is never read
# and the next GUI apply does not so much as mention it.
#
# The writable surface pfSense does offer is the DNS Resolver "Custom
# options" box, which unbound.inc splices into the generated config
# verbatim. It is stored base64-encoded in config.xml, which is the part
# that makes it the right home: config.xml is what survives a reboot, a
# firmware upgrade and a config restore. What this package installs
# under /usr/local survives a firmware upgrade too (pfSense-upgrade
# reinstalls only pfSense-pkg-* packages), but not a removal of the
# package. So the .fips zone can outlive the daemon, which is a loud
# failure (SERVFAIL on .fips) rather than a quiet one.
#
# This edits the firewall's live configuration, so it is deliberately
# NOT run from the package's post-install: installing a package should
# not rewrite config.xml behind the operator's back. Run it by hand.
# write_config() records a config-history entry, so the edit is
# revertable from Diagnostics > Backup & Restore > Config History.
#
# Usage: fips-dns-setup [--config <fips.yaml>]
set -eu
FIPS_CONFIG="/usr/local/etc/fips/fips.yaml"
PHP_HELPER="/usr/local/libexec/fips/fips-unbound-custom.php"
while [ $# -gt 0 ]; do
case "$1" in
--config) FIPS_CONFIG="${2:?--config requires an argument}"; shift ;;
*) echo "usage: $0 [--config <fips.yaml>]" >&2; exit 1 ;;
esac
shift
done
log() { echo "fips-dns: $*"; }
[ -f /etc/inc/config.inc ] || {
log "ERROR: /etc/inc/config.inc not found - this is not a pfSense system."
log "ERROR: On stock FreeBSD use the FreeBSD package's fips-dns-setup."
exit 1
}
[ -f "$PHP_HELPER" ] || { log "ERROR: ${PHP_HELPER} is missing"; exit 1; }
[ -f "$FIPS_CONFIG" ] || { log "ERROR: ${FIPS_CONFIG} is missing"; exit 1; }
# Read dns.bind_addr and dns.port out of fips.yaml.
#
# Scoped to the top-level `dns:` block on purpose: `bind_addr` also
# appears under every transport, and a plain grep would happily hand
# back the UDP transport's 0.0.0.0:2121 and point unbound at it.
yaml_dns_field() {
awk -v field="$1" '
# A line starting in column 1 with a key opens a new top-level
# block; anything indented belongs to the block above it.
/^[^[:space:]#]/ { section = $1; sub(/:.*/, "", section); next }
section != "dns" { next }
{
line = $0
sub(/[[:space:]]*#.*$/, "", line) # strip trailing comment
if (line !~ "^[[:space:]]+" field ":") next
sub("^[[:space:]]+" field ":[[:space:]]*", "", line)
gsub(/^["'"'"']|["'"'"']$/, "", line) # strip quotes
if (line != "") { print line; exit }
}
' "$FIPS_CONFIG"
}
BIND_ADDR="$(yaml_dns_field bind_addr)"
DNS_PORT="$(yaml_dns_field port)"
# Defaults match src/upper/config.rs (DEFAULT_DNS_BIND_ADDR, DEFAULT_DNS_PORT).
[ -n "$BIND_ADDR" ] || BIND_ADDR="::1"
[ -n "$DNS_PORT" ] || DNS_PORT="5354"
# forward-addr needs a specific address to send to. A wildcard bind is
# valid for the daemon (it listens everywhere) but names nothing unbound
# could query, so refuse it here rather than write a forward-zone that
# can never answer.
case "$BIND_ADDR" in
0.0.0.0 | :: | '[::]')
log "ERROR: dns.bind_addr is ${BIND_ADDR}, a wildcard. unbound's forward-addr"
log "ERROR: needs one address to send to. Set in ${FIPS_CONFIG}:"
log "ERROR: dns:"
log "ERROR: bind_addr: \"127.0.0.1\""
log "ERROR: then restart: /usr/local/etc/rc.d/fips.sh restart && $0"
exit 1
;;
esac
# unbound wants a bare address in forward-addr; the @port suffix carries
# the port, and pfSense's own domain-override code uses the same form.
FORWARD_ADDR="${BIND_ADDR}@${DNS_PORT}"
case "$BIND_ADDR" in
*:*)
# An IPv6 bind means unbound has to query over IPv6, and pfSense
# writes `do-ip6: {$ipv6_allow}` from the system "Allow IPv6"
# setting. With IPv6 disallowed, do-ip6: no makes every .fips
# query fail with nothing in the log to say why. `do-ip6: yes`
# in custom options cannot be relied on to override the
# generated one, so point the operator at the fix that always
# works: bind the responder on IPv4 loopback.
log "NOTE: dns.bind_addr is ${BIND_ADDR} (IPv6). unbound only queries over"
log "NOTE: IPv6 when System > Advanced > Networking has 'Allow IPv6' set."
log "NOTE: If .fips does not resolve, set in ${FIPS_CONFIG}:"
log "NOTE: dns:"
log "NOTE: bind_addr: \"127.0.0.1\""
log "NOTE: then restart: /usr/local/etc/rc.d/fips.sh restart && $0"
;;
esac
# Refuse to touch config.xml for a daemon that is not answering. Without
# this the script edits the firewall's configuration, restarts unbound
# and reports success while nothing is listening — which is a far worse
# failure than declining, because the operator then has a healthy-looking
# resolver forwarding into a void and no message saying so.
wait_for_daemon() {
# sockstat prints IPv6 endpoints bracketed ([::1]:5354) and IPv4 bare.
case "$BIND_ADDR" in
*:*) SOCK_ADDR="[${BIND_ADDR}]" ;;
*) SOCK_ADDR="$BIND_ADDR" ;;
esac
i=0
while [ "$i" -lt 30 ]; do
sockstat -l -p "$DNS_PORT" 2>/dev/null \
| grep -Fq "${SOCK_ADDR}:${DNS_PORT}" && return 0
sleep 1
i=$((i + 1))
done
log "ERROR: nothing is listening on ${BIND_ADDR}:${DNS_PORT} after 30s."
log "ERROR: config.xml has NOT been modified. Check the daemon first:"
log "ERROR: /usr/local/etc/rc.d/fips.sh status"
log "ERROR: tail -50 /var/log/fips.log"
log "ERROR: and confirm dns.enabled is true in ${FIPS_CONFIG}."
return 1
}
# A resolver can be configured perfectly and still never be asked. If the
# firewall's own stub resolver points at a public server rather than at
# unbound, .fips queries leave the box and come back NXDOMAIN from the
# root servers, with nothing in unbound's log because unbound never saw
# them. `drill <npub>.fips` reporting SERVER: 8.8.8.8 is exactly this.
warn_if_not_system_resolver() {
if grep -Eq '^[[:space:]]*nameserver[[:space:]]+(127\.0\.0\.1|::1)' \
/etc/resolv.conf 2>/dev/null; then
return 0
fi
log "WARNING: /etc/resolv.conf has no 127.0.0.1/::1 nameserver, so this"
log "WARNING: firewall does not ask its own resolver — .fips queries go"
log "WARNING: straight to the upstream servers and come back NXDOMAIN."
log "WARNING: The forward-zone just written is correct but unreachable."
log "WARNING:"
log "WARNING: Fix in the GUI: System > General Setup > DNS Server Settings,"
log "WARNING: set 'DNS Resolution Behavior' to"
log "WARNING: Use local DNS (127.0.0.1), fall back to remote DNS servers"
log "WARNING: and make sure Services > DNS Resolver is enabled."
log "WARNING:"
log "WARNING: Current /etc/resolv.conf nameservers:"
grep -E '^[[:space:]]*nameserver' /etc/resolv.conf 2>/dev/null \
| sed 's/^/fips-dns: /' || true
return 0
}
wait_for_daemon || exit 1
log "Pointing the fips. zone at ${FORWARD_ADDR} via DNS Resolver custom options"
# The snippet itself. Three directives beyond the forward-zone, each one
# load-bearing:
#
# domain-insecure - the .fips zone is unsigned, and pfSense enables
# DNSSEC validation by default; without this every
# answer is thrown away as bogus.
# do-not-query-localhost: no
# - unbound's default is to refuse loopback
# forwarders outright, which SERVFAILs every
# .fips query instead of asking the daemon.
# pfSense's unbound.inc never sets this.
# forward-first: no - never fall back to the public resolvers for a
# name the daemon declined; .fips does not exist
# outside the mesh, and leaking the query would
# publish which npubs this firewall talks to.
SNIPPET="$(cat <<EOS
server:
domain-insecure: "fips."
do-not-query-localhost: no
forward-zone:
name: "fips."
forward-addr: ${FORWARD_ADDR}
forward-first: no
# Custom options are spliced in last, so anything added below the END
# marker would otherwise continue this forward-zone clause.
server:
EOS
)"
# `--` separates the script's arguments from php's own options; with -f,
# php is entitled to read what follows the filename as its own flags.
printf '%s\n' "$SNIPPET" | /usr/local/bin/php -f "$PHP_HELPER" -- add "$BIND_ADDR" "$DNS_PORT"
rc=$?
case "$rc" in
0) ;;
3) # Written, but the DNS Resolver is disabled: the block is inert and
# the helper has already said what to do instead. Nothing to verify.
exit 0 ;;
*) exit "$rc" ;;
esac
# The block is in place; whether anything will ever ask unbound for it
# is a separate question, and the answer is not always yes.
warn_if_not_system_resolver
log "Done. Verify with:"
log " drill -p ${DNS_PORT} <npub>.fips @${BIND_ADDR} AAAA # the daemon directly"
log " drill <npub>.fips AAAA # the full chain"