Add Windows platform support (#45)

Gate platform-specific code behind cfg attributes and add full Windows
  support: TUN device via wintun, TCP control socket on localhost:21210,
  Windows Service lifecycle (--install-service/--uninstall-service/--service),
  CI build and test matrix, and packaging with ZIP builder and PowerShell
  service management scripts.

  Key changes:

  - Cargo.toml: move tun/libc/rtnetlink behind cfg(unix); add wintun and
    windows-service dependencies for Windows
  - upper/tun.rs: wintun-based TUN implementation with netsh configuration
    for IPv6 address, MTU, and fd00::/8 routing
  - control/mod.rs: split into unix_impl/windows_impl; Windows uses TCP on
    localhost:21210 with shared connection handler
  - bin/fips.rs: refactor main() into run_daemon() accepting a shutdown
    signal; add Windows Service support via windows-service crate
  - transport/udp/socket.rs: platform-gated modules; Windows uses
    tokio::net::UdpSocket (kernel drop count unavailable, returns 0)
  - transport/ethernet: gate to cfg(unix); add Windows stub types
  - config: platform-conditional default paths (socket, hosts) for Windows
  - CI: add windows-latest to build matrix and test-windows job with
    cargo-nextest
  - packaging/windows: build-zip.ps1, install-service.ps1,
    uninstall-service.ps1, and package-windows.yml workflow
  - README/docs: Windows build instructions, service management, and
    control socket platform differences

  Linux and macOS behavior is unchanged.
This commit is contained in:
OceanSlim
2026-04-11 13:31:48 -04:00
committed by GitHub
parent 7494ed058d
commit 774e33fd27
26 changed files with 2505 additions and 626 deletions

View File

@@ -47,13 +47,22 @@ jobs:
- os: ubuntu-latest - os: ubuntu-latest
- os: ubuntu-24.04-arm - os: ubuntu-24.04-arm
- os: macos-latest - os: macos-latest
- os: windows-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set SOURCE_DATE_EPOCH from git - name: Set SOURCE_DATE_EPOCH from git (Unix)
if: runner.os != 'Windows'
run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV" run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV"
- name: Set SOURCE_DATE_EPOCH from git (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$epoch = git log -1 --format=%ct
echo "SOURCE_DATE_EPOCH=$epoch" >> $env:GITHUB_ENV
- name: Install system dependencies (Linux only) - name: Install system dependencies (Linux only)
if: runner.os == 'Linux' if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev
@@ -73,7 +82,7 @@ jobs:
${{ runner.os }}-cargo- ${{ runner.os }}-cargo-
- name: Build - name: Build
run: cargo build --release ${{ runner.os == 'macOS' && '--no-default-features --features tui' || '--features gateway' }} run: cargo build --release ${{ (runner.os == 'macOS' || runner.os == 'Windows') && '--no-default-features --features tui' || '--features gateway' }}
- name: SHA-256 hashes (Linux) - name: SHA-256 hashes (Linux)
if: runner.os == 'Linux' if: runner.os == 'Linux'
@@ -83,6 +92,11 @@ jobs:
if: runner.os == 'macOS' if: runner.os == 'macOS'
run: shasum -a 256 target/release/fips target/release/fipsctl target/release/fipstop run: shasum -a 256 target/release/fips target/release/fipsctl target/release/fipstop
- name: SHA-256 hashes (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: Get-FileHash target\release\fips.exe, target\release\fipsctl.exe, target\release\fipstop.exe -Algorithm SHA256
# Upload the Linux binary so integration jobs can use it without rebuilding # Upload the Linux binary so integration jobs can use it without rebuilding
- name: Upload Linux binary - name: Upload Linux binary
if: matrix.os == 'ubuntu-latest' if: matrix.os == 'ubuntu-latest'
@@ -185,6 +199,35 @@ jobs:
- name: Run unit tests - name: Run unit tests
run: cargo nextest run --all --profile ci --no-default-features --features tui run: cargo nextest run --all --profile ci --no-default-features --features tui
# ─────────────────────────────────────────────────────────────────────────────
# Job 2c Unit tests (Windows)
# ─────────────────────────────────────────────────────────────────────────────
test-windows:
name: Unit tests (Windows)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo registry + build
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Install cargo-nextest
uses: taiki-e/install-action@nextest
- name: Run unit tests
run: cargo nextest run --all --profile ci --no-default-features --features tui
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Job 3 Integration tests (static mesh + chaos simulation) # Job 3 Integration tests (static mesh + chaos simulation)
# #

158
.github/workflows/package-windows.yml vendored Normal file
View File

@@ -0,0 +1,158 @@
name: Windows Package
on:
push:
branches:
- master
- maint
- next
tags:
- "v*"
pull_request:
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
determine-versioning:
runs-on: windows-latest
outputs:
package_version: ${{ steps.version.outputs.package_version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Derive package version
id: version
shell: pwsh
run: |
$cargoToml = Get-Content Cargo.toml -Raw
if ($cargoToml -match 'version\s*=\s*"([^"]+)"') {
$baseVersion = $Matches[1]
} else {
throw "Could not determine version from Cargo.toml"
}
if ($env:GITHUB_REF -like "refs/tags/*") {
$version = $env:GITHUB_REF_NAME -replace '^v', ''
} else {
$branch = $env:GITHUB_REF_NAME -replace '[^A-Za-z0-9]', '.' -replace '\.\.+', '.' -replace '^\.|\.$$', ''
$height = git rev-list --count HEAD
$hash = git rev-parse --short HEAD
if (-not $branch) { $branch = "ref" }
$version = "$baseVersion+$branch.$height.$hash"
}
echo "package_version=$version" >> $env:GITHUB_OUTPUT
build:
name: Build Windows package
runs-on: windows-latest
needs: determine-versioning
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set SOURCE_DATE_EPOCH from git
shell: pwsh
run: |
$epoch = git log -1 --format=%ct
echo "SOURCE_DATE_EPOCH=$epoch" >> $env: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: windows-release-x86_64-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
windows-release-x86_64-
- name: Build release binaries
run: cargo build --release --no-default-features --features tui
- name: Build Windows package
shell: pwsh
run: |
powershell -File packaging/windows/build-zip.ps1 `
-Version "${{ needs.determine-versioning.outputs.package_version }}" `
-NoBuild
- name: SHA-256 hash
shell: pwsh
run: |
Write-Host "==> Windows release asset:"
Get-ChildItem deploy\fips-*-windows-*.zip | ForEach-Object {
Get-FileHash $_.FullName -Algorithm SHA256 | Format-Table -AutoSize
}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: fips_${{ needs.determine-versioning.outputs.package_version }}_x86_64_windows
path: deploy/fips-*-windows-*.zip
retention-days: 30
- name: Build summary
shell: pwsh
run: |
$pkg = Get-ChildItem deploy\fips-*-windows-*.zip | Select-Object -First 1
Write-Host "Build Summary for Windows/x86_64:"
Write-Host " Package: $($pkg.Name)"
Write-Host " Size: $([math]::Round($pkg.Length / 1MB, 2)) MB"
release:
name: Publish Windows assets to GitHub Release
runs-on: ubuntu-latest
needs: build
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: write
steps:
- name: Download Windows artifacts
uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Generate Windows release checksums
run: |
cd dist
find . -maxdepth 1 -type f -name '*.zip' -printf '%P\n' \
| LC_ALL=C sort \
| xargs sha256sum \
> checksums-windows.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 Windows assets
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload "${GITHUB_REF_NAME}" \
dist/*.zip \
dist/checksums-windows.txt \
--clobber \
--repo "${GITHUB_REPOSITORY}"

65
Cargo.lock generated
View File

@@ -287,13 +287,33 @@ version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "c2rust-bitfields"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b43c3f07ab0ef604fa6f595aa46ec2f8a22172c975e186f6f5bf9829a3b72c41"
dependencies = [
"c2rust-bitfields-derive 0.18.0",
]
[[package]] [[package]]
name = "c2rust-bitfields" name = "c2rust-bitfields"
version = "0.21.0" version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcee50917f9de1a018e3f4f9a8f2ff3d030a288cffa4b18d9b391e97c12e4cfb" checksum = "dcee50917f9de1a018e3f4f9a8f2ff3d030a288cffa4b18d9b391e97c12e4cfb"
dependencies = [ dependencies = [
"c2rust-bitfields-derive", "c2rust-bitfields-derive 0.21.0",
]
[[package]]
name = "c2rust-bitfields-derive"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3cbc102e2597c9744c8bd8c15915d554300601c91a079430d309816b0912545"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
] ]
[[package]] [[package]]
@@ -983,6 +1003,8 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"tun", "tun",
"windows-service",
"wintun",
] ]
[[package]] [[package]]
@@ -3156,6 +3178,12 @@ dependencies = [
"wezterm-dynamic", "wezterm-dynamic",
] ]
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@@ -3193,6 +3221,26 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-service"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a"
dependencies = [
"bitflags 2.10.0",
"widestring",
"windows-sys 0.52.0",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -3359,6 +3407,19 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "wintun"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da99be64b5aa3de869c16977994314d0759a698d9a73ab0a5b1d52e2282033ae"
dependencies = [
"c2rust-bitfields 0.18.0",
"libloading 0.8.9",
"log",
"thiserror 1.0.69",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "wintun-bindings" name = "wintun-bindings"
version = "0.7.34" version = "0.7.34"
@@ -3366,7 +3427,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27ae04d34b8569174e849128d2e36538329a27daa79c06ed0375f2c5d6704461" checksum = "27ae04d34b8569174e849128d2e36538329a27daa79c06ed0375f2c5d6704461"
dependencies = [ dependencies = [
"blocking", "blocking",
"c2rust-bitfields", "c2rust-bitfields 0.21.0",
"futures", "futures",
"libloading 0.9.0", "libloading 0.9.0",
"log", "log",

View File

@@ -31,9 +31,7 @@ hex = "0.4"
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tun = { version = "0.8.5", features = ["async"] } tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net", "time", "process", "io-util"] }
libc = "0.2"
tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net", "time", "process"] }
futures = "0.3" futures = "0.3"
simple-dns = "0.11.2" simple-dns = "0.11.2"
socket2 = { version = "0.6.2", features = ["all"] } socket2 = { version = "0.6.2", features = ["all"] }
@@ -41,10 +39,18 @@ tokio-socks = "0.5"
rustables = { version = "0.8.7", optional = true } rustables = { version = "0.8.7", optional = true }
[target.'cfg(unix)'.dependencies]
tun = { version = "0.8.5", features = ["async"] }
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
rtnetlink = "0.20.0" rtnetlink = "0.20.0"
bluer = { version = "0.17", features = ["bluetoothd", "l2cap"], optional = true } bluer = { version = "0.17", features = ["bluetoothd", "l2cap"], optional = true }
[target.'cfg(windows)'.dependencies]
wintun = "0.5"
windows-service = "0.7"
[package.metadata.deb] [package.metadata.deb]
maintainer = "Johnathan Corgan <jcorgan@corganlabs.com>" maintainer = "Johnathan Corgan <jcorgan@corganlabs.com>"
copyright = "2026 Johnathan Corgan" copyright = "2026 Johnathan Corgan"

View File

@@ -63,8 +63,8 @@ cd fips
cargo build --release cargo build --release
``` ```
Requires Rust 1.85+ (edition 2024) and a Unix-like OS with TUN support Requires Rust 1.85+ (edition 2024). Linux, macOS, and Windows are
(Linux or macOS). supported (see transport matrix below).
### Transport support by platform ### Transport support by platform
@@ -159,6 +159,44 @@ sudo tail -f /usr/local/var/log/fips/fips.log
> **Note:** On macOS, the TUN device is named `utun<N>` (kernel-assigned) > **Note:** On macOS, the TUN device is named `utun<N>` (kernel-assigned)
> rather than `fips0`. > rather than `fips0`.
### Windows
Build without BLE (requires Linux-only libdbus):
```powershell
cargo build --release --no-default-features --features tui
```
The [wintun](https://www.wintun.net/) driver is required for TUN support.
Download `wintun.dll` and place it in the same directory as `fips.exe`.
Running the daemon requires Administrator privileges for TUN creation.
**Foreground mode:**
```powershell
.\fips.exe -c fips.yaml
```
**Windows Service:**
```powershell
# Install (requires Administrator)
.\fips.exe --install-service
# Manage via standard service tools
sc start fips
sc stop fips
# Uninstall
.\fips.exe --uninstall-service
```
Place `fips.yaml` in the current directory or `%APPDATA%\fips\`, or set
the `FIPS_CONFIG` environment variable.
The control socket uses TCP on `localhost:21210` instead of a Unix domain
socket. `fipsctl` and `fipstop` connect to this port automatically.
## Configuration ## Configuration
The default configuration file is installed at `/etc/fips/fips.yaml`: The default configuration file is installed at `/etc/fips/fips.yaml`:

View File

@@ -53,8 +53,8 @@ peers: # Static peer list
| Parameter | Type | Default | Description | | Parameter | Type | Default | Description |
|-----------|------|---------|-------------| |-----------|------|---------|-------------|
| `node.control.enabled` | bool | `true` | Enable the Unix domain control socket | | `node.control.enabled` | bool | `true` | Enable the control socket |
| `node.control.socket_path` | string | *(auto)* | Socket file path. Default: `$XDG_RUNTIME_DIR/fips/control.sock`, then `/run/fips/control.sock` (if root), then `/tmp/fips-control.sock` | | `node.control.socket_path` | string | *(auto)* | **Linux:** Socket file path. Default: `$XDG_RUNTIME_DIR/fips/control.sock`, then `/run/fips/control.sock` (if root), then `/tmp/fips-control.sock`. **Windows:** TCP port number (default: `21210`); the control socket listens on `localhost` at this port. |
The control socket provides access to node state and runtime management The control socket provides access to node state and runtime management
via the `fipsctl` command-line tool. In addition to read-only status via the `fipsctl` command-line tool. In addition to read-only status
@@ -62,6 +62,20 @@ queries, `fipsctl connect` and `fipsctl disconnect` enable runtime peer
management. See the project [README](../../README.md#inspect) for the management. See the project [README](../../README.md#inspect) for the
command list. command list.
On Linux, the control socket is a Unix domain socket with filesystem
permissions (mode 0770, group `fips`). On Windows, it is a TCP listener
on localhost. TCP does not provide filesystem-level ACLs, so any local
user can connect to the control port.
> **Security note (Windows):** The TCP control socket on Windows is a
> known limitation. Any process running on the local machine can connect
> to the control port and issue commands, including `disconnect`,
> `connect`, and `inject-config`. This is acceptable for single-user
> workstations but may be inappropriate for shared machines. Future
> improvements may include named pipe support (with Windows ACLs) or an
> authentication token mechanism. On shared Windows systems, consider
> using firewall rules to restrict access to the control port.
All tunable protocol parameters live under `node.*`, organized as sysctl-style All tunable protocol parameters live under `node.*`, organized as sysctl-style
dotted paths. The top-level sections (`tun`, `dns`, `transports`, `peers`) dotted paths. The top-level sections (`tun`, `dns`, `transports`, `peers`)
handle infrastructure concerns only. handle infrastructure concerns only.

View File

@@ -9,6 +9,7 @@
# make ipk Build an OpenWrt .ipk package # make ipk Build an OpenWrt .ipk package
# make aur Build fips-git AUR package and validate with namcap # make aur Build fips-git AUR package and validate with namcap
# make pkg Build a macOS .pkg installer # make pkg Build a macOS .pkg installer
# make zip Build a Windows .zip package
# make all Build deb and tarball (default) # make all Build deb and tarball (default)
# make clean Remove deploy/ directory # make clean Remove deploy/ directory
@@ -16,7 +17,7 @@ SHELL := /bin/bash
PACKAGING_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) PACKAGING_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
PROJECT_ROOT := $(abspath $(PACKAGING_DIR)/..) PROJECT_ROOT := $(abspath $(PACKAGING_DIR)/..)
.PHONY: all deb tarball ipk aur pkg clean .PHONY: all deb tarball ipk aur pkg zip clean
all: deb tarball all: deb tarball
@@ -35,5 +36,8 @@ aur:
pkg: pkg:
@bash $(PACKAGING_DIR)/macos/build-pkg.sh @bash $(PACKAGING_DIR)/macos/build-pkg.sh
zip:
@powershell -File $(PACKAGING_DIR)/windows/build-zip.ps1
clean: clean:
rm -rf $(PROJECT_ROOT)/deploy rm -rf $(PROJECT_ROOT)/deploy

View File

@@ -11,6 +11,7 @@ make tarball # systemd install tarball
make ipk # OpenWrt .ipk make ipk # OpenWrt .ipk
make aur # Arch Linux AUR package (fips-git, local build + namcap) make aur # Arch Linux AUR package (fips-git, local build + namcap)
make pkg # macOS .pkg installer make pkg # macOS .pkg installer
make zip # Windows .zip package
make all # deb + tarball (default) make all # deb + tarball (default)
``` ```
@@ -24,6 +25,7 @@ packaging/
macos/ macOS .pkg installer via pkgbuild macos/ macOS .pkg installer via pkgbuild
systemd/ Generic Linux systemd tarball packaging systemd/ Generic Linux systemd tarball packaging
openwrt/ OpenWrt .ipk packaging via cargo-zigbuild openwrt/ OpenWrt .ipk packaging via cargo-zigbuild
windows/ Windows .zip package with service scripts
``` ```
## Formats ## Formats
@@ -101,6 +103,31 @@ sudo installer -pkg deploy/fips-<version>-macos-<arch>.pkg -target /
sudo packaging/macos/uninstall.sh sudo packaging/macos/uninstall.sh
``` ```
### Windows (`.zip`)
A ZIP archive containing binaries, default config, and PowerShell
service helper scripts. Requires the [wintun](https://www.wintun.net/)
driver for TUN support.
```powershell
# Build
make zip
# Or directly
powershell -File packaging/windows/build-zip.ps1
# Extract and install as service (requires Administrator)
Expand-Archive deploy\fips-<version>-windows-x86_64.zip -DestinationPath fips
cd fips
powershell -File install-service.ps1
# Uninstall (preserves config)
powershell -File uninstall-service.ps1
# Uninstall and remove config
powershell -File uninstall-service.ps1 -RemoveAll
```
### Arch Linux (AUR) ### Arch Linux (AUR)
Two AUR packages are maintained: `fips` (release, builds from tagged Two AUR packages are maintained: `fips` (release, builds from tagged

View File

@@ -0,0 +1,120 @@
# Build a Windows ZIP package for FIPS.
#
# Usage: powershell -File packaging/windows/build-zip.ps1 [-Version <version>] [-NoBuild]
# Output: deploy/fips-<version>-windows-x86_64.zip
#
# Prerequisites: Rust toolchain installed
param(
[string]$Version = "",
[switch]$NoBuild
)
$ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$PackagingDir = Split-Path -Parent $ScriptDir
$ProjectRoot = Split-Path -Parent $PackagingDir
# Derive version from Cargo.toml if not provided
if (-not $Version) {
$cargoToml = Get-Content "$ProjectRoot\Cargo.toml" -Raw
if ($cargoToml -match 'version\s*=\s*"([^"]+)"') {
$Version = $Matches[1]
} else {
Write-Error "Could not determine version from Cargo.toml"
exit 1
}
}
$Arch = "x86_64"
$PkgName = "fips-$Version-windows-$Arch"
$DeployDir = "$ProjectRoot\deploy"
$StagingDir = "$env:TEMP\fips-staging-$([guid]::NewGuid().ToString('N'))"
$BinaryDir = "$ProjectRoot\target\release"
Write-Host "Building FIPS v$Version for Windows $Arch..."
# Build release binaries
if (-not $NoBuild) {
Push-Location $ProjectRoot
cargo build --release --no-default-features --features tui
if ($LASTEXITCODE -ne 0) {
Write-Error "cargo build failed"
exit 1
}
Pop-Location
}
# Verify binaries exist
$Binaries = @("fips.exe", "fipsctl.exe", "fipstop.exe")
foreach ($bin in $Binaries) {
if (-not (Test-Path "$BinaryDir\$bin")) {
Write-Error "Missing binary: $BinaryDir\$bin"
exit 1
}
}
# Create staging directory
New-Item -ItemType Directory -Force -Path $StagingDir | Out-Null
# Copy binaries
foreach ($bin in $Binaries) {
Copy-Item "$BinaryDir\$bin" "$StagingDir\$bin"
}
# Copy config
Copy-Item "$PackagingDir\common\fips.yaml" "$StagingDir\fips.yaml"
Copy-Item "$PackagingDir\common\hosts" "$StagingDir\hosts"
# Copy helper scripts
Copy-Item "$ScriptDir\install-service.ps1" "$StagingDir\install-service.ps1"
Copy-Item "$ScriptDir\uninstall-service.ps1" "$StagingDir\uninstall-service.ps1"
# Create README
@"
FIPS v$Version for Windows
==========================
Quick Start (foreground mode):
.\fips.exe -c fips.yaml
Windows Service:
# Install (requires Administrator)
powershell -File install-service.ps1
# Manage
sc start fips
sc stop fips
# Uninstall
powershell -File uninstall-service.ps1
TUN Support:
Download wintun.dll from https://www.wintun.net/ and place it
in the same directory as fips.exe. Running the daemon requires
Administrator privileges for TUN creation.
Control Socket:
The control socket uses TCP on localhost:21210.
fipsctl and fipstop connect to this port automatically.
Configuration:
Edit fips.yaml before starting. Place it in the same directory
as fips.exe, or in %APPDATA%\fips\, or set FIPS_CONFIG.
"@ | Out-File -FilePath "$StagingDir\README.txt" -Encoding UTF8
# Create ZIP
New-Item -ItemType Directory -Force -Path $DeployDir | Out-Null
$ZipPath = "$DeployDir\$PkgName.zip"
if (Test-Path $ZipPath) { Remove-Item $ZipPath }
Compress-Archive -Path "$StagingDir\*" -DestinationPath $ZipPath
# Cleanup
Remove-Item -Recurse -Force $StagingDir
Write-Host ""
Write-Host "Package built: deploy\$PkgName.zip"
Write-Host " Size: $([math]::Round((Get-Item $ZipPath).Length / 1MB, 2)) MB"
Write-Host ""
Write-Host "Extract and run: .\fips.exe -c fips.yaml"

View File

@@ -0,0 +1,89 @@
# Install FIPS as a Windows service.
#
# Usage: powershell -File install-service.ps1
# Requires: Administrator privileges
$ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
# Check for admin
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Error "This script requires Administrator privileges. Right-click PowerShell and select 'Run as Administrator'."
exit 1
}
$InstallDir = "$env:ProgramFiles\fips"
$ConfigDir = "$env:ProgramData\fips"
Write-Host "Installing FIPS service..."
# Create directories
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null
# Copy binaries
$Binaries = @("fips.exe", "fipsctl.exe", "fipstop.exe")
foreach ($bin in $Binaries) {
$src = "$ScriptDir\$bin"
if (Test-Path $src) {
Copy-Item $src "$InstallDir\$bin" -Force
Write-Host " Installed $bin"
} else {
Write-Warning "Missing $bin in $ScriptDir"
}
}
# Copy wintun.dll if present
if (Test-Path "$ScriptDir\wintun.dll") {
Copy-Item "$ScriptDir\wintun.dll" "$InstallDir\wintun.dll" -Force
Write-Host " Installed wintun.dll"
}
# Install config (preserve existing)
if (-not (Test-Path "$ConfigDir\fips.yaml")) {
if (Test-Path "$ScriptDir\fips.yaml") {
Copy-Item "$ScriptDir\fips.yaml" "$ConfigDir\fips.yaml"
Write-Host " Installed default config"
}
} else {
Write-Host " Config already exists, preserving"
}
if (-not (Test-Path "$ConfigDir\hosts")) {
if (Test-Path "$ScriptDir\hosts") {
Copy-Item "$ScriptDir\hosts" "$ConfigDir\hosts"
}
}
# Set FIPS_CONFIG environment variable (machine-wide)
[Environment]::SetEnvironmentVariable("FIPS_CONFIG", "$ConfigDir\fips.yaml", "Machine")
Write-Host " Set FIPS_CONFIG=$ConfigDir\fips.yaml"
# Add install dir to system PATH if not already there
$machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine")
if ($machinePath -notlike "*$InstallDir*") {
[Environment]::SetEnvironmentVariable("Path", "$machinePath;$InstallDir", "Machine")
Write-Host " Added $InstallDir to system PATH"
}
# Install the service (run from install dir so current_exe() points to the right path)
Write-Host " Registering Windows service..."
Push-Location $InstallDir
& "$InstallDir\fips.exe" --install-service
$exitCode = $LASTEXITCODE
Pop-Location
if ($exitCode -ne 0) {
Write-Error "Failed to install service"
exit 1
}
Write-Host ""
Write-Host "FIPS service installed successfully."
Write-Host ""
Write-Host "Edit config: notepad $ConfigDir\fips.yaml"
Write-Host "Start: sc start fips"
Write-Host "Stop: sc stop fips"
Write-Host "Status: sc query fips"
Write-Host "Uninstall: powershell -File uninstall-service.ps1"

View File

@@ -0,0 +1,73 @@
# Uninstall the FIPS Windows service.
#
# Usage: powershell -File uninstall-service.ps1 [-RemoveAll]
# Requires: Administrator privileges
#
# By default preserves config in %ProgramData%\fips\.
# Pass -RemoveAll to remove config and identity keys too.
param(
[switch]$RemoveAll
)
$ErrorActionPreference = "Stop"
# Check for admin
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Error "This script requires Administrator privileges. Right-click PowerShell and select 'Run as Administrator'."
exit 1
}
$InstallDir = "$env:ProgramFiles\fips"
$ConfigDir = "$env:ProgramData\fips"
Write-Host "Uninstalling FIPS service..."
# Stop the service if running
$svc = Get-Service -Name "fips" -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -eq "Running") {
Write-Host " Stopping service..."
Stop-Service -Name "fips" -Force
}
# Uninstall the service
if (Test-Path "$InstallDir\fips.exe") {
Write-Host " Removing service registration..."
& "$InstallDir\fips.exe" --uninstall-service
}
# Remove binaries
if (Test-Path $InstallDir) {
Write-Host " Removing $InstallDir"
Remove-Item -Recurse -Force $InstallDir
}
# Remove from PATH
$machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine")
if ($machinePath -like "*$InstallDir*") {
$newPath = ($machinePath -split ";" | Where-Object { $_ -ne $InstallDir }) -join ";"
[Environment]::SetEnvironmentVariable("Path", $newPath, "Machine")
Write-Host " Removed from system PATH"
}
# Remove FIPS_CONFIG env var
[Environment]::SetEnvironmentVariable("FIPS_CONFIG", $null, "Machine")
# Config removal
if ($RemoveAll) {
if (Test-Path $ConfigDir) {
Write-Host " Removing config and keys at $ConfigDir"
Remove-Item -Recurse -Force $ConfigDir
}
} else {
if (Test-Path $ConfigDir) {
Write-Host " Preserving config at $ConfigDir"
}
}
Write-Host ""
Write-Host "FIPS service uninstalled."
if (-not $RemoveAll) {
Write-Host "Config preserved at $ConfigDir (use -RemoveAll to delete)"
}

View File

@@ -1,6 +1,7 @@
//! FIPS daemon binary //! FIPS daemon binary
//! //!
//! Loads configuration and creates the top-level node instance. //! Loads configuration and creates the top-level node instance.
//! On Windows, can run as a Windows Service when invoked with `--service`.
use clap::Parser; use clap::Parser;
use fips::config::{IdentitySource, resolve_identity}; use fips::config::{IdentitySource, resolve_identity};
@@ -22,15 +23,35 @@ struct Args {
/// Path to configuration file (overrides default search paths) /// Path to configuration file (overrides default search paths)
#[arg(short, long, value_name = "FILE")] #[arg(short, long, value_name = "FILE")]
config: Option<PathBuf>, config: Option<PathBuf>,
/// Run as a Windows service (internal use by service control manager)
#[cfg(windows)]
#[arg(long, hide = true)]
service: bool,
/// Install as a Windows service
#[cfg(windows)]
#[arg(long)]
install_service: bool,
/// Uninstall the Windows service
#[cfg(windows)]
#[arg(long)]
uninstall_service: bool,
} }
#[tokio::main(flavor = "current_thread")] /// Run the FIPS daemon (shared between foreground and service modes).
async fn main() { ///
let args = Args::parse(); /// `config_path` overrides the default config search. `shutdown_signal`
/// is awaited to trigger a graceful stop — in foreground mode this is
/// Ctrl+C / SIGTERM, in service mode it's the service stop event.
async fn run_daemon(
config_path: Option<PathBuf>,
shutdown_signal: impl std::future::Future<Output = ()>,
) {
// Load configuration before initializing logging so we can use // Load configuration before initializing logging so we can use
// the config's log_level as the tracing filter default. // the config's log_level as the tracing filter default.
let (config, loaded_paths) = if let Some(config_path) = &args.config { let (config, loaded_paths) = if let Some(config_path) = &config_path {
match Config::load_file(config_path) { match Config::load_file(config_path) {
Ok(config) => (config, vec![config_path.clone()]), Ok(config) => (config, vec![config_path.clone()]),
Err(e) => { Err(e) => {
@@ -101,7 +122,6 @@ async fn main() {
} }
}; };
// Log node information
info!("Node created:"); info!("Node created:");
info!(" npub: {}", node.npub()); info!(" npub: {}", node.npub());
info!(" node_addr: {}", hex::encode(node.node_addr().as_bytes())); info!(" node_addr: {}", hex::encode(node.node_addr().as_bytes()));
@@ -115,23 +135,10 @@ async fn main() {
std::process::exit(1); std::process::exit(1);
} }
info!("FIPS running, press Ctrl+C to exit"); info!("FIPS running");
// Run the RX event loop until shutdown signal (SIGINT or SIGTERM). // Run the RX event loop until shutdown signal.
// stop() drops the packet channel, causing run_rx_loop to exit. // stop() drops the packet channel, causing run_rx_loop to exit.
#[cfg(unix)]
let shutdown = async {
use tokio::signal::unix::{SignalKind, signal};
let mut sigterm =
signal(SignalKind::terminate()).expect("failed to register SIGTERM handler");
tokio::select! {
_ = tokio::signal::ctrl_c() => {},
_ = sigterm.recv() => {},
}
};
#[cfg(not(unix))]
let shutdown = tokio::signal::ctrl_c();
tokio::select! { tokio::select! {
result = node.run_rx_loop() => { result = node.run_rx_loop() => {
match result { match result {
@@ -139,7 +146,7 @@ async fn main() {
Err(e) => error!("RX loop error: {}", e), Err(e) => error!("RX loop error: {}", e),
} }
} }
_ = shutdown => { _ = shutdown_signal => {
info!("Shutdown signal received"); info!("Shutdown signal received");
} }
} }
@@ -153,3 +160,244 @@ async fn main() {
info!("FIPS shutdown complete"); info!("FIPS shutdown complete");
} }
/// Build a shutdown future for foreground mode (Ctrl+C / SIGTERM).
async fn foreground_shutdown_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let mut sigterm =
signal(SignalKind::terminate()).expect("failed to register SIGTERM handler");
tokio::select! {
_ = tokio::signal::ctrl_c() => {},
_ = sigterm.recv() => {},
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
}
// ============================================================================
// Unix entry point
// ============================================================================
#[cfg(not(windows))]
#[tokio::main(flavor = "current_thread")]
async fn main() {
let args = Args::parse();
run_daemon(args.config, foreground_shutdown_signal()).await;
}
// ============================================================================
// Windows entry point and service support
// ============================================================================
#[cfg(windows)]
fn main() {
let args = Args::parse();
if args.install_service {
if let Err(e) = service::install_service() {
eprintln!("Failed to install service: {}", e);
std::process::exit(1);
}
return;
}
if args.uninstall_service {
if let Err(e) = service::uninstall_service() {
eprintln!("Failed to uninstall service: {}", e);
std::process::exit(1);
}
return;
}
if args.service {
// Running as a Windows service (invoked by the service control manager)
if let Err(e) = service::run_as_service() {
eprintln!("Failed to start as service: {}", e);
std::process::exit(1);
}
return;
}
// Foreground mode: build a manual tokio runtime since we can't use
// #[tokio::main] with platform-conditional main functions.
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to create tokio runtime");
rt.block_on(run_daemon(args.config, foreground_shutdown_signal()));
}
#[cfg(windows)]
mod service {
use std::ffi::OsString;
use std::path::PathBuf;
use std::time::Duration;
use windows_service::{
define_windows_service,
service::{
ServiceAccess, ServiceControl, ServiceControlAccept, ServiceErrorControl,
ServiceExitCode, ServiceInfo, ServiceStartType, ServiceState, ServiceStatus,
ServiceType,
},
service_control_handler::{self, ServiceControlHandlerResult},
service_dispatcher,
service_manager::{ServiceManager, ServiceManagerAccess},
};
const SERVICE_NAME: &str = "fips";
const SERVICE_DISPLAY_NAME: &str = "FIPS Mesh Network Daemon";
const SERVICE_DESCRIPTION: &str =
"Free Internetworking Peering System - distributed mesh networking protocol";
define_windows_service!(ffi_service_main, service_main);
/// Start the service dispatcher, which blocks until the service stops.
pub fn run_as_service() -> Result<(), windows_service::Error> {
service_dispatcher::start(SERVICE_NAME, ffi_service_main)
}
/// Entry point called by the Windows service control manager.
fn service_main(arguments: Vec<OsString>) {
if let Err(e) = run_service(arguments) {
eprintln!("Service error: {:?}", e);
}
}
/// Core service logic: register control handler, run daemon, report status.
fn run_service(_arguments: Vec<OsString>) -> Result<(), windows_service::Error> {
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let shutdown_tx = std::sync::Mutex::new(Some(shutdown_tx));
let event_handler = move |control_event| -> ServiceControlHandlerResult {
match control_event {
ServiceControl::Stop | ServiceControl::Shutdown => {
if let Ok(mut guard) = shutdown_tx.lock()
&& let Some(tx) = guard.take()
{
let _ = tx.send(());
}
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
}
};
let status_handle = service_control_handler::register(SERVICE_NAME, event_handler)?;
// Report running
status_handle.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Running,
controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to create tokio runtime");
// Look for config file path from FIPS_CONFIG env var
let config_path: Option<PathBuf> = std::env::var("FIPS_CONFIG").ok().map(PathBuf::from);
rt.block_on(super::run_daemon(config_path, async {
let _ = shutdown_rx.await;
}));
// Report stopped
status_handle.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Stopped,
controls_accepted: ServiceControlAccept::empty(),
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})?;
Ok(())
}
/// Install FIPS as a Windows service (requires Administrator).
pub fn install_service() -> Result<(), Box<dyn std::error::Error>> {
let manager = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CREATE_SERVICE | ServiceManagerAccess::CONNECT,
)?;
let exe_path = std::env::current_exe()?;
let service_info = ServiceInfo {
name: OsString::from(SERVICE_NAME),
display_name: OsString::from(SERVICE_DISPLAY_NAME),
service_type: ServiceType::OWN_PROCESS,
start_type: ServiceStartType::AutoStart,
error_control: ServiceErrorControl::Normal,
executable_path: exe_path,
launch_arguments: vec![OsString::from("--service")],
dependencies: vec![],
account_name: None,
account_password: None,
};
let service = match manager.create_service(&service_info, ServiceAccess::CHANGE_CONFIG) {
Ok(s) => s,
Err(windows_service::Error::Winapi(ref e)) if e.raw_os_error() == Some(0x431) => {
// ERROR_SERVICE_EXISTS (1073) — open the existing service instead
println!(
"Service '{}' already exists, updating configuration...",
SERVICE_NAME
);
manager.open_service(SERVICE_NAME, ServiceAccess::CHANGE_CONFIG)?
}
Err(e) => return Err(format!("Failed to create service: {}", e).into()),
};
// set_description is non-critical — don't fail the install over it
if let Err(e) = service.set_description(SERVICE_DESCRIPTION) {
eprintln!("Warning: could not set service description: {}", e);
}
println!("Service '{}' installed successfully.", SERVICE_NAME);
println!("Start it with: sc start {}", SERVICE_NAME);
println!();
println!("Configuration: place fips.yaml in one of:");
println!(" - Current directory");
println!(" - %APPDATA%\\fips\\fips.yaml");
println!(" - Set FIPS_CONFIG environment variable");
Ok(())
}
/// Uninstall the FIPS Windows service (requires Administrator).
pub fn uninstall_service() -> Result<(), Box<dyn std::error::Error>> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
let service = manager.open_service(
SERVICE_NAME,
ServiceAccess::STOP | ServiceAccess::DELETE | ServiceAccess::QUERY_STATUS,
)?;
// Stop the service if running
if let Ok(status) = service.query_status()
&& status.current_state != ServiceState::Stopped
{
println!("Stopping service...");
let _ = service.stop();
// Wait briefly for the service to stop
std::thread::sleep(Duration::from_secs(2));
}
service.delete()?;
println!("Service '{}' uninstalled.", SERVICE_NAME);
Ok(())
}
}

View File

@@ -1,7 +1,10 @@
//! fipsctl — FIPS control client //! fipsctl — FIPS control client
//! //!
//! Connects to the FIPS daemon's Unix domain control socket, sends //! Connects to the FIPS daemon's control socket, sends commands, and
//! commands, and pretty-prints the JSON response. //! pretty-prints the JSON response.
//!
//! On Unix, uses a Unix domain socket for local IPC.
//! On Windows, uses a TCP connection to localhost.
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use fips::config::{write_key_file, write_pub_file}; use fips::config::{write_key_file, write_pub_file};
@@ -9,7 +12,6 @@ use fips::upper::hosts::HostMap;
use fips::version; use fips::version;
use fips::{Identity, encode_nsec}; use fips::{Identity, encode_nsec};
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::Duration;
@@ -40,7 +42,7 @@ enum Commands {
/// Generate a new FIPS identity keypair /// Generate a new FIPS identity keypair
Keygen { Keygen {
/// Output directory for fips.key and fips.pub /// Output directory for fips.key and fips.pub
#[arg(short = 'd', long = "dir", default_value = "/etc/fips")] #[arg(short = 'd', long = "dir", default_value_os_t = default_key_dir())]
dir: PathBuf, dir: PathBuf,
/// Overwrite existing key files /// Overwrite existing key files
#[arg(short = 'f', long = "force")] #[arg(short = 'f', long = "force")]
@@ -109,24 +111,18 @@ impl ShowCommands {
} }
} }
/// Determine the default socket path.
///
/// Checks the system-wide path first (used when the daemon runs as a
/// systemd service), then falls back to the user's XDG runtime directory.
/// Uses directory existence rather than socket file existence so the check
/// works even when the user lacks traverse permission on /run/fips/ (0750).
fn default_socket_path() -> PathBuf { fn default_socket_path() -> PathBuf {
if Path::new("/run/fips").exists() { fips::config::default_control_path()
PathBuf::from("/run/fips/control.sock")
} else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(format!("{runtime_dir}/fips/control.sock"))
} else {
PathBuf::from("/tmp/fips-control.sock")
}
} }
/// Send a JSON request to the control socket and return the response. /// Send a JSON request to the control socket and return the response.
///
/// On Unix, connects via Unix domain socket.
/// On Windows, connects via TCP to localhost.
#[cfg(unix)]
fn send_request(socket_path: &Path, request_json: &str) -> Result<serde_json::Value, String> { fn send_request(socket_path: &Path, request_json: &str) -> Result<serde_json::Value, String> {
use std::os::unix::net::UnixStream;
let mut stream = UnixStream::connect(socket_path).map_err(|e| { let mut stream = UnixStream::connect(socket_path).map_err(|e| {
if e.kind() == std::io::ErrorKind::PermissionDenied { if e.kind() == std::io::ErrorKind::PermissionDenied {
format!( format!(
@@ -164,6 +160,46 @@ fn send_request(socket_path: &Path, request_json: &str) -> Result<serde_json::Va
serde_json::from_str(&line).map_err(|e| format!("invalid response JSON: {e}")) serde_json::from_str(&line).map_err(|e| format!("invalid response JSON: {e}"))
} }
#[cfg(windows)]
fn send_request(socket_path: &Path, request_json: &str) -> Result<serde_json::Value, String> {
use std::net::TcpStream;
let port_str = socket_path.to_string_lossy();
let port: u16 = match port_str.parse() {
Ok(p) => p,
Err(_) => {
eprintln!("warning: invalid port '{}', using default 21210", port_str);
21210
}
};
let addr = format!("127.0.0.1:{port}");
let mut stream = TcpStream::connect(&addr).map_err(|e| {
format!(
"cannot connect to {}: {}\nIs the FIPS daemon running?",
addr, e
)
})?;
let timeout = Duration::from_secs(5);
let _ = stream.set_read_timeout(Some(timeout));
let _ = stream.set_write_timeout(Some(timeout));
stream
.write_all(request_json.as_bytes())
.map_err(|e| format!("failed to send request: {e}"))?;
let _ = stream.shutdown(std::net::Shutdown::Write);
let reader = BufReader::new(&stream);
let line = reader
.lines()
.next()
.ok_or("no response from daemon")?
.map_err(|e| format!("failed to read response: {e}"))?;
serde_json::from_str(&line).map_err(|e| format!("invalid response JSON: {e}"))
}
/// Build a request JSON string for a simple command (no params). /// Build a request JSON string for a simple command (no params).
fn build_query(command: &str) -> String { fn build_query(command: &str) -> String {
format!("{{\"command\":\"{command}\"}}\n") format!("{{\"command\":\"{command}\"}}\n")
@@ -199,10 +235,24 @@ fn print_response(value: &serde_json::Value) {
println!("{}", output.unwrap_or_else(|_| format!("{value}"))); println!("{}", output.unwrap_or_else(|_| format!("{value}")));
} }
/// Default directory for keygen output.
fn default_key_dir() -> PathBuf {
#[cfg(unix)]
{
PathBuf::from("/etc/fips")
}
#[cfg(windows)]
{
dirs::config_dir()
.map(|d| d.join("fips"))
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData\\fips"))
}
}
/// Resolve a peer identifier to an npub. /// Resolve a peer identifier to an npub.
/// ///
/// If the identifier starts with "npub1", it's returned as-is. /// If the identifier starts with "npub1", it's returned as-is.
/// Otherwise, it's looked up as a hostname in /etc/fips/hosts. /// Otherwise, it's looked up as a hostname in the hosts file.
fn resolve_peer(peer: &str) -> String { fn resolve_peer(peer: &str) -> String {
if peer.starts_with("npub1") { if peer.starts_with("npub1") {
return peer.to_string(); return peer.to_string();
@@ -213,7 +263,10 @@ fn resolve_peer(peer: &str) -> String {
Some(npub) => npub.to_string(), Some(npub) => npub.to_string(),
None => { None => {
eprintln!("error: unknown host '{peer}'"); eprintln!("error: unknown host '{peer}'");
eprintln!("Not found in /etc/fips/hosts and not an npub."); eprintln!(
"Not found in {} and not an npub.",
fips::upper::hosts::DEFAULT_HOSTS_PATH
);
std::process::exit(1); std::process::exit(1);
} }
} }

View File

@@ -1,30 +1,26 @@
use serde_json::Value; use serde_json::Value;
use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tokio::time::timeout; use tokio::time::timeout;
const IO_TIMEOUT: Duration = Duration::from_secs(5); const IO_TIMEOUT: Duration = Duration::from_secs(5);
pub struct ControlClient { pub struct ControlClient {
socket_path: PathBuf, /// On Unix, this is a socket path. On Windows, this is a TCP port string.
address: String,
} }
impl ControlClient { impl ControlClient {
pub fn new(socket_path: &Path) -> Self { pub fn new(socket_path: &std::path::Path) -> Self {
Self { Self {
socket_path: socket_path.to_path_buf(), address: socket_path.to_string_lossy().into_owned(),
} }
} }
pub async fn query(&self, command: &str) -> Result<Value, String> { pub async fn query(&self, command: &str) -> Result<Value, String> {
let stream = timeout(IO_TIMEOUT, UnixStream::connect(&self.socket_path)) let stream = self.connect().await?;
.await
.map_err(|_| "connection timed out".to_string())?
.map_err(|e| format!("connect: {e}"))?;
let (reader, mut writer) = stream.into_split(); let (reader, mut writer) = tokio::io::split(stream);
let request = format!("{{\"command\":\"{command}\"}}\n"); let request = format!("{{\"command\":\"{command}\"}}\n");
timeout(IO_TIMEOUT, writer.write_all(request.as_bytes())) timeout(IO_TIMEOUT, writer.write_all(request.as_bytes()))
@@ -62,4 +58,31 @@ impl ControlClient {
Ok(response.get("data").cloned().unwrap_or(Value::Null)) Ok(response.get("data").cloned().unwrap_or(Value::Null))
} }
#[cfg(unix)]
async fn connect(&self) -> Result<tokio::net::UnixStream, String> {
timeout(IO_TIMEOUT, tokio::net::UnixStream::connect(&self.address))
.await
.map_err(|_| "connection timed out".to_string())?
.map_err(|e| format!("connect: {e}"))
}
#[cfg(windows)]
async fn connect(&self) -> Result<tokio::net::TcpStream, String> {
let port: u16 = match self.address.parse() {
Ok(p) => p,
Err(_) => {
eprintln!(
"warning: invalid port '{}', using default 21210",
self.address
);
21210
}
};
let addr = format!("127.0.0.1:{port}");
timeout(IO_TIMEOUT, tokio::net::TcpStream::connect(&addr))
.await
.map_err(|_| "connection timed out".to_string())?
.map_err(|e| format!("connect: {e}"))
}
} }

View File

@@ -9,7 +9,7 @@ use client::ControlClient;
use event::{Event, EventHandler}; use event::{Event, EventHandler};
use fips::version; use fips::version;
use ratatui::crossterm::event::{KeyCode, KeyModifiers}; use ratatui::crossterm::event::{KeyCode, KeyModifiers};
use std::path::{Path, PathBuf}; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
/// FIPS mesh monitoring TUI /// FIPS mesh monitoring TUI
@@ -34,31 +34,12 @@ struct Cli {
refresh: u64, refresh: u64,
} }
/// Determine the default socket path.
///
/// Checks the system-wide path first (used when the daemon runs as a
/// systemd service), then falls back to the user's XDG runtime directory.
/// Uses directory existence rather than socket file existence so the check
/// works even when the user lacks traverse permission on /run/fips/ (0750).
fn default_socket_path() -> PathBuf { fn default_socket_path() -> PathBuf {
if Path::new("/run/fips").exists() { fips::config::default_control_path()
PathBuf::from("/run/fips/control.sock")
} else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(format!("{runtime_dir}/fips/control.sock"))
} else {
PathBuf::from("/tmp/fips-control.sock")
}
} }
/// Determine the default gateway socket path.
fn default_gateway_socket_path() -> PathBuf { fn default_gateway_socket_path() -> PathBuf {
if Path::new("/run/fips").exists() { fips::config::default_gateway_path()
PathBuf::from("/run/fips/gateway.sock")
} else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(format!("{runtime_dir}/fips/gateway.sock"))
} else {
PathBuf::from("/tmp/fips-gateway.sock")
}
} }
fn restore_terminal() { fn restore_terminal() {

View File

@@ -68,6 +68,49 @@ pub fn pub_file_path(config_path: &Path) -> PathBuf {
.join(PUB_FILENAME) .join(PUB_FILENAME)
} }
/// Default control socket path for fipsctl / fipstop.
///
/// On Unix, checks the system-wide path first (used when the daemon runs as
/// a systemd service), then falls back to the user's XDG runtime directory.
/// On Windows, returns the default TCP port ("21210").
pub fn default_control_path() -> PathBuf {
#[cfg(unix)]
{
if Path::new("/run/fips").exists() {
PathBuf::from("/run/fips/control.sock")
} else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(format!("{runtime_dir}/fips/control.sock"))
} else {
PathBuf::from("/tmp/fips-control.sock")
}
}
#[cfg(windows)]
{
PathBuf::from("21210")
}
}
/// Default gateway control socket path.
///
/// On Unix, follows the same pattern as the main control socket.
/// On Windows, returns a placeholder TCP port ("21211").
pub fn default_gateway_path() -> PathBuf {
#[cfg(unix)]
{
if Path::new("/run/fips").exists() {
PathBuf::from("/run/fips/gateway.sock")
} else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(format!("{runtime_dir}/fips/gateway.sock"))
} else {
PathBuf::from("/tmp/fips-gateway.sock")
}
}
#[cfg(windows)]
{
PathBuf::from("21211")
}
}
/// Read a bare bech32 nsec from a key file. /// Read a bare bech32 nsec from a key file.
pub fn read_key_file(path: &Path) -> Result<String, ConfigError> { pub fn read_key_file(path: &Path) -> Result<String, ConfigError> {
let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile { let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
@@ -83,18 +126,23 @@ pub fn read_key_file(path: &Path) -> Result<String, ConfigError> {
Ok(nsec) Ok(nsec)
} }
/// Write a bare bech32 nsec to a key file with restricted permissions (mode 0600). /// Write a bare bech32 nsec to a key file with restricted permissions.
///
/// On Unix, the file is created with mode 0600 (owner read/write only).
/// On Windows, the file inherits default ACLs from the parent directory.
pub fn write_key_file(path: &Path, nsec: &str) -> Result<(), ConfigError> { pub fn write_key_file(path: &Path, nsec: &str) -> Result<(), ConfigError> {
use std::io::Write; use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new() let mut opts = std::fs::OpenOptions::new();
.write(true) opts.write(true).create(true).truncate(true);
.create(true)
.truncate(true) #[cfg(unix)]
.mode(0o600) {
.open(path) use std::os::unix::fs::OpenOptionsExt;
.map_err(|e| ConfigError::WriteKeyFile { opts.mode(0o600);
}
let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile {
path: path.to_path_buf(), path: path.to_path_buf(),
source: e, source: e,
})?; })?;
@@ -112,18 +160,23 @@ pub fn write_key_file(path: &Path, nsec: &str) -> Result<(), ConfigError> {
Ok(()) Ok(())
} }
/// Write a bare bech32 npub to a public key file (mode 0644). /// Write a bare bech32 npub to a public key file.
///
/// On Unix, the file is created with mode 0644 (owner read/write, others read).
/// On Windows, the file inherits default ACLs from the parent directory.
pub fn write_pub_file(path: &Path, npub: &str) -> Result<(), ConfigError> { pub fn write_pub_file(path: &Path, npub: &str) -> Result<(), ConfigError> {
use std::io::Write; use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new() let mut opts = std::fs::OpenOptions::new();
.write(true) opts.write(true).create(true).truncate(true);
.create(true)
.truncate(true) #[cfg(unix)]
.mode(0o644) {
.open(path) use std::os::unix::fs::OpenOptionsExt;
.map_err(|e| ConfigError::WriteKeyFile { opts.mode(0o644);
}
let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile {
path: path.to_path_buf(), path: path.to_path_buf(),
source: e, source: e,
})?; })?;
@@ -672,7 +725,8 @@ node:
// Should include current directory // Should include current directory
assert!(paths.iter().any(|p| p.ends_with("fips.yaml"))); assert!(paths.iter().any(|p| p.ends_with("fips.yaml")));
// Should include /etc/fips // Should include /etc/fips on Unix
#[cfg(unix)]
assert!( assert!(
paths paths
.iter() .iter()
@@ -710,6 +764,7 @@ node:
assert_eq!(loaded_identity.npub(), identity.npub()); assert_eq!(loaded_identity.npub(), identity.npub());
} }
#[cfg(unix)]
#[test] #[test]
fn test_key_file_permissions() { fn test_key_file_permissions() {
use std::os::unix::fs::MetadataExt; use std::os::unix::fs::MetadataExt;
@@ -723,6 +778,7 @@ node:
assert_eq!(metadata.mode() & 0o777, 0o600); assert_eq!(metadata.mode() & 0o777, 0o600);
} }
#[cfg(unix)]
#[test] #[test]
fn test_pub_file_permissions() { fn test_pub_file_permissions() {
use std::os::unix::fs::MetadataExt; use std::os::unix::fs::MetadataExt;
@@ -772,6 +828,26 @@ node:
); );
} }
#[cfg(windows)]
#[test]
fn test_key_file_write_read_roundtrip_windows() {
let temp_dir = TempDir::new().unwrap();
let key_path = temp_dir.path().join("fips.key");
let identity = crate::Identity::generate();
let nsec = crate::encode_nsec(&identity.keypair().secret_key());
write_key_file(&key_path, &nsec).unwrap();
// Verify file was created and can be read back
let loaded_nsec = read_key_file(&key_path).unwrap();
assert_eq!(loaded_nsec, nsec);
// Verify the loaded nsec produces the same identity
let loaded_identity = crate::Identity::from_secret_str(&loaded_nsec).unwrap();
assert_eq!(loaded_identity.npub(), identity.npub());
}
#[test] #[test]
fn test_resolve_identity_from_config() { fn test_resolve_identity_from_config() {
let mut config = Config::new(); let mut config = Config::new();

View File

@@ -488,7 +488,15 @@ impl ControlConfig {
true true
} }
/// Default control socket path.
///
/// On Unix, returns a Unix domain socket path (XDG_RUNTIME_DIR, /run/fips,
/// or /tmp fallback). On Windows, returns a TCP port number as a string
/// since Windows does not support Unix domain sockets; the control socket
/// listens on localhost at this port.
fn default_socket_path() -> String { fn default_socket_path() -> String {
#[cfg(unix)]
{
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
format!("{runtime_dir}/fips/control.sock") format!("{runtime_dir}/fips/control.sock")
} else if std::fs::create_dir_all("/run/fips").is_ok() { } else if std::fs::create_dir_all("/run/fips").is_ok() {
@@ -497,6 +505,11 @@ impl ControlConfig {
"/tmp/fips-control.sock".to_string() "/tmp/fips-control.sock".to_string()
} }
} }
#[cfg(windows)]
{
"21210".to_string()
}
}
} }
/// Internal buffers (`node.buffers.*`). /// Internal buffers (`node.buffers.*`).
@@ -810,4 +823,16 @@ mod tests {
assert!((c.loss_threshold - 0.02).abs() < 1e-9); assert!((c.loss_threshold - 0.02).abs() < 1e-9);
assert!((c.etx_threshold - 3.0).abs() < 1e-9); // default assert!((c.etx_threshold - 3.0).abs() < 1e-9); // default
} }
#[cfg(windows)]
#[test]
fn test_default_socket_path_windows() {
let config = ControlConfig::default();
// On Windows, socket_path is a TCP port number
let port: u16 = config
.socket_path
.parse()
.expect("should be a valid port number");
assert_eq!(port, 21210);
}
} }

View File

@@ -1,8 +1,12 @@
//! Control socket for runtime management and observability. //! Control socket for runtime management and observability.
//! //!
//! Provides a Unix domain socket that accepts commands and returns //! Provides a control interface that accepts commands and returns
//! structured JSON responses. Supports both read-only queries (show_*) //! structured JSON responses. Supports both read-only queries (show_*)
//! and mutating commands (connect, disconnect). //! and mutating commands (connect, disconnect).
//!
//! Platform-specific implementations:
//! - Unix: Uses a Unix domain socket for local IPC
//! - Windows: Uses a TCP socket on localhost (see commit 3)
pub mod commands; pub mod commands;
pub mod protocol; pub mod protocol;
@@ -10,9 +14,7 @@ pub mod queries;
use crate::config::ControlConfig; use crate::config::ControlConfig;
use protocol::{Request, Response}; use protocol::{Request, Response};
use std::path::{Path, PathBuf};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixListener;
use tokio::sync::{mpsc, oneshot}; use tokio::sync::{mpsc, oneshot};
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
@@ -25,15 +27,105 @@ const IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
/// A message sent from the accept loop to the main event loop. /// A message sent from the accept loop to the main event loop.
pub type ControlMessage = (Request, oneshot::Sender<Response>); pub type ControlMessage = (Request, oneshot::Sender<Response>);
/// Control socket listener. /// Handle a single client connection over any AsyncRead + AsyncWrite stream.
/// ///
/// Manages the Unix domain socket lifecycle: bind, accept, cleanup. /// Shared between Unix and Windows implementations to avoid duplicating
pub struct ControlSocket { /// the request/response protocol logic.
listener: UnixListener, async fn handle_connection_generic<S>(
socket_path: PathBuf, stream: S,
control_tx: mpsc::Sender<ControlMessage>,
) -> Result<(), Box<dyn std::error::Error>>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let (reader, mut writer) = tokio::io::split(stream);
let mut buf_reader = BufReader::new(reader);
let mut line = String::new();
// Read one line with timeout and size limit
let read_result = tokio::time::timeout(IO_TIMEOUT, async {
let mut total = 0usize;
loop {
let n = buf_reader.read_line(&mut line).await?;
if n == 0 {
break; // EOF
}
total += n;
if total > MAX_REQUEST_SIZE {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"request too large",
));
}
if line.ends_with('\n') {
break;
}
}
Ok(())
})
.await;
let response = match read_result {
Ok(Ok(())) if line.is_empty() => Response::error("empty request"),
Ok(Ok(())) => {
// Parse the request
match serde_json::from_str::<Request>(line.trim()) {
Ok(request) => {
// Send to main loop and wait for response
let (resp_tx, resp_rx) = oneshot::channel();
if control_tx.send((request, resp_tx)).await.is_err() {
Response::error("node shutting down")
} else {
match tokio::time::timeout(IO_TIMEOUT, resp_rx).await {
Ok(Ok(resp)) => resp,
Ok(Err(_)) => Response::error("response channel closed"),
Err(_) => Response::error("query timeout"),
}
}
}
Err(e) => Response::error(format!("invalid request: {}", e)),
}
}
Ok(Err(e)) => Response::error(format!("read error: {}", e)),
Err(_) => Response::error("read timeout"),
};
// Write response with timeout
let json = serde_json::to_string(&response)?;
let write_result = tokio::time::timeout(IO_TIMEOUT, async {
writer.write_all(json.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.shutdown().await?;
Ok::<_, std::io::Error>(())
})
.await;
if let Err(_) | Ok(Err(_)) = write_result {
debug!("Control socket write failed or timed out");
}
Ok(())
} }
impl ControlSocket { // ============================================================================
// Unix implementation
// ============================================================================
#[cfg(unix)]
mod unix_impl {
use super::*;
use std::path::{Path, PathBuf};
use tokio::net::UnixListener;
/// Control socket listener (Unix domain socket).
///
/// Manages the Unix domain socket lifecycle: bind, accept, cleanup.
pub struct ControlSocket {
listener: UnixListener,
socket_path: PathBuf,
}
impl ControlSocket {
/// Bind a new control socket. /// Bind a new control socket.
/// ///
/// Creates parent directories if needed, removes stale socket files, /// Creates parent directories if needed, removes stale socket files,
@@ -147,87 +239,13 @@ impl ControlSocket {
let tx = control_tx.clone(); let tx = control_tx.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = Self::handle_connection(stream, tx).await { if let Err(e) = handle_connection_generic(stream, tx).await {
debug!(error = %e, "Control connection error"); debug!(error = %e, "Control connection error");
} }
}); });
} }
} }
/// Handle a single client connection.
async fn handle_connection(
stream: tokio::net::UnixStream,
control_tx: mpsc::Sender<ControlMessage>,
) -> Result<(), Box<dyn std::error::Error>> {
let (reader, mut writer) = stream.into_split();
let mut buf_reader = BufReader::new(reader);
let mut line = String::new();
// Read one line with timeout and size limit
let read_result = tokio::time::timeout(IO_TIMEOUT, async {
let mut total = 0usize;
loop {
let n = buf_reader.read_line(&mut line).await?;
if n == 0 {
break; // EOF
}
total += n;
if total > MAX_REQUEST_SIZE {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"request too large",
));
}
if line.ends_with('\n') {
break;
}
}
Ok(())
})
.await;
let response = match read_result {
Ok(Ok(())) if line.is_empty() => Response::error("empty request"),
Ok(Ok(())) => {
// Parse the request
match serde_json::from_str::<Request>(line.trim()) {
Ok(request) => {
// Send to main loop and wait for response
let (resp_tx, resp_rx) = oneshot::channel();
if control_tx.send((request, resp_tx)).await.is_err() {
Response::error("node shutting down")
} else {
match tokio::time::timeout(IO_TIMEOUT, resp_rx).await {
Ok(Ok(resp)) => resp,
Ok(Err(_)) => Response::error("response channel closed"),
Err(_) => Response::error("query timeout"),
}
}
}
Err(e) => Response::error(format!("invalid request: {}", e)),
}
}
Ok(Err(e)) => Response::error(format!("read error: {}", e)),
Err(_) => Response::error("read timeout"),
};
// Write response with timeout
let json = serde_json::to_string(&response)?;
let write_result = tokio::time::timeout(IO_TIMEOUT, async {
writer.write_all(json.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.shutdown().await?;
Ok::<_, std::io::Error>(())
})
.await;
if let Err(_) | Ok(Err(_)) = write_result {
debug!("Control socket write failed or timed out");
}
Ok(())
}
/// Get the socket path. /// Get the socket path.
pub fn socket_path(&self) -> &Path { pub fn socket_path(&self) -> &Path {
&self.socket_path &self.socket_path
@@ -247,10 +265,143 @@ impl ControlSocket {
} }
} }
} }
} }
impl Drop for ControlSocket { impl Drop for ControlSocket {
fn drop(&mut self) { fn drop(&mut self) {
self.cleanup(); self.cleanup();
} }
}
}
// ============================================================================
// Windows implementation (TCP on localhost)
// ============================================================================
#[cfg(windows)]
mod windows_impl {
use super::*;
use tokio::net::TcpListener;
/// Default TCP port for the control socket on Windows.
const DEFAULT_CONTROL_PORT: u16 = 21210;
/// Control socket listener (Windows TCP on localhost).
///
/// On Windows, the control socket uses a TCP listener bound to
/// `127.0.0.1` since Windows does not support Unix domain sockets
/// reliably. Only localhost connections are accepted.
///
/// Note: Unlike Unix domain sockets, TCP does not provide filesystem-level
/// ACLs. Any local user can connect to the control port. This is acceptable
/// for single-user Windows installations but should be documented.
pub struct ControlSocket {
listener: TcpListener,
port: u16,
}
impl ControlSocket {
/// Bind a TCP control socket on localhost.
///
/// Parses the port from `config.socket_path` (which is a port number
/// string on Windows, e.g. "21210"). Falls back to the default port
/// with a warning if parsing fails.
pub fn bind(config: &ControlConfig) -> Result<Self, std::io::Error> {
let port: u16 = match config.socket_path.parse() {
Ok(p) => p,
Err(e) => {
warn!(
path = %config.socket_path,
error = %e,
default = DEFAULT_CONTROL_PORT,
"Invalid control port, using default"
);
DEFAULT_CONTROL_PORT
}
};
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
let std_listener = std::net::TcpListener::bind(addr)?;
std_listener.set_nonblocking(true)?;
let listener = TcpListener::from_std(std_listener)?;
info!(port = port, "Control socket listening on localhost");
Ok(Self { listener, port })
}
/// Get the listening port.
pub fn port(&self) -> u16 {
self.port
}
/// Run the accept loop, forwarding requests to the main event loop via mpsc.
///
/// Each accepted connection is handled in a spawned task using the
/// shared `handle_connection_generic` protocol handler.
pub async fn accept_loop(self, control_tx: mpsc::Sender<ControlMessage>) {
loop {
let (stream, addr) = match self.listener.accept().await {
Ok(conn) => conn,
Err(e) => {
warn!(error = %e, "Control socket accept failed");
continue;
}
};
// Only accept connections from localhost
if !addr.ip().is_loopback() {
warn!(addr = %addr, "Rejected non-localhost control connection");
continue;
}
let tx = control_tx.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection_generic(stream, tx).await {
debug!(error = %e, "Control connection error");
}
});
}
}
}
}
// Re-export platform-specific types
#[cfg(unix)]
pub use unix_impl::ControlSocket;
#[cfg(windows)]
pub use windows_impl::ControlSocket;
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
#[tokio::test]
async fn test_tcp_control_socket_bind() {
let config = ControlConfig {
enabled: true,
socket_path: "0".to_string(), // port 0 = ephemeral
};
// Verify the socket binds successfully on an ephemeral port
let _socket = ControlSocket::bind(&config).expect("failed to bind control socket");
}
#[cfg(windows)]
#[tokio::test]
async fn test_tcp_control_socket_invalid_port_uses_default() {
let config = ControlConfig {
enabled: true,
socket_path: "not-a-port".to_string(),
};
// Should fall back to default port 21210. This may fail if 21210
// is already in use, which is acceptable for a unit test.
let result = ControlSocket::bind(&config);
// We mainly verify it doesn't panic on invalid input
if let Ok(socket) = result {
assert_eq!(socket.port(), 21210);
}
}
} }

View File

@@ -30,6 +30,7 @@ use crate::bloom::BloomState;
use crate::cache::CoordCache; use crate::cache::CoordCache;
use crate::node::session::SessionEntry; use crate::node::session::SessionEntry;
use crate::peer::{ActivePeer, PeerConnection}; use crate::peer::{ActivePeer, PeerConnection};
#[cfg(unix)]
use crate::transport::ethernet::EthernetTransport; use crate::transport::ethernet::EthernetTransport;
use crate::transport::tcp::TcpTransport; use crate::transport::tcp::TcpTransport;
use crate::transport::tor::TorTransport; use crate::transport::tor::TorTransport;
@@ -705,7 +706,9 @@ impl Node {
transports.push(TransportHandle::Udp(udp)); transports.push(TransportHandle::Udp(udp));
} }
// Create Ethernet transport instances // Create Ethernet transport instances (Unix only — requires raw sockets)
#[cfg(unix)]
{
let eth_instances: Vec<_> = self let eth_instances: Vec<_> = self
.config .config
.transports .transports
@@ -716,10 +719,12 @@ impl Node {
let xonly = self.identity.pubkey(); let xonly = self.identity.pubkey();
for (name, eth_config) in eth_instances { for (name, eth_config) in eth_instances {
let transport_id = self.allocate_transport_id(); let transport_id = self.allocate_transport_id();
let mut eth = EthernetTransport::new(transport_id, name, eth_config, packet_tx.clone()); let mut eth =
EthernetTransport::new(transport_id, name, eth_config, packet_tx.clone());
eth.set_local_pubkey(xonly); eth.set_local_pubkey(xonly);
transports.push(TransportHandle::Ethernet(eth)); transports.push(TransportHandle::Ethernet(eth));
} }
}
// Create TCP transport instances // Create TCP transport instances
let tcp_instances: Vec<_> = self let tcp_instances: Vec<_> = self
@@ -812,10 +817,13 @@ impl Node {
/// ///
/// Finds the Ethernet transport instance bound to the named interface /// Finds the Ethernet transport instance bound to the named interface
/// and parses the MAC portion into a 6-byte TransportAddr. /// and parses the MAC portion into a 6-byte TransportAddr.
#[allow(unused_variables)]
fn resolve_ethernet_addr( fn resolve_ethernet_addr(
&self, &self,
addr_str: &str, addr_str: &str,
) -> Result<(TransportId, TransportAddr), NodeError> { ) -> Result<(TransportId, TransportAddr), NodeError> {
#[cfg(unix)]
{
let (iface, mac_str) = addr_str.split_once('/').ok_or_else(|| { let (iface, mac_str) = addr_str.split_once('/').ok_or_else(|| {
NodeError::NoTransportForType(format!( NodeError::NoTransportForType(format!(
"invalid Ethernet address format '{}': expected 'interface/mac'", "invalid Ethernet address format '{}': expected 'interface/mac'",
@@ -846,6 +854,13 @@ impl Node {
Ok((transport_id, TransportAddr::from_bytes(&mac))) Ok((transport_id, TransportAddr::from_bytes(&mac)))
} }
#[cfg(not(unix))]
{
Err(NodeError::NoTransportForType(
"Ethernet transport is not supported on this platform".to_string(),
))
}
}
/// Resolve a BLE address string (`"adapter/AA:BB:CC:DD:EE:FF"`) to a /// Resolve a BLE address string (`"adapter/AA:BB:CC:DD:EE:FF"`) to a
/// (TransportId, TransportAddr) pair by finding the BLE transport /// (TransportId, TransportAddr) pair by finding the BLE transport

View File

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

View File

@@ -18,6 +18,7 @@ mod platform;
#[path = "socket_macos.rs"] #[path = "socket_macos.rs"]
mod platform; mod platform;
#[cfg(unix)]
pub use platform::PacketSocket; pub use platform::PacketSocket;
// ============================================================================= // =============================================================================
@@ -263,11 +264,23 @@ mod async_impl {
} }
} }
#[cfg(unix)]
pub use async_impl::AsyncPacketSocket; pub use async_impl::AsyncPacketSocket;
#[cfg(unix)]
impl PacketSocket { impl PacketSocket {
/// Wrap this socket in an async wrapper for tokio integration. /// Wrap this socket in an async wrapper for tokio integration.
pub fn into_async(self) -> Result<AsyncPacketSocket, TransportError> { pub fn into_async(self) -> Result<AsyncPacketSocket, TransportError> {
AsyncPacketSocket::new(self) AsyncPacketSocket::new(self)
} }
} }
// =============================================================================
// Windows: stub types (Ethernet not supported on Windows)
// =============================================================================
#[cfg(windows)]
pub struct PacketSocket;
#[cfg(windows)]
pub struct AsyncPacketSocket;

View File

@@ -8,6 +8,7 @@ pub mod tcp;
pub mod tor; pub mod tor;
pub mod udp; pub mod udp;
#[cfg(unix)]
pub mod ethernet; pub mod ethernet;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -15,6 +16,7 @@ pub mod ble;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use ble::DefaultBleTransport; use ble::DefaultBleTransport;
#[cfg(unix)]
use ethernet::EthernetTransport; use ethernet::EthernetTransport;
use secp256k1::XOnlyPublicKey; use secp256k1::XOnlyPublicKey;
use std::fmt; use std::fmt;
@@ -851,6 +853,7 @@ pub enum TransportHandle {
/// UDP/IP transport. /// UDP/IP transport.
Udp(UdpTransport), Udp(UdpTransport),
/// Raw Ethernet transport. /// Raw Ethernet transport.
#[cfg(unix)]
Ethernet(EthernetTransport), Ethernet(EthernetTransport),
/// TCP/IP transport. /// TCP/IP transport.
Tcp(TcpTransport), Tcp(TcpTransport),
@@ -866,6 +869,7 @@ impl TransportHandle {
pub async fn start(&mut self) -> Result<(), TransportError> { pub async fn start(&mut self) -> Result<(), TransportError> {
match self { match self {
TransportHandle::Udp(t) => t.start_async().await, TransportHandle::Udp(t) => t.start_async().await,
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.start_async().await, TransportHandle::Ethernet(t) => t.start_async().await,
TransportHandle::Tcp(t) => t.start_async().await, TransportHandle::Tcp(t) => t.start_async().await,
TransportHandle::Tor(t) => t.start_async().await, TransportHandle::Tor(t) => t.start_async().await,
@@ -878,6 +882,7 @@ impl TransportHandle {
pub async fn stop(&mut self) -> Result<(), TransportError> { pub async fn stop(&mut self) -> Result<(), TransportError> {
match self { match self {
TransportHandle::Udp(t) => t.stop_async().await, TransportHandle::Udp(t) => t.stop_async().await,
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.stop_async().await, TransportHandle::Ethernet(t) => t.stop_async().await,
TransportHandle::Tcp(t) => t.stop_async().await, TransportHandle::Tcp(t) => t.stop_async().await,
TransportHandle::Tor(t) => t.stop_async().await, TransportHandle::Tor(t) => t.stop_async().await,
@@ -890,6 +895,7 @@ impl TransportHandle {
pub async fn send(&self, addr: &TransportAddr, data: &[u8]) -> Result<usize, TransportError> { pub async fn send(&self, addr: &TransportAddr, data: &[u8]) -> Result<usize, TransportError> {
match self { match self {
TransportHandle::Udp(t) => t.send_async(addr, data).await, TransportHandle::Udp(t) => t.send_async(addr, data).await,
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.send_async(addr, data).await, TransportHandle::Ethernet(t) => t.send_async(addr, data).await,
TransportHandle::Tcp(t) => t.send_async(addr, data).await, TransportHandle::Tcp(t) => t.send_async(addr, data).await,
TransportHandle::Tor(t) => t.send_async(addr, data).await, TransportHandle::Tor(t) => t.send_async(addr, data).await,
@@ -902,6 +908,7 @@ impl TransportHandle {
pub fn transport_id(&self) -> TransportId { pub fn transport_id(&self) -> TransportId {
match self { match self {
TransportHandle::Udp(t) => t.transport_id(), TransportHandle::Udp(t) => t.transport_id(),
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.transport_id(), TransportHandle::Ethernet(t) => t.transport_id(),
TransportHandle::Tcp(t) => t.transport_id(), TransportHandle::Tcp(t) => t.transport_id(),
TransportHandle::Tor(t) => t.transport_id(), TransportHandle::Tor(t) => t.transport_id(),
@@ -914,6 +921,7 @@ impl TransportHandle {
pub fn name(&self) -> Option<&str> { pub fn name(&self) -> Option<&str> {
match self { match self {
TransportHandle::Udp(t) => t.name(), TransportHandle::Udp(t) => t.name(),
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.name(), TransportHandle::Ethernet(t) => t.name(),
TransportHandle::Tcp(t) => t.name(), TransportHandle::Tcp(t) => t.name(),
TransportHandle::Tor(t) => t.name(), TransportHandle::Tor(t) => t.name(),
@@ -926,6 +934,7 @@ impl TransportHandle {
pub fn transport_type(&self) -> &TransportType { pub fn transport_type(&self) -> &TransportType {
match self { match self {
TransportHandle::Udp(t) => t.transport_type(), TransportHandle::Udp(t) => t.transport_type(),
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.transport_type(), TransportHandle::Ethernet(t) => t.transport_type(),
TransportHandle::Tcp(t) => t.transport_type(), TransportHandle::Tcp(t) => t.transport_type(),
TransportHandle::Tor(t) => t.transport_type(), TransportHandle::Tor(t) => t.transport_type(),
@@ -938,6 +947,7 @@ impl TransportHandle {
pub fn state(&self) -> TransportState { pub fn state(&self) -> TransportState {
match self { match self {
TransportHandle::Udp(t) => t.state(), TransportHandle::Udp(t) => t.state(),
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.state(), TransportHandle::Ethernet(t) => t.state(),
TransportHandle::Tcp(t) => t.state(), TransportHandle::Tcp(t) => t.state(),
TransportHandle::Tor(t) => t.state(), TransportHandle::Tor(t) => t.state(),
@@ -950,6 +960,7 @@ impl TransportHandle {
pub fn mtu(&self) -> u16 { pub fn mtu(&self) -> u16 {
match self { match self {
TransportHandle::Udp(t) => t.mtu(), TransportHandle::Udp(t) => t.mtu(),
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.mtu(), TransportHandle::Ethernet(t) => t.mtu(),
TransportHandle::Tcp(t) => t.mtu(), TransportHandle::Tcp(t) => t.mtu(),
TransportHandle::Tor(t) => t.mtu(), TransportHandle::Tor(t) => t.mtu(),
@@ -965,6 +976,7 @@ impl TransportHandle {
pub fn link_mtu(&self, addr: &TransportAddr) -> u16 { pub fn link_mtu(&self, addr: &TransportAddr) -> u16 {
match self { match self {
TransportHandle::Udp(t) => t.link_mtu(addr), TransportHandle::Udp(t) => t.link_mtu(addr),
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.link_mtu(addr), TransportHandle::Ethernet(t) => t.link_mtu(addr),
TransportHandle::Tcp(t) => t.link_mtu(addr), TransportHandle::Tcp(t) => t.link_mtu(addr),
TransportHandle::Tor(t) => t.link_mtu(addr), TransportHandle::Tor(t) => t.link_mtu(addr),
@@ -977,6 +989,7 @@ impl TransportHandle {
pub fn local_addr(&self) -> Option<std::net::SocketAddr> { pub fn local_addr(&self) -> Option<std::net::SocketAddr> {
match self { match self {
TransportHandle::Udp(t) => t.local_addr(), TransportHandle::Udp(t) => t.local_addr(),
#[cfg(unix)]
TransportHandle::Ethernet(_) => None, TransportHandle::Ethernet(_) => None,
TransportHandle::Tcp(t) => t.local_addr(), TransportHandle::Tcp(t) => t.local_addr(),
TransportHandle::Tor(_) => None, TransportHandle::Tor(_) => None,
@@ -989,6 +1002,7 @@ impl TransportHandle {
pub fn interface_name(&self) -> Option<&str> { pub fn interface_name(&self) -> Option<&str> {
match self { match self {
TransportHandle::Udp(_) => None, TransportHandle::Udp(_) => None,
#[cfg(unix)]
TransportHandle::Ethernet(t) => Some(t.interface_name()), TransportHandle::Ethernet(t) => Some(t.interface_name()),
TransportHandle::Tcp(_) => None, TransportHandle::Tcp(_) => None,
TransportHandle::Tor(_) => None, TransportHandle::Tor(_) => None,
@@ -1025,6 +1039,7 @@ impl TransportHandle {
pub fn discover(&self) -> Result<Vec<DiscoveredPeer>, TransportError> { pub fn discover(&self) -> Result<Vec<DiscoveredPeer>, TransportError> {
match self { match self {
TransportHandle::Udp(t) => t.discover(), TransportHandle::Udp(t) => t.discover(),
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.discover(), TransportHandle::Ethernet(t) => t.discover(),
TransportHandle::Tcp(t) => t.discover(), TransportHandle::Tcp(t) => t.discover(),
TransportHandle::Tor(t) => t.discover(), TransportHandle::Tor(t) => t.discover(),
@@ -1037,6 +1052,7 @@ impl TransportHandle {
pub fn auto_connect(&self) -> bool { pub fn auto_connect(&self) -> bool {
match self { match self {
TransportHandle::Udp(t) => t.auto_connect(), TransportHandle::Udp(t) => t.auto_connect(),
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.auto_connect(), TransportHandle::Ethernet(t) => t.auto_connect(),
TransportHandle::Tcp(t) => t.auto_connect(), TransportHandle::Tcp(t) => t.auto_connect(),
TransportHandle::Tor(t) => t.auto_connect(), TransportHandle::Tor(t) => t.auto_connect(),
@@ -1049,6 +1065,7 @@ impl TransportHandle {
pub fn accept_connections(&self) -> bool { pub fn accept_connections(&self) -> bool {
match self { match self {
TransportHandle::Udp(t) => t.accept_connections(), TransportHandle::Udp(t) => t.accept_connections(),
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.accept_connections(), TransportHandle::Ethernet(t) => t.accept_connections(),
TransportHandle::Tcp(t) => t.accept_connections(), TransportHandle::Tcp(t) => t.accept_connections(),
TransportHandle::Tor(t) => t.accept_connections(), TransportHandle::Tor(t) => t.accept_connections(),
@@ -1067,6 +1084,7 @@ impl TransportHandle {
pub async fn connect(&self, addr: &TransportAddr) -> Result<(), TransportError> { pub async fn connect(&self, addr: &TransportAddr) -> Result<(), TransportError> {
match self { match self {
TransportHandle::Udp(_) => Ok(()), // connectionless TransportHandle::Udp(_) => Ok(()), // connectionless
#[cfg(unix)]
TransportHandle::Ethernet(_) => Ok(()), // connectionless TransportHandle::Ethernet(_) => Ok(()), // connectionless
TransportHandle::Tcp(t) => t.connect_async(addr).await, TransportHandle::Tcp(t) => t.connect_async(addr).await,
TransportHandle::Tor(t) => t.connect_async(addr).await, TransportHandle::Tor(t) => t.connect_async(addr).await,
@@ -1083,6 +1101,7 @@ impl TransportHandle {
pub fn connection_state(&self, addr: &TransportAddr) -> ConnectionState { pub fn connection_state(&self, addr: &TransportAddr) -> ConnectionState {
match self { match self {
TransportHandle::Udp(_) => ConnectionState::Connected, TransportHandle::Udp(_) => ConnectionState::Connected,
#[cfg(unix)]
TransportHandle::Ethernet(_) => ConnectionState::Connected, TransportHandle::Ethernet(_) => ConnectionState::Connected,
TransportHandle::Tcp(t) => t.connection_state_sync(addr), TransportHandle::Tcp(t) => t.connection_state_sync(addr),
TransportHandle::Tor(t) => t.connection_state_sync(addr), TransportHandle::Tor(t) => t.connection_state_sync(addr),
@@ -1098,6 +1117,7 @@ impl TransportHandle {
pub async fn close_connection(&self, addr: &TransportAddr) { pub async fn close_connection(&self, addr: &TransportAddr) {
match self { match self {
TransportHandle::Udp(t) => t.close_connection(addr), TransportHandle::Udp(t) => t.close_connection(addr),
#[cfg(unix)]
TransportHandle::Ethernet(t) => t.close_connection(addr), TransportHandle::Ethernet(t) => t.close_connection(addr),
TransportHandle::Tcp(t) => t.close_connection_async(addr).await, TransportHandle::Tcp(t) => t.close_connection_async(addr).await,
TransportHandle::Tor(t) => t.close_connection_async(addr).await, TransportHandle::Tor(t) => t.close_connection_async(addr).await,
@@ -1119,6 +1139,7 @@ impl TransportHandle {
pub fn congestion(&self) -> TransportCongestion { pub fn congestion(&self) -> TransportCongestion {
match self { match self {
TransportHandle::Udp(t) => t.congestion(), TransportHandle::Udp(t) => t.congestion(),
#[cfg(unix)]
TransportHandle::Ethernet(_) => TransportCongestion::default(), TransportHandle::Ethernet(_) => TransportCongestion::default(),
TransportHandle::Tcp(_) => TransportCongestion::default(), TransportHandle::Tcp(_) => TransportCongestion::default(),
TransportHandle::Tor(_) => TransportCongestion::default(), TransportHandle::Tor(_) => TransportCongestion::default(),
@@ -1135,6 +1156,7 @@ impl TransportHandle {
TransportHandle::Udp(t) => { TransportHandle::Udp(t) => {
serde_json::to_value(t.stats().snapshot()).unwrap_or_default() serde_json::to_value(t.stats().snapshot()).unwrap_or_default()
} }
#[cfg(unix)]
TransportHandle::Ethernet(t) => { TransportHandle::Ethernet(t) => {
let snap = t.stats().snapshot(); let snap = t.stats().snapshot();
serde_json::json!({ serde_json::json!({

View File

@@ -138,11 +138,11 @@ impl TorControlClient {
/// permission-based access control and are not reachable from containers /// permission-based access control and are not reachable from containers
/// unless explicitly mounted. The Debian default is `/run/tor/control`. /// unless explicitly mounted. The Debian default is `/run/tor/control`.
pub async fn connect(addr: &str) -> Result<Self, TorControlError> { pub async fn connect(addr: &str) -> Result<Self, TorControlError> {
#[cfg(unix)]
if is_unix_socket_path(addr) { if is_unix_socket_path(addr) {
Self::connect_unix(addr).await return Self::connect_unix(addr).await;
} else {
Self::connect_tcp(addr).await
} }
Self::connect_tcp(addr).await
} }
/// Connect via TCP to a control port at `host:port`. /// Connect via TCP to a control port at `host:port`.
@@ -184,14 +184,6 @@ impl TorControlClient {
}) })
} }
#[cfg(not(unix))]
async fn connect_unix(path: &str) -> Result<Self, TorControlError> {
Err(TorControlError::ConnectionFailed(format!(
"Unix sockets not supported on this platform: {}",
path
)))
}
/// Authenticate with the Tor daemon. /// Authenticate with the Tor daemon.
pub async fn authenticate(&mut self, auth: &ControlAuth) -> Result<(), TorControlError> { pub async fn authenticate(&mut self, auth: &ControlAuth) -> Result<(), TorControlError> {
let command = match auth { let command = match auth {
@@ -483,6 +475,7 @@ fn read_cookie_file(path: &Path) -> Result<Vec<u8>, TorControlError> {
/// ///
/// Returns true if the string starts with `/` or `./`, indicating a /// Returns true if the string starts with `/` or `./`, indicating a
/// filesystem path rather than a `host:port` TCP address. /// filesystem path rather than a `host:port` TCP address.
#[cfg(unix)]
fn is_unix_socket_path(addr: &str) -> bool { fn is_unix_socket_path(addr: &str) -> bool {
addr.starts_with('/') || addr.starts_with("./") addr.starts_with('/') || addr.starts_with("./")
} }
@@ -559,6 +552,7 @@ mod tests {
// === Unix socket path detection === // === Unix socket path detection ===
#[cfg(unix)]
#[test] #[test]
fn test_is_unix_socket_path() { fn test_is_unix_socket_path() {
assert!(is_unix_socket_path("/run/tor/control")); assert!(is_unix_socket_path("/run/tor/control"));
@@ -569,6 +563,7 @@ mod tests {
assert!(!is_unix_socket_path("localhost:9051")); assert!(!is_unix_socket_path("localhost:9051"));
} }
#[cfg(unix)]
#[tokio::test] #[tokio::test]
async fn test_connect_unix_socket_nonexistent() { async fn test_connect_unix_socket_nonexistent() {
let result = TorControlClient::connect("/tmp/nonexistent-tor-control.sock").await; let result = TorControlClient::connect("/tmp/nonexistent-tor-control.sock").await;
@@ -577,6 +572,7 @@ mod tests {
assert!(err.contains("control socket")); assert!(err.contains("control socket"));
} }
#[cfg(unix)]
#[tokio::test] #[tokio::test]
async fn test_connect_unix_socket_roundtrip() { async fn test_connect_unix_socket_roundtrip() {
// Create a Unix socket listener, accept a connection, respond to AUTHENTICATE // Create a Unix socket listener, accept a connection, respond to AUTHENTICATE

View File

@@ -1,28 +1,42 @@
//! UDP socket wrapper with `SO_RXQ_OVFL` kernel drop counter support. //! UDP socket wrapper with platform-specific receive implementations.
//! //!
//! Provides sync send/recv operations over a `socket2::Socket` with //! On Linux, provides `SO_RXQ_OVFL` kernel drop counter support via
//! `recvmsg()` ancillary data parsing for the kernel receive buffer //! `recvmsg()` ancillary data parsing. The async wrapper uses
//! drop counter. The async wrapper uses `tokio::io::unix::AsyncFd` //! `tokio::io::unix::AsyncFd` for integration with the tokio runtime.
//! for integration with the tokio runtime. //!
//! On macOS, uses the same `recvmsg()` path but without `SO_RXQ_OVFL`
//! (kernel drop counting is not available; the drops field returns 0).
//!
//! On Windows, uses `tokio::net::UdpSocket` directly (kernel drop
//! counting is not available; the drops field always returns 0).
//! //!
//! Follows the pattern established by `transport/ethernet/socket.rs`. //! Follows the pattern established by `transport/ethernet/socket.rs`.
use crate::transport::TransportError; use crate::transport::TransportError;
use socket2::{Domain, Protocol, Socket, Type}; use socket2::{Domain, Protocol, Socket, Type};
use std::net::SocketAddr; use std::net::SocketAddr;
use std::os::unix::io::{AsRawFd, RawFd};
use std::sync::Arc; use std::sync::Arc;
use tokio::io::unix::AsyncFd; #[cfg(unix)]
use tracing::warn; use tracing::warn;
/// Wrapper around a `socket2::Socket` providing sync send/recv with // ============================================================================
/// `SO_RXQ_OVFL` ancillary data parsing. // Unix implementation
pub struct UdpRawSocket { // ============================================================================
#[cfg(unix)]
mod platform {
use super::*;
use std::os::unix::io::{AsRawFd, RawFd};
use tokio::io::unix::AsyncFd;
/// Wrapper around a `socket2::Socket` providing sync send/recv with
/// `SO_RXQ_OVFL` ancillary data parsing.
pub struct UdpRawSocket {
inner: Socket, inner: Socket,
local_addr: SocketAddr, local_addr: SocketAddr,
} }
impl UdpRawSocket { impl UdpRawSocket {
/// Create, bind, and configure a UDP socket. /// Create, bind, and configure a UDP socket.
/// ///
/// Enables `SO_RXQ_OVFL` for kernel drop counting (non-fatal if /// Enables `SO_RXQ_OVFL` for kernel drop counting (non-fatal if
@@ -40,8 +54,9 @@ impl UdpRawSocket {
let sock = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP)) let sock = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP))
.map_err(|e| TransportError::StartFailed(format!("socket create failed: {}", e)))?; .map_err(|e| TransportError::StartFailed(format!("socket create failed: {}", e)))?;
sock.set_nonblocking(true) sock.set_nonblocking(true).map_err(|e| {
.map_err(|e| TransportError::StartFailed(format!("set nonblocking failed: {}", e)))?; TransportError::StartFailed(format!("set nonblocking failed: {}", e))
})?;
sock.bind(&bind_addr.into()) sock.bind(&bind_addr.into())
.map_err(|e| TransportError::StartFailed(format!("bind failed: {}", e)))?; .map_err(|e| TransportError::StartFailed(format!("bind failed: {}", e)))?;
@@ -184,7 +199,8 @@ impl UdpRawSocket {
unsafe { unsafe {
let mut cmsg = libc::CMSG_FIRSTHDR(&msg); let mut cmsg = libc::CMSG_FIRSTHDR(&msg);
while !cmsg.is_null() { while !cmsg.is_null() {
if (*cmsg).cmsg_level == libc::SOL_SOCKET && (*cmsg).cmsg_type == libc::SO_RXQ_OVFL if (*cmsg).cmsg_level == libc::SOL_SOCKET
&& (*cmsg).cmsg_type == libc::SO_RXQ_OVFL
{ {
let data = libc::CMSG_DATA(cmsg); let data = libc::CMSG_DATA(cmsg);
drops = std::ptr::read_unaligned(data as *const u32); drops = std::ptr::read_unaligned(data as *const u32);
@@ -204,26 +220,30 @@ impl UdpRawSocket {
inner: Arc::new(async_fd), inner: Arc::new(async_fd),
}) })
} }
} }
impl AsRawFd for UdpRawSocket { impl AsRawFd for UdpRawSocket {
fn as_raw_fd(&self) -> RawFd { fn as_raw_fd(&self) -> RawFd {
self.inner.as_raw_fd() self.inner.as_raw_fd()
} }
} }
/// Async wrapper around `UdpRawSocket` using tokio's `AsyncFd`. /// Async wrapper around `UdpRawSocket` using tokio's `AsyncFd`.
/// ///
/// `Arc`-shareable between send and receive tasks. `AsyncFd<T>` is /// `Arc`-shareable between send and receive tasks. `AsyncFd<T>` is
/// `Sync` when `T: Send`, which `socket2::Socket` satisfies. /// `Sync` when `T: Send`, which `socket2::Socket` satisfies.
#[derive(Clone)] #[derive(Clone)]
pub struct AsyncUdpSocket { pub struct AsyncUdpSocket {
inner: Arc<AsyncFd<UdpRawSocket>>, inner: Arc<AsyncFd<UdpRawSocket>>,
} }
impl AsyncUdpSocket { impl AsyncUdpSocket {
/// Send a payload to a destination address. /// Send a payload to a destination address.
pub async fn send_to(&self, data: &[u8], dest: &SocketAddr) -> Result<usize, TransportError> { pub async fn send_to(
&self,
data: &[u8],
dest: &SocketAddr,
) -> Result<usize, TransportError> {
loop { loop {
let mut guard = self let mut guard = self
.inner .inner
@@ -260,10 +280,10 @@ impl AsyncUdpSocket {
} }
} }
} }
} }
/// Convert a `libc::sockaddr_storage` to `std::net::SocketAddr`. /// Convert a `libc::sockaddr_storage` to `std::net::SocketAddr`.
fn sockaddr_to_socket_addr(storage: &libc::sockaddr_storage) -> std::io::Result<SocketAddr> { fn sockaddr_to_socket_addr(storage: &libc::sockaddr_storage) -> std::io::Result<SocketAddr> {
match storage.ss_family as libc::c_int { match storage.ss_family as libc::c_int {
libc::AF_INET => { libc::AF_INET => {
let addr: &libc::sockaddr_in = let addr: &libc::sockaddr_in =
@@ -284,4 +304,194 @@ fn sockaddr_to_socket_addr(storage: &libc::sockaddr_storage) -> std::io::Result<
format!("unsupported address family: {}", family), format!("unsupported address family: {}", family),
)), )),
} }
}
}
// ============================================================================
// Windows implementation
// ============================================================================
#[cfg(windows)]
mod platform {
use super::*;
/// UDP socket wrapper (Windows).
///
/// Uses `socket2::Socket` for configuration and `tokio::net::UdpSocket`
/// for async I/O. Kernel drop counting is not available on Windows;
/// the drops field always returns 0.
pub struct UdpRawSocket {
inner: Socket,
local_addr: SocketAddr,
}
impl UdpRawSocket {
/// Create, bind, and configure a UDP socket.
///
/// Sets non-blocking mode and configures buffer sizes. The socket
/// is bound immediately so `local_addr()` returns the actual
/// assigned address (important when binding to port 0).
pub fn open(
bind_addr: SocketAddr,
recv_buf_size: usize,
send_buf_size: usize,
) -> Result<Self, TransportError> {
let domain = if bind_addr.is_ipv4() {
Domain::IPV4
} else {
Domain::IPV6
};
let sock = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP))
.map_err(|e| TransportError::StartFailed(format!("socket create failed: {}", e)))?;
sock.set_nonblocking(true).map_err(|e| {
TransportError::StartFailed(format!("set nonblocking failed: {}", e))
})?;
sock.bind(&bind_addr.into())
.map_err(|e| TransportError::StartFailed(format!("bind failed: {}", e)))?;
// Set socket buffer sizes
sock.set_recv_buffer_size(recv_buf_size)
.map_err(|e| TransportError::StartFailed(format!("set recv buffer: {}", e)))?;
sock.set_send_buffer_size(send_buf_size)
.map_err(|e| TransportError::StartFailed(format!("set send buffer: {}", e)))?;
let local_addr = sock
.local_addr()
.map_err(|e| TransportError::StartFailed(format!("get local addr: {}", e)))?
.as_socket()
.ok_or_else(|| {
TransportError::StartFailed("local address is not an IP socket".into())
})?;
Ok(Self {
inner: sock,
local_addr,
})
}
/// Get the local bound address.
pub fn local_addr(&self) -> SocketAddr {
self.local_addr
}
/// Get the actual receive buffer size.
pub fn recv_buffer_size(&self) -> Result<usize, TransportError> {
self.inner
.recv_buffer_size()
.map_err(|e| TransportError::StartFailed(format!("get recv buffer: {}", e)))
}
/// Get the actual send buffer size.
pub fn send_buffer_size(&self) -> Result<usize, TransportError> {
self.inner
.send_buffer_size()
.map_err(|e| TransportError::StartFailed(format!("get send buffer: {}", e)))
}
/// Wrap this socket in an async wrapper for tokio I/O.
pub fn into_async(self) -> Result<AsyncUdpSocket, TransportError> {
let std_socket: std::net::UdpSocket = self.inner.into();
let tokio_socket = tokio::net::UdpSocket::from_std(std_socket)
.map_err(|e| TransportError::StartFailed(format!("tokio socket failed: {}", e)))?;
Ok(AsyncUdpSocket {
inner: Arc::new(tokio_socket),
})
}
}
/// Async UDP socket wrapper (Windows).
///
/// Uses `tokio::net::UdpSocket` directly. Kernel drop counting
/// is not available; the drops field always returns 0.
#[derive(Clone)]
pub struct AsyncUdpSocket {
inner: Arc<tokio::net::UdpSocket>,
}
impl AsyncUdpSocket {
/// Send a payload to a destination address.
pub async fn send_to(
&self,
data: &[u8],
dest: &SocketAddr,
) -> Result<usize, TransportError> {
self.inner
.send_to(data, dest)
.await
.map_err(|e| TransportError::SendFailed(format!("{}", e)))
}
/// Receive a payload, source address, and kernel drop counter.
///
/// Returns `(bytes_read, source_addr, 0)`. The drops field is always 0
/// on Windows since kernel drop counting is not available.
pub async fn recv_from(
&self,
buf: &mut [u8],
) -> Result<(usize, SocketAddr, u32), TransportError> {
let (n, addr) = self
.inner
.recv_from(buf)
.await
.map_err(|e| TransportError::RecvFailed(format!("{}", e)))?;
Ok((n, addr, 0))
}
}
}
pub use platform::{AsyncUdpSocket, UdpRawSocket};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_udp_socket_bind() {
// Bind to an ephemeral port
let sock = UdpRawSocket::open("127.0.0.1:0".parse().unwrap(), 65536, 65536)
.expect("failed to bind UDP socket");
let addr = sock.local_addr();
assert!(addr.port() > 0, "should be assigned an ephemeral port");
assert!(addr.ip().is_loopback());
}
#[test]
fn test_udp_socket_buffer_sizes() {
let sock = UdpRawSocket::open("127.0.0.1:0".parse().unwrap(), 65536, 65536)
.expect("failed to bind UDP socket");
let recv_buf = sock.recv_buffer_size().expect("get recv buffer");
let send_buf = sock.send_buffer_size().expect("get send buffer");
assert!(recv_buf > 0, "recv buffer should be non-zero");
assert!(send_buf > 0, "send buffer should be non-zero");
}
#[tokio::test]
async fn test_async_udp_socket_send_recv() {
let sock1 = UdpRawSocket::open("127.0.0.1:0".parse().unwrap(), 65536, 65536)
.expect("failed to bind socket 1");
let addr1 = sock1.local_addr();
let async1 = sock1.into_async().expect("into_async 1");
let sock2 = UdpRawSocket::open("127.0.0.1:0".parse().unwrap(), 65536, 65536)
.expect("failed to bind socket 2");
let addr2 = sock2.local_addr();
let async2 = sock2.into_async().expect("into_async 2");
// Send from socket 1 to socket 2
let payload = b"hello fips";
let sent = async1.send_to(payload, &addr2).await.expect("send_to");
assert_eq!(sent, payload.len());
// Receive on socket 2
let mut buf = [0u8; 1024];
let (n, src, _drops) = async2.recv_from(&mut buf).await.expect("recv_from");
assert_eq!(n, payload.len());
assert_eq!(&buf[..n], payload);
assert_eq!(src, addr1);
}
} }

View File

@@ -17,7 +17,10 @@ use std::time::SystemTime;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
/// Default path for the FIPS hosts file. /// Default path for the FIPS hosts file.
#[cfg(unix)]
pub const DEFAULT_HOSTS_PATH: &str = "/etc/fips/hosts"; pub const DEFAULT_HOSTS_PATH: &str = "/etc/fips/hosts";
#[cfg(windows)]
pub const DEFAULT_HOSTS_PATH: &str = r"C:\ProgramData\fips\hosts";
/// Bidirectional hostname ↔ npub mapping table. /// Bidirectional hostname ↔ npub mapping table.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]

View File

@@ -3,17 +3,34 @@
//! Manages the TUN device for sending and receiving IPv6 packets. //! Manages the TUN device for sending and receiving IPv6 packets.
//! The TUN interface presents FIPS addresses to the local system, //! The TUN interface presents FIPS addresses to the local system,
//! allowing standard socket applications to communicate over the mesh. //! allowing standard socket applications to communicate over the mesh.
//!
//! Platform-specific implementations:
//! - Linux: Uses the `tun` crate with `rtnetlink` for interface configuration
//! - macOS: Uses the `tun` crate with `ifconfig`/`route` for interface configuration
//! - Windows: Uses the `wintun` crate for TUN device support
#[cfg(windows)]
use crate::FipsAddress;
#[cfg(unix)]
use crate::{FipsAddress, TunConfig}; use crate::{FipsAddress, TunConfig};
#[cfg(unix)]
use std::fs::File; use std::fs::File;
#[cfg(unix)]
use std::io::Read; use std::io::Read;
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
#[cfg(unix)]
use std::io::Write; use std::io::Write;
use std::net::Ipv6Addr; use std::net::Ipv6Addr;
#[cfg(unix)]
use std::os::unix::io::{AsRawFd, FromRawFd}; use std::os::unix::io::{AsRawFd, FromRawFd};
use std::sync::mpsc; use std::sync::mpsc;
use thiserror::Error; use thiserror::Error;
use tracing::{debug, error, trace}; #[cfg(unix)]
use tracing::error;
use tracing::{debug, trace};
#[cfg(windows)]
use tracing::{error, warn};
#[cfg(unix)]
use tun::Layer; use tun::Layer;
/// Channel sender for packets to be written to TUN. /// Channel sender for packets to be written to TUN.
@@ -28,7 +45,7 @@ pub type TunOutboundRx = tokio::sync::mpsc::Receiver<Vec<u8>>;
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum TunError { pub enum TunError {
#[error("failed to create TUN device: {0}")] #[error("failed to create TUN device: {0}")]
Create(#[from] tun::Error), Create(#[source] Box<dyn std::error::Error + Send + Sync>),
#[error("failed to configure TUN device: {0}")] #[error("failed to configure TUN device: {0}")]
Configure(String), Configure(String),
@@ -43,10 +60,18 @@ pub enum TunError {
#[error("permission denied: {0}")] #[error("permission denied: {0}")]
PermissionDenied(String), PermissionDenied(String),
#[cfg(unix)]
#[error("IPv6 is disabled (set net.ipv6.conf.all.disable_ipv6=0)")] #[error("IPv6 is disabled (set net.ipv6.conf.all.disable_ipv6=0)")]
Ipv6Disabled, Ipv6Disabled,
} }
#[cfg(unix)]
impl From<tun::Error> for TunError {
fn from(e: tun::Error) -> Self {
TunError::Create(Box::new(e))
}
}
/// TUN device state. /// TUN device state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TunState { pub enum TunState {
@@ -71,7 +96,12 @@ impl std::fmt::Display for TunState {
} }
} }
// ============================================================================
// Unix (Linux + macOS) TUN implementation
// ============================================================================
/// FIPS TUN device wrapper. /// FIPS TUN device wrapper.
#[cfg(unix)]
pub struct TunDevice { pub struct TunDevice {
device: tun::Device, device: tun::Device,
name: String, name: String,
@@ -79,6 +109,7 @@ pub struct TunDevice {
address: FipsAddress, address: FipsAddress,
} }
#[cfg(unix)]
impl TunDevice { impl TunDevice {
/// Create or open a TUN device. /// Create or open a TUN device.
/// ///
@@ -227,6 +258,7 @@ impl TunDevice {
/// Multiple producers can send packets via the TunTx channel. /// Multiple producers can send packets via the TunTx channel.
/// ///
/// Also performs TCP MSS clamping on inbound SYN-ACK packets. /// Also performs TCP MSS clamping on inbound SYN-ACK packets.
#[cfg(unix)]
pub struct TunWriter { pub struct TunWriter {
file: File, file: File,
rx: mpsc::Receiver<Vec<u8>>, rx: mpsc::Receiver<Vec<u8>>,
@@ -234,6 +266,7 @@ pub struct TunWriter {
max_mss: u16, max_mss: u16,
} }
#[cfg(unix)]
impl TunWriter { impl TunWriter {
/// Run the writer loop. /// Run the writer loop.
/// ///
@@ -318,6 +351,7 @@ impl TunWriter {
/// The loop exits when the TUN interface is deleted (EFAULT) or an unrecoverable /// The loop exits when the TUN interface is deleted (EFAULT) or an unrecoverable
/// error occurs. /// error occurs.
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
#[cfg(unix)]
pub fn run_tun_reader( pub fn run_tun_reader(
mut device: TunDevice, mut device: TunDevice,
mtu: u16, mtu: u16,
@@ -326,7 +360,7 @@ pub fn run_tun_reader(
outbound_tx: TunOutboundTx, outbound_tx: TunOutboundTx,
transport_mtu: u16, transport_mtu: u16,
) { ) {
let (name, mut buf, max_mss) = tun_reader_setup(&device, mtu, transport_mtu); let (name, mut buf, max_mss) = tun_reader_setup(device.name(), mtu, transport_mtu);
loop { loop {
match device.read_packet(&mut buf) { match device.read_packet(&mut buf) {
@@ -387,7 +421,7 @@ pub fn run_tun_reader(
) { ) {
let _shutdown_fd = ShutdownFd(shutdown_fd); let _shutdown_fd = ShutdownFd(shutdown_fd);
let tun_fd = device.device().as_raw_fd(); let tun_fd = device.device().as_raw_fd();
let (name, mut buf, max_mss) = tun_reader_setup(&device, mtu, transport_mtu); let (name, mut buf, max_mss) = tun_reader_setup(device.name(), mtu, transport_mtu);
// Set TUN fd to non-blocking so we can use select + read without blocking // Set TUN fd to non-blocking so we can use select + read without blocking
// past the point where select returns readable. // past the point where select returns readable.
@@ -463,11 +497,11 @@ pub fn run_tun_reader(
// _shutdown_fd closes on drop // _shutdown_fd closes on drop
} }
/// Common setup for TUN reader: extracts name, allocates buffer, computes max MSS. /// Common setup for TUN reader: allocates buffer, computes max MSS.
fn tun_reader_setup(device: &TunDevice, mtu: u16, transport_mtu: u16) -> (String, Vec<u8>, u16) { fn tun_reader_setup(device_name: &str, mtu: u16, transport_mtu: u16) -> (String, Vec<u8>, u16) {
use super::icmp::effective_ipv6_mtu; use super::icmp::effective_ipv6_mtu;
let name = device.name().to_string(); let name = device_name.to_string();
let buf = vec![0u8; mtu as usize + 100]; let buf = vec![0u8; mtu as usize + 100];
const IPV6_HEADER: u16 = 40; const IPV6_HEADER: u16 = 40;
@@ -531,6 +565,17 @@ fn handle_tun_packet(
true true
} }
#[cfg(unix)]
impl std::fmt::Debug for TunDevice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TunDevice")
.field("name", &self.name)
.field("mtu", &self.mtu)
.field("address", &self.address)
.finish()
}
}
/// Log basic information about an IPv6 packet at TRACE level. /// Log basic information about an IPv6 packet at TRACE level.
pub fn log_ipv6_packet(packet: &[u8]) { pub fn log_ipv6_packet(packet: &[u8]) {
if packet.len() < 40 { if packet.len() < 40 {
@@ -570,6 +615,7 @@ pub fn log_ipv6_packet(packet: &[u8]) {
/// This deletes the interface, which will cause any blocking reads /// This deletes the interface, which will cause any blocking reads
/// to return an error. Use this for graceful shutdown when the TUN device /// to return an error. Use this for graceful shutdown when the TUN device
/// has been moved to another thread. /// has been moved to another thread.
#[cfg(unix)]
pub async fn shutdown_tun_interface(name: &str) -> Result<(), TunError> { pub async fn shutdown_tun_interface(name: &str) -> Result<(), TunError> {
debug!("Shutting down TUN interface {}", name); debug!("Shutting down TUN interface {}", name);
platform::delete_interface(name).await?; platform::delete_interface(name).await?;
@@ -577,7 +623,170 @@ pub async fn shutdown_tun_interface(name: &str) -> Result<(), TunError> {
Ok(()) Ok(())
} }
impl std::fmt::Debug for TunDevice { // ============================================================================
// Windows TUN implementation (wintun)
// ============================================================================
#[cfg(windows)]
mod windows_tun {
use super::*;
use crate::TunConfig;
use std::sync::Arc;
/// The Windows adapter name visible in network settings and used in netsh commands.
pub(crate) const ADAPTER_NAME: &str = "FIPS";
/// Wintun ring buffer capacity in bytes. Must be a power of 2 between
/// 0x20000 (128 KiB) and 0x4000000 (64 MiB). 2 MiB balances memory
/// usage against burst tolerance.
const WINTUN_RING_CAPACITY: u32 = 0x200000; // 2 MiB
/// FIPS TUN device wrapper (Windows/wintun).
///
/// Uses the wintun driver for userspace packet I/O on Windows. The wintun
/// DLL must be present in the executable's directory or system PATH.
/// Adapter creation requires Administrator privileges.
///
/// Unlike the Linux TUN which uses a file descriptor, wintun uses a
/// session-based API with ring buffers for packet exchange.
pub struct TunDevice {
session: Arc<wintun::Session>,
_adapter: Arc<wintun::Adapter>,
name: String,
mtu: u16,
address: FipsAddress,
}
impl TunDevice {
/// Create a wintun TUN adapter and configure it with an IPv6 address.
///
/// Loads the wintun DLL, creates (or reopens) a named adapter, starts
/// a session with a 2 MiB ring buffer, and configures the interface
/// via netsh. Requires Administrator privileges.
pub async fn create(config: &TunConfig, address: FipsAddress) -> Result<Self, TunError> {
let name = config.name();
let mtu = config.mtu();
// Load the wintun DLL
let wintun = unsafe { wintun::load() }.map_err(|e| {
TunError::Create(
format!(
"Failed to load wintun.dll: {}. Download from https://www.wintun.net/",
e
)
.into(),
)
})?;
// Create or reopen the adapter.
// First arg: adapter name visible in Windows network settings.
// Second arg: tunnel type (internal identifier for wintun).
let adapter = match wintun::Adapter::create(&wintun, ADAPTER_NAME, name, None) {
Ok(a) => a,
Err(e) => {
return Err(TunError::Create(
format!(
"Failed to create wintun adapter '{}': {}. Run as Administrator.",
name, e
)
.into(),
));
}
};
// Start a session with the configured ring buffer capacity
let session = adapter.start_session(WINTUN_RING_CAPACITY).map_err(|e| {
TunError::Create(format!("Failed to start wintun session: {}", e).into())
})?;
let session = Arc::new(session);
// Configure the IPv6 address and route via netsh.
// Use the adapter name (ADAPTER_NAME) not the tunnel type name.
let ipv6_addr = address.to_ipv6();
configure_windows_interface(ADAPTER_NAME, ipv6_addr, mtu).await?;
Ok(Self {
session,
_adapter: adapter,
name: name.to_string(),
mtu,
address,
})
}
/// Get the device name.
pub fn name(&self) -> &str {
&self.name
}
/// Get the configured MTU.
pub fn mtu(&self) -> u16 {
self.mtu
}
/// Get the FIPS address assigned to this device.
pub fn address(&self) -> &FipsAddress {
&self.address
}
/// Read a packet from the TUN device.
///
/// Blocks until a packet is available from the wintun session.
/// Returns the number of bytes copied into `buf`.
pub fn read_packet(&mut self, buf: &mut [u8]) -> Result<usize, TunError> {
match self.session.receive_blocking() {
Ok(packet) => {
let bytes = packet.bytes();
let len = bytes.len().min(buf.len());
buf[..len].copy_from_slice(&bytes[..len]);
Ok(len)
}
Err(e) => Err(TunError::Configure(format!("read failed: {}", e))),
}
}
/// Shutdown the TUN device by removing the fd00::/8 route.
///
/// The wintun adapter and session are cleaned up when dropped.
pub async fn shutdown(&self) -> Result<(), TunError> {
debug!(name = %self.name, "Shutting down TUN device");
let _ = tokio::process::Command::new("netsh")
.args([
"interface",
"ipv6",
"delete",
"route",
"fd00::/8",
&format!("interface={}", ADAPTER_NAME),
])
.output()
.await;
Ok(())
}
/// Create a TunWriter for this device.
///
/// Clones the wintun session `Arc` so the writer can allocate and send
/// packets independently. Returns the writer and a channel sender for
/// submitting packets to be written.
///
/// The `max_mss` parameter is used for TCP MSS clamping on inbound packets.
pub fn create_writer(&self, max_mss: u16) -> Result<(TunWriter, TunTx), TunError> {
let (tx, rx) = mpsc::channel();
Ok((
TunWriter {
session: self.session.clone(),
rx,
name: self.name.clone(),
max_mss,
},
tx,
))
}
}
impl std::fmt::Debug for TunDevice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TunDevice") f.debug_struct("TunDevice")
.field("name", &self.name) .field("name", &self.name)
@@ -585,11 +794,231 @@ impl std::fmt::Debug for TunDevice {
.field("address", &self.address) .field("address", &self.address)
.finish() .finish()
} }
}
/// Writer thread for TUN device (Windows).
///
/// Services a queue of outbound packets and writes them to the wintun
/// session. Uses `allocate_send_packet()` / `send_packet()` instead of
/// file I/O.
///
/// Also performs TCP MSS clamping on inbound SYN-ACK packets.
pub struct TunWriter {
session: Arc<wintun::Session>,
rx: mpsc::Receiver<Vec<u8>>,
name: String,
max_mss: u16,
}
impl TunWriter {
/// Run the writer loop.
///
/// Blocks forever, reading packets from the channel and writing them
/// to the wintun session. Returns when the channel is closed.
pub fn run(self) {
use crate::upper::tcp_mss::clamp_tcp_mss;
debug!(name = %self.name, max_mss = self.max_mss, "TUN writer starting");
for mut packet in self.rx {
// Clamp TCP MSS on inbound SYN-ACK packets
if clamp_tcp_mss(&mut packet, self.max_mss) {
trace!(
name = %self.name,
max_mss = self.max_mss,
"Clamped TCP MSS in inbound SYN-ACK packet"
);
}
let pkt_len = match u16::try_from(packet.len()) {
Ok(len) => len,
Err(_) => {
warn!(name = %self.name, len = packet.len(), "Dropping oversized packet for TUN");
continue;
}
};
match self.session.allocate_send_packet(pkt_len) {
Ok(mut send_packet) => {
send_packet.bytes_mut().copy_from_slice(&packet);
self.session.send_packet(send_packet);
trace!(name = %self.name, len = packet.len(), "TUN packet written");
}
Err(e) => {
error!(name = %self.name, error = %e, "TUN write error (allocate)");
}
}
}
}
}
/// TUN packet reader loop (Windows).
///
/// Reads IPv6 packets from the wintun session. Packets destined for FIPS
/// addresses (fd::/8) are forwarded to the Node via the outbound channel
/// for session encapsulation and routing. Non-FIPS packets receive ICMPv6
/// Destination Unreachable responses.
///
/// Also performs TCP MSS clamping on SYN packets to prevent oversized segments.
///
/// This is designed to run in a dedicated thread since wintun reads are blocking.
/// The loop exits when the session is closed or an unrecoverable error occurs.
pub fn run_tun_reader(
mut device: TunDevice,
mtu: u16,
our_addr: FipsAddress,
tun_tx: TunTx,
outbound_tx: TunOutboundTx,
transport_mtu: u16,
) {
let (name, mut buf, max_mss) = super::tun_reader_setup(device.name(), mtu, transport_mtu);
loop {
match device.read_packet(&mut buf) {
Ok(n) if n > 0 => {
if !super::handle_tun_packet(
&mut buf[..n],
max_mss,
&name,
our_addr,
&tun_tx,
&outbound_tx,
) {
break;
}
}
Ok(_) => {}
Err(e) => {
let err_str = format!("{}", e);
if !err_str.contains("Bad address") {
error!(name = %name, error = %e, "TUN read error");
}
break;
}
}
}
}
/// Shutdown and delete a TUN interface by name (Windows).
///
/// Removes the fd00::/8 route via netsh. The wintun adapter itself
/// is cleaned up when the `Adapter` handle is dropped.
pub async fn shutdown_tun_interface(name: &str) -> Result<(), TunError> {
debug!("Shutting down TUN interface {}", name);
let _ = tokio::process::Command::new("netsh")
.args([
"interface",
"ipv6",
"delete",
"route",
"fd00::/8",
&format!("interface={}", ADAPTER_NAME),
])
.output()
.await;
let _ = name; // name is the tunnel type, not the adapter name
debug!("TUN interface {} stopped", name);
Ok(())
}
/// Configure the Windows network interface with IPv6 address, MTU, and route.
///
/// Uses `netsh` commands to configure the wintun adapter. A brief delay
/// is inserted before configuration to allow Windows to fully register
/// the adapter in its network stack.
///
/// `adapter_name` must be the Windows adapter name (e.g. "FIPS"), not the
/// wintun tunnel type name.
async fn configure_windows_interface(
adapter_name: &str,
addr: Ipv6Addr,
mtu: u16,
) -> Result<(), TunError> {
// Brief delay to let Windows fully register the adapter
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
// Set IPv6 address
let output = tokio::process::Command::new("netsh")
.args([
"interface",
"ipv6",
"add",
"address",
adapter_name,
&format!("{}/128", addr),
])
.output()
.await
.map_err(|e| TunError::Configure(format!("netsh add address failed: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
if !stderr.contains("already") && !stdout.contains("already") {
warn!(
"netsh add address failed: stdout={} stderr={}",
stdout.trim(),
stderr.trim()
);
}
}
// Set MTU
let output = tokio::process::Command::new("netsh")
.args([
"interface",
"ipv6",
"set",
"subinterface",
adapter_name,
&format!("mtu={}", mtu),
])
.output()
.await
.map_err(|e| TunError::Configure(format!("netsh set mtu failed: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
warn!(
"netsh set mtu failed: stdout={} stderr={}",
stdout.trim(),
stderr.trim()
);
}
// Add route for fd00::/8 (FIPS address space) via this adapter
let output = tokio::process::Command::new("netsh")
.args([
"interface",
"ipv6",
"add",
"route",
"fd00::/8",
adapter_name,
])
.output()
.await
.map_err(|e| TunError::Configure(format!("netsh add route failed: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
if !stderr.contains("already") && !stdout.contains("already") {
warn!(
"netsh add route failed: stdout={} stderr={}",
stdout.trim(),
stderr.trim()
);
}
}
Ok(())
}
} }
// ============================================================================= // Re-export Windows TUN types at module level
// Platform-specific TUN configuration #[cfg(windows)]
// ============================================================================= pub use windows_tun::{TunDevice, TunWriter, run_tun_reader, shutdown_tun_interface};
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod platform { mod platform {