diff --git a/.github/workflows/package-freebsd.yml b/.github/workflows/package-freebsd.yml new file mode 100644 index 0000000..02ee1c7 --- /dev/null +++ b/.github/workflows/package-freebsd.yml @@ -0,0 +1,287 @@ +name: FreeBSD Package +on: + push: + branches: + - master + - maint + - next + tags: + - "v*" + pull_request: + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + determine-versioning: + runs-on: ubuntu-latest + outputs: + freebsd_package_version: ${{ steps.freebsd_version.outputs.freebsd_package_version }} + freebsd_pkg_file_version: ${{ steps.freebsd_version.outputs.freebsd_pkg_file_version }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Derive FreeBSD package version + id: freebsd_version + shell: bash + run: | + : ${GITHUB_OUTPUT:=/tmp/github_output} + + BASE_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + VERSION="${GITHUB_REF_NAME#v}" + else + BRANCH=$(echo "$GITHUB_REF_NAME" | sed 's|[^A-Za-z0-9]|.|g; s/\.\{2,\}/./g; s/^\.//; s/\.$//') + HEIGHT=$(git rev-list --count HEAD) + HASH=$(git rev-parse --short HEAD) + if [[ -z "$BRANCH" ]]; then + BRANCH="ref" + fi + VERSION="${BASE_VERSION}+${BRANCH}.${HEIGHT}.${HASH}" + fi + + # build-pkg.sh maps '-' and '+' to '.' (neither is allowed in a + # pkg version); derive the same mapping here so later steps can + # assert the exact artifact filename. + PKG_FILE_VERSION=$(printf '%s' "$VERSION" | tr -- '+-' '..') + + echo "freebsd_package_version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "freebsd_pkg_file_version=${PKG_FILE_VERSION}" >> "$GITHUB_OUTPUT" + + build: + name: Build FreeBSD package (x86_64) + # No GitHub-hosted FreeBSD runners exist; build inside a KVM-accelerated + # FreeBSD VM on the Linux runner. The release must track the .pkg ABI + # major (FreeBSD:15:amd64) — pkg on other majors refuses the package. + runs-on: ubuntu-latest + needs: determine-versioning + + steps: + - uses: actions/checkout@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 smoke-install in FreeBSD VM + uses: vmactions/freebsd-vm@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: | + pkg install -y curl + run: | + set -e + + # rustup rather than the ports rust: rust-toolchain.toml pins + # the toolchain, and rustup honors the pin on first cargo use. + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --default-toolchain none --profile minimal + . "$HOME/.cargo/env" + + cargo build --release + + # The only place the FreeBSD cfg arms' unit tests ever run in + # CI — the main CI matrix is Linux-only, and a release build + # compiles no #[cfg(test)] code (AF-prefix strip round-trips, + # platform module, config path gates). + cargo test + + packaging/freebsd/build-pkg.sh \ + --version "$FREEBSD_PACKAGE_VERSION" \ + --no-build + + # Smoke-install the package in the VM: files land where the + # rc.d scripts and DNS integration expect them, and the + # binaries link against this release's base libraries. + PKG=$(ls deploy/fips-*-freebsd-*.pkg) + pkg add "$PKG" + for bin in fips fipsctl fipstop; do + test -x "/usr/local/bin/$bin" || { echo "FAIL: missing /usr/local/bin/$bin"; exit 1; } + if ldd "/usr/local/bin/$bin" | grep "not found"; then + echo "FAIL: unresolved shared libraries in $bin"; exit 1 + fi + done + test -x /usr/local/etc/rc.d/fips + test -x /usr/local/etc/rc.d/fips_dns + test -f /usr/local/etc/fips/fips.yaml.sample + test -f /usr/local/etc/fips/hosts.sample + # The manifest post-install script must have copied the + # samples into place (install-if-absent semantics). + test -f /usr/local/etc/fips/fips.yaml + test -f /usr/local/etc/fips/hosts + # fips.yaml may hold a node private key (nsec:); it must not + # be world-readable — Debian and macOS both install it 0600. + for f in /usr/local/etc/fips/fips.yaml /usr/local/etc/fips/fips.yaml.sample; do + mode=$(stat -f %Lp "$f") + if [ "$mode" != "600" ]; then + echo "FAIL: $f mode is $mode, expected 600"; exit 1 + fi + done + # post-install must create the control-socket access group. + pw groupshow fips >/dev/null || { echo "FAIL: fips group missing"; exit 1; } + test -x /usr/local/libexec/fips/fips-dns-setup + pkg info fips + echo "==> pkg smoke-install PASSED" + + # SHA-256 sidecar computed inside the VM; the host verifies the + # bytes again after the rsync copyback, so corruption across + # the VM handoff is detected before upload. + ( cd deploy && sha256 -q "$(basename "$PKG")" \ + | { read -r h; printf '%s %s\n' "$h" "$(basename "$PKG")"; } \ + > "$(basename "$PKG").sha256" ) + + # The whole workspace is rsynced back to the host; drop the + # build tree so the copyback moves megabytes, not gigabytes. + rm -rf target + + - name: Resolve FreeBSD asset path + id: freebsd-assets + shell: bash + run: | + : ${GITHUB_OUTPUT:=/tmp/github_output} + set -euo pipefail + + # build-pkg.sh names the package from the derived version and the + # pkg ABI arch; assert the exact name so a naming regression fails + # here instead of colliding on the release page. + EXPECTED="deploy/fips-${{ needs.determine-versioning.outputs.freebsd_pkg_file_version }}-freebsd-amd64.pkg" + if [[ ! -f "$EXPECTED" ]]; then + echo "Expected package $EXPECTED was not produced" >&2 + echo "deploy/ contains:" >&2 + ls -la deploy >&2 || true + exit 1 + fi + + echo "pkg=$EXPECTED" >> "$GITHUB_OUTPUT" + + - name: Verify .pkg integrity across the VM handoff + shell: bash + run: | + set -euo pipefail + PKG="${{ steps.freebsd-assets.outputs.pkg }}" + sidecar="${PKG}.sha256" + if [[ ! -f "$sidecar" ]]; then + echo "FAIL: missing SHA-256 sidecar for $(basename "$PKG")" >&2 + exit 1 + fi + expected=$(awk '{print $1}' "$sidecar") + actual=$(sha256sum "$PKG" | awk '{print $1}') + if [[ "$expected" != "$actual" ]]; then + echo "FAIL: $(basename "$PKG") SHA-256 mismatch across the VM copyback" >&2 + echo " expected (FreeBSD VM): $expected" >&2 + echo " actual (host): $actual" >&2 + exit 1 + fi + echo "PASS: $(basename "$PKG") matches the in-VM SHA-256 ($actual)" + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: fips_${{ needs.determine-versioning.outputs.freebsd_package_version }}_x86_64_freebsd + path: | + ${{ steps.freebsd-assets.outputs.pkg }} + ${{ steps.freebsd-assets.outputs.pkg }}.sha256 + retention-days: 30 + + - name: Build summary + run: | + echo "Build Summary for freebsd/x86_64:" + echo " Package: ${{ steps.freebsd-assets.outputs.pkg }}" + + release: + name: Publish FreeBSD assets to GitHub Release + runs-on: ubuntu-latest + needs: build + if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: write + + steps: + - name: Download FreeBSD artifacts + uses: actions/download-artifact@v8 + with: + path: dist + merge-multiple: true + + - name: Validate .pkg bytes before publishing + shell: bash + run: | + set -euo pipefail + cd dist + + pkgs=$(find . -maxdepth 1 -type f -name '*.pkg' | LC_ALL=C sort) + if [[ -z "$pkgs" ]]; then + echo "FAIL: no .pkg artifacts were downloaded" >&2 + exit 1 + fi + + fail=0 + while IFS= read -r pkg; do + base=$(basename "$pkg") + sidecar="${pkg}.sha256" + if [[ ! -f "$sidecar" ]]; then + echo "FAIL: missing SHA-256 sidecar for $base" >&2 + fail=1 + continue + fi + expected=$(awk '{print $1}' "$sidecar") + actual=$(sha256sum "$pkg" | awk '{print $1}') + if [[ "$expected" != "$actual" ]]; then + echo "FAIL: $base SHA-256 mismatch on the bytes about to be published" >&2 + echo " expected (FreeBSD VM): $expected" >&2 + echo " actual (downloaded): $actual" >&2 + fail=1 + continue + fi + echo "PASS: $base matches the in-VM SHA-256 ($actual)" + done <<<"$pkgs" + + if [[ "$fail" -ne 0 ]]; then + echo "==> pre-publish .pkg verification FAILED; not publishing" >&2 + exit 1 + fi + echo "==> pre-publish .pkg verification PASSED" + + - name: Generate FreeBSD release checksums + run: | + cd dist + find . -maxdepth 1 -type f -name '*.pkg' -printf '%P\n' \ + | LC_ALL=C sort \ + | xargs sha256sum \ + > checksums-freebsd.txt + + - name: Wait for tag release + env: + GH_TOKEN: ${{ github.token }} + run: | + for attempt in $(seq 1 20); do + if gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + exit 0 + fi + echo "Release ${GITHUB_REF_NAME} not available yet; waiting..." + sleep 15 + done + + echo "Timed out waiting for release ${GITHUB_REF_NAME}" >&2 + exit 1 + + - name: Upload FreeBSD assets + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${GITHUB_REF_NAME}" \ + dist/*.pkg \ + dist/checksums-freebsd.txt \ + --clobber \ + --repo "${GITHUB_REPOSITORY}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fda03b..89037c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,23 @@ with v0.4.x or earlier peers. - The receive-path `RejectReason` classification (shipped in 0.4.0) is additionally wired into the Noise XX handshake cluster (msg1/msg2/msg3) and the rekey-initiator outbound sites on `next`. + +- FreeBSD support for the daemon, `fipsctl`, and `fipstop`: native TUN + datapath (TUNSIFHEAD address-family framing, kernel-assigned `tunN` + device name as with `utun` on macOS), clean service teardown, + `/usr/local/etc/fips` config search path, and `/var/run/fips` + control-socket default (both shared with macOS). The `hosts`, + `peers.allow` / `peers.deny` and `fipsctl keygen` defaults follow the + same `/usr/local/etc/fips` layout as macOS — see the corresponding + entry under Fixed, which describes that move and its startup warning. + `fips-gateway` remains Linux-only. Native `.pkg` packaging under + `packaging/freebsd/` + (`make freebsd`) with rc.d services, a `fips` control-socket group, + service stop/restart across `pkg upgrade`, and `.fips` DNS integration + for `local_unbound`/`unbound`/`dnsmasq`. mDNS LAN discovery works via + `mdns-sd` 0.20 (`socket-pktinfo` 0.4.1, the first release that builds + on FreeBSD). Daemon logs now disable ANSI color when stdout is not a + terminal (all platforms). - An optional tick-body profiler behind the new `profiling` Cargo feature, **off by default**. When enabled, `fipsctl profile tick on [--dir PATH]` / `off` / `status` starts and stops a capture at runtime with no restart. Each diff --git a/Cargo.lock b/Cargo.lock index c3c01a2..a91e2ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1124,9 +1124,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flume" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", @@ -1687,9 +1687,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libdbus-sys" @@ -1819,9 +1819,9 @@ dependencies = [ [[package]] name = "mdns-sd" -version = "0.19.2" +version = "0.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18148fee27e99e76dbf6e137f27727113d31f766e578d1b93a93c3615fca7081" +checksum = "86dbb9f00c8c367f75ed3a775d3eb31d0375a72f58275ef64a1bc53c255a2ce2" dependencies = [ "fastrand", "flume", @@ -3113,13 +3113,13 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket-pktinfo" -version = "0.3.2" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f" +checksum = "612942246d0cc239cfd83af1dfd39be47f649208a3524e5e9da651910128e0ac" dependencies = [ "libc", "socket2", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4037,16 +4037,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -4064,31 +4055,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -4097,96 +4071,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "wintun" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index e90d155..d724b96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net", "time", "process", "io-util"] } futures = "0.3" simple-dns = "0.11.2" -mdns-sd = "0.19" +# 0.20 picks up socket-pktinfo 0.4.1, the first release that builds on +# FreeBSD (IP_RECVDSTADDR/IP_RECVIF instead of Linux-style IP_PKTINFO). +mdns-sd = "0.20" socket2 = { version = "0.6.2", features = ["all"] } tokio-socks = "0.5" portable-atomic = { version = "1", features = ["std"] } diff --git a/README.md b/README.md index 38382f1..8e4cb3e 100644 --- a/README.md +++ b/README.md @@ -112,19 +112,19 @@ tutorial progression starting at cargo build --release ``` -Requires Rust 1.94.1+ (edition 2024). Linux, macOS, and Windows run as -standalone daemons; Android is supported as an embedded library (the host -app owns the TUN, e.g. a `VpnService`). Transport availability varies by -platform. +Requires Rust 1.94.1+ (edition 2024). Linux, macOS, FreeBSD, and Windows +run as standalone daemons; Android is supported as an embedded library +(the host app owns the TUN, e.g. a `VpnService`). Transport availability +varies by platform. -| Transport | Linux | macOS | Windows | Android | OpenWrt | -|-----------|:-----:|:-----:|:-------:|:-------:|:-------:| -| UDP | ✅ | ✅ | ✅ | ✅ | ✅ | -| TCP | ✅ | ✅ | ✅ | ✅ | ✅ | -| Ethernet | ✅ | ✅ | ❌ | ❌ | ✅ | -| Tor | ✅ | ✅ | ✅ | ❌ | ✅ | -| Nym | ✅ | ✅ | ✅ | ❌ | ❌ | -| BLE | ✅ | ❌ | ❌ | ❌ | ❌ | +| Transport | Linux | macOS | FreeBSD | Windows | Android | OpenWrt | +|-----------|:-----:|:-----:|:-------:|:-------:|:-------:|:-------:| +| UDP | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| TCP | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Ethernet | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | +| Tor | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | +| Nym | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | +| BLE | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | On Linux, a source build requires `libclang` — the LAN gateway's nftables bindings are generated by `bindgen` at build time, which @@ -254,7 +254,7 @@ full list of v0.6.0 wire-format changes in flight. - Reproducible builds with toolchain pinning and `SOURCE_DATE_EPOCH`. - Linux (Debian, systemd tarball, OpenWrt, AUR), macOS (`.pkg`), - and Windows (ZIP, service) packaging. + FreeBSD (`.pkg`), and Windows (ZIP, service) packaging. - Docker-based integration and chaos testing. ### Near-term priorities diff --git a/packaging/Makefile b/packaging/Makefile index 9f5d4ba..f83e3ea 100644 --- a/packaging/Makefile +++ b/packaging/Makefile @@ -10,6 +10,7 @@ # make apk Build an OpenWrt .apk package (apk-tools, mandatory on OpenWrt 25+) # 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 zip Build a Windows .zip package # make all Build deb and tarball (default) # make clean Remove deploy/ directory @@ -18,7 +19,7 @@ SHELL := /bin/bash PACKAGING_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) PROJECT_ROOT := $(abspath $(PACKAGING_DIR)/..) -.PHONY: all deb tarball ipk apk aur pkg zip clean +.PHONY: all deb tarball ipk apk aur pkg freebsd zip clean all: deb tarball @@ -40,6 +41,9 @@ aur: pkg: @bash $(PACKAGING_DIR)/macos/build-pkg.sh +freebsd: + @sh $(PACKAGING_DIR)/freebsd/build-pkg.sh + zip: @powershell -File $(PACKAGING_DIR)/windows/build-zip.ps1 diff --git a/packaging/README.md b/packaging/README.md index 553bf07..0552dd6 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -12,6 +12,7 @@ make ipk # OpenWrt .ipk (opkg, OpenWrt 24.x and earlier) 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 zip # Windows .zip package make all # deb + tarball (default) ``` @@ -45,6 +46,7 @@ packaging/ aur/ Arch Linux AUR packaging (PKGBUILD, supporting files) common/ Shared assets (default config, hosts file) debian/ Debian/Ubuntu .deb packaging via cargo-deb + freebsd/ FreeBSD .pkg packaging via pkg-create(8) macos/ macOS .pkg installer via pkgbuild systemd/ Generic Linux systemd tarball packaging openwrt-ipk/ OpenWrt .ipk packaging via cargo-zigbuild (opkg) @@ -158,6 +160,30 @@ sudo installer -pkg deploy/fips--macos-.pkg -target / sudo packaging/macos/uninstall.sh ``` +### FreeBSD (`.pkg`) + +Built natively on a FreeBSD host with `pkg create`. Ships `fips`, +`fipsctl`, and `fipstop` (`fips-gateway` is excluded — its NAT backend +is nftables, Linux-only), rc.d services, and `.fips` DNS integration +for `local_unbound`, `unbound`, or `dnsmasq`. Config installs +sample-style under `/usr/local/etc/fips/` (edits survive upgrades). + +```sh +# Build (on FreeBSD; this Makefile needs GNU make — pkg install gmake) +gmake freebsd +# or directly, no gmake needed: +./packaging/freebsd/build-pkg.sh + +# Install +pkg add ./deploy/fips--freebsd-.pkg +sysrc fips_enable=YES fips_dns_enable=YES +service fips start +service fips_dns start +``` + +See [freebsd/README.md](freebsd/README.md) for host resolver setup and +field-tested caveats. + ### Windows (`.zip`) A ZIP archive containing binaries, default config, and PowerShell diff --git a/packaging/freebsd/README.md b/packaging/freebsd/README.md new file mode 100644 index 0000000..9e6d366 --- /dev/null +++ b/packaging/freebsd/README.md @@ -0,0 +1,99 @@ +# FIPS FreeBSD packaging + +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). + +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)` +is a libc loop over `recvmsg`, not a kernel batch, so the Linux/macOS +batched arm would gain nothing — and the connected-UDP fast path is +not compiled pending FreeBSD-specific `SO_REUSEPORT` validation. + +## Build + +```sh +./packaging/freebsd/build-pkg.sh # cargo build --release + pkg create +./packaging/freebsd/build-pkg.sh --no-build # package existing release binaries +``` + +Output: `deploy/fips--freebsd-.pkg` (e.g. +`fips-0.5.0.dev-freebsd-amd64.pkg` — pkg versions cannot contain `-`). + +## Install + +```sh +pkg add ./deploy/fips-0.5.0.dev-freebsd-amd64.pkg +cp /usr/local/etc/fips/fips.yaml.sample /usr/local/etc/fips/fips.yaml # then edit +sysrc fips_enable=YES fips_dns_enable=YES +service fips start +service fips_dns start +fipsctl show status +``` + +Config installs sample-style (`@sample` semantics via manifest +scripts), so an edited `fips.yaml` survives upgrade/removal. +`fips.yaml` is installed `0600` — it may hold the node's private key +(`nsec:`). The daemon runs under daemon(8) with pidfile +`/var/run/fips/fips.pid` and logs to `/var/log/fips.log` (rc.conf +knobs: `fips_config`, `fips_flags`, `fips_logfile`). + +The package creates a `fips` group; members can run `fipsctl` and +`fipstop` without root (`pw groupmod fips -m `, then re-login). +On `pkg upgrade` the services are stopped before the binaries are +replaced and started again afterwards if enabled; on `pkg delete` they +are stopped and the `.fips` resolver drop-in is removed. + +## .fips DNS integration + +The daemon answers `.fips` queries on `[::1]:5354`. `fips_dns` points +the system resolver's `fips.` zone there; backends tried in order: +base `local_unbound` (drop-in `/var/unbound/conf.d/fips.conf`), pkg +`unbound`, pkg `dnsmasq`. The unbound drop-in must (and does) set: + +- `do-not-query-localhost: no` — unbound's default silently refuses + loopback forwarders, SERVFAILing every `.fips` query. +- `do-ip6: yes` — the daemon binds `::1` only. +- `domain-insecure: "fips."` — the zone is unsigned. + +### One-time host resolver setup (NOT automated) + +The package configures the `fips.` zone only; making the local +resolver the *system* resolver is an operator decision. On a typical +box: + +```sh +sysrc local_unbound_enable=YES +local-unbound-setup 1.1.1.1 9.9.9.9 # explicit upstreams — see below +service local_unbound restart +``` + +Field-tested caveats (`fips-dns-setup` detects and warns about each): + +- `/etc/resolv.conf` must list a loopback nameserver (ideally only + `127.0.0.1`), or nothing ever queries unbound and `.fips` cannot + resolve. +- **Do not use a home-router DNS proxy as unbound's upstream.** Many + CPE forwarders are EDNS-broken; unbound always sends EDNS, so every + public query SERVFAILs. Forward to real resolvers (ISP or public). +- Remove `options edns0` from `/etc/resolv.conf` if present, and set + `resolv_conf_options=""` in `/etc/resolvconf.conf` so resolvconf(8) + does not re-add it — against an EDNS-broken router it breaks libc + resolution outright. +- Never run `local-unbound-setup` with no arguments while resolv.conf + already points at 127.0.0.1 — it snapshots that as upstream and + forwards unbound to itself. + +## Debugging + +```sh +drill -p 5354 .fips @::1 AAAA # daemon directly (bypasses unbound) +drill .fips AAAA # full chain; SERVER: must be 127.0.0.1 +cat /var/run/fips/dns-backend # which backend fips_dns configured +``` + +The TUN interface gets a kernel-assigned name (`tun0`, `tun1`, ...), +like `utun` on macOS, and is destroyed automatically when the daemon +exits. `ifconfig ` prints `Opened by PID ` for the process +holding a tun device. diff --git a/packaging/freebsd/build-pkg.sh b/packaging/freebsd/build-pkg.sh new file mode 100755 index 0000000..85e2d7c --- /dev/null +++ b/packaging/freebsd/build-pkg.sh @@ -0,0 +1,169 @@ +#!/bin/sh +# Build a FreeBSD .pkg package for FIPS using pkg-create(8). +# +# Usage: packaging/freebsd/build-pkg.sh [--version ] [--no-build] +# +# Prerequisites: the pinned Rust toolchain, pkg(8). +# Output: deploy/fips--freebsd-.pkg +# +# Ships fips, fipsctl, and fipstop. fips-gateway is excluded: its NAT +# backend is nftables (Linux-only) and the binary is a stub elsewhere. + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +NO_BUILD=0 +VERSION="" +while [ $# -gt 0 ]; do + case "$1" in + --no-build) NO_BUILD=1 ;; + --version) VERSION="${2:?--version requires an argument}"; shift ;; + *) echo "usage: $0 [--version ] [--no-build]" >&2; exit 1 ;; + esac + shift +done + +# Default to the Cargo.toml version; CI passes a derived version that +# appends +.. 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 -- '+-' '..')" + +ABI="$(pkg config abi 2>/dev/null || echo "FreeBSD:15:amd64")" +ARCH="${ABI##*:}" + +if [ "$NO_BUILD" -eq 0 ]; then + echo "==> cargo build --release" + (cd "$PROJECT_ROOT" && cargo build --release) +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 + +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/" + +# Config ships sample-style: copied into place on install if absent, +# removed on deinstall only if unmodified (see the manifest scripts). +# fips.yaml may hold a node private key (nsec:), so it is never +# world-readable — 0600 like the Debian and macOS packages. +install -m 0600 "${PROJECT_ROOT}/packaging/common/fips.yaml" \ + "${STAGE}/usr/local/etc/fips/fips.yaml.sample" +install -m 0644 "${PROJECT_ROOT}/packaging/common/hosts" \ + "${STAGE}/usr/local/etc/fips/hosts.sample" + +install -m 0755 "${SCRIPT_DIR}/fips.rc" "${STAGE}/usr/local/etc/rc.d/fips" +install -m 0755 "${SCRIPT_DIR}/fips-dns.rc" "${STAGE}/usr/local/etc/rc.d/fips_dns" + +install -m 0755 "${SCRIPT_DIR}/fips-dns-setup" \ + "${SCRIPT_DIR}/fips-dns-teardown" \ + "${STAGE}/usr/local/libexec/fips/" + +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 a plain pkg-create host (e.g. +# a CI VM) does not have. +cat > "${STAGE}/+MANIFEST" </dev/null 2>&1 || pw groupadd fips +# 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 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 + if service fips enabled >/dev/null 2>&1; then + service fips start >/dev/null 2>&1 || true + fi + if service fips_dns enabled >/dev/null 2>&1; then + service fips_dns start >/dev/null 2>&1 || true + fi +fi +EOD + pre-deinstall: </dev/null 2>&1 || true +service fips onestop >/dev/null 2>&1 || true +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 +fi +EOD +} +EOF + +cat > "${STAGE}/pkg-plist" <<'EOF' +bin/fips +bin/fipsctl +bin/fipstop +etc/fips/fips.yaml.sample +etc/fips/hosts.sample +etc/rc.d/fips +etc/rc.d/fips_dns +libexec/fips/fips-dns-setup +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 -.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" diff --git a/packaging/freebsd/fips-dns-setup b/packaging/freebsd/fips-dns-setup new file mode 100755 index 0000000..a15ea13 --- /dev/null +++ b/packaging/freebsd/fips-dns-setup @@ -0,0 +1,221 @@ +#!/bin/sh +# fips-dns-setup — Configure DNS routing for the .fips domain (FreeBSD). +# +# Detects the system's DNS resolver and configures it to forward .fips +# queries to the FIPS DNS responder on [::1]:5354 (the daemon's default +# IPv6 loopback bind). +# +# Backends (tried in order): +# 1. local_unbound (base system) — drop-in /var/unbound/conf.d/fips.conf +# 2. unbound (pkg) — drop-in /usr/local/etc/unbound/conf.d/ +# 3. dnsmasq (pkg) — drop-in include, if a conf-dir is used +# 4. Warning with manual instructions +# +# Notes baked in from field debugging: +# - The daemon binds ::1 ONLY, so the resolver must forward over IPv6; +# local-unbound-setup often writes `do-ip6: no`, which fails silently. +# The drop-in forces `do-ip6: yes`. +# - `domain-insecure: "fips."` is required or DNSSEC validation rejects +# the unsigned zone. +# - A configured, running unbound is useless if /etc/resolv.conf does +# not point at it — warn loudly if it doesn't. + +set -eu + +FIPS_DNS_PORT="5354" +FIPS_DNS_LOOPBACK_V6="::1" + +LOCAL_UNBOUND_DROPIN_DIR="/var/unbound/conf.d" +LOCAL_UNBOUND_DROPIN="${LOCAL_UNBOUND_DROPIN_DIR}/fips.conf" +PKG_UNBOUND_DROPIN_DIR="/usr/local/etc/unbound/conf.d" +PKG_UNBOUND_DROPIN="${PKG_UNBOUND_DROPIN_DIR}/fips.conf" +DNSMASQ_DROPIN_DIR="/usr/local/etc/dnsmasq.d" +DNSMASQ_DROPIN="${DNSMASQ_DROPIN_DIR}/fips.conf" + +# Record which backend was configured, for teardown. +STATE_DIR="/var/run/fips" +STATE_FILE="${STATE_DIR}/dns-backend" + +log() { echo "fips-dns: $*"; } + +save_backend() { + mkdir -p "$STATE_DIR" + echo "$1" > "$STATE_FILE" +} + +service_enabled_or_running() { + service "$1" enabled >/dev/null 2>&1 || service "$1" onestatus >/dev/null 2>&1 +} + +# Wait for the daemon's DNS responder to be listening (up to 30s). The +# TUN interface name is kernel-assigned (tunN), so the responder socket +# is the reliable readiness signal. +wait_for_daemon() { + i=0 + while [ "$i" -lt 30 ]; do + sockstat -6 -l -p "$FIPS_DNS_PORT" 2>/dev/null | grep -q ":$FIPS_DNS_PORT" && return 0 + sleep 1 + i=$((i + 1)) + done + log "ERROR: nothing listening on [${FIPS_DNS_LOOPBACK_V6}]:$FIPS_DNS_PORT after 30s (is the fips service running?)" + return 1 +} + +# The forward-zone drop-in shared by both unbound backends. +unbound_snippet() { + cat </dev/null; then + log "WARNING: /etc/resolv.conf has no 127.0.0.1/::1 nameserver —" + log "WARNING: the local resolver is configured but is NOT the system resolver," + log "WARNING: so .fips names will not resolve. Fix (base local_unbound):" + log "WARNING: service local_unbound enable && local-unbound-setup " + log "WARNING: (rewrites resolv.conf to 127.0.0.1 and keeps this drop-in)." + fi + + # Field finding: `options edns0` in resolv.conf (written by + # local-unbound-setup via resolv_conf_options in /etc/resolvconf.conf) + # has broken public resolution on some setups. + if grep -Eq '^[[:space:]]*options.*\bedns0\b' /etc/resolv.conf 2>/dev/null; then + log "NOTE: /etc/resolv.conf sets 'options edns0'. If public DNS resolution" + log "NOTE: fails, remove that line and make it permanent by setting" + log "NOTE: resolv_conf_options=\"\"" + log "NOTE: in /etc/resolvconf.conf (else resolvconf(8) re-adds it)." + fi +} + +# local-unbound-setup snapshots the nameservers it finds in +# /etc/resolv.conf into /var/unbound/forward.conf. If it is (re-)run +# AFTER resolv.conf already points at 127.0.0.1, unbound ends up +# forwarding every public query to itself — and with unbound's +# do-not-query-localhost default it refuses the loop, so all public +# resolution dies with SERVFAIL. Detect and explain. +warn_if_forward_loop() { + fwd="/var/unbound/forward.conf" + [ -f "$fwd" ] || return 0 + if grep -Eq '^[[:space:]]*forward-addr:[[:space:]]*(127\.0\.0\.1|::1)([[:space:]]|$)' "$fwd"; then + log "WARNING: ${fwd} forwards public queries to localhost — unbound is" + log "WARNING: forwarding to itself, which breaks ALL public resolution." + log "WARNING: (Cause: local-unbound-setup was run while resolv.conf already" + log "WARNING: pointed at 127.0.0.1.) Fix by re-running it with explicit" + log "WARNING: upstream resolvers, e.g. your router or ISP resolver:" + log "WARNING: local-unbound-setup 192.168.1.1" + fi +} + +# Backend 1: base-system local_unbound +try_local_unbound() { + service_enabled_or_running local_unbound || return 1 + [ -d /var/unbound ] || return 1 + + log "Configuring via local_unbound (${LOCAL_UNBOUND_DROPIN})" + mkdir -p "$LOCAL_UNBOUND_DROPIN_DIR" + unbound_snippet > "$LOCAL_UNBOUND_DROPIN" + + if command -v local-unbound-checkconf >/dev/null 2>&1 \ + && ! local-unbound-checkconf >/dev/null 2>&1; then + log "ERROR: local-unbound-checkconf rejected the config; removing drop-in" + rm -f "$LOCAL_UNBOUND_DROPIN" + return 1 + fi + + service local_unbound reload >/dev/null 2>&1 \ + || service local_unbound onerestart >/dev/null 2>&1 \ + || log "WARNING: local_unbound reload failed (config written, may need manual restart)" + save_backend "local_unbound" + warn_if_not_system_resolver + warn_if_forward_loop + return 0 +} + +# Backend 2: pkg unbound +try_pkg_unbound() { + service_enabled_or_running unbound || return 1 + [ -d /usr/local/etc/unbound ] || return 1 + + log "Configuring via unbound (${PKG_UNBOUND_DROPIN})" + mkdir -p "$PKG_UNBOUND_DROPIN_DIR" + unbound_snippet > "$PKG_UNBOUND_DROPIN" + + if ! grep -Erqs '^[[:space:]]*include(-toplevel)?:.*conf\.d' /usr/local/etc/unbound/unbound.conf; then + log "NOTE: ensure unbound.conf includes the drop-in directory, e.g.:" + log "NOTE: include-toplevel: \"${PKG_UNBOUND_DROPIN_DIR}/*.conf\"" + fi + + service unbound reload >/dev/null 2>&1 \ + || service unbound onerestart >/dev/null 2>&1 \ + || log "WARNING: unbound reload failed (config written, may need manual restart)" + save_backend "pkg-unbound" + warn_if_not_system_resolver + return 0 +} + +# Backend 3: pkg dnsmasq +# +# dnsmasq's `server=//#` accepts a bare IPv6 literal. +try_dnsmasq() { + service_enabled_or_running dnsmasq || return 1 + + log "Configuring via dnsmasq (${DNSMASQ_DROPIN})" + mkdir -p "$DNSMASQ_DROPIN_DIR" + cat > "$DNSMASQ_DROPIN" </dev/null 2>&1 \ + || service dnsmasq onerestart >/dev/null 2>&1 \ + || log "WARNING: dnsmasq reload failed (config written, may need manual restart)" + save_backend "dnsmasq" + warn_if_not_system_resolver + return 0 +} + +# --- Main --- + +wait_for_daemon || exit 1 + +try_local_unbound && exit 0 +try_pkg_unbound && exit 0 +try_dnsmasq && exit 0 + +log "WARNING: No supported DNS resolver detected." +log "To resolve .fips domains, forward the fips. zone to" +log "[${FIPS_DNS_LOOPBACK_V6}]:${FIPS_DNS_PORT} (the daemon's default bind)." +log "" +log "Easiest path on FreeBSD (base local_unbound):" +log " sysrc local_unbound_enable=YES" +log " local-unbound-setup" +log " service fips_dns restart" +save_backend "none" +exit 0 diff --git a/packaging/freebsd/fips-dns-teardown b/packaging/freebsd/fips-dns-teardown new file mode 100755 index 0000000..0e179e7 --- /dev/null +++ b/packaging/freebsd/fips-dns-teardown @@ -0,0 +1,50 @@ +#!/bin/sh +# fips-dns-teardown — Remove .fips DNS routing configured by fips-dns-setup. + +set -eu + +STATE_FILE="/var/run/fips/dns-backend" + +LOCAL_UNBOUND_DROPIN="/var/unbound/conf.d/fips.conf" +PKG_UNBOUND_DROPIN="/usr/local/etc/unbound/conf.d/fips.conf" +DNSMASQ_DROPIN="/usr/local/etc/dnsmasq.d/fips.conf" + +log() { echo "fips-dns: $*"; } + +backend="" +[ -f "$STATE_FILE" ] && backend="$(cat "$STATE_FILE")" + +case "$backend" in + local_unbound) + log "Removing local_unbound drop-in" + rm -f "$LOCAL_UNBOUND_DROPIN" + service local_unbound reload >/dev/null 2>&1 \ + || service local_unbound onerestart >/dev/null 2>&1 \ + || log "WARNING: local_unbound reload failed" + ;; + pkg-unbound) + log "Removing unbound drop-in" + rm -f "$PKG_UNBOUND_DROPIN" + service unbound reload >/dev/null 2>&1 \ + || service unbound onerestart >/dev/null 2>&1 \ + || log "WARNING: unbound reload failed" + ;; + dnsmasq) + log "Removing dnsmasq drop-in" + rm -f "$DNSMASQ_DROPIN" + service dnsmasq reload >/dev/null 2>&1 \ + || service dnsmasq onerestart >/dev/null 2>&1 \ + || log "WARNING: dnsmasq reload failed" + ;; + none) + log "No backend was configured; nothing to remove" + ;; + *) + # Unknown or missing state — clean up any drop-in we may own. + log "No recorded backend; removing any FIPS drop-ins" + rm -f "$LOCAL_UNBOUND_DROPIN" "$PKG_UNBOUND_DROPIN" "$DNSMASQ_DROPIN" + ;; +esac + +rm -f "$STATE_FILE" +exit 0 diff --git a/packaging/freebsd/fips-dns.rc b/packaging/freebsd/fips-dns.rc new file mode 100755 index 0000000..c4839b3 --- /dev/null +++ b/packaging/freebsd/fips-dns.rc @@ -0,0 +1,32 @@ +#!/bin/sh + +# PROVIDE: fips_dns +# REQUIRE: fips +# KEYWORD: shutdown + +# One-shot service that points the system resolver's `fips.` zone at the +# FIPS daemon's built-in DNS responder on [::1]:5354, so `.fips` +# hostnames resolve system-wide. Set fips_dns_enable=YES in rc.conf. + +. /etc/rc.subr + +name="fips_dns" +rcvar="fips_dns_enable" +desc="Route .fips DNS queries to the FIPS daemon" + +load_rc_config $name + +: ${fips_dns_enable:="NO"} + +start_cmd="fips_dns_start" +stop_cmd="fips_dns_stop" + +fips_dns_start() { + /usr/local/libexec/fips/fips-dns-setup +} + +fips_dns_stop() { + /usr/local/libexec/fips/fips-dns-teardown +} + +run_rc_command "$1" diff --git a/packaging/freebsd/fips.rc b/packaging/freebsd/fips.rc new file mode 100755 index 0000000..81269d3 --- /dev/null +++ b/packaging/freebsd/fips.rc @@ -0,0 +1,46 @@ +#!/bin/sh + +# PROVIDE: fips +# REQUIRE: NETWORKING FILESYSTEMS +# KEYWORD: shutdown + +# rc.conf knobs: +# fips_enable (bool): Set YES to run the FIPS daemon. Default NO. +# fips_config (path): Config file. Default /usr/local/etc/fips/fips.yaml. +# fips_flags (str): Extra arguments passed to the fips daemon. +# fips_logfile (path): Daemon stdout/stderr log. Default /var/log/fips.log. + +. /etc/rc.subr + +name="fips" +rcvar="fips_enable" +desc="FIPS mesh networking daemon" + +load_rc_config $name + +: ${fips_enable:="NO"} +: ${fips_config:="/usr/local/etc/fips/fips.yaml"} +: ${fips_logfile:="/var/log/fips.log"} + +runtime_dir="/var/run/fips" +pidfile="${runtime_dir}/fips.pid" +procname="/usr/local/bin/fips" +command="/usr/sbin/daemon" +command_args="-p ${pidfile} -t fips -o ${fips_logfile} ${procname} --config ${fips_config} ${fips_flags}" +start_precmd="fips_precmd" + +# 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 (matching the Debian tmpfiles entry) lets members of +# the fips group use fipsctl/fipstop without root; the group is created +# by the package post-install. Fall back to 0755 for source builds +# where the group does not exist. +fips_precmd() { + 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 +} + +run_rc_command "$1" diff --git a/packaging/freebsd/pkg-descr b/packaging/freebsd/pkg-descr new file mode 100644 index 0000000..9a8ac03 --- /dev/null +++ b/packaging/freebsd/pkg-descr @@ -0,0 +1,14 @@ +FIPS (Free Internetworking Peering System) is a self-organizing +encrypted mesh network built on Nostr identities, operating over +arbitrary transports (UDP, TCP, Ethernet, 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 .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 package ships the fips daemon, the fipsctl control CLI, the +fipstop live-status TUI, and rc.d services for the daemon (fips) and +for system-wide .fips DNS resolution (fips_dns). diff --git a/src/bin/fips.rs b/src/bin/fips.rs index 50926ab..288d7dd 100644 --- a/src/bin/fips.rs +++ b/src/bin/fips.rs @@ -99,7 +99,13 @@ async fn run_daemon( _ => filter, }; - fmt().with_env_filter(filter).with_target(true).init(); + // ANSI color only when stdout is a terminal — under a supervisor + // (daemon(8), systemd) escape codes would litter the log file. + fmt() + .with_env_filter(filter) + .with_target(true) + .with_ansi(std::io::IsTerminal::is_terminal(&std::io::stdout())) + .init(); info!("FIPS {} starting", version::short_version()); @@ -111,9 +117,9 @@ async fn run_daemon( } } - // The hosts/ACL defaults on macOS moved from /etc/fips to + // The hosts/ACL defaults on these platforms moved from /etc/fips to // /usr/local/etc/fips; flag files stranded at the old location. - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "freebsd"))] fips::node::warn_on_legacy_config_paths(); // Identity provisioning: config nsec > key file > generate ephemeral diff --git a/src/bin/fipsctl.rs b/src/bin/fipsctl.rs index 4f68d33..60b4040 100644 --- a/src/bin/fipsctl.rs +++ b/src/bin/fipsctl.rs @@ -425,6 +425,17 @@ fn main() { let key_path = dir.join("fips.key"); let pub_path = dir.join("fips.pub"); + // The default key directory on macOS/FreeBSD moved from /etc/fips + // to /usr/local/etc/fips; point at keys stranded at the old path. + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + if std::path::Path::new("/etc/fips/fips.key").exists() && !key_path.exists() { + eprintln!("note: /etc/fips/fips.key exists but the default key directory"); + eprintln!( + " is now {}; that key is no longer used by default.", + dir.display() + ); + } + if key_path.exists() && !force { eprintln!("error: key file already exists: {}", key_path.display()); eprintln!("Use --force to overwrite."); @@ -655,15 +666,15 @@ fn sparkline(values: &[f64], min: f64, max: f64) -> String { mod tests { use super::*; - // macOS packaging ships config under /usr/local/etc/fips/. - #[cfg(target_os = "macos")] + // macOS and FreeBSD packaging ship config under /usr/local/etc/fips/. + #[cfg(any(target_os = "macos", target_os = "freebsd"))] #[test] - fn test_default_key_dir_follows_macos_packaging_layout() { + fn test_default_key_dir_follows_packaging_layout() { assert_eq!(default_key_dir(), PathBuf::from("/usr/local/etc/fips")); } - // Non-macOS Unix keeps the historic /etc/fips/ location. - #[cfg(all(unix, not(target_os = "macos")))] + // Other Unix keeps the historic /etc/fips/ location. + #[cfg(all(unix, not(any(target_os = "macos", target_os = "freebsd"))))] #[test] fn test_default_key_dir_keeps_etc_fips_layout() { assert_eq!(default_key_dir(), PathBuf::from("/etc/fips")); diff --git a/src/config/mod.rs b/src/config/mod.rs index 705904d..35efc99 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -49,12 +49,13 @@ pub use transport::{ const CONFIG_FILENAME: &str = "fips.yaml"; /// System-wide config directory, following the platform's packaging layout -/// (`/usr/local/etc/fips` on macOS, `/etc/fips` otherwise). The daemon -/// derives identity key paths from the config file's location, so anything -/// that reads or writes config-adjacent files should use this one constant. -#[cfg(target_os = "macos")] +/// (`/usr/local/etc/fips` on macOS and FreeBSD, `/etc/fips` otherwise). The +/// daemon derives identity key paths from the config file's location, so +/// anything that reads or writes config-adjacent files should use this one +/// constant. +#[cfg(any(target_os = "macos", target_os = "freebsd"))] pub const SYSTEM_CONFIG_DIR: &str = "/usr/local/etc/fips"; -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "macos", target_os = "freebsd")))] pub const SYSTEM_CONFIG_DIR: &str = "/etc/fips"; /// Default key filename, placed alongside the config file. @@ -167,6 +168,13 @@ pub(crate) fn resolve_default_socket(filename: &str) -> String { return format!("/run/fips/{filename}"); } + // 1b. /var/run/fips — macOS and FreeBSD have no /run; the FreeBSD + // rc.d script creates this directory at service start. + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + if Path::new("/var/run/fips").is_dir() { + return format!("/var/run/fips/{filename}"); + } + // 2. $XDG_RUNTIME_DIR/fips/ — only if the variable points at an existing // directory. if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") { @@ -624,12 +632,12 @@ impl Config { // keep working after an upgrade. paths.push(PathBuf::from("/etc/fips").join(CONFIG_FILENAME)); - // macOS packaging installs config under /usr/local/etc/fips - // (Homebrew-style prefix); probe it after /etc/fips so the - // packaged file wins over a stale /etc/fips leftover. Read from - // SYSTEM_CONFIG_DIR rather than a second literal, so this path and - // the directory `fipsctl keygen` writes into cannot drift apart. - #[cfg(target_os = "macos")] + // macOS and FreeBSD packaging install config under /usr/local/etc/fips; + // probe it after /etc/fips so the packaged file wins over a stale + // /etc/fips leftover. Read from SYSTEM_CONFIG_DIR rather than a second + // literal, so this path and the directory `fipsctl keygen` writes into + // cannot drift apart. + #[cfg(any(target_os = "macos", target_os = "freebsd"))] paths.push(PathBuf::from(SYSTEM_CONFIG_DIR).join(CONFIG_FILENAME)); // User config directory @@ -1159,8 +1167,8 @@ node: .any(|p| p.starts_with("/etc/fips") && p.ends_with("fips.yaml")) ); - // macOS should also include /usr/local/etc/fips - #[cfg(target_os = "macos")] + // macOS and FreeBSD should also include /usr/local/etc/fips + #[cfg(any(target_os = "macos", target_os = "freebsd"))] assert!( paths .iter() @@ -2096,13 +2104,18 @@ node: // If /run/fips happens to be writable in the test environment (CI // running as root, for instance), the resolver legitimately picks - // /run/fips and skips XDG entirely. Accept either outcome but - // demand that one of the two canonical prefixes is chosen — never - // /tmp when XDG was valid. + // /run/fips and skips XDG entirely — likewise /var/run/fips on + // macOS/FreeBSD. Accept either outcome but demand that one of the + // canonical prefixes is chosen — never /tmp when XDG was valid. + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + let canonical_var_run = path.starts_with("/var/run/fips/"); + #[cfg(not(any(target_os = "macos", target_os = "freebsd")))] + let canonical_var_run = false; assert!( path.starts_with("/run/fips/") + || canonical_var_run || path.starts_with(&format!("{}/fips/", temp_dir.path().display())), - "expected /run/fips or XDG path, got: {path}" + "expected /run/fips, /var/run/fips, or XDG path, got: {path}" ); } @@ -2132,12 +2145,17 @@ node: } } - // Accept either /run/fips/ (test running as root with that dir - // writable) or /tmp/fips-... (the dev-machine fallback). Never - // accept the bogus XDG dir leaking through. + // Accept /run/fips/ (test running as root with that dir + // writable), /var/run/fips/ on macOS/FreeBSD, or /tmp/fips-... + // (the dev-machine fallback). Never accept the bogus XDG dir + // leaking through. + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + let canonical_var_run = path.starts_with("/var/run/fips/"); + #[cfg(not(any(target_os = "macos", target_os = "freebsd")))] + let canonical_var_run = false; assert!( - path.starts_with("/run/fips/") || path == "/tmp/fips-gateway.sock", - "expected /run/fips or /tmp fallback, got: {path}" + path.starts_with("/run/fips/") || canonical_var_run || path == "/tmp/fips-gateway.sock", + "expected /run/fips, /var/run/fips, or /tmp fallback, got: {path}" ); assert!( !path.starts_with(bogus), diff --git a/src/node/acl.rs b/src/node/acl.rs index dbccbb1..6dafd94 100644 --- a/src/node/acl.rs +++ b/src/node/acl.rs @@ -24,33 +24,34 @@ use tracing::{debug, info, warn}; /// Default path for the peer allow list. /// -/// On macOS the install layout (see `packaging/macos/`) ships config under -/// `/usr/local/etc/fips/` rather than `/etc/fips/`; the default follows the -/// platform's packaging so the daemon reads the file the operator was told -/// to edit. Linux and other Unix keep the historic `/etc/fips/` location. -#[cfg(target_os = "macos")] -pub const DEFAULT_PEERS_ALLOW_PATH: &str = "/usr/local/etc/fips/peers.allow"; -#[cfg(not(target_os = "macos"))] +/// On macOS (`packaging/macos/`) and FreeBSD (`packaging/freebsd/`) the +/// install layout ships config under `/usr/local/etc/fips/` rather than +/// `/etc/fips/`; the default follows the platform's packaging so the daemon +/// reads the file the operator was told to edit. Linux and other Unix keep +/// the historic `/etc/fips/` location. +#[cfg(not(any(target_os = "macos", target_os = "freebsd")))] pub const DEFAULT_PEERS_ALLOW_PATH: &str = "/etc/fips/peers.allow"; +#[cfg(any(target_os = "macos", target_os = "freebsd"))] +pub const DEFAULT_PEERS_ALLOW_PATH: &str = "/usr/local/etc/fips/peers.allow"; /// Default path for the peer deny list. /// -/// See [`DEFAULT_PEERS_ALLOW_PATH`] for the macOS `/usr/local/etc/fips/` +/// See [`DEFAULT_PEERS_ALLOW_PATH`] for the `/usr/local/etc/fips/` /// rationale. -#[cfg(target_os = "macos")] -pub const DEFAULT_PEERS_DENY_PATH: &str = "/usr/local/etc/fips/peers.deny"; -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "macos", target_os = "freebsd")))] pub const DEFAULT_PEERS_DENY_PATH: &str = "/etc/fips/peers.deny"; +#[cfg(any(target_os = "macos", target_os = "freebsd"))] +pub const DEFAULT_PEERS_DENY_PATH: &str = "/usr/local/etc/fips/peers.deny"; /// Warn about config files stranded at the pre-move default location. /// -/// The macOS defaults for `hosts`, `peers.allow` and `peers.deny` moved -/// from `/etc/fips` to `/usr/local/etc/fips`, the directory the macOS -/// packaging actually populates. The old location is no longer read by -/// the default path constants, and a `peers.deny` silently left behind -/// there would fail open (a missing deny list is not an error), so surface -/// the situation loudly once at startup. -#[cfg(target_os = "macos")] +/// The macOS/FreeBSD defaults for `hosts`, `peers.allow` and `peers.deny` +/// moved from `/etc/fips` to `/usr/local/etc/fips`, the directory both +/// installers actually populate. The old location is no longer read, and +/// a `peers.deny` silently left behind there would fail open (a missing +/// deny list is not an error), so surface the situation loudly once at +/// startup. +#[cfg(any(target_os = "macos", target_os = "freebsd"))] pub fn warn_on_legacy_config_paths() { for (current, name) in [ (crate::upper::hosts::DEFAULT_HOSTS_PATH, "hosts"), @@ -538,21 +539,22 @@ mod tests { acl } - // Guard against the macOS path regression: the install layout - // (`packaging/macos/`) ships config under `/usr/local/etc/fips/`, so the - // default ACL paths must follow it, or `peers.allow`/`peers.deny` are - // silently unread on macOS (see the `NotFound` no-op in `load_file`). - #[cfg(target_os = "macos")] + // Guard against the path regression: the macOS and FreeBSD install + // layouts (`packaging/macos/`, `packaging/freebsd/`) ship config under + // `/usr/local/etc/fips/`, so the default ACL paths must follow it, or + // `peers.allow`/`peers.deny` are silently unread there (see the + // `NotFound` no-op in `load_file`). + #[cfg(any(target_os = "macos", target_os = "freebsd"))] #[test] - fn test_default_acl_paths_follow_macos_packaging_layout() { + fn test_default_acl_paths_follow_packaging_layout() { assert_eq!(DEFAULT_PEERS_ALLOW_PATH, "/usr/local/etc/fips/peers.allow"); assert_eq!(DEFAULT_PEERS_DENY_PATH, "/usr/local/etc/fips/peers.deny"); } - // Non-macOS Unix/Linux keeps the historic `/etc/fips/` location; this + // Other Unix/Linux keeps the historic `/etc/fips/` location; this // runs on the Linux CI matrix and pins the value so a future refactor // can't silently drift it. - #[cfg(all(unix, not(target_os = "macos")))] + #[cfg(all(unix, not(any(target_os = "macos", target_os = "freebsd"))))] #[test] fn test_default_acl_paths_keep_etc_fips_layout() { assert_eq!(DEFAULT_PEERS_ALLOW_PATH, "/etc/fips/peers.allow"); diff --git a/src/node/lifecycle/mod.rs b/src/node/lifecycle/mod.rs index b3ac80a..b18b21b 100644 --- a/src/node/lifecycle/mod.rs +++ b/src/node/lifecycle/mod.rs @@ -1857,10 +1857,13 @@ impl Node { info!("effective MTU: {} bytes", effective_mtu); debug!(" max TCP MSS: {} bytes", max_mss); - // On macOS, create a shutdown pipe. Writing to it unblocks the - // reader thread's select() loop without closing the TUN fd - // (which would cause a double-close when TunDevice drops). - #[cfg(target_os = "macos")] + // On macOS and FreeBSD, create a shutdown pipe. Writing to it + // unblocks the reader thread's select() loop without closing + // the TUN fd (which would cause a double-close when TunDevice + // drops). Linux instead unblocks the reader by deleting the + // interface; on macOS/FreeBSD downing the interface does not + // wake a blocked read. + #[cfg(any(target_os = "macos", target_os = "freebsd"))] let (shutdown_read_fd, shutdown_write_fd) = { let mut fds = [0i32; 2]; if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 { @@ -1906,7 +1909,7 @@ impl Node { let transport_mtu = self.transport_mtu(); let path_mtu_lookup = self.path_mtu_lookup.clone(); let reader_child_tx = self.child_exit_tx.clone(); - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "freebsd"))] let reader_handle = thread::spawn(move || { run_tun_reader( device, @@ -1922,7 +1925,7 @@ impl Node { let _ = tx.blocking_send(Child::Tun); } }); - #[cfg(not(target_os = "macos"))] + #[cfg(not(any(target_os = "macos", target_os = "freebsd")))] let reader_handle = thread::spawn(move || { run_tun_reader( device, @@ -1944,7 +1947,7 @@ impl Node { self.supervisor.tun_outbound_rx = Some(outbound_rx); self.supervisor.tun_reader_handle = Some(reader_handle); self.supervisor.tun_writer_handle = Some(writer_handle); - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "freebsd"))] { self.supervisor.tun_shutdown_fd = Some(shutdown_write_fd); } @@ -2335,14 +2338,17 @@ impl Node { // Drop the tun_tx to signal the writer to stop self.supervisor.tun_tx.take(); - // Delete the interface (on Linux, causes reader to get EFAULT) + // Delete the interface (on Linux, causes reader to get + // EFAULT; on macOS/FreeBSD this downs it — the kernel + // destroys the device once the reader closes the fd). if let Err(e) = shutdown_tun_interface(&name).await { warn!(name = %name, error = %e, "Failed to shutdown TUN interface"); } - // On macOS, signal the reader thread to exit by writing to the - // shutdown pipe. The reader's select() will wake up and break. - #[cfg(target_os = "macos")] + // On macOS and FreeBSD, signal the reader thread to exit by + // writing to the shutdown pipe. The reader's select() will + // wake up and break. + #[cfg(any(target_os = "macos", target_os = "freebsd"))] if let Some(fd) = self.supervisor.tun_shutdown_fd.take() { unsafe { libc::write(fd, b"x".as_ptr() as *const libc::c_void, 1); diff --git a/src/node/lifecycle/supervisor.rs b/src/node/lifecycle/supervisor.rs index 72e1697..9fc1913 100644 --- a/src/node/lifecycle/supervisor.rs +++ b/src/node/lifecycle/supervisor.rs @@ -682,9 +682,10 @@ pub(crate) struct Supervisor { pub(in crate::node) tun_reader_handle: Option>, /// TUN writer thread handle. pub(in crate::node) tun_writer_handle: Option>, - /// Shutdown pipe: writing to this fd unblocks the TUN reader thread on macOS. - /// On Linux, deleting the interface via netlink serves the same purpose. - #[cfg(target_os = "macos")] + /// Shutdown pipe: writing to this fd unblocks the TUN reader thread on + /// macOS and FreeBSD. On Linux, deleting the interface via netlink + /// serves the same purpose. + #[cfg(any(target_os = "macos", target_os = "freebsd"))] pub(in crate::node) tun_shutdown_fd: Option, /// Receiver for resolved identities from the DNS responder. @@ -732,7 +733,7 @@ impl Supervisor { tun_outbound_rx: None, tun_reader_handle: None, tun_writer_handle: None, - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "freebsd"))] tun_shutdown_fd: None, dns_identity_rx: None, dns_task: None, diff --git a/src/node/mod.rs b/src/node/mod.rs index da9a0d9..cd4eaae 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -5,7 +5,7 @@ //! Bloom filters, coordinate caches, transports, links, and peers. pub(crate) mod acl; -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "freebsd"))] pub use acl::warn_on_legacy_config_paths; mod bloom; pub(crate) mod context; @@ -961,6 +961,16 @@ impl Node { transports.push(TransportHandle::Ethernet(eth)); } } + // `EthernetConfig` always parses, so on platforms without the + // transport a configured `ethernet:` block would otherwise be + // dropped silently and the node would report healthy without it. + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + for (name, _) in self.config().transports.ethernet.iter() { + tracing::warn!( + instance = name.unwrap_or("default"), + "Ethernet transport is not supported on this platform; ignoring configured instance" + ); + } // Create TCP transport instances let tcp_instances: Vec<_> = self diff --git a/src/upper/hosts.rs b/src/upper/hosts.rs index 3ba6f56..e68eb7e 100644 --- a/src/upper/hosts.rs +++ b/src/upper/hosts.rs @@ -18,15 +18,15 @@ use tracing::{debug, info, warn}; /// Default path for the FIPS hosts file. /// -/// On macOS the install layout (see `packaging/macos/`) ships config under -/// `/usr/local/etc/fips/`, not `/etc/fips/`; the default follows the -/// platform's packaging so aliases edited by the operator are actually -/// loaded. Linux and other Unix keep the historic `/etc/fips/` location; -/// Windows uses `%ProgramData%`. -#[cfg(target_os = "macos")] -pub const DEFAULT_HOSTS_PATH: &str = "/usr/local/etc/fips/hosts"; -#[cfg(all(unix, not(target_os = "macos")))] +/// On macOS (`packaging/macos/`) and FreeBSD (`packaging/freebsd/`) the +/// install layout ships config under `/usr/local/etc/fips/`, not +/// `/etc/fips/`; the default follows the platform's packaging so aliases +/// edited by the operator are actually loaded. Linux and other Unix keep +/// the historic `/etc/fips/` location; Windows uses `%ProgramData%`. +#[cfg(all(unix, not(any(target_os = "macos", target_os = "freebsd"))))] pub const DEFAULT_HOSTS_PATH: &str = "/etc/fips/hosts"; +#[cfg(any(target_os = "macos", target_os = "freebsd"))] +pub const DEFAULT_HOSTS_PATH: &str = "/usr/local/etc/fips/hosts"; #[cfg(windows)] pub const DEFAULT_HOSTS_PATH: &str = r"C:\ProgramData\fips\hosts"; @@ -321,18 +321,18 @@ mod tests { // --- default path tests --- - // Guard against the macOS path regression: the install layout - // (`packaging/macos/`) ships config under `/usr/local/etc/fips/`, so the - // default hosts path must follow it, or host-file aliases are silently - // unloaded on macOS (see the `NotFound` no-op in `load_hosts_file`). - #[cfg(target_os = "macos")] + // Guard against the path regression: the macOS and FreeBSD install + // layouts ship config under `/usr/local/etc/fips/`, so the default hosts + // path must follow it, or host-file aliases are silently unloaded there + // (see the `NotFound` no-op in `load_hosts_file`). + #[cfg(any(target_os = "macos", target_os = "freebsd"))] #[test] - fn test_default_hosts_path_follows_macos_packaging_layout() { + fn test_default_hosts_path_follows_packaging_layout() { assert_eq!(DEFAULT_HOSTS_PATH, "/usr/local/etc/fips/hosts"); } - // Non-macOS Unix/Linux keeps the historic `/etc/fips/` location. - #[cfg(all(unix, not(target_os = "macos")))] + // Other Unix/Linux keeps the historic `/etc/fips/` location. + #[cfg(all(unix, not(any(target_os = "macos", target_os = "freebsd"))))] #[test] fn test_default_hosts_path_keeps_etc_fips_layout() { assert_eq!(DEFAULT_HOSTS_PATH, "/etc/fips/hosts"); diff --git a/src/upper/tun.rs b/src/upper/tun.rs index 3ad0f0e..7d2490d 100644 --- a/src/upper/tun.rs +++ b/src/upper/tun.rs @@ -6,7 +6,8 @@ //! //! Platform-specific implementations: //! - Linux: Uses the `tun` crate with `rtnetlink` for interface configuration -//! - macOS: Uses the `tun` crate with `ifconfig`/`route` for interface configuration +//! - macOS/FreeBSD: Uses the `tun` crate with `ifconfig`/`route` for interface +//! configuration //! - Windows: Uses the `wintun` crate for TUN device support #[cfg(windows)] @@ -18,7 +19,7 @@ use std::collections::HashMap; use std::fs::File; #[cfg(unix)] use std::io::Read; -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "macos", target_os = "freebsd")))] #[cfg(unix)] use std::io::Write; use std::net::Ipv6Addr; @@ -31,7 +32,7 @@ use tracing::error; use tracing::{debug, trace}; #[cfg(windows)] use tracing::{error, warn}; -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] use tun::Layer; /// Read-only handle to the per-destination path MTU map. Populated by @@ -237,16 +238,20 @@ impl TunDevice { // Create the TUN device. `mut` is only exercised on linux/macos, where // the name/layer/mtu are set below; other unix targets (android) pass // the default config through unchanged. - #[cfg_attr(not(any(target_os = "linux", target_os = "macos")), allow(unused_mut))] + #[cfg_attr( + not(any(target_os = "linux", target_os = "macos", target_os = "freebsd")), + allow(unused_mut) + )] let mut tun_config = tun::Configuration::default(); - // On macOS, utun devices get kernel-assigned names (utun0, utun1, ...), - // so we skip setting the name and read it back after creation. #[cfg(target_os = "linux")] #[allow(deprecated)] tun_config.name(name).layer(Layer::L3).mtu(mtu); - #[cfg(target_os = "macos")] + // On macOS and FreeBSD the kernel assigns the device name (utun0..., + // tun0...), so the name is not set here and is read back after + // creation. + #[cfg(any(target_os = "macos", target_os = "freebsd"))] { #[allow(deprecated)] tun_config.layer(Layer::L3).mtu(mtu); @@ -254,7 +259,25 @@ impl TunDevice { let device = tun::create(&tun_config)?; - // Read the actual device name (on macOS this is the kernel-assigned utun* name) + // FreeBSD: enable TUNSIFHEAD. Without it, tunoutput() rejects every + // non-IPv4 packet with EAFNOSUPPORT, so nothing IPv6 can be sent + // through the interface. With it, every frame on the fd carries a + // 4-byte network-order address-family prefix (like macOS utun), + // which the reader and writer handle. + #[cfg(target_os = "freebsd")] + { + const TUNSIFHEAD: libc::c_ulong = 0x8004_7460; // _IOW('t', 96, int) + let enable: libc::c_int = 1; + if unsafe { libc::ioctl(device.as_raw_fd(), TUNSIFHEAD, &enable) } != 0 { + return Err(TunError::Configure(format!( + "TUNSIFHEAD ioctl failed: {}", + std::io::Error::last_os_error() + ))); + } + } + + // Read the actual device name (on macOS and FreeBSD this is the + // kernel-assigned utunN / tunN name) let actual_name = { use tun::AbstractDevice; device @@ -304,13 +327,30 @@ impl TunDevice { /// The buffer should be at least MTU + header size (typically 1500+ bytes). /// /// The tun crate's `Read` impl transparently strips the macOS utun - /// packet information header, so this returns a raw IP packet on all - /// platforms. + /// packet information header, and on FreeBSD (TUNSIFHEAD) the + /// equivalent 4-byte address-family prefix is stripped here, so this + /// returns a raw IP packet on all platforms. `Ok(0)` means the frame + /// carried no payload; callers should skip it. /// /// The raw `io::Error` is returned so callers can inspect `ErrorKind` /// (e.g. `WouldBlock`) or `raw_os_error()` without string matching. pub fn read_packet(&mut self, buf: &mut [u8]) -> Result { - self.device.read(buf) + let n = self.device.read(buf)?; + // FreeBSD tun in TUNSIFHEAD mode prefixes every frame with its + // address family. Strip it here so callers see the same raw-IP + // contract as on Linux and macOS. Non-IPv6 families pass through + // stripped — matching macOS, where the tun crate does the same — + // and are dropped by the IPv6 version check in handle_tun_packet. + #[cfg(target_os = "freebsd")] + let n = match parse_utun_af_prefix(&buf[..n]) { + Some(_) if n > 4 => { + buf.copy_within(4..n, 0); + n - 4 + } + // Header-only or truncated frame: no payload. + _ => 0, + }; + Ok(n) } /// Shutdown and delete the TUN device. @@ -364,35 +404,34 @@ impl TunDevice { } } -/// macOS utun protocol family value for IPv6 (matches `` -/// `AF_INET6` on Darwin). Used as the 4-byte big-endian packet-info -/// header prepended to every utun frame. -#[cfg(target_os = "macos")] -const UTUN_AF_INET6: u32 = 30; +/// Address-family value for IPv6 in the 4-byte packet-info header used +/// by macOS `utun` and FreeBSD `tun` (TUNSIFHEAD) devices: the target's +/// `AF_INET6` — 30 on Darwin, 28 on FreeBSD. +#[cfg(any(target_os = "macos", target_os = "freebsd"))] +const UTUN_AF_INET6: u32 = libc::AF_INET6 as u32; -/// Build the 4-byte big-endian utun packet-info header for an IPv6 frame. +/// Build the 4-byte big-endian packet-info header for an IPv6 frame. /// -/// utun devices on macOS require a 4-byte address-family prefix on every -/// frame: a single big-endian `u32` carrying the protocol family. For -/// IPv6 traffic (the only family FIPS sends) this is `AF_INET6 = 30`, -/// which serializes as `[0x00, 0x00, 0x00, 0x1e]`. -#[cfg(target_os = "macos")] +/// macOS utun and FreeBSD tun (TUNSIFHEAD) devices require a 4-byte +/// address-family prefix on every frame: a single big-endian `u32` +/// carrying the protocol family. For IPv6 traffic (the only family FIPS +/// sends) this is the target's `AF_INET6`. +#[cfg(any(target_os = "macos", target_os = "freebsd"))] #[inline] fn utun_af_inet6_header() -> [u8; 4] { UTUN_AF_INET6.to_be_bytes() } -/// Parse the 4-byte big-endian utun packet-info header. +/// Parse the 4-byte big-endian packet-info header. /// -/// Returns the address-family value (`AF_INET6 = 30` for IPv6 frames), -/// or `None` if the buffer is shorter than the 4-byte header. The `tun` -/// crate's `Read` impl strips this transparently for us in the read -/// path; this helper exists for round-trip testability with -/// [`utun_af_inet6_header`] and for any future code path that reads -/// from the dup'd fd directly. -#[cfg(target_os = "macos")] +/// Returns the address-family value, or `None` if the buffer is shorter +/// than the 4-byte header. On FreeBSD [`TunDevice::read_packet`] strips +/// the prefix with this; on macOS the `tun` crate's `Read` impl strips +/// it before we see the frame, so there the helper is exercised only by +/// the round-trip tests below. +#[cfg(any(target_os = "macos", target_os = "freebsd"))] +#[cfg_attr(target_os = "macos", allow(dead_code))] #[inline] -#[allow(dead_code)] fn parse_utun_af_prefix(buf: &[u8]) -> Option { if buf.len() < 4 { return None; @@ -421,7 +460,7 @@ impl TunWriter { /// /// Blocks forever, reading packets from the channel and writing them /// to the TUN device. Returns when the channel is closed (all senders dropped). - #[cfg_attr(target_os = "macos", allow(unused_mut))] + #[cfg_attr(any(target_os = "macos", target_os = "freebsd"), allow(unused_mut))] pub fn run(mut self) { use super::tcp_mss::clamp_tcp_mss; @@ -445,11 +484,12 @@ impl TunWriter { ); } - // On macOS, utun devices require a 4-byte packet information header - // prepended to each packet. The tun crate handles this for its own + // On macOS (utun) and FreeBSD (tun with TUNSIFHEAD), the device + // requires a 4-byte network-order address-family header prepended + // to each packet. The tun crate handles this for its own // Read/Write impl, but we use a dup'd fd directly. We use writev // to avoid allocating a buffer on every packet. - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "freebsd"))] let write_result = { use std::os::unix::io::AsRawFd; let af_header = utun_af_inet6_header(); @@ -478,7 +518,7 @@ impl TunWriter { } } }; - #[cfg(not(target_os = "macos"))] + #[cfg(not(any(target_os = "macos", target_os = "freebsd")))] let write_result = self.file.write_all(&packet); if let Err(e) = write_result { @@ -507,7 +547,7 @@ impl TunWriter { /// This is designed to run in a dedicated thread since TUN reads are blocking. /// The loop exits when the TUN interface is deleted (EFAULT) or an unrecoverable /// error occurs. -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "macos", target_os = "freebsd")))] #[cfg(unix)] pub fn run_tun_reader( mut device: TunDevice, @@ -551,10 +591,10 @@ pub fn run_tun_reader( /// /// Used to ensure the shutdown pipe read-end is always closed when /// `run_tun_reader` returns, regardless of which exit path is taken. -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "freebsd"))] struct ShutdownFd(std::os::unix::io::RawFd); -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "freebsd"))] impl Drop for ShutdownFd { fn drop(&mut self) { unsafe { @@ -563,12 +603,14 @@ impl Drop for ShutdownFd { } } -/// TUN packet reader loop (macOS). +/// TUN packet reader loop (macOS / FreeBSD). /// /// Uses `select()` to multiplex between the TUN fd and a shutdown pipe, /// avoiding the need to close the TUN fd externally (which would cause a -/// double-close when `TunDevice` drops). -#[cfg(target_os = "macos")] +/// double-close when `TunDevice` drops). Both platforms need the pipe +/// because downing the interface does not reliably unblock a thread +/// parked in a blocking TUN read on either of them. +#[cfg(any(target_os = "macos", target_os = "freebsd"))] #[allow(clippy::too_many_arguments)] pub fn run_tun_reader( mut device: TunDevice, @@ -642,13 +684,19 @@ pub fn run_tun_reader( return; // _shutdown_fd closes on drop } } - Ok(_) => break, // No more data + // No more data, or an empty/skipped frame — either way + // fall back to select for the next readable event. + Ok(_) => break, Err(e) => { if e.kind() == std::io::ErrorKind::WouldBlock { break; // Done for this select round } - // EBADF is expected during shutdown when the fd is closed - if e.raw_os_error() != Some(libc::EBADF) { + // EBADF is expected during shutdown when the fd is + // closed; ENXIO when the interface was destroyed out + // from under the fd (FreeBSD). + if e.raw_os_error() != Some(libc::EBADF) + && e.raw_os_error() != Some(libc::ENXIO) + { error!(name = %name, error = %e, "TUN read error"); } return; // _shutdown_fd closes on drop @@ -1362,13 +1410,14 @@ mod platform { } } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "freebsd"))] mod platform { use super::TunError; use std::net::Ipv6Addr; use tokio::process::Command; /// Check if IPv6 is disabled system-wide. + #[cfg(target_os = "macos")] pub fn is_ipv6_disabled() -> bool { // macOS: check via sysctl; if the key doesn't exist, IPv6 is enabled std::process::Command::new("sysctl") @@ -1378,6 +1427,20 @@ mod platform { .unwrap_or(false) } + /// Check if IPv6 is disabled system-wide. + #[cfg(target_os = "freebsd")] + pub fn is_ipv6_disabled() -> bool { + // kern.features.inet6 is 1 when the kernel has IPv6 support. On a + // kernel built without INET6 the OID is absent entirely and sysctl + // exits nonzero, so a failed lookup means disabled. Only a failure + // to run sysctl at all assumes enabled. + std::process::Command::new("sysctl") + .args(["-n", "kern.features.inet6"]) + .output() + .map(|o| !o.status.success() || String::from_utf8_lossy(&o.stdout).trim() != "1") + .unwrap_or(false) + } + /// Check if a network interface already exists. pub async fn interface_exists(name: &str) -> bool { Command::new("ifconfig") @@ -1392,9 +1455,11 @@ mod platform { /// Shut down a network interface by name. /// - /// On macOS, utun devices are automatically destroyed when the file - /// descriptor is closed. Bringing the interface down causes any - /// blocking reads to return an error, which unblocks the reader thread. + /// utun (macOS) and devfs-cloned tun (FreeBSD) devices are + /// automatically destroyed by the kernel when the last file + /// descriptor closes. Bringing the interface down causes any + /// blocking reads to return an error, which unblocks the reader + /// thread. pub async fn delete_interface(name: &str) -> Result<(), TunError> { run_cmd("ifconfig", &[name, "down"]).await } @@ -1408,6 +1473,12 @@ mod platform { ) .await?; + // FreeBSD: a manually-configured interface with no autoconf + // link-local comes up with ND6_IFF_IFDISABLED set, which silently + // drops all IPv6. + #[cfg(target_os = "freebsd")] + run_cmd("ifconfig", &[name, "inet6", "-ifdisabled"]).await?; + // Set MTU run_cmd("ifconfig", &[name, "mtu", &mtu.to_string()]).await?; @@ -1415,6 +1486,7 @@ mod platform { run_cmd("ifconfig", &[name, "up"]).await?; // Add route for fd00::/8 (FIPS address space) via this interface + #[cfg(target_os = "macos")] run_cmd( "route", &[ @@ -1429,6 +1501,25 @@ mod platform { ) .await?; + // FreeBSD: a leftover route from a previous run is not an error. + #[cfg(target_os = "freebsd")] + { + let output = Command::new("route") + .args(["-6", "add", "fd00::/8", "-interface", name]) + .output() + .await + .map_err(|e| TunError::Configure(format!("route failed: {}", e)))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.contains("File exists") && !stderr.contains("already in table") { + return Err(TunError::Configure(format!( + "route -6 add fd00::/8 failed: {}", + stderr.trim() + ))); + } + } + } + Ok(()) } @@ -1656,32 +1747,41 @@ mod tests { } // ======================================================================== - // macOS utun packet-info header (AF_INET6 4-byte big-endian prefix) + // macOS utun / FreeBSD TUNSIFHEAD packet-info header (4-byte big-endian + // AF prefix) // // These tests are pure-data byte-buffer manipulation and require no // privilege, no actual TUN device, no system calls. They pin the wire // format that `TunWriter::run` emits ahead of every IPv6 frame on the - // dup'd utun fd, and the inverse parse used for round-trip checking. + // dup'd fd, and the inverse parse `read_packet` uses on FreeBSD. // ======================================================================== - #[cfg(target_os = "macos")] - mod macos_utun_header { + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + mod utun_header { use super::super::{UTUN_AF_INET6, parse_utun_af_prefix, utun_af_inet6_header}; #[test] - fn af_inet6_constant_matches_darwin() { - // Darwin's defines AF_INET6 = 30. If this ever - // diverges, every utun write FIPS issues will be misclassified - // by the kernel and dropped. + fn af_inet6_constant_matches_platform() { + // defines AF_INET6 = 30 on Darwin and 28 on + // FreeBSD. If this ever diverges, every TUN write FIPS issues + // will be misclassified by the kernel and dropped, and every + // inbound frame will be skipped as non-IPv6. + #[cfg(target_os = "macos")] assert_eq!(UTUN_AF_INET6, 30); + #[cfg(target_os = "freebsd")] + assert_eq!(UTUN_AF_INET6, 28); + assert_eq!(libc::AF_INET6 as u32, UTUN_AF_INET6); } #[test] fn encode_produces_big_endian_af_inet6() { - // The kernel reads the 4-byte prefix as a big-endian u32. - // 30 == 0x0000001e, so the wire bytes are [0, 0, 0, 0x1e]. + // The kernel reads the 4-byte prefix as a big-endian u32: + // 30 == 0x0000001e (Darwin), 28 == 0x0000001c (FreeBSD). let header = utun_af_inet6_header(); + #[cfg(target_os = "macos")] assert_eq!(header, [0x00, 0x00, 0x00, 0x1e]); + #[cfg(target_os = "freebsd")] + assert_eq!(header, [0x00, 0x00, 0x00, 0x1c]); } #[test]