mirror of
https://github.com/jmcorgan/fips.git
synced 2026-09-14 00:45:08 +00:00
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>
This commit is contained in:
committed by
Johnathan Corgan
co-authored by
Johnathan Corgan
parent
38622e5dac
commit
429d77731b
@@ -227,6 +227,99 @@ jobs:
|
||||
echo "Build Summary for freebsd/x86_64:"
|
||||
echo " Package: ${{ steps.freebsd-assets.outputs.pkg }}"
|
||||
|
||||
pfsense:
|
||||
name: Build and check the pfSense package (x86_64)
|
||||
# A job of its own, and deliberately NOT a dependency of `release`. A
|
||||
# pfSense-only failure — a new key in the common dns: block, a
|
||||
# dependency that stops linking statically, a checker regression — reds
|
||||
# this check and nothing else; it can never hide behind the FreeBSD
|
||||
# job's result, and it cannot block the FreeBSD release asset. The
|
||||
# pfSense package is therefore never a release asset. It is published
|
||||
# here as a workflow artifact (30-day retention) for anyone to test,
|
||||
# until someone has installed it on a real pfSense box; see
|
||||
# packaging/pfsense/README.md.
|
||||
#
|
||||
# This VM is FreeBSD 15.1, so the package it produces is FreeBSD:15:amd64
|
||||
# (pfSense CE 2.8.1). CE 2.9 and Plus 26.x are FreeBSD 16 and need a
|
||||
# FreeBSD 16 host this workflow does not have. No aarch64 package is built
|
||||
# here or published anywhere: rustup ships no toolchain for
|
||||
# aarch64-unknown-freebsd, so such a build cannot honour the
|
||||
# rust-toolchain.toml pin every published artifact is built with. ARM is
|
||||
# build-it-yourself, per the README.
|
||||
runs-on: ubuntu-latest
|
||||
needs: determine-versioning
|
||||
# Its own full release build in an emulated FreeBSD VM, like the build
|
||||
# job; the same generous bound applies.
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set SOURCE_DATE_EPOCH from git
|
||||
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and check the pfSense package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@f0552d3b69211736abd97f02ff3d4674c56b73b1 # v1
|
||||
env:
|
||||
FREEBSD_PACKAGE_VERSION: ${{ needs.determine-versioning.outputs.freebsd_package_version }}
|
||||
with:
|
||||
release: "15.1"
|
||||
usesh: true
|
||||
sync: rsync
|
||||
copyback: true
|
||||
mem: 6144
|
||||
envs: "SOURCE_DATE_EPOCH CARGO_TERM_COLOR FREEBSD_PACKAGE_VERSION"
|
||||
prepare: |
|
||||
# curl for rustup; bash and php for testing/check-pfsense-pkg.sh
|
||||
# (the checker is bash, and it runs php -l plus a fips_strip_block
|
||||
# unit test on the config.xml helper).
|
||||
pkg install -y curl bash php85
|
||||
run: |
|
||||
set -e
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain none --profile minimal
|
||||
. "$HOME/.cargo/env"
|
||||
|
||||
# Builds the release binaries (static by default) and packages
|
||||
# them; on a FreeBSD 15.1 VM this yields the FreeBSD:15:amd64
|
||||
# package for pfSense CE 2.8.1.
|
||||
packaging/pfsense/build-pkg.sh --version "$FREEBSD_PACKAGE_VERSION"
|
||||
|
||||
PKG=$(ls deploy/fips-*-pfsense-*.pkg)
|
||||
testing/check-pfsense-pkg.sh "$PKG"
|
||||
php -l packaging/pfsense/fips-unbound-custom.php
|
||||
|
||||
( cd deploy && sha256 -q "$(basename "$PKG")" \
|
||||
| { read -r h; printf '%s %s\n' "$h" "$(basename "$PKG")"; } \
|
||||
> "$(basename "$PKG").sha256" )
|
||||
|
||||
rm -rf target
|
||||
|
||||
- name: Resolve pfSense asset path
|
||||
id: pfsense-asset
|
||||
shell: bash
|
||||
run: |
|
||||
: ${GITHUB_OUTPUT:=/tmp/github_output}
|
||||
set -euo pipefail
|
||||
VER="${{ needs.determine-versioning.outputs.freebsd_pkg_file_version }}"
|
||||
PKG=$(ls deploy/fips-${VER}-pfsense-*.pkg)
|
||||
if [[ ! -f "$PKG" ]]; then
|
||||
echo "No pfSense package was produced" >&2
|
||||
ls -la deploy >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "pkg=$PKG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload pfSense artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: fips_${{ needs.determine-versioning.outputs.freebsd_package_version }}_x86_64_pfsense
|
||||
path: |
|
||||
${{ steps.pfsense-asset.outputs.pkg }}
|
||||
${{ steps.pfsense-asset.outputs.pkg }}.sha256
|
||||
retention-days: 30
|
||||
|
||||
release:
|
||||
name: Publish FreeBSD assets to GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
@@ -239,6 +332,11 @@ jobs:
|
||||
- name: Download FreeBSD artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
# Only the FreeBSD artifact. Without a pattern this action downloads
|
||||
# every artifact in the run — including the pfSense package from the
|
||||
# `pfsense` job, which is a workflow artifact by design and must never
|
||||
# reach a release. `needs` only orders jobs; it does not scope this.
|
||||
pattern: fips_*_x86_64_freebsd
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
@@ -248,6 +346,15 @@ jobs:
|
||||
set -euo pipefail
|
||||
cd dist
|
||||
|
||||
# The pfSense package is never a release asset (see the `pfsense`
|
||||
# job). The download step is scoped to the FreeBSD artifact; this
|
||||
# keeps the rule if that artifact is ever renamed or a new one added.
|
||||
if compgen -G '*-pfsense-*' >/dev/null; then
|
||||
echo "FAIL: a pfSense package reached the release stage; it must stay a workflow artifact:" >&2
|
||||
ls -la ./*-pfsense-* >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pkgs=$(find . -maxdepth 1 -type f -name '*.pkg' | LC_ALL=C sort)
|
||||
if [[ -z "$pkgs" ]]; then
|
||||
echo "FAIL: no .pkg artifacts were downloaded" >&2
|
||||
|
||||
@@ -137,6 +137,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
a typo'd interface name were previously the same flat
|
||||
`StartFailed(String)`; nothing downstream could branch on absence.
|
||||
|
||||
#### Packaging
|
||||
|
||||
- A pfSense package (`packaging/pfsense/`, `gmake pfsense`). pfSense is
|
||||
FreeBSD underneath, but the FreeBSD package fails there in three
|
||||
silent ways: pfSense runs only `/usr/local/etc/rc.d/*.sh` at boot and
|
||||
re-runs them on WAN IP changes, so the suffixless rc script never
|
||||
starts; `unbound.conf` is generated from `config.xml` with no `conf.d`,
|
||||
so the DNS drop-in is never read; and — on a firewall where the
|
||||
default-on "Allow IPv6" has been turned off — the responder's `::1`
|
||||
default is unreachable when unbound is then generated with
|
||||
`do-ip6: no`, so it binds `127.0.0.1` for robustness. The package
|
||||
ships `fips.sh`, wires the `fips.` zone into the DNS Resolver through
|
||||
`config.xml`, and binds the responder on `127.0.0.1`. It links
|
||||
statically by default, since pfSense runs a FreeBSD base that cannot
|
||||
be obtained to link against; aarch64 is refused, where static binaries
|
||||
fault at `posix_spawn`. Mechanics shared with the FreeBSD builder live
|
||||
in `packaging/common/pkg-lib.sh`, which both source; the FreeBSD
|
||||
package is byte-identical before and after. The pfSense package is
|
||||
built and checked in its own CI job and published as a workflow
|
||||
artifact, not attached to a release, until it has been installed on a
|
||||
real pfSense box; CI produces the CE 2.8.1 (`FreeBSD:15:amd64`)
|
||||
package, while CE 2.9 and Plus 26.x on Intel need a FreeBSD 16 build
|
||||
host the CI does not have, and ARM stays build-it-yourself because
|
||||
rustup ships no toolchain for it. See `packaging/pfsense/README.md`.
|
||||
|
||||
### Changed
|
||||
|
||||
- `Degraded` is now a level rather than a latch. The supervisor's reason set
|
||||
|
||||
@@ -132,7 +132,8 @@ default `/etc/fips/fips.yaml` you can edit before starting. The package
|
||||
enables `fips` and `fips-dns` but starts neither, which is why the
|
||||
second command is there.
|
||||
|
||||
For macOS, Windows, FreeBSD, OpenWrt, the systemd tarball or a Nix
|
||||
For macOS, Windows, FreeBSD (including a pfSense build under
|
||||
`packaging/pfsense/`), OpenWrt, the systemd tarball or a Nix
|
||||
flake, see [docs/getting-started.md](docs/getting-started.md)
|
||||
for the full multi-platform installation guide.
|
||||
|
||||
|
||||
@@ -88,6 +88,12 @@ included: its NAT backend is nftables, which is Linux-only. The
|
||||
Ethernet and BLE transports are unavailable on FreeBSD; UDP, TCP,
|
||||
Tor, and Nym are.
|
||||
|
||||
On **pfSense** (CE or Plus) use `packaging/pfsense/` rather than this
|
||||
package: pfSense diverges from stock FreeBSD in how it boots packages,
|
||||
generates the DNS resolver config, and applies upgrades, and the pfSense
|
||||
package handles each. See
|
||||
[packaging/pfsense/README.md](../packaging/pfsense/README.md).
|
||||
|
||||
**One architecture.** The published artifact is
|
||||
`fips-<version>-freebsd-amd64.pkg`. There is no aarch64 FreeBSD
|
||||
build, so on any other architecture use the from-source path below.
|
||||
|
||||
+8
-1
@@ -12,6 +12,7 @@
|
||||
# make aur Build fips-git AUR package and validate with namcap
|
||||
# make pkg Build a macOS .pkg installer
|
||||
# make freebsd Build a FreeBSD .pkg package (on FreeBSD; use gmake)
|
||||
# make pfsense Build a pfSense .pkg package (on FreeBSD; use gmake)
|
||||
# make zip Build a Windows .zip package
|
||||
# make all Build deb and tarball (default)
|
||||
# make clean Remove deploy/ directory
|
||||
@@ -20,7 +21,7 @@ SHELL := /bin/bash
|
||||
PACKAGING_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
PROJECT_ROOT := $(abspath $(PACKAGING_DIR)/..)
|
||||
|
||||
.PHONY: all deb deb-host tarball ipk apk aur pkg freebsd zip clean
|
||||
.PHONY: all deb deb-host tarball ipk apk aur pkg freebsd pfsense zip clean
|
||||
|
||||
all: deb tarball
|
||||
|
||||
@@ -57,6 +58,12 @@ pkg:
|
||||
freebsd:
|
||||
@sh $(PACKAGING_DIR)/freebsd/build-pkg.sh
|
||||
|
||||
# pfSense is FreeBSD underneath but boots, resolves and is upgraded
|
||||
# differently enough that the FreeBSD package does not work there; see
|
||||
# packaging/pfsense/README.md for the three divergences.
|
||||
pfsense:
|
||||
@sh $(PACKAGING_DIR)/pfsense/build-pkg.sh
|
||||
|
||||
zip:
|
||||
@powershell -File $(PACKAGING_DIR)/windows/build-zip.ps1
|
||||
|
||||
|
||||
+56
-1
@@ -14,6 +14,7 @@ make apk # OpenWrt .apk (apk-tools, mandatory on OpenWrt 25+)
|
||||
make aur # Arch Linux AUR package (fips-git, local build + namcap)
|
||||
make pkg # macOS .pkg installer
|
||||
make freebsd # FreeBSD .pkg package (on FreeBSD; use gmake)
|
||||
make pfsense # pfSense .pkg package (on FreeBSD; use gmake)
|
||||
make zip # Windows .zip package
|
||||
make all # deb + tarball (default)
|
||||
```
|
||||
@@ -70,9 +71,11 @@ runtime dependency and is not needed to build.
|
||||
```text
|
||||
packaging/
|
||||
aur/ Arch Linux AUR packaging (PKGBUILD, supporting files)
|
||||
common/ Shared assets (default config, hosts file)
|
||||
common/ Shared assets (default config, hosts file) and pkg-lib.sh,
|
||||
the helpers the FreeBSD and pfSense builders share
|
||||
debian/ Debian/Ubuntu .deb packaging via cargo-deb
|
||||
freebsd/ FreeBSD .pkg packaging via pkg-create(8)
|
||||
pfsense/ pfSense .pkg packaging (FreeBSD-based, but not the same)
|
||||
macos/ macOS .pkg installer via pkgbuild
|
||||
nixos/ NixOS flake module (services.fips.*)
|
||||
systemd/ Generic Linux systemd tarball packaging
|
||||
@@ -212,6 +215,58 @@ service fips_dns start
|
||||
See [freebsd/README.md](freebsd/README.md) for host resolver setup and
|
||||
field-tested caveats.
|
||||
|
||||
### pfSense (`.pkg`)
|
||||
|
||||
pfSense is FreeBSD underneath, but the FreeBSD package does not work
|
||||
there, and fails silently in three ways: pfSense boots packages by
|
||||
globbing `/usr/local/etc/rc.d/*.sh` (a suffixless rc script is never
|
||||
run), it generates `unbound.conf` from `config.xml` and reads no
|
||||
`conf.d` directory (the DNS drop-in is never read), and it writes
|
||||
`do-ip6: no` unless "Allow IPv6" is enabled (so a responder on `[::1]`
|
||||
is unreachable). This package ships `fips.sh`, integrates DNS through
|
||||
the DNS Resolver custom options in `config.xml`, and binds the
|
||||
responder on `127.0.0.1`.
|
||||
|
||||
Unlike the other packages, this one **links statically by default**
|
||||
(`--dynamic` opts out). pfSense runs a FreeBSD base you cannot
|
||||
obtain — Netgate builds Plus from its own 16.0-CURRENT snapshot — so
|
||||
a dynamically linked binary can reference a libc symbol the appliance
|
||||
does not export, install cleanly, and then refuse to start. A static
|
||||
package declares no shared libraries at all.
|
||||
|
||||
**On aarch64 this is refused, not applied.** A statically linked
|
||||
aarch64 FreeBSD binary faults where `posix_spawn` should be, so the
|
||||
daemon dies the first time it shells out. ARM builds must pass
|
||||
`--dynamic`, and then `ldd` on the appliance is the check that the
|
||||
base drift is not real.
|
||||
|
||||
The build host's architecture and FreeBSD major must still match the
|
||||
target's: pfSense CE 2.8.1 is FreeBSD 15 amd64; CE 2.9.0 and Plus 26.x are
|
||||
FreeBSD 16 (amd64, plus aarch64 for Plus on ARM appliances), and `pkg` refuses a
|
||||
mismatched ABI. No aarch64 package is published: rustup ships no
|
||||
toolchain for aarch64 FreeBSD, so such a build cannot honour the
|
||||
`rust-toolchain.toml` pin. It is build-it-yourself.
|
||||
|
||||
```sh
|
||||
# Build (on FreeBSD; this Makefile needs GNU make — pkg install gmake)
|
||||
gmake pfsense
|
||||
# or directly, no gmake needed:
|
||||
./packaging/pfsense/build-pkg.sh
|
||||
|
||||
# Validate the package before shipping it
|
||||
./testing/check-pfsense-pkg.sh deploy/fips-<version>-pfsense-ce2.8-amd64.pkg
|
||||
|
||||
# Install (on the firewall, as root)
|
||||
pkg add ./fips-<version>-pfsense-ce2.8-amd64.pkg
|
||||
/usr/local/etc/rc.d/fips.sh start
|
||||
/usr/local/libexec/fips/fips-dns-setup # edits config.xml; run deliberately
|
||||
```
|
||||
|
||||
Not a Netgate-supported package, and a pfSense firmware upgrade removes
|
||||
it. See [pfsense/README.md](pfsense/README.md) for the "Allow IPv6"
|
||||
prerequisite the mesh depends on, firewall-rule notes, and removal
|
||||
behaviour.
|
||||
|
||||
### Windows (`.zip`)
|
||||
|
||||
A ZIP archive containing binaries, default config, and PowerShell
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/bin/sh
|
||||
# Shared helpers for the FreeBSD-family package builders.
|
||||
#
|
||||
# packaging/freebsd/build-pkg.sh and packaging/pfsense/build-pkg.sh
|
||||
# produce different packages for different systems, but the mechanics of
|
||||
# getting there — deriving a pkg-legal version, locating the binaries,
|
||||
# laying out the stage, and the manifest scripts that give the config
|
||||
# files @sample semantics — are the same work, and were duplicated
|
||||
# verbatim. They live here so a fix lands in both.
|
||||
#
|
||||
# What deliberately does NOT live here is anything the two packages
|
||||
# disagree about: the boot script, the DNS integration, linkage, the ABI
|
||||
# and product naming. Those differences are the reason there are two
|
||||
# builders at all, and folding them into a shared file with flags would
|
||||
# hide them.
|
||||
#
|
||||
# POSIX sh, sourced with `.` — no bashisms, no `local`.
|
||||
|
||||
# Print the version to stamp on the package, given the project root and
|
||||
# an optional override (CI passes a derived version on branch builds).
|
||||
#
|
||||
# '-' is the pkg name/version separator and neither '-' nor '+' is legal
|
||||
# inside a pkg version, so both map to '.': 0.6.0-dev -> 0.6.0.dev.
|
||||
pkg_resolve_version() {
|
||||
pkg_rv_root="$1"
|
||||
pkg_rv_version="${2:-}"
|
||||
[ -n "$pkg_rv_version" ] \
|
||||
|| pkg_rv_version="$(sed -n 's/^version = "\(.*\)"/\1/p' "${pkg_rv_root}/Cargo.toml" | head -1)"
|
||||
if [ -z "$pkg_rv_version" ]; then
|
||||
echo "error: could not read version from Cargo.toml" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$pkg_rv_version" | tr -- '+-' '..'
|
||||
}
|
||||
|
||||
# The build host's pkg ABI, or a sane default where pkg cannot say.
|
||||
pkg_host_abi() {
|
||||
pkg config abi 2>/dev/null || echo "FreeBSD:15:amd64"
|
||||
}
|
||||
|
||||
# Fail unless every named binary is present and executable in $1.
|
||||
pkg_require_binaries() {
|
||||
pkg_rb_dir="$1"
|
||||
shift
|
||||
for pkg_rb_bin in "$@"; do
|
||||
if [ ! -x "${pkg_rb_dir}/${pkg_rb_bin}" ]; then
|
||||
echo "error: ${pkg_rb_dir}/${pkg_rb_bin} missing (run without --no-build)" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# The directory skeleton both packages install into.
|
||||
pkg_stage_tree() {
|
||||
install -d "$1/usr/local/bin" \
|
||||
"$1/usr/local/etc/fips" \
|
||||
"$1/usr/local/etc/rc.d" \
|
||||
"$1/usr/local/libexec/fips"
|
||||
}
|
||||
|
||||
# Copy the named binaries from $1 into the stage at $2.
|
||||
pkg_stage_binaries() {
|
||||
pkg_sb_dir="$1"
|
||||
pkg_sb_stage="$2"
|
||||
shift 2
|
||||
for pkg_sb_bin in "$@"; do
|
||||
install -m 0755 "${pkg_sb_dir}/${pkg_sb_bin}" "${pkg_sb_stage}/usr/local/bin/" || return 1
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# Emit the post-install lines that copy a sample into place if the real
|
||||
# file is absent. Arguments are "<name>:<mode>" pairs, e.g. fips.yaml:0600.
|
||||
#
|
||||
# This is the @sample plist keyword spelled out by hand: that keyword
|
||||
# lives in the ports tree (/usr/ports/Keywords/sample.ucl), which neither
|
||||
# a plain pkg-create host nor pfSense has.
|
||||
#
|
||||
# FreeBSD has no "root" group; wheel is gid 0.
|
||||
pkg_sample_seed_script() {
|
||||
for pkg_ss_entry in "$@"; do
|
||||
pkg_ss_name="${pkg_ss_entry%%:*}"
|
||||
pkg_ss_mode="${pkg_ss_entry##*:}"
|
||||
cat <<EOS
|
||||
[ -f /usr/local/etc/fips/${pkg_ss_name} ] || install -m ${pkg_ss_mode} -o root -g wheel \\
|
||||
/usr/local/etc/fips/${pkg_ss_name}.sample /usr/local/etc/fips/${pkg_ss_name}
|
||||
EOS
|
||||
done
|
||||
}
|
||||
|
||||
# Emit the pre-deinstall lines that remove a seeded config only when it
|
||||
# is still byte-identical to the sample, so an edited config — and the
|
||||
# identity it may carry — is never deleted. Arguments are bare names.
|
||||
pkg_sample_purge_script() {
|
||||
printf ' for f in %s; do\n' "$*"
|
||||
cat <<'EOS'
|
||||
s="/usr/local/etc/fips/${f}.sample"
|
||||
t="/usr/local/etc/fips/${f}"
|
||||
if [ -f "$t" ] && cmp -s "$t" "$s"; then rm -f "$t"; fi
|
||||
done
|
||||
EOS
|
||||
}
|
||||
|
||||
# Run pkg-create(8) over a staged tree and move the result to $4.
|
||||
#
|
||||
# pkg create always names the file <name>-<version>.pkg; every caller
|
||||
# wants something more specific, so the rename is part of the helper
|
||||
# rather than repeated after it.
|
||||
pkg_create_package() {
|
||||
pkg_cp_stage="$1"
|
||||
pkg_cp_deploy="$2"
|
||||
pkg_cp_version="$3"
|
||||
pkg_cp_out="$4"
|
||||
|
||||
mkdir -p "$pkg_cp_deploy"
|
||||
echo "==> pkg create"
|
||||
pkg create -M "${pkg_cp_stage}/+MANIFEST" -p "${pkg_cp_stage}/pkg-plist" \
|
||||
-r "$pkg_cp_stage" -o "$pkg_cp_deploy" || return $?
|
||||
mv "${pkg_cp_deploy}/fips-${pkg_cp_version}.pkg" "$pkg_cp_out" || return $?
|
||||
|
||||
echo "==> built:"
|
||||
ls -l "$pkg_cp_out"
|
||||
}
|
||||
|
||||
# Emit the manifest fields both packages agree on, in the order
|
||||
# pkg-create(8) expects: $1 version, $2 ABI, $3 comment, $4 description.
|
||||
#
|
||||
# Shared because they are shared *policy*, not merely duplicated text —
|
||||
# origin, maintainer, licence and prefix describe one project, and two
|
||||
# copies is two things to forget to update. Anything a package decides
|
||||
# for itself (annotations, the scripts block) is appended by the caller
|
||||
# after this.
|
||||
pkg_manifest_header() {
|
||||
cat <<EOS
|
||||
name: "fips"
|
||||
version: "$1"
|
||||
origin: "net/fips"
|
||||
comment: "$3"
|
||||
desc: <<EOD
|
||||
$4
|
||||
EOD
|
||||
maintainer: "johnathan@corganlabs.com"
|
||||
www: "https://fips.network"
|
||||
abi: "$2"
|
||||
prefix: "/usr/local"
|
||||
licenselogic: "single"
|
||||
licenses: ["MIT"]
|
||||
categories: ["net"]
|
||||
EOS
|
||||
}
|
||||
|
||||
# The post-install lines creating the control-socket access group. Both
|
||||
# packages create /var/run/fips as root:fips 0750 from their boot
|
||||
# script, so members of this group can use fipsctl and fipstop without
|
||||
# root; the group has to exist before either runs.
|
||||
pkg_group_script() {
|
||||
cat <<'EOS'
|
||||
pw groupshow fips >/dev/null 2>&1 || pw groupadd fips
|
||||
EOS
|
||||
}
|
||||
|
||||
# Build the release binaries. $1 project root, $2 optional Rust target
|
||||
# triple — passing one keeps RUSTFLAGS off build scripts and proc-macros,
|
||||
# and puts the output under target/<triple>/release.
|
||||
pkg_cargo_build() {
|
||||
if [ -n "${2:-}" ]; then
|
||||
echo "==> cargo build --release --target $2"
|
||||
(cd "$1" && cargo build --release --target "$2")
|
||||
else
|
||||
echo "==> cargo build --release"
|
||||
(cd "$1" && cargo build --release)
|
||||
fi
|
||||
}
|
||||
@@ -4,6 +4,11 @@ Builds a native FreeBSD `.pkg` shipping `fips`, `fipsctl`, `fipstop`,
|
||||
rc.d services, and `.fips` DNS integration. `fips-gateway` is excluded
|
||||
(its NAT backend is nftables, Linux-only).
|
||||
|
||||
**On pfSense?** Use [`packaging/pfsense/`](../pfsense/README.md)
|
||||
instead. pfSense is FreeBSD underneath, but it boots packages, wires up
|
||||
DNS and handles upgrades differently enough that this package does not
|
||||
work there; the pfSense one addresses each difference.
|
||||
|
||||
Platform notes: the Ethernet and BLE transports are not available on
|
||||
FreeBSD (UDP, TCP, Tor, and Nym are). The UDP datapath deliberately
|
||||
uses the portable single-packet receive loop — FreeBSD's `recvmmsg(2)`
|
||||
|
||||
@@ -14,6 +14,10 @@ set -eu
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
# Mechanics shared with the pfSense builder: version derivation, the
|
||||
# stage layout, the @sample manifest scripts, and pkg create.
|
||||
. "${PROJECT_ROOT}/packaging/common/pkg-lib.sh"
|
||||
|
||||
NO_BUILD=0
|
||||
VERSION=""
|
||||
while [ $# -gt 0 ]; do
|
||||
@@ -25,41 +29,25 @@ while [ $# -gt 0 ]; do
|
||||
shift
|
||||
done
|
||||
|
||||
# Default to the Cargo.toml version; CI passes a derived version that
|
||||
# appends +<branch>.<height>.<hash> on branch builds. Either way, map
|
||||
# '-' and '+' to '.' — '-' is the pkg name/version separator and
|
||||
# neither is allowed inside a pkg version (0.5.0-dev -> 0.5.0.dev).
|
||||
[ -n "$VERSION" ] \
|
||||
|| VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' "${PROJECT_ROOT}/Cargo.toml" | head -1)"
|
||||
[ -n "$VERSION" ] || { echo "error: could not read version from Cargo.toml" >&2; exit 1; }
|
||||
VERSION="$(printf '%s' "$VERSION" | tr -- '+-' '..')"
|
||||
# CI passes a derived version that appends +<branch>.<height>.<hash> on
|
||||
# branch builds; otherwise the version comes from Cargo.toml.
|
||||
VERSION="$(pkg_resolve_version "$PROJECT_ROOT" "$VERSION")"
|
||||
|
||||
ABI="$(pkg config abi 2>/dev/null || echo "FreeBSD:15:amd64")"
|
||||
ABI="$(pkg_host_abi)"
|
||||
ARCH="${ABI##*:}"
|
||||
|
||||
if [ "$NO_BUILD" -eq 0 ]; then
|
||||
echo "==> cargo build --release"
|
||||
(cd "$PROJECT_ROOT" && cargo build --release)
|
||||
pkg_cargo_build "$PROJECT_ROOT"
|
||||
fi
|
||||
|
||||
for bin in fips fipsctl fipstop; do
|
||||
[ -x "${PROJECT_ROOT}/target/release/${bin}" ] \
|
||||
|| { echo "error: target/release/${bin} missing (run without --no-build)" >&2; exit 1; }
|
||||
done
|
||||
pkg_require_binaries "${PROJECT_ROOT}/target/release" fips fipsctl fipstop
|
||||
|
||||
STAGE="$(mktemp -d "${TMPDIR:-/tmp}/fips-pkg.XXXXXX")"
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
|
||||
echo "==> staging into ${STAGE}"
|
||||
install -d "${STAGE}/usr/local/bin" \
|
||||
"${STAGE}/usr/local/etc/fips" \
|
||||
"${STAGE}/usr/local/etc/rc.d" \
|
||||
"${STAGE}/usr/local/libexec/fips"
|
||||
|
||||
install -m 0755 "${PROJECT_ROOT}/target/release/fips" \
|
||||
"${PROJECT_ROOT}/target/release/fipsctl" \
|
||||
"${PROJECT_ROOT}/target/release/fipstop" \
|
||||
"${STAGE}/usr/local/bin/"
|
||||
pkg_stage_tree "$STAGE"
|
||||
pkg_stage_binaries "${PROJECT_ROOT}/target/release" "$STAGE" fips fipsctl fipstop
|
||||
|
||||
# Config ships sample-style: copied into place on install if absent,
|
||||
# removed on deinstall only if unmodified (see the manifest scripts).
|
||||
@@ -85,31 +73,15 @@ DESC="$(cat "${SCRIPT_DIR}/pkg-descr")"
|
||||
# (/usr/ports/Keywords/sample.ucl), which a plain pkg-create host (e.g.
|
||||
# a CI VM) does not have.
|
||||
cat > "${STAGE}/+MANIFEST" <<EOF
|
||||
name: "fips"
|
||||
version: "${VERSION}"
|
||||
origin: "net/fips"
|
||||
comment: "Self-organizing encrypted mesh network on Nostr identities"
|
||||
desc: <<EOD
|
||||
${DESC}
|
||||
EOD
|
||||
maintainer: "johnathan@corganlabs.com"
|
||||
www: "https://fips.network"
|
||||
abi: "${ABI}"
|
||||
prefix: "/usr/local"
|
||||
licenselogic: "single"
|
||||
licenses: ["MIT"]
|
||||
categories: ["net"]
|
||||
$(pkg_manifest_header "$VERSION" "$ABI" "Self-organizing encrypted mesh network on Nostr identities" "$DESC")
|
||||
scripts: {
|
||||
post-install: <<EOD
|
||||
# Control-socket access group: the rc script creates /var/run/fips as
|
||||
# root:fips 0750, so members can use fipsctl/fipstop without root.
|
||||
pw groupshow fips >/dev/null 2>&1 || pw groupadd fips
|
||||
$(pkg_group_script)
|
||||
# Install-if-absent config. fips.yaml may hold a node private key
|
||||
# (nsec:), so it is 0600; FreeBSD has no "root" group, wheel is gid 0.
|
||||
[ -f /usr/local/etc/fips/fips.yaml ] || install -m 0600 -o root -g wheel \\
|
||||
/usr/local/etc/fips/fips.yaml.sample /usr/local/etc/fips/fips.yaml
|
||||
[ -f /usr/local/etc/fips/hosts ] || install -m 0644 -o root -g wheel \\
|
||||
/usr/local/etc/fips/hosts.sample /usr/local/etc/fips/hosts
|
||||
$(pkg_sample_seed_script fips.yaml:0600 hosts:0644)
|
||||
# pkg upgrade runs the old package's pre-deinstall (which stops the
|
||||
# services); bring them back up on the new binaries if enabled.
|
||||
if [ "\${PKG_UPGRADE:-}" = "true" ]; then
|
||||
@@ -132,11 +104,7 @@ if [ "\${PKG_UPGRADE:-}" != "true" ]; then
|
||||
# Removal: clear the resolver drop-in even if the service was never
|
||||
# started through rc.
|
||||
/usr/local/libexec/fips/fips-dns-teardown 2>/dev/null || true
|
||||
for f in fips.yaml hosts; do
|
||||
s="/usr/local/etc/fips/\${f}.sample"
|
||||
t="/usr/local/etc/fips/\${f}"
|
||||
if [ -f "\$t" ] && cmp -s "\$t" "\$s"; then rm -f "\$t"; fi
|
||||
done
|
||||
$(pkg_sample_purge_script fips.yaml hosts)
|
||||
fi
|
||||
EOD
|
||||
}
|
||||
@@ -155,15 +123,8 @@ libexec/fips/fips-dns-teardown
|
||||
@dir etc/fips
|
||||
EOF
|
||||
|
||||
mkdir -p "${PROJECT_ROOT}/deploy"
|
||||
echo "==> pkg create"
|
||||
pkg create -M "${STAGE}/+MANIFEST" -p "${STAGE}/pkg-plist" \
|
||||
-r "$STAGE" -o "${PROJECT_ROOT}/deploy"
|
||||
|
||||
# pkg create always names the file <name>-<version>.pkg; add the OS and
|
||||
# arch so release assets stay distinct from the macOS .pkg files.
|
||||
OUT="${PROJECT_ROOT}/deploy/fips-${VERSION}-freebsd-${ARCH}.pkg"
|
||||
mv "${PROJECT_ROOT}/deploy/fips-${VERSION}.pkg" "$OUT"
|
||||
|
||||
echo "==> built:"
|
||||
ls -l "$OUT"
|
||||
pkg_create_package "$STAGE" "${PROJECT_ROOT}/deploy" "$VERSION" "$OUT"
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
# FIPS pfSense packaging
|
||||
|
||||
Builds a `.pkg` that installs FIPS on pfSense: `fips`, `fipsctl`,
|
||||
`fipstop`, a boot script pfSense actually runs, and helpers that wire
|
||||
the `.fips` zone into the DNS Resolver. `fips-gateway` is excluded (its
|
||||
NAT backend is nftables, Linux-only; pfSense has pf for that).
|
||||
|
||||
This is **not a Netgate-supported package** and has no GUI. Netgate
|
||||
[documents third-party packages as unsupported][netgate-freebsd-pkg]
|
||||
and warns they can break upgrades; treat it accordingly.
|
||||
|
||||
[netgate-freebsd-pkg]: https://docs.netgate.com/pfsense/en/latest/recipes/freebsd-pkg-repo.html
|
||||
|
||||
## Maintenance and reports
|
||||
|
||||
This package is maintained by **fr34aky** (via the project's issue
|
||||
tracker). pfSense-specific problems — a boot script that does not start,
|
||||
DNS wiring, an upgrade that misbehaves — are best reported there; the
|
||||
package manifest's maintainer field points at the project, so reports
|
||||
reach it either way. The ABI-to-product table below tracks Netgate's
|
||||
releases and needs updating when a new pfSense version ships or an old
|
||||
one goes end-of-life; that is part of maintaining this package.
|
||||
|
||||
## Why this is separate from `packaging/freebsd/`
|
||||
|
||||
pfSense is FreeBSD underneath, but the FreeBSD package does not work
|
||||
here — not "works worse", does not work — in three ways that all fail
|
||||
silently:
|
||||
|
||||
| | FreeBSD package | pfSense |
|
||||
|---|---|---|
|
||||
| Boot | `rc.d/fips`, an `rc.conf`-gated rc.subr service | pfSense's `rc.start_packages` globs `/usr/local/etc/rc.d/*.sh` and runs each as `<script> start`, at boot and again on a WAN IP change. A suffixless script is never run; the `.sh` script must make a re-run a quiet no-op. |
|
||||
| DNS | drop-in at `/var/unbound/conf.d/fips.conf` | `unbound.conf` is generated from `config.xml` and includes no `conf.d` directory. The drop-in is simply never read. |
|
||||
| Responder bind | `::1` | pfSense writes `do-ip6: no` unless "Allow IPv6" is set, and then cannot reach `[::1]` at all. This package binds `127.0.0.1`. |
|
||||
|
||||
So: `fips.sh` instead of `fips`, DNS Resolver custom options instead of
|
||||
a drop-in, and knobs in `/usr/local/etc/fips/fips.conf` instead of
|
||||
`/etc/rc.conf`.
|
||||
|
||||
The mechanics that are *not* different are shared rather than copied:
|
||||
`packaging/common/pkg-lib.sh` holds version derivation, the stage
|
||||
layout, the manifest fields both packages agree on, the @sample
|
||||
install-if-absent scripts, and `pkg create` itself. Both builders
|
||||
source it. What stays per-package is only what the two systems
|
||||
genuinely disagree about — boot, DNS, linkage, ABI and naming — since
|
||||
folding those into one file behind flags would hide the differences
|
||||
this table exists to explain.
|
||||
|
||||
## Which pfSense this matches
|
||||
|
||||
`pkg` refuses a package whose ABI does not match the running system, in
|
||||
**both** the FreeBSD major and the architecture. Netgate's ARM
|
||||
appliances are `aarch64`, so "which pfSense" is two questions, not one.
|
||||
Ask the appliance rather than guessing:
|
||||
|
||||
```sh
|
||||
pkg config abi # e.g. FreeBSD:16:aarch64
|
||||
uname -m
|
||||
```
|
||||
|
||||
The supported releases, from [Netgate's version
|
||||
table](https://docs.netgate.com/pfsense/en/latest/releases/versions.html)
|
||||
as of September 2026:
|
||||
|
||||
| Release | FreeBSD base | pkg ABI | Build host needed |
|
||||
|---|---|---|---|
|
||||
| pfSense CE 2.8.1 | 15.0-CURRENT | `FreeBSD:15:amd64` | FreeBSD 15, amd64 |
|
||||
| pfSense CE 2.9.0 | 16.0-CURRENT | `FreeBSD:16:amd64` | FreeBSD 16, amd64 |
|
||||
| pfSense Plus 26.03.1 / 26.07, Intel | 16.0-CURRENT | `FreeBSD:16:amd64` | FreeBSD 16, amd64 |
|
||||
| pfSense Plus 26.03.1 / 26.07, ARM | 16.0-CURRENT | `FreeBSD:16:aarch64` | FreeBSD 16, **aarch64** |
|
||||
|
||||
CE has only ever shipped for amd64; Netgate has said there are no plans
|
||||
for an ARM CE image. Plus 24.x and 25.x are end-of-life and deliberately
|
||||
not in the build's table: a package named for an unsupported release
|
||||
invites installing it there. The base moves between releases — CE 2.9
|
||||
moved to FreeBSD 16 in August 2026 — so check the table before building.
|
||||
|
||||
The filename names the **pfSense product(s)**, not the FreeBSD ABI —
|
||||
`fips-<version>-pfsense-<products>-<arch>.pkg` — because that is what
|
||||
someone choosing a download knows. You know you run "Plus 26.03 on a
|
||||
4200"; you do not necessarily know that means `FreeBSD:16:aarch64`.
|
||||
|
||||
One ABI can serve more than one product. CE 2.9 and Plus 26.x on Intel
|
||||
are both `FreeBSD:16:amd64` and the artifact is byte-identical, so its
|
||||
name carries both: `…-pfsense-ce2.9-plus26-amd64.pkg`. Architecture
|
||||
alone would not do either way — CE 2.8 and CE 2.9 are both `amd64` and
|
||||
`pkg` refuses each on the other's base.
|
||||
|
||||
The mapping is ABI → products, declared in one place in `build-pkg.sh`,
|
||||
because the ABI is what gets built and the products are what people look
|
||||
for. The build **refuses** an ABI that no supported release runs on
|
||||
(`FreeBSD:15:aarch64`: only end-of-life Plus versions), and `--product`
|
||||
is an assertion rather than a selector: pass it to say "I believe I am
|
||||
building for CE 2.8", and the build refuses if that product does not run
|
||||
on the ABI. The ABI stays in the manifest, where `pkg` reads it, and the
|
||||
products in a `pfsense_products` annotation, so a renamed file can still
|
||||
identify itself:
|
||||
|
||||
```sh
|
||||
pkg info -A -F <file>.pkg # pfsense_products: ce2.9 plus26
|
||||
pkg info -F <file>.pkg # Architecture: FreeBSD:16:amd64
|
||||
```
|
||||
|
||||
| Artifact | Installs on |
|
||||
|---|---|
|
||||
| `…-pfsense-ce2.8-amd64.pkg` | pfSense CE 2.8.1 |
|
||||
| `…-pfsense-ce2.9-plus26-amd64.pkg` | pfSense CE 2.9.0, and Plus 26.x on Intel |
|
||||
| `…-pfsense-plus26-aarch64.pkg` | pfSense Plus 26.x on ARM |
|
||||
|
||||
Which of those can be built, and with what provenance, differs — and the
|
||||
difference decides which may be published:
|
||||
|
||||
| Artifact | linkage | toolchain pin | CI |
|
||||
|---|---|---|---|
|
||||
| `…-pfsense-ce2.8-amd64.pkg` | static | honoured | built + checked, workflow artifact |
|
||||
| `…-pfsense-ce2.9-plus26-amd64.pkg` | static | honoured | not built — CI has no FreeBSD 16 host |
|
||||
| `…-pfsense-plus26-aarch64.pkg` | dynamic | **not** honoured | not built — build it yourself |
|
||||
|
||||
No pfSense package is attached to a release. It is built and checked in
|
||||
its own CI job (so a pfSense-only failure reds that job without blocking
|
||||
the FreeBSD asset) and kept as a 30-day workflow artifact, until one has
|
||||
been installed on a real pfSense box.
|
||||
|
||||
The two absences are not the same. The FreeBSD 16 Intel package builds
|
||||
cleanly with the pinned compiler and links statically, so it is
|
||||
releasable in principle and waits only on a FreeBSD 16 amd64 builder;
|
||||
the CI VM is 15.1 and `vmactions/freebsd-vm` offers nothing newer, and
|
||||
FreeBSD 16 is not released, so such a builder means a moving
|
||||
16.0-CURRENT snapshot. Until then, CI builds and checks only the package
|
||||
for the *older* supported CE release, as a workflow artifact. ARM cannot
|
||||
honour the pin at all, so it
|
||||
stays build-it-yourself regardless of infrastructure.
|
||||
|
||||
### There is no cross-compiling out of this
|
||||
|
||||
The build host must genuinely be the target's architecture. Two reasons,
|
||||
and the first is the one that wastes an afternoon:
|
||||
|
||||
- **`--abi` only relabels.** It changes the string `pkg` checks, not the
|
||||
binaries. A package labelled `FreeBSD:16:aarch64` full of x86-64
|
||||
binaries installs perfectly and then cannot exec.
|
||||
`testing/check-pfsense-pkg.sh` compares the label against the real
|
||||
binaries for exactly this reason, and fails the package.
|
||||
- **Rust ships no toolchain for FreeBSD/ARM, in either direction.**
|
||||
`rustup target add aarch64-unknown-freebsd` on an x86-64 FreeBSD host
|
||||
fails with "no prebuilt artifacts available for target" — only
|
||||
`i686-unknown-freebsd` and `x86_64-unknown-freebsd` are offered — and
|
||||
rustup has no installer for the platform natively either. What makes a
|
||||
native aarch64 builder the path of least resistance is not rustup but
|
||||
the ports Rust (`pkg install rust`), with the pin consequences below.
|
||||
|
||||
`build-pkg.sh --target <triple>` exists for a builder that is already
|
||||
the right architecture (it reads binaries from `target/<triple>/release`
|
||||
and cross-checks the triple against `--abi`). It does not conjure a
|
||||
toolchain that Rust does not distribute.
|
||||
|
||||
### ARM builds are build-it-yourself, and are not released
|
||||
|
||||
**No aarch64 package is published as a release artifact.** Build one
|
||||
yourself with the recipe above, on your own aarch64 FreeBSD 16 host.
|
||||
|
||||
The reason is the toolchain, not the architecture. `rust-toolchain.toml`
|
||||
pins an exact compiler, and every published artifact for every other
|
||||
platform is built with it. On aarch64 FreeBSD that is impossible:
|
||||
|
||||
```
|
||||
$ rustup target add aarch64-unknown-freebsd
|
||||
error: toolchain '1.94.1-x86_64-unknown-freebsd' has no prebuilt
|
||||
artifacts available for target 'aarch64-unknown-freebsd'
|
||||
|
||||
$ (on an aarch64 FreeBSD host)
|
||||
error: installer for platform 'aarch64-unknown-freebsd' not found
|
||||
```
|
||||
|
||||
So an ARM build uses the ports Rust, and the ports cargo ignores
|
||||
`rust-toolchain.toml` outright. Publishing such a package alongside the
|
||||
others would quietly imply a provenance it does not have.
|
||||
|
||||
The alternatives were considered and rejected: `RUSTC_BOOTSTRAP=1` with
|
||||
`-Z build-std` nominally satisfies the pin, but only by disabling the
|
||||
stable/unstable boundary and rebuilding `std` through a path upstream
|
||||
does not support — a *less* visible deviation than a different version
|
||||
number, for a daemon that terminates encrypted tunnels on a firewall.
|
||||
Building rustc 1.94.1 from source is a multi-hour bootstrap that still
|
||||
yields an unofficial, unverifiable compiler.
|
||||
|
||||
Every package records what produced it, so this is answerable from the
|
||||
artifact rather than from whoever remembers building it:
|
||||
|
||||
```sh
|
||||
pkg info -A fips
|
||||
# built_with : rustc <ports version>
|
||||
# toolchain_pin : 1.94.1
|
||||
# pin_honoured : no <- not a release artifact
|
||||
# linkage : static
|
||||
# rust_pkg : rust-<ports version>
|
||||
```
|
||||
|
||||
`build-pkg.sh` prints a loud notice whenever the compiler is not the
|
||||
pinned one, and refuses outright below the edition-2024 floor (1.85).
|
||||
`testing/check-pfsense-pkg.sh` fails a package that has lost these
|
||||
annotations, and flags `pin_honoured: no` in its output.
|
||||
|
||||
### FreeBSD 16 is not released
|
||||
|
||||
pfSense CE 2.9 and Plus 26.x are built from FreeBSD **16.0-CURRENT**, a development
|
||||
branch; 16.0-RELEASE does not exist yet. So a FreeBSD 16 builder means a
|
||||
[16.0-CURRENT snapshot](https://download.freebsd.org/snapshots/), not a
|
||||
release image — and `vmactions/freebsd-vm`, which this repo's CI uses,
|
||||
only goes up to 15.1.
|
||||
|
||||
That makes base-library drift a real risk rather than a theoretical one:
|
||||
Netgate's `16.0-CURRENT@<hash>` and a FreeBSD snapshot from another date
|
||||
are different trees, and a binary can reference a symbol the appliance's
|
||||
`libc` does not export. It installs and then fails to start. If `fips`
|
||||
exits immediately with a linker error, that is this. Build from a
|
||||
snapshot close to the appliance's base, and check what the binary
|
||||
actually needs:
|
||||
|
||||
```sh
|
||||
pkg info -F <the .pkg> | grep -A5 "Shared Libs" # on the build host
|
||||
ldd /usr/local/bin/fips # on the appliance
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
gmake -C packaging pfsense # or:
|
||||
./packaging/pfsense/build-pkg.sh # cargo build --release + pkg create
|
||||
./packaging/pfsense/build-pkg.sh --no-build # package existing release binaries
|
||||
./packaging/pfsense/build-pkg.sh --dynamic # link against libc.so.7 (see below)
|
||||
```
|
||||
|
||||
Output: `deploy/fips-<version>-pfsense-<products>-<arch>.pkg`. Validate it before
|
||||
shipping it anywhere:
|
||||
|
||||
```sh
|
||||
./testing/check-pfsense-pkg.sh deploy/fips-<version>-pfsense-ce2.8-amd64.pkg
|
||||
```
|
||||
|
||||
### Static linking is the default
|
||||
|
||||
Unlike every other platform's package, this one links statically unless
|
||||
you ask otherwise. The reason is specific to pfSense: **it runs a
|
||||
FreeBSD base you cannot obtain.** Netgate builds Plus from a
|
||||
16.0-CURRENT snapshot of their own, and download.freebsd.org keeps only
|
||||
the last two CURRENT builds — so there is usually no way to build
|
||||
against the appliance's libraries even if you want to.
|
||||
|
||||
In practice the build host's `libc` ends up *newer* than the
|
||||
appliance's, which is the direction that breaks: the binary references a
|
||||
versioned symbol the appliance does not export, installs cleanly, and
|
||||
then will not start. A dynamic package needs `libc.so.7`, `libm.so.5`,
|
||||
`libthr.so.3` and `libgcc_s.so.1` to agree with it; a static one
|
||||
declares no shared libraries at all. What is left is the kernel syscall
|
||||
ABI, which is stable within a FreeBSD major.
|
||||
|
||||
That is also why a static package survives a pfSense firmware upgrade's
|
||||
change of base, where a dynamic one is pinned to the image it was built
|
||||
against.
|
||||
|
||||
It is viable here because nothing in this codebase uses `dlopen` or
|
||||
`libloading`, and FreeBSD compiles `files`/`dns` resolution into `libc`
|
||||
— so a static binary still resolves hostnames. (This is where static
|
||||
*glibc* would defeat you; FreeBSD is not glibc.) Verified end to end: a
|
||||
static build resolves a peer hostname, completes the Noise handshake,
|
||||
joins the spanning tree and answers `.fips` queries.
|
||||
|
||||
`crt-static` is a request a target may silently ignore, so
|
||||
`build-pkg.sh` checks the produced binaries and fails if any came out
|
||||
dynamic — shipping a dynamic binary while believing it static would
|
||||
quietly reinstate the exposure this default exists to remove. The
|
||||
package records which it is, in the `linkage` annotation.
|
||||
|
||||
**aarch64 is the exception, and the build refuses rather than
|
||||
downgrades.** A statically linked aarch64 FreeBSD binary faults at
|
||||
`addr=0x0` where `posix_spawn` should be, so the daemon dies the
|
||||
first time it shells out — `sysctl`, from `is_ipv6_disabled()` at the
|
||||
top of `TunDevice::create`. It presents as a TUN bug, and with
|
||||
`tun.enabled: false` the daemon never spawns anything and looks
|
||||
healthy. The same trace on static amd64 reaches `rfork(RFSPAWN)` and
|
||||
spawns normally, so this is the architecture, not static linking.
|
||||
ARM builds must pass `--dynamic`; `ldd` on the appliance then tells
|
||||
you whether the drift this default exists to avoid is real.
|
||||
|
||||
Use `--dynamic` if you specifically want the smaller binaries and know
|
||||
your build host's base matches the appliance's.
|
||||
|
||||
## Install
|
||||
|
||||
Copy the package to the firewall and, as root:
|
||||
|
||||
```sh
|
||||
pkg add ./fips-<version>-pfsense-ce2.8-amd64.pkg
|
||||
vi /usr/local/etc/fips/fips.yaml # identity and peers
|
||||
/usr/local/etc/rc.d/fips.sh start
|
||||
fipsctl show status
|
||||
```
|
||||
|
||||
To upgrade an existing install, use `pkg install ./<file>.pkg`, not
|
||||
`pkg add`: only `pkg install` runs the upgrade path (the old package's
|
||||
pre-deinstall then the new post-install, with `PKG_UPGRADE=true`), which
|
||||
stops the daemon before its binary is replaced and starts it after.
|
||||
`pkg add` on an installed package refuses without `-f` and, with `-f`,
|
||||
reinstalls without those hooks — so follow a `pkg add -f` with
|
||||
`/usr/local/etc/rc.d/fips.sh restart` by hand. `pkg upgrade` does not
|
||||
apply: these packages are in no repository.
|
||||
|
||||
Then, separately and deliberately (it edits `config.xml`):
|
||||
|
||||
```sh
|
||||
/usr/local/libexec/fips/fips-dns-setup
|
||||
```
|
||||
|
||||
The daemon starts at boot from then on. To keep it installed but
|
||||
dormant, set `fips_enable="NO"` in `/usr/local/etc/fips/fips.conf`;
|
||||
`fips.sh onestart` still starts it by hand.
|
||||
|
||||
### "Allow IPv6" and the responder bind
|
||||
|
||||
`Allow IPv6` (System > Advanced > Networking) is **on in the factory
|
||||
configuration**, so most installs need nothing here. The notes below are
|
||||
for a firewall where it has been turned off.
|
||||
|
||||
|
||||
**System > Advanced > Networking > Allow IPv6.** The mesh is IPv6
|
||||
(`fd00::/8`) end to end. With that setting off, pfSense emits
|
||||
|
||||
```
|
||||
block in quick inet6 all
|
||||
block out quick inet6 all
|
||||
```
|
||||
|
||||
and a `quick` rule matches immediately — **no rule you add can override
|
||||
it**. The mesh is dead in both directions.
|
||||
|
||||
The trap is that this does not look like a failure. The loopback IPv6
|
||||
pass rule is unconditional, so the DNS responder keeps answering and
|
||||
`.fips` names keep resolving; the outer UDP and TCP transports are IPv4
|
||||
and keep peering happily. `fipsctl show status` looks healthy while
|
||||
nothing crosses the mesh. **`.fips` resolving is not evidence that the
|
||||
mesh carries traffic** — ping the address it returns.
|
||||
|
||||
Verify:
|
||||
|
||||
```sh
|
||||
pfctl -sr | grep -c "Block all IPv6" # must be 0
|
||||
pfctl -sr | grep "let out anything IPv6" # must be present
|
||||
```
|
||||
|
||||
## .fips DNS integration
|
||||
|
||||
`fips-dns-setup` adds a marked block to **Services > DNS Resolver >
|
||||
Custom options**, which is the only operator-writable surface in the
|
||||
generated `unbound.conf`:
|
||||
|
||||
```
|
||||
# BEGIN FIPS - managed by fips-dns-setup, do not edit this block
|
||||
server:
|
||||
domain-insecure: "fips."
|
||||
do-not-query-localhost: no
|
||||
|
||||
forward-zone:
|
||||
name: "fips."
|
||||
forward-addr: 127.0.0.1@5354
|
||||
forward-first: no
|
||||
# END FIPS
|
||||
```
|
||||
|
||||
Each line earns its place:
|
||||
|
||||
- `domain-insecure` — the `.fips` zone is unsigned and pfSense validates
|
||||
DNSSEC by default, so without it every answer is discarded as bogus.
|
||||
- `do-not-query-localhost: no` — unbound refuses loopback forwarders by
|
||||
default, which SERVFAILs every `.fips` query rather than asking the
|
||||
daemon. pfSense's `unbound.inc` never sets this. Note this applies to
|
||||
the **whole resolver**, not only the `fips.` zone: after this, unbound
|
||||
will also forward other loopback-directed queries it would otherwise
|
||||
refuse. On a firewall whose only loopback listener is the FIPS
|
||||
responder that changes nothing, but it is a resolver-wide setting.
|
||||
- `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.
|
||||
|
||||
It is stored (base64-encoded) in `config.xml`, which is the point:
|
||||
`config.xml` is pfSense's durable store — it survives reboots, config
|
||||
restores, and removal of this package — so the `fips.` zone is not tied
|
||||
to the package's own files. If the package is ever removed without
|
||||
`fips-dns-teardown`, the zone keeps pointing at `127.0.0.1:5354` and
|
||||
`.fips` fails loudly with SERVFAIL rather than resolving to something
|
||||
else.
|
||||
|
||||
The block is written between markers and everything outside them is left
|
||||
byte-for-byte alone, so your own custom options are safe. `write_config()`
|
||||
records a config-history entry, so the edit is revertable from
|
||||
**Diagnostics > Backup & Restore > Config History**.
|
||||
|
||||
To undo it while keeping the daemon:
|
||||
|
||||
```sh
|
||||
/usr/local/libexec/fips/fips-dns-teardown
|
||||
```
|
||||
|
||||
If this firewall uses the **DNS Forwarder (dnsmasq)** rather than the
|
||||
DNS Resolver, the script says so; the equivalent single line under
|
||||
Services > DNS Forwarder > Advanced Options is:
|
||||
|
||||
```
|
||||
server=/fips/127.0.0.1#5354
|
||||
```
|
||||
|
||||
## Firewall rules and the TUN interface
|
||||
|
||||
The daemon creates a `tun` interface for the mesh. Left unassigned, the
|
||||
default pfSense ruleset gives it the posture most people want:
|
||||
|
||||
- `pass out ... all keep state` ("let out anything from firewall host
|
||||
itself") is not interface-scoped, so it covers `tun` — **outbound
|
||||
passes and creates state, replies return on that state**;
|
||||
- the default deny covers inbound, so **unsolicited inbound is blocked**.
|
||||
|
||||
The sample `fips.yaml` binds its UDP and TCP transports to `0.0.0.0`,
|
||||
i.e. every interface including WAN. Nothing is reachable from outside
|
||||
only because pfSense's default WAN policy passes no unsolicited inbound;
|
||||
if you add a WAN pass rule for another service, make sure it does not
|
||||
cover the FIPS ports (2121/udp, 8443/tcp by default), or set
|
||||
`transports.udp.bind_addr` / `transports.tcp.bind_addr` to the LAN
|
||||
address. To accept inbound mesh connections deliberately, assign the
|
||||
interface (Interfaces > Assignments) and add pass rules. Two caveats:
|
||||
|
||||
- **The interface name is kernel-assigned.** On FreeBSD the daemon reads
|
||||
back whatever the kernel gave it (`tun0`, `tun1`, ...); `tun.name` in
|
||||
`fips.yaml` is silently ignored on this platform. A pfSense assignment
|
||||
pins a name, so confirm it is stable across daemon restarts and
|
||||
reboots before building rules on it.
|
||||
- **Match the daemon's posture to pf's.** pf dropping inbound does not
|
||||
stop the daemon advertising itself as reachable — peers keep dialing
|
||||
an endpoint that drops. If you are not allowing inbound, set
|
||||
`transports.udp.accept_connections: false` (refuses inbound `msg1` at
|
||||
the protocol level) or `outbound_only: true` (pure client) in
|
||||
`fips.yaml`.
|
||||
|
||||
LAN clients reaching the mesh through the firewall rely on state from
|
||||
the LAN pass rule. That holds under the default *floating* state policy;
|
||||
if System > Advanced > Firewall is set to interface-bound states, the
|
||||
`tun` side needs its own pass rule.
|
||||
|
||||
## Upgrades and removal
|
||||
|
||||
A pfSense **firmware upgrade** does not remove this package.
|
||||
`pfSense-upgrade` deletes and reinstalls only `pfSense-pkg-*` packages;
|
||||
`fips` is a plain pkg and is left in place. This was confirmed on a live
|
||||
**Plus 26.03.1 → 26.07** upgrade (aarch64): the package survived, the
|
||||
daemon restarted at boot, and `.fips` still resolved. That is a *minor*
|
||||
base change (FreeBSD 16 → 16). At a **major** change (CE 2.8.1 on
|
||||
FreeBSD 15 → CE 2.9.0 on FreeBSD 16) a FreeBSD-15 binary runs on a
|
||||
FreeBSD-16 kernel only through that kernel's compat layer — not tested —
|
||||
so after a major upgrade rebuild and reinstall the package for the new
|
||||
base (the `ce2.9-plus26-amd64` one)
|
||||
rather than trusting compat indefinitely. (The minor-upgrade survival
|
||||
above is from the hardware run; the cross-major compat behaviour is only
|
||||
what `pfSense-upgrade`'s source implies — see "What is and is not
|
||||
tested".)
|
||||
|
||||
A **package upgrade** (`pkg install ./<newer>.pkg`) stops the daemon
|
||||
before replacing its binary and starts it again afterwards only if it
|
||||
had been running. **`pkg delete`** stops it, takes the `.fips` block back
|
||||
out of the DNS Resolver, and deletes the config files only if they are
|
||||
still byte-identical to the shipped samples — an edited `fips.yaml`, and
|
||||
the identity key it may hold, is left in place.
|
||||
|
||||
## Debugging
|
||||
|
||||
```sh
|
||||
/usr/local/etc/rc.d/fips.sh status
|
||||
tail -f /var/log/fips.log
|
||||
|
||||
drill -p 5354 <npub>.fips @127.0.0.1 AAAA # the daemon directly
|
||||
drill <npub>.fips AAAA # the full chain via unbound
|
||||
ping6 <the AAAA it returned> # proves the mesh, not just DNS
|
||||
|
||||
unbound-checkconf /var/unbound/unbound.conf
|
||||
grep -A8 "Unbound custom options" /var/unbound/unbound.conf
|
||||
pfctl -ss | grep tun # mesh state entries
|
||||
```
|
||||
|
||||
`ifconfig <tun-name>` prints `Opened by PID <n>` for the process holding
|
||||
a tun device. The interface is destroyed automatically when the daemon
|
||||
exits.
|
||||
|
||||
## What is and is not tested
|
||||
|
||||
`testing/check-pfsense-pkg.sh` validates the package contents, the boot
|
||||
script's behaviour and the config it ships, on any FreeBSD host. It runs
|
||||
in CI. What it cannot cover — installing on pfSense, the `config.xml`
|
||||
edit (which needs pfSense's PHP and `config.inc`), unbound answering
|
||||
`.fips`, and pf passing mesh traffic — has no pfSense CI image to
|
||||
automate against and remains a manual step.
|
||||
|
||||
Those manual steps have been exercised once, on pfSense Plus 26.03.1
|
||||
aarch64: package install, boot script lifecycle, `fips-dns-setup`
|
||||
writing the DNS Resolver block, `.fips` resolving through unbound, the
|
||||
TUN interface coming up, and the mesh carrying traffic (304 packets
|
||||
delivered, no loss, no drops). That is one run on one appliance, not a
|
||||
gate — nothing re-checks it when this code changes.
|
||||
|
||||
Known still-unexercised paths, from that same run: `fips-dns-setup`'s
|
||||
refusal path (it has only ever run against a responder that was already
|
||||
answering), its DNS Forwarder branch, and `pkg delete`.
|
||||
(`fips-dns-teardown` has since been run on the same box and restored
|
||||
`custom_options` byte for byte.)
|
||||
|
||||
**No amd64 package has ever been installed.** The CE 2.8.1 (FreeBSD 15)
|
||||
and the CE 2.9 / Plus 26.x (FreeBSD 16) amd64 packages are built and
|
||||
pass the checker, and nothing more. The one hardware run was aarch64;
|
||||
the amd64 packages share every script here and have had none of that
|
||||
exposure, so read a passing check as "the package is well-formed", not
|
||||
"it works".
|
||||
|
||||
That matters because the aarch64 run found several defects, every one in
|
||||
this packaging rather than the daemon — a boot script whose pid check
|
||||
never succeeded, a DNS setup that reported success while nothing was
|
||||
listening, and a static build that faulted at `posix_spawn`. The daemon
|
||||
itself needed no changes. An untested path in the amd64 packages is
|
||||
exactly where the next one would sit.
|
||||
Executable
+572
@@ -0,0 +1,572 @@
|
||||
#!/bin/sh
|
||||
# Build a pfSense .pkg package for FIPS using pkg-create(8).
|
||||
#
|
||||
# Usage: packaging/pfsense/build-pkg.sh [--version <v>] [--abi <abi>]
|
||||
# [--target <triple>] [--product <id>] [--dynamic] [--no-build]
|
||||
#
|
||||
# Links statically by default; --dynamic opts out. See the "Static
|
||||
# linking is the default" section of packaging/pfsense/README.md.
|
||||
#
|
||||
# Prerequisites: the pinned Rust toolchain, pkg(8). Must run on FreeBSD:
|
||||
# the binaries are native, and pkg create needs a FreeBSD host.
|
||||
# Output: deploy/fips-<version>-pfsense-<products>-<arch>.pkg
|
||||
#
|
||||
# This is a sibling of packaging/freebsd/build-pkg.sh, not a replacement
|
||||
# for it. pfSense is FreeBSD underneath, but it diverges in the four
|
||||
# places a package touches:
|
||||
#
|
||||
# - Boot. pfSense's rc.start_packages globs /usr/local/etc/rc.d/*.sh
|
||||
# and runs each as `<script> start`. The FreeBSD package's
|
||||
# rc.d/fips has no .sh suffix and gates on an /etc/rc.conf variable,
|
||||
# so on pfSense it would never start.
|
||||
# - DNS. pfSense generates unbound.conf from config.xml and reads no
|
||||
# conf.d directory, so the FreeBSD package's drop-in is inert here.
|
||||
# This package integrates through the DNS Resolver custom options.
|
||||
# - The responder's bind address, for the reason recorded in
|
||||
# fips.yaml.dns.
|
||||
# - Lifetime. A pfSense firmware upgrade reinstalls the base image and
|
||||
# takes third-party packages with it, so post-install says so.
|
||||
#
|
||||
# Ships fips, fipsctl and fipstop. fips-gateway is excluded: its NAT
|
||||
# backend is nftables (Linux-only), and pfSense has pf for that anyway.
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
# Mechanics shared with the FreeBSD builder: version derivation, the
|
||||
# stage layout, the @sample manifest scripts, and pkg create.
|
||||
. "${PROJECT_ROOT}/packaging/common/pkg-lib.sh"
|
||||
|
||||
NO_BUILD=0
|
||||
VERSION=""
|
||||
ABI_OVERRIDE=""
|
||||
STATIC=1
|
||||
PRODUCT=""
|
||||
TARGET=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--no-build) NO_BUILD=1 ;;
|
||||
--static) STATIC=1 ;; # the default, spelled out
|
||||
--dynamic) STATIC=0 ;;
|
||||
--product) PRODUCT="${2:?--product requires an argument}"; shift ;;
|
||||
--version) VERSION="${2:?--version requires an argument}"; shift ;;
|
||||
--abi) ABI_OVERRIDE="${2:?--abi requires an argument}"; shift ;;
|
||||
--target) TARGET="${2:?--target requires an argument}"; shift ;;
|
||||
*)
|
||||
echo "usage: $0 [--version <version>] [--abi <abi>] [--target <triple>] [--product <id>] [--dynamic] [--no-build]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# CI passes a derived version that appends +<branch>.<height>.<hash> on
|
||||
# branch builds; otherwise the version comes from Cargo.toml.
|
||||
VERSION="$(pkg_resolve_version "$PROJECT_ROOT" "$VERSION")"
|
||||
|
||||
if [ -n "$ABI_OVERRIDE" ]; then
|
||||
ABI="$ABI_OVERRIDE"
|
||||
else
|
||||
ABI="$(pkg_host_abi)"
|
||||
fi
|
||||
ABI_MAJOR="$(printf '%s' "$ABI" | awk -F: '{print $2}')"
|
||||
ARCH="${ABI##*:}"
|
||||
|
||||
# --- Which pfSense this is for --------------------------------------
|
||||
#
|
||||
# The filename names the pfSense product(s), not the FreeBSD ABI, because
|
||||
# that is what the person choosing a download knows: "I run Plus 26.03 on
|
||||
# a 4200", not "I need FreeBSD:16:aarch64". pkg still enforces the ABI,
|
||||
# which the manifest carries; the name is wayfinding.
|
||||
#
|
||||
# One ABI can serve more than one product. CE 2.9 and Plus 26.x are both
|
||||
# FreeBSD 16 on amd64 and the artifact is byte-identical, so the name
|
||||
# carries every supported product the package installs on. The mapping is
|
||||
# therefore ABI -> products: the ABI is what gets built, the products are
|
||||
# what people look for.
|
||||
#
|
||||
# Supported releases as of 2026-09, from Netgate's version table:
|
||||
#
|
||||
# CE 2.8.1 FreeBSD 15 amd64
|
||||
# CE 2.9.0 FreeBSD 16 amd64
|
||||
# Plus 26.03.1 FreeBSD 16 amd64, aarch64
|
||||
# Plus 26.07 FreeBSD 16 amd64, aarch64
|
||||
#
|
||||
# CE has only ever shipped for amd64, and Netgate has said there are no
|
||||
# plans for an ARM CE image. Plus 24.x and 25.07 (FreeBSD 15) and 25.11
|
||||
# (FreeBSD 16) are end-of-life and deliberately absent: a package named
|
||||
# for an unsupported release is an invitation to install it there.
|
||||
#
|
||||
# Verify against https://docs.netgate.com/pfsense/en/latest/releases/versions.html
|
||||
# when a release moves, and change this table in the same commit.
|
||||
abi_products() {
|
||||
case "$1" in
|
||||
FreeBSD:15:amd64) echo "ce2.8" ;;
|
||||
FreeBSD:16:amd64) echo "ce2.9 plus26" ;;
|
||||
FreeBSD:16:aarch64) echo "plus26" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
PRODUCTS="$(abi_products "$ABI")"
|
||||
if [ -z "$PRODUCTS" ]; then
|
||||
echo "error: no supported pfSense release runs on ${ABI}." >&2
|
||||
case "$ABI" in
|
||||
FreeBSD:15:aarch64)
|
||||
echo "error: The ARM releases on FreeBSD 15 (Plus 24.x, 25.07) are end-of-" >&2
|
||||
echo "error: life. Plus 26.x on ARM is FreeBSD:16:aarch64." >&2
|
||||
;;
|
||||
*)
|
||||
echo "error: If Netgate has shipped a release on this base, add it to the" >&2
|
||||
echo "error: table in $0 in the same change, so the next build does not" >&2
|
||||
echo "error: have to guess." >&2
|
||||
;;
|
||||
esac
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --product is an assertion, not a selector. The artifact serves every
|
||||
# product listed for its ABI whatever is passed, so the name always
|
||||
# carries them all; --product says "I believe I am building for X", and
|
||||
# the build refuses when X is not among them — which is how an ABI
|
||||
# chosen for the wrong release gets caught before anything is packaged.
|
||||
if [ -n "$PRODUCT" ]; then
|
||||
product_ok=0
|
||||
for known_product in $PRODUCTS; do
|
||||
[ "$known_product" = "$PRODUCT" ] && product_ok=1
|
||||
done
|
||||
if [ "$product_ok" -eq 0 ]; then
|
||||
echo "error: --product ${PRODUCT} does not run on ${ABI}, which serves: ${PRODUCTS}." >&2
|
||||
case "$PRODUCT" in
|
||||
ce2.8 | ce2.9)
|
||||
echo "error: CE ships for amd64 only: CE 2.8 is FreeBSD 15, CE 2.9 is FreeBSD 16." >&2 ;;
|
||||
plus26)
|
||||
echo "error: Plus 26.x is FreeBSD 16, on amd64 or aarch64." >&2 ;;
|
||||
*)
|
||||
echo "error: Known products: ce2.8, ce2.9, plus26." >&2 ;;
|
||||
esac
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# The filename tag: every product this ABI serves, joined with '-'.
|
||||
PRODUCT_TAG="$(printf '%s' "$PRODUCTS" | tr ' ' '-')"
|
||||
|
||||
# Where cargo leaves the binaries. `--target` puts them one level deeper,
|
||||
# and is also the flag that says this is not a host build.
|
||||
if [ -n "$TARGET" ]; then
|
||||
BIN_DIR="${PROJECT_ROOT}/target/${TARGET}/release"
|
||||
else
|
||||
BIN_DIR="${PROJECT_ROOT}/target/release"
|
||||
fi
|
||||
|
||||
# --- Static linking -------------------------------------------------
|
||||
#
|
||||
# pfSense runs a FreeBSD base you cannot obtain: Netgate builds Plus
|
||||
# from a 16.0-CURRENT snapshot of their own, and download.freebsd.org
|
||||
# keeps only the last two CURRENT builds. So the build host's libc is
|
||||
# almost always *newer* than the appliance's, which is the direction
|
||||
# that breaks: a binary can reference a versioned libc symbol the
|
||||
# appliance does not export, install cleanly, and then refuse to start.
|
||||
#
|
||||
# Linking statically removes the negotiation entirely — there is no
|
||||
# libc.so.7 to disagree with. What is left is the kernel syscall ABI,
|
||||
# which is stable within a FreeBSD major.
|
||||
#
|
||||
# Verified viable on this codebase: no dlopen/libloading anywhere, and
|
||||
# FreeBSD builds files+dns resolution into libc, so a static binary
|
||||
# still resolves hostnames (the thing that defeats static glibc).
|
||||
if [ "$STATIC" -eq 1 ]; then
|
||||
# RUSTFLAGS must not reach build scripts and proc-macros, which run
|
||||
# on the build host; passing --target is what confines it.
|
||||
if [ -z "$TARGET" ]; then
|
||||
TARGET="$(rustc -vV | sed -n 's/^host: //p')"
|
||||
[ -n "$TARGET" ] || { echo "error: could not determine host target triple" >&2; exit 1; }
|
||||
BIN_DIR="${PROJECT_ROOT}/target/${TARGET}/release"
|
||||
echo "==> static build for host target ${TARGET}"
|
||||
fi
|
||||
RUSTFLAGS="${RUSTFLAGS:-} -C target-feature=+crt-static"
|
||||
export RUSTFLAGS
|
||||
fi
|
||||
|
||||
# The package's arch has to describe the binaries in it, and nothing
|
||||
# downstream checks that: pkg believes the ABI string in the manifest, so
|
||||
# a mislabelled package installs on the appliance and then cannot exec.
|
||||
# Cross-check the two here, where it is still cheap.
|
||||
#
|
||||
# pkg's arch names are not the Rust triple's: amd64 is x86_64, and only
|
||||
# aarch64 spells itself the same in both.
|
||||
case "$TARGET" in
|
||||
"") expected_arch="$ARCH" ;; # host build: nothing to cross-check
|
||||
aarch64-*freebsd*) expected_arch="aarch64" ;;
|
||||
x86_64-*freebsd*) expected_arch="amd64" ;;
|
||||
i686-*freebsd*) expected_arch="i386" ;;
|
||||
*)
|
||||
echo "error: --target ${TARGET} is not a FreeBSD target triple." >&2
|
||||
echo "error: This package can only be built for FreeBSD-based systems." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "$expected_arch" != "$ARCH" ]; then
|
||||
echo "error: --target ${TARGET} produces ${expected_arch} binaries, but the" >&2
|
||||
echo "error: ABI says ${ARCH} (${ABI}). Pass a matching --abi, e.g.:" >&2
|
||||
echo "error: --target ${TARGET} --abi FreeBSD:${ABI_MAJOR}:${expected_arch}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# pkg refuses a package whose ABI does not match the running system, in
|
||||
# both the FreeBSD major and the architecture, so the build has to be
|
||||
# aimed at the target's base rather than at the build host's.
|
||||
#
|
||||
# pfSense CE 2.8.1 FreeBSD 15, amd64 -> FreeBSD:15:amd64
|
||||
# pfSense CE 2.9.0 FreeBSD 16, amd64 -> FreeBSD:16:amd64
|
||||
# pfSense Plus 26.x FreeBSD 16, amd64 -> FreeBSD:16:amd64
|
||||
# pfSense Plus 26.x FreeBSD 16, ARM -> FreeBSD:16:aarch64
|
||||
#
|
||||
# The mapping moves between releases; check it against the target before
|
||||
# building, and confirm on the appliance with `pkg config abi`.
|
||||
HOST_ABI="$(pkg config abi 2>/dev/null || echo "unknown")"
|
||||
if [ "$ABI" != "$HOST_ABI" ]; then
|
||||
echo "warning: building for ${ABI}, but this host is ${HOST_ABI}." >&2
|
||||
echo "warning: The binaries must genuinely be ${ARCH} and must link against" >&2
|
||||
echo "warning: the target's base libraries — an ABI string alone does not" >&2
|
||||
echo "warning: make an amd64 binary run on ARM. Verify with:" >&2
|
||||
echo "warning: file ${BIN_DIR}/fips" >&2
|
||||
echo "warning: Target mapping: https://docs.netgate.com/pfsense/en/latest/releases/versions.html" >&2
|
||||
fi
|
||||
|
||||
# --- Toolchain provenance -------------------------------------------
|
||||
#
|
||||
# rust-toolchain.toml pins an exact compiler, and rustup honours it
|
||||
# wherever rustup has binaries. aarch64 FreeBSD is not such a place:
|
||||
#
|
||||
# rustup target add aarch64-unknown-freebsd
|
||||
# -> no prebuilt artifacts available for target
|
||||
# (and on the platform itself)
|
||||
# -> installer for platform 'aarch64-unknown-freebsd' not found
|
||||
#
|
||||
# so an ARM build uses the ports Rust, and the ports cargo ignores
|
||||
# rust-toolchain.toml entirely. The pin is therefore not a property
|
||||
# every build of this package has. Rather than let that be silent, the
|
||||
# version actually used is checked here and recorded in the package.
|
||||
RUST_VERSION="$(rustc --version 2>/dev/null | awk '{print $2}')"
|
||||
[ -n "$RUST_VERSION" ] \
|
||||
|| { echo "error: no rustc on PATH" >&2; exit 1; }
|
||||
PINNED_VERSION="$(sed -n 's/.*channel *= *"\([^"]*\)".*/\1/p' \
|
||||
"${PROJECT_ROOT}/rust-toolchain.toml" 2>/dev/null | head -1)"
|
||||
|
||||
# True when $1 is strictly older than $2, comparing dotted numerics.
|
||||
version_lt() {
|
||||
[ "$1" != "$2" ] || return 1
|
||||
[ "$(printf '%s\n%s\n' "$1" "$2" \
|
||||
| sort -t. -k1,1n -k2,2n -k3,3n | head -1)" = "$1" ]
|
||||
}
|
||||
|
||||
# Edition 2024 needs 1.85. Below it the build dies deep inside a
|
||||
# dependency with a message that never names the real cause, so refuse
|
||||
# up front where the error can say what is wrong.
|
||||
if version_lt "$RUST_VERSION" "1.85.0"; then
|
||||
echo "error: rustc ${RUST_VERSION} is below the edition-2024 floor (1.85.0)." >&2
|
||||
echo "error: This crate cannot be built with it." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PIN_HONOURED="yes"
|
||||
if [ -n "$PINNED_VERSION" ] && [ "$RUST_VERSION" != "$PINNED_VERSION" ]; then
|
||||
PIN_HONOURED="no"
|
||||
echo "notice: rustc ${RUST_VERSION} is NOT the pinned ${PINNED_VERSION}." >&2
|
||||
echo "notice: rust-toolchain.toml pins ${PINNED_VERSION}; this build does not" >&2
|
||||
echo "notice: honour it. Expected on aarch64, where rustup ships no toolchain;" >&2
|
||||
echo "notice: unexpected anywhere rustup works, and worth investigating there." >&2
|
||||
echo "notice: The package records this — see 'pkg info -A'." >&2
|
||||
fi
|
||||
|
||||
# Where the compiler came from, when it came from ports (ARM). Pinning
|
||||
# the ports package version is the only reproducibility available on a
|
||||
# platform rustup does not serve.
|
||||
RUST_PKG="$(pkg info -q rust 2>/dev/null | head -1)"
|
||||
|
||||
# Static linking is unusable on aarch64 FreeBSD, so the default does not
|
||||
# apply there. A statically linked aarch64 binary faults at addr=0x0
|
||||
# exactly where posix_spawn should be, killing the process the first time
|
||||
# it spawns anything:
|
||||
#
|
||||
# openat("/dev/null", O_RDONLY|O_CLOEXEC) = 9
|
||||
# pipe2() = 0
|
||||
# pipe2() = 0
|
||||
# SIGNAL 11 (SIGSEGV) code=SEGV_MAPERR addr=0x0
|
||||
#
|
||||
# The same trace on static amd64 reaches rfork(RFSPAWN) and spawns
|
||||
# normally, so this is specific to the architecture, not to static
|
||||
# linking. FIPS spawns sysctl in is_ipv6_disabled() at the top of
|
||||
# TunDevice::create, so the daemon dies during TUN setup and looks like a
|
||||
# TUN bug; with tun.enabled false it never spawns and appears healthy.
|
||||
#
|
||||
# Refused rather than silently downgraded: a package that quietly linked
|
||||
# differently from what was asked is how the wrong artifact ships.
|
||||
if [ "$STATIC" -eq 1 ] && [ "$ARCH" = "aarch64" ]; then
|
||||
echo "error: --static is not supported on aarch64: a statically linked" >&2
|
||||
echo "error: aarch64 FreeBSD binary segfaults at posix_spawn, so the daemon" >&2
|
||||
echo "error: dies the first time it shells out (sysctl, during TUN setup)." >&2
|
||||
echo "error: Build with --dynamic and check ldd on the target, since that" >&2
|
||||
echo "error: reintroduces the base-drift exposure static linking removes." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$NO_BUILD" -eq 0 ]; then
|
||||
pkg_cargo_build "$PROJECT_ROOT" "$TARGET"
|
||||
fi
|
||||
|
||||
pkg_require_binaries "$BIN_DIR" fips fipsctl fipstop
|
||||
|
||||
# crt-static is a request, not a guarantee: a target that does not
|
||||
# respect it still links dynamically and says nothing. Shipping a
|
||||
# dynamic binary while believing it static would put back exactly the
|
||||
# drift this flag exists to remove, so check the artifact, not the flag.
|
||||
if [ "$STATIC" -eq 1 ]; then
|
||||
for bin in fips fipsctl fipstop; do
|
||||
if ! file -b "${BIN_DIR}/${bin}" | grep -q 'statically linked'; then
|
||||
echo "error: --static was requested but ${bin} is not statically linked:" >&2
|
||||
echo "error: $(file -b "${BIN_DIR}/${bin}")" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "==> verified: all three binaries are statically linked"
|
||||
fi
|
||||
|
||||
|
||||
# The ABI in the manifest is an assertion about the binaries, and pkg
|
||||
# believes it without looking. Check it against what is actually in
|
||||
# BIN_DIR, on every build.
|
||||
#
|
||||
# The --target cross-check above cannot cover this: it compares the
|
||||
# requested triple with the requested ABI, so a host build that names a
|
||||
# foreign ABI (`--abi FreeBSD:15:aarch64` with no --target) passes it and
|
||||
# then packages the host's binaries under a foreign arch. That produces
|
||||
# a package which installs on the appliance and cannot exec — the exact
|
||||
# failure the naming rules exist to prevent. check-pfsense-pkg.sh catches
|
||||
# it, but only if someone runs it; the build should not emit it at all.
|
||||
case "$ARCH" in
|
||||
amd64) arch_signature="x86-64" ;;
|
||||
aarch64) arch_signature="aarch64" ;;
|
||||
i386) arch_signature="Intel 80386" ;;
|
||||
*) arch_signature="" ;;
|
||||
esac
|
||||
if [ -n "$arch_signature" ]; then
|
||||
binary_description="$(file -b "${BIN_DIR}/fips" 2>/dev/null)"
|
||||
case "$binary_description" in
|
||||
*"$arch_signature"*) ;;
|
||||
*)
|
||||
echo "error: the ABI says ${ARCH} (${ABI}), but ${BIN_DIR}/fips is not:" >&2
|
||||
echo "error: ${binary_description}" >&2
|
||||
echo "error: pkg trusts the manifest, so this package would install on" >&2
|
||||
echo "error: the appliance and then fail to exec. Build for ${ARCH}, or" >&2
|
||||
echo "error: correct --abi." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
STAGE="$(mktemp -d "${TMPDIR:-/tmp}/fips-pfsense-pkg.XXXXXX")"
|
||||
WORK="$(mktemp -d "${TMPDIR:-/tmp}/fips-pfsense-work.XXXXXX")"
|
||||
trap 'rm -rf "$STAGE" "$WORK"' EXIT
|
||||
|
||||
echo "==> staging into ${STAGE}"
|
||||
pkg_stage_tree "$STAGE"
|
||||
pkg_stage_binaries "$BIN_DIR" "$STAGE" fips fipsctl fipstop
|
||||
|
||||
# --- Configuration sample -------------------------------------------
|
||||
#
|
||||
# The shipped config is the common default with its `dns:` block
|
||||
# replaced by the pfSense one in fips.yaml.dns (which records why the
|
||||
# responder binds IPv4 loopback here). Everything else — identity,
|
||||
# transports, peers — is tracked from packaging/common/fips.yaml like
|
||||
# every other platform's package, so it does not drift.
|
||||
#
|
||||
# Owning one block silently would hide a new dns option added upstream,
|
||||
# so the key set of the common block is checked first: anything beyond
|
||||
# what fips.yaml.dns already accounts for fails the build.
|
||||
COMMON_CONFIG="${PROJECT_ROOT}/packaging/common/fips.yaml"
|
||||
DNS_BLOCK="${SCRIPT_DIR}/fips.yaml.dns"
|
||||
STAGED_CONFIG="${STAGE}/usr/local/etc/fips/fips.yaml.sample"
|
||||
|
||||
# Active (uncommented) keys in the common config's dns block. bind_addr
|
||||
# is commented out there, so it is deliberately not in this list.
|
||||
EXPECTED_DNS_KEYS="enabled port"
|
||||
|
||||
actual_dns_keys="$(awk '
|
||||
/^[^[:space:]#]/ { section = $1; sub(/:.*/, "", section); next }
|
||||
section != "dns" { next }
|
||||
/^[[:space:]]*#/ { next }
|
||||
/^[[:space:]]+[a-z_]+:/ {
|
||||
key = $1; sub(/:.*/, "", key); print key
|
||||
}
|
||||
' "$COMMON_CONFIG" | tr '\n' ' ' | sed 's/ *$//')"
|
||||
|
||||
if [ "$actual_dns_keys" != "$EXPECTED_DNS_KEYS" ]; then
|
||||
echo "error: the dns: block in ${COMMON_CONFIG} has changed." >&2
|
||||
echo "error: expected keys: ${EXPECTED_DNS_KEYS}" >&2
|
||||
echo "error: found keys: ${actual_dns_keys}" >&2
|
||||
echo "error: This package substitutes its own dns: block, so a new or" >&2
|
||||
echo "error: renamed option there would be dropped silently. Fold the" >&2
|
||||
echo "error: change into ${DNS_BLOCK} and update EXPECTED_DNS_KEYS." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Splice: copy the common config, replacing the top-level dns: block.
|
||||
# Skipping runs until the next top-level key, so the blank and indented
|
||||
# lines inside the block go with it.
|
||||
awk -v blockfile="$DNS_BLOCK" '
|
||||
BEGIN {
|
||||
while ((getline line < blockfile) > 0) { block = block line "\n" }
|
||||
if (block == "") { print "error: dns block file is empty" > "/dev/stderr"; exit 1 }
|
||||
}
|
||||
/^dns:[[:space:]]*$/ { replaced++; printf "%s", block; skipping = 1; next }
|
||||
skipping && /^[^[:space:]#]/ { skipping = 0 }
|
||||
skipping { next }
|
||||
{ print }
|
||||
END {
|
||||
if (replaced != 1) {
|
||||
printf "error: replaced %d dns: blocks, expected exactly 1\n", replaced > "/dev/stderr"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
' "$COMMON_CONFIG" > "${WORK}/fips.yaml"
|
||||
|
||||
# Belt and braces: the staged config must actually carry the pfSense
|
||||
# bind address, and must not still carry the ::1 default.
|
||||
grep -Fqx ' bind_addr: "127.0.0.1"' "${WORK}/fips.yaml" || {
|
||||
echo "error: staged config is missing the pfSense dns.bind_addr line" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# fips.yaml may hold a node private key (nsec:), so it is never
|
||||
# world-readable — 0600, like the Debian, macOS and FreeBSD packages.
|
||||
install -m 0600 "${WORK}/fips.yaml" "$STAGED_CONFIG"
|
||||
|
||||
install -m 0644 "${PROJECT_ROOT}/packaging/common/hosts" \
|
||||
"${STAGE}/usr/local/etc/fips/hosts.sample"
|
||||
install -m 0644 "${SCRIPT_DIR}/fips.conf" \
|
||||
"${STAGE}/usr/local/etc/fips/fips.conf.sample"
|
||||
# Not a .sample: it is the source fips.sh copies into
|
||||
# /var/etc/newsyslog.conf.d at every start, never edited in place.
|
||||
install -m 0644 "${SCRIPT_DIR}/fips.newsyslog" \
|
||||
"${STAGE}/usr/local/etc/fips/fips.newsyslog"
|
||||
|
||||
install -m 0755 "${SCRIPT_DIR}/fips.sh" "${STAGE}/usr/local/etc/rc.d/fips.sh"
|
||||
|
||||
install -m 0755 "${SCRIPT_DIR}/fips-dns-setup" \
|
||||
"${SCRIPT_DIR}/fips-dns-teardown" \
|
||||
"${STAGE}/usr/local/libexec/fips/"
|
||||
install -m 0644 "${SCRIPT_DIR}/fips-unbound-custom.php" \
|
||||
"${STAGE}/usr/local/libexec/fips/"
|
||||
|
||||
# Record what produced this package, in the package. "Which compiler
|
||||
# built this, and is it statically linked?" should be answerable from
|
||||
# the artifact via `pkg info -A fips`, not from the memory of whoever
|
||||
# ran the build — especially on aarch64, where the answer is not the
|
||||
# pinned toolchain.
|
||||
LINKAGE="dynamic"
|
||||
[ "$STATIC" -eq 1 ] && LINKAGE="static"
|
||||
ANNOTATIONS=" pfsense_products: \"${PRODUCTS}\"
|
||||
built_with: \"rustc ${RUST_VERSION}\"
|
||||
toolchain_pin: \"${PINNED_VERSION:-unset}\"
|
||||
pin_honoured: \"${PIN_HONOURED}\"
|
||||
linkage: \"${LINKAGE}\""
|
||||
if [ -n "$RUST_PKG" ]; then
|
||||
ANNOTATIONS="${ANNOTATIONS}
|
||||
rust_pkg: \"${RUST_PKG}\""
|
||||
fi
|
||||
|
||||
DESC="$(cat "${SCRIPT_DIR}/pkg-descr")"
|
||||
|
||||
# The config files get @sample semantics — copied into place on install
|
||||
# if absent, removed on deinstall only if unmodified — but spelled out as
|
||||
# manifest scripts: the @sample plist keyword lives in the ports tree
|
||||
# (/usr/ports/Keywords/sample.ucl), which neither a plain pkg-create
|
||||
# host nor pfSense itself has.
|
||||
cat > "${STAGE}/+MANIFEST" <<EOF
|
||||
$(pkg_manifest_header "$VERSION" "$ABI" "Self-organizing encrypted mesh network on Nostr identities (pfSense build)" "$DESC")
|
||||
annotations: {
|
||||
${ANNOTATIONS}
|
||||
}
|
||||
scripts: {
|
||||
post-install: <<EOD
|
||||
# Control-socket access group: fips.sh creates /var/run/fips as
|
||||
# root:fips 0750, so members can use fipsctl/fipstop without root.
|
||||
$(pkg_group_script)
|
||||
# Install-if-absent config. fips.yaml may hold a node private key
|
||||
# (nsec:), so it is 0600; FreeBSD has no "root" group, wheel is gid 0.
|
||||
$(pkg_sample_seed_script fips.yaml:0600 hosts:0644 fips.conf:0644)
|
||||
# pkg upgrade runs the old package's pre-deinstall, which stops the
|
||||
# daemon and leaves this marker if it had been running. Bring it back up
|
||||
# on the new binaries, and only then.
|
||||
if [ -f /var/run/fips/upgrade-restart ]; then
|
||||
rm -f /var/run/fips/upgrade-restart
|
||||
/usr/local/etc/rc.d/fips.sh onestart >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ "\${PKG_UPGRADE:-}" != "true" ]; then
|
||||
echo ""
|
||||
echo "FIPS installed. To finish:"
|
||||
echo " 1. vi /usr/local/etc/fips/fips.yaml # identity and peers"
|
||||
echo " 2. /usr/local/etc/rc.d/fips.sh start"
|
||||
echo " 3. /usr/local/libexec/fips/fips-dns-setup # .fips in the DNS Resolver"
|
||||
echo ""
|
||||
echo "Step 3 edits config.xml, so it is not run for you. It is"
|
||||
echo "revertable from Diagnostics > Backup & Restore > Config History."
|
||||
echo ""
|
||||
echo "The mesh needs 'Allow IPv6' (System > Advanced > Networking),"
|
||||
echo "which is on by default. If it has been turned off, pfSense blocks"
|
||||
echo "all IPv6 and the fd00::/8 mesh cannot pass traffic even though"
|
||||
echo ".fips still resolves — see the README."
|
||||
echo ""
|
||||
echo "Not a Netgate-supported package. A firmware upgrade keeps it"
|
||||
echo "(it is not a pfSense-pkg-*); after a major upgrade, reinstall the"
|
||||
echo "package built for the new base. To upgrade the package itself use"
|
||||
echo "'pkg install ./<file>.pkg', then 'fips.sh restart'."
|
||||
fi
|
||||
EOD
|
||||
pre-deinstall: <<EOD
|
||||
# Stop the daemon so its binary is never replaced (upgrade) or removed
|
||||
# (deinstall) underneath a running process, and remember whether it was
|
||||
# running so post-install can restore that state on upgrade.
|
||||
if /usr/local/etc/rc.d/fips.sh status >/dev/null 2>&1; then
|
||||
if [ "\${PKG_UPGRADE:-}" = "true" ]; then
|
||||
mkdir -p /var/run/fips && touch /var/run/fips/upgrade-restart
|
||||
fi
|
||||
fi
|
||||
/usr/local/etc/rc.d/fips.sh stop >/dev/null 2>&1 || true
|
||||
if [ "\${PKG_UPGRADE:-}" != "true" ]; then
|
||||
# Removal: take the .fips block back out of config.xml. Left behind,
|
||||
# it would forward the fips. zone to a port nothing listens on.
|
||||
if ! /usr/local/libexec/fips/fips-dns-teardown; then
|
||||
echo "fips: the .fips block is still in the DNS Resolver custom options;"
|
||||
echo "fips: remove it by hand under Services > DNS Resolver > Custom options,"
|
||||
echo "fips: or the fips. zone stays forwarded to a port nothing listens on."
|
||||
fi
|
||||
$(pkg_sample_purge_script fips.yaml hosts fips.conf)
|
||||
fi
|
||||
EOD
|
||||
}
|
||||
EOF
|
||||
|
||||
cat > "${STAGE}/pkg-plist" <<'EOF'
|
||||
bin/fips
|
||||
bin/fipsctl
|
||||
bin/fipstop
|
||||
etc/fips/fips.conf.sample
|
||||
etc/fips/fips.newsyslog
|
||||
etc/fips/fips.yaml.sample
|
||||
etc/fips/hosts.sample
|
||||
etc/rc.d/fips.sh
|
||||
libexec/fips/fips-dns-setup
|
||||
libexec/fips/fips-dns-teardown
|
||||
libexec/fips/fips-unbound-custom.php
|
||||
@dir etc/fips
|
||||
EOF
|
||||
|
||||
OUT="${PROJECT_ROOT}/deploy/fips-${VERSION}-pfsense-${PRODUCT_TAG}-${ARCH}.pkg"
|
||||
|
||||
pkg_create_package "$STAGE" "${PROJECT_ROOT}/deploy" "$VERSION" "$OUT"
|
||||
Executable
+223
@@ -0,0 +1,223 @@
|
||||
#!/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, whereas everything this package
|
||||
# installs under /usr/local does not. So the .fips zone keeps resolving
|
||||
# across an upgrade that removes 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"
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
# fips-dns-teardown - remove the .fips DNS routing added by fips-dns-setup.
|
||||
#
|
||||
# Deliberately separate from package removal: the block lives in
|
||||
# config.xml, which outlives the package (see fips-dns-setup for why
|
||||
# that is the point), so leaving it behind on a `pkg delete` would leave
|
||||
# the firewall forwarding fips. to a port nothing listens on. The
|
||||
# package's pre-deinstall calls this on removal; run it by hand to undo
|
||||
# the DNS integration while keeping the daemon.
|
||||
|
||||
set -eu
|
||||
|
||||
PHP_HELPER="/usr/local/libexec/fips/fips-unbound-custom.php"
|
||||
|
||||
log() { echo "fips-dns: $*"; }
|
||||
|
||||
if [ ! -f /etc/inc/config.inc ] || [ ! -x /usr/local/bin/php ]; then
|
||||
log "not a pfSense system (no /etc/inc/config.inc or php); nothing to remove"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -f "$PHP_HELPER" ]; then
|
||||
log "WARNING: ${PHP_HELPER} is missing, so the .fips block cannot be removed"
|
||||
log "WARNING: automatically. Delete the block between the FIPS markers under"
|
||||
log "WARNING: Services > DNS Resolver > Custom options and save."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
/usr/local/bin/php -f "$PHP_HELPER" -- remove
|
||||
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
/*
|
||||
* fips-unbound-custom.php - add or remove the FIPS .fips forward-zone in
|
||||
* the pfSense DNS Resolver "Custom options" box.
|
||||
*
|
||||
* pfSense stores that box base64-encoded at unbound/custom_options in
|
||||
* config.xml and splices it into the generated /var/unbound/unbound.conf
|
||||
* verbatim (see unbound_generate_config() in /etc/inc/unbound.inc). It
|
||||
* is the only operator-writable surface in that file: every include:
|
||||
* unbound.inc emits names a specific generated file, and there is no
|
||||
* conf.d directory to drop into.
|
||||
*
|
||||
* The box belongs to the operator, who may well have their own lines in
|
||||
* it, so this only ever touches the text between its own markers and
|
||||
* leaves the rest byte-for-byte alone.
|
||||
*
|
||||
* Usage:
|
||||
* fips-unbound-custom.php add # snippet on stdin
|
||||
* fips-unbound-custom.php remove
|
||||
*
|
||||
* Invoked by fips-dns-setup and fips-dns-teardown; see
|
||||
* packaging/pfsense/README.md.
|
||||
*/
|
||||
|
||||
/* Under FIPS_UNBOUND_HELPER_TEST the file only defines its functions, so
|
||||
* fips_strip_block() can be unit-tested where pfSense's includes do not
|
||||
* exist. Everything that touches config.xml stays below that guard. */
|
||||
if (!getenv('FIPS_UNBOUND_HELPER_TEST')) {
|
||||
require_once("config.inc");
|
||||
require_once("util.inc");
|
||||
require_once("unbound.inc");
|
||||
}
|
||||
|
||||
define('FIPS_BEGIN', '# BEGIN FIPS - managed by fips-dns-setup, do not edit this block');
|
||||
define('FIPS_END', '# END FIPS');
|
||||
|
||||
function fips_log($msg) {
|
||||
fwrite(STDERR, "fips-dns: {$msg}\n");
|
||||
}
|
||||
|
||||
/* pfSense 2.8 uses the config_*_path() accessors; older branches only
|
||||
* have the $config global. Support both rather than pinning this script
|
||||
* to one branch of a firewall people upgrade in place. */
|
||||
function fips_config_get_custom_options() {
|
||||
if (function_exists('config_get_path')) {
|
||||
return (string) config_get_path('unbound/custom_options', '');
|
||||
}
|
||||
global $config;
|
||||
return isset($config['unbound']['custom_options'])
|
||||
? (string) $config['unbound']['custom_options'] : '';
|
||||
}
|
||||
|
||||
function fips_config_set_custom_options($value) {
|
||||
if (function_exists('config_set_path')) {
|
||||
config_set_path('unbound/custom_options', $value);
|
||||
return;
|
||||
}
|
||||
global $config;
|
||||
$config['unbound']['custom_options'] = $value;
|
||||
}
|
||||
|
||||
function fips_resolver_enabled() {
|
||||
if (function_exists('config_path_enabled')) {
|
||||
return config_path_enabled('unbound');
|
||||
}
|
||||
global $config;
|
||||
return !empty($config['unbound']['enable']);
|
||||
}
|
||||
|
||||
/* Drop any existing FIPS block, returning the operator's own text. A
|
||||
* partial block (someone deleted one marker by hand in the GUI) is left
|
||||
* alone and reported, because guessing where it ended would be as
|
||||
* likely to eat their configuration as to fix ours. */
|
||||
function fips_strip_block($text, &$had_block, &$malformed) {
|
||||
$had_block = false;
|
||||
$malformed = false;
|
||||
|
||||
$begin = strpos($text, FIPS_BEGIN);
|
||||
$end = strpos($text, FIPS_END);
|
||||
|
||||
if ($begin === false && $end === false) {
|
||||
return $text;
|
||||
}
|
||||
if ($begin === false || $end === false || $end < $begin) {
|
||||
$malformed = true;
|
||||
return $text;
|
||||
}
|
||||
|
||||
$had_block = true;
|
||||
/* Cut exactly what fips_add_block() inserted, and no operator text.
|
||||
* fips_add_block puts a newline before BEGIN only when text precedes
|
||||
* it, and a newline after END. Consume the trailing newline always,
|
||||
* but the leading one only when nothing follows the block — otherwise
|
||||
* removing it would splice the line before BEGIN onto the line after
|
||||
* END (e.g. a comment above the block onto an option added below it).
|
||||
* So an add-then-remove restores the original byte for byte, and text
|
||||
* added after the block survives on its own line. */
|
||||
$stop = $end + strlen(FIPS_END);
|
||||
if (substr($text, $stop, 1) === "\n") {
|
||||
$stop += 1;
|
||||
}
|
||||
$tail = substr($text, $stop);
|
||||
$start = ($tail === '' && $begin > 0 && $text[$begin - 1] === "\n")
|
||||
? $begin - 1 : $begin;
|
||||
return substr($text, 0, $start) . $tail;
|
||||
}
|
||||
|
||||
/* Append the FIPS block to the operator's text without altering it. */
|
||||
function fips_add_block($text, $snippet) {
|
||||
$prefix = ($text === '') ? '' : "\n";
|
||||
return $text . $prefix . FIPS_BEGIN . "\n" . $snippet . "\n" . FIPS_END . "\n";
|
||||
}
|
||||
|
||||
if (getenv('FIPS_UNBOUND_HELPER_TEST')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$action = isset($argv[1]) ? $argv[1] : '';
|
||||
if ($action !== 'add' && $action !== 'remove') {
|
||||
fwrite(STDERR, "usage: {$argv[0]} add|remove\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$existing = base64_decode(fips_config_get_custom_options(), true);
|
||||
if ($existing === false) {
|
||||
fips_log("ERROR: unbound/custom_options in config.xml is not valid base64; refusing to touch it.");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$had_block = false;
|
||||
$malformed = false;
|
||||
$operator_text = fips_strip_block($existing, $had_block, $malformed);
|
||||
|
||||
if ($malformed) {
|
||||
fips_log("ERROR: found only one of the FIPS begin/end markers in the DNS Resolver");
|
||||
fips_log("ERROR: custom options. Remove the partial block by hand in Services >");
|
||||
fips_log("ERROR: DNS Resolver > Custom options, then re-run this command.");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if ($action === 'add') {
|
||||
$snippet = rtrim(stream_get_contents(STDIN), "\r\n");
|
||||
if ($snippet === '') {
|
||||
fips_log("ERROR: no snippet on stdin");
|
||||
exit(1);
|
||||
}
|
||||
$new = fips_add_block($operator_text, $snippet);
|
||||
$desc = $had_block
|
||||
? "fips: update .fips forward-zone in DNS Resolver custom options"
|
||||
: "fips: add .fips forward-zone to DNS Resolver custom options";
|
||||
} else {
|
||||
if (!$had_block) {
|
||||
fips_log("no FIPS block in the DNS Resolver custom options; nothing to remove");
|
||||
exit(0);
|
||||
}
|
||||
$new = $operator_text;
|
||||
$desc = "fips: remove .fips forward-zone from DNS Resolver custom options";
|
||||
}
|
||||
|
||||
if ($new === $existing) {
|
||||
fips_log("DNS Resolver custom options already match; leaving config.xml untouched");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
/* Validate the merged options the way the GUI does before saving them.
|
||||
* unbound.conf is generated wholesale from config.xml, so a merge that
|
||||
* does not parse would take DNS away from every client behind the
|
||||
* firewall on the restart below. test_unbound_config() renders the
|
||||
* candidate config into a scratch directory and runs unbound-checkconf
|
||||
* over it; guarded with function_exists like the other accessors, since
|
||||
* older branches do not have it. */
|
||||
if (function_exists('test_unbound_config')) {
|
||||
$live = function_exists('config_get_path')
|
||||
? config_get_path('unbound', array())
|
||||
: (isset($config['unbound']) ? $config['unbound'] : array());
|
||||
if (!is_array($live)) {
|
||||
$live = array();
|
||||
}
|
||||
// Same shape as pfSense's own save path (services_unbound.php): merge
|
||||
// the candidate custom_options over the live unbound config and hand
|
||||
// it to test_unbound_config, which renders the full unbound.conf and
|
||||
// runs unbound-checkconf. It returns the checker's exit code, so a
|
||||
// NON-zero result is the failure — matching core, which does
|
||||
// `if (test_unbound_config(...)) { $input_errors[] = ... }`. (A
|
||||
// re-apply draws a benign "duplicate forward zone ... ignored"
|
||||
// warning from the test render; checkconf still exits 0, which is a
|
||||
// pass, exactly as it is for a forward-zone entered in the GUI box.)
|
||||
$candidate = array_merge($live, array(
|
||||
'custom_options' => ($new === '') ? '' : base64_encode($new),
|
||||
));
|
||||
$check_output = array();
|
||||
if (test_unbound_config($candidate, $check_output)) {
|
||||
fips_log("ERROR: the merged DNS Resolver custom options do not pass unbound-checkconf;");
|
||||
fips_log("ERROR: config.xml has NOT been modified. unbound-checkconf said:");
|
||||
foreach ((array) $check_output as $line) {
|
||||
fips_log("ERROR: " . rtrim($line));
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fips_config_set_custom_options($new === '' ? '' : base64_encode($new));
|
||||
|
||||
/* write_config() records a config-history entry, so this edit is
|
||||
* revertable from Diagnostics > Backup & Restore > Config History. */
|
||||
write_config($desc);
|
||||
|
||||
if (!fips_resolver_enabled()) {
|
||||
$addr = isset($argv[2]) ? $argv[2] : '<bind_addr>';
|
||||
$port = isset($argv[3]) ? $argv[3] : '<port>';
|
||||
fips_log("WARNING: the DNS Resolver (unbound) is not enabled, so the block just");
|
||||
fips_log("WARNING: written has no effect until it is. If this firewall uses the DNS");
|
||||
fips_log("WARNING: Forwarder (dnsmasq) instead, add this line under Services > DNS");
|
||||
fips_log("WARNING: Forwarder > Advanced Options and run fips-dns-teardown:");
|
||||
fips_log("WARNING: server=/fips/{$addr}#{$port}");
|
||||
/* Distinct from success so the caller does not announce a working setup. */
|
||||
exit(3);
|
||||
}
|
||||
|
||||
/* Regenerate /var/unbound/unbound.conf from config.xml and restart the
|
||||
* resolver. Nothing shorter works: the file is generated wholesale, so
|
||||
* a reload alone would re-read the config we have not rewritten yet. */
|
||||
sync_unbound_service();
|
||||
|
||||
fips_log("DNS Resolver updated and restarted");
|
||||
exit(0);
|
||||
@@ -0,0 +1,22 @@
|
||||
# Knobs for /usr/local/etc/rc.d/fips.sh on pfSense.
|
||||
#
|
||||
# This file replaces the /etc/rc.conf entries the stock FreeBSD package
|
||||
# uses. /etc/rc.conf belongs to the pfSense base image and is replaced
|
||||
# by a firmware upgrade, so a `sysrc fips_enable=YES` there would be
|
||||
# lost without warning; this file sits beside fips.yaml instead and is
|
||||
# preserved by pkg the same way.
|
||||
#
|
||||
# It is sourced by /bin/sh, so it is shell syntax, not YAML.
|
||||
|
||||
# Start the daemon at boot. Set to NO to keep the package installed but
|
||||
# dormant; `fips.sh onestart` still starts it by hand.
|
||||
fips_enable="YES"
|
||||
|
||||
# Daemon configuration file.
|
||||
#fips_config="/usr/local/etc/fips/fips.yaml"
|
||||
|
||||
# Where the daemon's stdout and stderr are appended.
|
||||
#fips_logfile="/var/log/fips.log"
|
||||
|
||||
# Extra arguments passed through to the daemon.
|
||||
#fips_flags=""
|
||||
@@ -0,0 +1,15 @@
|
||||
# newsyslog(8) rotation for the FIPS daemon log. Installed by the package
|
||||
# under /usr/local/etc/fips and copied by fips.sh into
|
||||
# /var/etc/newsyslog.conf.d/fips.conf on every start, because pfSense
|
||||
# regenerates /etc/newsyslog.conf itself and, on a box using RAM disks,
|
||||
# rebuilds /var/etc at boot — a file put there by pkg would not survive.
|
||||
#
|
||||
# 5 generations, rotate at 1000 KB, compressed (J = bzip2), C = create the
|
||||
# file if missing. The pid file is the daemon(8) supervisor's, so after
|
||||
# rotation newsyslog sends it SIGHUP; daemon(8) is started with -H, which
|
||||
# makes it close and reopen its -o output file on SIGHUP. Without that the
|
||||
# daemon would keep writing to the rotated inode, and once the compressed
|
||||
# generation is unlinked it would write into a deleted file — on a RAM-disk
|
||||
# /var, space never reclaimed and /var/log/fips.log left empty.
|
||||
# logfilename [owner:group] mode count size when flags [/pid_file] [sig_num]
|
||||
/var/log/fips.log 644 5 1000 * JC /var/run/fips/daemon.pid
|
||||
Executable
+302
@@ -0,0 +1,302 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,19 @@
|
||||
dns:
|
||||
enabled: true
|
||||
# Bound to IPv4 loopback rather than the ::1 default. pfSense
|
||||
# generates unbound.conf with `do-ip6: no` unless System > Advanced >
|
||||
# Networking has "Allow IPv6" set, and in that state unbound cannot
|
||||
# reach a responder on [::1] at all: every .fips query fails with
|
||||
# nothing in any log naming the cause. IPv4 loopback works either way
|
||||
# (unbound's do-ip4 is hardcoded yes), and the answers are AAAA
|
||||
# records regardless of the transport that carried the query.
|
||||
#
|
||||
# A firewall with "Allow IPv6" off is a separate matter: pfSense then
|
||||
# emits `block ... quick inet6 all`, which no rule overrides, so the
|
||||
# fd00::/8 mesh cannot pass traffic. That setting is ON by default, so
|
||||
# most installs are unaffected; the IPv4 bind here is robustness against
|
||||
# the do-ip6 case above, not a workaround for a failure a default
|
||||
# install would hit. See packaging/pfsense/README.md.
|
||||
bind_addr: "127.0.0.1"
|
||||
port: 5354
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
FIPS (Free Internetworking Peering System) is a self-organizing
|
||||
encrypted mesh network built on Nostr identities, operating over
|
||||
arbitrary transports (UDP, TCP, Tor) with no central infrastructure.
|
||||
|
||||
A TUN interface maps each remote peer to an fd00::/8 address,
|
||||
so unmodified IPv6 applications work over the mesh, with a built-in
|
||||
DNS responder for <npub>.fips names. Peer discovery and NAT traversal
|
||||
are Nostr-mediated; traffic is encrypted hop-by-hop (Noise IK) and
|
||||
end-to-end (Noise XK) with periodic rekey.
|
||||
|
||||
This is the pfSense build: it ships the fips daemon, the fipsctl
|
||||
control CLI, the fipstop live-status TUI, a boot script pfSense
|
||||
actually runs (/usr/local/etc/rc.d/fips.sh), and helpers that wire the
|
||||
.fips zone into the DNS Resolver through config.xml. It is not a
|
||||
Netgate-supported package and has no GUI. A firmware upgrade keeps it
|
||||
(it is a plain pkg, not a pfSense-pkg-*); after a major base change,
|
||||
reinstall the package built for the new base.
|
||||
Executable
+571
@@ -0,0 +1,571 @@
|
||||
#!/usr/bin/env bash
|
||||
# ── pfSense package guard ───────────────────────────────────────────────────
|
||||
# Validates a built pfSense .pkg and the scripts inside it.
|
||||
#
|
||||
# The pfSense package cannot be exercised the way the Docker suites exercise
|
||||
# mesh behaviour: pfSense is not a container image, and Netgate publishes no
|
||||
# base image to build one from. What IS checkable without a firewall — and is
|
||||
# where this package's mistakes would actually live — is the handful of
|
||||
# pfSense-specific decisions that separate it from the FreeBSD package, each of
|
||||
# which fails silently on a real box if it regresses:
|
||||
#
|
||||
# - the boot script must be named *.sh, or pfSense's rc.start_packages glob
|
||||
# never runs it and the daemon simply never starts, with no error anywhere;
|
||||
# - `start` must be idempotent, because that same glob is re-run on every
|
||||
# interface link change, IP change and gateway event;
|
||||
# - a recycled pid must not be mistaken for a running daemon, which would
|
||||
# make `start` a permanent no-op;
|
||||
# - the shipped config must carry the IPv4 loopback bind, because a config
|
||||
# that kept the ::1 default resolves nothing on a firewall with "Allow
|
||||
# IPv6" off and says nothing about why;
|
||||
# - fips.yaml must be 0600, because it may hold a node private key (nsec:).
|
||||
#
|
||||
# What this does NOT cover, stated so nobody reads a pass as more than it is:
|
||||
# installing on pfSense, the config.xml edit in fips-unbound-custom.php (which
|
||||
# needs pfSense's PHP and its config.inc), unbound actually answering .fips,
|
||||
# and pf passing mesh traffic. Those are the manual steps in
|
||||
# packaging/pfsense/README.md.
|
||||
#
|
||||
# Usage: testing/check-pfsense-pkg.sh [<package.pkg>]
|
||||
# With no argument, builds one from target/release via build-pkg.sh --no-build.
|
||||
# FreeBSD only: it needs pkg(8) to have produced the package.
|
||||
#
|
||||
# Exit 0 = every check passed. Exit 1 = at least one failed. Exit 2 = the check
|
||||
# could not run; never treated as a pass.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
PFSENSE_DIR="$PROJECT_ROOT/packaging/pfsense"
|
||||
|
||||
failures=0
|
||||
checks=0
|
||||
|
||||
pass() { checks=$((checks + 1)); printf ' PASS %s\n' "$1"; }
|
||||
fail() {
|
||||
checks=$((checks + 1))
|
||||
failures=$((failures + 1))
|
||||
printf ' FAIL %s\n' "$1"
|
||||
[[ $# -gt 1 ]] && printf ' %s\n' "$2"
|
||||
return 0
|
||||
}
|
||||
bail() { printf 'check-pfsense-pkg: %s\n' "$1" >&2; exit 2; }
|
||||
# A check that could not run here. Printed, not counted: a skipped check
|
||||
# that reads as a pass would let a host without the tooling report a
|
||||
# cleaner result than a host with it.
|
||||
skip() { skipped=$((skipped + 1)); printf ' SKIP %s\n' "$1"; }
|
||||
skipped=0
|
||||
|
||||
# Probed by capability rather than by `uname -s`: what this needs is a
|
||||
# working pkg(8) to have produced and to read the package, and that is
|
||||
# also true on a host whose uname comes from a Linux compat layer.
|
||||
command -v pkg >/dev/null 2>&1 && pkg config abi >/dev/null 2>&1 \
|
||||
|| bail "needs pkg(8) — run this on the FreeBSD host that built the package"
|
||||
|
||||
PKG="${1:-}"
|
||||
if [[ -z "$PKG" ]]; then
|
||||
"$PFSENSE_DIR/build-pkg.sh" --no-build >/dev/null \
|
||||
|| bail "build-pkg.sh --no-build failed"
|
||||
PKG=$(ls -t "$PROJECT_ROOT"/deploy/fips-*-pfsense-*.pkg 2>/dev/null | head -1)
|
||||
fi
|
||||
[[ -n "$PKG" && -f "$PKG" ]] || bail "no package to check (pass one as \$1)"
|
||||
|
||||
WORK=$(mktemp -d "${TMPDIR:-/tmp}/fips-pfsense-check.XXXXXX") || bail "mktemp failed"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
echo "==> checking $(basename "$PKG")"
|
||||
|
||||
MANIFEST="$WORK/manifest.json"
|
||||
tar -xOf "$PKG" +MANIFEST > "$MANIFEST" 2>/dev/null \
|
||||
|| bail "cannot read +MANIFEST from $PKG"
|
||||
# Members are stored as absolute paths; bsdtar strips the leading "/" on
|
||||
# extract, so the payload lands under $WORK/usr/local.
|
||||
tar -xf "$PKG" -C "$WORK" 2>/dev/null || bail "cannot extract payload from $PKG"
|
||||
PAYLOAD="$WORK/usr/local"
|
||||
|
||||
# ── 1. Contents ─────────────────────────────────────────────────────────────
|
||||
echo "-- contents"
|
||||
|
||||
EXPECTED_PATHS="
|
||||
bin/fips
|
||||
bin/fipsctl
|
||||
bin/fipstop
|
||||
etc/fips/fips.conf.sample
|
||||
etc/fips/fips.newsyslog
|
||||
etc/fips/fips.yaml.sample
|
||||
etc/fips/hosts.sample
|
||||
etc/rc.d/fips.sh
|
||||
libexec/fips/fips-dns-setup
|
||||
libexec/fips/fips-dns-teardown
|
||||
libexec/fips/fips-unbound-custom.php
|
||||
"
|
||||
|
||||
for rel in $EXPECTED_PATHS; do
|
||||
if [[ -e "$PAYLOAD/$rel" ]]; then
|
||||
pass "ships $rel"
|
||||
else
|
||||
fail "missing $rel"
|
||||
fi
|
||||
done
|
||||
|
||||
# The whole reason this package exists separately from the FreeBSD one.
|
||||
if [[ -e "$PAYLOAD/etc/rc.d/fips" && ! -e "$PAYLOAD/etc/rc.d/fips.sh" ]]; then
|
||||
fail "boot script is rc.d/fips, not rc.d/fips.sh" \
|
||||
"pfSense's rc.start_packages globs *.sh; a suffixless script never runs."
|
||||
else
|
||||
pass "boot script carries the .sh suffix pfSense globs for"
|
||||
fi
|
||||
|
||||
for rel in etc/rc.d/fips.sh libexec/fips/fips-dns-setup libexec/fips/fips-dns-teardown; do
|
||||
if [[ -x "$PAYLOAD/$rel" ]]; then
|
||||
pass "$rel is executable"
|
||||
else
|
||||
fail "$rel is not executable"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── 2. Manifest ─────────────────────────────────────────────────────────────
|
||||
echo "-- manifest"
|
||||
|
||||
perm_of() {
|
||||
grep -o "\"/usr/local/$1\":{[^}]*}" "$MANIFEST" \
|
||||
| grep -o '"perm":"[0-7]*"' | head -1 | cut -d'"' -f4
|
||||
return 0
|
||||
}
|
||||
|
||||
# fips.yaml may hold a node private key (nsec:); every other platform's
|
||||
# package installs it 0600 and so must this one.
|
||||
yaml_perm=$(perm_of "etc/fips/fips.yaml.sample")
|
||||
if [[ "$yaml_perm" == "0600" ]]; then
|
||||
pass "fips.yaml.sample is 0600"
|
||||
else
|
||||
fail "fips.yaml.sample is ${yaml_perm:-unknown}, expected 0600" \
|
||||
"It may carry an nsec: private key."
|
||||
fi
|
||||
|
||||
abi=$(grep -o '"abi":"[^"]*"' "$MANIFEST" | head -1 | cut -d'"' -f4)
|
||||
if [[ "$abi" =~ ^FreeBSD:[0-9]+:[a-z0-9_]+$ ]]; then
|
||||
pass "manifest ABI is well-formed ($abi)"
|
||||
else
|
||||
fail "manifest ABI is malformed: ${abi:-unset}"
|
||||
fi
|
||||
# The ABI string is the only thing pkg checks on the appliance, and it is
|
||||
# just text in the manifest: a package labelled for one architecture and
|
||||
# filled with binaries for another installs cleanly and then cannot exec.
|
||||
# So compare the label against the binaries actually in the payload.
|
||||
abi_arch="${abi##*:}"
|
||||
file_out=$(file -b "$PAYLOAD/bin/fips" 2>/dev/null)
|
||||
case "$abi_arch" in
|
||||
amd64) want="x86-64" ;;
|
||||
aarch64) want="aarch64" ;;
|
||||
i386) want="Intel 80386" ;;
|
||||
*) want="" ;;
|
||||
esac
|
||||
if [[ -z "$want" ]]; then
|
||||
fail "unrecognised ABI architecture '${abi_arch}'" "$file_out"
|
||||
elif [[ "$file_out" == *"$want"* ]]; then
|
||||
pass "binaries are ${abi_arch}, matching the manifest ABI"
|
||||
else
|
||||
fail "manifest says ${abi_arch} but bin/fips is not ${want}" \
|
||||
"pkg trusts the label; the appliance would install this and fail to exec. file: ${file_out}"
|
||||
fi
|
||||
|
||||
|
||||
# Provenance annotations. These are the artifact's own record of which
|
||||
# compiler built it and how it was linked; a package that lost them
|
||||
# cannot answer "was this the pinned toolchain?" after the fact, which
|
||||
# is the whole point of recording them.
|
||||
ann_of() {
|
||||
grep -o "\"$1\":\"[^\"]*\"" "$MANIFEST" | head -1 | cut -d'"' -f4
|
||||
return 0
|
||||
}
|
||||
|
||||
built_with=$(ann_of built_with)
|
||||
linkage=$(ann_of linkage)
|
||||
pin_honoured=$(ann_of pin_honoured)
|
||||
toolchain_pin=$(ann_of toolchain_pin)
|
||||
|
||||
if [[ "$built_with" =~ ^rustc\ [0-9]+\.[0-9]+\.[0-9]+ ]]; then
|
||||
pass "records the compiler that built it (${built_with})"
|
||||
else
|
||||
fail "no usable 'built_with' annotation: '${built_with:-missing}'" \
|
||||
"pkg info -A could not say which rustc produced this package."
|
||||
fi
|
||||
|
||||
case "$linkage" in
|
||||
static | dynamic) pass "records its linkage (${linkage})" ;;
|
||||
*) fail "no usable 'linkage' annotation: '${linkage:-missing}'" ;;
|
||||
esac
|
||||
|
||||
# The annotation must describe the artifact, not the intent. A package
|
||||
# claiming static while carrying a dynamic binary would put back exactly
|
||||
# the base-drift exposure that static linking exists to remove.
|
||||
if [[ "$linkage" == "static" ]]; then
|
||||
if [[ "$file_out" == *"statically linked"* ]]; then
|
||||
pass "linkage annotation matches the binary (statically linked)"
|
||||
else
|
||||
fail "annotated static, but bin/fips is not statically linked" \
|
||||
"file: ${file_out}"
|
||||
fi
|
||||
if grep -q '"shlibs_required"' "$MANIFEST"; then
|
||||
fail "annotated static, but the manifest still declares shared libraries" \
|
||||
"A static package should depend on no shlibs at all."
|
||||
else
|
||||
pass "static package declares no shared-library dependencies"
|
||||
fi
|
||||
elif [[ "$linkage" == "dynamic" ]]; then
|
||||
if [[ "$file_out" == *"dynamically linked"* ]]; then
|
||||
pass "linkage annotation matches the binary (dynamically linked)"
|
||||
else
|
||||
fail "annotated dynamic, but bin/fips is not dynamically linked" "file: ${file_out}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Not a failure: an aarch64 package cannot honour the pin, because
|
||||
# rustup ships no toolchain for that platform. It is reported loudly
|
||||
# because such a package is build-it-yourself and must not be published
|
||||
# as a release artifact.
|
||||
case "$pin_honoured" in
|
||||
yes) pass "built with the pinned toolchain (${toolchain_pin})" ;;
|
||||
no) pass "NOT built with the pinned toolchain (${toolchain_pin}; used ${built_with}) — do not publish this as a release artifact" ;;
|
||||
*) fail "no usable 'pin_honoured' annotation: '${pin_honoured:-missing}'" ;;
|
||||
esac
|
||||
|
||||
# The FreeBSD major is not asserted against a fixed number: CE 2.8 is 15,
|
||||
# CE 2.9 and Plus 26.x are 16, and all are legitimate outputs. It only has
|
||||
# to be present and numeric — the operator matches it to the target.
|
||||
abi_major=$(printf '%s' "$abi" | awk -F: '{print $2}')
|
||||
if [[ "$abi_major" =~ ^[0-9]+$ ]]; then
|
||||
pass "ABI FreeBSD major is ${abi_major} — match this to the target's base"
|
||||
else
|
||||
fail "ABI has no numeric FreeBSD major: ${abi}"
|
||||
fi
|
||||
if grep -q 'pw groupadd fips' "$MANIFEST"; then
|
||||
pass "post-install creates the fips control-socket group"
|
||||
else
|
||||
fail "post-install does not create the fips group"
|
||||
fi
|
||||
# The manifest's file list always names the teardown script, so grep the
|
||||
# pre-deinstall script body itself, not the whole manifest.
|
||||
pre_deinstall=$(grep -o '"pre-deinstall":"[^"]*\(\\"[^"]*\)*"' "$MANIFEST" | head -1)
|
||||
post_install=$(grep -o '"post-install":"[^"]*\(\\"[^"]*\)*"' "$MANIFEST" | head -1)
|
||||
if [[ "$pre_deinstall" == *"libexec/fips/fips-dns-teardown"* ]]; then
|
||||
pass "pre-deinstall invokes fips-dns-teardown"
|
||||
else
|
||||
fail "pre-deinstall never calls fips-dns-teardown" \
|
||||
"config.xml would keep forwarding fips. to a dead port after removal."
|
||||
fi
|
||||
if [[ "$post_install" == *"install -m 0600"*"fips.yaml.sample"*"fips.yaml"* ]]; then
|
||||
pass "post-install seeds fips.yaml at 0600"
|
||||
else
|
||||
fail "post-install does not seed fips.yaml with install -m 0600" \
|
||||
"It may carry an nsec: private key."
|
||||
fi
|
||||
|
||||
# ── 3. Shipped configuration ────────────────────────────────────────────────
|
||||
echo "-- configuration"
|
||||
|
||||
CFG="$PAYLOAD/etc/fips/fips.yaml.sample"
|
||||
if grep -Fqx ' bind_addr: "127.0.0.1"' "$CFG"; then
|
||||
pass "dns.bind_addr is IPv4 loopback"
|
||||
else
|
||||
fail "dns.bind_addr is not 127.0.0.1" \
|
||||
"unbound gets do-ip6: no unless 'Allow IPv6' is set, and cannot reach [::1]."
|
||||
fi
|
||||
if grep -Eq '^[[:space:]]+bind_addr:[[:space:]]*"::1"' "$CFG"; then
|
||||
fail "config still carries an active ::1 bind"
|
||||
else
|
||||
pass "config carries no active ::1 bind"
|
||||
fi
|
||||
# The splice must keep the rest of the common config, not just the dns block.
|
||||
for key in "^node:" "^tun:" "^transports:" "^peers:"; do
|
||||
if grep -q "$key" "$CFG"; then
|
||||
pass "config retains ${key#^}"
|
||||
else
|
||||
fail "config lost ${key#^} — the dns: splice ate too much"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── 4. Boot script behaviour ────────────────────────────────────────────────
|
||||
# Two kinds of case. The negative ones prove a stale or foreign pid is not
|
||||
# mistaken for the daemon. The positive one proves the script can see a
|
||||
# daemon it started — which no negative case can: a pid check that never
|
||||
# succeeds passes every negative case. That defect shipped once, so the
|
||||
# positive case comes first and the rewrites it depends on are asserted.
|
||||
# Exercised against the copy inside the package, with its pidfile and knobs
|
||||
# file redirected into the work dir so nothing here touches a running daemon.
|
||||
echo "-- boot script"
|
||||
|
||||
RC="$WORK/fips-under-test.sh"
|
||||
# The stub daemon is a copy of sleep named fips: the script identifies the
|
||||
# daemon by comm, and comm is the basename it was exec'd as.
|
||||
mkdir -p "$WORK/run"; : > "$WORK/cfg.yaml"; cp /bin/sleep "$WORK/fips"
|
||||
sed -e "s|^pidfile=.*|pidfile=\"$WORK/test.pid\"|" \
|
||||
-e "s|^supervisor_pidfile=.*|supervisor_pidfile=\"$WORK/test-daemon.pid\"|" \
|
||||
-e "s|^daemon_err=.*|daemon_err=\"$WORK/run/daemon.err\"|" \
|
||||
-e "s|^fips_conf=.*|fips_conf=\"$WORK/fips.conf\"|" \
|
||||
-e "s|^procname=.*|procname=\"$WORK/fips\"|" \
|
||||
-e "s|^runtime_dir=.*|runtime_dir=\"$WORK/run\"|" \
|
||||
-e "s|fips_config:=/usr/local/etc/fips/fips.yaml|fips_config:=$WORK/cfg.yaml|" \
|
||||
-e "s|fips_logfile:=/var/log/fips.log|fips_logfile:=$WORK/fips.log|" \
|
||||
-e "s|^ if pw groupshow fips| if false \&\& pw groupshow fips|" \
|
||||
-e "s|--config \"\$fips_config\" \${fips_flags}|300|" \
|
||||
"$PAYLOAD/etc/rc.d/fips.sh" > "$RC"
|
||||
chmod +x "$RC"
|
||||
|
||||
# Every one of those rewrites must have landed, or the cases below run
|
||||
# against the wrong paths and the wrong binary and prove nothing.
|
||||
for want in "pidfile=\"$WORK/test.pid\"" "procname=\"$WORK/fips\"" "runtime_dir=\"$WORK/run\"" \
|
||||
"fips_config:=$WORK/cfg.yaml" "fips_logfile:=$WORK/fips.log"; do
|
||||
if grep -Fq "$want" "$RC"; then
|
||||
pass "harness rewrite in place: ${want%%=*}"
|
||||
else
|
||||
fail "harness rewrite missing: $want" "the lifecycle cases below would test the wrong thing."
|
||||
fi
|
||||
done
|
||||
|
||||
# Redirect the script's output to a file, not through $(...): start and
|
||||
# restart background daemon(8), whose child inherits the caller's stdout,
|
||||
# so a command-substitution capture would block until that child exits —
|
||||
# the same pipe-inheritance trap the boot script itself avoids. The
|
||||
# daemon writes to the file; the capture only reads cat's output.
|
||||
rc() {
|
||||
: > "$WORK/rc.out"
|
||||
# --foreground matters. Without it, FreeBSD's timeout(1) makes itself a
|
||||
# reaper for everything the script spawns (procctl PROC_REAP_ACQUIRE):
|
||||
# after the script exits it keeps waiting until every descendant has
|
||||
# exited too, and at the time limit it kills them all (PROC_REAP_KILL).
|
||||
# daemon(8) never exits, so `start` would sit for the full 30 seconds
|
||||
# and then the daemon it started would be killed — "started", no
|
||||
# daemon. With --foreground timeout only signals its direct child, and
|
||||
# the detached daemon is left alone. No process group is involved.
|
||||
# (Found by running this checker as root in a FreeBSD VM.)
|
||||
timeout --foreground 30 sh "$RC" "$@" > "$WORK/rc.out" 2>&1 </dev/null
|
||||
rc_status=$?
|
||||
cat "$WORK/rc.out"
|
||||
return $rc_status
|
||||
}
|
||||
|
||||
out=$(rc start); sleep 1
|
||||
if [[ "$out" == *"started"* ]] && [[ -s "$WORK/test.pid" ]]; then
|
||||
pass "start launches the daemon via daemon(8)"
|
||||
else
|
||||
fail "start did not launch the daemon" "$out"
|
||||
fi
|
||||
# daemon(8) writes the pid with no trailing newline; read must cope with
|
||||
# that, which is exactly what the shipped-once defect did not. Require the
|
||||
# pidfile to exist first: tail on a missing file prints nothing, which
|
||||
# would otherwise pass this vacuously.
|
||||
if [[ ! -s "$WORK/test.pid" ]]; then
|
||||
fail "no pidfile to check for a trailing newline (start did not write one)"
|
||||
elif [[ "$(tail -c1 "$WORK/test.pid" | od -An -c | tr -d ' ')" != '\n' ]]; then
|
||||
pass "pidfile has no trailing newline (the case read must handle)"
|
||||
else
|
||||
fail "pidfile ends in a newline — this harness no longer exercises the read-at-EOF case"
|
||||
fi
|
||||
live=$(cat "$WORK/test.pid")
|
||||
out=$(rc status)
|
||||
if [[ "$out" == *"running as pid $live"* ]]; then
|
||||
pass "status sees the daemon it started (pid $live)"
|
||||
else
|
||||
fail "status cannot see a running daemon" "$out"
|
||||
fi
|
||||
out=$(rc start)
|
||||
if [[ "$out" == *"already running as pid $live"* ]] && kill -0 "$live" 2>/dev/null; then
|
||||
pass "re-entrant start is a no-op (pfSense re-runs start on WAN IP changes)"
|
||||
else
|
||||
fail "re-entrant start disturbed a running daemon" "$out"
|
||||
fi
|
||||
out=$(rc restart); sleep 1
|
||||
newpid=$(cat "$WORK/test.pid" 2>/dev/null)
|
||||
if [[ "$out" == *"stopped"* ]] && [[ "$out" == *"started"* ]] && [[ -n "$newpid" ]] && [[ "$newpid" != "$live" ]] \
|
||||
&& ! kill -0 "$live" 2>/dev/null && kill -0 "$newpid" 2>/dev/null; then
|
||||
pass "restart stops the old daemon and starts a new one ($live -> $newpid)"
|
||||
else
|
||||
fail "restart did not replace the daemon" "$out"
|
||||
fi
|
||||
out=$(rc stop); sleep 1
|
||||
if [[ "$out" == *"stopped"* ]] && ! kill -0 "$newpid" 2>/dev/null; then
|
||||
pass "stop terminates the daemon"
|
||||
else
|
||||
fail "stop left the daemon running" "$out"
|
||||
fi
|
||||
if rc status >/dev/null 2>&1; then fail "status exits 0 after stop"; else pass "status exits non-zero after stop"; fi
|
||||
pkill -f "$WORK/fips" 2>/dev/null || true
|
||||
|
||||
# A live process whose comm is not "fips": stands in for a recycled pid.
|
||||
sleep 120 &
|
||||
decoy_pid=$!
|
||||
|
||||
status_says() {
|
||||
sh "$RC" status 2>&1 | tail -1
|
||||
return 0
|
||||
}
|
||||
|
||||
printf '%s\n' "$$" > "$WORK/test.pid"
|
||||
# $$ is this shell, comm "bash" — a pid that exists but is not the daemon.
|
||||
if [[ "$(status_says)" == *"not running"* ]]; then
|
||||
pass "a live pid whose comm is not fips reads as not running"
|
||||
else
|
||||
fail "a recycled pid was mistaken for a running daemon" \
|
||||
"start would then be a permanent no-op."
|
||||
fi
|
||||
|
||||
printf '%s\n' "$decoy_pid" > "$WORK/test.pid"
|
||||
if [[ "$(status_says)" == *"not running"* ]]; then
|
||||
pass "a live non-fips child reads as not running"
|
||||
else
|
||||
fail "a live non-fips process was mistaken for the daemon"
|
||||
fi
|
||||
|
||||
printf 'junk\n' > "$WORK/test.pid"
|
||||
if [[ "$(status_says)" == *"not running"* ]]; then
|
||||
pass "a non-numeric pidfile reads as not running"
|
||||
else
|
||||
fail "a non-numeric pidfile was accepted"
|
||||
fi
|
||||
|
||||
printf '999999\n' > "$WORK/test.pid"
|
||||
if [[ "$(status_says)" == *"not running"* ]]; then
|
||||
pass "a pid that does not exist reads as not running"
|
||||
else
|
||||
fail "a dead pid was reported as running"
|
||||
fi
|
||||
|
||||
rm -f "$WORK/test.pid"
|
||||
if [[ "$(status_says)" == *"not running"* ]]; then
|
||||
pass "a missing pidfile reads as not running"
|
||||
else
|
||||
fail "a missing pidfile was not handled"
|
||||
fi
|
||||
|
||||
kill "$decoy_pid" 2>/dev/null
|
||||
wait "$decoy_pid" 2>/dev/null
|
||||
|
||||
# status must exit non-zero when not running: the package's pre-deinstall
|
||||
# tests it to decide whether to restart the daemon after an upgrade.
|
||||
sh "$RC" status >/dev/null 2>&1
|
||||
if [[ $? -ne 0 ]]; then
|
||||
pass "status exits non-zero when not running"
|
||||
else
|
||||
fail "status exits 0 when not running" \
|
||||
"pre-deinstall would mark a stopped daemon for restart on upgrade."
|
||||
fi
|
||||
|
||||
# The opt-out has to be silent as well as inert: pfSense re-runs this on every
|
||||
# interface event, and a chatty no-op floods /tmp/bootup_messages.
|
||||
printf 'fips_enable="NO"\n' > "$WORK/fips.conf"
|
||||
out=$(sh "$RC" start 2>&1)
|
||||
rc=$?
|
||||
if [[ $rc -eq 0 && -z "$out" ]]; then
|
||||
pass "fips_enable=NO start is silent and exits 0"
|
||||
else
|
||||
fail "fips_enable=NO start printed '${out}' and exited ${rc}"
|
||||
fi
|
||||
rm -f "$WORK/fips.conf"
|
||||
|
||||
sh "$RC" not-a-verb >/dev/null 2>&1
|
||||
if [[ $? -eq 64 ]]; then
|
||||
pass "an unknown verb exits 64 (EX_USAGE)"
|
||||
else
|
||||
fail "an unknown verb did not exit 64"
|
||||
fi
|
||||
|
||||
# ── 5. DNS helper ───────────────────────────────────────────────────────────
|
||||
echo "-- dns helper"
|
||||
|
||||
# On anything that is not pfSense it must refuse rather than half-configure a
|
||||
# resolver it does not understand. This host is FreeBSD, so /etc/inc/config.inc
|
||||
# is absent and the refusal path is the one that runs.
|
||||
if [[ -f /etc/inc/config.inc ]]; then
|
||||
skip "non-pfSense refusal check (this host is pfSense)"
|
||||
else
|
||||
out=$("$PAYLOAD/libexec/fips/fips-dns-setup" 2>&1)
|
||||
rc=$?
|
||||
if [[ $rc -ne 0 && "$out" == *"not a pfSense system"* ]]; then
|
||||
pass "fips-dns-setup refuses on a non-pfSense host"
|
||||
else
|
||||
fail "fips-dns-setup did not refuse on a non-pfSense host (rc=${rc})" "$out"
|
||||
fi
|
||||
fi
|
||||
|
||||
# The reader must take dns.bind_addr, not a transport's bind_addr — a plain
|
||||
# grep would hand back the UDP transport's 0.0.0.0:2121 and point unbound there.
|
||||
READER="$WORK/reader.sh"
|
||||
sed -n '/^yaml_dns_field()/,/^}/p' "$PAYLOAD/libexec/fips/fips-dns-setup" > "$READER"
|
||||
{
|
||||
echo 'echo "$(yaml_dns_field bind_addr) $(yaml_dns_field port)"'
|
||||
} >> "$READER"
|
||||
got=$(FIPS_CONFIG="$CFG" sh -c ". '$READER'")
|
||||
if [[ "$got" == "127.0.0.1 5354" ]]; then
|
||||
pass "dns field reader is scoped to the dns: block ($got)"
|
||||
else
|
||||
fail "dns field reader returned '${got}', expected '127.0.0.1 5354'"
|
||||
fi
|
||||
# In the shipped config dns: precedes transports:, so a first-match grep
|
||||
# would also pass. Put a transport's bind_addr FIRST and make sure the
|
||||
# reader still finds the dns: one.
|
||||
cat > "$WORK/transports-first.yaml" <<'EOF'
|
||||
transports:
|
||||
udp:
|
||||
bind_addr: "0.0.0.0:2121"
|
||||
port: 1
|
||||
dns:
|
||||
enabled: true
|
||||
bind_addr: "127.0.0.1"
|
||||
port: 5354
|
||||
EOF
|
||||
got=$(FIPS_CONFIG="$WORK/transports-first.yaml" sh -c ". '$READER'")
|
||||
if [[ "$got" == "127.0.0.1 5354" ]]; then
|
||||
pass "dns field reader ignores a transports: block that comes first"
|
||||
else
|
||||
fail "dns field reader picked up the transport's bind_addr: '${got}'" \
|
||||
"unbound would be pointed at the UDP transport."
|
||||
fi
|
||||
|
||||
# ── 6. PHP helper ───────────────────────────────────────────────────────────
|
||||
echo "-- php helper"
|
||||
HELPER="$PAYLOAD/libexec/fips/fips-unbound-custom.php"
|
||||
if ! command -v php >/dev/null 2>&1; then
|
||||
skip "php -l and the fips_strip_block unit test (no php on this host)"
|
||||
else
|
||||
if php -l "$HELPER" >/dev/null 2>&1; then
|
||||
pass "fips-unbound-custom.php parses (php -l)"
|
||||
else
|
||||
fail "fips-unbound-custom.php does not parse" "$(php -l "$HELPER" 2>&1 | head -3)"
|
||||
fi
|
||||
# fips_strip_block has no side effects; under FIPS_UNBOUND_HELPER_TEST the
|
||||
# file defines its functions and returns before touching config.xml.
|
||||
if php -r '
|
||||
putenv("FIPS_UNBOUND_HELPER_TEST=1"); require $argv[1];
|
||||
$bad = 0;
|
||||
foreach (["", "opt: 1\n", "opt: 1", "a\n\n\nb\n", "\n"] as $t) {
|
||||
$hb = $mf = false;
|
||||
$r = fips_strip_block(fips_add_block($t, "server:\n x: y"), $hb, $mf);
|
||||
if ($r !== $t || !$hb || $mf) { $bad++; }
|
||||
}
|
||||
$hb = $mf = false; fips_strip_block("x\n" . FIPS_BEGIN . "\nzzz\n", $hb, $mf); if (!$mf) { $bad++; }
|
||||
$hb = $mf = false; $r = fips_strip_block("keep\n", $hb, $mf); if ($r !== "keep\n" || $hb) { $bad++; }
|
||||
// Operator text on its own line below the block must survive on its
|
||||
// own line, not be spliced onto the line before BEGIN.
|
||||
$above = "# my local tweaks";
|
||||
$below = fips_add_block($above, "server:\n x: y") . "private-domain: \"lan\"";
|
||||
$hb = $mf = false; $r = fips_strip_block($below, $hb, $mf);
|
||||
if ($r !== $above . "\nprivate-domain: \"lan\"" || !$hb || $mf) { $bad++; }
|
||||
exit($bad ? 1 : 0);' "$HELPER" >/dev/null 2>&1; then
|
||||
pass "fips_strip_block: add then remove is byte-identical; partial block flagged"
|
||||
else
|
||||
fail "fips_strip_block does not round-trip the operator text" \
|
||||
"the README promises the surrounding text is left byte for byte."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Result ──────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
if [[ $failures -eq 0 ]]; then
|
||||
echo "==> pfSense package checks PASSED (${checks} checks, ${skipped} skipped)"
|
||||
exit 0
|
||||
fi
|
||||
echo "==> pfSense package checks FAILED (${failures} of ${checks})" >&2
|
||||
exit 1
|
||||
Reference in New Issue
Block a user