From d0dcb4095851b4f1677e93103d4991d9563a22b3 Mon Sep 17 00:00:00 2001 From: fr34aky Date: Sun, 9 Aug 2026 13:20:36 +0000 Subject: [PATCH] FreeBSD support: daemon, TUN datapath, .fips DNS, and native pkg packaging Adds FreeBSD as a supported platform. The daemon, fipsctl, TUN datapath and DNS integration build and run there, with a native pkg and an rc.d service. The one piece of genuinely new datapath logic is the TUN framing. FreeBSD's tun rejects every non-IPv4 packet with EAFNOSUPPORT unless TUNSIFHEAD is set, so nothing IPv6 can be sent at all; with it set, every frame carries a 4-byte network-order address-family prefix the way macOS utun does. The ioctl is issued at device creation and the prefix is stripped on read, which gives callers the same raw-IP contract as Linux and macOS. A frame carrying only the header reads as zero bytes and the reader loops treat it as nothing to do. The address family is now taken from libc rather than hardcoded, because AF_INET6 is 30 on Darwin and 28 on FreeBSD. The reader shutdown path, the writer's address-family header and the supervisor's shutdown pipe were all macOS-only and are now shared with FreeBSD, since neither platform wakes a blocked read when the interface goes down. Linux continues to rely on interface deletion. mdns-sd moves from 0.19 to 0.20 for socket-pktinfo 0.4.1, the first release that builds on FreeBSD, which uses IP_RECVDSTADDR and IP_RECVIF instead of Linux-style IP_PKTINFO. This is the only change here that affects every platform rather than just the new one. The config, ACL, hosts and keygen path constants now treat FreeBSD the same as macOS, since both install under /usr/local/etc/fips. Those constants arrived separately on maint and are merged here rather than duplicated: the predicates widen to cover FreeBSD, the platform-gated tests widen with them, and keygen keeps reading the shared SYSTEM_CONFIG_DIR constant rather than reintroducing a literal. Co-authored-by: Johnathan Corgan --- .github/workflows/package-freebsd.yml | 287 ++++++++++++++++++++++++++ CHANGELOG.md | 16 ++ Cargo.lock | 110 ++-------- Cargo.toml | 4 +- README.md | 26 +-- packaging/Makefile | 6 +- packaging/README.md | 26 +++ packaging/freebsd/README.md | 99 +++++++++ packaging/freebsd/build-pkg.sh | 169 +++++++++++++++ packaging/freebsd/fips-dns-setup | 221 ++++++++++++++++++++ packaging/freebsd/fips-dns-teardown | 50 +++++ packaging/freebsd/fips-dns.rc | 32 +++ packaging/freebsd/fips.rc | 46 +++++ packaging/freebsd/pkg-descr | 14 ++ src/bin/fips.rs | 12 +- src/bin/fipsctl.rs | 21 +- src/config/mod.rs | 62 ++++-- src/node/acl.rs | 54 ++--- src/node/lifecycle/mod.rs | 28 ++- src/node/lifecycle/supervisor.rs | 9 +- src/node/mod.rs | 12 +- src/upper/hosts.rs | 32 +-- src/upper/tun.rs | 218 +++++++++++++------ 23 files changed, 1300 insertions(+), 254 deletions(-) create mode 100644 .github/workflows/package-freebsd.yml create mode 100644 packaging/freebsd/README.md create mode 100755 packaging/freebsd/build-pkg.sh create mode 100755 packaging/freebsd/fips-dns-setup create mode 100755 packaging/freebsd/fips-dns-teardown create mode 100755 packaging/freebsd/fips-dns.rc create mode 100755 packaging/freebsd/fips.rc create mode 100644 packaging/freebsd/pkg-descr 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 36dda74..c9cdcb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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 bed9868..c9becf9 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 83d01ed..dc58572 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 2516fce..4990ca0 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 @@ -252,7 +252,7 @@ release line. - 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 69e6770..3289247 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 @@ -1144,8 +1152,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() @@ -2079,13 +2087,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}" ); } @@ -2115,12 +2128,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 46f49db..ddcc1a1 100644 --- a/src/node/lifecycle/mod.rs +++ b/src/node/lifecycle/mod.rs @@ -1621,10 +1621,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 { @@ -1670,7 +1673,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, @@ -1686,7 +1689,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, @@ -1708,7 +1711,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); } @@ -2099,14 +2102,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 6487751..3a66f5a 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; @@ -926,6 +926,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]