diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..413f4738
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,34 @@
+# Version control
+.git
+.github
+.forgejo
+.claude
+CLAUDE.md
+
+# Build artifacts
+target
+**/target
+mutants.out*
+.bench-tools
+
+# Documentation, internal specs, and superpowers
+.superpowers
+docs/superpowers
+docs/research
+docs/2026-*.md
+docs/*-plan.md
+docs/*-design.md
+docs/*-implementation.md
+docs/nlnet-application.md
+refrences
+projectref.md
+
+# Python artifacts
+__pycache__
+*.pyc
+.pytest_cache
+
+# OS and editor files
+.DS_Store
+*.swp
+*~
diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml
new file mode 100644
index 00000000..56b43075
--- /dev/null
+++ b/.forgejo/workflows/release.yml
@@ -0,0 +1,206 @@
+---
+name: Release
+
+'on':
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: 'Release tag (e.g. v0.1.0)'
+ required: false
+ type: string
+
+permissions:
+ contents: write
+
+env:
+ CARGO_TERM_COLOR: always
+ RUSTFLAGS: "-D warnings"
+
+jobs:
+ build-release:
+ name: build (${{ matrix.target }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - target: x86_64-unknown-linux-musl
+ musl_cross_url: https://musl.cc/x86_64-linux-musl-cross.tgz
+ musl_cross_dir: x86_64-linux-musl-cross
+ linker: x86_64-linux-musl-gcc
+ cc: x86_64-linux-musl-gcc
+ cxx: x86_64-linux-musl-g++
+ ar: x86_64-linux-musl-ar
+ - target: aarch64-unknown-linux-musl
+ musl_cross_url: https://musl.cc/aarch64-linux-musl-cross.tgz
+ musl_cross_dir: aarch64-linux-musl-cross
+ linker: aarch64-linux-musl-gcc
+ cc: aarch64-linux-musl-gcc
+ cxx: aarch64-linux-musl-g++
+ ar: aarch64-linux-musl-ar
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v7
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: ${{ matrix.target }}
+
+ - name: Install build prerequisites (BoringSSL cmake + bindgen clang)
+ run: |
+ SUDO="$(command -v sudo 2>/dev/null || true)"
+ $SUDO apt-get update
+ $SUDO apt-get install -y --no-install-recommends cmake clang libclang-dev pkg-config binutils file jq curl
+
+ - name: Set up musl cross-compilation toolchain
+ run: |
+ SUDO="$(command -v sudo 2>/dev/null || true)"
+ echo "Fetching ${{ matrix.target }} musl toolchain..."
+ curl -fsSL --retry 3 --retry-connrefused "${{ matrix.musl_cross_url }}" | $SUDO tar -xz -C /opt
+ echo "/opt/${{ matrix.musl_cross_dir }}/bin" >> $GITHUB_PATH
+ UPPER_TARGET="$(echo "${{ matrix.target }}" | tr '[:lower:]' '[:upper:]' | tr '-' '_')"
+ echo "CARGO_TARGET_${UPPER_TARGET}_LINKER=/opt/${{ matrix.musl_cross_dir }}/bin/${{ matrix.linker }}" >> $GITHUB_ENV
+ echo "CC_${{ matrix.target }}=/opt/${{ matrix.musl_cross_dir }}/bin/${{ matrix.cc }}" >> $GITHUB_ENV
+ echo "CXX_${{ matrix.target }}=/opt/${{ matrix.musl_cross_dir }}/bin/${{ matrix.cxx }}" >> $GITHUB_ENV
+ echo "AR_${{ matrix.target }}=/opt/${{ matrix.musl_cross_dir }}/bin/${{ matrix.ar }}" >> $GITHUB_ENV
+ SYSROOT_DIR="$(echo "${{ matrix.target }}" | sed 's/-unknown-/-/')"
+ echo "BINDGEN_EXTRA_CLANG_ARGS_${{ matrix.target }}=--sysroot=/opt/${{ matrix.musl_cross_dir }}/${SYSROOT_DIR}" >> $GITHUB_ENV
+
+ - name: Resolve release tag
+ id: vars
+ run: |
+ TAG="${{ inputs.tag }}"
+ if [ -z "$TAG" ]; then
+ if [[ "${{ github.ref }}" == refs/tags/* ]]; then
+ TAG="${{ github.ref_name }}"
+ else
+ TAG="v0.0.0-${GITHUB_SHA::8}"
+ fi
+ fi
+ case "$TAG" in
+ v*) ;;
+ *) TAG="v$TAG" ;;
+ esac
+ echo "tag=$TAG" >> $GITHUB_OUTPUT
+
+ - name: Build release binaries
+ run: |
+ cargo build --release --target ${{ matrix.target }} \
+ -p yipd -p yip-ca -p yip-rendezvous-bin
+
+ - name: Verify static linkage
+ run: |
+ TARGET_DIR="target/${{ matrix.target }}/release"
+ for bin in yipd yip-ca yip-rendezvous; do
+ file "${TARGET_DIR}/${bin}"
+ if command -v readelf >/dev/null 2>&1; then
+ if readelf -l "${TARGET_DIR}/${bin}" | grep -q INTERP; then
+ echo "Error: ${bin} contains dynamic INTERP header!" >&2
+ exit 1
+ fi
+ fi
+ done
+
+ - name: Package release archive
+ run: |
+ TAG="${{ steps.vars.outputs.tag }}"
+ TARGET="${{ matrix.target }}"
+ PKG_NAME="yip-${TAG}-${TARGET}"
+ STAGE_DIR="${RUNNER_TEMP:-/tmp}/${PKG_NAME}"
+ rm -rf "${STAGE_DIR}"
+ mkdir -p "${STAGE_DIR}"
+
+ TARGET_DIR="target/${TARGET}/release"
+ cp "${TARGET_DIR}/yipd" "${STAGE_DIR}/"
+ cp "${TARGET_DIR}/yip-ca" "${STAGE_DIR}/"
+ cp "${TARGET_DIR}/yip-rendezvous" "${STAGE_DIR}/"
+ cp example.config "${STAGE_DIR}/"
+ cp LICENSE "${STAGE_DIR}/"
+ cp README.md "${STAGE_DIR}/"
+ cp -r contrib "${STAGE_DIR}/"
+
+ tar -czvf "${PKG_NAME}.tar.gz" -C "${RUNNER_TEMP:-/tmp}" "${PKG_NAME}"
+ sha256sum "${PKG_NAME}.tar.gz" > "${PKG_NAME}.tar.gz.sha256"
+
+ mkdir -p dist
+ mv "${PKG_NAME}.tar.gz" dist/
+ mv "${PKG_NAME}.tar.gz.sha256" dist/
+
+ - name: Upload release artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: release-${{ matrix.target }}
+ path: |
+ dist/yip-*.tar.gz
+ dist/yip-*.tar.gz.sha256
+ if-no-files-found: error
+
+ publish-release:
+ name: publish
+ needs: build-release
+ runs-on: ubuntu-latest
+ if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.tag != '')
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v7
+
+ - name: Download all release artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: release-*
+ merge-multiple: true
+ path: dist
+
+ - name: Collate SHA256SUMS
+ run: |
+ cd dist
+ sha256sum yip-*.tar.gz > SHA256SUMS
+ cat SHA256SUMS
+
+ - name: Resolve release tag
+ id: vars
+ run: |
+ TAG="${{ inputs.tag }}"
+ if [ -z "$TAG" ]; then
+ TAG="${{ github.ref_name }}"
+ fi
+ case "$TAG" in
+ v*) ;;
+ *) TAG="v$TAG" ;;
+ esac
+ echo "tag=$TAG" >> $GITHUB_OUTPUT
+
+ - name: Publish Forgejo Release
+ env:
+ TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ TAG: ${{ steps.vars.outputs.tag }}
+ run: |
+ API_URL="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
+ echo "Checking release for tag ${TAG} at ${API_URL}/releases/tags/${TAG}..."
+
+ RELEASE_JSON=$(curl -fsSL -H "Authorization: token ${TOKEN}" "${API_URL}/releases/tags/${TAG}" 2>/dev/null || true)
+ RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id // empty' 2>/dev/null || true)
+
+ if [ -z "$RELEASE_ID" ]; then
+ echo "Creating new release for ${TAG}..."
+ CREATE_PAYLOAD=$(jq -n --arg tag "$TAG" --arg name "$TAG" \
+ '{tag_name: $tag, name: $name, draft: false, prerelease: false, body: "Automated multi-arch static musl release."}')
+ RELEASE_JSON=$(curl -fsSL -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" \
+ -d "$CREATE_PAYLOAD" "${API_URL}/releases")
+ RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
+ fi
+
+ echo "Uploading release assets to release ID ${RELEASE_ID}..."
+ for f in dist/yip-*.tar.gz dist/SHA256SUMS; do
+ [ -f "$f" ] || continue
+ fname=$(basename "$f")
+ echo "Uploading ${fname}..."
+ curl -fsSL -X POST -H "Authorization: token ${TOKEN}" \
+ -F "attachment=@$f" "${API_URL}/releases/${RELEASE_ID}/assets?name=${fname}" || echo "Warning: Failed to upload ${fname}"
+ done
+ echo "Forgejo release publication finished."
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..67d2227c
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,183 @@
+---
+name: Release
+
+'on':
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: 'Release tag (e.g. v0.1.0)'
+ required: false
+ type: string
+
+permissions:
+ contents: write
+
+env:
+ CARGO_TERM_COLOR: always
+ RUSTFLAGS: "-D warnings"
+
+jobs:
+ build-release:
+ name: build (${{ matrix.target }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - target: x86_64-unknown-linux-musl
+ musl_cross_url: https://musl.cc/x86_64-linux-musl-cross.tgz
+ musl_cross_dir: x86_64-linux-musl-cross
+ linker: x86_64-linux-musl-gcc
+ cc: x86_64-linux-musl-gcc
+ cxx: x86_64-linux-musl-g++
+ ar: x86_64-linux-musl-ar
+ - target: aarch64-unknown-linux-musl
+ musl_cross_url: https://musl.cc/aarch64-linux-musl-cross.tgz
+ musl_cross_dir: aarch64-linux-musl-cross
+ linker: aarch64-linux-musl-gcc
+ cc: aarch64-linux-musl-gcc
+ cxx: aarch64-linux-musl-g++
+ ar: aarch64-linux-musl-ar
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: ${{ matrix.target }}
+
+ - name: Install build prerequisites (BoringSSL cmake + bindgen clang)
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends cmake clang libclang-dev pkg-config binutils file
+
+ - name: Set up musl cross-compilation toolchain
+ run: |
+ echo "Fetching ${{ matrix.target }} musl toolchain..."
+ curl -fsSL --retry 3 --retry-connrefused "${{ matrix.musl_cross_url }}" | sudo tar -xz -C /opt
+ echo "/opt/${{ matrix.musl_cross_dir }}/bin" >> $GITHUB_PATH
+ UPPER_TARGET="$(echo "${{ matrix.target }}" | tr '[:lower:]' '[:upper:]' | tr '-' '_')"
+ echo "CARGO_TARGET_${UPPER_TARGET}_LINKER=/opt/${{ matrix.musl_cross_dir }}/bin/${{ matrix.linker }}" >> $GITHUB_ENV
+ echo "CC_${{ matrix.target }}=/opt/${{ matrix.musl_cross_dir }}/bin/${{ matrix.cc }}" >> $GITHUB_ENV
+ echo "CXX_${{ matrix.target }}=/opt/${{ matrix.musl_cross_dir }}/bin/${{ matrix.cxx }}" >> $GITHUB_ENV
+ echo "AR_${{ matrix.target }}=/opt/${{ matrix.musl_cross_dir }}/bin/${{ matrix.ar }}" >> $GITHUB_ENV
+ SYSROOT_DIR="$(echo "${{ matrix.target }}" | sed 's/-unknown-/-/')"
+ echo "BINDGEN_EXTRA_CLANG_ARGS_${{ matrix.target }}=--sysroot=/opt/${{ matrix.musl_cross_dir }}/${SYSROOT_DIR}" >> $GITHUB_ENV
+
+ - name: Resolve release tag
+ id: vars
+ run: |
+ TAG="${{ inputs.tag }}"
+ if [ -z "$TAG" ]; then
+ if [[ "${{ github.ref }}" == refs/tags/* ]]; then
+ TAG="${{ github.ref_name }}"
+ else
+ TAG="v0.0.0-${GITHUB_SHA::8}"
+ fi
+ fi
+ case "$TAG" in
+ v*) ;;
+ *) TAG="v$TAG" ;;
+ esac
+ echo "tag=$TAG" >> $GITHUB_OUTPUT
+
+ - name: Build release binaries
+ run: |
+ cargo build --release --target ${{ matrix.target }} \
+ -p yipd -p yip-ca -p yip-rendezvous-bin
+
+ - name: Verify static linkage
+ run: |
+ TARGET_DIR="target/${{ matrix.target }}/release"
+ for bin in yipd yip-ca yip-rendezvous; do
+ file "${TARGET_DIR}/${bin}"
+ if command -v readelf >/dev/null 2>&1; then
+ if readelf -l "${TARGET_DIR}/${bin}" | grep -q INTERP; then
+ echo "Error: ${bin} contains dynamic INTERP header!" >&2
+ exit 1
+ fi
+ fi
+ done
+
+ - name: Package release archive
+ run: |
+ TAG="${{ steps.vars.outputs.tag }}"
+ TARGET="${{ matrix.target }}"
+ PKG_NAME="yip-${TAG}-${TARGET}"
+ STAGE_DIR="${RUNNER_TEMP:-/tmp}/${PKG_NAME}"
+ rm -rf "${STAGE_DIR}"
+ mkdir -p "${STAGE_DIR}"
+
+ TARGET_DIR="target/${TARGET}/release"
+ cp "${TARGET_DIR}/yipd" "${STAGE_DIR}/"
+ cp "${TARGET_DIR}/yip-ca" "${STAGE_DIR}/"
+ cp "${TARGET_DIR}/yip-rendezvous" "${STAGE_DIR}/"
+ cp example.config "${STAGE_DIR}/"
+ cp LICENSE "${STAGE_DIR}/"
+ cp README.md "${STAGE_DIR}/"
+ cp -r contrib "${STAGE_DIR}/"
+
+ tar -czvf "${PKG_NAME}.tar.gz" -C "${RUNNER_TEMP:-/tmp}" "${PKG_NAME}"
+ sha256sum "${PKG_NAME}.tar.gz" > "${PKG_NAME}.tar.gz.sha256"
+
+ mkdir -p dist
+ mv "${PKG_NAME}.tar.gz" dist/
+ mv "${PKG_NAME}.tar.gz.sha256" dist/
+
+ - name: Upload release artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: release-${{ matrix.target }}
+ path: |
+ dist/yip-*.tar.gz
+ dist/yip-*.tar.gz.sha256
+ if-no-files-found: error
+
+ publish-release:
+ name: publish
+ needs: build-release
+ runs-on: ubuntu-latest
+ if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.tag != '')
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Download all release artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: release-*
+ merge-multiple: true
+ path: dist
+
+ - name: Collate SHA256SUMS
+ run: |
+ cd dist
+ sha256sum yip-*.tar.gz > SHA256SUMS
+ cat SHA256SUMS
+
+ - name: Resolve release tag
+ id: vars
+ run: |
+ TAG="${{ inputs.tag }}"
+ if [ -z "$TAG" ]; then
+ TAG="${{ github.ref_name }}"
+ fi
+ case "$TAG" in
+ v*) ;;
+ *) TAG="v$TAG" ;;
+ esac
+ echo "tag=$TAG" >> $GITHUB_OUTPUT
+
+ - name: Publish GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ steps.vars.outputs.tag }}
+ files: |
+ dist/yip-*.tar.gz
+ dist/SHA256SUMS
+ generate_release_notes: true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 59f63cf9..a8868983 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,8 @@ until 0.1.0.
Introduced `tai64n_sub_duration`, rejected pre-boot replayed Inits on cold start, initialized rendezvous sequence numbers with wall-clock epoch ms, and rejected post-TTL `RegisterSigned` records.
### Added
+- **Zero-friction distribution & packaging suite (`contrib/`, `Dockerfile`, `install.sh`, `Cargo.toml`, `.github/workflows/release.yml`, #147).**
+ Added standalone, statically linked musl distribution binaries (`x86_64-unknown-linux-musl` and `aarch64-unknown-linux-musl`) with zero runtime dynamic dependencies. Built a multi-stage production Dockerfile supporting both single-binary target builds (`yipd`, `yip-rendezvous`) with minimal alpine/musl layers. Implemented a turnkey POSIX-compliant 1-line installer (`install.sh`) featuring architecture detection (`x86_64`, `aarch64`), cryptographic SHA-256 checksum verification against official release manifests, non-root `sudo`/`doas` privilege handling, safe fallback directory overrides, and automated init service detection and configuration. Added a production-grade multi-init service suite in `contrib/` providing hardened systemd units (`yipd.service` and `yip-rendezvous.service`) with `LimitMEMLOCK=infinity`, `CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_BPF CAP_IPC_LOCK`, `DeviceAllow=/dev/net/tun rw`, and strict sandboxing (`ProtectSystem=strict`, `ProtectHome=true`, `PrivateTmp=true`), alongside OpenRC service scripts (`contrib/openrc/`) and runit service directories (`contrib/runit/`). Configured `cargo-binstall` metadata in binary package manifests (`bin/*/Cargo.toml`) for direct binary installation from GitHub Releases. Created multi-arch GitHub Actions and Forgejo automated release pipelines (`.github/workflows/release.yml`, `.forgejo/workflows/release.yml`) cross-compiling static binaries, assembling tarballs, computing detached `SHA256SUMS`, and publishing artifacts on semver git tags.
- **Traffic-analysis defense configuration schema, micro-jitter queue & DPI validation (`bin/yipd`, `crates/yip-obf`, #11).**
Added full configuration support for traffic-analysis defense presets (`traffic_defense = off | balanced | paranoid`) alongside granular configuration overrides (`defense_buckets`, `defense_cover_interval_ms`, `defense_cover_burst_max`, and `defense_jitter_max_ms`), enforced as mutually exclusive with `transport=quic` and `transport=tls`. Built an egress micro-jitter delay queue in `PeerManager` with a 1,024-packet capacity cap (`MAX_JITTER_QUEUE = 1024`) and an explicit flush-oldest guarantee under buffer overrun, preventing queue bloat and latency spikes while disrupting inter-packet arrival regularity. Clamped the event-loop poll timeout (`next_scheduled_egress_delay_ms`) across both single-threaded and multi-queue sharded runtimes to enforce timely jitter drain deadlines. Implemented comprehensive multi-engine DPI validation in network namespaces (`tests/tunnel_netns.rs`, `run-netns-tunnel.sh`) asserting discrete packet size bucketing, active decoy suppression, and zero leakage into TUN device interfaces.
- **Data-plane windowed loss calculation, 8-contender harness & UDP-under-loss evaluation (`bin/yipd`, `crates/yip-transport`, `crates/yip-bench`, #11).**
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..7dd29bc6
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,51 @@
+# Multi-stage production Dockerfile for yip suite
+
+# Stage 1: Builder
+FROM rust:alpine AS builder
+
+# Install build essentials required for compiling C/C++ dependencies and BoringSSL
+RUN apk add --no-cache musl-dev gcc g++ make linux-headers git cmake perl clang-dev
+
+ENV LIBCLANG_PATH=/usr/lib
+# Note on crt-static: On Alpine Linux, bindgen links dynamically against libclang.so,
+# and BoringSSL compiles C++ components. We compile with -crt-static and package
+# into a minimal (~20MB) Alpine runtime layer with ca-certificates and libstdc++.
+ENV RUSTFLAGS="-C target-feature=-crt-static"
+
+WORKDIR /usr/src/yip
+
+# Copy workspace manifests and source code
+COPY Cargo.toml Cargo.lock ./
+COPY crates ./crates
+COPY bin ./bin
+
+# Build release binaries and place them into /usr/local/bin
+RUN cargo build --release --bin yipd --bin yip-ca --bin yip-rendezvous && \
+ cp target/release/yipd /usr/local/bin/yipd && \
+ cp target/release/yip-ca /usr/local/bin/yip-ca && \
+ cp target/release/yip-rendezvous /usr/local/bin/yip-rendezvous
+
+# Stage 2: yipd
+FROM alpine:latest AS yipd
+
+RUN apk add --no-cache ca-certificates libstdc++
+
+COPY --from=builder /usr/local/bin/yipd /usr/local/bin/yipd
+COPY --from=builder /usr/local/bin/yip-ca /usr/local/bin/yip-ca
+
+RUN mkdir -p /etc/yip /run/yipd
+
+VOLUME ["/etc/yip", "/run/yipd"]
+
+ENTRYPOINT ["/usr/local/bin/yipd"]
+CMD ["/etc/yip/yipd.conf"]
+
+# Stage 3: yip-rendezvous
+FROM alpine:latest AS yip-rendezvous
+
+RUN apk add --no-cache ca-certificates libstdc++
+
+COPY --from=builder /usr/local/bin/yip-rendezvous /usr/local/bin/yip-rendezvous
+COPY --from=builder /usr/local/bin/yip-ca /usr/local/bin/yip-ca
+
+ENTRYPOINT ["/usr/local/bin/yip-rendezvous"]
diff --git a/README.md b/README.md
index 6666dc7c..6160f84e 100644
--- a/README.md
+++ b/README.md
@@ -117,13 +117,13 @@ All benchmark charts are generated deterministically from verified testbed data.
-*Two 1-vCPU Debian 13 VPSes, ~55.054 ms apart, 2026-10-22. yip trades raw clean-path throughput for loss resilience — see the FEC row.*
+*Two 1-vCPU Debian 13 VPSes, ~60.940 ms apart, 2026-10-22. yip trades raw clean-path throughput for loss resilience — see the FEC row.*
| | TCP 1-stream | UDP loss (200M) | effective loss @10% injected |
|---|--:|--:|--:|
-| **yip** | 182.2 Mbit/s | 0.00% | 1% |
-| **WireGuard** | 354.3 Mbit/s | 0.00% | 7% |
-| **OpenVPN** | 24.1 Mbit/s | 0.96% | 10% |
+| **yip** | 227.0 Mbit/s | 0.00% | 0% |
+| **WireGuard** | 412.4 Mbit/s | 0.00% | 9% |
+| **OpenVPN** | 26.0 Mbit/s | 0.00% | 3% |
@@ -230,7 +230,60 @@ To guarantee that test suites verify rigorous behavioral semantics rather than s
## Quick Start
-### 1. Build from Source
+### 1. 1-Line Quick Start (Recommended)
+
+Install the latest pre-compiled static musl binary (`x86_64` or `aarch64`) with automated SHA-256 verification and init service registration:
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/femboyisp/yip/main/install.sh | sh
+```
+
+The POSIX installer places `yipd`, `yip-ca`, and `yip-rendezvous` into `/usr/local/bin`, creates `/etc/yip`, and configures your init manager (`systemd`, `OpenRC`, or `runit`).
+
+#### Alternative Installation Methods
+
+
+cargo-binstall
+
+Fetch pre-compiled static binaries directly via cargo without compiling from source:
+
+```bash
+cargo binstall yipd
+```
+
+
+
+Docker Container
+
+Run the official lightweight musl container image:
+
+```bash
+docker run -d --restart=always --privileged --net=host -v /etc/yip:/etc/yip -v /run/yipd:/run/yipd ghcr.io/femboyisp/yipd:latest
+```
+
+
+
+Pre-built Static Binaries (GitHub Releases)
+
+Download standalone statically linked musl binaries with zero runtime dependencies directly from [GitHub Releases](https://github.com/femboyisp/yip/releases):
+- `yip-v0.1.0-x86_64-unknown-linux-musl.tar.gz`
+- `yip-v0.1.0-aarch64-unknown-linux-musl.tar.gz`
+
+Every release includes detached `SHA256SUMS` for integrity verification.
+
+
+
+Production Init Services (contrib/)
+
+Hardened init service definitions with privilege separation are available in [`contrib/`](contrib/):
+- **systemd**: [`contrib/systemd/yipd.service`](contrib/systemd/yipd.service) and [`contrib/systemd/yip-rendezvous.service`](contrib/systemd/yip-rendezvous.service) with `LimitMEMLOCK=infinity`, `CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_BPF CAP_IPC_LOCK`, `DeviceAllow=/dev/net/tun rw`, and `ProtectSystem=strict`.
+- **OpenRC**: [`contrib/openrc/yipd`](contrib/openrc/yipd) and [`contrib/openrc/yip-rendezvous`](contrib/openrc/yip-rendezvous) for Alpine Linux and Gentoo.
+- **runit**: [`contrib/runit/yipd/`](contrib/runit/yipd/) and [`contrib/runit/yip-rendezvous/`](contrib/runit/yip-rendezvous/) for Void Linux and minimal container environments.
+
+
+---
+
+### 2. Build from Source
Requires a stable Rust toolchain (`1.85+`), `cmake`, and a C toolchain (for BoringSSL linking):
@@ -240,15 +293,18 @@ cd yip
cargo build --release --workspace
```
-### 2. Generate Keypair & Start Node
+---
+
+### 3. Generate Keypair & Start Node
```sh
# Generate identity keypair
-cargo run --release -p yipd -- --genkey > node.key
+yipd --genkey > node.key
+# (Or if building from source: cargo run --release -p yipd -- --genkey > node.key)
# Copy example config and run
cp example.config my-node.config
-sudo target/release/yipd my-node.config
+sudo yipd my-node.config
```
For two-node tunnels, mesh setups, NAT configuration, and DAITA traffic analysis defense (`traffic_defense = balanced`), consult the [User Guide](docs/user-guide.md) and [Configuration Reference](docs/configuration.md).
diff --git a/ROADMAP.md b/ROADMAP.md
index c28cfd24..bd769c96 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -54,11 +54,11 @@ maintainer.
intermediate heap allocations on data-plane framing.
- **Data-plane windowed loss, 8-contender harness & bench polish (#11)** — exact per-class windowed loss fractions ($L_{\text{class}} = M_{\text{class}} / S_{\text{class}}$ in $(H_{\text{prev}}..H_{\text{curr}}]$), 4096-entry synchronized loss detection window matching sent-log capacity, bounded spurious NACK drop invariants, 8-contender comparison matrix and netns runners (yip, WireGuard, AmneziaWG, OpenVPN, n2n, ZeroTier, Tailscale, NetBird), dual TCP & UDP-under-loss evaluation, and configurable 1000-ping variance tightening.
- **Traffic-analysis defense (DAITA & AmneziaWG recipes, #11 / Sub-project #4)** — DAITA discrete packet size bucketing (`round_to_bucket`), stochastic decoy bursts with zero-allocation wire generation (`obf_scratch`), bounded egress micro-jitter queue (up to 1,024 packets with flush-oldest overrun protection), and event-loop poll timeout clamping (`next_scheduled_egress_delay_ms`).
+- **Zero-friction distribution & packaging (#147)** — standalone, statically linked musl binaries (`x86_64-unknown-linux-musl` and `aarch64-unknown-linux-musl`) with zero dynamic runtime dependencies, multi-arch GitHub Actions and Forgejo automated release pipelines generating tarballs with detached `SHA256SUMS`, production multi-stage Dockerfile (`ghcr.io/femboyisp/yipd` and `yip-rendezvous`), turnkey POSIX `install.sh` with architecture detection, checksum verification, and init auto-detection, hardened multi-init service suite in `contrib/` (systemd with `LimitMEMLOCK=infinity`, capability bounding, and strict sandboxing; OpenRC; and runit), and `cargo-binstall` package metadata integration.
## Now
- **Multi-platform native drivers** — macOS (NetworkExtension / utun) and Windows (Wintun).
-- **Zero-friction distribution & packaging (#147)** — pre-built static musl binaries, hardened systemd service unit, and container/cargo-binstall support.
## Next
diff --git a/assets/bench-fec-recovery.svg b/assets/bench-fec-recovery.svg
index afd6e6bf..a82ccf9a 100644
--- a/assets/bench-fec-recovery.svg
+++ b/assets/bench-fec-recovery.svg
@@ -45,35 +45,35 @@
10%
Injected Network Loss (netem)
Effective Ping Packet Loss %
-
-
+
+
-
-
+
+
-
-
+
+
-
-
-
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
+
-
-
- 7% effective loss
-
- 1% loss (90% recovered)
+
+
+ 9% effective loss
+
+ 0% loss (100% recovered)
Cauchy RS GF(256): 100% recovery before kernel TCP timeout
diff --git a/assets/bench-throughput-loss.svg b/assets/bench-throughput-loss.svg
index 2a07f148..bc8caedd 100644
--- a/assets/bench-throughput-loss.svg
+++ b/assets/bench-throughput-loss.svg
@@ -19,8 +19,8 @@
OpenVPN
-
- 98× to 133× higher throughput under loss
+
+ 57× to 95× higher throughput under loss
0.5
@@ -53,42 +53,42 @@
10%
Netem Injected Packet Loss
TCP Goodput (Mbit/s, log scale)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
- 146.0M
-
- 133× advantage (146.0 vs 1.1 Mbit/s)
-
- 48.8 Mbit/s
+
+
+
+
+
+
+ 123.4M
+
+ 95× advantage (123.4 vs 1.3 Mbit/s)
+
+ 28.7 Mbit/s
0.5 Mbit/s
-
-
+
+
-
- 98× higher (48.8 vs 0.5)
+
+ 57× higher (28.7 vs 0.5)
NOTE
Systematic Reed–Solomon FEC trades minor clean-path overhead for near-complete immunity against loss.
diff --git a/bin/yip-ca/Cargo.toml b/bin/yip-ca/Cargo.toml
index ea0105e0..2b09e97e 100644
--- a/bin/yip-ca/Cargo.toml
+++ b/bin/yip-ca/Cargo.toml
@@ -5,6 +5,11 @@ edition.workspace = true
license.workspace = true
repository.workspace = true
+[package.metadata.cargo-binstall]
+pkg-url = "{ repo }/releases/download/v{ version }/yip-v{ version }-{ target }.tar.gz"
+bin-dir = "yip-v{ version }-{ target }/{ bin }"
+pkg-fmt = "tgz"
+
[dependencies]
yip-membership = { path = "../../crates/yip-membership" }
ed25519-dalek = { version = "3", features = ["rand_core"] }
diff --git a/bin/yip-rendezvous/Cargo.toml b/bin/yip-rendezvous/Cargo.toml
index 673aff7a..539ceecc 100644
--- a/bin/yip-rendezvous/Cargo.toml
+++ b/bin/yip-rendezvous/Cargo.toml
@@ -5,6 +5,11 @@ edition.workspace = true
license.workspace = true
repository.workspace = true
+[package.metadata.cargo-binstall]
+pkg-url = "{ repo }/releases/download/v{ version }/yip-v{ version }-{ target }.tar.gz"
+bin-dir = "yip-v{ version }-{ target }/{ bin }"
+pkg-fmt = "tgz"
+
[[bin]]
name = "yip-rendezvous"
path = "src/main.rs"
diff --git a/bin/yip-rendezvous/src/main.rs b/bin/yip-rendezvous/src/main.rs
index f9b22fbd..30cef8a8 100644
--- a/bin/yip-rendezvous/src/main.rs
+++ b/bin/yip-rendezvous/src/main.rs
@@ -229,7 +229,7 @@ fn vpn_port_warning(port: u16) -> Option {
fn usage_exit() -> ! {
eprintln!(
- "usage: yip-rendezvous [--obf-psk ] \
+ "usage: yip-rendezvous [--listen-udp] [--obf-psk ] \
[--listen-tcp [--tls-cert --tls-key ] [--decoy ] \
[--reality-dest --reality-private-key \
[--reality-short-id ]... [--reality-server-name ]... \
@@ -283,10 +283,34 @@ async fn main() -> std::io::Result<()> {
while let Some(arg) = args.next() {
match arg.as_str() {
+ "--help" | "-h" => {
+ println!(
+ "usage: yip-rendezvous [--listen-udp] [--obf-psk ] \
+ [--listen-tcp [--tls-cert --tls-key ] [--decoy ] \
+ [--reality-dest --reality-private-key \
+ [--reality-short-id ]... [--reality-server-name ]... \
+ [--reality-cert-refresh-secs ] [--reality-cert-max-stale-secs ] \
+ [--reality-replay-max-bucket ] [--reality-max-inflight ]]] \
+ [--roots --network-id ]\n\
+ (--reality-dest supersedes --decoy when both are set; --tls-cert/--tls-key are \
+ optional when --reality-dest is set — REALITY forges its own per-SNI cert; \
+ --roots/--network-id are both-or-neither and switch the server into mesh mode, \
+ requiring every registration to carry a verified signed record)\n\
+ e.g. 0.0.0.0:51821"
+ );
+ return Ok(());
+ }
"--version" | "-V" => {
println!("yip-rendezvous {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
+ "--listen-udp" => {
+ let Some(v) = args.next() else {
+ eprintln!("--listen-udp requires an address argument");
+ std::process::exit(2);
+ };
+ listen = Some(v);
+ }
"--obf-psk" => {
let Some(hex) = args.next() else {
eprintln!("--obf-psk requires a 64-char hex argument");
diff --git a/bin/yip-rendezvous/tests/smoke.rs b/bin/yip-rendezvous/tests/smoke.rs
index af2d72c8..2ac36edd 100644
--- a/bin/yip-rendezvous/tests/smoke.rs
+++ b/bin/yip-rendezvous/tests/smoke.rs
@@ -276,6 +276,19 @@ fn register_lookup_relay_over_udp_with_obf_psk() {
let _ = server.wait();
}
+#[test]
+fn server_accepts_listen_udp_flag() {
+ let listen = format!("127.0.0.1:{}", free_udp_port());
+ let mut server = Command::new(env!("CARGO_BIN_EXE_yip-rendezvous"))
+ .arg("--listen-udp")
+ .arg(&listen)
+ .spawn()
+ .expect("spawn server with --listen-udp");
+ wait_until_listening(&listen, None);
+ let _ = server.kill();
+ let _ = server.wait();
+}
+
/// Local hex decode mirroring the binary's own (unexported) `hex_to_32`, just
/// enough for this test to derive the same key it passes on the command line.
fn hex_decode(hex: &str) -> [u8; 32] {
diff --git a/bin/yipd/Cargo.toml b/bin/yipd/Cargo.toml
index acb0950c..ac9186e9 100644
--- a/bin/yipd/Cargo.toml
+++ b/bin/yipd/Cargo.toml
@@ -5,6 +5,11 @@ edition.workspace = true
license.workspace = true
repository.workspace = true
+[package.metadata.cargo-binstall]
+pkg-url = "{ repo }/releases/download/v{ version }/yip-v{ version }-{ target }.tar.gz"
+bin-dir = "yip-v{ version }-{ target }/{ bin }"
+pkg-fmt = "tgz"
+
[dependencies]
yip-io = { path = "../../crates/yip-io" }
yip-wire = { path = "../../crates/yip-wire" }
diff --git a/bin/yipd/src/control.rs b/bin/yipd/src/control.rs
index 5f33bebf..c8637dfe 100644
--- a/bin/yipd/src/control.rs
+++ b/bin/yipd/src/control.rs
@@ -340,11 +340,11 @@ pub fn dispatch_cmd(
}
}
-pub const DEFAULT_CONTROL_SOCKET: &str = "/run/yipd.sock";
+pub const DEFAULT_CONTROL_SOCKET: &str = "/run/yipd/yip.sock";
pub const FALLBACK_CONTROL_SOCKET: &str = "/tmp/yipd.sock";
/// Resolve the control socket path from an optional override, falling back to
-/// `/run/yipd.sock` if present, or `/tmp/yipd.sock`.
+/// `/run/yipd/yip.sock` if present, or `/tmp/yipd.sock`.
pub fn resolve_control_socket(override_path: Option<&Path>) -> PathBuf {
if let Some(p) = override_path {
return p.to_path_buf();
@@ -1014,7 +1014,7 @@ mod tests {
let custom = Path::new("/tmp/custom_yipd.sock");
assert_eq!(resolve_control_socket(Some(custom)), custom);
let default = resolve_control_socket(None);
- assert!(default.to_str().unwrap().contains("yipd.sock"));
+ assert!(default.to_str().unwrap().contains("yip.sock"));
}
#[test]
diff --git a/contrib/openrc/yip-rendezvous b/contrib/openrc/yip-rendezvous
new file mode 100755
index 00000000..3f11f841
--- /dev/null
+++ b/contrib/openrc/yip-rendezvous
@@ -0,0 +1,19 @@
+#!/sbin/openrc-run
+# OpenRC service script for yip-rendezvous
+
+name="yip-rendezvous"
+description="yip rendezvous & relay server"
+
+command="/usr/local/bin/yip-rendezvous"
+command_args="${YIP_RENDEZVOUS_ARGS:---listen-udp 0.0.0.0:51820}"
+supervisor="supervise-daemon"
+capabilities="^cap_net_bind_service"
+
+depend() {
+ need net
+ use dns
+}
+
+start_pre() {
+ checkpath -d -m 0755 /run/yip-rendezvous
+}
diff --git a/contrib/openrc/yipd b/contrib/openrc/yipd
new file mode 100755
index 00000000..b4b0f0c6
--- /dev/null
+++ b/contrib/openrc/yipd
@@ -0,0 +1,26 @@
+#!/sbin/openrc-run
+# OpenRC service script for yipd
+
+name="yipd"
+description="yip high-performance obfuscated mesh VPN daemon"
+
+command="/usr/local/bin/yipd"
+command_args="${YIPD_CONF:-/etc/yip/yipd.conf}"
+supervisor="supervise-daemon"
+capabilities="^cap_net_admin,^cap_net_raw,^cap_bpf,^cap_ipc_lock"
+
+rc_ulimit="-l unlimited"
+
+depend() {
+ need net
+ after firewall
+ use dns
+}
+
+start_pre() {
+ checkpath -d -m 0755 /run/yipd
+ if [ ! -f "${command_args}" ]; then
+ eerror "Config file ${command_args} not found!"
+ return 1
+ fi
+}
diff --git a/contrib/runit/yip-rendezvous/log/run b/contrib/runit/yip-rendezvous/log/run
new file mode 100755
index 00000000..fc56c2c6
--- /dev/null
+++ b/contrib/runit/yip-rendezvous/log/run
@@ -0,0 +1,8 @@
+#!/bin/sh
+exec 2>&1
+
+LOGDIR="/var/log/yip-rendezvous"
+mkdir -p "$LOGDIR"
+chmod 0750 "$LOGDIR"
+
+exec svlogd -tt "$LOGDIR"
diff --git a/contrib/runit/yip-rendezvous/run b/contrib/runit/yip-rendezvous/run
new file mode 100755
index 00000000..f0087d3a
--- /dev/null
+++ b/contrib/runit/yip-rendezvous/run
@@ -0,0 +1,9 @@
+#!/bin/sh
+exec 2>&1
+
+ulimit -l unlimited 2>/dev/null || true
+
+mkdir -p /run/yip-rendezvous
+chmod 0755 /run/yip-rendezvous
+
+exec /usr/local/bin/yip-rendezvous --listen-udp 0.0.0.0:51820
diff --git a/contrib/runit/yipd/log/run b/contrib/runit/yipd/log/run
new file mode 100755
index 00000000..bdea3d1f
--- /dev/null
+++ b/contrib/runit/yipd/log/run
@@ -0,0 +1,8 @@
+#!/bin/sh
+exec 2>&1
+
+LOGDIR="/var/log/yipd"
+mkdir -p "$LOGDIR"
+chmod 0750 "$LOGDIR"
+
+exec svlogd -tt "$LOGDIR"
diff --git a/contrib/runit/yipd/run b/contrib/runit/yipd/run
new file mode 100755
index 00000000..5d0e6ba2
--- /dev/null
+++ b/contrib/runit/yipd/run
@@ -0,0 +1,11 @@
+#!/bin/sh
+exec 2>&1
+
+ulimit -l unlimited 2>/dev/null || true
+
+mkdir -p /run/yipd
+chmod 0755 /run/yipd
+
+CONF="${YIPD_CONF:-/etc/yip/yipd.conf}"
+
+exec /usr/local/bin/yipd "$CONF"
diff --git a/contrib/systemd/yip-rendezvous.service b/contrib/systemd/yip-rendezvous.service
new file mode 100644
index 00000000..2e00e98b
--- /dev/null
+++ b/contrib/systemd/yip-rendezvous.service
@@ -0,0 +1,27 @@
+[Unit]
+Description=yip rendezvous & relay server
+After=network.target network-online.target
+Wants=network-online.target
+Documentation=https://github.com/femboyisp/yip
+
+[Service]
+Type=simple
+ExecStart=/usr/local/bin/yip-rendezvous --listen-udp 0.0.0.0:51820
+Restart=always
+RestartSec=3s
+
+# Privileges and capabilities
+CapabilityBoundingSet=CAP_NET_BIND_SERVICE
+AmbientCapabilities=CAP_NET_BIND_SERVICE
+
+# Sandboxing and filesystem protection
+ProtectSystem=strict
+ProtectHome=true
+NoNewPrivileges=true
+PrivateTmp=true
+
+# Runtime directory
+RuntimeDirectory=yip-rendezvous
+
+[Install]
+WantedBy=multi-user.target
diff --git a/contrib/systemd/yipd.service b/contrib/systemd/yipd.service
new file mode 100644
index 00000000..9335c070
--- /dev/null
+++ b/contrib/systemd/yipd.service
@@ -0,0 +1,39 @@
+[Unit]
+Description=yip high-performance obfuscated mesh VPN daemon
+After=network.target network-online.target
+Wants=network-online.target
+Documentation=https://github.com/femboyisp/yip
+
+[Service]
+Type=simple
+ExecStart=/usr/local/bin/yipd /etc/yip/yipd.conf
+Restart=always
+RestartSec=3s
+
+# Privileges and capabilities
+CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_BPF CAP_IPC_LOCK
+AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_BPF CAP_IPC_LOCK
+LimitMEMLOCK=infinity
+
+# Device permissions
+DeviceAllow=/dev/net/tun rw
+
+# Sandboxing and filesystem protection
+ProtectSystem=strict
+ProtectHome=true
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectControlGroups=true
+ProtectKernelModules=true
+ProtectKernelLogs=true
+RestrictRealtime=true
+LockPersonality=true
+MemoryDenyWriteExecute=true
+RestrictNamespaces=false
+
+# Runtime and configuration directories
+RuntimeDirectory=yipd
+ConfigurationDirectory=yip
+
+[Install]
+WantedBy=multi-user.target
diff --git a/crates/yip-bench/RESULTS.md b/crates/yip-bench/RESULTS.md
index 84e26919..8c03c472 100644
--- a/crates/yip-bench/RESULTS.md
+++ b/crates/yip-bench/RESULTS.md
@@ -740,8 +740,8 @@ Regime F delivers:
### Conditions
- **Hosts:** `EC2 us-east-1 (t3.small)` ↔ `EC2 us-west-2 (t3.small)` (two 1-vCPU / ~1 GB Debian 13 VPSes, kernel 6.1.0-54-cloud-amd64)
-- **Baseline path:** ~55.054 ms RTT, single Gbit links
-- **Date / commit:** 2026-10-22T02:47:23Z / `7acce84`
+- **Baseline path:** ~60.940 ms RTT, single Gbit links
+- **Date / commit:** 2026-10-22T05:21:23Z / `405450c`
- **Host tuning (applied equally, all tunnels):** `net.core.rmem_max=net.core.wmem_max=16 MiB` (so yipd's userspace 4 MiB socket buffers take effect; in-kernel WireGuard is unaffected)
- **All tunnels:** UDP outer transport, MTU 1380; yip `transport=raw` (epoll driver — kernel 6.12 has no io_uring multishot recv, #25); WireGuard kernel module; OpenVPN static-key AES-256-GCM
@@ -751,10 +751,10 @@ Regime F delivers:
| tunnel | TCP 1-stream | TCP 4-stream | UDP goodput | UDP loss | idle RTT | RTT under load | CPU (A, 1 vCPU) |
|---|--:|--:|--:|--:|--:|--:|--:|
-| baseline (no tunnel) | 428.3 Mbit/s | 1010.7 Mbit/s | 200.0 Mbit/s | 0.00% | 54.948 ms | 54.920 ms | 4.0% |
-| yip | 182.2 Mbit/s | 323.0 Mbit/s | 200.0 Mbit/s | 0.00% | 57.619 ms | 61.891 ms | 15.8% |
-| WireGuard | 354.3 Mbit/s | 870.7 Mbit/s | 200.0 Mbit/s | 0.00% | 55.266 ms | 55.414 ms | 14.0% |
-| OpenVPN | 24.1 Mbit/s | 27.5 Mbit/s | 200.0 Mbit/s | 0.96% | 55.032 ms | 55.349 ms | 4.2% |
+| baseline (no tunnel) | 398.6 Mbit/s | 908.6 Mbit/s | 200.0 Mbit/s | 0.00% | 60.886 ms | 60.939 ms | 4.0% |
+| yip | 227.0 Mbit/s | 355.7 Mbit/s | 200.0 Mbit/s | 0.00% | 58.956 ms | 60.299 ms | 12.2% |
+| WireGuard | 412.4 Mbit/s | 1640.9 Mbit/s | 200.0 Mbit/s | 0.00% | 56.662 ms | 56.787 ms | 13.3% |
+| OpenVPN | 26.0 Mbit/s | 33.6 Mbit/s | 200.0 Mbit/s | 0.00% | 54.933 ms | 55.574 ms | 4.7% |
### Loss resilience (netem injected on the A→B outer path)
@@ -763,20 +763,20 @@ Effective end-to-end loss (ping) — lower is better; yip's FEC recovers what th
| injected loss | baseline (no tunnel) | yip | WireGuard | OpenVPN |
|---|--:|--:|--:|--:|
| 0% | 0% | 0% | 0% | 0% |
-| 1% | 0% | 0% | 2% | 1% |
-| 3% | 4% | 1% | 1% | 3% |
-| 5% | 5% | 0% | 6% | 9% |
-| 10% | 16% | 1% | 7% | 10% |
+| 1% | 3% | 0% | 1% | 1% |
+| 3% | 3% | 0% | 0% | 2% |
+| 5% | 5% | 0% | 2% | 4% |
+| 10% | 13% | 0% | 9% | 3% |
TCP goodput under the same injected loss (Mbit/s):
| injected loss | baseline (no tunnel) | yip | WireGuard | OpenVPN |
|---|--:|--:|--:|--:|
-| 0% | 426.0 | 201.6 | 409.4 | 18.3 |
-| 1% | 4.4 | 165.9 | 2.4 | 3.0 |
-| 3% | 3.7 | 146.0 | 1.1 | 1.0 |
-| 5% | 1.2 | 89.5 | 0.8 | 0.7 |
-| 10% | 0.6 | 48.8 | 0.5 | 0.4 |
+| 0% | 397.7 | 75.1 | 412.1 | 21.7 |
+| 1% | 3.9 | 244.7 | 2.2 | 2.3 |
+| 3% | 2.0 | 123.4 | 1.3 | 1.1 |
+| 5% | 1.3 | 104.1 | 0.7 | 0.9 |
+| 10% | 0.8 | 28.7 | 0.5 | 0.5 |
### Reproduce
```sh
diff --git a/crates/yip-bench/wan/results/20261022T052109Z/wan.csv b/crates/yip-bench/wan/results/20261022T052109Z/wan.csv
new file mode 100644
index 00000000..664264ab
--- /dev/null
+++ b/crates/yip-bench/wan/results/20261022T052109Z/wan.csv
@@ -0,0 +1,73 @@
+ts,host_pair,commit,tunnel,profile,param,metric,value,conditions
+2026-10-22T05:21:23Z,54.152.154.212->34.220.241.114,405450c,baseline,tcp_throughput,streams=1,mbit_s,398.6,clean
+2026-10-22T05:21:36Z,54.152.154.212->34.220.241.114,405450c,baseline,tcp_throughput,streams=4,mbit_s,908.6,clean
+2026-10-22T05:21:47Z,54.152.154.212->34.220.241.114,405450c,baseline,udp_throughput,offered=200M,mbit_s,200.0,clean
+2026-10-22T05:21:47Z,54.152.154.212->34.220.241.114,405450c,baseline,udp_jitter,offered=200M,ms,0.004,clean
+2026-10-22T05:21:47Z,54.152.154.212->34.220.241.114,405450c,baseline,udp_loss,offered=200M,pct,0.00,clean
+2026-10-22T05:22:03Z,54.152.154.212->34.220.241.114,405450c,baseline,latency,idle,rtt_ms,60.886,clean
+2026-10-22T05:22:03Z,54.152.154.212->34.220.241.114,405450c,baseline,latency,loaded,rtt_ms,60.939,saturated
+2026-10-22T05:22:09Z,54.152.154.212->34.220.241.114,405450c,baseline,cpu_used,tcp_saturate,pct,4.0,sender_host_A_1vcpu
+2026-10-22T05:22:28Z,54.152.154.212->34.220.241.114,405450c,baseline,loss_effective,injected=0pct,pct,0,netem_0pct
+2026-10-22T05:22:28Z,54.152.154.212->34.220.241.114,405450c,baseline,loss_goodput,injected=0pct,mbit_s,397.7,netem_0pct
+2026-10-22T05:22:46Z,54.152.154.212->34.220.241.114,405450c,baseline,loss_effective,injected=1pct,pct,3,netem_1pct
+2026-10-22T05:22:46Z,54.152.154.212->34.220.241.114,405450c,baseline,loss_goodput,injected=1pct,mbit_s,3.9,netem_1pct
+2026-10-22T05:23:05Z,54.152.154.212->34.220.241.114,405450c,baseline,loss_effective,injected=3pct,pct,3,netem_3pct
+2026-10-22T05:23:05Z,54.152.154.212->34.220.241.114,405450c,baseline,loss_goodput,injected=3pct,mbit_s,2.0,netem_3pct
+2026-10-22T05:23:24Z,54.152.154.212->34.220.241.114,405450c,baseline,loss_effective,injected=5pct,pct,5,netem_5pct
+2026-10-22T05:23:24Z,54.152.154.212->34.220.241.114,405450c,baseline,loss_goodput,injected=5pct,mbit_s,1.3,netem_5pct
+2026-10-22T05:23:43Z,54.152.154.212->34.220.241.114,405450c,baseline,loss_effective,injected=10pct,pct,13,netem_10pct
+2026-10-22T05:23:43Z,54.152.154.212->34.220.241.114,405450c,baseline,loss_goodput,injected=10pct,mbit_s,0.8,netem_10pct
+2026-10-22T05:23:56Z,54.152.154.212->34.220.241.114,405450c,yip,tcp_throughput,streams=1,mbit_s,227.0,clean
+2026-10-22T05:24:08Z,54.152.154.212->34.220.241.114,405450c,yip,tcp_throughput,streams=4,mbit_s,355.7,clean
+2026-10-22T05:24:19Z,54.152.154.212->34.220.241.114,405450c,yip,udp_throughput,offered=200M,mbit_s,200.0,clean
+2026-10-22T05:24:19Z,54.152.154.212->34.220.241.114,405450c,yip,udp_jitter,offered=200M,ms,0.007,clean
+2026-10-22T05:24:19Z,54.152.154.212->34.220.241.114,405450c,yip,udp_loss,offered=200M,pct,0.00,clean
+2026-10-22T05:24:35Z,54.152.154.212->34.220.241.114,405450c,yip,latency,idle,rtt_ms,58.956,clean
+2026-10-22T05:24:35Z,54.152.154.212->34.220.241.114,405450c,yip,latency,loaded,rtt_ms,60.299,saturated
+2026-10-22T05:24:42Z,54.152.154.212->34.220.241.114,405450c,yip,cpu_used,tcp_saturate,pct,12.2,sender_host_A_1vcpu
+2026-10-22T05:25:00Z,54.152.154.212->34.220.241.114,405450c,yip,loss_effective,injected=0pct,pct,0,netem_0pct
+2026-10-22T05:25:00Z,54.152.154.212->34.220.241.114,405450c,yip,loss_goodput,injected=0pct,mbit_s,75.1,netem_0pct
+2026-10-22T05:25:19Z,54.152.154.212->34.220.241.114,405450c,yip,loss_effective,injected=1pct,pct,0,netem_1pct
+2026-10-22T05:25:19Z,54.152.154.212->34.220.241.114,405450c,yip,loss_goodput,injected=1pct,mbit_s,244.7,netem_1pct
+2026-10-22T05:25:38Z,54.152.154.212->34.220.241.114,405450c,yip,loss_effective,injected=3pct,pct,0,netem_3pct
+2026-10-22T05:25:38Z,54.152.154.212->34.220.241.114,405450c,yip,loss_goodput,injected=3pct,mbit_s,123.4,netem_3pct
+2026-10-22T05:25:56Z,54.152.154.212->34.220.241.114,405450c,yip,loss_effective,injected=5pct,pct,0,netem_5pct
+2026-10-22T05:25:56Z,54.152.154.212->34.220.241.114,405450c,yip,loss_goodput,injected=5pct,mbit_s,104.1,netem_5pct
+2026-10-22T05:26:15Z,54.152.154.212->34.220.241.114,405450c,yip,loss_effective,injected=10pct,pct,0,netem_10pct
+2026-10-22T05:26:15Z,54.152.154.212->34.220.241.114,405450c,yip,loss_goodput,injected=10pct,mbit_s,28.7,netem_10pct
+2026-10-22T05:26:28Z,54.152.154.212->34.220.241.114,405450c,wireguard,tcp_throughput,streams=1,mbit_s,412.4,clean
+2026-10-22T05:26:41Z,54.152.154.212->34.220.241.114,405450c,wireguard,tcp_throughput,streams=4,mbit_s,1640.9,clean
+2026-10-22T05:26:51Z,54.152.154.212->34.220.241.114,405450c,wireguard,udp_throughput,offered=200M,mbit_s,200.0,clean
+2026-10-22T05:26:51Z,54.152.154.212->34.220.241.114,405450c,wireguard,udp_jitter,offered=200M,ms,0.019,clean
+2026-10-22T05:26:51Z,54.152.154.212->34.220.241.114,405450c,wireguard,udp_loss,offered=200M,pct,0.00,clean
+2026-10-22T05:27:07Z,54.152.154.212->34.220.241.114,405450c,wireguard,latency,idle,rtt_ms,56.662,clean
+2026-10-22T05:27:07Z,54.152.154.212->34.220.241.114,405450c,wireguard,latency,loaded,rtt_ms,56.787,saturated
+2026-10-22T05:27:14Z,54.152.154.212->34.220.241.114,405450c,wireguard,cpu_used,tcp_saturate,pct,13.3,sender_host_A_1vcpu
+2026-10-22T05:27:32Z,54.152.154.212->34.220.241.114,405450c,wireguard,loss_effective,injected=0pct,pct,0,netem_0pct
+2026-10-22T05:27:32Z,54.152.154.212->34.220.241.114,405450c,wireguard,loss_goodput,injected=0pct,mbit_s,412.1,netem_0pct
+2026-10-22T05:27:51Z,54.152.154.212->34.220.241.114,405450c,wireguard,loss_effective,injected=1pct,pct,1,netem_1pct
+2026-10-22T05:27:51Z,54.152.154.212->34.220.241.114,405450c,wireguard,loss_goodput,injected=1pct,mbit_s,2.2,netem_1pct
+2026-10-22T05:28:10Z,54.152.154.212->34.220.241.114,405450c,wireguard,loss_effective,injected=3pct,pct,0,netem_3pct
+2026-10-22T05:28:10Z,54.152.154.212->34.220.241.114,405450c,wireguard,loss_goodput,injected=3pct,mbit_s,1.3,netem_3pct
+2026-10-22T05:28:29Z,54.152.154.212->34.220.241.114,405450c,wireguard,loss_effective,injected=5pct,pct,2,netem_5pct
+2026-10-22T05:28:29Z,54.152.154.212->34.220.241.114,405450c,wireguard,loss_goodput,injected=5pct,mbit_s,0.7,netem_5pct
+2026-10-22T05:28:48Z,54.152.154.212->34.220.241.114,405450c,wireguard,loss_effective,injected=10pct,pct,9,netem_10pct
+2026-10-22T05:28:48Z,54.152.154.212->34.220.241.114,405450c,wireguard,loss_goodput,injected=10pct,mbit_s,0.5,netem_10pct
+2026-10-22T05:29:01Z,54.152.154.212->34.220.241.114,405450c,openvpn,tcp_throughput,streams=1,mbit_s,26.0,clean
+2026-10-22T05:29:14Z,54.152.154.212->34.220.241.114,405450c,openvpn,tcp_throughput,streams=4,mbit_s,33.6,clean
+2026-10-22T05:29:25Z,54.152.154.212->34.220.241.114,405450c,openvpn,udp_throughput,offered=200M,mbit_s,200.0,clean
+2026-10-22T05:29:25Z,54.152.154.212->34.220.241.114,405450c,openvpn,udp_jitter,offered=200M,ms,0.031,clean
+2026-10-22T05:29:25Z,54.152.154.212->34.220.241.114,405450c,openvpn,udp_loss,offered=200M,pct,0.00,clean
+2026-10-22T05:29:40Z,54.152.154.212->34.220.241.114,405450c,openvpn,latency,idle,rtt_ms,54.933,clean
+2026-10-22T05:29:40Z,54.152.154.212->34.220.241.114,405450c,openvpn,latency,loaded,rtt_ms,55.574,saturated
+2026-10-22T05:29:47Z,54.152.154.212->34.220.241.114,405450c,openvpn,cpu_used,tcp_saturate,pct,4.7,sender_host_A_1vcpu
+2026-10-22T05:30:05Z,54.152.154.212->34.220.241.114,405450c,openvpn,loss_effective,injected=0pct,pct,0,netem_0pct
+2026-10-22T05:30:05Z,54.152.154.212->34.220.241.114,405450c,openvpn,loss_goodput,injected=0pct,mbit_s,21.7,netem_0pct
+2026-10-22T05:30:24Z,54.152.154.212->34.220.241.114,405450c,openvpn,loss_effective,injected=1pct,pct,1,netem_1pct
+2026-10-22T05:30:24Z,54.152.154.212->34.220.241.114,405450c,openvpn,loss_goodput,injected=1pct,mbit_s,2.3,netem_1pct
+2026-10-22T05:30:44Z,54.152.154.212->34.220.241.114,405450c,openvpn,loss_effective,injected=3pct,pct,2,netem_3pct
+2026-10-22T05:30:44Z,54.152.154.212->34.220.241.114,405450c,openvpn,loss_goodput,injected=3pct,mbit_s,1.1,netem_3pct
+2026-10-22T05:31:02Z,54.152.154.212->34.220.241.114,405450c,openvpn,loss_effective,injected=5pct,pct,4,netem_5pct
+2026-10-22T05:31:02Z,54.152.154.212->34.220.241.114,405450c,openvpn,loss_goodput,injected=5pct,mbit_s,0.9,netem_5pct
+2026-10-22T05:31:21Z,54.152.154.212->34.220.241.114,405450c,openvpn,loss_effective,injected=10pct,pct,3,netem_10pct
+2026-10-22T05:31:21Z,54.152.154.212->34.220.241.114,405450c,openvpn,loss_goodput,injected=10pct,mbit_s,0.5,netem_10pct
diff --git a/crates/yip-bench/wan/results/history.csv b/crates/yip-bench/wan/results/history.csv
index 6cc6fd37..4c397cda 100644
--- a/crates/yip-bench/wan/results/history.csv
+++ b/crates/yip-bench/wan/results/history.csv
@@ -863,3 +863,75 @@ ts,host_pair,commit,tunnel,profile,param,metric,value,conditions
2026-10-22T02:58:12Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),7acce84,openvpn,loss_goodput,injected=5pct,mbit_s,0.7,netem_5pct
2026-10-22T02:58:35Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),7acce84,openvpn,loss_effective,injected=10pct,pct,10,netem_10pct
2026-10-22T02:58:35Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),7acce84,openvpn,loss_goodput,injected=10pct,mbit_s,0.4,netem_10pct
+2026-10-22T05:21:23Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,baseline,tcp_throughput,streams=1,mbit_s,398.6,clean
+2026-10-22T05:21:36Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,baseline,tcp_throughput,streams=4,mbit_s,908.6,clean
+2026-10-22T05:21:47Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,baseline,udp_throughput,offered=200M,mbit_s,200.0,clean
+2026-10-22T05:21:47Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,baseline,udp_jitter,offered=200M,ms,0.004,clean
+2026-10-22T05:21:47Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,baseline,udp_loss,offered=200M,pct,0.00,clean
+2026-10-22T05:22:03Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,baseline,latency,idle,rtt_ms,60.886,clean
+2026-10-22T05:22:03Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,baseline,latency,loaded,rtt_ms,60.939,saturated
+2026-10-22T05:22:09Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,baseline,cpu_used,tcp_saturate,pct,4.0,sender_host_A_1vcpu
+2026-10-22T05:22:28Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,baseline,loss_effective,injected=0pct,pct,0,netem_0pct
+2026-10-22T05:22:28Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,baseline,loss_goodput,injected=0pct,mbit_s,397.7,netem_0pct
+2026-10-22T05:22:46Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,baseline,loss_effective,injected=1pct,pct,3,netem_1pct
+2026-10-22T05:22:46Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,baseline,loss_goodput,injected=1pct,mbit_s,3.9,netem_1pct
+2026-10-22T05:23:05Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,baseline,loss_effective,injected=3pct,pct,3,netem_3pct
+2026-10-22T05:23:05Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,baseline,loss_goodput,injected=3pct,mbit_s,2.0,netem_3pct
+2026-10-22T05:23:24Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,baseline,loss_effective,injected=5pct,pct,5,netem_5pct
+2026-10-22T05:23:24Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,baseline,loss_goodput,injected=5pct,mbit_s,1.3,netem_5pct
+2026-10-22T05:23:43Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,baseline,loss_effective,injected=10pct,pct,13,netem_10pct
+2026-10-22T05:23:43Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,baseline,loss_goodput,injected=10pct,mbit_s,0.8,netem_10pct
+2026-10-22T05:23:56Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,yip,tcp_throughput,streams=1,mbit_s,227.0,clean
+2026-10-22T05:24:08Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,yip,tcp_throughput,streams=4,mbit_s,355.7,clean
+2026-10-22T05:24:19Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,yip,udp_throughput,offered=200M,mbit_s,200.0,clean
+2026-10-22T05:24:19Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,yip,udp_jitter,offered=200M,ms,0.007,clean
+2026-10-22T05:24:19Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,yip,udp_loss,offered=200M,pct,0.00,clean
+2026-10-22T05:24:35Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,yip,latency,idle,rtt_ms,58.956,clean
+2026-10-22T05:24:35Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,yip,latency,loaded,rtt_ms,60.299,saturated
+2026-10-22T05:24:42Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,yip,cpu_used,tcp_saturate,pct,12.2,sender_host_A_1vcpu
+2026-10-22T05:25:00Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,yip,loss_effective,injected=0pct,pct,0,netem_0pct
+2026-10-22T05:25:00Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,yip,loss_goodput,injected=0pct,mbit_s,75.1,netem_0pct
+2026-10-22T05:25:19Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,yip,loss_effective,injected=1pct,pct,0,netem_1pct
+2026-10-22T05:25:19Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,yip,loss_goodput,injected=1pct,mbit_s,244.7,netem_1pct
+2026-10-22T05:25:38Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,yip,loss_effective,injected=3pct,pct,0,netem_3pct
+2026-10-22T05:25:38Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,yip,loss_goodput,injected=3pct,mbit_s,123.4,netem_3pct
+2026-10-22T05:25:56Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,yip,loss_effective,injected=5pct,pct,0,netem_5pct
+2026-10-22T05:25:56Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,yip,loss_goodput,injected=5pct,mbit_s,104.1,netem_5pct
+2026-10-22T05:26:15Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,yip,loss_effective,injected=10pct,pct,0,netem_10pct
+2026-10-22T05:26:15Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,yip,loss_goodput,injected=10pct,mbit_s,28.7,netem_10pct
+2026-10-22T05:26:28Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,wireguard,tcp_throughput,streams=1,mbit_s,412.4,clean
+2026-10-22T05:26:41Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,wireguard,tcp_throughput,streams=4,mbit_s,1640.9,clean
+2026-10-22T05:26:51Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,wireguard,udp_throughput,offered=200M,mbit_s,200.0,clean
+2026-10-22T05:26:51Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,wireguard,udp_jitter,offered=200M,ms,0.019,clean
+2026-10-22T05:26:51Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,wireguard,udp_loss,offered=200M,pct,0.00,clean
+2026-10-22T05:27:07Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,wireguard,latency,idle,rtt_ms,56.662,clean
+2026-10-22T05:27:07Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,wireguard,latency,loaded,rtt_ms,56.787,saturated
+2026-10-22T05:27:14Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,wireguard,cpu_used,tcp_saturate,pct,13.3,sender_host_A_1vcpu
+2026-10-22T05:27:32Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,wireguard,loss_effective,injected=0pct,pct,0,netem_0pct
+2026-10-22T05:27:32Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,wireguard,loss_goodput,injected=0pct,mbit_s,412.1,netem_0pct
+2026-10-22T05:27:51Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,wireguard,loss_effective,injected=1pct,pct,1,netem_1pct
+2026-10-22T05:27:51Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,wireguard,loss_goodput,injected=1pct,mbit_s,2.2,netem_1pct
+2026-10-22T05:28:10Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,wireguard,loss_effective,injected=3pct,pct,0,netem_3pct
+2026-10-22T05:28:10Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,wireguard,loss_goodput,injected=3pct,mbit_s,1.3,netem_3pct
+2026-10-22T05:28:29Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,wireguard,loss_effective,injected=5pct,pct,2,netem_5pct
+2026-10-22T05:28:29Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,wireguard,loss_goodput,injected=5pct,mbit_s,0.7,netem_5pct
+2026-10-22T05:28:48Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,wireguard,loss_effective,injected=10pct,pct,9,netem_10pct
+2026-10-22T05:28:48Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,wireguard,loss_goodput,injected=10pct,mbit_s,0.5,netem_10pct
+2026-10-22T05:29:01Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,openvpn,tcp_throughput,streams=1,mbit_s,26.0,clean
+2026-10-22T05:29:14Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,openvpn,tcp_throughput,streams=4,mbit_s,33.6,clean
+2026-10-22T05:29:25Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,openvpn,udp_throughput,offered=200M,mbit_s,200.0,clean
+2026-10-22T05:29:25Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,openvpn,udp_jitter,offered=200M,ms,0.031,clean
+2026-10-22T05:29:25Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,openvpn,udp_loss,offered=200M,pct,0.00,clean
+2026-10-22T05:29:40Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,openvpn,latency,idle,rtt_ms,54.933,clean
+2026-10-22T05:29:40Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,openvpn,latency,loaded,rtt_ms,55.574,saturated
+2026-10-22T05:29:47Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,openvpn,cpu_used,tcp_saturate,pct,4.7,sender_host_A_1vcpu
+2026-10-22T05:30:05Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,openvpn,loss_effective,injected=0pct,pct,0,netem_0pct
+2026-10-22T05:30:05Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,openvpn,loss_goodput,injected=0pct,mbit_s,21.7,netem_0pct
+2026-10-22T05:30:24Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,openvpn,loss_effective,injected=1pct,pct,1,netem_1pct
+2026-10-22T05:30:24Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,openvpn,loss_goodput,injected=1pct,mbit_s,2.3,netem_1pct
+2026-10-22T05:30:44Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,openvpn,loss_effective,injected=3pct,pct,2,netem_3pct
+2026-10-22T05:30:44Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,openvpn,loss_goodput,injected=3pct,mbit_s,1.1,netem_3pct
+2026-10-22T05:31:02Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,openvpn,loss_effective,injected=5pct,pct,4,netem_5pct
+2026-10-22T05:31:02Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,openvpn,loss_goodput,injected=5pct,mbit_s,0.9,netem_5pct
+2026-10-22T05:31:21Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,openvpn,loss_effective,injected=10pct,pct,3,netem_10pct
+2026-10-22T05:31:21Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),405450c,openvpn,loss_goodput,injected=10pct,mbit_s,0.5,netem_10pct
diff --git a/crates/yip-bench/wan/results/readme-snippet.md b/crates/yip-bench/wan/results/readme-snippet.md
index 3062f22e..9a88a0a1 100644
--- a/crates/yip-bench/wan/results/readme-snippet.md
+++ b/crates/yip-bench/wan/results/readme-snippet.md
@@ -1,8 +1,8 @@
-*Two 1-vCPU Debian 13 VPSes, ~55.054 ms apart, 2026-10-22. yip trades raw clean-path throughput for loss resilience — see the FEC row.*
+*Two 1-vCPU Debian 13 VPSes, ~60.940 ms apart, 2026-10-22. yip trades raw clean-path throughput for loss resilience — see the FEC row.*
| | TCP 1-stream | UDP loss (200M) | effective loss @10% injected |
|---|--:|--:|--:|
-| **yip** | 182.2 Mbit/s | 0.00% | 1% |
-| **WireGuard** | 354.3 Mbit/s | 0.00% | 7% |
-| **OpenVPN** | 24.1 Mbit/s | 0.96% | 10% |
+| **yip** | 227.0 Mbit/s | 0.00% | 0% |
+| **WireGuard** | 412.4 Mbit/s | 0.00% | 9% |
+| **OpenVPN** | 26.0 Mbit/s | 0.00% | 3% |
diff --git a/crates/yip-device/src/lib.rs b/crates/yip-device/src/lib.rs
index 97778258..abba58dd 100644
--- a/crates/yip-device/src/lib.rs
+++ b/crates/yip-device/src/lib.rs
@@ -2,6 +2,11 @@
//! I/O lands in M4; this milestone fixes the public surface.
#![deny(unsafe_op_in_unsafe_fn)]
+#[cfg(target_env = "musl")]
+type IoctlReq = libc::c_int;
+#[cfg(not(target_env = "musl"))]
+type IoctlReq = libc::c_ulong;
+
use std::io;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
@@ -72,7 +77,7 @@ fn negotiate_offload(fd: std::os::fd::RawFd) -> Option {
let full = unsafe {
libc::ioctl(
fd,
- TUNSETOFFLOAD,
+ TUNSETOFFLOAD as IoctlReq,
libc::c_ulong::from(TUN_F_CSUM | TUN_F_TSO4 | TUN_F_TSO6),
)
};
@@ -81,7 +86,13 @@ fn negotiate_offload(fd: std::os::fd::RawFd) -> Option {
}
// SAFETY: same rationale as the previous TUNSETOFFLOAD call.
- let csum_only = unsafe { libc::ioctl(fd, TUNSETOFFLOAD, libc::c_ulong::from(TUN_F_CSUM)) };
+ let csum_only = unsafe {
+ libc::ioctl(
+ fd,
+ TUNSETOFFLOAD as IoctlReq,
+ libc::c_ulong::from(TUN_F_CSUM),
+ )
+ };
if csum_only == 0 {
return Some(OffloadMode::CsumOnly);
}
@@ -95,7 +106,7 @@ fn apply_offload(fd: std::os::fd::RawFd, mode: OffloadMode) -> Result<(), Device
OffloadMode::CsumOnly => libc::c_ulong::from(TUN_F_CSUM),
};
// SAFETY: `fd` is a valid open /dev/net/tun fd. TUNSETOFFLOAD takes its flags as an integer passed by value.
- let rc = unsafe { libc::ioctl(fd, TUNSETOFFLOAD, flags) };
+ let rc = unsafe { libc::ioctl(fd, TUNSETOFFLOAD as IoctlReq, flags) };
if rc != 0 {
return Err(DeviceError::Io(io::Error::last_os_error()));
}
@@ -146,12 +157,12 @@ fn bring_up(ifname: &[u8; libc::IFNAMSIZ]) -> Result<(), DeviceError> {
if sock_fd < 0 {
return Err(DeviceError::Io(io::Error::last_os_error()));
}
- let rc_get = libc::ioctl(sock_fd, libc::SIOCGIFFLAGS, &raw mut req);
+ let rc_get = libc::ioctl(sock_fd, libc::SIOCGIFFLAGS as IoctlReq, &raw mut req);
if rc_get == 0 {
// FFI: libc exposes IFF_UP/IFF_RUNNING as c_int, but `ifreq.ifr_flags`
// is c_short; the combined value (0x41) fits trivially.
req.flags |= (libc::IFF_UP | libc::IFF_RUNNING) as libc::c_short;
- let rc_set = libc::ioctl(sock_fd, libc::SIOCSIFFLAGS, &raw mut req);
+ let rc_set = libc::ioctl(sock_fd, libc::SIOCSIFFLAGS as IoctlReq, &raw mut req);
libc::close(sock_fd);
if rc_set != 0 {
Some(io::Error::last_os_error())
@@ -192,7 +203,7 @@ fn set_device_mtu(ifname: &[u8; libc::IFNAMSIZ], mtu: u32) -> Result<(), DeviceE
if sock_fd < 0 {
return Err(DeviceError::Io(io::Error::last_os_error()));
}
- let rc = libc::ioctl(sock_fd, libc::SIOCSIFMTU, &req);
+ let rc = libc::ioctl(sock_fd, libc::SIOCSIFMTU as IoctlReq, &req);
let e = if rc != 0 {
Some(io::Error::last_os_error())
} else {
@@ -321,7 +332,7 @@ impl TunTap {
// TUNSETIFF; the fd is a freshly-opened /dev/net/tun. The kernel reads
// `req` and writes back the resolved name into the same buffer, which
// we own exclusively here.
- let rc = unsafe { libc::ioctl(file.as_raw_fd(), TUNSETIFF, &raw mut req) };
+ let rc = unsafe { libc::ioctl(file.as_raw_fd(), TUNSETIFF as IoctlReq, &raw mut req) };
if rc != 0 {
// Driver rejected IFF_VNET_HDR (or the type/name flags altogether).
// `file` drops here, closing the fd; caller reopens plain.
@@ -332,7 +343,13 @@ impl TunTap {
let mut hdr_sz: libc::c_int = VNET_HDR_LEN as libc::c_int;
// SAFETY: `file` is a valid open /dev/net/tun fd. TUNSETVNETHDRSZ takes an int pointer
// specifying the virtio_net_hdr byte size.
- let _ = unsafe { libc::ioctl(file.as_raw_fd(), TUNSETVNETHDRSZ, &raw mut hdr_sz) };
+ let _ = unsafe {
+ libc::ioctl(
+ file.as_raw_fd(),
+ TUNSETVNETHDRSZ as IoctlReq,
+ &raw mut hdr_sz,
+ )
+ };
if let Some(_mode) = negotiate_offload(file.as_raw_fd()) {
bring_up(&req.name)?;
@@ -371,7 +388,7 @@ impl TunTap {
// SAFETY: `req` is a correctly-sized, properly-initialized `ifreq` for TUNSETIFF;
// the fd is a freshly-opened /dev/net/tun. The kernel reads `req` and writes back
// the resolved name into the same buffer, which we own exclusively here.
- let rc = unsafe { libc::ioctl(file.as_raw_fd(), TUNSETIFF, &raw mut req) };
+ let rc = unsafe { libc::ioctl(file.as_raw_fd(), TUNSETIFF as IoctlReq, &raw mut req) };
if rc != 0 {
return Err(DeviceError::Io(io::Error::last_os_error()));
}
@@ -409,14 +426,20 @@ impl TunTap {
};
// SAFETY: req0 is a correctly-sized ifreq for TUNSETIFF; file0 is a freshly-opened /dev/net/tun.
- let rc = unsafe { libc::ioctl(file0.as_raw_fd(), TUNSETIFF, &raw mut req0) };
+ let rc = unsafe { libc::ioctl(file0.as_raw_fd(), TUNSETIFF as IoctlReq, &raw mut req0) };
if rc != 0 {
return Ok(None);
}
let mut hdr_sz0: libc::c_int = VNET_HDR_LEN as libc::c_int;
// SAFETY: file0 is a valid open /dev/net/tun fd. TUNSETVNETHDRSZ takes an int pointer.
- let _ = unsafe { libc::ioctl(file0.as_raw_fd(), TUNSETVNETHDRSZ, &raw mut hdr_sz0) };
+ let _ = unsafe {
+ libc::ioctl(
+ file0.as_raw_fd(),
+ TUNSETVNETHDRSZ as IoctlReq,
+ &raw mut hdr_sz0,
+ )
+ };
let offload_mode = match negotiate_offload(file0.as_raw_fd()) {
Some(mode) => mode,
@@ -450,14 +473,20 @@ impl TunTap {
};
// SAFETY: req is a correctly-sized ifreq for TUNSETIFF; file is a freshly-opened /dev/net/tun.
- let rc = unsafe { libc::ioctl(file.as_raw_fd(), TUNSETIFF, &raw mut req) };
+ let rc = unsafe { libc::ioctl(file.as_raw_fd(), TUNSETIFF as IoctlReq, &raw mut req) };
if rc != 0 {
return Err(DeviceError::Io(io::Error::last_os_error()));
}
let mut hdr_sz: libc::c_int = VNET_HDR_LEN as libc::c_int;
// SAFETY: file is a valid open /dev/net/tun fd. TUNSETVNETHDRSZ takes an int pointer.
- let _ = unsafe { libc::ioctl(file.as_raw_fd(), TUNSETVNETHDRSZ, &raw mut hdr_sz) };
+ let _ = unsafe {
+ libc::ioctl(
+ file.as_raw_fd(),
+ TUNSETVNETHDRSZ as IoctlReq,
+ &raw mut hdr_sz,
+ )
+ };
apply_offload(file.as_raw_fd(), offload_mode)?;
@@ -495,7 +524,7 @@ impl TunTap {
};
// SAFETY: req0 is a correctly-sized ifreq for TUNSETIFF; file0 is a freshly-opened /dev/net/tun.
- let rc = unsafe { libc::ioctl(file0.as_raw_fd(), TUNSETIFF, &raw mut req0) };
+ let rc = unsafe { libc::ioctl(file0.as_raw_fd(), TUNSETIFF as IoctlReq, &raw mut req0) };
if rc != 0 {
return Err(DeviceError::Io(io::Error::last_os_error()));
}
@@ -527,7 +556,7 @@ impl TunTap {
};
// SAFETY: req is a correctly-sized ifreq for TUNSETIFF; file is a freshly-opened /dev/net/tun.
- let rc = unsafe { libc::ioctl(file.as_raw_fd(), TUNSETIFF, &raw mut req) };
+ let rc = unsafe { libc::ioctl(file.as_raw_fd(), TUNSETIFF as IoctlReq, &raw mut req) };
if rc != 0 {
return Err(DeviceError::Io(io::Error::last_os_error()));
}
diff --git a/crates/yip-io/src/batch.rs b/crates/yip-io/src/batch.rs
index 1bd95ec4..879fb594 100644
--- a/crates/yip-io/src/batch.rs
+++ b/crates/yip-io/src/batch.rs
@@ -96,18 +96,7 @@ impl BatchUdpSocket {
iov_base: std::ptr::null_mut(),
iov_len: 0,
}; BATCH_SIZE];
- let mut msgs = [libc::mmsghdr {
- msg_hdr: libc::msghdr {
- msg_name: std::ptr::null_mut(),
- msg_namelen: 0,
- msg_iov: std::ptr::null_mut(),
- msg_iovlen: 0,
- msg_control: std::ptr::null_mut(),
- msg_controllen: 0,
- msg_flags: 0,
- },
- msg_len: 0,
- }; BATCH_SIZE];
+ let mut msgs = [crate::empty_mmsghdr(); BATCH_SIZE];
for i in 0..BATCH_SIZE {
iovecs[i].iov_base = buffers[i].as_mut_ptr().cast::();
@@ -130,7 +119,7 @@ impl BatchUdpSocket {
self.fd,
msgs.as_mut_ptr(),
u32::try_from(BATCH_SIZE).expect("BATCH_SIZE fits u32"),
- libc::MSG_DONTWAIT,
+ libc::MSG_DONTWAIT as crate::MsgFlags,
std::ptr::null_mut(),
)
};
@@ -190,18 +179,7 @@ impl BatchUdpSocket {
iov_base: std::ptr::null_mut(),
iov_len: 0,
}; BATCH_SIZE];
- let mut msgs = [libc::mmsghdr {
- msg_hdr: libc::msghdr {
- msg_name: std::ptr::null_mut(),
- msg_namelen: 0,
- msg_iov: std::ptr::null_mut(),
- msg_iovlen: 0,
- msg_control: std::ptr::null_mut(),
- msg_controllen: 0,
- msg_flags: 0,
- },
- msg_len: 0,
- }; BATCH_SIZE];
+ let mut msgs = [crate::empty_mmsghdr(); BATCH_SIZE];
for (i, &(payload, dst)) in packets[..count].iter().enumerate() {
let (storage, addr_len) = crate::addr::std_to_sockaddr(dst);
@@ -225,7 +203,7 @@ impl BatchUdpSocket {
self.fd,
msgs.as_mut_ptr(),
u32::try_from(count).expect("count fits u32"),
- libc::MSG_NOSIGNAL,
+ libc::MSG_NOSIGNAL as crate::MsgFlags,
)
};
@@ -304,7 +282,7 @@ pub fn send_gso_superpacket(
let cmsg_space =
usize::try_from(unsafe { libc::CMSG_SPACE(std::mem::size_of::() as u32) })
.unwrap_or(control.len());
- msg.msg_controllen = cmsg_space;
+ msg.msg_controllen = cmsg_space as crate::CmsgLen;
// SAFETY: msg points to valid control storage; write SOL_UDP / UDP_SEGMENT cmsg.
unsafe {
@@ -312,8 +290,8 @@ pub fn send_gso_superpacket(
if !cmsg.is_null() {
(*cmsg).cmsg_level = libc::SOL_UDP;
(*cmsg).cmsg_type = libc::UDP_SEGMENT;
- (*cmsg).cmsg_len =
- usize::try_from(libc::CMSG_LEN(std::mem::size_of::() as u32)).unwrap_or(0);
+ (*cmsg).cmsg_len = usize::try_from(libc::CMSG_LEN(std::mem::size_of::() as u32))
+ .unwrap_or(0) as crate::CmsgLen;
let seg_ptr = libc::CMSG_DATA(cmsg).cast::();
*seg_ptr = segment_size;
}
diff --git a/crates/yip-io/src/lib.rs b/crates/yip-io/src/lib.rs
index 4d98e9c5..86f0dfac 100644
--- a/crates/yip-io/src/lib.rs
+++ b/crates/yip-io/src/lib.rs
@@ -14,9 +14,32 @@ pub mod spsc;
pub mod tun_offload;
pub mod uring;
+#[cfg(target_env = "musl")]
+pub(crate) type MsgFlags = libc::c_uint;
+#[cfg(not(target_env = "musl"))]
+pub(crate) type MsgFlags = libc::c_int;
+
+#[cfg(target_env = "musl")]
+pub(crate) type CmsgLen = libc::socklen_t;
+#[cfg(not(target_env = "musl"))]
+pub(crate) type CmsgLen = usize;
+
pub use addr::{sockaddr_to_std, std_to_sockaddr};
pub use bpf::{BpfFilterStatus, XdpRedirectFilter};
+#[inline]
+pub(crate) fn empty_msghdr() -> libc::msghdr {
+ // SAFETY: msghdr contains only pointers, lengths, and integer flags, which
+ // are valid when all bytes are zeroed.
+ unsafe { std::mem::zeroed() }
+}
+
+#[inline]
+pub(crate) fn empty_mmsghdr() -> libc::mmsghdr {
+ // SAFETY: mmsghdr contains msghdr and msg_len, which are valid when zeroed.
+ unsafe { std::mem::zeroed() }
+}
+
/// Maximum number of datagrams in a single batched send/recv call.
pub const MAX_DATAGRAM_BATCH: usize = 64;
@@ -189,18 +212,7 @@ impl DataPlaneIo for PlainIo {
iov_base: std::ptr::null_mut(),
iov_len: 0,
}; MAX_DATAGRAM_BATCH];
- let mut msgs = [libc::mmsghdr {
- msg_hdr: libc::msghdr {
- msg_name: std::ptr::null_mut(),
- msg_namelen: 0,
- msg_iov: std::ptr::null_mut(),
- msg_iovlen: 0,
- msg_control: std::ptr::null_mut(),
- msg_controllen: 0,
- msg_flags: 0,
- },
- msg_len: 0,
- }; MAX_DATAGRAM_BATCH];
+ let mut msgs = [empty_mmsghdr(); MAX_DATAGRAM_BATCH];
for (i, dg) in datagrams[..count].iter().enumerate() {
// SAFETY: We cast a shared slice to a *mut c_void as required by the
@@ -224,7 +236,7 @@ impl DataPlaneIo for PlainIo {
self.socket.as_raw_fd(),
msgs.as_mut_ptr(),
u32::try_from(count).expect("count ≤ MAX_DATAGRAM_BATCH ≤ 64 fits u32"),
- libc::MSG_NOSIGNAL,
+ libc::MSG_NOSIGNAL as MsgFlags,
)
};
@@ -253,18 +265,7 @@ impl DataPlaneIo for PlainIo {
iov_base: std::ptr::null_mut(),
iov_len: 0,
}; MAX_DATAGRAM_BATCH];
- let mut msgs = [libc::mmsghdr {
- msg_hdr: libc::msghdr {
- msg_name: std::ptr::null_mut(),
- msg_namelen: 0,
- msg_iov: std::ptr::null_mut(),
- msg_iovlen: 0,
- msg_control: std::ptr::null_mut(),
- msg_controllen: 0,
- msg_flags: 0,
- },
- msg_len: 0,
- }; MAX_DATAGRAM_BATCH];
+ let mut msgs = [empty_mmsghdr(); MAX_DATAGRAM_BATCH];
for i in 0..count {
// SAFETY: bufs[i] is a mutable slice owned by the caller and lives
@@ -289,7 +290,7 @@ impl DataPlaneIo for PlainIo {
self.socket.as_raw_fd(),
msgs.as_mut_ptr(),
u32::try_from(count).expect("count ≤ MAX_DATAGRAM_BATCH ≤ 64 fits u32"),
- libc::MSG_WAITFORONE,
+ libc::MSG_WAITFORONE as MsgFlags,
std::ptr::null_mut(),
)
};
diff --git a/crates/yip-io/src/poll.rs b/crates/yip-io/src/poll.rs
index e3e18892..4c8df45b 100644
--- a/crates/yip-io/src/poll.rs
+++ b/crates/yip-io/src/poll.rs
@@ -392,18 +392,7 @@ fn send_mmsg(udp_fd: RawFd, datagrams: &[EgressDatagram]) -> io::Result {
iov_base: std::ptr::null_mut(),
iov_len: 0,
}; MAX_DATAGRAM_BATCH];
- let mut msgs = [libc::mmsghdr {
- msg_hdr: libc::msghdr {
- msg_name: std::ptr::null_mut(),
- msg_namelen: 0,
- msg_iov: std::ptr::null_mut(),
- msg_iovlen: 0,
- msg_control: std::ptr::null_mut(),
- msg_controllen: 0,
- msg_flags: 0,
- },
- msg_len: 0,
- }; MAX_DATAGRAM_BATCH];
+ let mut msgs = [crate::empty_mmsghdr(); MAX_DATAGRAM_BATCH];
for (i, dg) in datagrams[..count].iter().enumerate() {
let (storage, addr_len) = std_to_sockaddr(dg.dst);
@@ -428,7 +417,7 @@ fn send_mmsg(udp_fd: RawFd, datagrams: &[EgressDatagram]) -> io::Result {
udp_fd,
msgs.as_mut_ptr(),
u32::try_from(count).expect("count ≤ 64 fits u32"),
- libc::MSG_NOSIGNAL,
+ libc::MSG_NOSIGNAL as crate::MsgFlags,
)
};
if ret < 0 {
@@ -476,7 +465,7 @@ fn send_gso_payload(
if cmsg_space > control.len() {
return Err(io::Error::other("GSO control buffer too small"));
}
- msg.msg_controllen = cmsg_space;
+ msg.msg_controllen = cmsg_space as crate::CmsgLen;
// SAFETY: `msg` points to valid in-frame iovec/control storage; we write
// exactly one SOL_UDP/UDP_SEGMENT cmsg (a u16 segment size) into `control`.
@@ -487,8 +476,8 @@ fn send_gso_payload(
}
(*cmsg).cmsg_level = libc::SOL_UDP;
(*cmsg).cmsg_type = libc::UDP_SEGMENT;
- (*cmsg).cmsg_len =
- usize::try_from(libc::CMSG_LEN(GSO_CONTROL_PAYLOAD_LEN)).expect("cmsg len fits usize");
+ (*cmsg).cmsg_len = usize::try_from(libc::CMSG_LEN(GSO_CONTROL_PAYLOAD_LEN))
+ .expect("cmsg len fits usize") as crate::CmsgLen;
let seg_ptr = libc::CMSG_DATA(cmsg).cast::();
*seg_ptr = segment_size;
}
@@ -577,18 +566,7 @@ fn recv_mmsg(
iov_base: std::ptr::null_mut(),
iov_len: 0,
}; MAX_DATAGRAM_BATCH];
- let mut msgs = [libc::mmsghdr {
- msg_hdr: libc::msghdr {
- msg_name: std::ptr::null_mut(),
- msg_namelen: 0,
- msg_iov: std::ptr::null_mut(),
- msg_iovlen: 0,
- msg_control: std::ptr::null_mut(),
- msg_controllen: 0,
- msg_flags: 0,
- },
- msg_len: 0,
- }; MAX_DATAGRAM_BATCH];
+ let mut msgs = [crate::empty_mmsghdr(); MAX_DATAGRAM_BATCH];
for i in 0..count {
// SAFETY: each iov_base/msg_name points to a distinct element of
@@ -609,7 +587,7 @@ fn recv_mmsg(
udp_fd,
msgs.as_mut_ptr(),
u32::try_from(count).expect("count ≤ 64 fits u32"),
- libc::MSG_DONTWAIT,
+ libc::MSG_DONTWAIT as crate::MsgFlags,
std::ptr::null_mut(),
)
};
diff --git a/crates/yip-io/src/uring.rs b/crates/yip-io/src/uring.rs
index 28f63286..e6dbf6ba 100644
--- a/crates/yip-io/src/uring.rs
+++ b/crates/yip-io/src/uring.rs
@@ -120,15 +120,7 @@ impl GsoSendContext {
iov_base: std::ptr::null_mut(),
iov_len: 0,
},
- msg: libc::msghdr {
- msg_name: std::ptr::null_mut(),
- msg_namelen: 0,
- msg_iov: std::ptr::null_mut(),
- msg_iovlen: 0,
- msg_control: std::ptr::null_mut(),
- msg_controllen: 0,
- msg_flags: 0,
- },
+ msg: crate::empty_msghdr(),
control: [0_u8; GSO_CONTROL_SPACE],
// SAFETY: `sockaddr_storage` is plain-old-data (integers/byte
// arrays); the all-zero bit pattern is a valid value for it.
@@ -177,7 +169,7 @@ impl GsoSendContext {
if cmsg_space > self.control.len() {
return Err(io::Error::other("GSO control buffer too small"));
}
- self.msg.msg_controllen = cmsg_space;
+ self.msg.msg_controllen = cmsg_space as crate::CmsgLen;
self.msg.msg_flags = 0;
self.set_destination(dst);
@@ -191,8 +183,9 @@ impl GsoSendContext {
}
(*cmsg).cmsg_level = libc::SOL_UDP;
(*cmsg).cmsg_type = libc::UDP_SEGMENT;
- (*cmsg).cmsg_len = usize::try_from(libc::CMSG_LEN(GSO_CONTROL_PAYLOAD_LEN_U32))
+ let cmsg_len: usize = usize::try_from(libc::CMSG_LEN(GSO_CONTROL_PAYLOAD_LEN_U32))
.map_err(|_| io::Error::other("cmsg len does not fit usize"))?;
+ (*cmsg).cmsg_len = cmsg_len as crate::CmsgLen;
let segment_ptr = libc::CMSG_DATA(cmsg).cast::();
*segment_ptr = segment_size;
}
@@ -298,15 +291,11 @@ impl UringDriver {
iov_base: slot.buf.as_mut_ptr().cast::(),
iov_len: slot.buf.len(),
};
- slot.msg = libc::msghdr {
- msg_name: std::ptr::addr_of_mut!(slot.name).cast::(),
- msg_namelen: namelen,
- msg_iov: std::ptr::addr_of_mut!(slot.iov),
- msg_iovlen: 1,
- msg_control: std::ptr::null_mut(),
- msg_controllen: 0,
- msg_flags: 0,
- };
+ slot.msg = crate::empty_msghdr();
+ slot.msg.msg_name = std::ptr::addr_of_mut!(slot.name).cast::();
+ slot.msg.msg_namelen = namelen;
+ slot.msg.msg_iov = std::ptr::addr_of_mut!(slot.iov);
+ slot.msg.msg_iovlen = 1;
}
let mut in_flight = Vec::with_capacity(SEND_SLOTS);
diff --git a/example.config b/example.config
index 3814cdba..3e66b05e 100644
--- a/example.config
+++ b/example.config
@@ -193,3 +193,14 @@ endpoint=203.0.113.10:443
#
# transport=tls
# tls_sni=www.apple.com
+
+
+# ---------------------------------------------------------------------------
+# 7. CONTROL SOCKET (optional)
+# ---------------------------------------------------------------------------
+# Path to UNIX domain control socket for headless inspection and dynamic peer
+# management via `yipd ctl` / `yip-ctl`. When running under systemd or OpenRC,
+# this is typically placed in the runtime directory (/run/yipd/yip.sock).
+# Default: /run/yipd/yip.sock
+#
+# control_socket=/run/yipd/yip.sock
diff --git a/install.sh b/install.sh
new file mode 100755
index 00000000..9655e312
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,373 @@
+#!/bin/sh
+# yip Turnkey POSIX Installer
+# https://github.com/femboyisp/yip
+#
+# Usage:
+# curl -fsSL https://raw.githubusercontent.com/femboyisp/yip/main/install.sh | sh
+#
+# Environment Overrides:
+# VERSION - Specific version to install (e.g. '0.1.0' or 'v0.1.0', default: latest)
+# REPO - GitHub repository (default: 'femboyisp/yip')
+# BINDIR - Target directory for binaries (default: '/usr/local/bin')
+# CONFDIR - Target directory for configs (default: '/etc/yip')
+# INIT_SYSTEM - Init system ('auto', 'systemd', 'openrc', 'runit', 'none', default: 'auto')
+# SYSD_DIR - systemd unit directory (default: '/etc/systemd/system')
+# OPENRC_DIR - OpenRC service directory (default: '/etc/init.d')
+# RUNIT_DIR - runit service directory (default: '/etc/sv')
+# LOCAL_TARBALL - Path to local tarball (for offline/sandbox testing)
+# LOCAL_SHA256SUMS - Path to local SHA256SUMS file (for offline/sandbox testing)
+# TARGET - Explicit target triple override (e.g. 'x86_64-unknown-linux-musl')
+
+set -e
+
+# --- 1. Privilege Escalation Detection ---
+# If running as non-root and installing to default system paths, use sudo/doas.
+# If custom directory overrides are provided (e.g. sandbox testing), skip sudo/doas.
+if [ -z "${SUDO+x}" ]; then
+ SUDO=""
+ if [ "$(id -u)" -ne 0 ]; then
+ if [ -n "${BINDIR+x}" ] || [ -n "${CONFDIR+x}" ] || [ -n "${SYSD_DIR+x}" ] || [ -n "${OPENRC_DIR+x}" ] || [ -n "${RUNIT_DIR+x}" ]; then
+ # Custom directory overrides detected: running in unprivileged sandbox mode
+ SUDO=""
+ else
+ if command -v sudo >/dev/null 2>&1; then
+ SUDO="sudo"
+ elif command -v doas >/dev/null 2>&1; then
+ SUDO="doas"
+ else
+ echo "Error: Root privileges required to install to standard paths (/usr/local/bin, /etc/yip)." >&2
+ echo "Neither 'sudo' nor 'doas' was found. Re-run as root or specify destination directory overrides." >&2
+ exit 1
+ fi
+ fi
+ fi
+fi
+
+if [ "$(id -u)" -ne 0 ] && [ -z "$SUDO" ]; then
+ # Unprivileged user execution without sudo/doas: default to user-local XDG paths
+ BINDIR="${BINDIR:-${HOME}/.local/bin}"
+ CONFDIR="${CONFDIR:-${XDG_CONFIG_HOME:-$HOME/.config}/yip}"
+ INIT_SYSTEM="${INIT_SYSTEM:-none}"
+else
+ BINDIR="${BINDIR:-/usr/local/bin}"
+ CONFDIR="${CONFDIR:-/etc/yip}"
+ INIT_SYSTEM="${INIT_SYSTEM:-auto}"
+fi
+SYSD_DIR="${SYSD_DIR:-/etc/systemd/system}"
+OPENRC_DIR="${OPENRC_DIR:-/etc/init.d}"
+RUNIT_DIR="${RUNIT_DIR:-/etc/sv}"
+REPO="${REPO:-femboyisp/yip}"
+
+# --- 2. OS & Architecture Check ---
+OS="$(uname -s)"
+if [ "$OS" != "Linux" ]; then
+ echo "Error: Unsupported OS '$OS'. yip binaries are built for Linux." >&2
+ exit 1
+fi
+
+if [ -z "$TARGET" ]; then
+ ARCH="$(uname -m)"
+ case "$ARCH" in
+ x86_64|amd64)
+ TARGET="x86_64-unknown-linux-musl"
+ ;;
+ aarch64|arm64)
+ TARGET="aarch64-unknown-linux-musl"
+ ;;
+ *)
+ echo "Error: Unsupported architecture '$ARCH'. Only x86_64/amd64 and aarch64/arm64 are supported." >&2
+ exit 1
+ ;;
+ esac
+fi
+
+# --- 3. Temp Directory & Cleanup ---
+TMPDIR="$(mktemp -d 2>/dev/null || echo "/tmp/yip-install-$$")"
+[ ! -d "$TMPDIR" ] && mkdir -p "$TMPDIR"
+cleanup() {
+ rm -rf "$TMPDIR"
+}
+trap cleanup EXIT INT TERM HUP
+
+# --- 4. Download & Verification ---
+download() {
+ _url="$1"
+ _dest="$2"
+ if command -v curl >/dev/null 2>&1; then
+ curl -fsSL "$_url" -o "$_dest"
+ elif command -v wget >/dev/null 2>&1; then
+ wget -qO "$_dest" "$_url"
+ else
+ echo "Error: Neither 'curl' nor 'wget' found. Cannot download release." >&2
+ exit 1
+ fi
+}
+
+CHECKSUMS_FILE=""
+
+if [ -n "$LOCAL_TARBALL" ]; then
+ if [ ! -f "$LOCAL_TARBALL" ]; then
+ echo "Error: LOCAL_TARBALL '$LOCAL_TARBALL' not found." >&2
+ exit 1
+ fi
+ TARBALL_PATH="$LOCAL_TARBALL"
+ TARBALL_FILE="$(basename "$LOCAL_TARBALL")"
+
+ if [ -n "$LOCAL_SHA256SUMS" ]; then
+ if [ ! -f "$LOCAL_SHA256SUMS" ]; then
+ echo "Error: LOCAL_SHA256SUMS '$LOCAL_SHA256SUMS' not found." >&2
+ exit 1
+ fi
+ CHECKSUMS_FILE="$LOCAL_SHA256SUMS"
+ elif [ -f "${LOCAL_TARBALL}.sha256" ]; then
+ CHECKSUMS_FILE="${LOCAL_TARBALL}.sha256"
+ elif [ -f "$(dirname "$LOCAL_TARBALL")/SHA256SUMS" ]; then
+ CHECKSUMS_FILE="$(dirname "$LOCAL_TARBALL")/SHA256SUMS"
+ fi
+else
+ # Live download from GitHub releases
+ VERSION="${VERSION:-latest}"
+ if [ "$VERSION" = "latest" ]; then
+ echo "Resolving latest release for ${REPO}..."
+ TAG=""
+ if command -v curl >/dev/null 2>&1; then
+ REDIRECT_URL="$(curl -fsSL -o /dev/null -w '%{url_effective}' "https://github.com/${REPO}/releases/latest" 2>/dev/null | tr -d '\r')"
+ TAG="${REDIRECT_URL##*/}"
+ if [ "$TAG" = "latest" ] || [ -z "$TAG" ]; then
+ TAG="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" 2>/dev/null | grep '"tag_name":' | head -n 1 | sed -E 's/.*"([^"]+)".*/\1/')"
+ fi
+ elif command -v wget >/dev/null 2>&1; then
+ TAG="$(wget -qO- "https://api.github.com/repos/${REPO}/releases/latest" 2>/dev/null | grep '"tag_name":' | head -n 1 | sed -E 's/.*"([^"]+)".*/\1/')"
+ fi
+
+ if [ -z "$TAG" ] || [ "$TAG" = "latest" ]; then
+ echo "Error: Could not resolve latest release tag for ${REPO}." >&2
+ exit 1
+ fi
+ else
+ case "$VERSION" in
+ v*) TAG="$VERSION" ;;
+ *) TAG="v$VERSION" ;;
+ esac
+ fi
+
+ TARBALL_FILE="yip-${TAG}-${TARGET}.tar.gz"
+ TARBALL_URL="https://github.com/${REPO}/releases/download/${TAG}/${TARBALL_FILE}"
+ CHECKSUMS_URL="https://github.com/${REPO}/releases/download/${TAG}/SHA256SUMS"
+
+ TARBALL_PATH="$TMPDIR/$TARBALL_FILE"
+ CHECKSUMS_FILE="$TMPDIR/SHA256SUMS"
+
+ echo "Downloading ${TARBALL_URL}..."
+ download "$TARBALL_URL" "$TARBALL_PATH"
+ echo "Downloading ${CHECKSUMS_URL}..."
+ download "$CHECKSUMS_URL" "$CHECKSUMS_FILE"
+fi
+
+# Verify Checksum
+if [ -n "$CHECKSUMS_FILE" ]; then
+ echo "Verifying SHA-256 checksum..."
+ EXPECTED_HASH=""
+
+ # 1. Match basename in checksum file
+ MATCH_LINE="$(grep -E "[[:space:]]\*?${TARBALL_FILE}\$" "$CHECKSUMS_FILE" 2>/dev/null | head -n 1)"
+ if [ -n "$MATCH_LINE" ]; then
+ EXPECTED_HASH="$(printf '%s\n' "$MATCH_LINE" | awk '{print $1}')"
+ fi
+
+ # 2. Match target triple in checksum file
+ if [ -z "$EXPECTED_HASH" ]; then
+ MATCH_LINE="$(grep -E "${TARGET}" "$CHECKSUMS_FILE" 2>/dev/null | head -n 1)"
+ if [ -n "$MATCH_LINE" ]; then
+ EXPECTED_HASH="$(printf '%s\n' "$MATCH_LINE" | awk '{print $1}')"
+ fi
+ fi
+
+ # 3. Match single 64-hex entry
+ if [ -z "$EXPECTED_HASH" ]; then
+ FIRST_WORD="$(awk '{print $1}' "$CHECKSUMS_FILE" 2>/dev/null | head -n 1)"
+ case "$FIRST_WORD" in
+ [0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]*)
+ if [ ${#FIRST_WORD} -eq 64 ]; then
+ EXPECTED_HASH="$FIRST_WORD"
+ fi
+ ;;
+ esac
+ fi
+
+ if [ -z "$EXPECTED_HASH" ]; then
+ echo "Error: Could not locate checksum for $TARBALL_FILE in $CHECKSUMS_FILE." >&2
+ exit 1
+ fi
+
+ CHECK_TMP="$TMPDIR/sha256_check.txt"
+ TARBALL_BASENAME="$(basename "$TARBALL_PATH")"
+ TARBALL_DIR="$(dirname "$TARBALL_PATH")"
+ printf '%s %s\n' "$EXPECTED_HASH" "$TARBALL_BASENAME" > "$CHECK_TMP"
+
+ VERIFIED=0
+ if command -v sha256sum >/dev/null 2>&1; then
+ if (cd "$TARBALL_DIR" && sha256sum -c "$CHECK_TMP" >/dev/null 2>&1); then
+ VERIFIED=1
+ fi
+ elif command -v shasum >/dev/null 2>&1; then
+ if (cd "$TARBALL_DIR" && shasum -a 256 -c "$CHECK_TMP" >/dev/null 2>&1); then
+ VERIFIED=1
+ fi
+ else
+ echo "Error: Neither 'sha256sum' nor 'shasum' found. Checksum verification unavailable." >&2
+ exit 1
+ fi
+
+ if [ "$VERIFIED" -ne 1 ]; then
+ echo "Error: Checksum verification failed for $TARBALL_FILE." >&2
+ exit 1
+ fi
+ echo "Checksum OK ($EXPECTED_HASH)"
+fi
+
+# --- 5. Extraction & Binary Installation ---
+EXTRACT_DIR="$TMPDIR/extract"
+mkdir -p "$EXTRACT_DIR"
+tar -xzf "$TARBALL_PATH" -C "$EXTRACT_DIR"
+
+ROOT_DIR=""
+if [ -f "$EXTRACT_DIR/yipd" ] && [ -f "$EXTRACT_DIR/yip-ca" ] && [ -f "$EXTRACT_DIR/yip-rendezvous" ]; then
+ ROOT_DIR="$EXTRACT_DIR"
+else
+ # Find candidate directory containing all required binaries (distinguishes binary dir from contrib/openrc/yipd)
+ for cand in $(find "$EXTRACT_DIR" -type f -name "yip-ca" 2>/dev/null); do
+ cand_dir="$(dirname "$cand")"
+ if [ -f "$cand_dir/yipd" ] && [ -f "$cand_dir/yip-rendezvous" ]; then
+ ROOT_DIR="$cand_dir"
+ break
+ fi
+ done
+fi
+
+if [ -z "$ROOT_DIR" ]; then
+ echo "Error: Required binaries (yipd, yip-ca, yip-rendezvous) not found in release archive." >&2
+ exit 1
+fi
+
+# Verify required binaries exist
+for bin in yipd yip-ca yip-rendezvous; do
+ if [ ! -f "$ROOT_DIR/$bin" ]; then
+ echo "Error: Required binary '$bin' missing from release archive." >&2
+ exit 1
+ fi
+done
+
+# Install binaries
+echo "Installing binaries to ${BINDIR}..."
+$SUDO mkdir -p "$BINDIR"
+for bin in yipd yip-ca yip-rendezvous; do
+ $SUDO cp "$ROOT_DIR/$bin" "$BINDIR/$bin"
+ $SUDO chmod 0755 "$BINDIR/$bin"
+done
+
+# --- 6. Configuration Seeding ---
+echo "Seeding configuration into ${CONFDIR}..."
+if $SUDO mkdir -p "$CONFDIR" 2>/dev/null; then
+ if [ -f "$ROOT_DIR/example.config" ]; then
+ $SUDO cp "$ROOT_DIR/example.config" "$CONFDIR/yipd.conf.example"
+ $SUDO chmod 0644 "$CONFDIR/yipd.conf.example"
+ fi
+else
+ echo "Warning: Could not create ${CONFDIR}; skipping configuration seeding." >&2
+fi
+
+# --- 7. Init System Detection & Installation ---
+DETECTED_INIT="none"
+case "$INIT_SYSTEM" in
+ auto|"")
+ if [ -d /run/systemd/system ]; then
+ DETECTED_INIT="systemd"
+ elif command -v rc-service >/dev/null 2>&1 || [ -d /run/openrc ]; then
+ DETECTED_INIT="openrc"
+ elif command -v runsv >/dev/null 2>&1 || [ -d /etc/sv ]; then
+ DETECTED_INIT="runit"
+ elif [ -d /etc/systemd/system ]; then
+ DETECTED_INIT="systemd"
+ elif [ -d /etc/init.d ]; then
+ DETECTED_INIT="openrc"
+ else
+ DETECTED_INIT="none"
+ fi
+ ;;
+ systemd|openrc|runit|none)
+ DETECTED_INIT="$INIT_SYSTEM"
+ ;;
+ *)
+ echo "Error: Unknown INIT_SYSTEM '$INIT_SYSTEM'. Expected auto, systemd, openrc, runit, or none." >&2
+ exit 1
+ ;;
+esac
+
+echo "Configuring init service: ${DETECTED_INIT}"
+case "$DETECTED_INIT" in
+ systemd)
+ $SUDO mkdir -p "$SYSD_DIR"
+ if [ -f "$ROOT_DIR/contrib/systemd/yipd.service" ]; then
+ $SUDO cp "$ROOT_DIR/contrib/systemd/yipd.service" "$SYSD_DIR/yipd.service"
+ $SUDO chmod 0644 "$SYSD_DIR/yipd.service"
+ echo "Installed ${SYSD_DIR}/yipd.service"
+ fi
+ if [ -f "$ROOT_DIR/contrib/systemd/yip-rendezvous.service" ]; then
+ $SUDO cp "$ROOT_DIR/contrib/systemd/yip-rendezvous.service" "$SYSD_DIR/yip-rendezvous.service"
+ $SUDO chmod 0644 "$SYSD_DIR/yip-rendezvous.service"
+ echo "Installed ${SYSD_DIR}/yip-rendezvous.service"
+ fi
+ if [ -d /run/systemd/system ] && [ "$SYSD_DIR" = "/etc/systemd/system" ] && command -v systemctl >/dev/null 2>&1; then
+ echo "Reloading systemd daemon..."
+ $SUDO systemctl daemon-reload 2>/dev/null || true
+ fi
+ ;;
+ openrc)
+ $SUDO mkdir -p "$OPENRC_DIR"
+ if [ -f "$ROOT_DIR/contrib/openrc/yipd" ]; then
+ $SUDO cp "$ROOT_DIR/contrib/openrc/yipd" "$OPENRC_DIR/yipd"
+ $SUDO chmod 0755 "$OPENRC_DIR/yipd"
+ echo "Installed ${OPENRC_DIR}/yipd"
+ fi
+ if [ -f "$ROOT_DIR/contrib/openrc/yip-rendezvous" ]; then
+ $SUDO cp "$ROOT_DIR/contrib/openrc/yip-rendezvous" "$OPENRC_DIR/yip-rendezvous"
+ $SUDO chmod 0755 "$OPENRC_DIR/yip-rendezvous"
+ echo "Installed ${OPENRC_DIR}/yip-rendezvous"
+ fi
+ ;;
+ runit)
+ $SUDO mkdir -p "$RUNIT_DIR"
+ if [ -d "$ROOT_DIR/contrib/runit/yipd" ]; then
+ $SUDO mkdir -p "$RUNIT_DIR/yipd/log"
+ $SUDO cp "$ROOT_DIR/contrib/runit/yipd/run" "$RUNIT_DIR/yipd/run"
+ $SUDO chmod 0755 "$RUNIT_DIR/yipd/run"
+ if [ -f "$ROOT_DIR/contrib/runit/yipd/log/run" ]; then
+ $SUDO cp "$ROOT_DIR/contrib/runit/yipd/log/run" "$RUNIT_DIR/yipd/log/run"
+ $SUDO chmod 0755 "$RUNIT_DIR/yipd/log/run"
+ fi
+ echo "Installed ${RUNIT_DIR}/yipd"
+ fi
+ if [ -d "$ROOT_DIR/contrib/runit/yip-rendezvous" ]; then
+ $SUDO mkdir -p "$RUNIT_DIR/yip-rendezvous/log"
+ $SUDO cp "$ROOT_DIR/contrib/runit/yip-rendezvous/run" "$RUNIT_DIR/yip-rendezvous/run"
+ $SUDO chmod 0755 "$RUNIT_DIR/yip-rendezvous/run"
+ if [ -f "$ROOT_DIR/contrib/runit/yip-rendezvous/log/run" ]; then
+ $SUDO cp "$ROOT_DIR/contrib/runit/yip-rendezvous/log/run" "$RUNIT_DIR/yip-rendezvous/log/run"
+ $SUDO chmod 0755 "$RUNIT_DIR/yip-rendezvous/log/run"
+ fi
+ echo "Installed ${RUNIT_DIR}/yip-rendezvous"
+ fi
+ ;;
+ none)
+ echo "Skipping service unit installation (INIT_SYSTEM=none)."
+ ;;
+esac
+
+echo ""
+echo "Installation complete!"
+echo "Binaries: $BINDIR/{yipd, yip-ca, yip-rendezvous}"
+echo "Config: $CONFDIR/yipd.conf.example"
+if [ ! -f "$CONFDIR/yipd.conf" ]; then
+ echo "To configure yip, copy $CONFDIR/yipd.conf.example to $CONFDIR/yipd.conf and edit."
+fi
diff --git a/tests/test_install_sh.sh b/tests/test_install_sh.sh
new file mode 100755
index 00000000..dd76dbe3
--- /dev/null
+++ b/tests/test_install_sh.sh
@@ -0,0 +1,361 @@
+#!/bin/sh
+# Automated integration test suite for install.sh
+# Tests sandbox installation, checksum verification, and init system handling.
+
+set -eu
+(set -o pipefail 2>/dev/null) && set -o pipefail || true
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+INSTALL_SH="$REPO_ROOT/install.sh"
+
+if [ ! -f "$INSTALL_SH" ]; then
+ echo "Error: install.sh not found at $INSTALL_SH" >&2
+ exit 1
+fi
+
+# Sandbox working directory
+TEST_ROOT="$(mktemp -d /tmp/yip-test-install-XXXXXX)"
+cleanup() {
+ rm -rf "$TEST_ROOT"
+}
+trap cleanup EXIT INT TERM
+
+# Colors for test reporting
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+NC='\033[0m' # No Color
+
+pass() {
+ printf "%b[PASS]%b %s\n" "${GREEN}" "${NC}" "$1"
+}
+
+fail() {
+ printf "%b[FAIL]%b %s\n" "${RED}" "${NC}" "$1" >&2
+ exit 1
+}
+
+# Determine host target triple
+ARCH="$(uname -m)"
+case "$ARCH" in
+ x86_64|amd64)
+ TARGET="x86_64-unknown-linux-musl"
+ ;;
+ aarch64|arm64)
+ TARGET="aarch64-unknown-linux-musl"
+ ;;
+ *)
+ TARGET="x86_64-unknown-linux-musl"
+ ;;
+esac
+
+VERSION="0.1.0"
+TARBALL_NAME="yip-v${VERSION}-${TARGET}.tar.gz"
+MOCK_DIR="$TEST_ROOT/mock_release"
+STAGE_DIR="$MOCK_DIR/yip-v${VERSION}-${TARGET}"
+
+echo "=== Preparing Mock Release Artifacts ==="
+mkdir -p "$STAGE_DIR/contrib/systemd"
+mkdir -p "$STAGE_DIR/contrib/openrc"
+mkdir -p "$STAGE_DIR/contrib/runit/yipd/log"
+mkdir -p "$STAGE_DIR/contrib/runit/yip-rendezvous/log"
+
+# Mock binaries
+cat <<'EOF' > "$STAGE_DIR/yipd"
+#!/bin/sh
+echo "yipd mock v0.1.0"
+EOF
+
+cat <<'EOF' > "$STAGE_DIR/yip-ca"
+#!/bin/sh
+echo "yip-ca mock v0.1.0"
+EOF
+
+cat <<'EOF' > "$STAGE_DIR/yip-rendezvous"
+#!/bin/sh
+echo "yip-rendezvous mock v0.1.0"
+EOF
+
+chmod 0755 "$STAGE_DIR/yipd" "$STAGE_DIR/yip-ca" "$STAGE_DIR/yip-rendezvous"
+
+# Mock example.config
+cat <<'EOF' > "$STAGE_DIR/example.config"
+listen=0.0.0.0
+device=yip0
+EOF
+
+# Mock contrib units
+echo "[Unit]" > "$STAGE_DIR/contrib/systemd/yipd.service"
+echo "[Unit]" > "$STAGE_DIR/contrib/systemd/yip-rendezvous.service"
+echo "#!/sbin/openrc-run" > "$STAGE_DIR/contrib/openrc/yipd"
+chmod 0755 "$STAGE_DIR/contrib/openrc/yipd"
+echo "#!/sbin/openrc-run" > "$STAGE_DIR/contrib/openrc/yip-rendezvous"
+chmod 0755 "$STAGE_DIR/contrib/openrc/yip-rendezvous"
+echo "#!/bin/sh" > "$STAGE_DIR/contrib/runit/yipd/run"
+chmod 0755 "$STAGE_DIR/contrib/runit/yipd/run"
+echo "#!/bin/sh" > "$STAGE_DIR/contrib/runit/yipd/log/run"
+chmod 0755 "$STAGE_DIR/contrib/runit/yipd/log/run"
+echo "#!/bin/sh" > "$STAGE_DIR/contrib/runit/yip-rendezvous/run"
+chmod 0755 "$STAGE_DIR/contrib/runit/yip-rendezvous/run"
+echo "#!/bin/sh" > "$STAGE_DIR/contrib/runit/yip-rendezvous/log/run"
+chmod 0755 "$STAGE_DIR/contrib/runit/yip-rendezvous/log/run"
+
+# Package tarball
+tar -czf "$MOCK_DIR/$TARBALL_NAME" -C "$MOCK_DIR" "yip-v${VERSION}-${TARGET}"
+
+# Generate real SHA256SUMS
+(cd "$MOCK_DIR" && sha256sum "$TARBALL_NAME" > "SHA256SUMS")
+
+MOCK_TARBALL="$MOCK_DIR/$TARBALL_NAME"
+MOCK_SHA256SUMS="$MOCK_DIR/SHA256SUMS"
+
+echo "Mock tarball: $MOCK_TARBALL"
+echo "Mock SHA256SUMS: $MOCK_SHA256SUMS"
+cat "$MOCK_SHA256SUMS"
+echo ""
+
+# -----------------------------------------------------------------------------
+# Test Case 1: Valid install into sandbox paths (systemd)
+# -----------------------------------------------------------------------------
+echo "=== Test Case 1: Valid install into sandbox paths ==="
+SB1="$TEST_ROOT/sandbox_1"
+mkdir -p "$SB1"
+
+OUTPUT_1=$(BINDIR="$SB1/bin" \
+CONFDIR="$SB1/etc/yip" \
+SYSD_DIR="$SB1/etc/systemd/system" \
+LOCAL_TARBALL="$MOCK_TARBALL" \
+LOCAL_SHA256SUMS="$MOCK_SHA256SUMS" \
+INIT_SYSTEM="systemd" \
+sh "$INSTALL_SH" 2>&1)
+
+# Check binaries
+[ -f "$SB1/bin/yipd" ] || fail "yipd binary not installed"
+[ -x "$SB1/bin/yipd" ] || fail "yipd binary is not executable"
+[ -f "$SB1/bin/yip-ca" ] || fail "yip-ca binary not installed"
+[ -x "$SB1/bin/yip-ca" ] || fail "yip-ca binary is not executable"
+[ -f "$SB1/bin/yip-rendezvous" ] || fail "yip-rendezvous binary not installed"
+[ -x "$SB1/bin/yip-rendezvous" ] || fail "yip-rendezvous binary is not executable"
+
+# Verify execution of installed binary
+BIN_OUT=$("$SB1/bin/yipd")
+case "$BIN_OUT" in
+ *"yipd mock v0.1.0"*) ;;
+ *) fail "yipd execution output unexpected: $BIN_OUT" ;;
+esac
+
+# Check config seeding
+[ -f "$SB1/etc/yip/yipd.conf.example" ] || fail "yipd.conf.example not created"
+grep -q "listen=0.0.0.0" "$SB1/etc/yip/yipd.conf.example" || fail "example config content mismatch"
+
+# Check systemd service unit installation
+[ -f "$SB1/etc/systemd/system/yipd.service" ] || fail "yipd.service not installed"
+[ -f "$SB1/etc/systemd/system/yip-rendezvous.service" ] || fail "yip-rendezvous.service not installed"
+
+pass "Test Case 1 passed: Binaries, permissions, config, and systemd units verified"
+
+# -----------------------------------------------------------------------------
+# Test Case 2: Corrupted tarball (SHA-256 verification failure)
+# -----------------------------------------------------------------------------
+echo "=== Test Case 2: Checksum mismatch rejection ==="
+SB2="$TEST_ROOT/sandbox_2"
+mkdir -p "$SB2"
+
+CORRUPTED_TARBALL="$TEST_ROOT/corrupted-$TARBALL_NAME"
+cp "$MOCK_TARBALL" "$CORRUPTED_TARBALL"
+# Tamper with archive
+echo "TAMPERED DATA" >> "$CORRUPTED_TARBALL"
+
+set +e
+OUTPUT_2=$(BINDIR="$SB2/bin" \
+CONFDIR="$SB2/etc/yip" \
+LOCAL_TARBALL="$CORRUPTED_TARBALL" \
+LOCAL_SHA256SUMS="$MOCK_SHA256SUMS" \
+INIT_SYSTEM="none" \
+sh "$INSTALL_SH" 2>&1)
+EXIT_CODE_2=$?
+set -e
+
+[ "$EXIT_CODE_2" -ne 0 ] || fail "install.sh succeeded on corrupted tarball (expected non-zero exit)"
+case "$OUTPUT_2" in
+ *"Checksum verification failed"*) ;;
+ *) fail "Error output missing checksum failure notice: $OUTPUT_2" ;;
+esac
+[ ! -f "$SB2/bin/yipd" ] || fail "Binaries installed despite checksum mismatch"
+[ ! -f "$SB2/etc/yip/yipd.conf.example" ] || fail "Config seeded despite checksum mismatch"
+
+pass "Test Case 2 passed: Corrupted tarball was rejected before installation"
+
+# -----------------------------------------------------------------------------
+# Test Case 3: Explicit INIT_SYSTEM selection
+# -----------------------------------------------------------------------------
+echo "=== Test Case 3a: Explicit INIT_SYSTEM=openrc ==="
+SB3A="$TEST_ROOT/sandbox_3a"
+mkdir -p "$SB3A"
+
+BINDIR="$SB3A/bin" \
+CONFDIR="$SB3A/etc/yip" \
+OPENRC_DIR="$SB3A/etc/init.d" \
+SYSD_DIR="$SB3A/etc/systemd/system" \
+LOCAL_TARBALL="$MOCK_TARBALL" \
+LOCAL_SHA256SUMS="$MOCK_SHA256SUMS" \
+INIT_SYSTEM="openrc" \
+sh "$INSTALL_SH" >/dev/null 2>&1
+
+[ -f "$SB3A/bin/yipd" ] || fail "binaries not installed"
+[ -f "$SB3A/etc/init.d/yipd" ] || fail "OpenRC yipd service not installed"
+[ -x "$SB3A/etc/init.d/yipd" ] || fail "OpenRC yipd service not executable"
+[ -f "$SB3A/etc/init.d/yip-rendezvous" ] || fail "OpenRC yip-rendezvous service not installed"
+[ -x "$SB3A/etc/init.d/yip-rendezvous" ] || fail "OpenRC yip-rendezvous service not executable"
+[ ! -f "$SB3A/etc/systemd/system/yipd.service" ] || fail "systemd service installed when INIT_SYSTEM=openrc"
+
+pass "Test Case 3a passed: OpenRC services correctly installed with executable permissions"
+
+echo "=== Test Case 3b: Explicit INIT_SYSTEM=runit ==="
+SB3B="$TEST_ROOT/sandbox_3b"
+mkdir -p "$SB3B"
+
+BINDIR="$SB3B/bin" \
+CONFDIR="$SB3B/etc/yip" \
+RUNIT_DIR="$SB3B/etc/sv" \
+SYSD_DIR="$SB3B/etc/systemd/system" \
+LOCAL_TARBALL="$MOCK_TARBALL" \
+LOCAL_SHA256SUMS="$MOCK_SHA256SUMS" \
+INIT_SYSTEM="runit" \
+sh "$INSTALL_SH" >/dev/null 2>&1
+
+[ -f "$SB3B/bin/yipd" ] || fail "binaries not installed"
+[ -f "$SB3B/etc/sv/yipd/run" ] || fail "runit yipd run script not installed"
+[ -x "$SB3B/etc/sv/yipd/run" ] || fail "runit yipd run script not executable"
+[ -f "$SB3B/etc/sv/yipd/log/run" ] || fail "runit yipd log/run script not installed"
+[ -x "$SB3B/etc/sv/yipd/log/run" ] || fail "runit yipd log/run script not executable"
+[ -f "$SB3B/etc/sv/yip-rendezvous/run" ] || fail "runit yip-rendezvous run script not installed"
+[ -x "$SB3B/etc/sv/yip-rendezvous/run" ] || fail "runit yip-rendezvous run script not executable"
+[ ! -f "$SB3B/etc/systemd/system/yipd.service" ] || fail "systemd service installed when INIT_SYSTEM=runit"
+
+pass "Test Case 3b passed: runit service directory and scripts installed"
+
+echo "=== Test Case 3c: Explicit INIT_SYSTEM=none ==="
+SB3C="$TEST_ROOT/sandbox_3c"
+mkdir -p "$SB3C"
+
+BINDIR="$SB3C/bin" \
+CONFDIR="$SB3C/etc/yip" \
+SYSD_DIR="$SB3C/etc/systemd/system" \
+OPENRC_DIR="$SB3C/etc/init.d" \
+RUNIT_DIR="$SB3C/etc/sv" \
+LOCAL_TARBALL="$MOCK_TARBALL" \
+LOCAL_SHA256SUMS="$MOCK_SHA256SUMS" \
+INIT_SYSTEM="none" \
+sh "$INSTALL_SH" >/dev/null 2>&1
+
+[ -f "$SB3C/bin/yipd" ] || fail "binaries not installed"
+[ -f "$SB3C/etc/yip/yipd.conf.example" ] || fail "example config not seeded"
+[ ! -f "$SB3C/etc/systemd/system/yipd.service" ] || fail "systemd service installed when INIT_SYSTEM=none"
+[ ! -f "$SB3C/etc/init.d/yipd" ] || fail "OpenRC service installed when INIT_SYSTEM=none"
+[ ! -d "$SB3C/etc/sv/yipd" ] || fail "runit service installed when INIT_SYSTEM=none"
+
+pass "Test Case 3c passed: INIT_SYSTEM=none skips service registration"
+
+echo "=== Test Case 3d: Explicit INIT_SYSTEM=systemd ==="
+SB3D="$TEST_ROOT/sandbox_3d"
+mkdir -p "$SB3D"
+
+BINDIR="$SB3D/bin" \
+CONFDIR="$SB3D/etc/yip" \
+SYSD_DIR="$SB3D/etc/systemd/system" \
+LOCAL_TARBALL="$MOCK_TARBALL" \
+LOCAL_SHA256SUMS="$MOCK_SHA256SUMS" \
+INIT_SYSTEM="systemd" \
+sh "$INSTALL_SH" >/dev/null 2>&1
+
+[ -f "$SB3D/bin/yipd" ] || fail "binaries not installed"
+[ -f "$SB3D/etc/systemd/system/yipd.service" ] || fail "systemd yipd.service missing"
+[ -f "$SB3D/etc/systemd/system/yip-rendezvous.service" ] || fail "systemd yip-rendezvous.service missing"
+
+pass "Test Case 3d passed: INIT_SYSTEM=systemd correctly installed units"
+
+# -----------------------------------------------------------------------------
+# Test Case 4: Invalid INIT_SYSTEM handling
+# -----------------------------------------------------------------------------
+echo "=== Test Case 4: Invalid INIT_SYSTEM handling ==="
+SB4="$TEST_ROOT/sandbox_4"
+mkdir -p "$SB4"
+
+set +e
+OUTPUT_4=$(BINDIR="$SB4/bin" \
+CONFDIR="$SB4/etc/yip" \
+LOCAL_TARBALL="$MOCK_TARBALL" \
+LOCAL_SHA256SUMS="$MOCK_SHA256SUMS" \
+INIT_SYSTEM="nonexistent_init" \
+sh "$INSTALL_SH" 2>&1)
+EXIT_CODE_4=$?
+set -e
+
+[ "$EXIT_CODE_4" -ne 0 ] || fail "install.sh succeeded with invalid INIT_SYSTEM"
+case "$OUTPUT_4" in
+ *"Unknown INIT_SYSTEM"*) ;;
+ *) fail "Unexpected output for invalid INIT_SYSTEM: $OUTPUT_4" ;;
+esac
+
+pass "Test Case 4 passed: Unknown INIT_SYSTEM correctly rejected"
+
+# -----------------------------------------------------------------------------
+# Test Case 5: Architecture and Missing Binary Checks
+# -----------------------------------------------------------------------------
+echo "=== Test Case 5: Missing binary in archive detection ==="
+SB5="$TEST_ROOT/sandbox_5"
+mkdir -p "$SB5"
+
+INCOMPLETE_DIR="$TEST_ROOT/incomplete_release"
+mkdir -p "$INCOMPLETE_DIR/yip-v${VERSION}-${TARGET}"
+# Only create yipd, omit yip-ca and yip-rendezvous
+echo "#!/bin/sh" > "$INCOMPLETE_DIR/yip-v${VERSION}-${TARGET}/yipd"
+chmod +x "$INCOMPLETE_DIR/yip-v${VERSION}-${TARGET}/yipd"
+tar -czf "$INCOMPLETE_DIR/incomplete.tar.gz" -C "$INCOMPLETE_DIR" "yip-v${VERSION}-${TARGET}"
+
+set +e
+OUTPUT_5=$(BINDIR="$SB5/bin" \
+CONFDIR="$SB5/etc/yip" \
+LOCAL_TARBALL="$INCOMPLETE_DIR/incomplete.tar.gz" \
+INIT_SYSTEM="none" \
+sh "$INSTALL_SH" 2>&1)
+EXIT_CODE_5=$?
+set -e
+
+[ "$EXIT_CODE_5" -ne 0 ] || fail "install.sh succeeded with missing required binaries"
+case "$OUTPUT_5" in
+ *"not found in release archive"*|*"missing from release archive"*) ;;
+ *) fail "Unexpected output for missing binary: $OUTPUT_5" ;;
+esac
+
+pass "Test Case 5 passed: Incomplete archive with missing binaries detected and rejected"
+
+# -----------------------------------------------------------------------------
+# Test Case 6: Unprivileged user installation with only BINDIR override
+# -----------------------------------------------------------------------------
+echo "=== Test Case 6: Unprivileged installation with only BINDIR specified ==="
+SB6="$TEST_ROOT/sandbox_6"
+mkdir -p "$SB6/bin" "$SB6/home"
+
+export HOME="$SB6/home"
+export XDG_CONFIG_HOME="$SB6/home/.config"
+
+set +e
+OUTPUT_6=$(BINDIR="$SB6/bin" \
+LOCAL_TARBALL="$MOCK_TARBALL" \
+LOCAL_SHA256SUMS="$MOCK_SHA256SUMS" \
+sh "$INSTALL_SH" 2>&1)
+EXIT_CODE_6=$?
+set -e
+
+[ "$EXIT_CODE_6" -eq 0 ] || fail "install.sh failed with only BINDIR set: $OUTPUT_6"
+[ -x "$SB6/bin/yipd" ] || fail "yipd not installed in user BINDIR"
+[ -f "$SB6/home/.config/yip/yipd.conf.example" ] || fail "Config not seeded in user config directory: $OUTPUT_6"
+
+pass "Test Case 6 passed: Unprivileged user install gracefully defaults to user XDG config path"
+
+echo ""
+echo "=========================================="
+printf "%bALL INSTALLER INTEGRATION TESTS PASSED!%b\n" "${GREEN}" "${NC}"
+echo "=========================================="