Add macOS support, fix bloom filter routing and MMP intervals

macOS platform:
- Platform-native TUN interface management with shutdown pipe
- Raw Ethernet transport with macOS socket backend (socket_macos.rs)
- EthernetTransport and TransportHandle::Ethernet ungated from Linux-only
- macOS .pkg packaging (build-pkg.sh, launchd plist, uninstall script)
- CI: macOS build and unit test jobs; x86_64 cross-compiled from
  macos-latest via rustup target add x86_64-apple-darwin

Gateway feature flag:
- New opt-in `gateway` Cargo feature activates optional `rustables` dep
- `pub mod gateway` and `Config.gateway` gated behind the feature so
  macOS builds never pull in Linux-only nftables bindings
- `fips-gateway` bin has `required-features = ["gateway"]`
- All Linux/OpenWrt/AUR packaging passes `--features gateway`

CI / packaging:
- package-linux, package-macos, package-openwrt now trigger on push to
  master/maint/next and on pull requests; release uploads remain tag-gated
- Bloom filter routing fix: fall through to tree routing when no candidate
  is strictly closer
- MMP intervals: raise MIN to 1s / MAX to 5s with 5-sample cold-start phase
This commit is contained in:
Origami74
2026-04-09 20:03:42 +01:00
parent 1e4f375dcc
commit e693f4fb7e
35 changed files with 2552 additions and 636 deletions

View File

@@ -23,8 +23,7 @@ env:
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Job 1 Build matrix # Job 1 Build matrix
# #
# Builds on Linux x86_64 and Linux aarch64. macOS and Windows are in a # Builds on Linux x86_64, Linux aarch64, and macOS.
# separate ci-compat.yml workflow so their failures don't mark this run red.
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
jobs: jobs:
build: build:
@@ -37,6 +36,7 @@ jobs:
include: include:
- os: ubuntu-latest - os: ubuntu-latest
- os: ubuntu-24.04-arm - os: ubuntu-24.04-arm
- os: macos-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -44,7 +44,8 @@ jobs:
- name: Set SOURCE_DATE_EPOCH from git - name: Set SOURCE_DATE_EPOCH from git
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV" 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 run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev
- name: Install Rust toolchain - name: Install Rust toolchain
@@ -62,11 +63,16 @@ jobs:
${{ runner.os }}-cargo- ${{ runner.os }}-cargo-
- name: Build - 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 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 # Upload the Linux binary so integration jobs can use it without rebuilding
- name: Upload Linux binary - name: Upload Linux binary
if: matrix.os == 'ubuntu-latest' if: matrix.os == 'ubuntu-latest'
@@ -117,7 +123,7 @@ jobs:
uses: taiki-e/install-action@nextest uses: taiki-e/install-action@nextest
- name: Run unit tests - 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) - name: Publish test report (Checks tab)
uses: dorny/test-reporter@v2 uses: dorny/test-reporter@v2
@@ -136,6 +142,39 @@ jobs:
check_name: Unit Tests Summary check_name: Unit Tests Summary
fail_on_failure: false 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) # Job 3 Integration tests (static mesh + chaos simulation)
# #

View File

@@ -1,8 +1,13 @@
name: Linux Package name: Linux Package
on: on:
push: push:
branches:
- master
- maint
- next
tags: tags:
- "v*" - "v*"
pull_request:
workflow_dispatch: workflow_dispatch:
env: env:
@@ -85,7 +90,7 @@ jobs:
run: cargo install cargo-deb --version 3.6.3 --locked run: cargo install cargo-deb --version 3.6.3 --locked
- name: Build release binaries - name: Build release binaries
run: cargo build --release run: cargo build --release --features gateway
- name: Build systemd tarball - name: Build systemd tarball
env: env:

175
.github/workflows/package-macos.yml vendored Normal file
View File

@@ -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}"

View File

@@ -1,6 +1,13 @@
name: OpenWrt Package name: OpenWrt Package
on: on:
push: push:
branches:
- master
- maint
- next
tags:
- "v*"
pull_request:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
arch: arch:

8
.gitignore vendored
View File

@@ -11,8 +11,11 @@
.vscode/ .vscode/
.idea/ .idea/
# Claude Code # AI Agents
.claude/ .claude/
AGENTS.md
CLAUDE.md
agents/
deploy/ deploy/
vps.env vps.env
@@ -28,4 +31,5 @@ sim-results/
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*.egg-info/ *.egg-info/
*.egg *.egg

View File

@@ -12,6 +12,7 @@ readme = "README.md"
default = ["tui", "ble"] default = ["tui", "ble"]
tui = ["dep:ratatui"] tui = ["dep:ratatui"]
ble = ["dep:bluer"] ble = ["dep:bluer"]
gateway = ["dep:rustables"]
[dependencies] [dependencies]
ratatui = { version = "0.30", optional = true } ratatui = { version = "0.30", optional = true }
@@ -32,14 +33,17 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tun = { version = "0.8.5", features = ["async"] } tun = { version = "0.8.5", features = ["async"] }
libc = "0.2" libc = "0.2"
rtnetlink = "0.20.0"
tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net", "time", "process"] } tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net", "time", "process"] }
futures = "0.3" futures = "0.3"
simple-dns = "0.11.2" simple-dns = "0.11.2"
socket2 = { version = "0.6.2", features = ["all"] } socket2 = { version = "0.6.2", features = ["all"] }
tokio-socks = "0.5" 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 } bluer = { version = "0.17", features = ["bluetoothd", "l2cap"], optional = true }
rustables = "0.8.7"
[package.metadata.deb] [package.metadata.deb]
maintainer = "Johnathan Corgan <jcorgan@corganlabs.com>" maintainer = "Johnathan Corgan <jcorgan@corganlabs.com>"
@@ -82,6 +86,7 @@ path = "src/bin/fipsctl.rs"
[[bin]] [[bin]]
name = "fips-gateway" name = "fips-gateway"
path = "src/bin/fips-gateway.rs" path = "src/bin/fips-gateway.rs"
required-features = ["gateway"]
[[bin]] [[bin]]
name = "fipstop" name = "fipstop"

View File

@@ -63,11 +63,22 @@ cd fips
cargo build --release 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 ### Transport support by platform
Debian/Ubuntu: `sudo apt install bluez libdbus-1-dev`. To build without BLE:
`cargo build --release --no-default-features --features tui`. | 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 ## Installation
@@ -113,6 +124,41 @@ sudo ./install.sh
See [packaging/systemd/README.install.md](packaging/systemd/README.install.md) See [packaging/systemd/README.install.md](packaging/systemd/README.install.md)
for the full installation and configuration guide. 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<N>` (kernel-assigned)
> rather than `fips0`.
## Configuration ## Configuration
The default configuration file is installed at `/etc/fips/fips.yaml`: The default configuration file is installed at `/etc/fips/fips.yaml`:
@@ -175,20 +221,30 @@ for the full reference.
### DNS Resolution ### DNS Resolution
FIPS includes a DNS resolver (enabled by default, port 5354) that maps 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 ```bash
sudo resolvectl dns fips0 127.0.0.1:5354 sudo resolvectl dns fips0 127.0.0.1:5354
sudo resolvectl domain fips0 ~fips 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: Then reach any FIPS node by npub with standard IPv6 tools:
```bash ```bash
ping6 npub1bbb....fips 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 ### Monitoring
Use `fipsctl` to query a running node: 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) - 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` - Runtime inspection and peer management via `fipsctl` and `fipstop`
- Reproducible builds with toolchain pinning and SOURCE_DATE_EPOCH - 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 - Docker-based integration and chaos testing
### Near-term priorities ### Near-term priorities

View File

@@ -8,6 +8,7 @@
# make tarball Build a systemd install tarball # make tarball Build a systemd install tarball
# make ipk Build an OpenWrt .ipk package # make ipk Build an OpenWrt .ipk package
# make aur Build fips-git AUR package and validate with namcap # 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 all Build deb and tarball (default)
# make clean Remove deploy/ directory # make clean Remove deploy/ directory
@@ -15,7 +16,7 @@ SHELL := /bin/bash
PACKAGING_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) PACKAGING_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
PROJECT_ROOT := $(abspath $(PACKAGING_DIR)/..) PROJECT_ROOT := $(abspath $(PACKAGING_DIR)/..)
.PHONY: all deb tarball ipk aur clean .PHONY: all deb tarball ipk aur pkg clean
all: deb tarball all: deb tarball
@@ -31,5 +32,8 @@ ipk:
aur: aur:
@bash $(PACKAGING_DIR)/aur/build-aur.sh @bash $(PACKAGING_DIR)/aur/build-aur.sh
pkg:
@bash $(PACKAGING_DIR)/macos/build-pkg.sh
clean: clean:
rm -rf $(PROJECT_ROOT)/deploy rm -rf $(PROJECT_ROOT)/deploy

View File

@@ -10,6 +10,7 @@ make deb # Debian/Ubuntu .deb
make tarball # systemd install tarball make tarball # systemd install tarball
make ipk # OpenWrt .ipk make ipk # OpenWrt .ipk
make aur # Arch Linux AUR package (fips-git, local build + namcap) make aur # Arch Linux AUR package (fips-git, local build + namcap)
make pkg # macOS .pkg installer
make all # deb + tarball (default) make all # deb + tarball (default)
``` ```
@@ -20,6 +21,7 @@ packaging/
aur/ Arch Linux AUR packaging (PKGBUILD, supporting files) aur/ Arch Linux AUR packaging (PKGBUILD, supporting files)
common/ Shared assets (default config, hosts file) common/ Shared assets (default config, hosts file)
debian/ Debian/Ubuntu .deb packaging via cargo-deb debian/ Debian/Ubuntu .deb packaging via cargo-deb
macos/ macOS .pkg installer via pkgbuild
systemd/ Generic Linux systemd tarball packaging systemd/ Generic Linux systemd tarball packaging
openwrt/ OpenWrt .ipk packaging via cargo-zigbuild 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 See [openwrt/README.md](openwrt/README.md) for router-specific
installation instructions. 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<N>` (kernel-assigned)
rather than `fips0`.
```sh
# Build
make pkg
# Install
sudo installer -pkg deploy/fips-<version>-macos-<arch>.pkg -target /
# Remove
sudo packaging/macos/uninstall.sh
```
### Arch Linux (AUR) ### Arch Linux (AUR)
Two AUR packages are maintained: `fips` (release, builds from tagged Two AUR packages are maintained: `fips` (release, builds from tagged

View File

@@ -29,13 +29,13 @@ build() {
cd "$pkgname-$pkgver" cd "$pkgname-$pkgver"
export CARGO_TARGET_DIR=target export CARGO_TARGET_DIR=target
export SOURCE_DATE_EPOCH=$(stat -c %Y Cargo.toml) export SOURCE_DATE_EPOCH=$(stat -c %Y Cargo.toml)
cargo build --frozen --release cargo build --frozen --release --features gateway
} }
check() { check() {
cd "$pkgname-$pkgver" cd "$pkgname-$pkgver"
export CARGO_TARGET_DIR=target export CARGO_TARGET_DIR=target
cargo test --frozen --lib cargo test --frozen --lib --features gateway
} }
package() { package() {

View File

@@ -66,7 +66,7 @@ transports:
peers: [] peers: []
# Static peers for bootstrapping (UDP or TCP): # Static peers for bootstrapping (UDP or TCP):
# - npub: "npub1..." # - npub: "npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98"
# alias: "gateway" # alias: "gateway"
# addresses: # addresses:
# - transport: udp # - transport: udp

View File

@@ -71,7 +71,7 @@ echo "Building .deb package..."
OUTPUT_DIR="$(mktemp -d)" OUTPUT_DIR="$(mktemp -d)"
trap 'rm -rf "${OUTPUT_DIR}"' EXIT 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 if [[ -n "${TARGET_TRIPLE}" ]]; then
cargo_args+=(--target "${TARGET_TRIPLE}") cargo_args+=(--target "${TARGET_TRIPLE}")
fi fi

195
packaging/macos/build-pkg.sh Executable file
View File

@@ -0,0 +1,195 @@
#!/usr/bin/env bash
# Build a macOS .pkg installer for FIPS.
#
# Usage: ./packaging/macos/build-pkg.sh [--version <version>] [--no-build]
# Output: deploy/fips-<version>-macos-<arch>.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 <version> Override package version
--target <triple> 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" <<EOF
nameserver 127.0.0.1
port 5354
EOF
# Create postinstall script
cat > "${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"

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.fips.daemon</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/fips</string>
<string>--config</string>
<string>/usr/local/etc/fips/fips.yaml</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>StandardOutPath</key>
<string>/usr/local/var/log/fips/fips.log</string>
<key>StandardErrorPath</key>
<string>/usr/local/var/log/fips/fips.log</string>
<key>WorkingDirectory</key>
<string>/usr/local/etc/fips</string>
</dict>
</plist>

42
packaging/macos/uninstall.sh Executable file
View File

@@ -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)"

View File

@@ -82,6 +82,7 @@ define Build/Compile
cargo build \ cargo build \
--release \ --release \
--target $(RUST_TARGET) \ --target $(RUST_TARGET) \
--features gateway \
--bin fips \ --bin fips \
--bin fipsctl \ --bin fipsctl \
--bin fipstop \ --bin fipstop \

View File

@@ -110,7 +110,7 @@ cargo zigbuild \
--release \ --release \
--target "$RUST_TARGET" \ --target "$RUST_TARGET" \
--no-default-features \ --no-default-features \
--features tui \ --features tui,gateway \
--bin fips \ --bin fips \
--bin fipsctl \ --bin fipsctl \
--bin fipstop \ --bin fipstop \

View File

@@ -86,7 +86,7 @@ echo "Building FIPS v${VERSION} for ${ARCH}..."
# Build release binaries (tui is a default feature, includes fipstop) # Build release binaries (tui is a default feature, includes fipstop)
if [[ "${NO_BUILD}" -eq 0 ]]; then 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 if [[ -n "${TARGET_TRIPLE}" ]]; then
cargo_args+=(--target "${TARGET_TRIPLE}") cargo_args+=(--target "${TARGET_TRIPLE}")
fi fi

View File

@@ -4,6 +4,7 @@
//! DNS-allocated virtual IPs and kernel nftables NAT. //! DNS-allocated virtual IPs and kernel nftables NAT.
use clap::Parser; use clap::Parser;
#[cfg(target_os = "linux")]
use fips::gateway::{control, dns, nat, net, pool}; use fips::gateway::{control, dns, nat, net, pool};
use fips::version; use fips::version;
use fips::Config; use fips::Config;

View File

@@ -112,8 +112,20 @@ async fn main() {
info!("FIPS running, press Ctrl+C to exit"); 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. // 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! { tokio::select! {
result = node.run_rx_loop() => { result = node.run_rx_loop() => {
match result { match result {
@@ -121,7 +133,7 @@ async fn main() {
Err(e) => error!("RX loop error: {}", e), Err(e) => error!("RX loop error: {}", e),
} }
} }
_ = tokio::signal::ctrl_c() => { _ = shutdown => {
info!("Shutdown signal received"); info!("Shutdown signal received");
} }
} }

View File

@@ -18,6 +18,7 @@
//! nsec: "nsec1..." //! nsec: "nsec1..."
//! ``` //! ```
#[cfg(feature = "gateway")]
mod gateway; mod gateway;
mod node; mod node;
mod peer; mod peer;
@@ -34,6 +35,7 @@ pub use node::{
NodeConfig, RateLimitConfig, RekeyConfig, RetryConfig, SessionConfig, SessionMmpConfig, NodeConfig, RateLimitConfig, RekeyConfig, RetryConfig, SessionConfig, SessionMmpConfig,
TreeConfig, TreeConfig,
}; };
#[cfg(feature = "gateway")]
pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig}; pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig};
pub use peer::{ConnectPolicy, PeerAddress, PeerConfig}; pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
pub use transport::{BleConfig, DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances, TransportsConfig, UdpConfig}; pub use transport::{BleConfig, DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances, TransportsConfig, UdpConfig};
@@ -320,6 +322,7 @@ pub struct Config {
pub peers: Vec<PeerConfig>, pub peers: Vec<PeerConfig>,
/// Gateway configuration (`gateway`). /// Gateway configuration (`gateway`).
#[cfg(feature = "gateway")]
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub gateway: Option<GatewayConfig>, pub gateway: Option<GatewayConfig>,
} }
@@ -441,6 +444,7 @@ impl Config {
self.peers = other.peers; self.peers = other.peers;
} }
// Merge gateway section — higher-priority config replaces entirely // Merge gateway section — higher-priority config replaces entirely
#[cfg(feature = "gateway")]
if other.gateway.is_some() { if other.gateway.is_some() {
self.gateway = other.gateway; self.gateway = other.gateway;
} }

View File

@@ -8,6 +8,7 @@ pub mod bloom;
pub mod cache; pub mod cache;
pub mod config; pub mod config;
pub mod control; pub mod control;
#[cfg(feature = "gateway")]
pub mod gateway; pub mod gateway;
pub mod identity; pub mod identity;
pub mod mmp; pub mod mmp;
@@ -27,7 +28,7 @@ pub use identity::{
}; };
// Re-export config types // 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}; pub use upper::config::{DnsConfig, TunConfig};
// Re-export tree types // Re-export tree types

View File

@@ -587,6 +587,22 @@ impl Node {
info!("effective MTU: {} bytes", effective_mtu); info!("effective MTU: {} bytes", effective_mtu);
debug!(" max TCP MSS: {} bytes", max_mss); 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) // Create writer (dups the fd for independent write access)
let (writer, tun_tx) = device.create_writer(max_mss)?; let (writer, tun_tx) = device.create_writer(max_mss)?;
@@ -604,6 +620,11 @@ impl Node {
// Spawn reader thread // Spawn reader thread
let transport_mtu = self.transport_mtu(); 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 || { let reader_handle = thread::spawn(move || {
run_tun_reader(device, mtu, our_addr, reader_tun_tx, outbound_tx, transport_mtu); 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_outbound_rx = Some(outbound_rx);
self.tun_reader_handle = Some(reader_handle); self.tun_reader_handle = Some(reader_handle);
self.tun_writer_handle = Some(writer_handle); self.tun_writer_handle = Some(writer_handle);
#[cfg(target_os = "macos")]
{ self.tun_shutdown_fd = Some(shutdown_write_fd); }
} }
Err(e) => { Err(e) => {
self.tun_state = TunState::Failed; self.tun_state = TunState::Failed;
@@ -704,11 +727,21 @@ impl Node {
// Drop the tun_tx to signal the writer to stop // Drop the tun_tx to signal the writer to stop
self.tun_tx.take(); 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 { if let Err(e) = shutdown_tun_interface(&name).await {
warn!(name = %name, error = %e, "Failed to shutdown TUN interface"); 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 // Wait for threads to finish
if let Some(handle) = self.tun_reader_handle.take() { if let Some(handle) = self.tun_reader_handle.take() {
let _ = handle.join(); let _ = handle.join();

View File

@@ -33,7 +33,6 @@ use crate::transport::{
use crate::transport::udp::UdpTransport; use crate::transport::udp::UdpTransport;
use crate::transport::tcp::TcpTransport; use crate::transport::tcp::TcpTransport;
use crate::transport::tor::TorTransport; use crate::transport::tor::TorTransport;
#[cfg(target_os = "linux")]
use crate::transport::ethernet::EthernetTransport; use crate::transport::ethernet::EthernetTransport;
use crate::tree::TreeState; use crate::tree::TreeState;
use crate::upper::hosts::HostMap; use crate::upper::hosts::HostMap;
@@ -364,6 +363,10 @@ pub struct Node {
tun_reader_handle: Option<JoinHandle<()>>, tun_reader_handle: Option<JoinHandle<()>>,
/// TUN writer thread handle. /// TUN writer thread handle.
tun_writer_handle: Option<JoinHandle<()>>, tun_writer_handle: Option<JoinHandle<()>>,
/// Shutdown pipe: writing to this fd unblocks the TUN reader thread on macOS.
/// On Linux, deleting the interface via netlink serves the same purpose.
#[cfg(target_os = "macos")]
tun_shutdown_fd: Option<std::os::unix::io::RawFd>,
// === DNS Responder === // === DNS Responder ===
/// Receiver for resolved identities from the DNS responder. /// Receiver for resolved identities from the DNS responder.
@@ -530,6 +533,8 @@ impl Node {
tun_outbound_rx: None, tun_outbound_rx: None,
tun_reader_handle: None, tun_reader_handle: None,
tun_writer_handle: None, tun_writer_handle: None,
#[cfg(target_os = "macos")]
tun_shutdown_fd: None,
dns_identity_rx: None, dns_identity_rx: None,
dns_task: None, dns_task: None,
index_allocator: IndexAllocator::new(), index_allocator: IndexAllocator::new(),
@@ -640,6 +645,8 @@ impl Node {
tun_outbound_rx: None, tun_outbound_rx: None,
tun_reader_handle: None, tun_reader_handle: None,
tun_writer_handle: None, tun_writer_handle: None,
#[cfg(target_os = "macos")]
tun_shutdown_fd: None,
dns_identity_rx: None, dns_identity_rx: None,
dns_task: None, dns_task: None,
index_allocator: IndexAllocator::new(), index_allocator: IndexAllocator::new(),
@@ -695,23 +702,19 @@ impl Node {
} }
// Create Ethernet transport instances // Create Ethernet transport instances
#[cfg(target_os = "linux")] let eth_instances: Vec<_> = self
{ .config
let eth_instances: Vec<_> = self .transports
.config .ethernet
.transports .iter()
.ethernet .map(|(name, config)| (name.map(|s| s.to_string()), config.clone()))
.iter() .collect();
.map(|(name, config)| (name.map(|s| s.to_string()), config.clone())) let xonly = self.identity.pubkey();
.collect(); for (name, eth_config) in eth_instances {
let transport_id = self.allocate_transport_id();
let xonly = self.identity.pubkey(); let mut eth = EthernetTransport::new(transport_id, name, eth_config, packet_tx.clone());
for (name, eth_config) in eth_instances { eth.set_local_pubkey(xonly);
let transport_id = self.allocate_transport_id(); transports.push(TransportHandle::Ethernet(eth));
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 // 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| { let mac = crate::transport::ethernet::parse_mac_string(mac_str).map_err(|e| {
NodeError::NoTransportForType(format!("invalid MAC in '{}': {}", addr_str, 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))) Ok((transport_id, TransportAddr::from_bytes(&mac)))
} }

View File

@@ -272,12 +272,20 @@ mod tests {
} }
assert!(!bucket.available()); assert!(!bucket.available());
// Wait for refill // Wait for refill, measuring actual elapsed time to avoid sensitivity
thread::sleep(Duration::from_millis(50)); // Should refill ~5 tokens // 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(); 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] #[test]

View File

@@ -9,7 +9,6 @@ mod bloom;
mod ble; mod ble;
mod disconnect; mod disconnect;
mod discovery; mod discovery;
#[cfg(target_os = "linux")]
mod ethernet; mod ethernet;
mod forwarding; mod forwarding;
mod handshake; mod handshake;

View File

@@ -1,8 +1,9 @@
//! Ethernet Transport Implementation //! Ethernet Transport Implementation
//! //!
//! Provides raw Ethernet transport for FIPS peer communication using //! Provides raw Ethernet transport for FIPS peer communication. On Linux,
//! AF_PACKET sockets with SOCK_DGRAM. Works on wired Ethernet and WiFi //! uses AF_PACKET/SOCK_DGRAM sockets; on macOS, uses BPF devices (`/dev/bpf*`).
//! interfaces (kernel mac80211 abstracts 802.11 transparently). //! Works on wired Ethernet and WiFi interfaces (kernel mac80211 abstracts
//! 802.11 transparently on Linux).
pub mod discovery; pub mod discovery;
pub mod socket; pub mod socket;
@@ -240,16 +241,26 @@ impl EthernetTransport {
return Err(TransportError::NotStarted); return Err(TransportError::NotStarted);
} }
// Abort beacon task // Signal the socket to shut down. On macOS this writes to the
if let Some(task) = self.beacon_task.take() { // shutdown pipe, waking the reader thread's select() immediately.
task.abort(); // On Linux this is a no-op (AsyncFd cancellation handles it).
let _ = task.await; 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() { if let Some(task) = self.recv_task.take() {
task.abort(); task.abort();
let _ = task.await; #[cfg(not(target_os = "macos"))]
{ let _ = task.await; }
} }
// Drop socket // Drop socket

View File

@@ -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 crate::transport::TransportError;
use std::os::unix::io::{AsRawFd, RawFd};
use tokio::io::unix::AsyncFd;
/// Broadcast MAC address. /// Broadcast MAC address.
pub const ETHERNET_BROADCAST: [u8; 6] = [0xff; 6]; pub const ETHERNET_BROADCAST: [u8; 6] = [0xff; 6];
/// Wrapper around an AF_PACKET SOCK_DGRAM file descriptor. // Platform-specific PacketSocket implementation.
/// #[cfg(target_os = "linux")]
/// Owns the fd and closes it on drop. Provides synchronous send/recv #[path = "socket_linux.rs"]
/// methods used by the async wrappers via `AsyncFd`. mod platform;
pub struct PacketSocket {
fd: RawFd, #[cfg(target_os = "macos")]
if_index: i32, #[path = "socket_macos.rs"]
ethertype: u16, 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<PacketSocket>,
}
impl AsyncPacketSocket {
pub fn new(socket: PacketSocket) -> Result<Self, TransportError> {
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<usize, TransportError> {
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>, [u8; 6]);
pub struct AsyncPacketSocket {
inner: Arc<PacketSocket>,
rx: tokio::sync::Mutex<tokio::sync::mpsc::Receiver<Frame>>,
reader_thread: Option<std::thread::JoinHandle<()>>,
}
impl AsyncPacketSocket {
pub fn new(socket: PacketSocket) -> Result<Self, TransportError> {
// Channel capacity: buffer up to 1024 frames to decouple
// the blocking reader from the async consumer.
let (tx, rx) = tokio::sync::mpsc::channel::<Frame>(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<usize, TransportError> {
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 { impl PacketSocket {
/// Create and bind an AF_PACKET SOCK_DGRAM socket. /// Wrap this socket in an async wrapper for tokio integration.
///
/// Returns an error with a clear message if CAP_NET_RAW is missing.
pub fn open(interface: &str, ethertype: u16) -> Result<Self, TransportError> {
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 <binary>)"
.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::<libc::sockaddr_ll>() 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<u16, TransportError> {
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::<libc::c_int>() 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::<libc::c_int>() 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<usize> {
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::<libc::sockaddr_ll>() 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::<libc::sockaddr_ll>() 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.
pub fn into_async(self) -> Result<AsyncPacketSocket, TransportError> { pub fn into_async(self) -> Result<AsyncPacketSocket, TransportError> {
let async_fd = AsyncFd::new(self) AsyncPacketSocket::new(self)
.map_err(|e| TransportError::StartFailed(format!("AsyncFd::new failed: {}", e)))?;
Ok(AsyncPacketSocket { inner: async_fd })
} }
} }
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<PacketSocket>,
}
impl AsyncPacketSocket {
/// Send a payload to a destination MAC address.
pub async fn send_to(&self, data: &[u8], dest_mac: &[u8; 6]) -> Result<usize, TransportError> {
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<i32, TransportError> {
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<u16, TransportError> {
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)
}

View File

@@ -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<Self, TransportError> {
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 <binary>)"
.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::<libc::sockaddr_ll>() 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<u16, TransportError> {
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::<libc::c_int>() 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::<libc::c_int>() 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<usize> {
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::<libc::sockaddr_ll>() 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::<libc::sockaddr_ll>() 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<i32, TransportError> {
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<u16, TransportError> {
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)
}

View File

@@ -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<u8>,
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<BpfReadState>,
/// 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<Self, TransportError> {
// 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<u16, TransportError> {
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<usize> {
// 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<std::io::Result<(usize, [u8; 6])>> {
const BPF_HDR_SIZE: usize = std::mem::size_of::<BpfHeader>();
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 `<net/bpf.h>`).
///
/// 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::<BpfHeader>() == 20);
/// BPF word alignment (round up to next 4-byte boundary).
fn bpf_wordalign(x: usize) -> usize {
(x + 3) & !3
}
/// BPF ioctl constants (from <net/bpf.h>).
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<RawFd, TransportError> {
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<usize, TransportError> {
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<i32, TransportError> {
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<u8> {
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::<BpfHeader>() as u16,
_pad: 0,
};
let hdr_size = std::mem::size_of::<BpfHeader>();
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::<BpfHeader>();
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<u16, TransportError> {
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)
}

View File

@@ -8,9 +8,9 @@ pub mod udp;
pub mod tcp; pub mod tcp;
pub mod tor; pub mod tor;
#[cfg(target_os = "linux")]
pub mod ethernet; pub mod ethernet;
#[cfg(target_os = "linux")]
pub mod ble; pub mod ble;
use secp256k1::XOnlyPublicKey; use secp256k1::XOnlyPublicKey;
@@ -18,7 +18,6 @@ use udp::UdpTransport;
use tcp::TcpTransport; use tcp::TcpTransport;
use tor::control::TorMonitoringInfo; use tor::control::TorMonitoringInfo;
use tor::TorTransport; use tor::TorTransport;
#[cfg(target_os = "linux")]
use ethernet::EthernetTransport; use ethernet::EthernetTransport;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use ble::DefaultBleTransport; use ble::DefaultBleTransport;
@@ -852,7 +851,6 @@ pub enum TransportHandle {
/// UDP/IP transport. /// UDP/IP transport.
Udp(UdpTransport), Udp(UdpTransport),
/// Raw Ethernet transport. /// Raw Ethernet transport.
#[cfg(target_os = "linux")]
Ethernet(EthernetTransport), Ethernet(EthernetTransport),
/// TCP/IP transport. /// TCP/IP transport.
Tcp(TcpTransport), Tcp(TcpTransport),
@@ -868,7 +866,6 @@ impl TransportHandle {
pub async fn start(&mut self) -> Result<(), TransportError> { pub async fn start(&mut self) -> Result<(), TransportError> {
match self { match self {
TransportHandle::Udp(t) => t.start_async().await, TransportHandle::Udp(t) => t.start_async().await,
#[cfg(target_os = "linux")]
TransportHandle::Ethernet(t) => t.start_async().await, TransportHandle::Ethernet(t) => t.start_async().await,
TransportHandle::Tcp(t) => t.start_async().await, TransportHandle::Tcp(t) => t.start_async().await,
TransportHandle::Tor(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> { pub async fn stop(&mut self) -> Result<(), TransportError> {
match self { match self {
TransportHandle::Udp(t) => t.stop_async().await, TransportHandle::Udp(t) => t.stop_async().await,
#[cfg(target_os = "linux")]
TransportHandle::Ethernet(t) => t.stop_async().await, TransportHandle::Ethernet(t) => t.stop_async().await,
TransportHandle::Tcp(t) => t.stop_async().await, TransportHandle::Tcp(t) => t.stop_async().await,
TransportHandle::Tor(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<usize, TransportError> { pub async fn send(&self, addr: &TransportAddr, data: &[u8]) -> Result<usize, TransportError> {
match self { match self {
TransportHandle::Udp(t) => t.send_async(addr, data).await, TransportHandle::Udp(t) => t.send_async(addr, data).await,
#[cfg(target_os = "linux")]
TransportHandle::Ethernet(t) => t.send_async(addr, data).await, TransportHandle::Ethernet(t) => t.send_async(addr, data).await,
TransportHandle::Tcp(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, TransportHandle::Tor(t) => t.send_async(addr, data).await,
@@ -907,7 +902,6 @@ impl TransportHandle {
pub fn transport_id(&self) -> TransportId { pub fn transport_id(&self) -> TransportId {
match self { match self {
TransportHandle::Udp(t) => t.transport_id(), TransportHandle::Udp(t) => t.transport_id(),
#[cfg(target_os = "linux")]
TransportHandle::Ethernet(t) => t.transport_id(), TransportHandle::Ethernet(t) => t.transport_id(),
TransportHandle::Tcp(t) => t.transport_id(), TransportHandle::Tcp(t) => t.transport_id(),
TransportHandle::Tor(t) => t.transport_id(), TransportHandle::Tor(t) => t.transport_id(),
@@ -920,7 +914,6 @@ impl TransportHandle {
pub fn name(&self) -> Option<&str> { pub fn name(&self) -> Option<&str> {
match self { match self {
TransportHandle::Udp(t) => t.name(), TransportHandle::Udp(t) => t.name(),
#[cfg(target_os = "linux")]
TransportHandle::Ethernet(t) => t.name(), TransportHandle::Ethernet(t) => t.name(),
TransportHandle::Tcp(t) => t.name(), TransportHandle::Tcp(t) => t.name(),
TransportHandle::Tor(t) => t.name(), TransportHandle::Tor(t) => t.name(),
@@ -933,7 +926,6 @@ impl TransportHandle {
pub fn transport_type(&self) -> &TransportType { pub fn transport_type(&self) -> &TransportType {
match self { match self {
TransportHandle::Udp(t) => t.transport_type(), TransportHandle::Udp(t) => t.transport_type(),
#[cfg(target_os = "linux")]
TransportHandle::Ethernet(t) => t.transport_type(), TransportHandle::Ethernet(t) => t.transport_type(),
TransportHandle::Tcp(t) => t.transport_type(), TransportHandle::Tcp(t) => t.transport_type(),
TransportHandle::Tor(t) => t.transport_type(), TransportHandle::Tor(t) => t.transport_type(),
@@ -946,7 +938,6 @@ impl TransportHandle {
pub fn state(&self) -> TransportState { pub fn state(&self) -> TransportState {
match self { match self {
TransportHandle::Udp(t) => t.state(), TransportHandle::Udp(t) => t.state(),
#[cfg(target_os = "linux")]
TransportHandle::Ethernet(t) => t.state(), TransportHandle::Ethernet(t) => t.state(),
TransportHandle::Tcp(t) => t.state(), TransportHandle::Tcp(t) => t.state(),
TransportHandle::Tor(t) => t.state(), TransportHandle::Tor(t) => t.state(),
@@ -959,7 +950,6 @@ impl TransportHandle {
pub fn mtu(&self) -> u16 { pub fn mtu(&self) -> u16 {
match self { match self {
TransportHandle::Udp(t) => t.mtu(), TransportHandle::Udp(t) => t.mtu(),
#[cfg(target_os = "linux")]
TransportHandle::Ethernet(t) => t.mtu(), TransportHandle::Ethernet(t) => t.mtu(),
TransportHandle::Tcp(t) => t.mtu(), TransportHandle::Tcp(t) => t.mtu(),
TransportHandle::Tor(t) => t.mtu(), TransportHandle::Tor(t) => t.mtu(),
@@ -975,7 +965,6 @@ impl TransportHandle {
pub fn link_mtu(&self, addr: &TransportAddr) -> u16 { pub fn link_mtu(&self, addr: &TransportAddr) -> u16 {
match self { match self {
TransportHandle::Udp(t) => t.link_mtu(addr), TransportHandle::Udp(t) => t.link_mtu(addr),
#[cfg(target_os = "linux")]
TransportHandle::Ethernet(t) => t.link_mtu(addr), TransportHandle::Ethernet(t) => t.link_mtu(addr),
TransportHandle::Tcp(t) => t.link_mtu(addr), TransportHandle::Tcp(t) => t.link_mtu(addr),
TransportHandle::Tor(t) => t.link_mtu(addr), TransportHandle::Tor(t) => t.link_mtu(addr),
@@ -988,7 +977,6 @@ impl TransportHandle {
pub fn local_addr(&self) -> Option<std::net::SocketAddr> { pub fn local_addr(&self) -> Option<std::net::SocketAddr> {
match self { match self {
TransportHandle::Udp(t) => t.local_addr(), TransportHandle::Udp(t) => t.local_addr(),
#[cfg(target_os = "linux")]
TransportHandle::Ethernet(_) => None, TransportHandle::Ethernet(_) => None,
TransportHandle::Tcp(t) => t.local_addr(), TransportHandle::Tcp(t) => t.local_addr(),
TransportHandle::Tor(_) => None, TransportHandle::Tor(_) => None,
@@ -1001,7 +989,6 @@ impl TransportHandle {
pub fn interface_name(&self) -> Option<&str> { pub fn interface_name(&self) -> Option<&str> {
match self { match self {
TransportHandle::Udp(_) => None, TransportHandle::Udp(_) => None,
#[cfg(target_os = "linux")]
TransportHandle::Ethernet(t) => Some(t.interface_name()), TransportHandle::Ethernet(t) => Some(t.interface_name()),
TransportHandle::Tcp(_) => None, TransportHandle::Tcp(_) => None,
TransportHandle::Tor(_) => None, TransportHandle::Tor(_) => None,
@@ -1038,7 +1025,6 @@ impl TransportHandle {
pub fn discover(&self) -> Result<Vec<DiscoveredPeer>, TransportError> { pub fn discover(&self) -> Result<Vec<DiscoveredPeer>, TransportError> {
match self { match self {
TransportHandle::Udp(t) => t.discover(), TransportHandle::Udp(t) => t.discover(),
#[cfg(target_os = "linux")]
TransportHandle::Ethernet(t) => t.discover(), TransportHandle::Ethernet(t) => t.discover(),
TransportHandle::Tcp(t) => t.discover(), TransportHandle::Tcp(t) => t.discover(),
TransportHandle::Tor(t) => t.discover(), TransportHandle::Tor(t) => t.discover(),
@@ -1051,7 +1037,6 @@ impl TransportHandle {
pub fn auto_connect(&self) -> bool { pub fn auto_connect(&self) -> bool {
match self { match self {
TransportHandle::Udp(t) => t.auto_connect(), TransportHandle::Udp(t) => t.auto_connect(),
#[cfg(target_os = "linux")]
TransportHandle::Ethernet(t) => t.auto_connect(), TransportHandle::Ethernet(t) => t.auto_connect(),
TransportHandle::Tcp(t) => t.auto_connect(), TransportHandle::Tcp(t) => t.auto_connect(),
TransportHandle::Tor(t) => t.auto_connect(), TransportHandle::Tor(t) => t.auto_connect(),
@@ -1064,7 +1049,6 @@ impl TransportHandle {
pub fn accept_connections(&self) -> bool { pub fn accept_connections(&self) -> bool {
match self { match self {
TransportHandle::Udp(t) => t.accept_connections(), TransportHandle::Udp(t) => t.accept_connections(),
#[cfg(target_os = "linux")]
TransportHandle::Ethernet(t) => t.accept_connections(), TransportHandle::Ethernet(t) => t.accept_connections(),
TransportHandle::Tcp(t) => t.accept_connections(), TransportHandle::Tcp(t) => t.accept_connections(),
TransportHandle::Tor(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> { pub async fn connect(&self, addr: &TransportAddr) -> Result<(), TransportError> {
match self { match self {
TransportHandle::Udp(_) => Ok(()), // connectionless TransportHandle::Udp(_) => Ok(()), // connectionless
#[cfg(target_os = "linux")]
TransportHandle::Ethernet(_) => Ok(()), // connectionless TransportHandle::Ethernet(_) => Ok(()), // connectionless
TransportHandle::Tcp(t) => t.connect_async(addr).await, TransportHandle::Tcp(t) => t.connect_async(addr).await,
TransportHandle::Tor(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 { pub fn connection_state(&self, addr: &TransportAddr) -> ConnectionState {
match self { match self {
TransportHandle::Udp(_) => ConnectionState::Connected, TransportHandle::Udp(_) => ConnectionState::Connected,
#[cfg(target_os = "linux")]
TransportHandle::Ethernet(_) => ConnectionState::Connected, TransportHandle::Ethernet(_) => ConnectionState::Connected,
TransportHandle::Tcp(t) => t.connection_state_sync(addr), TransportHandle::Tcp(t) => t.connection_state_sync(addr),
TransportHandle::Tor(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) { pub async fn close_connection(&self, addr: &TransportAddr) {
match self { match self {
TransportHandle::Udp(t) => t.close_connection(addr), TransportHandle::Udp(t) => t.close_connection(addr),
#[cfg(target_os = "linux")]
TransportHandle::Ethernet(t) => t.close_connection(addr), TransportHandle::Ethernet(t) => t.close_connection(addr),
TransportHandle::Tcp(t) => t.close_connection_async(addr).await, TransportHandle::Tcp(t) => t.close_connection_async(addr).await,
TransportHandle::Tor(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 { pub fn congestion(&self) -> TransportCongestion {
match self { match self {
TransportHandle::Udp(t) => t.congestion(), TransportHandle::Udp(t) => t.congestion(),
#[cfg(target_os = "linux")]
TransportHandle::Ethernet(_) => TransportCongestion::default(), TransportHandle::Ethernet(_) => TransportCongestion::default(),
TransportHandle::Tcp(_) => TransportCongestion::default(), TransportHandle::Tcp(_) => TransportCongestion::default(),
TransportHandle::Tor(_) => TransportCongestion::default(), TransportHandle::Tor(_) => TransportCongestion::default(),
@@ -1155,7 +1135,6 @@ impl TransportHandle {
TransportHandle::Udp(t) => { TransportHandle::Udp(t) => {
serde_json::to_value(t.stats().snapshot()).unwrap_or_default() serde_json::to_value(t.stats().snapshot()).unwrap_or_default()
} }
#[cfg(target_os = "linux")]
TransportHandle::Ethernet(t) => { TransportHandle::Ethernet(t) => {
let snap = t.stats().snapshot(); let snap = t.stats().snapshot();
serde_json::json!({ serde_json::json!({

View File

@@ -152,7 +152,10 @@ impl UdpRawSocket {
// Control message buffer sized for SO_RXQ_OVFL (u32). // Control message buffer sized for SO_RXQ_OVFL (u32).
// CMSG_SPACE computes the aligned size including header. // CMSG_SPACE computes the aligned size including header.
#[cfg(target_os = "linux")]
const CMSG_BUF_SIZE: usize = unsafe { libc::CMSG_SPACE(4) } as usize; 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 cmsg_buf = [0u8; CMSG_BUF_SIZE];
let mut src_addr: libc::sockaddr_storage = unsafe { std::mem::zeroed() }; 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_name = &mut src_addr as *mut _ as *mut libc::c_void;
msg.msg_namelen = std::mem::size_of::<libc::sockaddr_storage>() as libc::socklen_t; msg.msg_namelen = std::mem::size_of::<libc::sockaddr_storage>() as libc::socklen_t;
msg.msg_iov = &mut iov; 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_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void;
msg.msg_controllen = cmsg_buf.len() as _; msg.msg_controllen = cmsg_buf.len() as _;
@@ -173,7 +176,10 @@ impl UdpRawSocket {
let addr = sockaddr_to_socket_addr(&src_addr)?; let addr = sockaddr_to_socket_addr(&src_addr)?;
// Walk cmsg chain for SO_RXQ_OVFL drop counter // Walk cmsg chain for SO_RXQ_OVFL drop counter
#[cfg(target_os = "linux")]
let mut drops: u32 = 0; let mut drops: u32 = 0;
#[cfg(not(target_os = "linux"))]
let drops: u32 = 0;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
unsafe { unsafe {
let mut cmsg = libc::CMSG_FIRSTHDR(&msg); let mut cmsg = libc::CMSG_FIRSTHDR(&msg);

View File

@@ -5,10 +5,10 @@
//! allowing standard socket applications to communicate over the mesh. //! allowing standard socket applications to communicate over the mesh.
use crate::{FipsAddress, TunConfig}; use crate::{FipsAddress, TunConfig};
use futures::TryStreamExt;
use rtnetlink::{new_connection, Handle, LinkUnspec, RouteMessageBuilder};
use std::fs::File; 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::net::Ipv6Addr;
use std::os::unix::io::{AsRawFd, FromRawFd}; use std::os::unix::io::{AsRawFd, FromRawFd};
use std::sync::mpsc; use std::sync::mpsc;
@@ -33,6 +33,7 @@ pub enum TunError {
#[error("failed to configure TUN device: {0}")] #[error("failed to configure TUN device: {0}")]
Configure(String), Configure(String),
#[cfg(target_os = "linux")]
#[error("netlink error: {0}")] #[error("netlink error: {0}")]
Netlink(#[from] rtnetlink::Error), Netlink(#[from] rtnetlink::Error),
@@ -87,7 +88,7 @@ impl TunDevice {
/// This requires CAP_NET_ADMIN capability (run with sudo or setcap). /// This requires CAP_NET_ADMIN capability (run with sudo or setcap).
pub async fn create(config: &TunConfig, address: FipsAddress) -> Result<Self, TunError> { pub async fn create(config: &TunConfig, address: FipsAddress) -> Result<Self, TunError> {
// Check if IPv6 is enabled // Check if IPv6 is enabled
if is_ipv6_disabled() { if platform::is_ipv6_disabled() {
return Err(TunError::Ipv6Disabled); return Err(TunError::Ipv6Disabled);
} }
@@ -95,9 +96,9 @@ impl TunDevice {
let mtu = config.mtu(); let mtu = config.mtu();
// Delete existing interface if present (TUN devices are exclusive) // 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"); 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"); debug!(name, error = %e, "Failed to delete existing interface");
} }
} }
@@ -105,17 +106,32 @@ impl TunDevice {
// Create the TUN device // Create the TUN device
let mut tun_config = tun::Configuration::default(); 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)] #[allow(deprecated)]
tun_config.name(name).layer(Layer::L3).mtu(mtu); 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)?; let device = tun::create(&tun_config)?;
// Configure address and bring up via netlink // Read the actual device name (on macOS this is the kernel-assigned utun* name)
configure_interface(name, address.to_ipv6(), mtu).await?; 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 { Ok(Self {
device, device,
name: name.to_string(), name: actual_name,
mtu, mtu,
address, address,
}) })
@@ -148,10 +164,17 @@ impl TunDevice {
/// Read a packet from the TUN device. /// 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). /// The buffer should be at least MTU + header size (typically 1500+ bytes).
pub fn read_packet(&mut self, buf: &mut [u8]) -> Result<usize, TunError> { ///
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<usize, std::io::Error> {
self.device.read(buf)
} }
/// Shutdown and delete the TUN device. /// Shutdown and delete the TUN device.
@@ -159,7 +182,7 @@ impl TunDevice {
/// This deletes the interface entirely. /// This deletes the interface entirely.
pub async fn shutdown(&self) -> Result<(), TunError> { pub async fn shutdown(&self) -> Result<(), TunError> {
debug!(name = %self.name, "Deleting TUN device"); debug!(name = %self.name, "Deleting TUN device");
delete_interface(&self.name).await platform::delete_interface(&self.name).await
} }
/// Create a TunWriter for this device. /// Create a TunWriter for this device.
@@ -214,6 +237,7 @@ impl TunWriter {
/// ///
/// Blocks forever, reading packets from the channel and writing them /// Blocks forever, reading packets from the channel and writing them
/// to the TUN device. Returns when the channel is closed (all senders dropped). /// 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) { pub fn run(mut self) {
use super::tcp_mss::clamp_tcp_mss; 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 // "Bad address" is expected during shutdown when interface is deleted
let err_str = e.to_string(); let err_str = e.to_string();
if err_str.contains("Bad address") { 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 /// 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 /// (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. /// 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 /// The loop exits when the TUN interface is deleted (EFAULT) or an unrecoverable
/// error occurs. /// error occurs.
#[cfg(not(target_os = "macos"))]
pub fn run_tun_reader( pub fn run_tun_reader(
mut device: TunDevice, mut device: TunDevice,
mtu: u16, mtu: u16,
@@ -263,13 +324,128 @@ pub fn run_tun_reader(
outbound_tx: TunOutboundTx, outbound_tx: TunOutboundTx,
transport_mtu: u16, transport_mtu: u16,
) { ) {
use super::icmp::{build_dest_unreachable, effective_ipv6_mtu, should_send_icmp_error, DestUnreachableCode}; let (name, mut buf, max_mss) = tun_reader_setup(&device, mtu, transport_mtu);
use super::tcp_mss::clamp_tcp_mss;
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<u8>, u16) {
use super::icmp::effective_ipv6_mtu;
let name = device.name().to_string(); 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 IPV6_HEADER: u16 = 40;
const TCP_HEADER: u16 = 20; const TCP_HEADER: u16 = 20;
let effective_mtu = effective_ipv6_mtu(transport_mtu); let effective_mtu = effective_ipv6_mtu(transport_mtu);
@@ -286,65 +462,52 @@ pub fn run_tun_reader(
"TUN reader starting" "TUN reader starting"
); );
loop { (name, buf, max_mss)
match device.read_packet(&mut buf) { }
Ok(n) if n > 0 => {
let packet = &mut buf[..n];
log_ipv6_packet(packet);
// Must be a valid IPv6 packet /// Process a single TUN packet. Returns `false` if the reader should exit.
if packet.len() < 40 || packet[0] >> 4 != 6 { fn handle_tun_packet(
continue; 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) log_ipv6_packet(packet);
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"
);
}
// Forward to Node for session encapsulation and routing // Must be a valid IPv6 packet
if outbound_tx.blocking_send(packet.to_vec()).is_err() { if packet.len() < 40 || packet[0] >> 4 != 6 {
break; // Channel closed, shutdown return true;
} }
} else {
// Non-FIPS destination: send ICMPv6 Destination Unreachable // Check if destination is a FIPS address (fd::/8 prefix)
if should_send_icmp_error(packet) if packet[24] == crate::identity::FIPS_ADDRESS_PREFIX {
&& let Some(response) = build_dest_unreachable( if clamp_tcp_mss(packet, max_mss) {
packet, trace!(name = %name, max_mss = max_mss, "Clamped TCP MSS in SYN packet");
DestUnreachableCode::NoRoute, }
our_addr.to_ipv6(), if outbound_tx.blocking_send(packet.to_vec()).is_err() {
) return false; // Channel closed, shutdown
{ }
trace!( } else {
name = %name, // Non-FIPS destination: send ICMPv6 Destination Unreachable
len = response.len(), if should_send_icmp_error(packet)
"Sending ICMPv6 Destination Unreachable (non-FIPS destination)" && let Some(response) = build_dest_unreachable(
); packet,
if tun_tx.send(response).is_err() { DestUnreachableCode::NoRoute,
break; our_addr.to_ipv6(),
} )
} {
} trace!(name = %name, len = response.len(), "Sending ICMPv6 Destination Unreachable (non-FIPS destination)");
} if tun_tx.send(response).is_err() {
Ok(_) => { return false;
// 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;
} }
} }
} }
true
} }
/// Log basic information about an IPv6 packet at TRACE level. /// 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. /// has been moved to another thread.
pub async fn shutdown_tun_interface(name: &str) -> Result<(), TunError> { pub async fn shutdown_tun_interface(name: &str) -> Result<(), TunError> {
debug!("Shutting down TUN interface {}", name); debug!("Shutting down TUN interface {}", name);
delete_interface(name).await?; platform::delete_interface(name).await?;
debug!("TUN interface {} stopped", name); debug!("TUN interface {} stopped", name);
Ok(()) 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 { // Platform-specific TUN configuration
let Ok((connection, handle, _)) = new_connection() else { // =============================================================================
return false;
};
tokio::spawn(connection);
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. /// Check if IPv6 is disabled system-wide.
async fn delete_interface(name: &str) -> Result<(), TunError> { pub fn is_ipv6_disabled() -> bool {
let (connection, handle, _) = new_connection() std::fs::read_to_string("/proc/sys/net/ipv6/conf/all/disable_ipv6")
.map_err(|e| TunError::Configure(format!("netlink connection failed: {}", e)))?; .map(|s| s.trim() == "1")
tokio::spawn(connection); .unwrap_or(false)
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::<Ipv6Addr>::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(()) /// 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. get_interface_index(&handle, name).await.is_ok()
async fn get_interface_index(handle: &Handle, name: &str) -> Result<u32, TunError> { }
let mut links = handle.link().get().match_name(name.to_string()).execute();
if let Some(link) = links.try_next().await? { /// Delete a network interface by name.
Ok(link.header.index) pub async fn delete_interface(name: &str) -> Result<(), TunError> {
} else { let (connection, handle, _) = new_connection()
Err(TunError::InterfaceNotFound(name.to_string())) .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::<Ipv6Addr>::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<u32, TunError> {
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. #[cfg(target_os = "macos")]
fn is_ipv6_disabled() -> bool { mod platform {
std::fs::read_to_string("/proc/sys/net/ipv6/conf/all/disable_ipv6") use super::TunError;
.map(|s| s.trim() == "1") use std::net::Ipv6Addr;
.unwrap_or(false) 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)] #[cfg(test)]

View File

@@ -24,7 +24,7 @@ start_dnsmasq() {
start_services() { start_services() {
/usr/sbin/sshd /usr/sbin/sshd
iperf3 -s -D 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 ────────────────────────────────────── # ── Chaos: TCP ECN + ethernet wait ──────────────────────────────────────

View File

@@ -340,14 +340,14 @@ Each container runs the following services alongside FIPS:
| ------- | ---- | --------------------------------------------- | | ------- | ---- | --------------------------------------------- |
| SSH | 22 | Root login with no password (test only) | | SSH | 22 | Root login with no password (test only) |
| iperf3 | 5201 | Bandwidth testing server (`-s -D`) | | 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 All services bind to IPv6 (`::`) and are accessible over the FIPS overlay
using `<npub>.fips` hostnames: using `<npub>.fips` hostnames:
```bash ```bash
# HTTP over FIPS # 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 # SSH over FIPS
docker exec fips-node-b ssh $NPUB_A.fips docker exec fips-node-b ssh $NPUB_A.fips