diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e825f88..52848fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,13 +47,22 @@ jobs: - os: ubuntu-latest - os: ubuntu-24.04-arm - os: macos-latest + - os: windows-latest steps: - 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" + - 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) if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev @@ -73,7 +82,7 @@ jobs: ${{ runner.os }}-cargo- - 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) if: runner.os == 'Linux' @@ -83,6 +92,11 @@ jobs: if: runner.os == 'macOS' 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 - name: Upload Linux binary if: matrix.os == 'ubuntu-latest' @@ -185,6 +199,35 @@ jobs: - name: Run unit tests 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) # diff --git a/.github/workflows/package-windows.yml b/.github/workflows/package-windows.yml new file mode 100644 index 0000000..b2469d9 --- /dev/null +++ b/.github/workflows/package-windows.yml @@ -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}" diff --git a/Cargo.lock b/Cargo.lock index 18442c6..1b045dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -287,13 +287,33 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "c2rust-bitfields" version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcee50917f9de1a018e3f4f9a8f2ff3d030a288cffa4b18d9b391e97c12e4cfb" 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]] @@ -983,6 +1003,8 @@ dependencies = [ "tracing", "tracing-subscriber", "tun", + "windows-service", + "wintun", ] [[package]] @@ -3156,6 +3178,12 @@ dependencies = [ "wezterm-dynamic", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -3193,6 +3221,26 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows-sys" version = "0.59.0" @@ -3359,6 +3407,19 @@ dependencies = [ "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]] name = "wintun-bindings" version = "0.7.34" @@ -3366,7 +3427,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27ae04d34b8569174e849128d2e36538329a27daa79c06ed0375f2c5d6704461" dependencies = [ "blocking", - "c2rust-bitfields", + "c2rust-bitfields 0.21.0", "futures", "libloading 0.9.0", "log", diff --git a/Cargo.toml b/Cargo.toml index 9fac7b1..ca22196 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,9 +31,7 @@ hex = "0.4" clap = { version = "4.5", features = ["derive"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -tun = { version = "0.8.5", features = ["async"] } -libc = "0.2" -tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net", "time", "process"] } +tokio = { version = "1", features = ["rt", "macros", "signal", "sync", "net", "time", "process", "io-util"] } futures = "0.3" simple-dns = "0.11.2" socket2 = { version = "0.6.2", features = ["all"] } @@ -41,10 +39,18 @@ tokio-socks = "0.5" 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] rtnetlink = "0.20.0" bluer = { version = "0.17", features = ["bluetoothd", "l2cap"], optional = true } +[target.'cfg(windows)'.dependencies] +wintun = "0.5" +windows-service = "0.7" + [package.metadata.deb] maintainer = "Johnathan Corgan " copyright = "2026 Johnathan Corgan" diff --git a/README.md b/README.md index 19507b6..ceccad3 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,8 @@ cd fips cargo build --release ``` -Requires Rust 1.85+ (edition 2024) and a Unix-like OS with TUN support -(Linux or macOS). +Requires Rust 1.85+ (edition 2024). Linux, macOS, and Windows are +supported (see transport matrix below). ### 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` (kernel-assigned) > 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 The default configuration file is installed at `/etc/fips/fips.yaml`: diff --git a/docs/design/fips-configuration.md b/docs/design/fips-configuration.md index dc87248..2bcb0d9 100644 --- a/docs/design/fips-configuration.md +++ b/docs/design/fips-configuration.md @@ -53,8 +53,8 @@ peers: # Static peer list | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `node.control.enabled` | bool | `true` | Enable the Unix domain 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.enabled` | bool | `true` | Enable the control socket | +| `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 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 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 dotted paths. The top-level sections (`tun`, `dns`, `transports`, `peers`) handle infrastructure concerns only. diff --git a/packaging/Makefile b/packaging/Makefile index d54fa71..70d0d4d 100644 --- a/packaging/Makefile +++ b/packaging/Makefile @@ -9,6 +9,7 @@ # make ipk Build an OpenWrt .ipk package # make aur Build fips-git AUR package and validate with namcap # make pkg Build a macOS .pkg installer +# make zip Build a Windows .zip package # make all Build deb and tarball (default) # make clean Remove deploy/ directory @@ -16,7 +17,7 @@ SHELL := /bin/bash PACKAGING_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) 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 @@ -35,5 +36,8 @@ aur: pkg: @bash $(PACKAGING_DIR)/macos/build-pkg.sh +zip: + @powershell -File $(PACKAGING_DIR)/windows/build-zip.ps1 + clean: rm -rf $(PROJECT_ROOT)/deploy diff --git a/packaging/README.md b/packaging/README.md index 134366f..161207c 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -11,6 +11,7 @@ make tarball # systemd install tarball make ipk # OpenWrt .ipk make aur # Arch Linux AUR package (fips-git, local build + namcap) make pkg # macOS .pkg installer +make zip # Windows .zip package make all # deb + tarball (default) ``` @@ -24,6 +25,7 @@ packaging/ macos/ macOS .pkg installer via pkgbuild systemd/ Generic Linux systemd tarball packaging openwrt/ OpenWrt .ipk packaging via cargo-zigbuild + windows/ Windows .zip package with service scripts ``` ## Formats @@ -101,6 +103,31 @@ sudo installer -pkg deploy/fips--macos-.pkg -target / 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--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) Two AUR packages are maintained: `fips` (release, builds from tagged diff --git a/packaging/windows/build-zip.ps1 b/packaging/windows/build-zip.ps1 new file mode 100644 index 0000000..fff1620 --- /dev/null +++ b/packaging/windows/build-zip.ps1 @@ -0,0 +1,120 @@ +# Build a Windows ZIP package for FIPS. +# +# Usage: powershell -File packaging/windows/build-zip.ps1 [-Version ] [-NoBuild] +# Output: deploy/fips--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" diff --git a/packaging/windows/install-service.ps1 b/packaging/windows/install-service.ps1 new file mode 100644 index 0000000..1306ba9 --- /dev/null +++ b/packaging/windows/install-service.ps1 @@ -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" diff --git a/packaging/windows/uninstall-service.ps1 b/packaging/windows/uninstall-service.ps1 new file mode 100644 index 0000000..4f0348e --- /dev/null +++ b/packaging/windows/uninstall-service.ps1 @@ -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)" +} diff --git a/src/bin/fips.rs b/src/bin/fips.rs index 84b2d25..6aa8cd1 100644 --- a/src/bin/fips.rs +++ b/src/bin/fips.rs @@ -1,6 +1,7 @@ //! FIPS daemon binary //! //! 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 fips::config::{IdentitySource, resolve_identity}; @@ -22,15 +23,35 @@ struct Args { /// Path to configuration file (overrides default search paths) #[arg(short, long, value_name = "FILE")] config: Option, + + /// 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")] -async fn main() { - let args = Args::parse(); - +/// Run the FIPS daemon (shared between foreground and service modes). +/// +/// `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, + shutdown_signal: impl std::future::Future, +) { // Load configuration before initializing logging so we can use // 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) { Ok(config) => (config, vec![config_path.clone()]), Err(e) => { @@ -101,7 +122,6 @@ async fn main() { } }; - // Log node information info!("Node created:"); info!(" npub: {}", node.npub()); info!(" node_addr: {}", hex::encode(node.node_addr().as_bytes())); @@ -115,23 +135,10 @@ async fn main() { 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. - #[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! { result = node.run_rx_loop() => { match result { @@ -139,7 +146,7 @@ async fn main() { Err(e) => error!("RX loop error: {}", e), } } - _ = shutdown => { + _ = shutdown_signal => { info!("Shutdown signal received"); } } @@ -153,3 +160,244 @@ async fn main() { 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) { + 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) -> 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 = 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> { + 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> { + 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(()) + } +} diff --git a/src/bin/fipsctl.rs b/src/bin/fipsctl.rs index 9c36e68..f649932 100644 --- a/src/bin/fipsctl.rs +++ b/src/bin/fipsctl.rs @@ -1,7 +1,10 @@ //! fipsctl — FIPS control client //! -//! Connects to the FIPS daemon's Unix domain control socket, sends -//! commands, and pretty-prints the JSON response. +//! Connects to the FIPS daemon's control socket, sends commands, and +//! 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 fips::config::{write_key_file, write_pub_file}; @@ -9,7 +12,6 @@ use fips::upper::hosts::HostMap; use fips::version; use fips::{Identity, encode_nsec}; use std::io::{BufRead, BufReader, Write}; -use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -40,7 +42,7 @@ enum Commands { /// Generate a new FIPS identity keypair Keygen { /// 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, /// Overwrite existing key files #[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 { - 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") - } + fips::config::default_control_path() } /// 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 { + use std::os::unix::net::UnixStream; + let mut stream = UnixStream::connect(socket_path).map_err(|e| { if e.kind() == std::io::ErrorKind::PermissionDenied { format!( @@ -164,6 +160,46 @@ fn send_request(socket_path: &Path, request_json: &str) -> Result Result { + 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). fn build_query(command: &str) -> String { format!("{{\"command\":\"{command}\"}}\n") @@ -199,10 +235,24 @@ fn print_response(value: &serde_json::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. /// /// 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 { if peer.starts_with("npub1") { return peer.to_string(); @@ -213,7 +263,10 @@ fn resolve_peer(peer: &str) -> String { Some(npub) => npub.to_string(), None => { 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); } } diff --git a/src/bin/fipstop/client.rs b/src/bin/fipstop/client.rs index 7732e8f..fa5b6cb 100644 --- a/src/bin/fipstop/client.rs +++ b/src/bin/fipstop/client.rs @@ -1,30 +1,26 @@ use serde_json::Value; -use std::path::{Path, PathBuf}; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::UnixStream; use tokio::time::timeout; const IO_TIMEOUT: Duration = Duration::from_secs(5); 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 { - pub fn new(socket_path: &Path) -> Self { + pub fn new(socket_path: &std::path::Path) -> 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 { - let stream = timeout(IO_TIMEOUT, UnixStream::connect(&self.socket_path)) - .await - .map_err(|_| "connection timed out".to_string())? - .map_err(|e| format!("connect: {e}"))?; + let stream = self.connect().await?; - let (reader, mut writer) = stream.into_split(); + let (reader, mut writer) = tokio::io::split(stream); let request = format!("{{\"command\":\"{command}\"}}\n"); timeout(IO_TIMEOUT, writer.write_all(request.as_bytes())) @@ -62,4 +58,31 @@ impl ControlClient { Ok(response.get("data").cloned().unwrap_or(Value::Null)) } + + #[cfg(unix)] + async fn connect(&self) -> Result { + 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 { + 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}")) + } } diff --git a/src/bin/fipstop/main.rs b/src/bin/fipstop/main.rs index fee82b8..a599526 100644 --- a/src/bin/fipstop/main.rs +++ b/src/bin/fipstop/main.rs @@ -9,7 +9,7 @@ use client::ControlClient; use event::{Event, EventHandler}; use fips::version; use ratatui::crossterm::event::{KeyCode, KeyModifiers}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::time::Duration; /// FIPS mesh monitoring TUI @@ -34,31 +34,12 @@ struct Cli { 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 { - 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") - } + fips::config::default_control_path() } -/// Determine the default gateway socket path. fn default_gateway_socket_path() -> PathBuf { - 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") - } + fips::config::default_gateway_path() } fn restore_terminal() { diff --git a/src/config/mod.rs b/src/config/mod.rs index d536d9a..5542a1e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -68,6 +68,49 @@ pub fn pub_file_path(config_path: &Path) -> PathBuf { .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. pub fn read_key_file(path: &Path) -> Result { let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile { @@ -83,21 +126,26 @@ pub fn read_key_file(path: &Path) -> Result { 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> { use std::io::Write; - use std::os::unix::fs::OpenOptionsExt; - let mut file = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(path) - .map_err(|e| ConfigError::WriteKeyFile { - path: path.to_path_buf(), - source: e, - })?; + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + + let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile { + path: path.to_path_buf(), + source: e, + })?; file.write_all(nsec.as_bytes()) .map_err(|e| ConfigError::WriteKeyFile { @@ -112,21 +160,26 @@ pub fn write_key_file(path: &Path, nsec: &str) -> Result<(), ConfigError> { 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> { use std::io::Write; - use std::os::unix::fs::OpenOptionsExt; - let mut file = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o644) - .open(path) - .map_err(|e| ConfigError::WriteKeyFile { - path: path.to_path_buf(), - source: e, - })?; + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o644); + } + + let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile { + path: path.to_path_buf(), + source: e, + })?; file.write_all(npub.as_bytes()) .map_err(|e| ConfigError::WriteKeyFile { @@ -672,7 +725,8 @@ node: // Should include current directory assert!(paths.iter().any(|p| p.ends_with("fips.yaml"))); - // Should include /etc/fips + // Should include /etc/fips on Unix + #[cfg(unix)] assert!( paths .iter() @@ -710,6 +764,7 @@ node: assert_eq!(loaded_identity.npub(), identity.npub()); } + #[cfg(unix)] #[test] fn test_key_file_permissions() { use std::os::unix::fs::MetadataExt; @@ -723,6 +778,7 @@ node: assert_eq!(metadata.mode() & 0o777, 0o600); } + #[cfg(unix)] #[test] fn test_pub_file_permissions() { 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] fn test_resolve_identity_from_config() { let mut config = Config::new(); diff --git a/src/config/node.rs b/src/config/node.rs index 1499030..a265241 100644 --- a/src/config/node.rs +++ b/src/config/node.rs @@ -488,13 +488,26 @@ impl ControlConfig { 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 { - if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { - format!("{runtime_dir}/fips/control.sock") - } else if std::fs::create_dir_all("/run/fips").is_ok() { - "/run/fips/control.sock".to_string() - } else { - "/tmp/fips-control.sock".to_string() + #[cfg(unix)] + { + if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { + format!("{runtime_dir}/fips/control.sock") + } else if std::fs::create_dir_all("/run/fips").is_ok() { + "/run/fips/control.sock".to_string() + } else { + "/tmp/fips-control.sock".to_string() + } + } + #[cfg(windows)] + { + "21210".to_string() } } } @@ -810,4 +823,16 @@ mod tests { assert!((c.loss_threshold - 0.02).abs() < 1e-9); 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); + } } diff --git a/src/control/mod.rs b/src/control/mod.rs index 5591088..f0c003b 100644 --- a/src/control/mod.rs +++ b/src/control/mod.rs @@ -1,8 +1,12 @@ //! 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_*) //! 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 protocol; @@ -10,9 +14,7 @@ pub mod queries; use crate::config::ControlConfig; use protocol::{Request, Response}; -use std::path::{Path, PathBuf}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::UnixListener; use tokio::sync::{mpsc, oneshot}; use tracing::{debug, info, warn}; @@ -25,232 +27,381 @@ const IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); /// A message sent from the accept loop to the main event loop. pub type ControlMessage = (Request, oneshot::Sender); -/// Control socket listener. +/// Handle a single client connection over any AsyncRead + AsyncWrite stream. /// -/// Manages the Unix domain socket lifecycle: bind, accept, cleanup. -pub struct ControlSocket { - listener: UnixListener, - socket_path: PathBuf, +/// Shared between Unix and Windows implementations to avoid duplicating +/// the request/response protocol logic. +async fn handle_connection_generic( + stream: S, + control_tx: mpsc::Sender, +) -> Result<(), Box> +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::(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 { - /// Bind a new control socket. +// ============================================================================ +// Unix implementation +// ============================================================================ + +#[cfg(unix)] +mod unix_impl { + use super::*; + use std::path::{Path, PathBuf}; + use tokio::net::UnixListener; + + /// Control socket listener (Unix domain socket). /// - /// Creates parent directories if needed, removes stale socket files, - /// and binds the Unix listener. - pub fn bind(config: &ControlConfig) -> Result { - let socket_path = PathBuf::from(&config.socket_path); - - // Create parent directory if it doesn't exist - if let Some(parent) = socket_path.parent() - && !parent.exists() - { - std::fs::create_dir_all(parent)?; - debug!(path = %parent.display(), "Created control socket directory"); - } - - // Remove stale socket if it exists - if socket_path.exists() { - Self::remove_stale_socket(&socket_path)?; - } - - let listener = UnixListener::bind(&socket_path)?; - - // Make the socket and its parent directory group-accessible so - // 'fips' group members can use fipsctl/fipstop without root. - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o770))?; - Self::chown_to_fips_group(&socket_path); - if let Some(parent) = socket_path.parent() { - Self::chown_to_fips_group(parent); - } - - info!(path = %socket_path.display(), "Control socket listening"); - - Ok(Self { - listener, - socket_path, - }) + /// Manages the Unix domain socket lifecycle: bind, accept, cleanup. + pub struct ControlSocket { + listener: UnixListener, + socket_path: PathBuf, } - /// Remove a stale socket file. - /// - /// If the file exists but no one is listening, remove it so we can - /// bind. This handles unclean daemon exits. - fn remove_stale_socket(path: &Path) -> Result<(), std::io::Error> { - // Try connecting to see if someone is listening - match std::os::unix::net::UnixStream::connect(path) { - Ok(_) => { - // Someone is listening — don't remove it - Err(std::io::Error::new( - std::io::ErrorKind::AddrInUse, - format!("control socket already in use: {}", path.display()), - )) + impl ControlSocket { + /// Bind a new control socket. + /// + /// Creates parent directories if needed, removes stale socket files, + /// and binds the Unix listener. + pub fn bind(config: &ControlConfig) -> Result { + let socket_path = PathBuf::from(&config.socket_path); + + // Create parent directory if it doesn't exist + if let Some(parent) = socket_path.parent() + && !parent.exists() + { + std::fs::create_dir_all(parent)?; + debug!(path = %parent.display(), "Created control socket directory"); } - Err(_) => { - // No one listening — remove the stale socket - debug!(path = %path.display(), "Removing stale control socket"); - std::fs::remove_file(path)?; - Ok(()) + + // Remove stale socket if it exists + if socket_path.exists() { + Self::remove_stale_socket(&socket_path)?; + } + + let listener = UnixListener::bind(&socket_path)?; + + // Make the socket and its parent directory group-accessible so + // 'fips' group members can use fipsctl/fipstop without root. + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o770))?; + Self::chown_to_fips_group(&socket_path); + if let Some(parent) = socket_path.parent() { + Self::chown_to_fips_group(parent); + } + + info!(path = %socket_path.display(), "Control socket listening"); + + Ok(Self { + listener, + socket_path, + }) + } + + /// Remove a stale socket file. + /// + /// If the file exists but no one is listening, remove it so we can + /// bind. This handles unclean daemon exits. + fn remove_stale_socket(path: &Path) -> Result<(), std::io::Error> { + // Try connecting to see if someone is listening + match std::os::unix::net::UnixStream::connect(path) { + Ok(_) => { + // Someone is listening — don't remove it + Err(std::io::Error::new( + std::io::ErrorKind::AddrInUse, + format!("control socket already in use: {}", path.display()), + )) + } + Err(_) => { + // No one listening — remove the stale socket + debug!(path = %path.display(), "Removing stale control socket"); + std::fs::remove_file(path)?; + Ok(()) + } + } + } + + /// Set group ownership of a path to the 'fips' group (best-effort). + fn chown_to_fips_group(path: &Path) { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + // Look up the 'fips' group + let group_name = CString::new("fips").unwrap(); + let grp = unsafe { libc::getgrnam(group_name.as_ptr()) }; + if grp.is_null() { + debug!( + "'fips' group not found, skipping chown for {}", + path.display() + ); + return; + } + let gid = unsafe { (*grp).gr_gid }; + + let c_path = match CString::new(path.as_os_str().as_bytes()) { + Ok(p) => p, + Err(_) => return, + }; + let ret = unsafe { libc::chown(c_path.as_ptr(), u32::MAX, gid) }; + if ret != 0 { + warn!( + path = %path.display(), + error = %std::io::Error::last_os_error(), + "Failed to chown control socket to 'fips' group" + ); + } + } + + /// Run the accept loop, forwarding requests to the main event loop via mpsc. + /// + /// Each accepted connection is handled in a spawned task: + /// 1. Read one line of JSON (the request) + /// 2. Send (Request, oneshot::Sender) to the main loop + /// 3. Wait for the response via oneshot + /// 4. Write the response as one line of JSON + /// 5. Close the connection + pub async fn accept_loop(self, control_tx: mpsc::Sender) { + loop { + let (stream, _addr) = match self.listener.accept().await { + Ok(conn) => conn, + Err(e) => { + warn!(error = %e, "Control socket accept failed"); + 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"); + } + }); + } + } + + /// Get the socket path. + pub fn socket_path(&self) -> &Path { + &self.socket_path + } + + /// Clean up the socket file. + fn cleanup(&self) { + if self.socket_path.exists() { + if let Err(e) = std::fs::remove_file(&self.socket_path) { + warn!( + path = %self.socket_path.display(), + error = %e, + "Failed to remove control socket" + ); + } else { + debug!(path = %self.socket_path.display(), "Control socket removed"); + } } } } - /// Set group ownership of a path to the 'fips' group (best-effort). - fn chown_to_fips_group(path: &Path) { - use std::ffi::CString; - use std::os::unix::ffi::OsStrExt; - - // Look up the 'fips' group - let group_name = CString::new("fips").unwrap(); - let grp = unsafe { libc::getgrnam(group_name.as_ptr()) }; - if grp.is_null() { - debug!( - "'fips' group not found, skipping chown for {}", - path.display() - ); - return; - } - let gid = unsafe { (*grp).gr_gid }; - - let c_path = match CString::new(path.as_os_str().as_bytes()) { - Ok(p) => p, - Err(_) => return, - }; - let ret = unsafe { libc::chown(c_path.as_ptr(), u32::MAX, gid) }; - if ret != 0 { - warn!( - path = %path.display(), - error = %std::io::Error::last_os_error(), - "Failed to chown control socket to 'fips' group" - ); + impl Drop for ControlSocket { + fn drop(&mut self) { + self.cleanup(); } } +} - /// Run the accept loop, forwarding requests to the main event loop via mpsc. +// ============================================================================ +// 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). /// - /// Each accepted connection is handled in a spawned task: - /// 1. Read one line of JSON (the request) - /// 2. Send (Request, oneshot::Sender) to the main loop - /// 3. Wait for the response via oneshot - /// 4. Write the response as one line of JSON - /// 5. Close the connection - pub async fn accept_loop(self, control_tx: mpsc::Sender) { - loop { - let (stream, _addr) = match self.listener.accept().await { - Ok(conn) => conn, + /// 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 { + let port: u16 = match config.socket_path.parse() { + Ok(p) => p, Err(e) => { - warn!(error = %e, "Control socket accept failed"); - continue; + warn!( + path = %config.socket_path, + error = %e, + default = DEFAULT_CONTROL_PORT, + "Invalid control port, using default" + ); + DEFAULT_CONTROL_PORT } }; - let tx = control_tx.clone(); - tokio::spawn(async move { - if let Err(e) = Self::handle_connection(stream, tx).await { - debug!(error = %e, "Control connection error"); + 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) { + 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"); + } + }); + } } } +} - /// Handle a single client connection. - async fn handle_connection( - stream: tokio::net::UnixStream, - control_tx: mpsc::Sender, - ) -> Result<(), Box> { - let (reader, mut writer) = stream.into_split(); - let mut buf_reader = BufReader::new(reader); - let mut line = String::new(); +// Re-export platform-specific types +#[cfg(unix)] +pub use unix_impl::ControlSocket; +#[cfg(windows)] +pub use windows_impl::ControlSocket; - // 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; +#[cfg(test)] +mod tests { + use super::*; - 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::(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"), + #[cfg(windows)] + #[tokio::test] + async fn test_tcp_control_socket_bind() { + let config = ControlConfig { + enabled: true, + socket_path: "0".to_string(), // port 0 = ephemeral }; - // 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(()) + // Verify the socket binds successfully on an ephemeral port + let _socket = ControlSocket::bind(&config).expect("failed to bind control socket"); } - /// Get the socket path. - pub fn socket_path(&self) -> &Path { - &self.socket_path - } + #[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(), + }; - /// Clean up the socket file. - fn cleanup(&self) { - if self.socket_path.exists() { - if let Err(e) = std::fs::remove_file(&self.socket_path) { - warn!( - path = %self.socket_path.display(), - error = %e, - "Failed to remove control socket" - ); - } else { - debug!(path = %self.socket_path.display(), "Control socket removed"); - } + // 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); } } } - -impl Drop for ControlSocket { - fn drop(&mut self) { - self.cleanup(); - } -} diff --git a/src/node/mod.rs b/src/node/mod.rs index b5f0c76..a623bf6 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -30,6 +30,7 @@ use crate::bloom::BloomState; use crate::cache::CoordCache; use crate::node::session::SessionEntry; use crate::peer::{ActivePeer, PeerConnection}; +#[cfg(unix)] use crate::transport::ethernet::EthernetTransport; use crate::transport::tcp::TcpTransport; use crate::transport::tor::TorTransport; @@ -705,20 +706,24 @@ impl Node { transports.push(TransportHandle::Udp(udp)); } - // Create Ethernet transport instances - let eth_instances: Vec<_> = self - .config - .transports - .ethernet - .iter() - .map(|(name, config)| (name.map(|s| s.to_string()), config.clone())) - .collect(); - let xonly = self.identity.pubkey(); - for (name, eth_config) in eth_instances { - let transport_id = self.allocate_transport_id(); - let mut eth = EthernetTransport::new(transport_id, name, eth_config, packet_tx.clone()); - eth.set_local_pubkey(xonly); - transports.push(TransportHandle::Ethernet(eth)); + // Create Ethernet transport instances (Unix only — requires raw sockets) + #[cfg(unix)] + { + let eth_instances: Vec<_> = self + .config + .transports + .ethernet + .iter() + .map(|(name, config)| (name.map(|s| s.to_string()), config.clone())) + .collect(); + let xonly = self.identity.pubkey(); + for (name, eth_config) in eth_instances { + let transport_id = self.allocate_transport_id(); + let mut eth = + EthernetTransport::new(transport_id, name, eth_config, packet_tx.clone()); + eth.set_local_pubkey(xonly); + transports.push(TransportHandle::Ethernet(eth)); + } } // Create TCP transport instances @@ -812,39 +817,49 @@ impl Node { /// /// Finds the Ethernet transport instance bound to the named interface /// and parses the MAC portion into a 6-byte TransportAddr. + #[allow(unused_variables)] fn resolve_ethernet_addr( &self, addr_str: &str, ) -> Result<(TransportId, TransportAddr), NodeError> { - let (iface, mac_str) = addr_str.split_once('/').ok_or_else(|| { - NodeError::NoTransportForType(format!( - "invalid Ethernet address format '{}': expected 'interface/mac'", - addr_str - )) - })?; - - // Find the Ethernet transport bound to this interface - let transport_id = self - .transports - .iter() - .find(|(_, handle)| { - handle.transport_type().name == "ethernet" - && handle.is_operational() - && handle.interface_name() == Some(iface) - }) - .map(|(id, _)| *id) - .ok_or_else(|| { + #[cfg(unix)] + { + let (iface, mac_str) = addr_str.split_once('/').ok_or_else(|| { NodeError::NoTransportForType(format!( - "no operational Ethernet transport for interface '{}'", - iface + "invalid Ethernet address format '{}': expected 'interface/mac'", + addr_str )) })?; - let mac = crate::transport::ethernet::parse_mac_string(mac_str).map_err(|e| { - NodeError::NoTransportForType(format!("invalid MAC in '{}': {}", addr_str, e)) - })?; + // Find the Ethernet transport bound to this interface + let transport_id = self + .transports + .iter() + .find(|(_, handle)| { + handle.transport_type().name == "ethernet" + && handle.is_operational() + && handle.interface_name() == Some(iface) + }) + .map(|(id, _)| *id) + .ok_or_else(|| { + NodeError::NoTransportForType(format!( + "no operational Ethernet transport for interface '{}'", + iface + )) + })?; - Ok((transport_id, TransportAddr::from_bytes(&mac))) + let mac = crate::transport::ethernet::parse_mac_string(mac_str).map_err(|e| { + NodeError::NoTransportForType(format!("invalid MAC in '{}': {}", addr_str, e)) + })?; + + 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 diff --git a/src/node/tests/mod.rs b/src/node/tests/mod.rs index 9898e42..053fe45 100644 --- a/src/node/tests/mod.rs +++ b/src/node/tests/mod.rs @@ -9,6 +9,7 @@ mod ble; mod bloom; mod disconnect; mod discovery; +#[cfg(unix)] mod ethernet; mod forwarding; mod handshake; diff --git a/src/transport/ethernet/socket.rs b/src/transport/ethernet/socket.rs index 77aba72..622d7f6 100644 --- a/src/transport/ethernet/socket.rs +++ b/src/transport/ethernet/socket.rs @@ -18,6 +18,7 @@ mod platform; #[path = "socket_macos.rs"] mod platform; +#[cfg(unix)] pub use platform::PacketSocket; // ============================================================================= @@ -263,11 +264,23 @@ mod async_impl { } } +#[cfg(unix)] pub use async_impl::AsyncPacketSocket; +#[cfg(unix)] impl PacketSocket { /// Wrap this socket in an async wrapper for tokio integration. pub fn into_async(self) -> Result { AsyncPacketSocket::new(self) } } + +// ============================================================================= +// Windows: stub types (Ethernet not supported on Windows) +// ============================================================================= + +#[cfg(windows)] +pub struct PacketSocket; + +#[cfg(windows)] +pub struct AsyncPacketSocket; diff --git a/src/transport/mod.rs b/src/transport/mod.rs index 0970212..41d0f8d 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -8,6 +8,7 @@ pub mod tcp; pub mod tor; pub mod udp; +#[cfg(unix)] pub mod ethernet; #[cfg(target_os = "linux")] @@ -15,6 +16,7 @@ pub mod ble; #[cfg(target_os = "linux")] use ble::DefaultBleTransport; +#[cfg(unix)] use ethernet::EthernetTransport; use secp256k1::XOnlyPublicKey; use std::fmt; @@ -851,6 +853,7 @@ pub enum TransportHandle { /// UDP/IP transport. Udp(UdpTransport), /// Raw Ethernet transport. + #[cfg(unix)] Ethernet(EthernetTransport), /// TCP/IP transport. Tcp(TcpTransport), @@ -866,6 +869,7 @@ impl TransportHandle { pub async fn start(&mut self) -> Result<(), TransportError> { match self { TransportHandle::Udp(t) => t.start_async().await, + #[cfg(unix)] TransportHandle::Ethernet(t) => t.start_async().await, TransportHandle::Tcp(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> { match self { TransportHandle::Udp(t) => t.stop_async().await, + #[cfg(unix)] TransportHandle::Ethernet(t) => t.stop_async().await, TransportHandle::Tcp(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 { match self { TransportHandle::Udp(t) => t.send_async(addr, data).await, + #[cfg(unix)] TransportHandle::Ethernet(t) => t.send_async(addr, data).await, TransportHandle::Tcp(t) => t.send_async(addr, data).await, TransportHandle::Tor(t) => t.send_async(addr, data).await, @@ -902,6 +908,7 @@ impl TransportHandle { pub fn transport_id(&self) -> TransportId { match self { TransportHandle::Udp(t) => t.transport_id(), + #[cfg(unix)] TransportHandle::Ethernet(t) => t.transport_id(), TransportHandle::Tcp(t) => t.transport_id(), TransportHandle::Tor(t) => t.transport_id(), @@ -914,6 +921,7 @@ impl TransportHandle { pub fn name(&self) -> Option<&str> { match self { TransportHandle::Udp(t) => t.name(), + #[cfg(unix)] TransportHandle::Ethernet(t) => t.name(), TransportHandle::Tcp(t) => t.name(), TransportHandle::Tor(t) => t.name(), @@ -926,6 +934,7 @@ impl TransportHandle { pub fn transport_type(&self) -> &TransportType { match self { TransportHandle::Udp(t) => t.transport_type(), + #[cfg(unix)] TransportHandle::Ethernet(t) => t.transport_type(), TransportHandle::Tcp(t) => t.transport_type(), TransportHandle::Tor(t) => t.transport_type(), @@ -938,6 +947,7 @@ impl TransportHandle { pub fn state(&self) -> TransportState { match self { TransportHandle::Udp(t) => t.state(), + #[cfg(unix)] TransportHandle::Ethernet(t) => t.state(), TransportHandle::Tcp(t) => t.state(), TransportHandle::Tor(t) => t.state(), @@ -950,6 +960,7 @@ impl TransportHandle { pub fn mtu(&self) -> u16 { match self { TransportHandle::Udp(t) => t.mtu(), + #[cfg(unix)] TransportHandle::Ethernet(t) => t.mtu(), TransportHandle::Tcp(t) => t.mtu(), TransportHandle::Tor(t) => t.mtu(), @@ -965,6 +976,7 @@ impl TransportHandle { pub fn link_mtu(&self, addr: &TransportAddr) -> u16 { match self { TransportHandle::Udp(t) => t.link_mtu(addr), + #[cfg(unix)] TransportHandle::Ethernet(t) => t.link_mtu(addr), TransportHandle::Tcp(t) => t.link_mtu(addr), TransportHandle::Tor(t) => t.link_mtu(addr), @@ -977,6 +989,7 @@ impl TransportHandle { pub fn local_addr(&self) -> Option { match self { TransportHandle::Udp(t) => t.local_addr(), + #[cfg(unix)] TransportHandle::Ethernet(_) => None, TransportHandle::Tcp(t) => t.local_addr(), TransportHandle::Tor(_) => None, @@ -989,6 +1002,7 @@ impl TransportHandle { pub fn interface_name(&self) -> Option<&str> { match self { TransportHandle::Udp(_) => None, + #[cfg(unix)] TransportHandle::Ethernet(t) => Some(t.interface_name()), TransportHandle::Tcp(_) => None, TransportHandle::Tor(_) => None, @@ -1025,6 +1039,7 @@ impl TransportHandle { pub fn discover(&self) -> Result, TransportError> { match self { TransportHandle::Udp(t) => t.discover(), + #[cfg(unix)] TransportHandle::Ethernet(t) => t.discover(), TransportHandle::Tcp(t) => t.discover(), TransportHandle::Tor(t) => t.discover(), @@ -1037,6 +1052,7 @@ impl TransportHandle { pub fn auto_connect(&self) -> bool { match self { TransportHandle::Udp(t) => t.auto_connect(), + #[cfg(unix)] TransportHandle::Ethernet(t) => t.auto_connect(), TransportHandle::Tcp(t) => t.auto_connect(), TransportHandle::Tor(t) => t.auto_connect(), @@ -1049,6 +1065,7 @@ impl TransportHandle { pub fn accept_connections(&self) -> bool { match self { TransportHandle::Udp(t) => t.accept_connections(), + #[cfg(unix)] TransportHandle::Ethernet(t) => t.accept_connections(), TransportHandle::Tcp(t) => t.accept_connections(), TransportHandle::Tor(t) => t.accept_connections(), @@ -1066,7 +1083,8 @@ impl TransportHandle { /// Poll `connection_state()` to check when the connection is ready. pub async fn connect(&self, addr: &TransportAddr) -> Result<(), TransportError> { match self { - TransportHandle::Udp(_) => Ok(()), // connectionless + TransportHandle::Udp(_) => Ok(()), // connectionless + #[cfg(unix)] TransportHandle::Ethernet(_) => Ok(()), // connectionless TransportHandle::Tcp(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 { match self { TransportHandle::Udp(_) => ConnectionState::Connected, + #[cfg(unix)] TransportHandle::Ethernet(_) => ConnectionState::Connected, TransportHandle::Tcp(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) { match self { TransportHandle::Udp(t) => t.close_connection(addr), + #[cfg(unix)] TransportHandle::Ethernet(t) => t.close_connection(addr), TransportHandle::Tcp(t) => t.close_connection_async(addr).await, TransportHandle::Tor(t) => t.close_connection_async(addr).await, @@ -1119,6 +1139,7 @@ impl TransportHandle { pub fn congestion(&self) -> TransportCongestion { match self { TransportHandle::Udp(t) => t.congestion(), + #[cfg(unix)] TransportHandle::Ethernet(_) => TransportCongestion::default(), TransportHandle::Tcp(_) => TransportCongestion::default(), TransportHandle::Tor(_) => TransportCongestion::default(), @@ -1135,6 +1156,7 @@ impl TransportHandle { TransportHandle::Udp(t) => { serde_json::to_value(t.stats().snapshot()).unwrap_or_default() } + #[cfg(unix)] TransportHandle::Ethernet(t) => { let snap = t.stats().snapshot(); serde_json::json!({ diff --git a/src/transport/tor/control.rs b/src/transport/tor/control.rs index d4e5954..58f3cf0 100644 --- a/src/transport/tor/control.rs +++ b/src/transport/tor/control.rs @@ -138,11 +138,11 @@ impl TorControlClient { /// permission-based access control and are not reachable from containers /// unless explicitly mounted. The Debian default is `/run/tor/control`. pub async fn connect(addr: &str) -> Result { + #[cfg(unix)] if is_unix_socket_path(addr) { - Self::connect_unix(addr).await - } else { - Self::connect_tcp(addr).await + return Self::connect_unix(addr).await; } + Self::connect_tcp(addr).await } /// 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 { - Err(TorControlError::ConnectionFailed(format!( - "Unix sockets not supported on this platform: {}", - path - ))) - } - /// Authenticate with the Tor daemon. pub async fn authenticate(&mut self, auth: &ControlAuth) -> Result<(), TorControlError> { let command = match auth { @@ -483,6 +475,7 @@ fn read_cookie_file(path: &Path) -> Result, TorControlError> { /// /// Returns true if the string starts with `/` or `./`, indicating a /// filesystem path rather than a `host:port` TCP address. +#[cfg(unix)] fn is_unix_socket_path(addr: &str) -> bool { addr.starts_with('/') || addr.starts_with("./") } @@ -559,6 +552,7 @@ mod tests { // === Unix socket path detection === + #[cfg(unix)] #[test] fn test_is_unix_socket_path() { assert!(is_unix_socket_path("/run/tor/control")); @@ -569,6 +563,7 @@ mod tests { assert!(!is_unix_socket_path("localhost:9051")); } + #[cfg(unix)] #[tokio::test] async fn test_connect_unix_socket_nonexistent() { let result = TorControlClient::connect("/tmp/nonexistent-tor-control.sock").await; @@ -577,6 +572,7 @@ mod tests { assert!(err.contains("control socket")); } + #[cfg(unix)] #[tokio::test] async fn test_connect_unix_socket_roundtrip() { // Create a Unix socket listener, accept a connection, respond to AUTHENTICATE diff --git a/src/transport/udp/socket.rs b/src/transport/udp/socket.rs index 43b1a3a..c069c73 100644 --- a/src/transport/udp/socket.rs +++ b/src/transport/udp/socket.rs @@ -1,287 +1,497 @@ -//! 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 -//! `recvmsg()` ancillary data parsing for the kernel receive buffer -//! drop counter. The async wrapper uses `tokio::io::unix::AsyncFd` -//! for integration with the tokio runtime. +//! On Linux, provides `SO_RXQ_OVFL` kernel drop counter support via +//! `recvmsg()` ancillary data parsing. The async wrapper uses +//! `tokio::io::unix::AsyncFd` 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`. use crate::transport::TransportError; use socket2::{Domain, Protocol, Socket, Type}; use std::net::SocketAddr; -use std::os::unix::io::{AsRawFd, RawFd}; use std::sync::Arc; -use tokio::io::unix::AsyncFd; +#[cfg(unix)] use tracing::warn; -/// Wrapper around a `socket2::Socket` providing sync send/recv with -/// `SO_RXQ_OVFL` ancillary data parsing. -pub struct UdpRawSocket { - inner: Socket, - local_addr: SocketAddr, -} +// ============================================================================ +// Unix implementation +// ============================================================================ -impl UdpRawSocket { - /// Create, bind, and configure a UDP socket. - /// - /// Enables `SO_RXQ_OVFL` for kernel drop counting (non-fatal if - /// unsupported). Sets non-blocking mode for async integration. - pub fn open( - bind_addr: SocketAddr, - recv_buf_size: usize, - send_buf_size: usize, - ) -> Result { - 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)))?; +#[cfg(unix)] +mod platform { + use super::*; + use std::os::unix::io::{AsRawFd, RawFd}; + use tokio::io::unix::AsyncFd; - sock.set_nonblocking(true) - .map_err(|e| TransportError::StartFailed(format!("set nonblocking failed: {}", e)))?; + /// Wrapper around a `socket2::Socket` providing sync send/recv with + /// `SO_RXQ_OVFL` ancillary data parsing. + pub struct UdpRawSocket { + inner: Socket, + local_addr: SocketAddr, + } - 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 actual_recv = sock - .recv_buffer_size() - .map_err(|e| TransportError::StartFailed(format!("get recv buffer: {}", e)))?; - let actual_send = sock - .send_buffer_size() - .map_err(|e| TransportError::StartFailed(format!("get send buffer: {}", e)))?; - - if actual_recv < recv_buf_size { - warn!( - requested = recv_buf_size, - actual = actual_recv, - "UDP recv buffer clamped by kernel (increase net.core.rmem_max)" - ); - } - if actual_send < send_buf_size { - warn!( - requested = send_buf_size, - actual = actual_send, - "UDP send buffer clamped by kernel (increase net.core.wmem_max)" - ); - } - - // Enable SO_RXQ_OVFL for kernel drop counter in recvmsg ancillary data. - // Non-fatal: older kernels or non-Linux platforms may not support it. - #[cfg(target_os = "linux")] - { - let enable: libc::c_int = 1; - let ret = unsafe { - libc::setsockopt( - sock.as_raw_fd(), - libc::SOL_SOCKET, - libc::SO_RXQ_OVFL, - &enable as *const _ as *const libc::c_void, - std::mem::size_of::() as libc::socklen_t, - ) + impl UdpRawSocket { + /// Create, bind, and configure a UDP socket. + /// + /// Enables `SO_RXQ_OVFL` for kernel drop counting (non-fatal if + /// unsupported). Sets non-blocking mode for async integration. + pub fn open( + bind_addr: SocketAddr, + recv_buf_size: usize, + send_buf_size: usize, + ) -> Result { + let domain = if bind_addr.is_ipv4() { + Domain::IPV4 + } else { + Domain::IPV6 }; - if ret < 0 { - warn!( - "setsockopt(SO_RXQ_OVFL) failed: {}", - std::io::Error::last_os_error() - ); - } - } + let sock = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP)) + .map_err(|e| TransportError::StartFailed(format!("socket create failed: {}", 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()) + sock.set_nonblocking(true).map_err(|e| { + TransportError::StartFailed(format!("set nonblocking failed: {}", e)) })?; - Ok(Self { - inner: sock, - local_addr, - }) - } + sock.bind(&bind_addr.into()) + .map_err(|e| TransportError::StartFailed(format!("bind failed: {}", e)))?; - /// Get the local bound address. - pub fn local_addr(&self) -> SocketAddr { - self.local_addr - } + // 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)))?; - /// Get the actual receive buffer size granted by the kernel. - pub fn recv_buffer_size(&self) -> Result { - self.inner - .recv_buffer_size() - .map_err(|e| TransportError::StartFailed(format!("get recv buffer: {}", e))) - } + let actual_recv = sock + .recv_buffer_size() + .map_err(|e| TransportError::StartFailed(format!("get recv buffer: {}", e)))?; + let actual_send = sock + .send_buffer_size() + .map_err(|e| TransportError::StartFailed(format!("get send buffer: {}", e)))?; - /// Get the actual send buffer size granted by the kernel. - pub fn send_buffer_size(&self) -> Result { - self.inner - .send_buffer_size() - .map_err(|e| TransportError::StartFailed(format!("get send buffer: {}", e))) - } + if actual_recv < recv_buf_size { + warn!( + requested = recv_buf_size, + actual = actual_recv, + "UDP recv buffer clamped by kernel (increase net.core.rmem_max)" + ); + } + if actual_send < send_buf_size { + warn!( + requested = send_buf_size, + actual = actual_send, + "UDP send buffer clamped by kernel (increase net.core.wmem_max)" + ); + } - /// Synchronous send to a destination address. - /// - /// Returns the number of bytes sent, or an `io::Error`. - pub fn send_to(&self, data: &[u8], dest: &SocketAddr) -> std::io::Result { - let dest: socket2::SockAddr = (*dest).into(); - self.inner.send_to(data, &dest) - } - - /// Synchronous receive with `SO_RXQ_OVFL` ancillary data parsing. - /// - /// Returns `(bytes_read, source_addr, kernel_drops)`. The `kernel_drops` - /// value is a cumulative counter since socket creation; it is 0 if - /// `SO_RXQ_OVFL` is not supported. - pub fn recv_from(&self, buf: &mut [u8]) -> std::io::Result<(usize, SocketAddr, u32)> { - let fd = self.inner.as_raw_fd(); - - let mut iov = libc::iovec { - iov_base: buf.as_mut_ptr() as *mut libc::c_void, - iov_len: buf.len(), - }; - - // Control message buffer sized for SO_RXQ_OVFL (u32). - // CMSG_SPACE computes the aligned size including header. - #[cfg(target_os = "linux")] - const CMSG_BUF_SIZE: usize = unsafe { libc::CMSG_SPACE(4) } as usize; - #[cfg(not(target_os = "linux"))] - const CMSG_BUF_SIZE: usize = 64; - let mut cmsg_buf = [0u8; CMSG_BUF_SIZE]; - - let mut src_addr: libc::sockaddr_storage = unsafe { std::mem::zeroed() }; - let mut msg: libc::msghdr = unsafe { std::mem::zeroed() }; - msg.msg_name = &mut src_addr as *mut _ as *mut libc::c_void; - msg.msg_namelen = std::mem::size_of::() as libc::socklen_t; - msg.msg_iov = &mut iov; - msg.msg_iovlen = 1 as _; - msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void; - msg.msg_controllen = cmsg_buf.len() as _; - - let n = unsafe { libc::recvmsg(fd, &mut msg, 0) }; - if n < 0 { - return Err(std::io::Error::last_os_error()); - } - - // Parse source address from sockaddr_storage - let addr = sockaddr_to_socket_addr(&src_addr)?; - - // Walk cmsg chain for SO_RXQ_OVFL drop counter - #[cfg(target_os = "linux")] - let mut drops: u32 = 0; - #[cfg(not(target_os = "linux"))] - let drops: u32 = 0; - #[cfg(target_os = "linux")] - unsafe { - let mut cmsg = libc::CMSG_FIRSTHDR(&msg); - while !cmsg.is_null() { - if (*cmsg).cmsg_level == libc::SOL_SOCKET && (*cmsg).cmsg_type == libc::SO_RXQ_OVFL - { - let data = libc::CMSG_DATA(cmsg); - drops = std::ptr::read_unaligned(data as *const u32); + // Enable SO_RXQ_OVFL for kernel drop counter in recvmsg ancillary data. + // Non-fatal: older kernels or non-Linux platforms may not support it. + #[cfg(target_os = "linux")] + { + let enable: libc::c_int = 1; + let ret = unsafe { + libc::setsockopt( + sock.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_RXQ_OVFL, + &enable as *const _ as *const libc::c_void, + std::mem::size_of::() as libc::socklen_t, + ) + }; + if ret < 0 { + warn!( + "setsockopt(SO_RXQ_OVFL) failed: {}", + std::io::Error::last_os_error() + ); } - cmsg = libc::CMSG_NXTHDR(&msg, cmsg); } + + 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, + }) } - Ok((n as usize, addr, drops)) - } + /// Get the local bound address. + pub fn local_addr(&self) -> SocketAddr { + self.local_addr + } - /// Wrap this socket in a tokio `AsyncFd` for async I/O. - pub fn into_async(self) -> Result { - let async_fd = AsyncFd::new(self) - .map_err(|e| TransportError::StartFailed(format!("AsyncFd::new failed: {}", e)))?; - Ok(AsyncUdpSocket { - inner: Arc::new(async_fd), - }) - } -} + /// Get the actual receive buffer size granted by the kernel. + pub fn recv_buffer_size(&self) -> Result { + self.inner + .recv_buffer_size() + .map_err(|e| TransportError::StartFailed(format!("get recv buffer: {}", e))) + } -impl AsRawFd for UdpRawSocket { - fn as_raw_fd(&self) -> RawFd { - self.inner.as_raw_fd() - } -} + /// Get the actual send buffer size granted by the kernel. + pub fn send_buffer_size(&self) -> Result { + self.inner + .send_buffer_size() + .map_err(|e| TransportError::StartFailed(format!("get send buffer: {}", e))) + } -/// Async wrapper around `UdpRawSocket` using tokio's `AsyncFd`. -/// -/// `Arc`-shareable between send and receive tasks. `AsyncFd` is -/// `Sync` when `T: Send`, which `socket2::Socket` satisfies. -#[derive(Clone)] -pub struct AsyncUdpSocket { - inner: Arc>, -} + /// Synchronous send to a destination address. + /// + /// Returns the number of bytes sent, or an `io::Error`. + pub fn send_to(&self, data: &[u8], dest: &SocketAddr) -> std::io::Result { + let dest: socket2::SockAddr = (*dest).into(); + self.inner.send_to(data, &dest) + } -impl AsyncUdpSocket { - /// Send a payload to a destination address. - pub async fn send_to(&self, data: &[u8], dest: &SocketAddr) -> Result { - loop { - let mut guard = self - .inner - .writable() - .await - .map_err(|e| TransportError::SendFailed(format!("writable wait: {}", e)))?; + /// Synchronous receive with `SO_RXQ_OVFL` ancillary data parsing. + /// + /// Returns `(bytes_read, source_addr, kernel_drops)`. The `kernel_drops` + /// value is a cumulative counter since socket creation; it is 0 if + /// `SO_RXQ_OVFL` is not supported. + pub fn recv_from(&self, buf: &mut [u8]) -> std::io::Result<(usize, SocketAddr, u32)> { + let fd = self.inner.as_raw_fd(); - match guard.try_io(|inner| inner.get_ref().send_to(data, dest)) { - Ok(Ok(n)) => return Ok(n), - Ok(Err(e)) => return Err(TransportError::SendFailed(format!("{}", e))), - Err(_would_block) => continue, + let mut iov = libc::iovec { + iov_base: buf.as_mut_ptr() as *mut libc::c_void, + iov_len: buf.len(), + }; + + // Control message buffer sized for SO_RXQ_OVFL (u32). + // CMSG_SPACE computes the aligned size including header. + #[cfg(target_os = "linux")] + const CMSG_BUF_SIZE: usize = unsafe { libc::CMSG_SPACE(4) } as usize; + #[cfg(not(target_os = "linux"))] + const CMSG_BUF_SIZE: usize = 64; + let mut cmsg_buf = [0u8; CMSG_BUF_SIZE]; + + let mut src_addr: libc::sockaddr_storage = unsafe { std::mem::zeroed() }; + let mut msg: libc::msghdr = unsafe { std::mem::zeroed() }; + msg.msg_name = &mut src_addr as *mut _ as *mut libc::c_void; + msg.msg_namelen = std::mem::size_of::() as libc::socklen_t; + msg.msg_iov = &mut iov; + msg.msg_iovlen = 1 as _; + msg.msg_control = cmsg_buf.as_mut_ptr() as *mut libc::c_void; + msg.msg_controllen = cmsg_buf.len() as _; + + let n = unsafe { libc::recvmsg(fd, &mut msg, 0) }; + if n < 0 { + return Err(std::io::Error::last_os_error()); } + + // Parse source address from sockaddr_storage + let addr = sockaddr_to_socket_addr(&src_addr)?; + + // Walk cmsg chain for SO_RXQ_OVFL drop counter + #[cfg(target_os = "linux")] + let mut drops: u32 = 0; + #[cfg(not(target_os = "linux"))] + let drops: u32 = 0; + #[cfg(target_os = "linux")] + unsafe { + let mut cmsg = libc::CMSG_FIRSTHDR(&msg); + while !cmsg.is_null() { + if (*cmsg).cmsg_level == libc::SOL_SOCKET + && (*cmsg).cmsg_type == libc::SO_RXQ_OVFL + { + let data = libc::CMSG_DATA(cmsg); + drops = std::ptr::read_unaligned(data as *const u32); + } + cmsg = libc::CMSG_NXTHDR(&msg, cmsg); + } + } + + Ok((n as usize, addr, drops)) + } + + /// Wrap this socket in a tokio `AsyncFd` for async I/O. + pub fn into_async(self) -> Result { + let async_fd = AsyncFd::new(self) + .map_err(|e| TransportError::StartFailed(format!("AsyncFd::new failed: {}", e)))?; + Ok(AsyncUdpSocket { + inner: Arc::new(async_fd), + }) } } - /// Receive a payload, source address, and kernel drop counter. + impl AsRawFd for UdpRawSocket { + fn as_raw_fd(&self) -> RawFd { + self.inner.as_raw_fd() + } + } + + /// Async wrapper around `UdpRawSocket` using tokio's `AsyncFd`. /// - /// Returns `(bytes_read, source_addr, kernel_drops)`. - pub async fn recv_from( - &self, - buf: &mut [u8], - ) -> Result<(usize, SocketAddr, u32), TransportError> { - loop { - let mut guard = self - .inner - .readable() - .await - .map_err(|e| TransportError::RecvFailed(format!("readable wait: {}", e)))?; + /// `Arc`-shareable between send and receive tasks. `AsyncFd` is + /// `Sync` when `T: Send`, which `socket2::Socket` satisfies. + #[derive(Clone)] + pub struct AsyncUdpSocket { + inner: Arc>, + } - match guard.try_io(|inner| inner.get_ref().recv_from(buf)) { - Ok(Ok(result)) => return Ok(result), - Ok(Err(e)) => return Err(TransportError::RecvFailed(format!("{}", e))), - Err(_would_block) => continue, + impl AsyncUdpSocket { + /// Send a payload to a destination address. + pub async fn send_to( + &self, + data: &[u8], + dest: &SocketAddr, + ) -> Result { + loop { + let mut guard = self + .inner + .writable() + .await + .map_err(|e| TransportError::SendFailed(format!("writable wait: {}", e)))?; + + match guard.try_io(|inner| inner.get_ref().send_to(data, dest)) { + Ok(Ok(n)) => return Ok(n), + Ok(Err(e)) => return Err(TransportError::SendFailed(format!("{}", e))), + Err(_would_block) => continue, + } + } + } + + /// Receive a payload, source address, and kernel drop counter. + /// + /// Returns `(bytes_read, source_addr, kernel_drops)`. + pub async fn recv_from( + &self, + buf: &mut [u8], + ) -> Result<(usize, SocketAddr, u32), TransportError> { + loop { + let mut guard = self + .inner + .readable() + .await + .map_err(|e| TransportError::RecvFailed(format!("readable wait: {}", e)))?; + + match guard.try_io(|inner| inner.get_ref().recv_from(buf)) { + Ok(Ok(result)) => return Ok(result), + Ok(Err(e)) => return Err(TransportError::RecvFailed(format!("{}", e))), + Err(_would_block) => continue, + } } } } -} -/// Convert a `libc::sockaddr_storage` to `std::net::SocketAddr`. -fn sockaddr_to_socket_addr(storage: &libc::sockaddr_storage) -> std::io::Result { - match storage.ss_family as libc::c_int { - libc::AF_INET => { - let addr: &libc::sockaddr_in = - unsafe { &*(storage as *const _ as *const libc::sockaddr_in) }; - let ip = std::net::Ipv4Addr::from(u32::from_be(addr.sin_addr.s_addr)); - let port = u16::from_be(addr.sin_port); - Ok(SocketAddr::from((ip, port))) + /// Convert a `libc::sockaddr_storage` to `std::net::SocketAddr`. + fn sockaddr_to_socket_addr(storage: &libc::sockaddr_storage) -> std::io::Result { + match storage.ss_family as libc::c_int { + libc::AF_INET => { + let addr: &libc::sockaddr_in = + unsafe { &*(storage as *const _ as *const libc::sockaddr_in) }; + let ip = std::net::Ipv4Addr::from(u32::from_be(addr.sin_addr.s_addr)); + let port = u16::from_be(addr.sin_port); + Ok(SocketAddr::from((ip, port))) + } + libc::AF_INET6 => { + let addr: &libc::sockaddr_in6 = + unsafe { &*(storage as *const _ as *const libc::sockaddr_in6) }; + let ip = std::net::Ipv6Addr::from(addr.sin6_addr.s6_addr); + let port = u16::from_be(addr.sin6_port); + Ok(SocketAddr::from((ip, port))) + } + family => Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("unsupported address family: {}", family), + )), } - libc::AF_INET6 => { - let addr: &libc::sockaddr_in6 = - unsafe { &*(storage as *const _ as *const libc::sockaddr_in6) }; - let ip = std::net::Ipv6Addr::from(addr.sin6_addr.s6_addr); - let port = u16::from_be(addr.sin6_port); - Ok(SocketAddr::from((ip, port))) - } - family => Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - 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 { + 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 { + 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 { + 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 { + 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, + } + + impl AsyncUdpSocket { + /// Send a payload to a destination address. + pub async fn send_to( + &self, + data: &[u8], + dest: &SocketAddr, + ) -> Result { + 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); } } diff --git a/src/upper/hosts.rs b/src/upper/hosts.rs index bc3799f..7959deb 100644 --- a/src/upper/hosts.rs +++ b/src/upper/hosts.rs @@ -17,7 +17,10 @@ use std::time::SystemTime; use tracing::{debug, info, warn}; /// Default path for the FIPS hosts file. +#[cfg(unix)] 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. #[derive(Debug, Clone, Default)] diff --git a/src/upper/tun.rs b/src/upper/tun.rs index d34ec81..4cf7657 100644 --- a/src/upper/tun.rs +++ b/src/upper/tun.rs @@ -3,17 +3,34 @@ //! Manages the TUN device for sending and receiving IPv6 packets. //! The TUN interface presents FIPS addresses to the local system, //! 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}; +#[cfg(unix)] use std::fs::File; +#[cfg(unix)] use std::io::Read; #[cfg(not(target_os = "macos"))] +#[cfg(unix)] use std::io::Write; use std::net::Ipv6Addr; +#[cfg(unix)] use std::os::unix::io::{AsRawFd, FromRawFd}; use std::sync::mpsc; 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; /// Channel sender for packets to be written to TUN. @@ -28,7 +45,7 @@ pub type TunOutboundRx = tokio::sync::mpsc::Receiver>; #[derive(Debug, Error)] pub enum TunError { #[error("failed to create TUN device: {0}")] - Create(#[from] tun::Error), + Create(#[source] Box), #[error("failed to configure TUN device: {0}")] Configure(String), @@ -43,10 +60,18 @@ pub enum TunError { #[error("permission denied: {0}")] PermissionDenied(String), + #[cfg(unix)] #[error("IPv6 is disabled (set net.ipv6.conf.all.disable_ipv6=0)")] Ipv6Disabled, } +#[cfg(unix)] +impl From for TunError { + fn from(e: tun::Error) -> Self { + TunError::Create(Box::new(e)) + } +} + /// TUN device state. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TunState { @@ -71,7 +96,12 @@ impl std::fmt::Display for TunState { } } +// ============================================================================ +// Unix (Linux + macOS) TUN implementation +// ============================================================================ + /// FIPS TUN device wrapper. +#[cfg(unix)] pub struct TunDevice { device: tun::Device, name: String, @@ -79,6 +109,7 @@ pub struct TunDevice { address: FipsAddress, } +#[cfg(unix)] impl TunDevice { /// Create or open a TUN device. /// @@ -227,6 +258,7 @@ impl TunDevice { /// Multiple producers can send packets via the TunTx channel. /// /// Also performs TCP MSS clamping on inbound SYN-ACK packets. +#[cfg(unix)] pub struct TunWriter { file: File, rx: mpsc::Receiver>, @@ -234,6 +266,7 @@ pub struct TunWriter { max_mss: u16, } +#[cfg(unix)] impl TunWriter { /// Run the writer loop. /// @@ -318,6 +351,7 @@ impl TunWriter { /// The loop exits when the TUN interface is deleted (EFAULT) or an unrecoverable /// error occurs. #[cfg(not(target_os = "macos"))] +#[cfg(unix)] pub fn run_tun_reader( mut device: TunDevice, mtu: u16, @@ -326,7 +360,7 @@ pub fn run_tun_reader( outbound_tx: TunOutboundTx, 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 { match device.read_packet(&mut buf) { @@ -387,7 +421,7 @@ pub fn run_tun_reader( ) { let _shutdown_fd = ShutdownFd(shutdown_fd); let tun_fd = device.device().as_raw_fd(); - let (name, mut buf, max_mss) = tun_reader_setup(&device, mtu, transport_mtu); + 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 // past the point where select returns readable. @@ -463,11 +497,11 @@ pub fn run_tun_reader( // _shutdown_fd closes on drop } -/// Common setup for TUN reader: extracts name, allocates buffer, computes max MSS. -fn tun_reader_setup(device: &TunDevice, mtu: u16, transport_mtu: u16) -> (String, Vec, u16) { +/// Common setup for TUN reader: allocates buffer, computes max MSS. +fn tun_reader_setup(device_name: &str, mtu: u16, transport_mtu: u16) -> (String, Vec, u16) { 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]; const IPV6_HEADER: u16 = 40; @@ -531,6 +565,17 @@ fn handle_tun_packet( 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. pub fn log_ipv6_packet(packet: &[u8]) { 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 /// to return an error. Use this for graceful shutdown when the TUN device /// has been moved to another thread. +#[cfg(unix)] pub async fn shutdown_tun_interface(name: &str) -> Result<(), TunError> { debug!("Shutting down TUN interface {}", name); platform::delete_interface(name).await?; @@ -577,19 +623,402 @@ pub async fn shutdown_tun_interface(name: &str) -> Result<(), TunError> { Ok(()) } -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() +// ============================================================================ +// 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, + _adapter: Arc, + 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 { + 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 { + 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 { + f.debug_struct("TunDevice") + .field("name", &self.name) + .field("mtu", &self.mtu) + .field("address", &self.address) + .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, + rx: mpsc::Receiver>, + 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(()) } } -// ============================================================================= -// Platform-specific TUN configuration -// ============================================================================= +// Re-export Windows TUN types at module level +#[cfg(windows)] +pub use windows_tun::{TunDevice, TunWriter, run_tun_reader, shutdown_tun_interface}; #[cfg(target_os = "linux")] mod platform {