diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8615f17..a6ebf1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,8 +23,7 @@ env: # ───────────────────────────────────────────────────────────────────────────── # Job 1 – Build matrix # -# Builds on Linux x86_64 and Linux aarch64. macOS and Windows are in a -# separate ci-compat.yml workflow so their failures don't mark this run red. +# Builds on Linux x86_64, Linux aarch64, and macOS. # ───────────────────────────────────────────────────────────────────────────── jobs: build: @@ -37,6 +36,7 @@ jobs: include: - os: ubuntu-latest - os: ubuntu-24.04-arm + - os: macos-latest steps: - uses: actions/checkout@v4 @@ -44,7 +44,8 @@ jobs: - name: Set SOURCE_DATE_EPOCH from git run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV" - - name: Install system dependencies + - name: Install system dependencies (Linux only) + if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev - name: Install Rust toolchain @@ -62,11 +63,16 @@ jobs: ${{ runner.os }}-cargo- - name: Build - run: cargo build --release + run: cargo build --release ${{ runner.os == 'macOS' && '--no-default-features --features tui' || '--features gateway' }} - - name: SHA-256 hashes + - name: SHA-256 hashes (Linux) + if: runner.os == 'Linux' run: sha256sum target/release/fips target/release/fipsctl target/release/fipstop target/release/fips-gateway + - name: SHA-256 hashes (macOS) + if: runner.os == 'macOS' + run: shasum -a 256 target/release/fips target/release/fipsctl target/release/fipstop + # Upload the Linux binary so integration jobs can use it without rebuilding - name: Upload Linux binary if: matrix.os == 'ubuntu-latest' @@ -117,7 +123,7 @@ jobs: uses: taiki-e/install-action@nextest - name: Run unit tests - run: cargo nextest run --all --profile ci + run: cargo nextest run --all --profile ci --features gateway - name: Publish test report (Checks tab) uses: dorny/test-reporter@v2 @@ -136,6 +142,39 @@ jobs: check_name: Unit Tests Summary fail_on_failure: false +# ───────────────────────────────────────────────────────────────────────────── +# Job 2b – Unit tests (macOS) +# ───────────────────────────────────────────────────────────────────────────── + test-macos: + name: Unit tests (macOS) + runs-on: macos-latest + needs: [build] + steps: + - uses: actions/checkout@v4 + + - name: Set SOURCE_DATE_EPOCH from git + run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo registry + build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Install cargo-nextest + uses: taiki-e/install-action@nextest + + - name: Run unit tests + run: cargo nextest run --all --profile ci --no-default-features --features tui + # ───────────────────────────────────────────────────────────────────────────── # Job 3 – Integration tests (static mesh + chaos simulation) # diff --git a/.github/workflows/package-linux.yml b/.github/workflows/package-linux.yml index 62c22de..836507e 100644 --- a/.github/workflows/package-linux.yml +++ b/.github/workflows/package-linux.yml @@ -1,8 +1,13 @@ name: Linux Package on: push: + branches: + - master + - maint + - next tags: - "v*" + pull_request: workflow_dispatch: env: @@ -85,7 +90,7 @@ jobs: run: cargo install cargo-deb --version 3.6.3 --locked - name: Build release binaries - run: cargo build --release + run: cargo build --release --features gateway - name: Build systemd tarball env: diff --git a/.github/workflows/package-macos.yml b/.github/workflows/package-macos.yml new file mode 100644 index 0000000..c2eea4f --- /dev/null +++ b/.github/workflows/package-macos.yml @@ -0,0 +1,175 @@ +name: macOS Package +on: + push: + branches: + - master + - maint + - next + tags: + - "v*" + pull_request: + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + determine-versioning: + runs-on: macos-latest + outputs: + macos_package_version: ${{ steps.macos_version.outputs.macos_package_version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Derive macOS package version + id: macos_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/\.\.+/./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 + + echo "macos_package_version=${VERSION}" >> "$GITHUB_OUTPUT" + + build: + name: Build macOS package (${{ matrix.arch }}) + runs-on: ${{ matrix.os }} + needs: determine-versioning + + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + arch: arm64 + target: aarch64-apple-darwin + - os: macos-latest + arch: x86_64 + target: x86_64-apple-darwin + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set SOURCE_DATE_EPOCH from git + run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Add cross-compile target + run: rustup target add ${{ matrix.target }} + + - name: Cache Cargo registry + build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: macos-release-${{ matrix.arch }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + macos-release-${{ matrix.arch }}- + + - name: Build release binaries + run: cargo build --release --target ${{ matrix.target }} --no-default-features --features tui + + - name: Build macOS package + run: | + packaging/macos/build-pkg.sh \ + --version "${{ needs.determine-versioning.outputs.macos_package_version }}" \ + --target ${{ matrix.target }} \ + --no-build + + - name: Resolve macOS asset path + id: macos-assets + shell: bash + run: | + : ${GITHUB_OUTPUT:=/tmp/github_output} + + PKG_FILE=$(find deploy -maxdepth 1 -type f -name "fips-*-macos-*.pkg" | sort | head -n 1) + if [[ -z "$PKG_FILE" ]]; then + echo "Missing macOS package" >&2 + exit 1 + fi + + echo "pkg=$PKG_FILE" >> "$GITHUB_OUTPUT" + + - name: SHA-256 hash + run: | + echo "==> macOS release asset:" + shasum -a 256 "${{ steps.macos-assets.outputs.pkg }}" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: fips_${{ needs.determine-versioning.outputs.macos_package_version }}_${{ matrix.arch }}_macos + path: ${{ steps.macos-assets.outputs.pkg }} + retention-days: 30 + + - name: Build summary + run: | + echo "Build Summary for macOS/${{ matrix.arch }}:" + echo " Package: ${{ steps.macos-assets.outputs.pkg }}" + + release: + name: Publish macOS assets to GitHub Release + runs-on: ubuntu-latest + needs: build + if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: write + + steps: + - name: Download macOS artifacts + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Generate macOS release checksums + run: | + cd dist + find . -maxdepth 1 -type f -name '*.pkg' -printf '%P\n' \ + | LC_ALL=C sort \ + | xargs sha256sum \ + > checksums-macos.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 macOS assets + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${GITHUB_REF_NAME}" \ + dist/*.pkg \ + dist/checksums-macos.txt \ + --clobber \ + --repo "${GITHUB_REPOSITORY}" diff --git a/.github/workflows/package-openwrt.yml b/.github/workflows/package-openwrt.yml index 3bdb6ed..24765b8 100644 --- a/.github/workflows/package-openwrt.yml +++ b/.github/workflows/package-openwrt.yml @@ -1,6 +1,13 @@ name: OpenWrt Package on: push: + branches: + - master + - maint + - next + tags: + - "v*" + pull_request: workflow_dispatch: inputs: arch: diff --git a/.gitignore b/.gitignore index 8f39f87..b3b857e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,8 +11,11 @@ .vscode/ .idea/ -# Claude Code +# AI Agents .claude/ +AGENTS.md +CLAUDE.md +agents/ deploy/ vps.env @@ -28,4 +31,5 @@ sim-results/ __pycache__/ *.py[cod] *.egg-info/ -*.egg \ No newline at end of file +*.egg + diff --git a/Cargo.toml b/Cargo.toml index 5cbfd81..9fac7b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ readme = "README.md" default = ["tui", "ble"] tui = ["dep:ratatui"] ble = ["dep:bluer"] +gateway = ["dep:rustables"] [dependencies] ratatui = { version = "0.30", optional = true } @@ -32,14 +33,17 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tun = { version = "0.8.5", features = ["async"] } libc = "0.2" -rtnetlink = "0.20.0" tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net", "time", "process"] } futures = "0.3" simple-dns = "0.11.2" socket2 = { version = "0.6.2", features = ["all"] } tokio-socks = "0.5" + +rustables = { version = "0.8.7", optional = true } + +[target.'cfg(target_os = "linux")'.dependencies] +rtnetlink = "0.20.0" bluer = { version = "0.17", features = ["bluetoothd", "l2cap"], optional = true } -rustables = "0.8.7" [package.metadata.deb] maintainer = "Johnathan Corgan " @@ -82,6 +86,7 @@ path = "src/bin/fipsctl.rs" [[bin]] name = "fips-gateway" path = "src/bin/fips-gateway.rs" +required-features = ["gateway"] [[bin]] name = "fipstop" diff --git a/README.md b/README.md index 99ecf30..2a9a7a7 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,22 @@ cd fips cargo build --release ``` -Requires Rust 1.85+ (edition 2024) and Linux with TUN support. +Requires Rust 1.85+ (edition 2024) and a Unix-like OS with TUN support +(Linux or macOS). -The BLE transport (enabled by default) requires BlueZ and libdbus. On -Debian/Ubuntu: `sudo apt install bluez libdbus-1-dev`. To build without BLE: -`cargo build --release --no-default-features --features tui`. +### Transport support by platform + +| Transport | Linux | macOS | Windows | +|-----------|:-----:|:-----:|:-------:| +| UDP | ✅ | ✅ | ❌ | +| TCP | ✅ | ✅ | ❌ | +| Ethernet | ✅ | ✅ | ❌ | +| Tor | ✅ | ✅ | ❌ | +| BLE | ✅ | ❌ | ❌ | + +On **Linux**, the BLE transport requires BlueZ and libdbus. On +Debian/Ubuntu: `sudo apt install bluez libdbus-1-dev`. Then build with +BLE enabled: `cargo build --release --features ble`. ## Installation @@ -113,6 +124,41 @@ sudo ./install.sh See [packaging/systemd/README.install.md](packaging/systemd/README.install.md) for the full installation and configuration guide. +### macOS (.pkg) + +```bash +./packaging/macos/build-pkg.sh +sudo installer -pkg deploy/fips-*-macos-*.pkg -target / +``` + +This installs binaries to `/usr/local/bin/`, config to +`/usr/local/etc/fips/`, sets up `.fips` DNS resolution via +`/etc/resolver/fips`, and registers a launchd daemon. Edit +`/usr/local/etc/fips/fips.yaml` before starting: + +```bash +sudo nano /usr/local/etc/fips/fips.yaml +sudo launchctl load -w /Library/LaunchDaemons/com.fips.daemon.plist +``` + +Remove with `sudo packaging/macos/uninstall.sh` (preserves config). + +To restart the node after making configuration changes: + +```bash +sudo launchctl unload -w /Library/LaunchDaemons/com.fips.daemon.plist +sudo launchctl load -w /Library/LaunchDaemons/com.fips.daemon.plist +``` + +Check logs for troubleshooting: + +```bash +sudo tail -f /usr/local/var/log/fips/fips.log +``` + +> **Note:** On macOS, the TUN device is named `utun` (kernel-assigned) +> rather than `fips0`. + ## Configuration The default configuration file is installed at `/etc/fips/fips.yaml`: @@ -175,20 +221,30 @@ for the full reference. ### DNS Resolution FIPS includes a DNS resolver (enabled by default, port 5354) that maps -`.fips` names to fd00::/8 IPv6 addresses. With systemd-resolved: +`.fips` names to fd00::/8 IPv6 addresses. + +**Linux** (systemd-resolved): ```bash sudo resolvectl dns fips0 127.0.0.1:5354 sudo resolvectl domain fips0 ~fips ``` +**macOS**: DNS is configured automatically by the `.pkg` installer via +`/etc/resolver/fips`. No manual setup is needed. + Then reach any FIPS node by npub with standard IPv6 tools: ```bash ping6 npub1bbb....fips -ssh npub1bbb....fips +ssh -6 npub1bbb....fips ``` +> **macOS note:** Use `ping6` instead of `ping`. macOS ships separate +> `ping` (IPv4-only) and `ping6` (IPv6) binaries; `ping` will not +> resolve AAAA records. Similarly, use `curl -6`, `ssh -6`, etc. when +> connecting by `.fips` hostname. + ### Monitoring Use `fipsctl` to query a running node: @@ -266,7 +322,7 @@ Ethernet, Tor, and Bluetooth (BLE) with a small live mesh of deployed nodes. - UDP, TCP, Ethernet, Tor, and BLE transports (BLE via L2CAP CoC with per-link MTU negotiation) - Runtime inspection and peer management via `fipsctl` and `fipstop` - Reproducible builds with toolchain pinning and SOURCE_DATE_EPOCH -- Debian and systemd tarball packaging +- Linux (Debian, systemd tarball, OpenWrt, AUR) and macOS packaging - Docker-based integration and chaos testing ### Near-term priorities diff --git a/packaging/Makefile b/packaging/Makefile index 0f437c7..d54fa71 100644 --- a/packaging/Makefile +++ b/packaging/Makefile @@ -8,6 +8,7 @@ # make tarball Build a systemd install tarball # make ipk Build an OpenWrt .ipk package # make aur Build fips-git AUR package and validate with namcap +# make pkg Build a macOS .pkg installer # make all Build deb and tarball (default) # make clean Remove deploy/ directory @@ -15,7 +16,7 @@ SHELL := /bin/bash PACKAGING_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) PROJECT_ROOT := $(abspath $(PACKAGING_DIR)/..) -.PHONY: all deb tarball ipk aur clean +.PHONY: all deb tarball ipk aur pkg clean all: deb tarball @@ -31,5 +32,8 @@ ipk: aur: @bash $(PACKAGING_DIR)/aur/build-aur.sh +pkg: + @bash $(PACKAGING_DIR)/macos/build-pkg.sh + clean: rm -rf $(PROJECT_ROOT)/deploy diff --git a/packaging/README.md b/packaging/README.md index 3658adc..134366f 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -10,6 +10,7 @@ make deb # Debian/Ubuntu .deb make tarball # systemd install tarball make ipk # OpenWrt .ipk make aur # Arch Linux AUR package (fips-git, local build + namcap) +make pkg # macOS .pkg installer make all # deb + tarball (default) ``` @@ -20,6 +21,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 + macos/ macOS .pkg installer via pkgbuild systemd/ Generic Linux systemd tarball packaging openwrt/ OpenWrt .ipk packaging via cargo-zigbuild ``` @@ -80,6 +82,25 @@ bash packaging/openwrt/build-ipk.sh --arch mipsel See [openwrt/README.md](openwrt/README.md) for router-specific installation instructions. +### macOS (`.pkg`) + +Built with `pkgbuild` (included with Xcode command-line tools). Installs +binaries to `/usr/local/bin/`, config to `/usr/local/etc/fips/`, sets up +the `/etc/resolver/fips` DNS resolver for `.fips` domains, and loads a +launchd daemon. The TUN device is named `utun` (kernel-assigned) +rather than `fips0`. + +```sh +# Build +make pkg + +# Install +sudo installer -pkg deploy/fips--macos-.pkg -target / + +# Remove +sudo packaging/macos/uninstall.sh +``` + ### Arch Linux (AUR) Two AUR packages are maintained: `fips` (release, builds from tagged diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index 84aead6..34a3edd 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -29,13 +29,13 @@ build() { cd "$pkgname-$pkgver" export CARGO_TARGET_DIR=target export SOURCE_DATE_EPOCH=$(stat -c %Y Cargo.toml) - cargo build --frozen --release + cargo build --frozen --release --features gateway } check() { cd "$pkgname-$pkgver" export CARGO_TARGET_DIR=target - cargo test --frozen --lib + cargo test --frozen --lib --features gateway } package() { diff --git a/packaging/common/fips.yaml b/packaging/common/fips.yaml index bd8c842..00ae684 100644 --- a/packaging/common/fips.yaml +++ b/packaging/common/fips.yaml @@ -66,7 +66,7 @@ transports: peers: [] # Static peers for bootstrapping (UDP or TCP): - # - npub: "npub1..." + # - npub: "npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98" # alias: "gateway" # addresses: # - transport: udp diff --git a/packaging/debian/build-deb.sh b/packaging/debian/build-deb.sh index d703044..13a7aa8 100755 --- a/packaging/debian/build-deb.sh +++ b/packaging/debian/build-deb.sh @@ -71,7 +71,7 @@ echo "Building .deb package..." OUTPUT_DIR="$(mktemp -d)" trap 'rm -rf "${OUTPUT_DIR}"' EXIT -cargo_args=(deb --output "${OUTPUT_DIR}") +cargo_args=(deb --output "${OUTPUT_DIR}" --features gateway) if [[ -n "${TARGET_TRIPLE}" ]]; then cargo_args+=(--target "${TARGET_TRIPLE}") fi diff --git a/packaging/macos/build-pkg.sh b/packaging/macos/build-pkg.sh new file mode 100755 index 0000000..01a9e10 --- /dev/null +++ b/packaging/macos/build-pkg.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# Build a macOS .pkg installer for FIPS. +# +# Usage: ./packaging/macos/build-pkg.sh [--version ] [--no-build] +# Output: deploy/fips--macos-.pkg +# +# Prerequisites: Xcode command-line tools (pkgbuild is included) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PACKAGING_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +PROJECT_ROOT="$(cd "${PACKAGING_DIR}/.." && pwd)" + +usage() { + cat <<'EOF' +Usage: packaging/macos/build-pkg.sh [options] + +Options: + --version Override package version + --target Rust target triple (e.g. x86_64-apple-darwin) + --no-build Package existing binaries without running cargo build + -h, --help Show this help +EOF +} + +VERSION_OVERRIDE="" +TARGET_TRIPLE="" +NO_BUILD=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + VERSION_OVERRIDE="${2:?missing value for --version}" + shift 2 + ;; + --target) + TARGET_TRIPLE="${2:?missing value for --target}" + shift 2 + ;; + --no-build) + NO_BUILD=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +VERSION="${VERSION_OVERRIDE:-$(grep '^version' "${PROJECT_ROOT}/Cargo.toml" | head -1 | sed 's/.*"\(.*\)"/\1/')}" +ARCH="$(uname -m)" +PKG_NAME="fips-${VERSION}-macos-${ARCH}" +DEPLOY_DIR="${PROJECT_ROOT}/deploy" +STAGING_DIR="$(mktemp -d)" +SCRIPTS_DIR="$(mktemp -d)" +trap 'rm -rf "${STAGING_DIR}" "${SCRIPTS_DIR}"' EXIT + +if [[ -n "${TARGET_TRIPLE}" ]]; then + BINARY_DIR="${PROJECT_ROOT}/target/${TARGET_TRIPLE}/release" +else + BINARY_DIR="${PROJECT_ROOT}/target/release" +fi + +echo "Building FIPS v${VERSION} for macOS ${ARCH}..." + +# Build release binaries +if [[ "${NO_BUILD}" -eq 0 ]]; then + cargo_args=(build --release --manifest-path="${PROJECT_ROOT}/Cargo.toml" --no-default-features --features tui) + [[ -n "${TARGET_TRIPLE}" ]] && cargo_args+=(--target "${TARGET_TRIPLE}") + cargo "${cargo_args[@]}" +fi + +# Verify binaries exist +for bin in fips fipsctl fipstop; do + if [[ ! -f "${BINARY_DIR}/${bin}" ]]; then + echo "Missing binary: ${BINARY_DIR}/${bin}" >&2 + exit 1 + fi +done + +# Stage the payload (mirrors installed filesystem layout) +mkdir -p "${STAGING_DIR}/usr/local/bin" +mkdir -p "${STAGING_DIR}/usr/local/etc/fips" +mkdir -p "${STAGING_DIR}/usr/local/var/log/fips" +mkdir -p "${STAGING_DIR}/Library/LaunchDaemons" +mkdir -p "${STAGING_DIR}/etc/resolver" + +# Binaries +for bin in fips fipsctl fipstop; do + cp "${BINARY_DIR}/${bin}" "${STAGING_DIR}/usr/local/bin/" + strip "${STAGING_DIR}/usr/local/bin/${bin}" +done + +# Config (marked as conf file via postinstall logic — won't overwrite on upgrade) +cp "${PACKAGING_DIR}/common/fips.yaml" "${STAGING_DIR}/usr/local/etc/fips/fips.yaml.default" +cp "${PACKAGING_DIR}/common/hosts" "${STAGING_DIR}/usr/local/etc/fips/hosts.default" + +# LaunchDaemon plist +cp "${SCRIPT_DIR}/com.fips.daemon.plist" "${STAGING_DIR}/Library/LaunchDaemons/" + +# DNS resolver +cat > "${STAGING_DIR}/etc/resolver/fips" < "${SCRIPTS_DIR}/postinstall" <<'POSTINSTALL' +#!/bin/sh +set -e + +LOG="/var/log/fips-install.log" +log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" | tee -a "$LOG"; logger -t fips-install "$*"; } + +log "postinstall started" + +CONFDIR="/usr/local/etc/fips" + +# Install default config only if none exists (preserve on upgrade) +if [ ! -f "$CONFDIR/fips.yaml" ]; then + cp "$CONFDIR/fips.yaml.default" "$CONFDIR/fips.yaml" + chmod 600 "$CONFDIR/fips.yaml" + log "installed default config" +fi +if [ ! -f "$CONFDIR/hosts" ]; then + cp "$CONFDIR/hosts.default" "$CONFDIR/hosts" +fi + +# Flush DNS cache so macOS picks up the new /etc/resolver/fips file +dscacheutil -flushcache +killall -HUP mDNSResponder 2>/dev/null || true +log "flushed DNS cache" + +# Create fips group if it doesn't exist +if ! dscl . -read /Groups/fips > /dev/null 2>&1; then + dscl . -create /Groups/fips RecordName fips + dscl . -create /Groups/fips PrimaryGroupID 999 + log "created group fips" +fi + +# stat /dev/console gives the user logged into the GUI session — +# logname/SUDO_USER are not set in pkg postinstall context +REAL_USER="$(stat -f '%Su' /dev/console 2>/dev/null || true)" +log "console user: ${REAL_USER:-unknown}" +if [ -n "$REAL_USER" ] && [ "$REAL_USER" != "root" ]; then + if ! dscl . -read /Groups/fips GroupMembership 2>/dev/null | grep -qw "$REAL_USER"; then + dscl . -append /Groups/fips GroupMembership "$REAL_USER" + log "added $REAL_USER to group fips" + else + log "$REAL_USER already in group fips" + fi +fi + +# Load the launchd service +launchctl bootout system /Library/LaunchDaemons/com.fips.daemon.plist 2>/dev/null || true +launchctl bootstrap system /Library/LaunchDaemons/com.fips.daemon.plist 2>/dev/null || true +log "launchd service loaded" + +log "postinstall complete" +exit 0 +POSTINSTALL +chmod +x "${SCRIPTS_DIR}/postinstall" + +# Create preinstall script (stop service before upgrade) +cat > "${SCRIPTS_DIR}/preinstall" <<'PREINSTALL' +#!/bin/sh +# Stop service before upgrade +launchctl bootout system /Library/LaunchDaemons/com.fips.daemon.plist 2>/dev/null || true +exit 0 +PREINSTALL +chmod +x "${SCRIPTS_DIR}/preinstall" + +# Build the .pkg +mkdir -p "${DEPLOY_DIR}" +pkgbuild \ + --root "${STAGING_DIR}" \ + --scripts "${SCRIPTS_DIR}" \ + --identifier com.fips.pkg \ + --version "${VERSION}" \ + --ownership recommended \ + "${DEPLOY_DIR}/${PKG_NAME}.pkg" + +echo "" +echo "Package built: deploy/${PKG_NAME}.pkg" +ls -lh "${DEPLOY_DIR}/${PKG_NAME}.pkg" +echo "" +echo "Install with: sudo installer -pkg deploy/${PKG_NAME}.pkg -target /" +echo "Remove with: sudo packaging/macos/uninstall.sh" diff --git a/packaging/macos/com.fips.daemon.plist b/packaging/macos/com.fips.daemon.plist new file mode 100644 index 0000000..059eca9 --- /dev/null +++ b/packaging/macos/com.fips.daemon.plist @@ -0,0 +1,33 @@ + + + + + Label + com.fips.daemon + + ProgramArguments + + /usr/local/bin/fips + --config + /usr/local/etc/fips/fips.yaml + + + RunAtLoad + + + KeepAlive + + SuccessfulExit + + + + StandardOutPath + /usr/local/var/log/fips/fips.log + + StandardErrorPath + /usr/local/var/log/fips/fips.log + + WorkingDirectory + /usr/local/etc/fips + + diff --git a/packaging/macos/uninstall.sh b/packaging/macos/uninstall.sh new file mode 100755 index 0000000..0f7dc57 --- /dev/null +++ b/packaging/macos/uninstall.sh @@ -0,0 +1,42 @@ +#!/bin/sh +# FIPS uninstall script for macOS +# +# Run with: sudo ./uninstall.sh +set -e + +PLIST="/Library/LaunchDaemons/com.fips.daemon.plist" + +# Require root +if [ "$(id -u)" -ne 0 ]; then + echo "Error: must run as root (sudo $0)" >&2 + exit 1 +fi + +echo "Uninstalling FIPS..." + +# Stop and unload the service +if launchctl list com.fips.daemon >/dev/null 2>&1; then + launchctl unload "$PLIST" 2>/dev/null || true + echo " Service stopped" +fi + +# Remove launchd plist +rm -f "$PLIST" +echo " Removed $PLIST" + +# Remove DNS resolver +rm -f /etc/resolver/fips +dscacheutil -flushcache +killall -HUP mDNSResponder 2>/dev/null || true +echo " Removed /etc/resolver/fips" + +# Remove binaries +for bin in fips fipsctl fipstop; do + rm -f "/usr/local/bin/$bin" +done +echo " Removed binaries from /usr/local/bin/" + +echo "" +echo "FIPS uninstalled." +echo "Config preserved at /usr/local/etc/fips/ (remove manually if desired)" +echo "Logs preserved at /usr/local/var/log/fips/ (remove manually if desired)" diff --git a/packaging/openwrt-ipk/Makefile b/packaging/openwrt-ipk/Makefile index d8f45da..8dc74c7 100644 --- a/packaging/openwrt-ipk/Makefile +++ b/packaging/openwrt-ipk/Makefile @@ -82,6 +82,7 @@ define Build/Compile cargo build \ --release \ --target $(RUST_TARGET) \ + --features gateway \ --bin fips \ --bin fipsctl \ --bin fipstop \ diff --git a/packaging/openwrt-ipk/build-ipk.sh b/packaging/openwrt-ipk/build-ipk.sh index 1714595..2e43565 100755 --- a/packaging/openwrt-ipk/build-ipk.sh +++ b/packaging/openwrt-ipk/build-ipk.sh @@ -110,7 +110,7 @@ cargo zigbuild \ --release \ --target "$RUST_TARGET" \ --no-default-features \ - --features tui \ + --features tui,gateway \ --bin fips \ --bin fipsctl \ --bin fipstop \ diff --git a/packaging/systemd/build-tarball.sh b/packaging/systemd/build-tarball.sh index 7887735..af0f72f 100755 --- a/packaging/systemd/build-tarball.sh +++ b/packaging/systemd/build-tarball.sh @@ -86,7 +86,7 @@ echo "Building FIPS v${VERSION} for ${ARCH}..." # Build release binaries (tui is a default feature, includes fipstop) if [[ "${NO_BUILD}" -eq 0 ]]; then - cargo_args=(build --release --manifest-path="${PROJECT_ROOT}/Cargo.toml") + cargo_args=(build --release --manifest-path="${PROJECT_ROOT}/Cargo.toml" --features gateway) if [[ -n "${TARGET_TRIPLE}" ]]; then cargo_args+=(--target "${TARGET_TRIPLE}") fi diff --git a/src/bin/fips-gateway.rs b/src/bin/fips-gateway.rs index a047766..0dbbffd 100644 --- a/src/bin/fips-gateway.rs +++ b/src/bin/fips-gateway.rs @@ -4,6 +4,7 @@ //! DNS-allocated virtual IPs and kernel nftables NAT. use clap::Parser; +#[cfg(target_os = "linux")] use fips::gateway::{control, dns, nat, net, pool}; use fips::version; use fips::Config; diff --git a/src/bin/fips.rs b/src/bin/fips.rs index 76d59af..9aeb6bd 100644 --- a/src/bin/fips.rs +++ b/src/bin/fips.rs @@ -112,8 +112,20 @@ async fn main() { info!("FIPS running, press Ctrl+C to exit"); - // Run the RX event loop until shutdown signal. + // Run the RX event loop until shutdown signal (SIGINT or SIGTERM). // stop() drops the packet channel, causing run_rx_loop to exit. + #[cfg(unix)] + let shutdown = async { + use tokio::signal::unix::{signal, SignalKind}; + let mut sigterm = signal(SignalKind::terminate()).expect("failed to register SIGTERM handler"); + tokio::select! { + _ = tokio::signal::ctrl_c() => {}, + _ = sigterm.recv() => {}, + } + }; + #[cfg(not(unix))] + let shutdown = tokio::signal::ctrl_c(); + tokio::select! { result = node.run_rx_loop() => { match result { @@ -121,7 +133,7 @@ async fn main() { Err(e) => error!("RX loop error: {}", e), } } - _ = tokio::signal::ctrl_c() => { + _ = shutdown => { info!("Shutdown signal received"); } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 2b18e28..ee2df6b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -18,6 +18,7 @@ //! nsec: "nsec1..." //! ``` +#[cfg(feature = "gateway")] mod gateway; mod node; mod peer; @@ -34,6 +35,7 @@ pub use node::{ NodeConfig, RateLimitConfig, RekeyConfig, RetryConfig, SessionConfig, SessionMmpConfig, TreeConfig, }; +#[cfg(feature = "gateway")] pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig}; pub use peer::{ConnectPolicy, PeerAddress, PeerConfig}; pub use transport::{BleConfig, DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances, TransportsConfig, UdpConfig}; @@ -320,6 +322,7 @@ pub struct Config { pub peers: Vec, /// Gateway configuration (`gateway`). + #[cfg(feature = "gateway")] #[serde(default, skip_serializing_if = "Option::is_none")] pub gateway: Option, } @@ -441,6 +444,7 @@ impl Config { self.peers = other.peers; } // Merge gateway section — higher-priority config replaces entirely + #[cfg(feature = "gateway")] if other.gateway.is_some() { self.gateway = other.gateway; } diff --git a/src/lib.rs b/src/lib.rs index 756db66..339fafe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod bloom; pub mod cache; pub mod config; pub mod control; +#[cfg(feature = "gateway")] pub mod gateway; pub mod identity; pub mod mmp; @@ -27,7 +28,7 @@ pub use identity::{ }; // Re-export config types -pub use config::{Config, ConfigError, GatewayConfig, IdentityConfig, TorConfig, UdpConfig}; +pub use config::{Config, ConfigError, IdentityConfig, TorConfig, UdpConfig}; pub use upper::config::{DnsConfig, TunConfig}; // Re-export tree types diff --git a/src/node/lifecycle.rs b/src/node/lifecycle.rs index c405940..69087be 100644 --- a/src/node/lifecycle.rs +++ b/src/node/lifecycle.rs @@ -587,6 +587,22 @@ 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")] + let (shutdown_read_fd, shutdown_write_fd) = { + let mut fds = [0i32; 2]; + if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 { + return Err(NodeError::Tun( + crate::upper::tun::TunError::Configure( + "failed to create shutdown pipe".into(), + ), + )); + } + (fds[0], fds[1]) + }; + // Create writer (dups the fd for independent write access) let (writer, tun_tx) = device.create_writer(max_mss)?; @@ -604,6 +620,11 @@ impl Node { // Spawn reader thread let transport_mtu = self.transport_mtu(); + #[cfg(target_os = "macos")] + let reader_handle = thread::spawn(move || { + run_tun_reader(device, mtu, our_addr, reader_tun_tx, outbound_tx, transport_mtu, shutdown_read_fd); + }); + #[cfg(not(target_os = "macos"))] let reader_handle = thread::spawn(move || { run_tun_reader(device, mtu, our_addr, reader_tun_tx, outbound_tx, transport_mtu); }); @@ -614,6 +635,8 @@ impl Node { self.tun_outbound_rx = Some(outbound_rx); self.tun_reader_handle = Some(reader_handle); self.tun_writer_handle = Some(writer_handle); + #[cfg(target_os = "macos")] + { self.tun_shutdown_fd = Some(shutdown_write_fd); } } Err(e) => { self.tun_state = TunState::Failed; @@ -704,11 +727,21 @@ impl Node { // Drop the tun_tx to signal the writer to stop self.tun_tx.take(); - // Delete the interface (causes reader to get EFAULT) + // Delete the interface (on Linux, causes reader to get EFAULT) 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")] + if let Some(fd) = self.tun_shutdown_fd.take() { + unsafe { + libc::write(fd, b"x".as_ptr() as *const libc::c_void, 1); + libc::close(fd); + } + } + // Wait for threads to finish if let Some(handle) = self.tun_reader_handle.take() { let _ = handle.join(); diff --git a/src/node/mod.rs b/src/node/mod.rs index d6ef551..c5525b5 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -33,7 +33,6 @@ use crate::transport::{ use crate::transport::udp::UdpTransport; use crate::transport::tcp::TcpTransport; use crate::transport::tor::TorTransport; -#[cfg(target_os = "linux")] use crate::transport::ethernet::EthernetTransport; use crate::tree::TreeState; use crate::upper::hosts::HostMap; @@ -364,6 +363,10 @@ pub struct Node { tun_reader_handle: Option>, /// TUN writer thread handle. 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")] + tun_shutdown_fd: Option, // === DNS Responder === /// Receiver for resolved identities from the DNS responder. @@ -530,6 +533,8 @@ impl Node { tun_outbound_rx: None, tun_reader_handle: None, tun_writer_handle: None, + #[cfg(target_os = "macos")] + tun_shutdown_fd: None, dns_identity_rx: None, dns_task: None, index_allocator: IndexAllocator::new(), @@ -640,6 +645,8 @@ impl Node { tun_outbound_rx: None, tun_reader_handle: None, tun_writer_handle: None, + #[cfg(target_os = "macos")] + tun_shutdown_fd: None, dns_identity_rx: None, dns_task: None, index_allocator: IndexAllocator::new(), @@ -695,23 +702,19 @@ impl Node { } // Create Ethernet transport instances - #[cfg(target_os = "linux")] - { - let eth_instances: Vec<_> = self - .config - .transports - .ethernet - .iter() - .map(|(name, config)| (name.map(|s| s.to_string()), config.clone())) - .collect(); - - let xonly = self.identity.pubkey(); - for (name, eth_config) in eth_instances { - let transport_id = self.allocate_transport_id(); - let mut eth = EthernetTransport::new(transport_id, name, eth_config, packet_tx.clone()); - eth.set_local_pubkey(xonly); - transports.push(TransportHandle::Ethernet(eth)); - } + let eth_instances: Vec<_> = self + .config + .transports + .ethernet + .iter() + .map(|(name, config)| (name.map(|s| s.to_string()), config.clone())) + .collect(); + let xonly = self.identity.pubkey(); + for (name, eth_config) in eth_instances { + let transport_id = self.allocate_transport_id(); + let mut eth = EthernetTransport::new(transport_id, name, eth_config, packet_tx.clone()); + eth.set_local_pubkey(xonly); + transports.push(TransportHandle::Ethernet(eth)); } // Create TCP transport instances @@ -831,18 +834,9 @@ impl Node { )) })?; - // Parse the MAC address - #[cfg(target_os = "linux")] let mac = crate::transport::ethernet::parse_mac_string(mac_str).map_err(|e| { NodeError::NoTransportForType(format!("invalid MAC in '{}': {}", addr_str, e)) })?; - #[cfg(not(target_os = "linux"))] - let mac: [u8; 6] = { - let _ = mac_str; - return Err(NodeError::NoTransportForType( - "Ethernet transport not available on this platform".into(), - )); - }; Ok((transport_id, TransportAddr::from_bytes(&mac))) } diff --git a/src/node/rate_limit.rs b/src/node/rate_limit.rs index 04325eb..72e3f25 100644 --- a/src/node/rate_limit.rs +++ b/src/node/rate_limit.rs @@ -272,12 +272,20 @@ mod tests { } assert!(!bucket.available()); - // Wait for refill - thread::sleep(Duration::from_millis(50)); // Should refill ~5 tokens + // Wait for refill, measuring actual elapsed time to avoid sensitivity + // to OS scheduler variance (sleep can overshoot by a large margin). + let before = Instant::now(); + thread::sleep(Duration::from_millis(50)); + let elapsed_secs = before.elapsed().as_secs_f64(); + + // Expected tokens = elapsed * rate, capped at capacity. + // Allow ±20% tolerance around the actual elapsed time. + let expected = (elapsed_secs * 100.0).min(10.0); + let lo = (expected * 0.8).min(expected - 0.5).max(0.0); + let hi = (expected * 1.2).max(expected + 0.5).min(10.0); - // Should have tokens now let tokens = bucket.tokens(); - assert!((4.0..=6.0).contains(&tokens), "tokens: {}", tokens); + assert!((lo..=hi).contains(&tokens), "tokens: {}, expected ~{:.2} (range {:.2}..={:.2})", tokens, expected, lo, hi); } #[test] diff --git a/src/node/tests/mod.rs b/src/node/tests/mod.rs index 20f8967..cd02a5e 100644 --- a/src/node/tests/mod.rs +++ b/src/node/tests/mod.rs @@ -9,7 +9,6 @@ mod bloom; mod ble; mod disconnect; mod discovery; -#[cfg(target_os = "linux")] mod ethernet; mod forwarding; mod handshake; diff --git a/src/transport/ethernet/mod.rs b/src/transport/ethernet/mod.rs index 59be62a..843af10 100644 --- a/src/transport/ethernet/mod.rs +++ b/src/transport/ethernet/mod.rs @@ -1,8 +1,9 @@ //! Ethernet Transport Implementation //! -//! Provides raw Ethernet transport for FIPS peer communication using -//! AF_PACKET sockets with SOCK_DGRAM. Works on wired Ethernet and WiFi -//! interfaces (kernel mac80211 abstracts 802.11 transparently). +//! Provides raw Ethernet transport for FIPS peer communication. On Linux, +//! uses AF_PACKET/SOCK_DGRAM sockets; on macOS, uses BPF devices (`/dev/bpf*`). +//! Works on wired Ethernet and WiFi interfaces (kernel mac80211 abstracts +//! 802.11 transparently on Linux). pub mod discovery; pub mod socket; @@ -240,16 +241,26 @@ impl EthernetTransport { return Err(TransportError::NotStarted); } - // Abort beacon task - if let Some(task) = self.beacon_task.take() { - task.abort(); - let _ = task.await; + // Signal the socket to shut down. On macOS this writes to the + // shutdown pipe, waking the reader thread's select() immediately. + // On Linux this is a no-op (AsyncFd cancellation handles it). + if let Some(ref socket) = self.socket { + socket.shutdown(); } - // Abort receive task + // Abort tasks. On Linux, safe to await since all I/O is + // AsyncFd-based and cancellation-safe. On macOS, do NOT await — + // on a current_thread runtime the aborted task can't be polled + // while we're blocked on the JoinHandle, causing a deadlock. + if let Some(task) = self.beacon_task.take() { + task.abort(); + #[cfg(not(target_os = "macos"))] + { let _ = task.await; } + } if let Some(task) = self.recv_task.take() { task.abort(); - let _ = task.await; + #[cfg(not(target_os = "macos"))] + { let _ = task.await; } } // Drop socket diff --git a/src/transport/ethernet/socket.rs b/src/transport/ethernet/socket.rs index ecc243a..e665d60 100644 --- a/src/transport/ethernet/socket.rs +++ b/src/transport/ethernet/socket.rs @@ -1,396 +1,267 @@ -//! AF_PACKET socket creation, binding, and ioctl helpers. +//! Raw Ethernet socket abstraction. +//! +//! Platform-specific implementations live in `socket_linux.rs` (AF_PACKET) +//! and `socket_macos.rs` (BPF). This module re-exports `PacketSocket` and +//! provides `AsyncPacketSocket`. use crate::transport::TransportError; -use std::os::unix::io::{AsRawFd, RawFd}; -use tokio::io::unix::AsyncFd; /// Broadcast MAC address. pub const ETHERNET_BROADCAST: [u8; 6] = [0xff; 6]; -/// Wrapper around an AF_PACKET SOCK_DGRAM file descriptor. -/// -/// Owns the fd and closes it on drop. Provides synchronous send/recv -/// methods used by the async wrappers via `AsyncFd`. -pub struct PacketSocket { - fd: RawFd, - if_index: i32, - ethertype: u16, +// Platform-specific PacketSocket implementation. +#[cfg(target_os = "linux")] +#[path = "socket_linux.rs"] +mod platform; + +#[cfg(target_os = "macos")] +#[path = "socket_macos.rs"] +mod platform; + +pub use platform::PacketSocket; + +// ============================================================================= +// Linux: AsyncFd-based async wrapper +// ============================================================================= + +#[cfg(target_os = "linux")] +mod async_impl { + use super::PacketSocket; + use crate::transport::TransportError; + use tokio::io::unix::AsyncFd; + + pub struct AsyncPacketSocket { + inner: AsyncFd, + } + + impl AsyncPacketSocket { + pub fn new(socket: PacketSocket) -> Result { + let async_fd = AsyncFd::new(socket) + .map_err(|e| TransportError::StartFailed(format!("AsyncFd::new failed: {}", e)))?; + Ok(Self { inner: async_fd }) + } + + pub async fn send_to(&self, data: &[u8], dest_mac: &[u8; 6]) -> Result { + loop { + let mut guard = self + .inner + .writable() + .await + .map_err(|e| TransportError::SendFailed(format!("writable wait: {}", e)))?; + + match guard.try_io(|inner| inner.get_ref().send_to(data, dest_mac)) { + Ok(Ok(n)) => return Ok(n), + Ok(Err(e)) => return Err(TransportError::SendFailed(format!("{}", e))), + Err(_would_block) => continue, + } + } + } + + pub async fn recv_from( + &self, + buf: &mut [u8], + ) -> Result<(usize, [u8; 6]), TransportError> { + loop { + let mut guard = self + .inner + .readable() + .await + .map_err(|e| TransportError::RecvFailed(format!("readable wait: {}", e)))?; + + match guard.try_io(|inner| inner.get_ref().recv_from(buf)) { + Ok(Ok(result)) => return Ok(result), + Ok(Err(e)) => return Err(TransportError::RecvFailed(format!("{}", e))), + Err(_would_block) => continue, + } + } + } + + pub fn get_ref(&self) -> &PacketSocket { + self.inner.get_ref() + } + + /// Shut down the socket, unblocking any pending recv. + /// + /// On Linux this is a no-op — aborting the tokio task suffices + /// since AsyncFd is cancellation-aware. + pub fn shutdown(&self) {} + } } +// ============================================================================= +// macOS: dedicated reader thread with async channel +// +// BPF fds don't support kqueue, so we can't use AsyncFd. Instead of +// spawn_blocking per packet (which was the bottleneck causing 84 Mbps), +// we spawn a single dedicated reader thread that loops on blocking +// read() and feeds frames through a tokio mpsc channel. +// ============================================================================= + +#[cfg(target_os = "macos")] +mod async_impl { + use super::PacketSocket; + use crate::transport::TransportError; + use std::os::unix::io::AsRawFd; + use std::sync::Arc; + + /// A received frame: (payload, source_mac). + type Frame = (Vec, [u8; 6]); + + pub struct AsyncPacketSocket { + inner: Arc, + rx: tokio::sync::Mutex>, + reader_thread: Option>, + } + + impl AsyncPacketSocket { + pub fn new(socket: PacketSocket) -> Result { + // Channel capacity: buffer up to 1024 frames to decouple + // the blocking reader from the async consumer. + let (tx, rx) = tokio::sync::mpsc::channel::(1024); + let inner = Arc::new(socket); + let reader_socket = Arc::clone(&inner); + + let reader_thread = std::thread::Builder::new() + .name("bpf-reader".into()) + .spawn(move || { + let bpf_fd = reader_socket.as_raw_fd(); + let shutdown_fd = reader_socket.shutdown_read_fd(); + let bpf_buflen = reader_socket.bpf_buflen(); + let mut read_buf = vec![0u8; bpf_buflen]; + let mut parse_buf = vec![0u8; bpf_buflen]; + let mut parse_offset: usize = 0; + let mut parse_len: usize = 0; + let nfds = bpf_fd.max(shutdown_fd) + 1; + + loop { + // Drain any buffered frames from the previous read + while let Some(result) = super::platform::parse_next_frame( + &parse_buf, &mut parse_offset, parse_len, &mut read_buf, + ) { + match result { + Ok((n, mac)) => { + let data = read_buf[..n].to_vec(); + if tx.blocking_send((data, mac)).is_err() { + return; + } + } + Err(_) => break, + } + } + + // Wait for BPF data or shutdown signal via select() + unsafe { + let mut read_fds: libc::fd_set = std::mem::zeroed(); + libc::FD_ZERO(&mut read_fds); + libc::FD_SET(bpf_fd, &mut read_fds); + libc::FD_SET(shutdown_fd, &mut read_fds); + + let ret = libc::select( + nfds, + &mut read_fds, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ); + if ret < 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::Interrupted { + continue; + } + break; + } + if libc::FD_ISSET(shutdown_fd, &read_fds) { + break; // shutdown signal + } + } + + // BPF fd is readable + let ret = unsafe { + libc::read( + bpf_fd, + parse_buf.as_mut_ptr() as *mut libc::c_void, + bpf_buflen, + ) + }; + if ret <= 0 { + if ret < 0 { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EBADF) { + break; + } + } + parse_len = 0; + parse_offset = 0; + continue; + } + parse_len = ret as usize; + parse_offset = 0; + } + }) + .map_err(|e| TransportError::StartFailed(format!("reader thread: {}", e)))?; + + Ok(Self { + inner, + rx: tokio::sync::Mutex::new(rx), + reader_thread: Some(reader_thread), + }) + } + + pub async fn send_to(&self, data: &[u8], dest_mac: &[u8; 6]) -> Result { + let socket = Arc::clone(&self.inner); + let data = data.to_vec(); + let dest = *dest_mac; + tokio::task::spawn_blocking(move || { + socket.send_to(&data, &dest) + .map_err(|e| TransportError::SendFailed(format!("{}", e))) + }) + .await + .map_err(|e| TransportError::SendFailed(format!("spawn_blocking: {}", e)))? + } + + pub async fn recv_from( + &self, + buf: &mut [u8], + ) -> Result<(usize, [u8; 6]), TransportError> { + let mut rx = self.rx.lock().await; + match rx.recv().await { + Some((data, mac)) => { + let n = data.len().min(buf.len()); + buf[..n].copy_from_slice(&data[..n]); + Ok((n, mac)) + } + None => Err(TransportError::RecvFailed("reader thread stopped".into())), + } + } + + pub fn get_ref(&self) -> &PacketSocket { + &self.inner + } + + /// Signal the reader thread to stop. + /// + /// Sets the shutdown flag; the reader thread checks it after + /// each BPF read timeout (~250ms) and exits. + pub fn shutdown(&self) { + self.inner.request_shutdown(); + } + } + + impl Drop for AsyncPacketSocket { + fn drop(&mut self) { + self.inner.request_shutdown(); + if let Some(handle) = self.reader_thread.take() { + let _ = handle.join(); + } + } + } +} + +pub use async_impl::AsyncPacketSocket; + impl PacketSocket { - /// Create and bind an AF_PACKET SOCK_DGRAM socket. - /// - /// Returns an error with a clear message if CAP_NET_RAW is missing. - pub fn open(interface: &str, ethertype: u16) -> Result { - let fd = unsafe { - libc::socket( - libc::AF_PACKET, - libc::SOCK_DGRAM, - (ethertype).to_be() as i32, - ) - }; - if fd < 0 { - let err = std::io::Error::last_os_error(); - if err.raw_os_error() == Some(libc::EPERM) { - return Err(TransportError::StartFailed( - "AF_PACKET requires CAP_NET_RAW capability \ - (run as root or use: setcap cap_net_raw=ep )" - .into(), - )); - } - return Err(TransportError::StartFailed(format!( - "socket(AF_PACKET) failed: {}", - err - ))); - } - - // Look up interface index - let if_index = get_if_index(fd, interface)?; - - // Bind to the interface - let mut sll: libc::sockaddr_ll = unsafe { std::mem::zeroed() }; - sll.sll_family = libc::AF_PACKET as u16; - sll.sll_protocol = ethertype.to_be(); - sll.sll_ifindex = if_index; - - let ret = unsafe { - libc::bind( - fd, - &sll as *const libc::sockaddr_ll as *const libc::sockaddr, - std::mem::size_of::() as libc::socklen_t, - ) - }; - if ret < 0 { - let err = std::io::Error::last_os_error(); - unsafe { libc::close(fd) }; - return Err(TransportError::StartFailed(format!( - "bind(AF_PACKET, {}) failed: {}", - interface, err - ))); - } - - // Set non-blocking for async integration - let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; - if flags < 0 { - let err = std::io::Error::last_os_error(); - unsafe { libc::close(fd) }; - return Err(TransportError::StartFailed(format!( - "fcntl(F_GETFL) failed: {}", - err - ))); - } - let ret = unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) }; - if ret < 0 { - let err = std::io::Error::last_os_error(); - unsafe { libc::close(fd) }; - return Err(TransportError::StartFailed(format!( - "fcntl(F_SETFL, O_NONBLOCK) failed: {}", - err - ))); - } - - Ok(Self { - fd, - if_index, - ethertype, - }) - } - - /// Get the interface index. - pub fn if_index(&self) -> i32 { - self.if_index - } - - /// Get the local MAC address of the bound interface. - pub fn local_mac(&self) -> Result<[u8; 6], TransportError> { - get_mac_addr(self.fd, self.if_index) - } - - /// Get the interface MTU. - pub fn interface_mtu(&self) -> Result { - get_if_mtu(self.fd, self.if_index) - } - - /// Set the socket receive buffer size. - pub fn set_recv_buffer_size(&self, size: usize) -> Result<(), TransportError> { - let size = size as libc::c_int; - let ret = unsafe { - libc::setsockopt( - self.fd, - libc::SOL_SOCKET, - libc::SO_RCVBUF, - &size as *const libc::c_int as *const libc::c_void, - std::mem::size_of::() as libc::socklen_t, - ) - }; - if ret < 0 { - return Err(TransportError::StartFailed(format!( - "setsockopt(SO_RCVBUF) failed: {}", - std::io::Error::last_os_error() - ))); - } - Ok(()) - } - - /// Set the socket send buffer size. - pub fn set_send_buffer_size(&self, size: usize) -> Result<(), TransportError> { - let size = size as libc::c_int; - let ret = unsafe { - libc::setsockopt( - self.fd, - libc::SOL_SOCKET, - libc::SO_SNDBUF, - &size as *const libc::c_int as *const libc::c_void, - std::mem::size_of::() as libc::socklen_t, - ) - }; - if ret < 0 { - return Err(TransportError::StartFailed(format!( - "setsockopt(SO_SNDBUF) failed: {}", - std::io::Error::last_os_error() - ))); - } - Ok(()) - } - - /// Send a payload to a destination MAC address. - /// - /// Returns the number of bytes sent, or an io::Error. - pub fn send_to(&self, data: &[u8], dest_mac: &[u8; 6]) -> std::io::Result { - let mut sll: libc::sockaddr_ll = unsafe { std::mem::zeroed() }; - sll.sll_family = libc::AF_PACKET as u16; - sll.sll_protocol = self.ethertype.to_be(); - sll.sll_ifindex = self.if_index; - sll.sll_halen = 6; - sll.sll_addr[..6].copy_from_slice(dest_mac); - - let ret = unsafe { - libc::sendto( - self.fd, - data.as_ptr() as *const libc::c_void, - data.len(), - 0, - &sll as *const libc::sockaddr_ll as *const libc::sockaddr, - std::mem::size_of::() as libc::socklen_t, - ) - }; - if ret < 0 { - Err(std::io::Error::last_os_error()) - } else { - Ok(ret as usize) - } - } - - /// Receive a payload and source MAC address. - /// - /// Returns (bytes_read, source_mac), or an io::Error. - pub fn recv_from(&self, buf: &mut [u8]) -> std::io::Result<(usize, [u8; 6])> { - let mut sll: libc::sockaddr_ll = unsafe { std::mem::zeroed() }; - let mut sll_len = std::mem::size_of::() as libc::socklen_t; - - let ret = unsafe { - libc::recvfrom( - self.fd, - buf.as_mut_ptr() as *mut libc::c_void, - buf.len(), - 0, - &mut sll as *mut libc::sockaddr_ll as *mut libc::sockaddr, - &mut sll_len, - ) - }; - if ret < 0 { - return Err(std::io::Error::last_os_error()); - } - - let mut src_mac = [0u8; 6]; - src_mac.copy_from_slice(&sll.sll_addr[..6]); - - Ok((ret as usize, src_mac)) - } - - /// Wrap this socket in a tokio AsyncFd for async I/O. + /// Wrap this socket in an async wrapper for tokio integration. pub fn into_async(self) -> Result { - let async_fd = AsyncFd::new(self) - .map_err(|e| TransportError::StartFailed(format!("AsyncFd::new failed: {}", e)))?; - Ok(AsyncPacketSocket { inner: async_fd }) + AsyncPacketSocket::new(self) } } - -impl AsRawFd for PacketSocket { - fn as_raw_fd(&self) -> RawFd { - self.fd - } -} - -impl Drop for PacketSocket { - fn drop(&mut self) { - unsafe { - libc::close(self.fd); - } - } -} - -/// Async wrapper around PacketSocket using tokio's AsyncFd. -pub struct AsyncPacketSocket { - inner: AsyncFd, -} - -impl AsyncPacketSocket { - /// Send a payload to a destination MAC address. - pub async fn send_to(&self, data: &[u8], dest_mac: &[u8; 6]) -> Result { - loop { - let mut guard = self - .inner - .writable() - .await - .map_err(|e| TransportError::SendFailed(format!("writable wait: {}", e)))?; - - match guard.try_io(|inner| inner.get_ref().send_to(data, dest_mac)) { - Ok(Ok(n)) => return Ok(n), - Ok(Err(e)) => return Err(TransportError::SendFailed(format!("{}", e))), - Err(_would_block) => continue, - } - } - } - - /// Receive a payload and source MAC address. - pub async fn recv_from( - &self, - buf: &mut [u8], - ) -> Result<(usize, [u8; 6]), TransportError> { - loop { - let mut guard = self - .inner - .readable() - .await - .map_err(|e| TransportError::RecvFailed(format!("readable wait: {}", e)))?; - - match guard.try_io(|inner| inner.get_ref().recv_from(buf)) { - Ok(Ok(result)) => return Ok(result), - Ok(Err(e)) => return Err(TransportError::RecvFailed(format!("{}", e))), - Err(_would_block) => continue, - } - } - } - - /// Get a reference to the inner PacketSocket. - pub fn get_ref(&self) -> &PacketSocket { - self.inner.get_ref() - } -} - -// ============================================================================ -// ioctl helpers -// ============================================================================ - -/// Get the interface index by name. -fn get_if_index(_fd: RawFd, interface: &str) -> Result { - let c_name = std::ffi::CString::new(interface).map_err(|_| { - TransportError::StartFailed(format!("invalid interface name: {}", interface)) - })?; - - let idx = unsafe { libc::if_nametoindex(c_name.as_ptr()) }; - if idx == 0 { - return Err(TransportError::StartFailed(format!( - "interface not found: {} ({})", - interface, - std::io::Error::last_os_error() - ))); - } - Ok(idx as i32) -} - -/// Get the MAC address of an interface by its index. -fn get_mac_addr(fd: RawFd, if_index: i32) -> Result<[u8; 6], TransportError> { - // First get the interface name from the index - let mut ifr: libc::ifreq = unsafe { std::mem::zeroed() }; - - // Use if_indextoname to get the name - let mut name_buf = [0u8; libc::IFNAMSIZ]; - let ret = unsafe { - libc::if_indextoname(if_index as libc::c_uint, name_buf.as_mut_ptr() as *mut libc::c_char) - }; - if ret.is_null() { - return Err(TransportError::StartFailed(format!( - "if_indextoname({}) failed: {}", - if_index, - std::io::Error::last_os_error() - ))); - } - - // Copy name into ifreq - let name_len = name_buf.iter().position(|&b| b == 0).unwrap_or(name_buf.len()); - let copy_len = name_len.min(libc::IFNAMSIZ - 1); - unsafe { - std::ptr::copy_nonoverlapping( - name_buf.as_ptr(), - ifr.ifr_name.as_mut_ptr() as *mut u8, - copy_len, - ); - } - - #[cfg(target_env = "musl")] - let ioctl_req = libc::SIOCGIFHWADDR as libc::c_int; - #[cfg(not(target_env = "musl"))] - let ioctl_req = libc::SIOCGIFHWADDR as libc::c_ulong; - let ret = unsafe { libc::ioctl(fd, ioctl_req, &ifr) }; - if ret < 0 { - return Err(TransportError::StartFailed(format!( - "ioctl(SIOCGIFHWADDR) failed: {}", - std::io::Error::last_os_error() - ))); - } - - let mut mac = [0u8; 6]; - unsafe { - let sa_data = ifr.ifr_ifru.ifru_hwaddr.sa_data; - for (i, byte) in mac.iter_mut().enumerate() { - *byte = sa_data[i] as u8; - } - } - - Ok(mac) -} - -/// Get the MTU of an interface by its index. -fn get_if_mtu(fd: RawFd, if_index: i32) -> Result { - let mut ifr: libc::ifreq = unsafe { std::mem::zeroed() }; - - // Get the interface name from index - let mut name_buf = [0u8; libc::IFNAMSIZ]; - let ret = unsafe { - libc::if_indextoname(if_index as libc::c_uint, name_buf.as_mut_ptr() as *mut libc::c_char) - }; - if ret.is_null() { - return Err(TransportError::StartFailed(format!( - "if_indextoname({}) failed: {}", - if_index, - std::io::Error::last_os_error() - ))); - } - - let name_len = name_buf.iter().position(|&b| b == 0).unwrap_or(name_buf.len()); - let copy_len = name_len.min(libc::IFNAMSIZ - 1); - unsafe { - std::ptr::copy_nonoverlapping( - name_buf.as_ptr(), - ifr.ifr_name.as_mut_ptr() as *mut u8, - copy_len, - ); - } - - #[cfg(target_env = "musl")] - let ioctl_req = libc::SIOCGIFMTU as libc::c_int; - #[cfg(not(target_env = "musl"))] - let ioctl_req = libc::SIOCGIFMTU as libc::c_ulong; - let ret = unsafe { libc::ioctl(fd, ioctl_req, &ifr) }; - if ret < 0 { - return Err(TransportError::StartFailed(format!( - "ioctl(SIOCGIFMTU) failed: {}", - std::io::Error::last_os_error() - ))); - } - - let mtu = unsafe { ifr.ifr_ifru.ifru_mtu } as u16; - Ok(mtu) -} diff --git a/src/transport/ethernet/socket_linux.rs b/src/transport/ethernet/socket_linux.rs new file mode 100644 index 0000000..3aa5e71 --- /dev/null +++ b/src/transport/ethernet/socket_linux.rs @@ -0,0 +1,336 @@ +//! AF_PACKET socket creation, binding, and ioctl helpers (Linux). + +use crate::transport::TransportError; +use std::os::unix::io::{AsRawFd, RawFd}; + +/// Wrapper around an AF_PACKET SOCK_DGRAM file descriptor. +/// +/// Owns the fd and closes it on drop. Provides synchronous send/recv +/// methods used by the async wrappers via `AsyncFd`. +pub struct PacketSocket { + fd: RawFd, + if_index: i32, + ethertype: u16, +} + +impl PacketSocket { + /// Create and bind an AF_PACKET SOCK_DGRAM socket. + /// + /// Returns an error with a clear message if CAP_NET_RAW is missing. + pub fn open(interface: &str, ethertype: u16) -> Result { + let fd = unsafe { + libc::socket( + libc::AF_PACKET, + libc::SOCK_DGRAM, + (ethertype).to_be() as i32, + ) + }; + if fd < 0 { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EPERM) { + return Err(TransportError::StartFailed( + "AF_PACKET requires CAP_NET_RAW capability \ + (run as root or use: setcap cap_net_raw=ep )" + .into(), + )); + } + return Err(TransportError::StartFailed(format!( + "socket(AF_PACKET) failed: {}", + err + ))); + } + + // Look up interface index + let if_index = get_if_index(fd, interface)?; + + // Bind to the interface + let mut sll: libc::sockaddr_ll = unsafe { std::mem::zeroed() }; + sll.sll_family = libc::AF_PACKET as u16; + sll.sll_protocol = ethertype.to_be(); + sll.sll_ifindex = if_index; + + let ret = unsafe { + libc::bind( + fd, + &sll as *const libc::sockaddr_ll as *const libc::sockaddr, + std::mem::size_of::() as libc::socklen_t, + ) + }; + if ret < 0 { + let err = std::io::Error::last_os_error(); + unsafe { libc::close(fd) }; + return Err(TransportError::StartFailed(format!( + "bind(AF_PACKET, {}) failed: {}", + interface, err + ))); + } + + // Set non-blocking for async integration + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + if flags < 0 { + let err = std::io::Error::last_os_error(); + unsafe { libc::close(fd) }; + return Err(TransportError::StartFailed(format!( + "fcntl(F_GETFL) failed: {}", + err + ))); + } + let ret = unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) }; + if ret < 0 { + let err = std::io::Error::last_os_error(); + unsafe { libc::close(fd) }; + return Err(TransportError::StartFailed(format!( + "fcntl(F_SETFL, O_NONBLOCK) failed: {}", + err + ))); + } + + Ok(Self { + fd, + if_index, + ethertype, + }) + } + + /// Get the interface index. + pub fn if_index(&self) -> i32 { + self.if_index + } + + /// Get the local MAC address of the bound interface. + pub fn local_mac(&self) -> Result<[u8; 6], TransportError> { + get_mac_addr(self.fd, self.if_index) + } + + /// Get the interface MTU. + pub fn interface_mtu(&self) -> Result { + get_if_mtu(self.fd, self.if_index) + } + + /// Set the socket receive buffer size. + pub fn set_recv_buffer_size(&self, size: usize) -> Result<(), TransportError> { + let size = size as libc::c_int; + let ret = unsafe { + libc::setsockopt( + self.fd, + libc::SOL_SOCKET, + libc::SO_RCVBUF, + &size as *const libc::c_int as *const libc::c_void, + std::mem::size_of::() as libc::socklen_t, + ) + }; + if ret < 0 { + return Err(TransportError::StartFailed(format!( + "setsockopt(SO_RCVBUF) failed: {}", + std::io::Error::last_os_error() + ))); + } + Ok(()) + } + + /// Set the socket send buffer size. + pub fn set_send_buffer_size(&self, size: usize) -> Result<(), TransportError> { + let size = size as libc::c_int; + let ret = unsafe { + libc::setsockopt( + self.fd, + libc::SOL_SOCKET, + libc::SO_SNDBUF, + &size as *const libc::c_int as *const libc::c_void, + std::mem::size_of::() as libc::socklen_t, + ) + }; + if ret < 0 { + return Err(TransportError::StartFailed(format!( + "setsockopt(SO_SNDBUF) failed: {}", + std::io::Error::last_os_error() + ))); + } + Ok(()) + } + + /// Send a payload to a destination MAC address. + /// + /// Returns the number of bytes sent, or an io::Error. + pub fn send_to(&self, data: &[u8], dest_mac: &[u8; 6]) -> std::io::Result { + let mut sll: libc::sockaddr_ll = unsafe { std::mem::zeroed() }; + sll.sll_family = libc::AF_PACKET as u16; + sll.sll_protocol = self.ethertype.to_be(); + sll.sll_ifindex = self.if_index; + sll.sll_halen = 6; + sll.sll_addr[..6].copy_from_slice(dest_mac); + + let ret = unsafe { + libc::sendto( + self.fd, + data.as_ptr() as *const libc::c_void, + data.len(), + 0, + &sll as *const libc::sockaddr_ll as *const libc::sockaddr, + std::mem::size_of::() as libc::socklen_t, + ) + }; + if ret < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(ret as usize) + } + } + + /// Receive a payload and source MAC address. + /// + /// Returns (bytes_read, source_mac), or an io::Error. + pub fn recv_from(&self, buf: &mut [u8]) -> std::io::Result<(usize, [u8; 6])> { + let mut sll: libc::sockaddr_ll = unsafe { std::mem::zeroed() }; + let mut sll_len = std::mem::size_of::() as libc::socklen_t; + + let ret = unsafe { + libc::recvfrom( + self.fd, + buf.as_mut_ptr() as *mut libc::c_void, + buf.len(), + 0, + &mut sll as *mut libc::sockaddr_ll as *mut libc::sockaddr, + &mut sll_len, + ) + }; + if ret < 0 { + return Err(std::io::Error::last_os_error()); + } + + let mut src_mac = [0u8; 6]; + src_mac.copy_from_slice(&sll.sll_addr[..6]); + + Ok((ret as usize, src_mac)) + } +} + +impl AsRawFd for PacketSocket { + fn as_raw_fd(&self) -> RawFd { + self.fd + } +} + +impl Drop for PacketSocket { + fn drop(&mut self) { + unsafe { + libc::close(self.fd); + } + } +} + +// ============================================================================ +// ioctl helpers +// ============================================================================ + +/// Get the interface index by name. +fn get_if_index(_fd: RawFd, interface: &str) -> Result { + let c_name = std::ffi::CString::new(interface).map_err(|_| { + TransportError::StartFailed(format!("invalid interface name: {}", interface)) + })?; + + let idx = unsafe { libc::if_nametoindex(c_name.as_ptr()) }; + if idx == 0 { + return Err(TransportError::StartFailed(format!( + "interface not found: {} ({})", + interface, + std::io::Error::last_os_error() + ))); + } + Ok(idx as i32) +} + +/// Get the MAC address of an interface by its index. +fn get_mac_addr(fd: RawFd, if_index: i32) -> Result<[u8; 6], TransportError> { + // First get the interface name from the index + let mut ifr: libc::ifreq = unsafe { std::mem::zeroed() }; + + // Use if_indextoname to get the name + let mut name_buf = [0u8; libc::IFNAMSIZ]; + let ret = unsafe { + libc::if_indextoname(if_index as libc::c_uint, name_buf.as_mut_ptr() as *mut libc::c_char) + }; + if ret.is_null() { + return Err(TransportError::StartFailed(format!( + "if_indextoname({}) failed: {}", + if_index, + std::io::Error::last_os_error() + ))); + } + + // Copy name into ifreq + let name_len = name_buf.iter().position(|&b| b == 0).unwrap_or(name_buf.len()); + let copy_len = name_len.min(libc::IFNAMSIZ - 1); + unsafe { + std::ptr::copy_nonoverlapping( + name_buf.as_ptr(), + ifr.ifr_name.as_mut_ptr() as *mut u8, + copy_len, + ); + } + + #[cfg(target_env = "musl")] + let ioctl_req = libc::SIOCGIFHWADDR as libc::c_int; + #[cfg(not(target_env = "musl"))] + let ioctl_req = libc::SIOCGIFHWADDR as libc::c_ulong; + let ret = unsafe { libc::ioctl(fd, ioctl_req, &ifr) }; + if ret < 0 { + return Err(TransportError::StartFailed(format!( + "ioctl(SIOCGIFHWADDR) failed: {}", + std::io::Error::last_os_error() + ))); + } + + let mut mac = [0u8; 6]; + unsafe { + let sa_data = ifr.ifr_ifru.ifru_hwaddr.sa_data; + for (i, byte) in mac.iter_mut().enumerate() { + *byte = sa_data[i] as u8; + } + } + + Ok(mac) +} + +/// Get the MTU of an interface by its index. +fn get_if_mtu(fd: RawFd, if_index: i32) -> Result { + let mut ifr: libc::ifreq = unsafe { std::mem::zeroed() }; + + // Get the interface name from index + let mut name_buf = [0u8; libc::IFNAMSIZ]; + let ret = unsafe { + libc::if_indextoname(if_index as libc::c_uint, name_buf.as_mut_ptr() as *mut libc::c_char) + }; + if ret.is_null() { + return Err(TransportError::StartFailed(format!( + "if_indextoname({}) failed: {}", + if_index, + std::io::Error::last_os_error() + ))); + } + + let name_len = name_buf.iter().position(|&b| b == 0).unwrap_or(name_buf.len()); + let copy_len = name_len.min(libc::IFNAMSIZ - 1); + unsafe { + std::ptr::copy_nonoverlapping( + name_buf.as_ptr(), + ifr.ifr_name.as_mut_ptr() as *mut u8, + copy_len, + ); + } + + #[cfg(target_env = "musl")] + let ioctl_req = libc::SIOCGIFMTU as libc::c_int; + #[cfg(not(target_env = "musl"))] + let ioctl_req = libc::SIOCGIFMTU as libc::c_ulong; + let ret = unsafe { libc::ioctl(fd, ioctl_req, &ifr) }; + if ret < 0 { + return Err(TransportError::StartFailed(format!( + "ioctl(SIOCGIFMTU) failed: {}", + std::io::Error::last_os_error() + ))); + } + + let mtu = unsafe { ifr.ifr_ifru.ifru_mtu } as u16; + Ok(mtu) +} diff --git a/src/transport/ethernet/socket_macos.rs b/src/transport/ethernet/socket_macos.rs new file mode 100644 index 0000000..ec428ef --- /dev/null +++ b/src/transport/ethernet/socket_macos.rs @@ -0,0 +1,823 @@ +//! BPF-based raw Ethernet socket for macOS. +//! +//! Provides the same `PacketSocket` API as the Linux AF_PACKET implementation, +//! using Berkeley Packet Filter (BPF) devices (`/dev/bpf*`). +//! +//! Key differences from Linux AF_PACKET SOCK_DGRAM: +//! - BPF operates on raw frames including the 14-byte Ethernet header. +//! `send_to()` prepends it; `recv_from()` strips it. +//! - BPF reads may return multiple frames in one buffer (chained `bpf_hdr`). +//! - MAC address is obtained via `getifaddrs()` with `AF_LINK`. + +use crate::transport::TransportError; +use std::os::unix::io::{AsRawFd, RawFd}; +use std::sync::Mutex; + +/// Ethernet header size: dst(6) + src(6) + ethertype(2). +const ETH_HDRLEN: usize = 14; + +/// macOS SIOCGIFMTU ioctl constant. +const SIOCGIFMTU: libc::c_ulong = 0xC0206933; + +/// BPF internal read state (behind Mutex for interior mutability, +/// since the async recv path needs `&self` access). +struct BpfReadState { + buf: Vec, + offset: usize, + len: usize, +} + +/// RAII guard that closes a raw fd on drop, used during socket setup +/// to prevent fd leaks if an intermediate step fails. +struct FdGuard(RawFd); + +impl FdGuard { + /// Disarm the guard, returning the fd without closing it. + fn into_raw(self) -> RawFd { + let fd = self.0; + std::mem::forget(self); + fd + } +} + +impl Drop for FdGuard { + fn drop(&mut self) { + unsafe { libc::close(self.0); } + } +} + +/// Wrapper around a BPF file descriptor. +/// +/// Owns the fd and closes it on drop. Provides synchronous send/recv +/// methods matching the Linux `PacketSocket` API. +pub struct PacketSocket { + fd: RawFd, + if_index: i32, + ethertype: u16, + local_mac: [u8; 6], + bpf_buflen: usize, + read_state: Mutex, + /// Write end of the shutdown pipe. Writing a byte wakes the reader + /// thread's `select()` loop so it can exit promptly. + shutdown_write_fd: RawFd, + /// Read end of the shutdown pipe (passed to the reader thread). + shutdown_read_fd: RawFd, +} + +impl PacketSocket { + /// Open a BPF device and bind it to the named interface. + pub fn open(interface: &str, ethertype: u16) -> Result { + // Open the first available /dev/bpf device + let guard = FdGuard(open_bpf_device()?); + let fd = guard.0; + + // Look up interface index (for API compat; BPF binds by name) + let if_index = get_if_index(interface)?; + + // Bind to the interface + bind_to_interface(fd, interface)?; + + // Set immediate mode (deliver packets as they arrive) + set_bpf_immediate(fd)?; + + // We supply complete Ethernet headers on writes + set_bpf_hdrcmplt(fd)?; + + // Install a BPF filter to only capture our EtherType + install_ethertype_filter(fd, ethertype)?; + + // Get the BPF read buffer length + let bpf_buflen = get_bpf_buflen(fd)?; + + // Get local MAC address + let local_mac = get_mac_addr(interface)?; + + // Create shutdown pipe — the reader thread select()s on both + // the BPF fd and this pipe. Writing a byte signals shutdown. + let mut pipe_fds = [0i32; 2]; + if unsafe { libc::pipe(pipe_fds.as_mut_ptr()) } < 0 { + unsafe { libc::close(guard.0) }; + std::mem::forget(guard); // don't double-close via FdGuard + return Err(TransportError::StartFailed(format!( + "pipe() failed: {}", std::io::Error::last_os_error() + ))); + } + + // All setup succeeded — disarm the guard so fd isn't closed + guard.into_raw(); + + Ok(Self { + fd, + if_index, + ethertype, + local_mac, + bpf_buflen, + read_state: Mutex::new(BpfReadState { + buf: vec![0u8; bpf_buflen], + offset: 0, + len: 0, + }), + shutdown_read_fd: pipe_fds[0], + shutdown_write_fd: pipe_fds[1], + }) + } + + /// Get the interface index. + pub fn if_index(&self) -> i32 { + self.if_index + } + + /// Get the local MAC address of the bound interface. + pub fn local_mac(&self) -> Result<[u8; 6], TransportError> { + Ok(self.local_mac) + } + + /// Get the interface MTU. + pub fn interface_mtu(&self) -> Result { + get_if_mtu(self.if_index) + } + + /// Set the socket receive buffer size. + /// + /// BPF devices use a fixed kernel buffer; silently ignored. + pub fn set_recv_buffer_size(&self, _size: usize) -> Result<(), TransportError> { + Ok(()) + } + + /// Get the BPF read buffer length. + pub fn bpf_buflen(&self) -> usize { + self.bpf_buflen + } + + /// Get the read end of the shutdown pipe (for the reader thread). + pub fn shutdown_read_fd(&self) -> RawFd { + self.shutdown_read_fd + } + + /// Signal the reader thread to stop by writing to the shutdown pipe. + pub fn request_shutdown(&self) { + unsafe { + libc::write(self.shutdown_write_fd, b"x".as_ptr() as *const libc::c_void, 1); + } + } + + /// Set the socket send buffer size. + /// + /// BPF devices use a fixed kernel buffer; silently ignored. + pub fn set_send_buffer_size(&self, _size: usize) -> Result<(), TransportError> { + Ok(()) + } + + /// Send a payload to a destination MAC address. + /// + /// Prepends a 14-byte Ethernet header (dst + src + ethertype) using + /// `writev` for zero-copy scatter-gather. Returns the payload bytes + /// sent (excluding the Ethernet header). + pub fn send_to(&self, data: &[u8], dest_mac: &[u8; 6]) -> std::io::Result { + // Build the 14-byte Ethernet header on the stack + let mut hdr = [0u8; ETH_HDRLEN]; + hdr[..6].copy_from_slice(dest_mac); + hdr[6..12].copy_from_slice(&self.local_mac); + hdr[12..14].copy_from_slice(&self.ethertype.to_be_bytes()); + + let iov = [ + libc::iovec { + iov_base: hdr.as_ptr() as *mut libc::c_void, + iov_len: ETH_HDRLEN, + }, + libc::iovec { + iov_base: data.as_ptr() as *mut libc::c_void, + iov_len: data.len(), + }, + ]; + + let ret = unsafe { libc::writev(self.fd, iov.as_ptr(), 2) }; + if ret < 0 { + Err(std::io::Error::last_os_error()) + } else { + // Return payload bytes (subtract Ethernet header) + let sent = (ret as usize).saturating_sub(ETH_HDRLEN); + Ok(sent) + } + } + + /// Receive a payload and source MAC address. + /// + /// BPF reads return raw frames with `bpf_hdr` prefixes. This method + /// parses the next frame from the internal buffer, stripping the + /// Ethernet header. Returns `(payload_bytes, source_mac)`. + pub fn recv_from(&self, buf: &mut [u8]) -> std::io::Result<(usize, [u8; 6])> { + let mut state = self.read_state.lock().unwrap(); + let state = &mut *state; + loop { + // Try to parse the next frame from the read buffer + if let Some(result) = parse_next_frame(&state.buf, &mut state.offset, state.len, buf) { + return result; + } + + // Buffer exhausted — read more from BPF + let ret = unsafe { + libc::read( + self.fd, + state.buf.as_mut_ptr() as *mut libc::c_void, + self.bpf_buflen, + ) + }; + if ret < 0 { + return Err(std::io::Error::last_os_error()); + } + state.len = ret as usize; + state.offset = 0; + } + } +} + +/// Parse the next BPF frame from the read buffer. +/// +/// Returns `None` if the buffer is exhausted and needs a new `read()`. +pub fn parse_next_frame( + read_buf: &[u8], + read_offset: &mut usize, + read_len: usize, + out_buf: &mut [u8], +) -> Option> { + const BPF_HDR_SIZE: usize = std::mem::size_of::(); + + if *read_offset >= read_len { + return None; + } + + let remaining = read_len - *read_offset; + if remaining < BPF_HDR_SIZE { + *read_offset = read_len; + return None; + } + + // Read the bpf_hdr + let hdr_ptr = read_buf[*read_offset..].as_ptr() as *const BpfHeader; + let hdr = unsafe { std::ptr::read_unaligned(hdr_ptr) }; + let cap_len = hdr.bh_caplen as usize; + let data_offset = hdr.bh_hdrlen as usize; + + // Advance past this frame (BPF_WORDALIGN) + let total_len = bpf_wordalign(data_offset + cap_len); + let frame_start = *read_offset + data_offset; + *read_offset += total_len; + + // Validate we have enough captured data for an Ethernet header + if cap_len < ETH_HDRLEN { + return None; // Runt frame, skip + } + + if frame_start + cap_len > read_len { + return None; // Truncated, skip + } + + let frame = &read_buf[frame_start..frame_start + cap_len]; + + // Extract source MAC from Ethernet header bytes [6..12] + let mut src_mac = [0u8; 6]; + src_mac.copy_from_slice(&frame[6..12]); + + // Copy payload (after 14-byte Ethernet header) into caller's buffer + let payload = &frame[ETH_HDRLEN..]; + let copy_len = payload.len().min(out_buf.len()); + out_buf[..copy_len].copy_from_slice(&payload[..copy_len]); + + Some(Ok((copy_len, src_mac))) +} + +impl AsRawFd for PacketSocket { + fn as_raw_fd(&self) -> RawFd { + self.fd + } +} + +impl Drop for PacketSocket { + fn drop(&mut self) { + unsafe { + libc::close(self.fd); + libc::close(self.shutdown_read_fd); + libc::close(self.shutdown_write_fd); + } + } +} + +// ============================================================================ +// BPF helpers +// ============================================================================ + +/// BPF header (matches `struct bpf_hdr` from ``). +/// +/// On macOS, `bpf_hdr` uses `struct BPF_TIMEVAL` which is `struct timeval32` +/// (two `u32` fields) regardless of architecture. Total size: 20 bytes +/// (4+4+4+4+2 = 18, padded to 20 for alignment). +#[repr(C)] +#[derive(Clone, Copy)] +struct BpfHeader { + bh_tstamp_sec: u32, + bh_tstamp_usec: u32, + bh_caplen: u32, + bh_datalen: u32, + bh_hdrlen: u16, + _pad: u16, +} + +// Compile-time check that our struct matches the kernel's bpf_hdr (20 bytes). +const _: () = assert!(std::mem::size_of::() == 20); + +/// BPF word alignment (round up to next 4-byte boundary). +fn bpf_wordalign(x: usize) -> usize { + (x + 3) & !3 +} + +/// BPF ioctl constants (from ). +const BIOCSETIF: libc::c_ulong = 0x8020426C; +const BIOCIMMEDIATE: libc::c_ulong = 0x80044270; +const BIOCSHDRCMPLT: libc::c_ulong = 0x80044275; +const BIOCSETF: libc::c_ulong = 0x80104267; +const BIOCGBLEN: libc::c_ulong = 0x40044266; + +/// BPF instruction. +#[repr(C)] +#[derive(Clone, Copy)] +struct BpfInsn { + code: u16, + jt: u8, + jf: u8, + k: u32, +} + +/// BPF program. +#[repr(C)] +struct BpfProgram { + bf_len: u32, + bf_insns: *const BpfInsn, +} + +/// Open the first available `/dev/bpf*` device. +fn open_bpf_device() -> Result { + for i in 0..256 { + let path = format!("/dev/bpf{}", i); + let c_path = std::ffi::CString::new(path.as_str()).unwrap(); + let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_RDWR) }; + if fd >= 0 { + return Ok(fd); + } + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EACCES) { + return Err(TransportError::StartFailed( + "BPF requires root (run with sudo)".into(), + )); + } + // EBUSY: device in use, try next one + } + Err(TransportError::StartFailed( + "no available /dev/bpf* device".into(), + )) +} + +/// Bind a BPF fd to a network interface. +fn bind_to_interface(fd: RawFd, interface: &str) -> Result<(), TransportError> { + let mut ifreq: [u8; 32] = [0; 32]; // struct ifreq + let name_bytes = interface.as_bytes(); + let copy_len = name_bytes.len().min(libc::IFNAMSIZ - 1); + ifreq[..copy_len].copy_from_slice(&name_bytes[..copy_len]); + + let ret = unsafe { libc::ioctl(fd, BIOCSETIF, ifreq.as_ptr()) }; + if ret < 0 { + return Err(TransportError::StartFailed(format!( + "BIOCSETIF({}) failed: {}", interface, std::io::Error::last_os_error() + ))); + } + Ok(()) +} + +/// Enable immediate mode (deliver packets without buffering). +fn set_bpf_immediate(fd: RawFd) -> Result<(), TransportError> { + let enable: libc::c_uint = 1; + let ret = unsafe { libc::ioctl(fd, BIOCIMMEDIATE, &enable) }; + if ret < 0 { + return Err(TransportError::StartFailed(format!( + "BIOCIMMEDIATE failed: {}", std::io::Error::last_os_error() + ))); + } + Ok(()) +} + +/// Tell BPF we supply complete Ethernet headers on writes. +fn set_bpf_hdrcmplt(fd: RawFd) -> Result<(), TransportError> { + let enable: libc::c_uint = 1; + let ret = unsafe { libc::ioctl(fd, BIOCSHDRCMPLT, &enable) }; + if ret < 0 { + return Err(TransportError::StartFailed(format!( + "BIOCSHDRCMPLT failed: {}", std::io::Error::last_os_error() + ))); + } + Ok(()) +} + +/// Install a BPF filter to only capture frames with the given EtherType. +fn install_ethertype_filter(fd: RawFd, ethertype: u16) -> Result<(), TransportError> { + // BPF program: match EtherType at offset 12 in Ethernet header + // ldh [12] ; load half-word at offset 12 (EtherType) + // jeq #ethertype, accept, reject + // accept: ret #65535 ; accept entire packet + // reject: ret #0 ; drop + let filter = [ + BpfInsn { code: 0x28, jt: 0, jf: 0, k: 12 }, // ldh [12] + BpfInsn { code: 0x15, jt: 0, jf: 1, k: ethertype as u32 }, // jeq #ethertype + BpfInsn { code: 0x06, jt: 0, jf: 0, k: 0xFFFF }, // ret #65535 + BpfInsn { code: 0x06, jt: 0, jf: 0, k: 0 }, // ret #0 + ]; + + let prog = BpfProgram { + bf_len: filter.len() as u32, + bf_insns: filter.as_ptr(), + }; + + let ret = unsafe { libc::ioctl(fd, BIOCSETF, &prog) }; + if ret < 0 { + return Err(TransportError::StartFailed(format!( + "BIOCSETF failed: {}", std::io::Error::last_os_error() + ))); + } + Ok(()) +} + +/// Get the BPF read buffer length. +fn get_bpf_buflen(fd: RawFd) -> Result { + let mut buflen: libc::c_uint = 0; + let ret = unsafe { libc::ioctl(fd, BIOCGBLEN, &mut buflen) }; + if ret < 0 { + return Err(TransportError::StartFailed(format!( + "BIOCGBLEN failed: {}", std::io::Error::last_os_error() + ))); + } + Ok(buflen as usize) +} + +/// Get the interface index by name. +fn get_if_index(interface: &str) -> Result { + let c_name = std::ffi::CString::new(interface).map_err(|_| { + TransportError::StartFailed(format!("invalid interface name: {}", interface)) + })?; + + let idx = unsafe { libc::if_nametoindex(c_name.as_ptr()) }; + if idx == 0 { + return Err(TransportError::StartFailed(format!( + "interface not found: {} ({})", + interface, + std::io::Error::last_os_error() + ))); + } + Ok(idx as i32) +} + +/// Get the MAC address of an interface using `getifaddrs()` with `AF_LINK`. +fn get_mac_addr(interface: &str) -> Result<[u8; 6], TransportError> { + let mut addrs: *mut libc::ifaddrs = std::ptr::null_mut(); + let ret = unsafe { libc::getifaddrs(&mut addrs) }; + if ret != 0 { + return Err(TransportError::StartFailed(format!( + "getifaddrs() failed: {}", std::io::Error::last_os_error() + ))); + } + + let result = (|| { + let mut cur = addrs; + while !cur.is_null() { + let ifa = unsafe { &*cur }; + let name = unsafe { std::ffi::CStr::from_ptr(ifa.ifa_name) } + .to_str() + .unwrap_or(""); + + if name == interface && !ifa.ifa_addr.is_null() { + let sa = unsafe { &*ifa.ifa_addr }; + if sa.sa_family as i32 == libc::AF_LINK { + let sdl = unsafe { &*(ifa.ifa_addr as *const libc::sockaddr_dl) }; + let nlen = sdl.sdl_nlen as usize; + // MAC address starts after the interface name in sdl_data + let data_ptr = sdl.sdl_data.as_ptr(); + let mut mac = [0u8; 6]; + for i in 0..6 { + mac[i] = unsafe { *data_ptr.add(nlen + i) } as u8; + } + return Ok(mac); + } + } + cur = unsafe { (*cur).ifa_next }; + } + Err(TransportError::StartFailed(format!( + "MAC address not found for interface: {}", interface + ))) + })(); + + unsafe { libc::freeifaddrs(addrs) }; + result +} + +// ============================================================================ +// Unit tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + // ----------------------------------------------------------------------- + // bpf_wordalign + // ----------------------------------------------------------------------- + + #[test] + fn test_bpf_wordalign_already_aligned() { + assert_eq!(bpf_wordalign(0), 0); + assert_eq!(bpf_wordalign(4), 4); + assert_eq!(bpf_wordalign(8), 8); + assert_eq!(bpf_wordalign(20), 20); + } + + #[test] + fn test_bpf_wordalign_rounds_up() { + assert_eq!(bpf_wordalign(1), 4); + assert_eq!(bpf_wordalign(2), 4); + assert_eq!(bpf_wordalign(3), 4); + assert_eq!(bpf_wordalign(5), 8); + assert_eq!(bpf_wordalign(21), 24); + } + + // ----------------------------------------------------------------------- + // parse_next_frame helpers + // ----------------------------------------------------------------------- + + /// Build a single BPF packet in a buffer. + /// + /// Layout: + /// [0..20] BpfHeader (bh_hdrlen = 20) + /// [20..26] dst_mac + /// [26..32] src_mac + /// [32..34] ethertype (0x0800) + /// [34..] payload + /// [..] zero padding to BPF_WORDALIGN boundary + fn make_bpf_packet(src_mac: [u8; 6], payload: &[u8]) -> Vec { + let cap_len = ETH_HDRLEN + payload.len(); + let hdr = BpfHeader { + bh_tstamp_sec: 0, + bh_tstamp_usec: 0, + bh_caplen: cap_len as u32, + bh_datalen: cap_len as u32, + bh_hdrlen: std::mem::size_of::() as u16, + _pad: 0, + }; + let hdr_size = std::mem::size_of::(); + let total = bpf_wordalign(hdr_size + cap_len); + let mut buf = vec![0u8; total]; + + // Write header + unsafe { + std::ptr::copy_nonoverlapping( + &hdr as *const BpfHeader as *const u8, + buf.as_mut_ptr(), + hdr_size, + ); + } + + // Write Ethernet header: dst(6) + src(6) + ethertype(2) + let frame_start = hdr_size; + buf[frame_start..frame_start + 6].copy_from_slice(&[0xff; 6]); // dst = broadcast + buf[frame_start + 6..frame_start + 12].copy_from_slice(&src_mac); + buf[frame_start + 12] = 0x08; + buf[frame_start + 13] = 0x00; + + // Write payload + buf[frame_start + ETH_HDRLEN..frame_start + ETH_HDRLEN + payload.len()] + .copy_from_slice(payload); + + buf + } + + // ----------------------------------------------------------------------- + // parse_next_frame + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_next_frame_single() { + let src_mac = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06]; + let payload = b"hello world"; + let buf = make_bpf_packet(src_mac, payload); + + let mut out_buf = vec![0u8; 1500]; + let mut offset = 0usize; + let len = buf.len(); + + let result = parse_next_frame(&buf, &mut offset, len, &mut out_buf) + .expect("should return Some") + .expect("should be Ok"); + + assert_eq!(result.0, payload.len()); + assert_eq!(result.1, src_mac); + assert_eq!(&out_buf[..result.0], payload); + // offset should have advanced past this frame + assert!(offset > 0); + } + + #[test] + fn test_parse_next_frame_two_frames() { + let src1 = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66]; + let src2 = [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]; + let payload1 = b"first"; + let payload2 = b"second"; + + let mut buf = make_bpf_packet(src1, payload1); + buf.extend_from_slice(&make_bpf_packet(src2, payload2)); + let len = buf.len(); + + let mut out_buf = vec![0u8; 1500]; + let mut offset = 0usize; + + // First frame + let (n1, mac1) = parse_next_frame(&buf, &mut offset, len, &mut out_buf) + .expect("Some") + .expect("Ok"); + assert_eq!(mac1, src1); + assert_eq!(&out_buf[..n1], payload1); + + // Second frame + let (n2, mac2) = parse_next_frame(&buf, &mut offset, len, &mut out_buf) + .expect("Some") + .expect("Ok"); + assert_eq!(mac2, src2); + assert_eq!(&out_buf[..n2], payload2); + + // Buffer exhausted + assert!(parse_next_frame(&buf, &mut offset, len, &mut out_buf).is_none()); + } + + #[test] + fn test_parse_next_frame_empty_buffer() { + let buf = vec![0u8; 0]; + let mut out_buf = vec![0u8; 1500]; + let mut offset = 0usize; + assert!(parse_next_frame(&buf, &mut offset, 0, &mut out_buf).is_none()); + } + + #[test] + fn test_parse_next_frame_offset_at_end() { + let buf = vec![0u8; 64]; + let mut out_buf = vec![0u8; 1500]; + let mut offset = 64usize; + assert!(parse_next_frame(&buf, &mut offset, 64, &mut out_buf).is_none()); + } + + #[test] + fn test_parse_next_frame_runt_skipped() { + // Build a packet with cap_len < ETH_HDRLEN (13 bytes — too short for + // a valid Ethernet header). parse_next_frame should skip it and return None. + let hdr_size = std::mem::size_of::(); + let cap_len: usize = 13; // < ETH_HDRLEN (14) + let hdr = BpfHeader { + bh_tstamp_sec: 0, + bh_tstamp_usec: 0, + bh_caplen: cap_len as u32, + bh_datalen: cap_len as u32, + bh_hdrlen: hdr_size as u16, + _pad: 0, + }; + let total = bpf_wordalign(hdr_size + cap_len); + let mut buf = vec![0u8; total]; + unsafe { + std::ptr::copy_nonoverlapping( + &hdr as *const BpfHeader as *const u8, + buf.as_mut_ptr(), + hdr_size, + ); + } + + let mut out_buf = vec![0u8; 1500]; + let mut offset = 0usize; + // Runt: returns None (skipped, not an error) + assert!(parse_next_frame(&buf, &mut offset, buf.len(), &mut out_buf).is_none()); + } + + #[test] + fn test_parse_next_frame_output_buf_truncation() { + // out_buf smaller than payload — result should be truncated to out_buf size. + let src_mac = [0xde, 0xad, 0xbe, 0xef, 0x00, 0x01]; + let payload = b"this payload is longer than the output buffer"; + let bpf_buf = make_bpf_packet(src_mac, payload); + let len = bpf_buf.len(); + + let mut out_buf = vec![0u8; 10]; // deliberately small + let mut offset = 0usize; + let (n, mac) = parse_next_frame(&bpf_buf, &mut offset, len, &mut out_buf) + .expect("Some") + .expect("Ok"); + + assert_eq!(n, 10); + assert_eq!(mac, src_mac); + assert_eq!(&out_buf[..n], &payload[..10]); + } + + // ----------------------------------------------------------------------- + // Shutdown pipe signaling + // + // Verifies the self-pipe pattern used to wake the BPF reader thread. + // ----------------------------------------------------------------------- + + /// Returns true if `fd` is readable within the given timeout (0 = poll). + fn fd_is_readable(fd: RawFd, timeout_ms: i64) -> bool { + unsafe { + let mut tv = libc::timeval { + tv_sec: timeout_ms / 1000, + tv_usec: ((timeout_ms % 1000) * 1000) as i32, + }; + let mut read_fds: libc::fd_set = std::mem::zeroed(); + libc::FD_ZERO(&mut read_fds); + libc::FD_SET(fd, &mut read_fds); + let ret = libc::select( + fd + 1, + &mut read_fds, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut tv, + ); + ret > 0 && libc::FD_ISSET(fd, &read_fds) + } + } + + #[test] + fn test_shutdown_pipe_initially_not_readable() { + let mut fds = [0i32; 2]; + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0); + let (read_fd, write_fd) = (fds[0], fds[1]); + + let readable = fd_is_readable(read_fd, 0); + unsafe { + libc::close(read_fd); + libc::close(write_fd); + } + assert!(!readable, "pipe read end should not be readable before write"); + } + + #[test] + fn test_shutdown_pipe_readable_after_write() { + let mut fds = [0i32; 2]; + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0); + let (read_fd, write_fd) = (fds[0], fds[1]); + + unsafe { + libc::write(write_fd, b"x".as_ptr() as *const libc::c_void, 1); + } + + let readable = fd_is_readable(read_fd, 0); + unsafe { + libc::close(read_fd); + libc::close(write_fd); + } + assert!(readable, "pipe read end should be readable after write"); + } +} + +/// Get the MTU of an interface by index. +fn get_if_mtu(if_index: i32) -> Result { + let sock = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0) }; + if sock < 0 { + return Err(TransportError::StartFailed(format!( + "socket(AF_INET) for MTU query failed: {}", + std::io::Error::last_os_error() + ))); + } + + // Get interface name from index + let mut name_buf = [0i8; libc::IFNAMSIZ]; + let ret = unsafe { + libc::if_indextoname(if_index as libc::c_uint, name_buf.as_mut_ptr()) + }; + if ret.is_null() { + unsafe { libc::close(sock) }; + return Err(TransportError::StartFailed(format!( + "if_indextoname({}) failed: {}", if_index, std::io::Error::last_os_error() + ))); + } + + let mut ifr: libc::ifreq = unsafe { std::mem::zeroed() }; + unsafe { + std::ptr::copy_nonoverlapping( + name_buf.as_ptr(), + ifr.ifr_name.as_mut_ptr(), + libc::IFNAMSIZ, + ); + } + + let ret = unsafe { libc::ioctl(sock, SIOCGIFMTU, &mut ifr) }; + unsafe { libc::close(sock) }; + if ret < 0 { + return Err(TransportError::StartFailed(format!( + "ioctl(SIOCGIFMTU) failed: {}", + std::io::Error::last_os_error() + ))); + } + + let mtu = unsafe { ifr.ifr_ifru.ifru_mtu } as u16; + Ok(mtu) +} diff --git a/src/transport/mod.rs b/src/transport/mod.rs index 63d1f09..639d95b 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -8,9 +8,9 @@ pub mod udp; pub mod tcp; pub mod tor; -#[cfg(target_os = "linux")] pub mod ethernet; +#[cfg(target_os = "linux")] pub mod ble; use secp256k1::XOnlyPublicKey; @@ -18,7 +18,6 @@ use udp::UdpTransport; use tcp::TcpTransport; use tor::control::TorMonitoringInfo; use tor::TorTransport; -#[cfg(target_os = "linux")] use ethernet::EthernetTransport; #[cfg(target_os = "linux")] use ble::DefaultBleTransport; @@ -852,7 +851,6 @@ pub enum TransportHandle { /// UDP/IP transport. Udp(UdpTransport), /// Raw Ethernet transport. - #[cfg(target_os = "linux")] Ethernet(EthernetTransport), /// TCP/IP transport. Tcp(TcpTransport), @@ -868,7 +866,6 @@ impl TransportHandle { pub async fn start(&mut self) -> Result<(), TransportError> { match self { TransportHandle::Udp(t) => t.start_async().await, - #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.start_async().await, TransportHandle::Tcp(t) => t.start_async().await, TransportHandle::Tor(t) => t.start_async().await, @@ -881,7 +878,6 @@ impl TransportHandle { pub async fn stop(&mut self) -> Result<(), TransportError> { match self { TransportHandle::Udp(t) => t.stop_async().await, - #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.stop_async().await, TransportHandle::Tcp(t) => t.stop_async().await, TransportHandle::Tor(t) => t.stop_async().await, @@ -894,7 +890,6 @@ impl TransportHandle { pub async fn send(&self, addr: &TransportAddr, data: &[u8]) -> Result { match self { TransportHandle::Udp(t) => t.send_async(addr, data).await, - #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.send_async(addr, data).await, TransportHandle::Tcp(t) => t.send_async(addr, data).await, TransportHandle::Tor(t) => t.send_async(addr, data).await, @@ -907,7 +902,6 @@ impl TransportHandle { pub fn transport_id(&self) -> TransportId { match self { TransportHandle::Udp(t) => t.transport_id(), - #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.transport_id(), TransportHandle::Tcp(t) => t.transport_id(), TransportHandle::Tor(t) => t.transport_id(), @@ -920,7 +914,6 @@ impl TransportHandle { pub fn name(&self) -> Option<&str> { match self { TransportHandle::Udp(t) => t.name(), - #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.name(), TransportHandle::Tcp(t) => t.name(), TransportHandle::Tor(t) => t.name(), @@ -933,7 +926,6 @@ impl TransportHandle { pub fn transport_type(&self) -> &TransportType { match self { TransportHandle::Udp(t) => t.transport_type(), - #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.transport_type(), TransportHandle::Tcp(t) => t.transport_type(), TransportHandle::Tor(t) => t.transport_type(), @@ -946,7 +938,6 @@ impl TransportHandle { pub fn state(&self) -> TransportState { match self { TransportHandle::Udp(t) => t.state(), - #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.state(), TransportHandle::Tcp(t) => t.state(), TransportHandle::Tor(t) => t.state(), @@ -959,7 +950,6 @@ impl TransportHandle { pub fn mtu(&self) -> u16 { match self { TransportHandle::Udp(t) => t.mtu(), - #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.mtu(), TransportHandle::Tcp(t) => t.mtu(), TransportHandle::Tor(t) => t.mtu(), @@ -975,7 +965,6 @@ impl TransportHandle { pub fn link_mtu(&self, addr: &TransportAddr) -> u16 { match self { TransportHandle::Udp(t) => t.link_mtu(addr), - #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.link_mtu(addr), TransportHandle::Tcp(t) => t.link_mtu(addr), TransportHandle::Tor(t) => t.link_mtu(addr), @@ -988,7 +977,6 @@ impl TransportHandle { pub fn local_addr(&self) -> Option { match self { TransportHandle::Udp(t) => t.local_addr(), - #[cfg(target_os = "linux")] TransportHandle::Ethernet(_) => None, TransportHandle::Tcp(t) => t.local_addr(), TransportHandle::Tor(_) => None, @@ -1001,7 +989,6 @@ impl TransportHandle { pub fn interface_name(&self) -> Option<&str> { match self { TransportHandle::Udp(_) => None, - #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => Some(t.interface_name()), TransportHandle::Tcp(_) => None, TransportHandle::Tor(_) => None, @@ -1038,7 +1025,6 @@ impl TransportHandle { pub fn discover(&self) -> Result, TransportError> { match self { TransportHandle::Udp(t) => t.discover(), - #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.discover(), TransportHandle::Tcp(t) => t.discover(), TransportHandle::Tor(t) => t.discover(), @@ -1051,7 +1037,6 @@ impl TransportHandle { pub fn auto_connect(&self) -> bool { match self { TransportHandle::Udp(t) => t.auto_connect(), - #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.auto_connect(), TransportHandle::Tcp(t) => t.auto_connect(), TransportHandle::Tor(t) => t.auto_connect(), @@ -1064,7 +1049,6 @@ impl TransportHandle { pub fn accept_connections(&self) -> bool { match self { TransportHandle::Udp(t) => t.accept_connections(), - #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.accept_connections(), TransportHandle::Tcp(t) => t.accept_connections(), TransportHandle::Tor(t) => t.accept_connections(), @@ -1083,7 +1067,6 @@ impl TransportHandle { pub async fn connect(&self, addr: &TransportAddr) -> Result<(), TransportError> { match self { TransportHandle::Udp(_) => Ok(()), // connectionless - #[cfg(target_os = "linux")] TransportHandle::Ethernet(_) => Ok(()), // connectionless TransportHandle::Tcp(t) => t.connect_async(addr).await, TransportHandle::Tor(t) => t.connect_async(addr).await, @@ -1100,7 +1083,6 @@ impl TransportHandle { pub fn connection_state(&self, addr: &TransportAddr) -> ConnectionState { match self { TransportHandle::Udp(_) => ConnectionState::Connected, - #[cfg(target_os = "linux")] TransportHandle::Ethernet(_) => ConnectionState::Connected, TransportHandle::Tcp(t) => t.connection_state_sync(addr), TransportHandle::Tor(t) => t.connection_state_sync(addr), @@ -1116,7 +1098,6 @@ impl TransportHandle { pub async fn close_connection(&self, addr: &TransportAddr) { match self { TransportHandle::Udp(t) => t.close_connection(addr), - #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => t.close_connection(addr), TransportHandle::Tcp(t) => t.close_connection_async(addr).await, TransportHandle::Tor(t) => t.close_connection_async(addr).await, @@ -1138,7 +1119,6 @@ impl TransportHandle { pub fn congestion(&self) -> TransportCongestion { match self { TransportHandle::Udp(t) => t.congestion(), - #[cfg(target_os = "linux")] TransportHandle::Ethernet(_) => TransportCongestion::default(), TransportHandle::Tcp(_) => TransportCongestion::default(), TransportHandle::Tor(_) => TransportCongestion::default(), @@ -1155,7 +1135,6 @@ impl TransportHandle { TransportHandle::Udp(t) => { serde_json::to_value(t.stats().snapshot()).unwrap_or_default() } - #[cfg(target_os = "linux")] TransportHandle::Ethernet(t) => { let snap = t.stats().snapshot(); serde_json::json!({ diff --git a/src/transport/udp/socket.rs b/src/transport/udp/socket.rs index e33f0a0..adfe48c 100644 --- a/src/transport/udp/socket.rs +++ b/src/transport/udp/socket.rs @@ -152,7 +152,10 @@ impl UdpRawSocket { // Control message buffer sized for SO_RXQ_OVFL (u32). // CMSG_SPACE computes the aligned size including header. + #[cfg(target_os = "linux")] const CMSG_BUF_SIZE: usize = unsafe { libc::CMSG_SPACE(4) } as usize; + #[cfg(not(target_os = "linux"))] + const CMSG_BUF_SIZE: usize = 64; let mut cmsg_buf = [0u8; CMSG_BUF_SIZE]; let mut src_addr: libc::sockaddr_storage = unsafe { std::mem::zeroed() }; @@ -160,7 +163,7 @@ impl UdpRawSocket { msg.msg_name = &mut src_addr as *mut _ as *mut libc::c_void; msg.msg_namelen = std::mem::size_of::() as libc::socklen_t; msg.msg_iov = &mut iov; - msg.msg_iovlen = 1; + msg.msg_iovlen = 1 as _; msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void; msg.msg_controllen = cmsg_buf.len() as _; @@ -173,7 +176,10 @@ impl UdpRawSocket { let addr = sockaddr_to_socket_addr(&src_addr)?; // Walk cmsg chain for SO_RXQ_OVFL drop counter + #[cfg(target_os = "linux")] let mut drops: u32 = 0; + #[cfg(not(target_os = "linux"))] + let drops: u32 = 0; #[cfg(target_os = "linux")] unsafe { let mut cmsg = libc::CMSG_FIRSTHDR(&msg); diff --git a/src/upper/tun.rs b/src/upper/tun.rs index 8e2e55c..61703ff 100644 --- a/src/upper/tun.rs +++ b/src/upper/tun.rs @@ -5,10 +5,10 @@ //! allowing standard socket applications to communicate over the mesh. use crate::{FipsAddress, TunConfig}; -use futures::TryStreamExt; -use rtnetlink::{new_connection, Handle, LinkUnspec, RouteMessageBuilder}; use std::fs::File; -use std::io::{Read, Write}; +use std::io::Read; +#[cfg(not(target_os = "macos"))] +use std::io::Write; use std::net::Ipv6Addr; use std::os::unix::io::{AsRawFd, FromRawFd}; use std::sync::mpsc; @@ -33,6 +33,7 @@ pub enum TunError { #[error("failed to configure TUN device: {0}")] Configure(String), + #[cfg(target_os = "linux")] #[error("netlink error: {0}")] Netlink(#[from] rtnetlink::Error), @@ -87,7 +88,7 @@ impl TunDevice { /// This requires CAP_NET_ADMIN capability (run with sudo or setcap). pub async fn create(config: &TunConfig, address: FipsAddress) -> Result { // Check if IPv6 is enabled - if is_ipv6_disabled() { + if platform::is_ipv6_disabled() { return Err(TunError::Ipv6Disabled); } @@ -95,9 +96,9 @@ impl TunDevice { let mtu = config.mtu(); // Delete existing interface if present (TUN devices are exclusive) - if interface_exists(name).await { + if platform::interface_exists(name).await { debug!(name, "Deleting existing TUN interface"); - if let Err(e) = delete_interface(name).await { + if let Err(e) = platform::delete_interface(name).await { debug!(name, error = %e, "Failed to delete existing interface"); } } @@ -105,17 +106,32 @@ impl TunDevice { // Create the TUN device 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")] + { + #[allow(deprecated)] + tun_config.layer(Layer::L3).mtu(mtu); + } + let device = tun::create(&tun_config)?; - // Configure address and bring up via netlink - configure_interface(name, address.to_ipv6(), mtu).await?; + // Read the actual device name (on macOS this is the kernel-assigned utun* name) + let actual_name = { + use tun::AbstractDevice; + device.tun_name().map_err(|e| TunError::Configure(format!("failed to get device name: {}", e)))? + }; + + // Configure address and bring up via platform-specific method + platform::configure_interface(&actual_name, address.to_ipv6(), mtu).await?; Ok(Self { device, - name: name.to_string(), + name: actual_name, mtu, address, }) @@ -148,10 +164,17 @@ impl TunDevice { /// Read a packet from the TUN device. /// - /// Returns the number of bytes read into the buffer, or an error. + /// Returns the number of bytes read into the buffer, or an `io::Error`. /// The buffer should be at least MTU + header size (typically 1500+ bytes). - pub fn read_packet(&mut self, buf: &mut [u8]) -> Result { - self.device.read(buf).map_err(|e| TunError::Configure(format!("read failed: {}", e))) + /// + /// The tun crate's `Read` impl transparently strips the macOS utun + /// packet information header, so this returns a raw IP packet on all + /// platforms. + /// + /// 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) } /// Shutdown and delete the TUN device. @@ -159,7 +182,7 @@ impl TunDevice { /// This deletes the interface entirely. pub async fn shutdown(&self) -> Result<(), TunError> { debug!(name = %self.name, "Deleting TUN device"); - delete_interface(&self.name).await + platform::delete_interface(&self.name).await } /// Create a TunWriter for this device. @@ -214,6 +237,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))] pub fn run(mut self) { use super::tcp_mss::clamp_tcp_mss; @@ -229,7 +253,43 @@ impl TunWriter { ); } - if let Err(e) = self.file.write_all(&packet) { + // On macOS, utun devices require a 4-byte packet information 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")] + let write_result = { + use std::os::unix::io::AsRawFd; + const AF_INET6_HEADER: [u8; 4] = [0, 0, 0, 30]; + let iov = [ + libc::iovec { + iov_base: AF_INET6_HEADER.as_ptr() as *mut libc::c_void, + iov_len: 4, + }, + libc::iovec { + iov_base: packet.as_ptr() as *mut libc::c_void, + iov_len: packet.len(), + }, + ]; + let ret = unsafe { libc::writev(self.file.as_raw_fd(), iov.as_ptr(), 2) }; + if ret < 0 { + Err(std::io::Error::last_os_error()) + } else { + let expected = 4 + packet.len(); + if (ret as usize) < expected { + Err(std::io::Error::new( + std::io::ErrorKind::WriteZero, + format!("short writev: {} of {} bytes", ret, expected), + )) + } else { + Ok(()) + } + } + }; + #[cfg(not(target_os = "macos"))] + let write_result = self.file.write_all(&packet); + + if let Err(e) = write_result { // "Bad address" is expected during shutdown when interface is deleted let err_str = e.to_string(); if err_str.contains("Bad address") { @@ -243,7 +303,7 @@ impl TunWriter { } } -/// TUN packet reader loop. +/// TUN packet reader loop (Linux). /// /// Reads IPv6 packets from the TUN device. Packets destined for FIPS addresses /// (fd::/8) are forwarded to the Node via the outbound channel for session @@ -255,6 +315,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"))] pub fn run_tun_reader( mut device: TunDevice, mtu: u16, @@ -263,13 +324,128 @@ pub fn run_tun_reader( outbound_tx: TunOutboundTx, transport_mtu: u16, ) { - use super::icmp::{build_dest_unreachable, effective_ipv6_mtu, should_send_icmp_error, DestUnreachableCode}; - use super::tcp_mss::clamp_tcp_mss; + let (name, mut buf, max_mss) = tun_reader_setup(&device, mtu, transport_mtu); + + loop { + match device.read_packet(&mut buf) { + Ok(n) if n > 0 => { + if !handle_tun_packet(&mut buf[..n], max_mss, &name, our_addr, &tun_tx, &outbound_tx) { + break; + } + } + Ok(_) => {} + Err(e) => { + // EFAULT ("Bad address") is expected during shutdown when the interface is deleted + if e.raw_os_error() != Some(libc::EFAULT) { + error!(name = %name, error = %e, "TUN read error"); + } + break; + } + } + } +} + +/// RAII wrapper that closes a raw fd on drop. +/// +/// 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")] +struct ShutdownFd(std::os::unix::io::RawFd); + +#[cfg(target_os = "macos")] +impl Drop for ShutdownFd { + fn drop(&mut self) { + unsafe { libc::close(self.0); } + } +} + +/// TUN packet reader loop (macOS). +/// +/// 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")] +pub fn run_tun_reader( + mut device: TunDevice, + mtu: u16, + our_addr: FipsAddress, + tun_tx: TunTx, + outbound_tx: TunOutboundTx, + transport_mtu: u16, + shutdown_fd: std::os::unix::io::RawFd, +) { + let _shutdown_fd = ShutdownFd(shutdown_fd); + let tun_fd = device.device().as_raw_fd(); + let (name, mut buf, max_mss) = tun_reader_setup(&device, mtu, transport_mtu); + + // Set TUN fd to non-blocking so we can use select + read without blocking + // past the point where select returns readable. + unsafe { + let flags = libc::fcntl(tun_fd, libc::F_GETFL); + if flags >= 0 { + libc::fcntl(tun_fd, libc::F_SETFL, flags | libc::O_NONBLOCK); + } + } + + let nfds = tun_fd.max(shutdown_fd) + 1; + + loop { + // Wait for either TUN data or shutdown signal + unsafe { + let mut read_fds: libc::fd_set = std::mem::zeroed(); + libc::FD_ZERO(&mut read_fds); + libc::FD_SET(tun_fd, &mut read_fds); + libc::FD_SET(shutdown_fd, &mut read_fds); + + let ret = libc::select(nfds, &mut read_fds, std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut()); + if ret < 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::Interrupted { + continue; + } + error!(name = %name, error = %err, "TUN select error"); + break; + } + + // Shutdown signal received + if libc::FD_ISSET(shutdown_fd, &read_fds) { + debug!(name = %name, "TUN reader received shutdown signal"); + break; + } + } + + // TUN fd is readable — drain all available packets + loop { + match device.read_packet(&mut buf) { + Ok(n) if n > 0 => { + if !handle_tun_packet(&mut buf[..n], max_mss, &name, our_addr, &tun_tx, &outbound_tx) { + return; // _shutdown_fd closes on drop + } + } + Ok(_) => break, // No more data + 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) { + error!(name = %name, error = %e, "TUN read error"); + } + return; // _shutdown_fd closes on drop + } + } + } + } + // _shutdown_fd closes on drop +} + +/// Common setup for TUN reader: extracts name, allocates buffer, computes max MSS. +fn tun_reader_setup(device: &TunDevice, mtu: u16, transport_mtu: u16) -> (String, Vec, u16) { + use super::icmp::effective_ipv6_mtu; let name = device.name().to_string(); - let mut buf = vec![0u8; mtu as usize + 100]; // Extra space for headers + let buf = vec![0u8; mtu as usize + 100]; - // Calculate maximum safe TCP MSS from the effective IPv6 MTU const IPV6_HEADER: u16 = 40; const TCP_HEADER: u16 = 20; let effective_mtu = effective_ipv6_mtu(transport_mtu); @@ -286,65 +462,52 @@ pub fn run_tun_reader( "TUN reader starting" ); - loop { - match device.read_packet(&mut buf) { - Ok(n) if n > 0 => { - let packet = &mut buf[..n]; - log_ipv6_packet(packet); + (name, buf, max_mss) +} - // Must be a valid IPv6 packet - if packet.len() < 40 || packet[0] >> 4 != 6 { - continue; - } +/// Process a single TUN packet. Returns `false` if the reader should exit. +fn handle_tun_packet( + packet: &mut [u8], + max_mss: u16, + name: &str, + our_addr: FipsAddress, + tun_tx: &TunTx, + outbound_tx: &TunOutboundTx, +) -> bool { + use super::icmp::{build_dest_unreachable, should_send_icmp_error, DestUnreachableCode}; + use super::tcp_mss::clamp_tcp_mss; - // Check if destination is a FIPS address (fd::/8 prefix) - if packet[24] == crate::identity::FIPS_ADDRESS_PREFIX { - // Clamp TCP MSS if this is a SYN packet - if clamp_tcp_mss(packet, max_mss) { - trace!( - name = %name, - max_mss = max_mss, - "Clamped TCP MSS in SYN packet" - ); - } + log_ipv6_packet(packet); - // Forward to Node for session encapsulation and routing - if outbound_tx.blocking_send(packet.to_vec()).is_err() { - break; // Channel closed, shutdown - } - } else { - // Non-FIPS destination: send ICMPv6 Destination Unreachable - if should_send_icmp_error(packet) - && let Some(response) = build_dest_unreachable( - packet, - DestUnreachableCode::NoRoute, - our_addr.to_ipv6(), - ) - { - trace!( - name = %name, - len = response.len(), - "Sending ICMPv6 Destination Unreachable (non-FIPS destination)" - ); - if tun_tx.send(response).is_err() { - break; - } - } - } - } - Ok(_) => { - // Zero-length read, continue - } - Err(e) => { - // "Bad address" (EFAULT) is expected during shutdown when interface is deleted - let err_str = format!("{}", e); - if !err_str.contains("Bad address") { - error!(name = %name, error = %e, "TUN read error"); - } - break; + // Must be a valid IPv6 packet + if packet.len() < 40 || packet[0] >> 4 != 6 { + return true; + } + + // Check if destination is a FIPS address (fd::/8 prefix) + if packet[24] == crate::identity::FIPS_ADDRESS_PREFIX { + if clamp_tcp_mss(packet, max_mss) { + trace!(name = %name, max_mss = max_mss, "Clamped TCP MSS in SYN packet"); + } + if outbound_tx.blocking_send(packet.to_vec()).is_err() { + return false; // Channel closed, shutdown + } + } else { + // Non-FIPS destination: send ICMPv6 Destination Unreachable + if should_send_icmp_error(packet) + && let Some(response) = build_dest_unreachable( + packet, + DestUnreachableCode::NoRoute, + our_addr.to_ipv6(), + ) + { + trace!(name = %name, len = response.len(), "Sending ICMPv6 Destination Unreachable (non-FIPS destination)"); + if tun_tx.send(response).is_err() { + return false; } } } + true } /// Log basic information about an IPv6 packet at TRACE level. @@ -388,7 +551,7 @@ pub fn log_ipv6_packet(packet: &[u8]) { /// has been moved to another thread. pub async fn shutdown_tun_interface(name: &str) -> Result<(), TunError> { debug!("Shutting down TUN interface {}", name); - delete_interface(name).await?; + platform::delete_interface(name).await?; debug!("TUN interface {} stopped", name); Ok(()) } @@ -403,98 +566,186 @@ impl std::fmt::Debug for TunDevice { } } -/// Check if a network interface already exists. -async fn interface_exists(name: &str) -> bool { - let Ok((connection, handle, _)) = new_connection() else { - return false; - }; - tokio::spawn(connection); +// ============================================================================= +// Platform-specific TUN configuration +// ============================================================================= - get_interface_index(&handle, name).await.is_ok() -} +#[cfg(target_os = "linux")] +mod platform { + use super::TunError; + use futures::TryStreamExt; + use rtnetlink::{new_connection, Handle, LinkUnspec, RouteMessageBuilder}; + use std::net::Ipv6Addr; + use tracing::debug; -/// Delete a network interface by name. -async fn delete_interface(name: &str) -> Result<(), TunError> { - let (connection, handle, _) = new_connection() - .map_err(|e| TunError::Configure(format!("netlink connection failed: {}", e)))?; - tokio::spawn(connection); - - let index = get_interface_index(&handle, name).await?; - handle.link().del(index).execute().await?; - Ok(()) -} - -/// Configure a network interface with an IPv6 address via netlink. -async fn configure_interface(name: &str, addr: Ipv6Addr, mtu: u16) -> Result<(), TunError> { - let (connection, handle, _) = new_connection() - .map_err(|e| TunError::Configure(format!("netlink connection failed: {}", e)))?; - tokio::spawn(connection); - - // Get interface index - let index = get_interface_index(&handle, name).await?; - - // Add IPv6 address with /128 prefix (point-to-point) - handle - .address() - .add(index, std::net::IpAddr::V6(addr), 128) - .execute() - .await?; - - // Set MTU - handle - .link() - .change(LinkUnspec::new_with_index(index).mtu(mtu as u32).build()) - .execute() - .await?; - - // Bring interface up - handle - .link() - .change(LinkUnspec::new_with_index(index).up().build()) - .execute() - .await?; - - // Add route for fd00::/8 (FIPS address space) via this interface - let fd_prefix: Ipv6Addr = "fd00::".parse().unwrap(); - let route = RouteMessageBuilder::::new() - .destination_prefix(fd_prefix, 8) - .output_interface(index) - .build(); - handle - .route() - .add(route) - .execute() - .await - .map_err(|e| TunError::Configure(format!("failed to add fd00::/8 route: {}", e)))?; - - // Add ip6 rule to ensure fd00::/8 uses the main table, preventing other - // routing software (e.g. Tailscale) from intercepting FIPS traffic via - // catch-all rules in auxiliary routing tables. - let mut rule_req = handle.rule().add().v6().destination_prefix(fd_prefix, 8).table_id(254).priority(5265); - rule_req.message_mut().header.action = 1.into(); // FR_ACT_TO_TBL - if let Err(e) = rule_req.execute().await { - debug!("ip6 rule for fd00::/8 not added (may already exist): {e}"); + /// Check if IPv6 is disabled system-wide. + pub fn is_ipv6_disabled() -> bool { + std::fs::read_to_string("/proc/sys/net/ipv6/conf/all/disable_ipv6") + .map(|s| s.trim() == "1") + .unwrap_or(false) } - Ok(()) -} + /// Check if a network interface already exists. + pub async fn interface_exists(name: &str) -> bool { + let Ok((connection, handle, _)) = new_connection() else { + return false; + }; + tokio::spawn(connection); -/// Get the interface index by name. -async fn get_interface_index(handle: &Handle, name: &str) -> Result { - let mut links = handle.link().get().match_name(name.to_string()).execute(); + get_interface_index(&handle, name).await.is_ok() + } - if let Some(link) = links.try_next().await? { - Ok(link.header.index) - } else { - Err(TunError::InterfaceNotFound(name.to_string())) + /// Delete a network interface by name. + pub async fn delete_interface(name: &str) -> Result<(), TunError> { + let (connection, handle, _) = new_connection() + .map_err(|e| TunError::Configure(format!("netlink connection failed: {}", e)))?; + tokio::spawn(connection); + + let index = get_interface_index(&handle, name).await?; + handle.link().del(index).execute().await?; + Ok(()) + } + + /// Configure a network interface with an IPv6 address via netlink. + pub async fn configure_interface(name: &str, addr: Ipv6Addr, mtu: u16) -> Result<(), TunError> { + let (connection, handle, _) = new_connection() + .map_err(|e| TunError::Configure(format!("netlink connection failed: {}", e)))?; + tokio::spawn(connection); + + // Get interface index + let index = get_interface_index(&handle, name).await?; + + // Add IPv6 address with /128 prefix (point-to-point) + handle + .address() + .add(index, std::net::IpAddr::V6(addr), 128) + .execute() + .await?; + + // Set MTU + handle + .link() + .change(LinkUnspec::new_with_index(index).mtu(mtu as u32).build()) + .execute() + .await?; + + // Bring interface up + handle + .link() + .change(LinkUnspec::new_with_index(index).up().build()) + .execute() + .await?; + + // Add route for fd00::/8 (FIPS address space) via this interface + let fd_prefix: Ipv6Addr = "fd00::".parse().unwrap(); + let route = RouteMessageBuilder::::new() + .destination_prefix(fd_prefix, 8) + .output_interface(index) + .build(); + handle + .route() + .add(route) + .execute() + .await + .map_err(|e| TunError::Configure(format!("failed to add fd00::/8 route: {}", e)))?; + + // Add ip6 rule to ensure fd00::/8 uses the main table, preventing other + // routing software (e.g. Tailscale) from intercepting FIPS traffic via + // catch-all rules in auxiliary routing tables. + let mut rule_req = handle.rule().add().v6().destination_prefix(fd_prefix, 8).table_id(254).priority(5265); + rule_req.message_mut().header.action = 1.into(); // FR_ACT_TO_TBL + if let Err(e) = rule_req.execute().await { + debug!("ip6 rule for fd00::/8 not added (may already exist): {e}"); + } + + Ok(()) + } + + /// Get the interface index by name. + async fn get_interface_index(handle: &Handle, name: &str) -> Result { + let mut links = handle.link().get().match_name(name.to_string()).execute(); + + if let Some(link) = links.try_next().await? { + Ok(link.header.index) + } else { + Err(TunError::InterfaceNotFound(name.to_string())) + } } } -/// Check if IPv6 is disabled system-wide. -fn is_ipv6_disabled() -> bool { - std::fs::read_to_string("/proc/sys/net/ipv6/conf/all/disable_ipv6") - .map(|s| s.trim() == "1") - .unwrap_or(false) +#[cfg(target_os = "macos")] +mod platform { + use super::TunError; + use std::net::Ipv6Addr; + use tokio::process::Command; + + /// Check if IPv6 is disabled system-wide. + pub fn is_ipv6_disabled() -> bool { + // macOS: check via sysctl; if the key doesn't exist, IPv6 is enabled + std::process::Command::new("sysctl") + .args(["-n", "net.inet6.ip6.disabled"]) + .output() + .map(|o| 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") + .arg(name) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false) + } + + /// 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. + pub async fn delete_interface(name: &str) -> Result<(), TunError> { + run_cmd("ifconfig", &[name, "down"]).await + } + + /// Configure a network interface with an IPv6 address using ifconfig/route. + pub async fn configure_interface(name: &str, addr: Ipv6Addr, mtu: u16) -> Result<(), TunError> { + // Add IPv6 address with /128 prefix + run_cmd("ifconfig", &[name, "inet6", &addr.to_string(), "prefixlen", "128"]).await?; + + // Set MTU + run_cmd("ifconfig", &[name, "mtu", &mtu.to_string()]).await?; + + // Bring interface up + run_cmd("ifconfig", &[name, "up"]).await?; + + // Add route for fd00::/8 (FIPS address space) via this interface + run_cmd("route", &["add", "-inet6", "-prefixlen", "8", "fd00::", "-interface", name]).await?; + + Ok(()) + } + + /// Run a command and return an error if it fails. + async fn run_cmd(program: &str, args: &[&str]) -> Result<(), TunError> { + let output = Command::new(program) + .args(args) + .output() + .await + .map_err(|e| TunError::Configure(format!("{} failed: {}", program, e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(TunError::Configure(format!( + "{} {} failed: {}", + program, + args.join(" "), + stderr.trim() + ))); + } + Ok(()) + } } #[cfg(test)] diff --git a/testing/docker/entrypoint.sh b/testing/docker/entrypoint.sh index c98b9e1..9b892ca 100644 --- a/testing/docker/entrypoint.sh +++ b/testing/docker/entrypoint.sh @@ -24,7 +24,7 @@ start_dnsmasq() { start_services() { /usr/sbin/sshd iperf3 -s -D - python3 -m http.server 8000 -d /root -b :: &>/dev/null & + python3 -m http.server 80 -d /root -b :: &>/dev/null & } # ── Chaos: TCP ECN + ethernet wait ────────────────────────────────────── diff --git a/testing/static/README.md b/testing/static/README.md index 5ee2296..8d22a08 100644 --- a/testing/static/README.md +++ b/testing/static/README.md @@ -340,14 +340,14 @@ Each container runs the following services alongside FIPS: | ------- | ---- | --------------------------------------------- | | SSH | 22 | Root login with no password (test only) | | iperf3 | 5201 | Bandwidth testing server (`-s -D`) | -| HTTP | 8000 | Python HTTP server serving `/root/index.html` | +| HTTP | 80 | Python HTTP server serving `/root/index.html` | All services bind to IPv6 (`::`) and are accessible over the FIPS overlay using `.fips` hostnames: ```bash # HTTP over FIPS -docker exec fips-node-b curl http://$NPUB_A.fips:8000/ +docker exec fips-node-b curl http://$NPUB_A.fips # SSH over FIPS docker exec fips-node-b ssh $NPUB_A.fips