mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-22 07:48:26 +00:00
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:
51
.github/workflows/ci.yml
vendored
51
.github/workflows/ci.yml
vendored
@@ -23,8 +23,7 @@ env:
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Job 1 – Build matrix
|
||||
#
|
||||
# Builds on Linux x86_64 and Linux aarch64. macOS and Windows are in a
|
||||
# separate ci-compat.yml workflow so their failures don't mark this run red.
|
||||
# Builds on Linux x86_64, Linux aarch64, and macOS.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
jobs:
|
||||
build:
|
||||
@@ -37,6 +36,7 @@ jobs:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
- os: ubuntu-24.04-arm
|
||||
- os: macos-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -44,7 +44,8 @@ jobs:
|
||||
- name: Set SOURCE_DATE_EPOCH from git
|
||||
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install system dependencies
|
||||
- name: Install system dependencies (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev
|
||||
|
||||
- name: Install Rust toolchain
|
||||
@@ -62,11 +63,16 @@ jobs:
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release
|
||||
run: cargo build --release ${{ runner.os == 'macOS' && '--no-default-features --features tui' || '--features gateway' }}
|
||||
|
||||
- name: SHA-256 hashes
|
||||
- name: SHA-256 hashes (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: sha256sum target/release/fips target/release/fipsctl target/release/fipstop target/release/fips-gateway
|
||||
|
||||
- name: SHA-256 hashes (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: shasum -a 256 target/release/fips target/release/fipsctl target/release/fipstop
|
||||
|
||||
# Upload the Linux binary so integration jobs can use it without rebuilding
|
||||
- name: Upload Linux binary
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
@@ -117,7 +123,7 @@ jobs:
|
||||
uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Run unit tests
|
||||
run: cargo nextest run --all --profile ci
|
||||
run: cargo nextest run --all --profile ci --features gateway
|
||||
|
||||
- name: Publish test report (Checks tab)
|
||||
uses: dorny/test-reporter@v2
|
||||
@@ -136,6 +142,39 @@ jobs:
|
||||
check_name: Unit Tests Summary
|
||||
fail_on_failure: false
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Job 2b – Unit tests (macOS)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
test-macos:
|
||||
name: Unit tests (macOS)
|
||||
runs-on: macos-latest
|
||||
needs: [build]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set SOURCE_DATE_EPOCH from git
|
||||
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Cargo registry + build
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Install cargo-nextest
|
||||
uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Run unit tests
|
||||
run: cargo nextest run --all --profile ci --no-default-features --features tui
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Job 3 – Integration tests (static mesh + chaos simulation)
|
||||
#
|
||||
|
||||
7
.github/workflows/package-linux.yml
vendored
7
.github/workflows/package-linux.yml
vendored
@@ -1,8 +1,13 @@
|
||||
name: Linux Package
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- maint
|
||||
- next
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -85,7 +90,7 @@ jobs:
|
||||
run: cargo install cargo-deb --version 3.6.3 --locked
|
||||
|
||||
- name: Build release binaries
|
||||
run: cargo build --release
|
||||
run: cargo build --release --features gateway
|
||||
|
||||
- name: Build systemd tarball
|
||||
env:
|
||||
|
||||
175
.github/workflows/package-macos.yml
vendored
Normal file
175
.github/workflows/package-macos.yml
vendored
Normal 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}"
|
||||
7
.github/workflows/package-openwrt.yml
vendored
7
.github/workflows/package-openwrt.yml
vendored
@@ -1,6 +1,13 @@
|
||||
name: OpenWrt Package
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- maint
|
||||
- next
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
arch:
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -11,8 +11,11 @@
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Claude Code
|
||||
# AI Agents
|
||||
.claude/
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
agents/
|
||||
|
||||
deploy/
|
||||
vps.env
|
||||
@@ -28,4 +31,5 @@ sim-results/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
*.egg
|
||||
*.egg
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ readme = "README.md"
|
||||
default = ["tui", "ble"]
|
||||
tui = ["dep:ratatui"]
|
||||
ble = ["dep:bluer"]
|
||||
gateway = ["dep:rustables"]
|
||||
|
||||
[dependencies]
|
||||
ratatui = { version = "0.30", optional = true }
|
||||
@@ -32,14 +33,17 @@ tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tun = { version = "0.8.5", features = ["async"] }
|
||||
libc = "0.2"
|
||||
rtnetlink = "0.20.0"
|
||||
tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net", "time", "process"] }
|
||||
futures = "0.3"
|
||||
simple-dns = "0.11.2"
|
||||
socket2 = { version = "0.6.2", features = ["all"] }
|
||||
tokio-socks = "0.5"
|
||||
|
||||
rustables = { version = "0.8.7", optional = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
rtnetlink = "0.20.0"
|
||||
bluer = { version = "0.17", features = ["bluetoothd", "l2cap"], optional = true }
|
||||
rustables = "0.8.7"
|
||||
|
||||
[package.metadata.deb]
|
||||
maintainer = "Johnathan Corgan <jcorgan@corganlabs.com>"
|
||||
@@ -82,6 +86,7 @@ path = "src/bin/fipsctl.rs"
|
||||
[[bin]]
|
||||
name = "fips-gateway"
|
||||
path = "src/bin/fips-gateway.rs"
|
||||
required-features = ["gateway"]
|
||||
|
||||
[[bin]]
|
||||
name = "fipstop"
|
||||
|
||||
70
README.md
70
README.md
@@ -63,11 +63,22 @@ cd fips
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Requires Rust 1.85+ (edition 2024) and Linux with TUN support.
|
||||
Requires Rust 1.85+ (edition 2024) and a Unix-like OS with TUN support
|
||||
(Linux or macOS).
|
||||
|
||||
The BLE transport (enabled by default) requires BlueZ and libdbus. On
|
||||
Debian/Ubuntu: `sudo apt install bluez libdbus-1-dev`. To build without BLE:
|
||||
`cargo build --release --no-default-features --features tui`.
|
||||
### Transport support by platform
|
||||
|
||||
| Transport | Linux | macOS | Windows |
|
||||
|-----------|:-----:|:-----:|:-------:|
|
||||
| UDP | ✅ | ✅ | ❌ |
|
||||
| TCP | ✅ | ✅ | ❌ |
|
||||
| Ethernet | ✅ | ✅ | ❌ |
|
||||
| Tor | ✅ | ✅ | ❌ |
|
||||
| BLE | ✅ | ❌ | ❌ |
|
||||
|
||||
On **Linux**, the BLE transport requires BlueZ and libdbus. On
|
||||
Debian/Ubuntu: `sudo apt install bluez libdbus-1-dev`. Then build with
|
||||
BLE enabled: `cargo build --release --features ble`.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -113,6 +124,41 @@ sudo ./install.sh
|
||||
See [packaging/systemd/README.install.md](packaging/systemd/README.install.md)
|
||||
for the full installation and configuration guide.
|
||||
|
||||
### macOS (.pkg)
|
||||
|
||||
```bash
|
||||
./packaging/macos/build-pkg.sh
|
||||
sudo installer -pkg deploy/fips-*-macos-*.pkg -target /
|
||||
```
|
||||
|
||||
This installs binaries to `/usr/local/bin/`, config to
|
||||
`/usr/local/etc/fips/`, sets up `.fips` DNS resolution via
|
||||
`/etc/resolver/fips`, and registers a launchd daemon. Edit
|
||||
`/usr/local/etc/fips/fips.yaml` before starting:
|
||||
|
||||
```bash
|
||||
sudo nano /usr/local/etc/fips/fips.yaml
|
||||
sudo launchctl load -w /Library/LaunchDaemons/com.fips.daemon.plist
|
||||
```
|
||||
|
||||
Remove with `sudo packaging/macos/uninstall.sh` (preserves config).
|
||||
|
||||
To restart the node after making configuration changes:
|
||||
|
||||
```bash
|
||||
sudo launchctl unload -w /Library/LaunchDaemons/com.fips.daemon.plist
|
||||
sudo launchctl load -w /Library/LaunchDaemons/com.fips.daemon.plist
|
||||
```
|
||||
|
||||
Check logs for troubleshooting:
|
||||
|
||||
```bash
|
||||
sudo tail -f /usr/local/var/log/fips/fips.log
|
||||
```
|
||||
|
||||
> **Note:** On macOS, the TUN device is named `utun<N>` (kernel-assigned)
|
||||
> rather than `fips0`.
|
||||
|
||||
## Configuration
|
||||
|
||||
The default configuration file is installed at `/etc/fips/fips.yaml`:
|
||||
@@ -175,20 +221,30 @@ for the full reference.
|
||||
### DNS Resolution
|
||||
|
||||
FIPS includes a DNS resolver (enabled by default, port 5354) that maps
|
||||
`.fips` names to fd00::/8 IPv6 addresses. With systemd-resolved:
|
||||
`.fips` names to fd00::/8 IPv6 addresses.
|
||||
|
||||
**Linux** (systemd-resolved):
|
||||
|
||||
```bash
|
||||
sudo resolvectl dns fips0 127.0.0.1:5354
|
||||
sudo resolvectl domain fips0 ~fips
|
||||
```
|
||||
|
||||
**macOS**: DNS is configured automatically by the `.pkg` installer via
|
||||
`/etc/resolver/fips`. No manual setup is needed.
|
||||
|
||||
Then reach any FIPS node by npub with standard IPv6 tools:
|
||||
|
||||
```bash
|
||||
ping6 npub1bbb....fips
|
||||
ssh npub1bbb....fips
|
||||
ssh -6 npub1bbb....fips
|
||||
```
|
||||
|
||||
> **macOS note:** Use `ping6` instead of `ping`. macOS ships separate
|
||||
> `ping` (IPv4-only) and `ping6` (IPv6) binaries; `ping` will not
|
||||
> resolve AAAA records. Similarly, use `curl -6`, `ssh -6`, etc. when
|
||||
> connecting by `.fips` hostname.
|
||||
|
||||
### Monitoring
|
||||
|
||||
Use `fipsctl` to query a running node:
|
||||
@@ -266,7 +322,7 @@ Ethernet, Tor, and Bluetooth (BLE) with a small live mesh of deployed nodes.
|
||||
- UDP, TCP, Ethernet, Tor, and BLE transports (BLE via L2CAP CoC with per-link MTU negotiation)
|
||||
- Runtime inspection and peer management via `fipsctl` and `fipstop`
|
||||
- Reproducible builds with toolchain pinning and SOURCE_DATE_EPOCH
|
||||
- Debian and systemd tarball packaging
|
||||
- Linux (Debian, systemd tarball, OpenWrt, AUR) and macOS packaging
|
||||
- Docker-based integration and chaos testing
|
||||
|
||||
### Near-term priorities
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
# make tarball Build a systemd install tarball
|
||||
# make ipk Build an OpenWrt .ipk package
|
||||
# make aur Build fips-git AUR package and validate with namcap
|
||||
# make pkg Build a macOS .pkg installer
|
||||
# make all Build deb and tarball (default)
|
||||
# make clean Remove deploy/ directory
|
||||
|
||||
@@ -15,7 +16,7 @@ SHELL := /bin/bash
|
||||
PACKAGING_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
PROJECT_ROOT := $(abspath $(PACKAGING_DIR)/..)
|
||||
|
||||
.PHONY: all deb tarball ipk aur clean
|
||||
.PHONY: all deb tarball ipk aur pkg clean
|
||||
|
||||
all: deb tarball
|
||||
|
||||
@@ -31,5 +32,8 @@ ipk:
|
||||
aur:
|
||||
@bash $(PACKAGING_DIR)/aur/build-aur.sh
|
||||
|
||||
pkg:
|
||||
@bash $(PACKAGING_DIR)/macos/build-pkg.sh
|
||||
|
||||
clean:
|
||||
rm -rf $(PROJECT_ROOT)/deploy
|
||||
|
||||
@@ -10,6 +10,7 @@ make deb # Debian/Ubuntu .deb
|
||||
make tarball # systemd install tarball
|
||||
make ipk # OpenWrt .ipk
|
||||
make aur # Arch Linux AUR package (fips-git, local build + namcap)
|
||||
make pkg # macOS .pkg installer
|
||||
make all # deb + tarball (default)
|
||||
```
|
||||
|
||||
@@ -20,6 +21,7 @@ packaging/
|
||||
aur/ Arch Linux AUR packaging (PKGBUILD, supporting files)
|
||||
common/ Shared assets (default config, hosts file)
|
||||
debian/ Debian/Ubuntu .deb packaging via cargo-deb
|
||||
macos/ macOS .pkg installer via pkgbuild
|
||||
systemd/ Generic Linux systemd tarball packaging
|
||||
openwrt/ OpenWrt .ipk packaging via cargo-zigbuild
|
||||
```
|
||||
@@ -80,6 +82,25 @@ bash packaging/openwrt/build-ipk.sh --arch mipsel
|
||||
See [openwrt/README.md](openwrt/README.md) for router-specific
|
||||
installation instructions.
|
||||
|
||||
### macOS (`.pkg`)
|
||||
|
||||
Built with `pkgbuild` (included with Xcode command-line tools). Installs
|
||||
binaries to `/usr/local/bin/`, config to `/usr/local/etc/fips/`, sets up
|
||||
the `/etc/resolver/fips` DNS resolver for `.fips` domains, and loads a
|
||||
launchd daemon. The TUN device is named `utun<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)
|
||||
|
||||
Two AUR packages are maintained: `fips` (release, builds from tagged
|
||||
|
||||
@@ -29,13 +29,13 @@ build() {
|
||||
cd "$pkgname-$pkgver"
|
||||
export CARGO_TARGET_DIR=target
|
||||
export SOURCE_DATE_EPOCH=$(stat -c %Y Cargo.toml)
|
||||
cargo build --frozen --release
|
||||
cargo build --frozen --release --features gateway
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "$pkgname-$pkgver"
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo test --frozen --lib
|
||||
cargo test --frozen --lib --features gateway
|
||||
}
|
||||
|
||||
package() {
|
||||
|
||||
@@ -66,7 +66,7 @@ transports:
|
||||
|
||||
peers: []
|
||||
# Static peers for bootstrapping (UDP or TCP):
|
||||
# - npub: "npub1..."
|
||||
# - npub: "npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98"
|
||||
# alias: "gateway"
|
||||
# addresses:
|
||||
# - transport: udp
|
||||
|
||||
@@ -71,7 +71,7 @@ echo "Building .deb package..."
|
||||
OUTPUT_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "${OUTPUT_DIR}"' EXIT
|
||||
|
||||
cargo_args=(deb --output "${OUTPUT_DIR}")
|
||||
cargo_args=(deb --output "${OUTPUT_DIR}" --features gateway)
|
||||
if [[ -n "${TARGET_TRIPLE}" ]]; then
|
||||
cargo_args+=(--target "${TARGET_TRIPLE}")
|
||||
fi
|
||||
|
||||
195
packaging/macos/build-pkg.sh
Executable file
195
packaging/macos/build-pkg.sh
Executable 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"
|
||||
33
packaging/macos/com.fips.daemon.plist
Normal file
33
packaging/macos/com.fips.daemon.plist
Normal 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
42
packaging/macos/uninstall.sh
Executable 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)"
|
||||
@@ -82,6 +82,7 @@ define Build/Compile
|
||||
cargo build \
|
||||
--release \
|
||||
--target $(RUST_TARGET) \
|
||||
--features gateway \
|
||||
--bin fips \
|
||||
--bin fipsctl \
|
||||
--bin fipstop \
|
||||
|
||||
@@ -110,7 +110,7 @@ cargo zigbuild \
|
||||
--release \
|
||||
--target "$RUST_TARGET" \
|
||||
--no-default-features \
|
||||
--features tui \
|
||||
--features tui,gateway \
|
||||
--bin fips \
|
||||
--bin fipsctl \
|
||||
--bin fipstop \
|
||||
|
||||
@@ -86,7 +86,7 @@ echo "Building FIPS v${VERSION} for ${ARCH}..."
|
||||
|
||||
# Build release binaries (tui is a default feature, includes fipstop)
|
||||
if [[ "${NO_BUILD}" -eq 0 ]]; then
|
||||
cargo_args=(build --release --manifest-path="${PROJECT_ROOT}/Cargo.toml")
|
||||
cargo_args=(build --release --manifest-path="${PROJECT_ROOT}/Cargo.toml" --features gateway)
|
||||
if [[ -n "${TARGET_TRIPLE}" ]]; then
|
||||
cargo_args+=(--target "${TARGET_TRIPLE}")
|
||||
fi
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! DNS-allocated virtual IPs and kernel nftables NAT.
|
||||
|
||||
use clap::Parser;
|
||||
#[cfg(target_os = "linux")]
|
||||
use fips::gateway::{control, dns, nat, net, pool};
|
||||
use fips::version;
|
||||
use fips::Config;
|
||||
|
||||
@@ -112,8 +112,20 @@ async fn main() {
|
||||
|
||||
info!("FIPS running, press Ctrl+C to exit");
|
||||
|
||||
// Run the RX event loop until shutdown signal.
|
||||
// Run the RX event loop until shutdown signal (SIGINT or SIGTERM).
|
||||
// stop() drops the packet channel, causing run_rx_loop to exit.
|
||||
#[cfg(unix)]
|
||||
let shutdown = async {
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
let mut sigterm = signal(SignalKind::terminate()).expect("failed to register SIGTERM handler");
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => {},
|
||||
_ = sigterm.recv() => {},
|
||||
}
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let shutdown = tokio::signal::ctrl_c();
|
||||
|
||||
tokio::select! {
|
||||
result = node.run_rx_loop() => {
|
||||
match result {
|
||||
@@ -121,7 +133,7 @@ async fn main() {
|
||||
Err(e) => error!("RX loop error: {}", e),
|
||||
}
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
_ = shutdown => {
|
||||
info!("Shutdown signal received");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
//! nsec: "nsec1..."
|
||||
//! ```
|
||||
|
||||
#[cfg(feature = "gateway")]
|
||||
mod gateway;
|
||||
mod node;
|
||||
mod peer;
|
||||
@@ -34,6 +35,7 @@ pub use node::{
|
||||
NodeConfig, RateLimitConfig, RekeyConfig, RetryConfig, SessionConfig, SessionMmpConfig,
|
||||
TreeConfig,
|
||||
};
|
||||
#[cfg(feature = "gateway")]
|
||||
pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig};
|
||||
pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
|
||||
pub use transport::{BleConfig, DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances, TransportsConfig, UdpConfig};
|
||||
@@ -320,6 +322,7 @@ pub struct Config {
|
||||
pub peers: Vec<PeerConfig>,
|
||||
|
||||
/// Gateway configuration (`gateway`).
|
||||
#[cfg(feature = "gateway")]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gateway: Option<GatewayConfig>,
|
||||
}
|
||||
@@ -441,6 +444,7 @@ impl Config {
|
||||
self.peers = other.peers;
|
||||
}
|
||||
// Merge gateway section — higher-priority config replaces entirely
|
||||
#[cfg(feature = "gateway")]
|
||||
if other.gateway.is_some() {
|
||||
self.gateway = other.gateway;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod bloom;
|
||||
pub mod cache;
|
||||
pub mod config;
|
||||
pub mod control;
|
||||
#[cfg(feature = "gateway")]
|
||||
pub mod gateway;
|
||||
pub mod identity;
|
||||
pub mod mmp;
|
||||
@@ -27,7 +28,7 @@ pub use identity::{
|
||||
};
|
||||
|
||||
// Re-export config types
|
||||
pub use config::{Config, ConfigError, GatewayConfig, IdentityConfig, TorConfig, UdpConfig};
|
||||
pub use config::{Config, ConfigError, IdentityConfig, TorConfig, UdpConfig};
|
||||
pub use upper::config::{DnsConfig, TunConfig};
|
||||
|
||||
// Re-export tree types
|
||||
|
||||
@@ -587,6 +587,22 @@ impl Node {
|
||||
info!("effective MTU: {} bytes", effective_mtu);
|
||||
debug!(" max TCP MSS: {} bytes", max_mss);
|
||||
|
||||
// On macOS, create a shutdown pipe. Writing to it unblocks the
|
||||
// reader thread's select() loop without closing the TUN fd
|
||||
// (which would cause a double-close when TunDevice drops).
|
||||
#[cfg(target_os = "macos")]
|
||||
let (shutdown_read_fd, shutdown_write_fd) = {
|
||||
let mut fds = [0i32; 2];
|
||||
if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 {
|
||||
return Err(NodeError::Tun(
|
||||
crate::upper::tun::TunError::Configure(
|
||||
"failed to create shutdown pipe".into(),
|
||||
),
|
||||
));
|
||||
}
|
||||
(fds[0], fds[1])
|
||||
};
|
||||
|
||||
// Create writer (dups the fd for independent write access)
|
||||
let (writer, tun_tx) = device.create_writer(max_mss)?;
|
||||
|
||||
@@ -604,6 +620,11 @@ impl Node {
|
||||
|
||||
// Spawn reader thread
|
||||
let transport_mtu = self.transport_mtu();
|
||||
#[cfg(target_os = "macos")]
|
||||
let reader_handle = thread::spawn(move || {
|
||||
run_tun_reader(device, mtu, our_addr, reader_tun_tx, outbound_tx, transport_mtu, shutdown_read_fd);
|
||||
});
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let reader_handle = thread::spawn(move || {
|
||||
run_tun_reader(device, mtu, our_addr, reader_tun_tx, outbound_tx, transport_mtu);
|
||||
});
|
||||
@@ -614,6 +635,8 @@ impl Node {
|
||||
self.tun_outbound_rx = Some(outbound_rx);
|
||||
self.tun_reader_handle = Some(reader_handle);
|
||||
self.tun_writer_handle = Some(writer_handle);
|
||||
#[cfg(target_os = "macos")]
|
||||
{ self.tun_shutdown_fd = Some(shutdown_write_fd); }
|
||||
}
|
||||
Err(e) => {
|
||||
self.tun_state = TunState::Failed;
|
||||
@@ -704,11 +727,21 @@ impl Node {
|
||||
// Drop the tun_tx to signal the writer to stop
|
||||
self.tun_tx.take();
|
||||
|
||||
// Delete the interface (causes reader to get EFAULT)
|
||||
// Delete the interface (on Linux, causes reader to get EFAULT)
|
||||
if let Err(e) = shutdown_tun_interface(&name).await {
|
||||
warn!(name = %name, error = %e, "Failed to shutdown TUN interface");
|
||||
}
|
||||
|
||||
// On macOS, signal the reader thread to exit by writing to the
|
||||
// shutdown pipe. The reader's select() will wake up and break.
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(fd) = self.tun_shutdown_fd.take() {
|
||||
unsafe {
|
||||
libc::write(fd, b"x".as_ptr() as *const libc::c_void, 1);
|
||||
libc::close(fd);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for threads to finish
|
||||
if let Some(handle) = self.tun_reader_handle.take() {
|
||||
let _ = handle.join();
|
||||
|
||||
@@ -33,7 +33,6 @@ use crate::transport::{
|
||||
use crate::transport::udp::UdpTransport;
|
||||
use crate::transport::tcp::TcpTransport;
|
||||
use crate::transport::tor::TorTransport;
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::transport::ethernet::EthernetTransport;
|
||||
use crate::tree::TreeState;
|
||||
use crate::upper::hosts::HostMap;
|
||||
@@ -364,6 +363,10 @@ pub struct Node {
|
||||
tun_reader_handle: Option<JoinHandle<()>>,
|
||||
/// TUN writer thread handle.
|
||||
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 ===
|
||||
/// Receiver for resolved identities from the DNS responder.
|
||||
@@ -530,6 +533,8 @@ impl Node {
|
||||
tun_outbound_rx: None,
|
||||
tun_reader_handle: None,
|
||||
tun_writer_handle: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
tun_shutdown_fd: None,
|
||||
dns_identity_rx: None,
|
||||
dns_task: None,
|
||||
index_allocator: IndexAllocator::new(),
|
||||
@@ -640,6 +645,8 @@ impl Node {
|
||||
tun_outbound_rx: None,
|
||||
tun_reader_handle: None,
|
||||
tun_writer_handle: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
tun_shutdown_fd: None,
|
||||
dns_identity_rx: None,
|
||||
dns_task: None,
|
||||
index_allocator: IndexAllocator::new(),
|
||||
@@ -695,23 +702,19 @@ impl Node {
|
||||
}
|
||||
|
||||
// Create Ethernet transport instances
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let eth_instances: Vec<_> = self
|
||||
.config
|
||||
.transports
|
||||
.ethernet
|
||||
.iter()
|
||||
.map(|(name, config)| (name.map(|s| s.to_string()), config.clone()))
|
||||
.collect();
|
||||
|
||||
let xonly = self.identity.pubkey();
|
||||
for (name, eth_config) in eth_instances {
|
||||
let transport_id = self.allocate_transport_id();
|
||||
let mut eth = EthernetTransport::new(transport_id, name, eth_config, packet_tx.clone());
|
||||
eth.set_local_pubkey(xonly);
|
||||
transports.push(TransportHandle::Ethernet(eth));
|
||||
}
|
||||
let eth_instances: Vec<_> = self
|
||||
.config
|
||||
.transports
|
||||
.ethernet
|
||||
.iter()
|
||||
.map(|(name, config)| (name.map(|s| s.to_string()), config.clone()))
|
||||
.collect();
|
||||
let xonly = self.identity.pubkey();
|
||||
for (name, eth_config) in eth_instances {
|
||||
let transport_id = self.allocate_transport_id();
|
||||
let mut eth = EthernetTransport::new(transport_id, name, eth_config, packet_tx.clone());
|
||||
eth.set_local_pubkey(xonly);
|
||||
transports.push(TransportHandle::Ethernet(eth));
|
||||
}
|
||||
|
||||
// Create TCP transport instances
|
||||
@@ -831,18 +834,9 @@ impl Node {
|
||||
))
|
||||
})?;
|
||||
|
||||
// Parse the MAC address
|
||||
#[cfg(target_os = "linux")]
|
||||
let mac = crate::transport::ethernet::parse_mac_string(mac_str).map_err(|e| {
|
||||
NodeError::NoTransportForType(format!("invalid MAC in '{}': {}", addr_str, e))
|
||||
})?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let mac: [u8; 6] = {
|
||||
let _ = mac_str;
|
||||
return Err(NodeError::NoTransportForType(
|
||||
"Ethernet transport not available on this platform".into(),
|
||||
));
|
||||
};
|
||||
|
||||
Ok((transport_id, TransportAddr::from_bytes(&mac)))
|
||||
}
|
||||
|
||||
@@ -272,12 +272,20 @@ mod tests {
|
||||
}
|
||||
assert!(!bucket.available());
|
||||
|
||||
// Wait for refill
|
||||
thread::sleep(Duration::from_millis(50)); // Should refill ~5 tokens
|
||||
// Wait for refill, measuring actual elapsed time to avoid sensitivity
|
||||
// to OS scheduler variance (sleep can overshoot by a large margin).
|
||||
let before = Instant::now();
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
let elapsed_secs = before.elapsed().as_secs_f64();
|
||||
|
||||
// Expected tokens = elapsed * rate, capped at capacity.
|
||||
// Allow ±20% tolerance around the actual elapsed time.
|
||||
let expected = (elapsed_secs * 100.0).min(10.0);
|
||||
let lo = (expected * 0.8).min(expected - 0.5).max(0.0);
|
||||
let hi = (expected * 1.2).max(expected + 0.5).min(10.0);
|
||||
|
||||
// Should have tokens now
|
||||
let tokens = bucket.tokens();
|
||||
assert!((4.0..=6.0).contains(&tokens), "tokens: {}", tokens);
|
||||
assert!((lo..=hi).contains(&tokens), "tokens: {}, expected ~{:.2} (range {:.2}..={:.2})", tokens, expected, lo, hi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9,7 +9,6 @@ mod bloom;
|
||||
mod ble;
|
||||
mod disconnect;
|
||||
mod discovery;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod ethernet;
|
||||
mod forwarding;
|
||||
mod handshake;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Ethernet Transport Implementation
|
||||
//!
|
||||
//! Provides raw Ethernet transport for FIPS peer communication using
|
||||
//! AF_PACKET sockets with SOCK_DGRAM. Works on wired Ethernet and WiFi
|
||||
//! interfaces (kernel mac80211 abstracts 802.11 transparently).
|
||||
//! Provides raw Ethernet transport for FIPS peer communication. On Linux,
|
||||
//! uses AF_PACKET/SOCK_DGRAM sockets; on macOS, uses BPF devices (`/dev/bpf*`).
|
||||
//! Works on wired Ethernet and WiFi interfaces (kernel mac80211 abstracts
|
||||
//! 802.11 transparently on Linux).
|
||||
|
||||
pub mod discovery;
|
||||
pub mod socket;
|
||||
@@ -240,16 +241,26 @@ impl EthernetTransport {
|
||||
return Err(TransportError::NotStarted);
|
||||
}
|
||||
|
||||
// Abort beacon task
|
||||
if let Some(task) = self.beacon_task.take() {
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
// Signal the socket to shut down. On macOS this writes to the
|
||||
// shutdown pipe, waking the reader thread's select() immediately.
|
||||
// On Linux this is a no-op (AsyncFd cancellation handles it).
|
||||
if let Some(ref socket) = self.socket {
|
||||
socket.shutdown();
|
||||
}
|
||||
|
||||
// Abort receive task
|
||||
// Abort tasks. On Linux, safe to await since all I/O is
|
||||
// AsyncFd-based and cancellation-safe. On macOS, do NOT await —
|
||||
// on a current_thread runtime the aborted task can't be polled
|
||||
// while we're blocked on the JoinHandle, causing a deadlock.
|
||||
if let Some(task) = self.beacon_task.take() {
|
||||
task.abort();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{ let _ = task.await; }
|
||||
}
|
||||
if let Some(task) = self.recv_task.take() {
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{ let _ = task.await; }
|
||||
}
|
||||
|
||||
// Drop socket
|
||||
|
||||
@@ -1,396 +1,267 @@
|
||||
//! AF_PACKET socket creation, binding, and ioctl helpers.
|
||||
//! Raw Ethernet socket abstraction.
|
||||
//!
|
||||
//! Platform-specific implementations live in `socket_linux.rs` (AF_PACKET)
|
||||
//! and `socket_macos.rs` (BPF). This module re-exports `PacketSocket` and
|
||||
//! provides `AsyncPacketSocket`.
|
||||
|
||||
use crate::transport::TransportError;
|
||||
use std::os::unix::io::{AsRawFd, RawFd};
|
||||
use tokio::io::unix::AsyncFd;
|
||||
|
||||
/// Broadcast MAC address.
|
||||
pub const ETHERNET_BROADCAST: [u8; 6] = [0xff; 6];
|
||||
|
||||
/// Wrapper around an AF_PACKET SOCK_DGRAM file descriptor.
|
||||
///
|
||||
/// Owns the fd and closes it on drop. Provides synchronous send/recv
|
||||
/// methods used by the async wrappers via `AsyncFd`.
|
||||
pub struct PacketSocket {
|
||||
fd: RawFd,
|
||||
if_index: i32,
|
||||
ethertype: u16,
|
||||
// Platform-specific PacketSocket implementation.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "socket_linux.rs"]
|
||||
mod platform;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[path = "socket_macos.rs"]
|
||||
mod platform;
|
||||
|
||||
pub use platform::PacketSocket;
|
||||
|
||||
// =============================================================================
|
||||
// Linux: AsyncFd-based async wrapper
|
||||
// =============================================================================
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod async_impl {
|
||||
use super::PacketSocket;
|
||||
use crate::transport::TransportError;
|
||||
use tokio::io::unix::AsyncFd;
|
||||
|
||||
pub struct AsyncPacketSocket {
|
||||
inner: AsyncFd<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 {
|
||||
/// 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))
|
||||
}
|
||||
|
||||
/// Wrap this socket in a tokio AsyncFd for async I/O.
|
||||
/// Wrap this socket in an async wrapper for tokio integration.
|
||||
pub fn into_async(self) -> Result<AsyncPacketSocket, TransportError> {
|
||||
let async_fd = AsyncFd::new(self)
|
||||
.map_err(|e| TransportError::StartFailed(format!("AsyncFd::new failed: {}", e)))?;
|
||||
Ok(AsyncPacketSocket { inner: async_fd })
|
||||
AsyncPacketSocket::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRawFd for PacketSocket {
|
||||
fn as_raw_fd(&self) -> RawFd {
|
||||
self.fd
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PacketSocket {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
libc::close(self.fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Async wrapper around PacketSocket using tokio's AsyncFd.
|
||||
pub struct AsyncPacketSocket {
|
||||
inner: AsyncFd<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)
|
||||
}
|
||||
|
||||
336
src/transport/ethernet/socket_linux.rs
Normal file
336
src/transport/ethernet/socket_linux.rs
Normal 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)
|
||||
}
|
||||
823
src/transport/ethernet/socket_macos.rs
Normal file
823
src/transport/ethernet/socket_macos.rs
Normal 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)
|
||||
}
|
||||
@@ -8,9 +8,9 @@ pub mod udp;
|
||||
pub mod tcp;
|
||||
pub mod tor;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod ethernet;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod ble;
|
||||
|
||||
use secp256k1::XOnlyPublicKey;
|
||||
@@ -18,7 +18,6 @@ use udp::UdpTransport;
|
||||
use tcp::TcpTransport;
|
||||
use tor::control::TorMonitoringInfo;
|
||||
use tor::TorTransport;
|
||||
#[cfg(target_os = "linux")]
|
||||
use ethernet::EthernetTransport;
|
||||
#[cfg(target_os = "linux")]
|
||||
use ble::DefaultBleTransport;
|
||||
@@ -852,7 +851,6 @@ pub enum TransportHandle {
|
||||
/// UDP/IP transport.
|
||||
Udp(UdpTransport),
|
||||
/// Raw Ethernet transport.
|
||||
#[cfg(target_os = "linux")]
|
||||
Ethernet(EthernetTransport),
|
||||
/// TCP/IP transport.
|
||||
Tcp(TcpTransport),
|
||||
@@ -868,7 +866,6 @@ impl TransportHandle {
|
||||
pub async fn start(&mut self) -> Result<(), TransportError> {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.start_async().await,
|
||||
#[cfg(target_os = "linux")]
|
||||
TransportHandle::Ethernet(t) => t.start_async().await,
|
||||
TransportHandle::Tcp(t) => t.start_async().await,
|
||||
TransportHandle::Tor(t) => t.start_async().await,
|
||||
@@ -881,7 +878,6 @@ impl TransportHandle {
|
||||
pub async fn stop(&mut self) -> Result<(), TransportError> {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.stop_async().await,
|
||||
#[cfg(target_os = "linux")]
|
||||
TransportHandle::Ethernet(t) => t.stop_async().await,
|
||||
TransportHandle::Tcp(t) => t.stop_async().await,
|
||||
TransportHandle::Tor(t) => t.stop_async().await,
|
||||
@@ -894,7 +890,6 @@ impl TransportHandle {
|
||||
pub async fn send(&self, addr: &TransportAddr, data: &[u8]) -> Result<usize, TransportError> {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.send_async(addr, data).await,
|
||||
#[cfg(target_os = "linux")]
|
||||
TransportHandle::Ethernet(t) => t.send_async(addr, data).await,
|
||||
TransportHandle::Tcp(t) => t.send_async(addr, data).await,
|
||||
TransportHandle::Tor(t) => t.send_async(addr, data).await,
|
||||
@@ -907,7 +902,6 @@ impl TransportHandle {
|
||||
pub fn transport_id(&self) -> TransportId {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.transport_id(),
|
||||
#[cfg(target_os = "linux")]
|
||||
TransportHandle::Ethernet(t) => t.transport_id(),
|
||||
TransportHandle::Tcp(t) => t.transport_id(),
|
||||
TransportHandle::Tor(t) => t.transport_id(),
|
||||
@@ -920,7 +914,6 @@ impl TransportHandle {
|
||||
pub fn name(&self) -> Option<&str> {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.name(),
|
||||
#[cfg(target_os = "linux")]
|
||||
TransportHandle::Ethernet(t) => t.name(),
|
||||
TransportHandle::Tcp(t) => t.name(),
|
||||
TransportHandle::Tor(t) => t.name(),
|
||||
@@ -933,7 +926,6 @@ impl TransportHandle {
|
||||
pub fn transport_type(&self) -> &TransportType {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.transport_type(),
|
||||
#[cfg(target_os = "linux")]
|
||||
TransportHandle::Ethernet(t) => t.transport_type(),
|
||||
TransportHandle::Tcp(t) => t.transport_type(),
|
||||
TransportHandle::Tor(t) => t.transport_type(),
|
||||
@@ -946,7 +938,6 @@ impl TransportHandle {
|
||||
pub fn state(&self) -> TransportState {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.state(),
|
||||
#[cfg(target_os = "linux")]
|
||||
TransportHandle::Ethernet(t) => t.state(),
|
||||
TransportHandle::Tcp(t) => t.state(),
|
||||
TransportHandle::Tor(t) => t.state(),
|
||||
@@ -959,7 +950,6 @@ impl TransportHandle {
|
||||
pub fn mtu(&self) -> u16 {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.mtu(),
|
||||
#[cfg(target_os = "linux")]
|
||||
TransportHandle::Ethernet(t) => t.mtu(),
|
||||
TransportHandle::Tcp(t) => t.mtu(),
|
||||
TransportHandle::Tor(t) => t.mtu(),
|
||||
@@ -975,7 +965,6 @@ impl TransportHandle {
|
||||
pub fn link_mtu(&self, addr: &TransportAddr) -> u16 {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.link_mtu(addr),
|
||||
#[cfg(target_os = "linux")]
|
||||
TransportHandle::Ethernet(t) => t.link_mtu(addr),
|
||||
TransportHandle::Tcp(t) => t.link_mtu(addr),
|
||||
TransportHandle::Tor(t) => t.link_mtu(addr),
|
||||
@@ -988,7 +977,6 @@ impl TransportHandle {
|
||||
pub fn local_addr(&self) -> Option<std::net::SocketAddr> {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.local_addr(),
|
||||
#[cfg(target_os = "linux")]
|
||||
TransportHandle::Ethernet(_) => None,
|
||||
TransportHandle::Tcp(t) => t.local_addr(),
|
||||
TransportHandle::Tor(_) => None,
|
||||
@@ -1001,7 +989,6 @@ impl TransportHandle {
|
||||
pub fn interface_name(&self) -> Option<&str> {
|
||||
match self {
|
||||
TransportHandle::Udp(_) => None,
|
||||
#[cfg(target_os = "linux")]
|
||||
TransportHandle::Ethernet(t) => Some(t.interface_name()),
|
||||
TransportHandle::Tcp(_) => None,
|
||||
TransportHandle::Tor(_) => None,
|
||||
@@ -1038,7 +1025,6 @@ impl TransportHandle {
|
||||
pub fn discover(&self) -> Result<Vec<DiscoveredPeer>, TransportError> {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.discover(),
|
||||
#[cfg(target_os = "linux")]
|
||||
TransportHandle::Ethernet(t) => t.discover(),
|
||||
TransportHandle::Tcp(t) => t.discover(),
|
||||
TransportHandle::Tor(t) => t.discover(),
|
||||
@@ -1051,7 +1037,6 @@ impl TransportHandle {
|
||||
pub fn auto_connect(&self) -> bool {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.auto_connect(),
|
||||
#[cfg(target_os = "linux")]
|
||||
TransportHandle::Ethernet(t) => t.auto_connect(),
|
||||
TransportHandle::Tcp(t) => t.auto_connect(),
|
||||
TransportHandle::Tor(t) => t.auto_connect(),
|
||||
@@ -1064,7 +1049,6 @@ impl TransportHandle {
|
||||
pub fn accept_connections(&self) -> bool {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.accept_connections(),
|
||||
#[cfg(target_os = "linux")]
|
||||
TransportHandle::Ethernet(t) => t.accept_connections(),
|
||||
TransportHandle::Tcp(t) => t.accept_connections(),
|
||||
TransportHandle::Tor(t) => t.accept_connections(),
|
||||
@@ -1083,7 +1067,6 @@ impl TransportHandle {
|
||||
pub async fn connect(&self, addr: &TransportAddr) -> Result<(), TransportError> {
|
||||
match self {
|
||||
TransportHandle::Udp(_) => Ok(()), // connectionless
|
||||
#[cfg(target_os = "linux")]
|
||||
TransportHandle::Ethernet(_) => Ok(()), // connectionless
|
||||
TransportHandle::Tcp(t) => t.connect_async(addr).await,
|
||||
TransportHandle::Tor(t) => t.connect_async(addr).await,
|
||||
@@ -1100,7 +1083,6 @@ impl TransportHandle {
|
||||
pub fn connection_state(&self, addr: &TransportAddr) -> ConnectionState {
|
||||
match self {
|
||||
TransportHandle::Udp(_) => ConnectionState::Connected,
|
||||
#[cfg(target_os = "linux")]
|
||||
TransportHandle::Ethernet(_) => ConnectionState::Connected,
|
||||
TransportHandle::Tcp(t) => t.connection_state_sync(addr),
|
||||
TransportHandle::Tor(t) => t.connection_state_sync(addr),
|
||||
@@ -1116,7 +1098,6 @@ impl TransportHandle {
|
||||
pub async fn close_connection(&self, addr: &TransportAddr) {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.close_connection(addr),
|
||||
#[cfg(target_os = "linux")]
|
||||
TransportHandle::Ethernet(t) => t.close_connection(addr),
|
||||
TransportHandle::Tcp(t) => t.close_connection_async(addr).await,
|
||||
TransportHandle::Tor(t) => t.close_connection_async(addr).await,
|
||||
@@ -1138,7 +1119,6 @@ impl TransportHandle {
|
||||
pub fn congestion(&self) -> TransportCongestion {
|
||||
match self {
|
||||
TransportHandle::Udp(t) => t.congestion(),
|
||||
#[cfg(target_os = "linux")]
|
||||
TransportHandle::Ethernet(_) => TransportCongestion::default(),
|
||||
TransportHandle::Tcp(_) => TransportCongestion::default(),
|
||||
TransportHandle::Tor(_) => TransportCongestion::default(),
|
||||
@@ -1155,7 +1135,6 @@ impl TransportHandle {
|
||||
TransportHandle::Udp(t) => {
|
||||
serde_json::to_value(t.stats().snapshot()).unwrap_or_default()
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
TransportHandle::Ethernet(t) => {
|
||||
let snap = t.stats().snapshot();
|
||||
serde_json::json!({
|
||||
|
||||
@@ -152,7 +152,10 @@ impl UdpRawSocket {
|
||||
|
||||
// Control message buffer sized for SO_RXQ_OVFL (u32).
|
||||
// CMSG_SPACE computes the aligned size including header.
|
||||
#[cfg(target_os = "linux")]
|
||||
const CMSG_BUF_SIZE: usize = unsafe { libc::CMSG_SPACE(4) } as usize;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
const CMSG_BUF_SIZE: usize = 64;
|
||||
let mut cmsg_buf = [0u8; CMSG_BUF_SIZE];
|
||||
|
||||
let mut src_addr: libc::sockaddr_storage = unsafe { std::mem::zeroed() };
|
||||
@@ -160,7 +163,7 @@ impl UdpRawSocket {
|
||||
msg.msg_name = &mut src_addr as *mut _ as *mut libc::c_void;
|
||||
msg.msg_namelen = std::mem::size_of::<libc::sockaddr_storage>() as libc::socklen_t;
|
||||
msg.msg_iov = &mut iov;
|
||||
msg.msg_iovlen = 1;
|
||||
msg.msg_iovlen = 1 as _;
|
||||
msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void;
|
||||
msg.msg_controllen = cmsg_buf.len() as _;
|
||||
|
||||
@@ -173,7 +176,10 @@ impl UdpRawSocket {
|
||||
let addr = sockaddr_to_socket_addr(&src_addr)?;
|
||||
|
||||
// Walk cmsg chain for SO_RXQ_OVFL drop counter
|
||||
#[cfg(target_os = "linux")]
|
||||
let mut drops: u32 = 0;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let drops: u32 = 0;
|
||||
#[cfg(target_os = "linux")]
|
||||
unsafe {
|
||||
let mut cmsg = libc::CMSG_FIRSTHDR(&msg);
|
||||
|
||||
563
src/upper/tun.rs
563
src/upper/tun.rs
@@ -5,10 +5,10 @@
|
||||
//! allowing standard socket applications to communicate over the mesh.
|
||||
|
||||
use crate::{FipsAddress, TunConfig};
|
||||
use futures::TryStreamExt;
|
||||
use rtnetlink::{new_connection, Handle, LinkUnspec, RouteMessageBuilder};
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Write};
|
||||
use std::io::Read;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
use std::io::Write;
|
||||
use std::net::Ipv6Addr;
|
||||
use std::os::unix::io::{AsRawFd, FromRawFd};
|
||||
use std::sync::mpsc;
|
||||
@@ -33,6 +33,7 @@ pub enum TunError {
|
||||
#[error("failed to configure TUN device: {0}")]
|
||||
Configure(String),
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[error("netlink error: {0}")]
|
||||
Netlink(#[from] rtnetlink::Error),
|
||||
|
||||
@@ -87,7 +88,7 @@ impl TunDevice {
|
||||
/// This requires CAP_NET_ADMIN capability (run with sudo or setcap).
|
||||
pub async fn create(config: &TunConfig, address: FipsAddress) -> Result<Self, TunError> {
|
||||
// Check if IPv6 is enabled
|
||||
if is_ipv6_disabled() {
|
||||
if platform::is_ipv6_disabled() {
|
||||
return Err(TunError::Ipv6Disabled);
|
||||
}
|
||||
|
||||
@@ -95,9 +96,9 @@ impl TunDevice {
|
||||
let mtu = config.mtu();
|
||||
|
||||
// Delete existing interface if present (TUN devices are exclusive)
|
||||
if interface_exists(name).await {
|
||||
if platform::interface_exists(name).await {
|
||||
debug!(name, "Deleting existing TUN interface");
|
||||
if let Err(e) = delete_interface(name).await {
|
||||
if let Err(e) = platform::delete_interface(name).await {
|
||||
debug!(name, error = %e, "Failed to delete existing interface");
|
||||
}
|
||||
}
|
||||
@@ -105,17 +106,32 @@ impl TunDevice {
|
||||
// Create the TUN device
|
||||
let mut tun_config = tun::Configuration::default();
|
||||
|
||||
// On macOS, utun devices get kernel-assigned names (utun0, utun1, ...),
|
||||
// so we skip setting the name and read it back after creation.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(deprecated)]
|
||||
tun_config.name(name).layer(Layer::L3).mtu(mtu);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
#[allow(deprecated)]
|
||||
tun_config.layer(Layer::L3).mtu(mtu);
|
||||
}
|
||||
|
||||
let device = tun::create(&tun_config)?;
|
||||
|
||||
// Configure address and bring up via netlink
|
||||
configure_interface(name, address.to_ipv6(), mtu).await?;
|
||||
// Read the actual device name (on macOS this is the kernel-assigned utun* name)
|
||||
let actual_name = {
|
||||
use tun::AbstractDevice;
|
||||
device.tun_name().map_err(|e| TunError::Configure(format!("failed to get device name: {}", e)))?
|
||||
};
|
||||
|
||||
// Configure address and bring up via platform-specific method
|
||||
platform::configure_interface(&actual_name, address.to_ipv6(), mtu).await?;
|
||||
|
||||
Ok(Self {
|
||||
device,
|
||||
name: name.to_string(),
|
||||
name: actual_name,
|
||||
mtu,
|
||||
address,
|
||||
})
|
||||
@@ -148,10 +164,17 @@ impl TunDevice {
|
||||
|
||||
/// Read a packet from the TUN device.
|
||||
///
|
||||
/// Returns the number of bytes read into the buffer, or an error.
|
||||
/// Returns the number of bytes read into the buffer, or an `io::Error`.
|
||||
/// The buffer should be at least MTU + header size (typically 1500+ bytes).
|
||||
pub fn read_packet(&mut self, buf: &mut [u8]) -> Result<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.
|
||||
@@ -159,7 +182,7 @@ impl TunDevice {
|
||||
/// This deletes the interface entirely.
|
||||
pub async fn shutdown(&self) -> Result<(), TunError> {
|
||||
debug!(name = %self.name, "Deleting TUN device");
|
||||
delete_interface(&self.name).await
|
||||
platform::delete_interface(&self.name).await
|
||||
}
|
||||
|
||||
/// Create a TunWriter for this device.
|
||||
@@ -214,6 +237,7 @@ impl TunWriter {
|
||||
///
|
||||
/// Blocks forever, reading packets from the channel and writing them
|
||||
/// to the TUN device. Returns when the channel is closed (all senders dropped).
|
||||
#[cfg_attr(target_os = "macos", allow(unused_mut))]
|
||||
pub fn run(mut self) {
|
||||
use super::tcp_mss::clamp_tcp_mss;
|
||||
|
||||
@@ -229,7 +253,43 @@ impl TunWriter {
|
||||
);
|
||||
}
|
||||
|
||||
if let Err(e) = self.file.write_all(&packet) {
|
||||
// On macOS, utun devices require a 4-byte packet information header
|
||||
// prepended to each packet. The tun crate handles this for its own
|
||||
// Read/Write impl, but we use a dup'd fd directly. We use writev
|
||||
// to avoid allocating a buffer on every packet.
|
||||
#[cfg(target_os = "macos")]
|
||||
let write_result = {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
const AF_INET6_HEADER: [u8; 4] = [0, 0, 0, 30];
|
||||
let iov = [
|
||||
libc::iovec {
|
||||
iov_base: AF_INET6_HEADER.as_ptr() as *mut libc::c_void,
|
||||
iov_len: 4,
|
||||
},
|
||||
libc::iovec {
|
||||
iov_base: packet.as_ptr() as *mut libc::c_void,
|
||||
iov_len: packet.len(),
|
||||
},
|
||||
];
|
||||
let ret = unsafe { libc::writev(self.file.as_raw_fd(), iov.as_ptr(), 2) };
|
||||
if ret < 0 {
|
||||
Err(std::io::Error::last_os_error())
|
||||
} else {
|
||||
let expected = 4 + packet.len();
|
||||
if (ret as usize) < expected {
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::WriteZero,
|
||||
format!("short writev: {} of {} bytes", ret, expected),
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let write_result = self.file.write_all(&packet);
|
||||
|
||||
if let Err(e) = write_result {
|
||||
// "Bad address" is expected during shutdown when interface is deleted
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("Bad address") {
|
||||
@@ -243,7 +303,7 @@ impl TunWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/// TUN packet reader loop.
|
||||
/// TUN packet reader loop (Linux).
|
||||
///
|
||||
/// Reads IPv6 packets from the TUN device. Packets destined for FIPS addresses
|
||||
/// (fd::/8) are forwarded to the Node via the outbound channel for session
|
||||
@@ -255,6 +315,7 @@ impl TunWriter {
|
||||
/// This is designed to run in a dedicated thread since TUN reads are blocking.
|
||||
/// The loop exits when the TUN interface is deleted (EFAULT) or an unrecoverable
|
||||
/// error occurs.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn run_tun_reader(
|
||||
mut device: TunDevice,
|
||||
mtu: u16,
|
||||
@@ -263,13 +324,128 @@ pub fn run_tun_reader(
|
||||
outbound_tx: TunOutboundTx,
|
||||
transport_mtu: u16,
|
||||
) {
|
||||
use super::icmp::{build_dest_unreachable, effective_ipv6_mtu, should_send_icmp_error, DestUnreachableCode};
|
||||
use super::tcp_mss::clamp_tcp_mss;
|
||||
let (name, mut buf, max_mss) = tun_reader_setup(&device, mtu, transport_mtu);
|
||||
|
||||
loop {
|
||||
match device.read_packet(&mut buf) {
|
||||
Ok(n) if n > 0 => {
|
||||
if !handle_tun_packet(&mut buf[..n], max_mss, &name, our_addr, &tun_tx, &outbound_tx) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
// EFAULT ("Bad address") is expected during shutdown when the interface is deleted
|
||||
if e.raw_os_error() != Some(libc::EFAULT) {
|
||||
error!(name = %name, error = %e, "TUN read error");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII wrapper that closes a raw fd on drop.
|
||||
///
|
||||
/// Used to ensure the shutdown pipe read-end is always closed when
|
||||
/// `run_tun_reader` returns, regardless of which exit path is taken.
|
||||
#[cfg(target_os = "macos")]
|
||||
struct ShutdownFd(std::os::unix::io::RawFd);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
impl Drop for ShutdownFd {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::close(self.0); }
|
||||
}
|
||||
}
|
||||
|
||||
/// TUN packet reader loop (macOS).
|
||||
///
|
||||
/// Uses `select()` to multiplex between the TUN fd and a shutdown pipe,
|
||||
/// avoiding the need to close the TUN fd externally (which would cause a
|
||||
/// double-close when `TunDevice` drops).
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn run_tun_reader(
|
||||
mut device: TunDevice,
|
||||
mtu: u16,
|
||||
our_addr: FipsAddress,
|
||||
tun_tx: TunTx,
|
||||
outbound_tx: TunOutboundTx,
|
||||
transport_mtu: u16,
|
||||
shutdown_fd: std::os::unix::io::RawFd,
|
||||
) {
|
||||
let _shutdown_fd = ShutdownFd(shutdown_fd);
|
||||
let tun_fd = device.device().as_raw_fd();
|
||||
let (name, mut buf, max_mss) = tun_reader_setup(&device, mtu, transport_mtu);
|
||||
|
||||
// Set TUN fd to non-blocking so we can use select + read without blocking
|
||||
// past the point where select returns readable.
|
||||
unsafe {
|
||||
let flags = libc::fcntl(tun_fd, libc::F_GETFL);
|
||||
if flags >= 0 {
|
||||
libc::fcntl(tun_fd, libc::F_SETFL, flags | libc::O_NONBLOCK);
|
||||
}
|
||||
}
|
||||
|
||||
let nfds = tun_fd.max(shutdown_fd) + 1;
|
||||
|
||||
loop {
|
||||
// Wait for either TUN data or shutdown signal
|
||||
unsafe {
|
||||
let mut read_fds: libc::fd_set = std::mem::zeroed();
|
||||
libc::FD_ZERO(&mut read_fds);
|
||||
libc::FD_SET(tun_fd, &mut read_fds);
|
||||
libc::FD_SET(shutdown_fd, &mut read_fds);
|
||||
|
||||
let ret = libc::select(nfds, &mut read_fds, std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut());
|
||||
if ret < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.kind() == std::io::ErrorKind::Interrupted {
|
||||
continue;
|
||||
}
|
||||
error!(name = %name, error = %err, "TUN select error");
|
||||
break;
|
||||
}
|
||||
|
||||
// Shutdown signal received
|
||||
if libc::FD_ISSET(shutdown_fd, &read_fds) {
|
||||
debug!(name = %name, "TUN reader received shutdown signal");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// TUN fd is readable — drain all available packets
|
||||
loop {
|
||||
match device.read_packet(&mut buf) {
|
||||
Ok(n) if n > 0 => {
|
||||
if !handle_tun_packet(&mut buf[..n], max_mss, &name, our_addr, &tun_tx, &outbound_tx) {
|
||||
return; // _shutdown_fd closes on drop
|
||||
}
|
||||
}
|
||||
Ok(_) => break, // No more data
|
||||
Err(e) => {
|
||||
if e.kind() == std::io::ErrorKind::WouldBlock {
|
||||
break; // Done for this select round
|
||||
}
|
||||
// EBADF is expected during shutdown when the fd is closed
|
||||
if e.raw_os_error() != Some(libc::EBADF) {
|
||||
error!(name = %name, error = %e, "TUN read error");
|
||||
}
|
||||
return; // _shutdown_fd closes on drop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// _shutdown_fd closes on drop
|
||||
}
|
||||
|
||||
/// Common setup for TUN reader: extracts name, allocates buffer, computes max MSS.
|
||||
fn tun_reader_setup(device: &TunDevice, mtu: u16, transport_mtu: u16) -> (String, Vec<u8>, u16) {
|
||||
use super::icmp::effective_ipv6_mtu;
|
||||
|
||||
let name = device.name().to_string();
|
||||
let mut buf = vec![0u8; mtu as usize + 100]; // Extra space for headers
|
||||
let buf = vec![0u8; mtu as usize + 100];
|
||||
|
||||
// Calculate maximum safe TCP MSS from the effective IPv6 MTU
|
||||
const IPV6_HEADER: u16 = 40;
|
||||
const TCP_HEADER: u16 = 20;
|
||||
let effective_mtu = effective_ipv6_mtu(transport_mtu);
|
||||
@@ -286,65 +462,52 @@ pub fn run_tun_reader(
|
||||
"TUN reader starting"
|
||||
);
|
||||
|
||||
loop {
|
||||
match device.read_packet(&mut buf) {
|
||||
Ok(n) if n > 0 => {
|
||||
let packet = &mut buf[..n];
|
||||
log_ipv6_packet(packet);
|
||||
(name, buf, max_mss)
|
||||
}
|
||||
|
||||
// Must be a valid IPv6 packet
|
||||
if packet.len() < 40 || packet[0] >> 4 != 6 {
|
||||
continue;
|
||||
}
|
||||
/// Process a single TUN packet. Returns `false` if the reader should exit.
|
||||
fn handle_tun_packet(
|
||||
packet: &mut [u8],
|
||||
max_mss: u16,
|
||||
name: &str,
|
||||
our_addr: FipsAddress,
|
||||
tun_tx: &TunTx,
|
||||
outbound_tx: &TunOutboundTx,
|
||||
) -> bool {
|
||||
use super::icmp::{build_dest_unreachable, should_send_icmp_error, DestUnreachableCode};
|
||||
use super::tcp_mss::clamp_tcp_mss;
|
||||
|
||||
// Check if destination is a FIPS address (fd::/8 prefix)
|
||||
if packet[24] == crate::identity::FIPS_ADDRESS_PREFIX {
|
||||
// Clamp TCP MSS if this is a SYN packet
|
||||
if clamp_tcp_mss(packet, max_mss) {
|
||||
trace!(
|
||||
name = %name,
|
||||
max_mss = max_mss,
|
||||
"Clamped TCP MSS in SYN packet"
|
||||
);
|
||||
}
|
||||
log_ipv6_packet(packet);
|
||||
|
||||
// Forward to Node for session encapsulation and routing
|
||||
if outbound_tx.blocking_send(packet.to_vec()).is_err() {
|
||||
break; // Channel closed, shutdown
|
||||
}
|
||||
} else {
|
||||
// Non-FIPS destination: send ICMPv6 Destination Unreachable
|
||||
if should_send_icmp_error(packet)
|
||||
&& let Some(response) = build_dest_unreachable(
|
||||
packet,
|
||||
DestUnreachableCode::NoRoute,
|
||||
our_addr.to_ipv6(),
|
||||
)
|
||||
{
|
||||
trace!(
|
||||
name = %name,
|
||||
len = response.len(),
|
||||
"Sending ICMPv6 Destination Unreachable (non-FIPS destination)"
|
||||
);
|
||||
if tun_tx.send(response).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
// Zero-length read, continue
|
||||
}
|
||||
Err(e) => {
|
||||
// "Bad address" (EFAULT) is expected during shutdown when interface is deleted
|
||||
let err_str = format!("{}", e);
|
||||
if !err_str.contains("Bad address") {
|
||||
error!(name = %name, error = %e, "TUN read error");
|
||||
}
|
||||
break;
|
||||
// Must be a valid IPv6 packet
|
||||
if packet.len() < 40 || packet[0] >> 4 != 6 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if destination is a FIPS address (fd::/8 prefix)
|
||||
if packet[24] == crate::identity::FIPS_ADDRESS_PREFIX {
|
||||
if clamp_tcp_mss(packet, max_mss) {
|
||||
trace!(name = %name, max_mss = max_mss, "Clamped TCP MSS in SYN packet");
|
||||
}
|
||||
if outbound_tx.blocking_send(packet.to_vec()).is_err() {
|
||||
return false; // Channel closed, shutdown
|
||||
}
|
||||
} else {
|
||||
// Non-FIPS destination: send ICMPv6 Destination Unreachable
|
||||
if should_send_icmp_error(packet)
|
||||
&& let Some(response) = build_dest_unreachable(
|
||||
packet,
|
||||
DestUnreachableCode::NoRoute,
|
||||
our_addr.to_ipv6(),
|
||||
)
|
||||
{
|
||||
trace!(name = %name, len = response.len(), "Sending ICMPv6 Destination Unreachable (non-FIPS destination)");
|
||||
if tun_tx.send(response).is_err() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Log basic information about an IPv6 packet at TRACE level.
|
||||
@@ -388,7 +551,7 @@ pub fn log_ipv6_packet(packet: &[u8]) {
|
||||
/// has been moved to another thread.
|
||||
pub async fn shutdown_tun_interface(name: &str) -> Result<(), TunError> {
|
||||
debug!("Shutting down TUN interface {}", name);
|
||||
delete_interface(name).await?;
|
||||
platform::delete_interface(name).await?;
|
||||
debug!("TUN interface {} stopped", name);
|
||||
Ok(())
|
||||
}
|
||||
@@ -403,98 +566,186 @@ impl std::fmt::Debug for TunDevice {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a network interface already exists.
|
||||
async fn interface_exists(name: &str) -> bool {
|
||||
let Ok((connection, handle, _)) = new_connection() else {
|
||||
return false;
|
||||
};
|
||||
tokio::spawn(connection);
|
||||
// =============================================================================
|
||||
// Platform-specific TUN configuration
|
||||
// =============================================================================
|
||||
|
||||
get_interface_index(&handle, name).await.is_ok()
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
mod platform {
|
||||
use super::TunError;
|
||||
use futures::TryStreamExt;
|
||||
use rtnetlink::{new_connection, Handle, LinkUnspec, RouteMessageBuilder};
|
||||
use std::net::Ipv6Addr;
|
||||
use tracing::debug;
|
||||
|
||||
/// Delete a network interface by name.
|
||||
async fn delete_interface(name: &str) -> Result<(), TunError> {
|
||||
let (connection, handle, _) = new_connection()
|
||||
.map_err(|e| TunError::Configure(format!("netlink connection failed: {}", e)))?;
|
||||
tokio::spawn(connection);
|
||||
|
||||
let index = get_interface_index(&handle, name).await?;
|
||||
handle.link().del(index).execute().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configure a network interface with an IPv6 address via netlink.
|
||||
async fn configure_interface(name: &str, addr: Ipv6Addr, mtu: u16) -> Result<(), TunError> {
|
||||
let (connection, handle, _) = new_connection()
|
||||
.map_err(|e| TunError::Configure(format!("netlink connection failed: {}", e)))?;
|
||||
tokio::spawn(connection);
|
||||
|
||||
// Get interface index
|
||||
let index = get_interface_index(&handle, name).await?;
|
||||
|
||||
// Add IPv6 address with /128 prefix (point-to-point)
|
||||
handle
|
||||
.address()
|
||||
.add(index, std::net::IpAddr::V6(addr), 128)
|
||||
.execute()
|
||||
.await?;
|
||||
|
||||
// Set MTU
|
||||
handle
|
||||
.link()
|
||||
.change(LinkUnspec::new_with_index(index).mtu(mtu as u32).build())
|
||||
.execute()
|
||||
.await?;
|
||||
|
||||
// Bring interface up
|
||||
handle
|
||||
.link()
|
||||
.change(LinkUnspec::new_with_index(index).up().build())
|
||||
.execute()
|
||||
.await?;
|
||||
|
||||
// Add route for fd00::/8 (FIPS address space) via this interface
|
||||
let fd_prefix: Ipv6Addr = "fd00::".parse().unwrap();
|
||||
let route = RouteMessageBuilder::<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}");
|
||||
/// Check if IPv6 is disabled system-wide.
|
||||
pub fn is_ipv6_disabled() -> bool {
|
||||
std::fs::read_to_string("/proc/sys/net/ipv6/conf/all/disable_ipv6")
|
||||
.map(|s| s.trim() == "1")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
/// Check if a network interface already exists.
|
||||
pub async fn interface_exists(name: &str) -> bool {
|
||||
let Ok((connection, handle, _)) = new_connection() else {
|
||||
return false;
|
||||
};
|
||||
tokio::spawn(connection);
|
||||
|
||||
/// Get the interface index by name.
|
||||
async fn get_interface_index(handle: &Handle, name: &str) -> Result<u32, TunError> {
|
||||
let mut links = handle.link().get().match_name(name.to_string()).execute();
|
||||
get_interface_index(&handle, name).await.is_ok()
|
||||
}
|
||||
|
||||
if let Some(link) = links.try_next().await? {
|
||||
Ok(link.header.index)
|
||||
} else {
|
||||
Err(TunError::InterfaceNotFound(name.to_string()))
|
||||
/// Delete a network interface by name.
|
||||
pub async fn delete_interface(name: &str) -> Result<(), TunError> {
|
||||
let (connection, handle, _) = new_connection()
|
||||
.map_err(|e| TunError::Configure(format!("netlink connection failed: {}", e)))?;
|
||||
tokio::spawn(connection);
|
||||
|
||||
let index = get_interface_index(&handle, name).await?;
|
||||
handle.link().del(index).execute().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configure a network interface with an IPv6 address via netlink.
|
||||
pub async fn configure_interface(name: &str, addr: Ipv6Addr, mtu: u16) -> Result<(), TunError> {
|
||||
let (connection, handle, _) = new_connection()
|
||||
.map_err(|e| TunError::Configure(format!("netlink connection failed: {}", e)))?;
|
||||
tokio::spawn(connection);
|
||||
|
||||
// Get interface index
|
||||
let index = get_interface_index(&handle, name).await?;
|
||||
|
||||
// Add IPv6 address with /128 prefix (point-to-point)
|
||||
handle
|
||||
.address()
|
||||
.add(index, std::net::IpAddr::V6(addr), 128)
|
||||
.execute()
|
||||
.await?;
|
||||
|
||||
// Set MTU
|
||||
handle
|
||||
.link()
|
||||
.change(LinkUnspec::new_with_index(index).mtu(mtu as u32).build())
|
||||
.execute()
|
||||
.await?;
|
||||
|
||||
// Bring interface up
|
||||
handle
|
||||
.link()
|
||||
.change(LinkUnspec::new_with_index(index).up().build())
|
||||
.execute()
|
||||
.await?;
|
||||
|
||||
// Add route for fd00::/8 (FIPS address space) via this interface
|
||||
let fd_prefix: Ipv6Addr = "fd00::".parse().unwrap();
|
||||
let route = RouteMessageBuilder::<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.
|
||||
fn is_ipv6_disabled() -> bool {
|
||||
std::fs::read_to_string("/proc/sys/net/ipv6/conf/all/disable_ipv6")
|
||||
.map(|s| s.trim() == "1")
|
||||
.unwrap_or(false)
|
||||
#[cfg(target_os = "macos")]
|
||||
mod platform {
|
||||
use super::TunError;
|
||||
use std::net::Ipv6Addr;
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Check if IPv6 is disabled system-wide.
|
||||
pub fn is_ipv6_disabled() -> bool {
|
||||
// macOS: check via sysctl; if the key doesn't exist, IPv6 is enabled
|
||||
std::process::Command::new("sysctl")
|
||||
.args(["-n", "net.inet6.ip6.disabled"])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "1")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Check if a network interface already exists.
|
||||
pub async fn interface_exists(name: &str) -> bool {
|
||||
Command::new("ifconfig")
|
||||
.arg(name)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Shut down a network interface by name.
|
||||
///
|
||||
/// On macOS, utun devices are automatically destroyed when the file
|
||||
/// descriptor is closed. Bringing the interface down causes any
|
||||
/// blocking reads to return an error, which unblocks the reader thread.
|
||||
pub async fn delete_interface(name: &str) -> Result<(), TunError> {
|
||||
run_cmd("ifconfig", &[name, "down"]).await
|
||||
}
|
||||
|
||||
/// Configure a network interface with an IPv6 address using ifconfig/route.
|
||||
pub async fn configure_interface(name: &str, addr: Ipv6Addr, mtu: u16) -> Result<(), TunError> {
|
||||
// Add IPv6 address with /128 prefix
|
||||
run_cmd("ifconfig", &[name, "inet6", &addr.to_string(), "prefixlen", "128"]).await?;
|
||||
|
||||
// Set MTU
|
||||
run_cmd("ifconfig", &[name, "mtu", &mtu.to_string()]).await?;
|
||||
|
||||
// Bring interface up
|
||||
run_cmd("ifconfig", &[name, "up"]).await?;
|
||||
|
||||
// Add route for fd00::/8 (FIPS address space) via this interface
|
||||
run_cmd("route", &["add", "-inet6", "-prefixlen", "8", "fd00::", "-interface", name]).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a command and return an error if it fails.
|
||||
async fn run_cmd(program: &str, args: &[&str]) -> Result<(), TunError> {
|
||||
let output = Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| TunError::Configure(format!("{} failed: {}", program, e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(TunError::Configure(format!(
|
||||
"{} {} failed: {}",
|
||||
program,
|
||||
args.join(" "),
|
||||
stderr.trim()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -24,7 +24,7 @@ start_dnsmasq() {
|
||||
start_services() {
|
||||
/usr/sbin/sshd
|
||||
iperf3 -s -D
|
||||
python3 -m http.server 8000 -d /root -b :: &>/dev/null &
|
||||
python3 -m http.server 80 -d /root -b :: &>/dev/null &
|
||||
}
|
||||
|
||||
# ── Chaos: TCP ECN + ethernet wait ──────────────────────────────────────
|
||||
|
||||
@@ -340,14 +340,14 @@ Each container runs the following services alongside FIPS:
|
||||
| ------- | ---- | --------------------------------------------- |
|
||||
| SSH | 22 | Root login with no password (test only) |
|
||||
| iperf3 | 5201 | Bandwidth testing server (`-s -D`) |
|
||||
| HTTP | 8000 | Python HTTP server serving `/root/index.html` |
|
||||
| HTTP | 80 | Python HTTP server serving `/root/index.html` |
|
||||
|
||||
All services bind to IPv6 (`::`) and are accessible over the FIPS overlay
|
||||
using `<npub>.fips` hostnames:
|
||||
|
||||
```bash
|
||||
# HTTP over FIPS
|
||||
docker exec fips-node-b curl http://$NPUB_A.fips:8000/
|
||||
docker exec fips-node-b curl http://$NPUB_A.fips
|
||||
|
||||
# SSH over FIPS
|
||||
docker exec fips-node-b ssh $NPUB_A.fips
|
||||
|
||||
Reference in New Issue
Block a user