Merge branch 'master' into next

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Johnathan Corgan
2026-08-09 14:01:38 +00:00
23 changed files with 1301 additions and 254 deletions
+287
View File
@@ -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}"
+17
View File
@@ -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
Generated
+18 -92
View File
@@ -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"
+3 -1
View File
@@ -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"] }
+13 -13
View File
@@ -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
+5 -1
View File
@@ -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
+26
View File
@@ -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-<version>-macos-<arch>.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-<version>-freebsd-<arch>.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
+99
View File
@@ -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-<version>-freebsd-<arch>.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 <user>`, 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 <npub>.fips @::1 AAAA # daemon directly (bypasses unbound)
drill <npub>.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 <name>` prints `Opened by PID <n>` for the process
holding a tun device.
+169
View File
@@ -0,0 +1,169 @@
#!/bin/sh
# Build a FreeBSD .pkg package for FIPS using pkg-create(8).
#
# Usage: packaging/freebsd/build-pkg.sh [--version <version>] [--no-build]
#
# Prerequisites: the pinned Rust toolchain, pkg(8).
# Output: deploy/fips-<version>-freebsd-<arch>.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 <version>] [--no-build]" >&2; exit 1 ;;
esac
shift
done
# Default to the Cargo.toml version; CI passes a derived version that
# appends +<branch>.<height>.<hash> on branch builds. Either way, map
# '-' and '+' to '.' — '-' is the pkg name/version separator and
# neither is allowed inside a pkg version (0.5.0-dev -> 0.5.0.dev).
[ -n "$VERSION" ] \
|| VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' "${PROJECT_ROOT}/Cargo.toml" | head -1)"
[ -n "$VERSION" ] || { echo "error: could not read version from Cargo.toml" >&2; exit 1; }
VERSION="$(printf '%s' "$VERSION" | tr -- '+-' '..')"
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" <<EOF
name: "fips"
version: "${VERSION}"
origin: "net/fips"
comment: "Self-organizing encrypted mesh network on Nostr identities"
desc: <<EOD
${DESC}
EOD
maintainer: "jcorgan@corganlabs.com"
www: "https://fips.network"
abi: "${ABI}"
prefix: "/usr/local"
licenselogic: "single"
licenses: ["MIT"]
categories: ["net"]
scripts: {
post-install: <<EOD
# Control-socket access group: the rc script creates /var/run/fips as
# root:fips 0750, so members can use fipsctl/fipstop without root.
pw groupshow fips >/dev/null 2>&1 || pw groupadd fips
# 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: <<EOD
# Stop the services so the daemon binary is never replaced (upgrade) or
# removed (deinstall) under a running process. fips_dns stop also tears
# down the .fips resolver drop-in; on upgrade the new package's
# post-install re-establishes it.
service fips_dns onestop >/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 <name>-<version>.pkg; add the OS and
# arch so release assets stay distinct from the macOS .pkg files.
OUT="${PROJECT_ROOT}/deploy/fips-${VERSION}-freebsd-${ARCH}.pkg"
mv "${PROJECT_ROOT}/deploy/fips-${VERSION}.pkg" "$OUT"
echo "==> built:"
ls -l "$OUT"
+221
View File
@@ -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 <<EOF
# Managed by fips_dns (fips-dns-setup). Routes .fips queries to the
# FIPS daemon's local DNS responder.
server:
# The .fips zone is unsigned; skip DNSSEC validation for it.
domain-insecure: "fips."
# The daemon binds [::1] only — forwarding must go over IPv6.
# local-unbound-setup writes do-ip6: no by default, which would
# make this forward-zone fail silently.
do-ip6: yes
# unbound refuses to query loopback forwarders by default, which
# silently SERVFAILs every .fips query instead of asking the
# daemon on [::1]:5354.
do-not-query-localhost: no
forward-zone:
name: "fips."
forward-addr: ${FIPS_DNS_LOOPBACK_V6}@${FIPS_DNS_PORT}
forward-first: no
EOF
}
# A resolver can be perfectly configured and still unused: if
# /etc/resolv.conf has no loopback nameserver, applications never query
# it. This was the actual root cause of ".fips doesn't resolve" in the
# field, so diagnose it here.
warn_if_not_system_resolver() {
if ! grep -Eq '^[[:space:]]*nameserver[[:space:]]+(127\.0\.0\.1|::1)' /etc/resolv.conf 2>/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 <upstream-ip ...>"
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=/<domain>/<addr>#<port>` 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" <<EOF
# FIPS .fips domain forwarding (managed by fips_dns)
server=/fips/${FIPS_DNS_LOOPBACK_V6}#${FIPS_DNS_PORT}
EOF
if ! grep -Eqs "^[[:space:]]*conf-dir=.*dnsmasq\.d" /usr/local/etc/dnsmasq.conf; then
log "NOTE: ensure dnsmasq.conf reads the drop-in directory, e.g.:"
log "NOTE: conf-dir=${DNSMASQ_DROPIN_DIR}/,*.conf"
fi
service dnsmasq reload >/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
+50
View File
@@ -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
+32
View File
@@ -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 `<name>.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"
+46
View File
@@ -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"
+14
View File
@@ -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 <npub>.fips names. Peer discovery and NAT traversal
are Nostr-mediated; traffic is encrypted hop-by-hop (Noise IK) and
end-to-end (Noise XK) with periodic rekey.
This 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).
+9 -3
View File
@@ -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
+16 -5
View File
@@ -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"));
+40 -22
View File
@@ -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),
+28 -26
View File
@@ -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");
+17 -11
View File
@@ -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);
+5 -4
View File
@@ -682,9 +682,10 @@ pub(crate) struct Supervisor {
pub(in crate::node) tun_reader_handle: Option<JoinHandle<()>>,
/// TUN writer thread handle.
pub(in crate::node) tun_writer_handle: Option<JoinHandle<()>>,
/// 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<std::os::unix::io::RawFd>,
/// 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,
+11 -1
View File
@@ -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
+16 -16
View File
@@ -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");
+159 -59
View File
@@ -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<usize, std::io::Error> {
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 `<sys/socket.h>`
/// `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<u32> {
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 <sys/socket.h> 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() {
// <sys/socket.h> 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]