chore: import upstream snapshot with attribution
Rust / build (push) Failing after 1s
Rust / docker (push) Failing after 0s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:29:44 +08:00
commit 19dc5d82a0
236 changed files with 224662 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
# Build artifacts
/target/
# Git
.git/
.gitignore
.dockerignore
# IDE
.vscode/
.idea/
*.swp
*.swo
# Logs
logs/
*.log
# Documentation and non-essential files
README.md
CHANGELOG.md
ROADMAP.md
LICENSE
RELEASE.md
rustnet-*.tar.gz
# Assets we don't need in container
assets/rustnet.gif
# Scripts (not needed in container)
scripts/
# CI/CD (already in repo context)
.github/
+2
View File
@@ -0,0 +1,2 @@
# Ignore the vmlinux.h header for GitHub language stats
vmlinux.h linguist-generated
+50
View File
@@ -0,0 +1,50 @@
# GitHub Actions PPA Setup
## Add GitHub Secrets
Go to: **Settings****Secrets and variables****Actions****New repository secret**
### 1. GPG_PRIVATE_KEY
```bash
# Display your CI private key
cat ci-signing-key.asc
```
Copy the entire output (including `-----BEGIN PGP PRIVATE KEY BLOCK-----` and `-----END...`)
- Name: `GPG_PRIVATE_KEY`
- Value: [paste the entire key]
### 2. GPG_KEY_ID
```bash
# Get your key ID
gpg --list-keys cadetg@gmail.com
```
Copy the long hex string (e.g., `ABC123...`)
- Name: `GPG_KEY_ID`
- Value: [paste just the key ID]
## Test the Workflow
```bash
# Create and push a test tag
git tag v1.0.0-test
git push origin v1.0.0-test
```
Check: **Actions** tab in GitHub → **Release to Ubuntu PPA**
## Remove Test Tag (if needed)
```bash
git tag -d v1.0.0-test
git push origin :refs/tags/v1.0.0-test
```
## Done!
From now on, just push version tags and GitHub will handle the PPA release automatically! 🚀
+38
View File
@@ -0,0 +1,38 @@
name: 'Build RustNet'
description: 'Build RustNet binary for a specific target'
inputs:
target:
description: 'Rust target triple (e.g., x86_64-unknown-linux-gnu)'
required: true
cargo-command:
description: 'Cargo command to use (cargo or cross)'
required: false
default: 'cargo'
default-features:
description: 'Enable default features including eBPF (true/false)'
required: false
default: 'true'
strip-symbols:
description: 'Strip debug symbols from binary (true/false)'
required: false
default: 'true'
runs:
using: 'composite'
steps:
- name: Build binary
shell: bash
env:
RUSTFLAGS: ${{ inputs.strip-symbols == 'true' && '-C strip=symbols' || '' }}
run: |
mkdir -p assets # Ensure asset dir exists for build.rs
BUILD_CMD="${{ inputs.cargo-command }} build --verbose --release --target ${{ inputs.target }}"
if [[ "${{ inputs.default-features }}" != "true" ]]; then
BUILD_CMD="$BUILD_CMD --no-default-features"
fi
echo "Running: $BUILD_CMD"
$BUILD_CMD
+30
View File
@@ -0,0 +1,30 @@
name: 'Build Static Binary'
description: 'Build a statically-linked Linux binary using musl'
runs:
using: 'composite'
steps:
- name: Install dependencies
shell: sh
run: |
apk add --no-cache \
musl-dev libpcap-dev pkgconfig build-base perl \
elfutils-dev zlib-dev zlib-static zstd-dev zstd-static \
clang llvm linux-headers git
rustup component add rustfmt
- name: Build static binary
shell: sh
env:
# -C strip=symbols: Strip debug symbols for smaller binary
# -C link-arg=-l:libzstd.a: Fix elfutils 0.189+ zstd dependency (libbpf/bpftool#152)
RUSTFLAGS: "-C strip=symbols -C link-arg=-l:libzstd.a"
run: cargo build --release
- name: Verify static linking
shell: sh
run: |
file target/release/rustnet
# Use file command to verify (ldd behaves differently inside Alpine)
file target/release/rustnet | grep -q "static.* linked" || \
(echo "ERROR: Binary is not statically linked" && exit 1)
@@ -0,0 +1,11 @@
name: 'Setup Linux Build Dependencies'
description: 'Install dependencies required for building RustNet on Linux'
runs:
using: 'composite'
steps:
- name: Install dependencies
shell: bash
run: |
sudo apt-get update -y
sudo apt-get install -y libpcap-dev libelf-dev zlib1g-dev clang llvm pkg-config
+46
View File
@@ -0,0 +1,46 @@
version: 2
updates:
# Maintain Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
cooldown:
default-days: 3
semver-major-days: 7
semver-minor-days: 4
semver-patch-days: 3
groups:
rust-dependencies:
patterns:
- "*"
update-types:
- "major"
- "minor"
- "patch"
# Maintain GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
groups:
actions:
patterns:
- "*"
update-types:
- "major"
- "minor"
- "patch"
# Maintain Docker base images (Dockerfile FROM pins)
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "daily"
cooldown:
default-days: 7
groups:
docker:
patterns:
- "*"
+56
View File
@@ -0,0 +1,56 @@
<!--
Thanks for contributing to rustnet. Please read CONTRIBUTING.md before
opening the PR, then fill in the sections below.
CONTRIBUTING.md: https://github.com/domcyrus/rustnet/blob/main/CONTRIBUTING.md
PRs that ship without the checklist completed will usually be asked to
update before review.
-->
## Summary
<!-- What does this PR do, and why? One or two paragraphs. -->
## Linked issue
<!-- For features and non-trivial changes, link the issue where the
approach was discussed. If there is no issue, please open one first
(see CONTRIBUTING.md: https://github.com/domcyrus/rustnet/blob/main/CONTRIBUTING.md#development-workflow).
Bug fixes and typo corrections do not need a prior issue. -->
Closes #
## Verification
Per [CONTRIBUTING.md > Code Quality Requirements](https://github.com/domcyrus/rustnet/blob/main/CONTRIBUTING.md#code-quality-requirements),
I ran the following locally and they all pass:
- [ ] `cargo fmt --all -- --check`
- [ ] `cargo clippy --all-targets --all-features -- -D warnings`
- [ ] `cargo test --all-features`
- [ ] `cargo build --release`
<!-- If any of these did not run, say which and why. CI will also run
them, but local verification catches issues faster. -->
## Scope
- [ ] One feature or fix per PR (no unrelated cleanups bundled in)
- [ ] No new dependencies, or rationale provided in the summary above
- [ ] No `#[allow(clippy::...)]` suppressions, or rationale provided
(see [CONTRIBUTING.md](https://github.com/domcyrus/rustnet/blob/main/CONTRIBUTING.md#code-quality-requirements))
## AI-assisted contributions
If you used an AI assistant (Copilot, Claude, ChatGPT, Cursor, etc.) to
write any of this code, that is fine, but please confirm:
- [ ] I have read every line I am submitting
- [ ] I ran the verification commands above myself
- [ ] The PR description reflects what the code actually does
## Notes for the reviewer
<!-- Anything that would help review: tricky edge cases, deliberate
trade-offs, follow-up work you plan to do in a separate PR. -->
+221
View File
@@ -0,0 +1,221 @@
name: Update AUR Package
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
jobs:
update-aur:
name: update-aur
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Extract version from tag
id: version
run: |
# Determine the tag based on trigger type
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
# Get the latest tag when manually triggered
git fetch --tags
TAG=$(git tag --sort=-version:refname | head -n1)
else
# For release event, use the release tag
TAG="${{ github.event.release.tag_name }}"
fi
# Extract version by removing 'v' prefix
VERSION="${TAG#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "Extracted version: $VERSION"
echo "Tag: $TAG"
- name: Download release assets and calculate checksums
id: checksums
run: |
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.tag }}"
# Define asset names
ASSET_X64="rustnet-${TAG}-x86_64-unknown-linux-gnu.tar.gz"
ASSET_ARM64="rustnet-${TAG}-aarch64-unknown-linux-gnu.tar.gz"
echo "Downloading release assets..."
# Download assets
gh release download "$TAG" -p "$ASSET_X64" -p "$ASSET_ARM64"
# Calculate SHA256 checksums
SHA256_X64=$(sha256sum "$ASSET_X64" | awk '{print $1}')
SHA256_ARM64=$(sha256sum "$ASSET_ARM64" | awk '{print $1}')
echo "x64_checksum=$SHA256_X64" >> $GITHUB_OUTPUT
echo "arm64_checksum=$SHA256_ARM64" >> $GITHUB_OUTPUT
echo "Checksums calculated:"
echo " x86_64: $SHA256_X64"
echo " aarch64: $SHA256_ARM64"
env:
GH_TOKEN: ${{ github.token }}
- name: Verify checksums match release assets
run: |
TAG="${{ steps.version.outputs.tag }}"
EXPECTED_X64="${{ steps.checksums.outputs.x64_checksum }}"
EXPECTED_ARM64="${{ steps.checksums.outputs.arm64_checksum }}"
FAILED=0
echo "Re-downloading assets to verify checksums are stable..."
ASSET_X64="rustnet-${TAG}-x86_64-unknown-linux-gnu.tar.gz"
ASSET_ARM64="rustnet-${TAG}-aarch64-unknown-linux-gnu.tar.gz"
gh release download "$TAG" -p "$ASSET_X64" -p "$ASSET_ARM64" -D /tmp/verify
ACTUAL_X64=$(sha256sum "/tmp/verify/$ASSET_X64" | awk '{print $1}')
ACTUAL_ARM64=$(sha256sum "/tmp/verify/$ASSET_ARM64" | awk '{print $1}')
rm -rf /tmp/verify
if [ "$ACTUAL_X64" != "$EXPECTED_X64" ]; then
echo "::error::x86_64 checksum mismatch: expected $EXPECTED_X64, got $ACTUAL_X64"
FAILED=1
else
echo " ✓ x86_64 checksum verified"
fi
if [ "$ACTUAL_ARM64" != "$EXPECTED_ARM64" ]; then
echo "::error::aarch64 checksum mismatch: expected $EXPECTED_ARM64, got $ACTUAL_ARM64"
FAILED=1
else
echo " ✓ aarch64 checksum verified"
fi
if [ "$FAILED" -ne 0 ]; then
echo "::error::Checksum verification failed — aborting to prevent pushing stale checksums to AUR"
exit 1
fi
echo "All checksums verified successfully"
env:
GH_TOKEN: ${{ github.token }}
- name: Setup SSH for AUR
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
# Pass the key via env, not inline interpolation, so its bytes are never
# spliced into the rendered command line (matches the GPG handling in
# ppa-release.yml). printf avoids echo's escape interpretation.
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur
chmod 600 ~/.ssh/aur
ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts
# Configure SSH to use the AUR key
cat > ~/.ssh/config <<EOF
Host aur.archlinux.org
IdentityFile ~/.ssh/aur
User aur
EOF
- name: Clone AUR repository
run: |
git clone ssh://aur@aur.archlinux.org/rustnet-bin.git aur-rustnet-bin
cd aur-rustnet-bin
# Configure git
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Update PKGBUILD
run: |
cd aur-rustnet-bin
VERSION="${{ steps.version.outputs.version }}"
TAG="${{ steps.version.outputs.tag }}"
SHA256_X64="${{ steps.checksums.outputs.x64_checksum }}"
SHA256_ARM64="${{ steps.checksums.outputs.arm64_checksum }}"
echo "Updating PKGBUILD to version $VERSION..."
# Update version and release
sed -i "s/^pkgver=.*/pkgver=$VERSION/" PKGBUILD
sed -i "s/^pkgrel=.*/pkgrel=1/" PKGBUILD
# Update source URLs (need to escape special characters for sed)
ESCAPED_TAG=$(echo "$TAG" | sed 's/[\/&]/\\&/g')
sed -i "s|rustnet-v[0-9.]*-\${arch}|rustnet-${ESCAPED_TAG}-\${arch}|g" PKGBUILD
# Update checksums
sed -i "s/sha256sums_x86_64=.*/sha256sums_x86_64=('$SHA256_X64')/" PKGBUILD
sed -i "s/sha256sums_aarch64=.*/sha256sums_aarch64=('$SHA256_ARM64')/" PKGBUILD
echo "PKGBUILD updated successfully"
echo ""
echo "Version info:"
grep "^pkgver=" PKGBUILD
grep "^pkgrel=" PKGBUILD
echo ""
echo "Checksums:"
grep "sha256sums_" PKGBUILD
- name: Generate .SRCINFO
run: |
# Use Arch Linux container to run makepkg as the host user
docker run --rm -v "$PWD/aur-rustnet-bin:/pkg" --user $(id -u):$(id -g) archlinux:latest bash -c '
cd /pkg &&
makepkg --printsrcinfo > .SRCINFO
' || {
# If that fails (no write permissions), install tools as root then run as user
docker run --rm -v "$PWD/aur-rustnet-bin:/pkg" archlinux:latest bash -c "
pacman -Sy --noconfirm binutils fakeroot sudo &&
useradd -m -u $(id -u) builder &&
cd /pkg &&
su builder -c 'makepkg --printsrcinfo > .SRCINFO'
"
}
echo ".SRCINFO generated successfully"
- name: Commit and push to AUR
run: |
cd aur-rustnet-bin
VERSION="${{ steps.version.outputs.version }}"
# Check if there are changes to commit
if git diff --quiet PKGBUILD .SRCINFO; then
echo "No changes to PKGBUILD or .SRCINFO (already at version $VERSION)"
exit 0
fi
# Show changes
echo "Changes to be committed:"
git diff PKGBUILD .SRCINFO
# Commit and push
git add PKGBUILD .SRCINFO
git commit -m "Update to version $VERSION"
git push origin master
echo "Pushed changes to AUR repository"
- name: Summary
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "## 🎉 AUR Package Updated" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Package**: rustnet-bin" >> $GITHUB_STEP_SUMMARY
echo "- **Version**: $VERSION" >> $GITHUB_STEP_SUMMARY
echo "- **Architectures**: x86_64, aarch64" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Checksums" >> $GITHUB_STEP_SUMMARY
echo "- **x86_64**: \`${{ steps.checksums.outputs.x64_checksum }}\`" >> $GITHUB_STEP_SUMMARY
echo "- **aarch64**: \`${{ steps.checksums.outputs.arm64_checksum }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "[View AUR Package →](https://aur.archlinux.org/packages/rustnet-bin)" >> $GITHUB_STEP_SUMMARY
+222
View File
@@ -0,0 +1,222 @@
name: Build Android Musl Static
# One-off workflow to build and upload android musl static binaries
# for multiple architectures to an existing release.
#
# Supported architectures:
# aarch64 - native on ARM runner (existing)
# x86_64 - native on x86_64 runner
# armv7 - cross-compiled on x86_64 using musl.cc armv7l-linux-musleabihf toolchain
# x86 - cross-compiled on x86_64 using musl.cc i686-linux-musl toolchain
on:
workflow_dispatch:
inputs:
version:
description: 'Release tag (e.g., v1.0.0)'
required: true
ref:
description: 'Git ref to build from (default: tag)'
required: false
default: ''
permissions:
contents: write
jobs:
build-android:
name: build-${{ matrix.arch }}-android-static
runs-on: ${{ matrix.runner }}
container:
image: rust:alpine
strategy:
fail-fast: false
matrix:
include:
- arch: aarch64
runner: ubuntu-24.04-arm
artifact: aarch64-linux-android-musl
outline-atomics: true
- arch: x86_64
runner: ubuntu-latest
artifact: x86_64-linux-android-musl
outline-atomics: false
- arch: armv7
runner: ubuntu-latest
artifact: armv7-linux-android-musl
outline-atomics: true
- arch: x86
runner: ubuntu-latest
artifact: i686-linux-android-musl
outline-atomics: false
steps:
# aarch64: JS actions don't work in Alpine containers on ARM runners
- name: Checkout repository (aarch64 workaround)
if: matrix.arch == 'aarch64'
run: |
apk add --no-cache git
git config --global --add safe.directory /__w/rustnet/rustnet
git clone --depth 1 https://github.com/${{ github.repository }}.git .
git fetch --depth 1 origin tag "${{ inputs.version }}"
git checkout "${{ inputs.version }}"
- name: Checkout repository
if: matrix.arch != 'aarch64'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ inputs.version }}
- name: Install common dependencies
run: |
apk add --no-cache \
musl-dev pkgconfig build-base perl \
zlib-dev zlib-static \
clang llvm linux-headers github-cli curl
rustup component add rustfmt
# aarch64/x86_64: libpcap-dev provides a native static libpcap.
# zstd-static is required because Alpine's libpcap-dev links against zstd.
- name: Install libpcap (native)
if: matrix.arch == 'aarch64' || matrix.arch == 'x86_64'
run: apk add --no-cache libpcap-dev elfutils-dev zstd-dev zstd-static
# armv7: use zig cc as the cross-compiler (no external toolchain download needed).
# zig bundles musl libc for all targets, so no -l:libzstd.a needed.
- name: Install cross-compiler and libpcap (armv7)
if: matrix.arch == 'armv7'
run: |
rustup target add armv7-unknown-linux-musleabihf
apk add zig flex bison bash
# zig cc wrapper: cc-rs detects it as clang and passes --target=armv7-*
# which zig rejects (zig uses 'arm' not 'armv7'). Filter those args out.
printf '%s\n' \
'#!/bin/bash' \
'args=()' \
'for arg in "$@"; do' \
' case "$arg" in --target=*) ;; *) args+=("$arg") ;; esac' \
'done' \
'exec zig cc -target arm-linux-musleabihf "${args[@]}"' \
> /usr/local/bin/arm-linux-musleabihf-gcc
chmod +x /usr/local/bin/arm-linux-musleabihf-gcc
# libpcap is pinned by version + sha256 (Dependabot does not track
# tarballs fetched in run steps, so bump the URL and the hash together).
curl -fsSL --retry 3 --retry-delay 5 \
-o libpcap.tar.gz https://www.tcpdump.org/release/libpcap-1.10.5.tar.gz
echo "37ced90a19a302a7f32e458224a00c365c117905c2cd35ac544b6880a81488f0 libpcap.tar.gz" | sha256sum -c -
tar xzf libpcap.tar.gz
cd libpcap-1.10.5
CC=arm-linux-musleabihf-gcc \
./configure --host=arm-linux-musleabihf \
--disable-shared --enable-static \
--disable-usb --disable-dbus --disable-bluetooth --disable-remote \
--prefix=/arm-sysroot
make -j$(nproc) && make install
cd ..
# x86: use zig cc as the cross-compiler (no external toolchain download needed).
# zig bundles musl libc for all targets, so no -l:libzstd.a needed.
- name: Install cross-compiler and libpcap (x86/i686)
if: matrix.arch == 'x86'
run: |
rustup target add i686-unknown-linux-musl
apk add zig flex bison bash
# zig cc wrapper: cc-rs passes --target=i686-* which zig doesn't accept;
# filter those args and use -target x86-linux-musl instead.
printf '%s\n' \
'#!/bin/bash' \
'args=()' \
'for arg in "$@"; do' \
' case "$arg" in --target=*) ;; *) args+=("$arg") ;; esac' \
'done' \
'exec zig cc -target x86-linux-musl "${args[@]}"' \
> /usr/local/bin/i686-linux-musl-gcc
chmod +x /usr/local/bin/i686-linux-musl-gcc
# libpcap is pinned by version + sha256 (Dependabot does not track
# tarballs fetched in run steps, so bump the URL and the hash together).
curl -fsSL --retry 3 --retry-delay 5 \
-o libpcap.tar.gz https://www.tcpdump.org/release/libpcap-1.10.5.tar.gz
echo "37ced90a19a302a7f32e458224a00c365c117905c2cd35ac544b6880a81488f0 libpcap.tar.gz" | sha256sum -c -
tar xzf libpcap.tar.gz
cd libpcap-1.10.5
CC=i686-linux-musl-gcc \
./configure --host=i686-linux-musl \
--disable-shared --enable-static \
--disable-usb --disable-dbus --disable-bluetooth --disable-remote \
--prefix=/x86-sysroot
make -j$(nproc) && make install
cd ..
- name: Build static binary (aarch64)
if: matrix.arch == 'aarch64'
env:
CFLAGS: "-mno-outline-atomics"
CXXFLAGS: "-mno-outline-atomics"
RUSTFLAGS: "-C strip=symbols -C link-arg=-l:libzstd.a"
run: cargo build --release --no-default-features
- name: Build static binary (x86_64)
if: matrix.arch == 'x86_64'
env:
RUSTFLAGS: "-C strip=symbols -C link-arg=-l:libzstd.a"
run: cargo build --release --no-default-features
- name: Build static binary (armv7)
if: matrix.arch == 'armv7'
env:
# link-self-contained=no: let zig provide musl CRT (avoid duplicate _start
# when both Rust's self-contained crt1.o and zig's crt1.o are linked)
RUSTFLAGS: "-C strip=symbols -C link-self-contained=no"
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER: arm-linux-musleabihf-gcc
PKG_CONFIG_PATH: /arm-sysroot/lib/pkgconfig
PKG_CONFIG_SYSROOT_DIR: /arm-sysroot
PKG_CONFIG_ALLOW_CROSS: "1"
run: cargo build --release --target armv7-unknown-linux-musleabihf --no-default-features
- name: Build static binary (x86/i686)
if: matrix.arch == 'x86'
env:
RUSTFLAGS: "-C strip=symbols -C link-self-contained=no"
CARGO_TARGET_I686_UNKNOWN_LINUX_MUSL_LINKER: i686-linux-musl-gcc
PKG_CONFIG_PATH: /x86-sysroot/lib/pkgconfig
PKG_CONFIG_SYSROOT_DIR: /x86-sysroot
PKG_CONFIG_ALLOW_CROSS: "1"
run: cargo build --release --target i686-unknown-linux-musl --no-default-features
- name: Verify static linking
run: |
BIN="${{ (matrix.arch == 'armv7' && 'target/armv7-unknown-linux-musleabihf/release/rustnet') || (matrix.arch == 'x86' && 'target/i686-unknown-linux-musl/release/rustnet') || 'target/release/rustnet' }}"
file "$BIN"
file "$BIN" | grep -q "static.* linked" || \
(echo "ERROR: Binary is not statically linked" && exit 1)
- name: Create release archive
run: |
VERSION="${{ inputs.version }}"
ARTIFACT="${{ matrix.artifact }}"
case "${{ matrix.arch }}" in
armv7) BIN="target/armv7-unknown-linux-musleabihf/release/rustnet" ;;
x86) BIN="target/i686-unknown-linux-musl/release/rustnet" ;;
*) BIN="target/release/rustnet" ;;
esac
staging="rustnet-${VERSION}-${ARTIFACT}"
mkdir -p "$staging/assets"
cp "$BIN" "$staging/rustnet"
cp crates/rustnet-core/assets/services "$staging/assets/" 2>/dev/null || true
cp README.md "$staging/"
cp LICENSE "$staging/" 2>/dev/null || true
tar czf "$staging.tar.gz" "$staging"
- name: Upload to release
run: |
VERSION="${{ inputs.version }}"
ARCHIVE="rustnet-${VERSION}-${{ matrix.artifact }}.tar.gz"
gh release upload "$VERSION" "$ARCHIVE" --repo "${{ github.repository }}" --clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+73
View File
@@ -0,0 +1,73 @@
name: Build ARM64 Musl Static
# One-off workflow to build and upload aarch64 musl static binary
# to an existing release. Used when the regular release pipeline
# fails to upload this artifact.
on:
workflow_dispatch:
inputs:
version:
description: 'Release tag (e.g., v1.0.0)'
required: true
ref:
description: 'Git ref to build from (default: tag)'
required: false
default: ''
permissions:
contents: write
jobs:
build-musl-arm64:
name: build-aarch64-musl-static
runs-on: ubuntu-24.04-arm
container:
image: rust:alpine
steps:
- name: Checkout repository
run: |
apk add --no-cache git
git config --global --add safe.directory /__w/rustnet/rustnet
REF="${{ inputs.ref || inputs.version }}"
git clone --depth 1 https://github.com/${{ github.repository }}.git .
git fetch --depth 1 origin tag "${{ inputs.version }}"
git checkout "${{ inputs.version }}"
- name: Install dependencies
run: |
apk add --no-cache \
musl-dev libpcap-dev pkgconfig build-base perl \
elfutils-dev zlib-dev zlib-static zstd-dev zstd-static \
clang llvm linux-headers github-cli
rustup component add rustfmt
- name: Build static binary
env:
CFLAGS: "-mno-outline-atomics"
CXXFLAGS: "-mno-outline-atomics"
RUSTFLAGS: "-C strip=symbols -C link-arg=-l:libzstd.a"
run: |
cargo build --release
file target/release/rustnet
file target/release/rustnet | grep -q "static.* linked" || \
(echo "ERROR: Binary is not statically linked" && exit 1)
- name: Create release archive
run: |
VERSION="${{ inputs.version }}"
staging="rustnet-${VERSION}-aarch64-unknown-linux-musl"
mkdir -p "$staging/assets"
cp target/release/rustnet "$staging/"
cp crates/rustnet-core/assets/services "$staging/assets/" 2>/dev/null || true
cp README.md "$staging/"
cp LICENSE "$staging/" 2>/dev/null || true
tar czf "$staging.tar.gz" "$staging"
- name: Upload to release
run: |
VERSION="${{ inputs.version }}"
ARCHIVE="rustnet-${VERSION}-aarch64-unknown-linux-musl.tar.gz"
gh release upload "$VERSION" "$ARCHIVE" --clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+353
View File
@@ -0,0 +1,353 @@
name: Build Platforms
# Reusable workflow for building RustNet on all platforms.
# Called by both test-platform-builds.yml and release.yml
on:
workflow_call:
inputs:
create-archives:
description: 'Create release archives with README/LICENSE (true for release, false for test)'
type: boolean
default: false
strip-symbols:
description: 'Strip debug symbols from binaries'
type: boolean
default: false
version:
description: 'Version string for archive naming (e.g., v0.3.0)'
type: string
default: ''
env:
RUST_BACKTRACE: 1
CARGO_TERM_COLOR: always
jobs:
build:
permissions:
contents: read
name: build-${{ matrix.build }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
build:
- linux-x64-gnu
- linux-aarch64-gnu
- linux-armv7-gnueabihf
- macos-aarch64
- macos-x64
- windows-x64-msvc
- windows-x86-msvc
include:
- os: ubuntu-22.04
- cargo: cargo
- build: linux-x64-gnu
target: x86_64-unknown-linux-gnu
run-tests: true
- build: linux-aarch64-gnu
target: aarch64-unknown-linux-gnu
cargo: cross
- build: linux-armv7-gnueabihf
target: armv7-unknown-linux-gnueabihf
cargo: cross
- build: macos-aarch64
os: macos-14
target: aarch64-apple-darwin
run-tests: true
- build: macos-x64
os: macos-14
target: x86_64-apple-darwin
- build: windows-x64-msvc
os: windows-latest
target: x86_64-pc-windows-msvc
- build: windows-x86-msvc
os: windows-latest
target: i686-pc-windows-msvc
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Linux dependencies
if: matrix.os == 'ubuntu-22.04'
uses: ./.github/actions/setup-linux-deps
- name: Install Rust
uses: dtolnay/rust-toolchain@stable # unpinned: maintained branch by Rust team member
with:
targets: ${{ matrix.target }}
components: rustfmt
- name: Install cross
if: matrix.cargo == 'cross'
run: cargo install cross@0.2.5
- name: Build
uses: ./.github/actions/build-rustnet
with:
target: ${{ matrix.target }}
cargo-command: ${{ matrix.cargo }}
default-features: ${{ contains(matrix.target, 'linux') && 'true' || 'false' }}
strip-symbols: ${{ inputs.strip-symbols && 'true' || 'false' }}
- name: Run tests
if: matrix.run-tests
run: cargo test --verbose
# For test builds: upload raw binary
- name: Upload binary artifact
if: ${{ !inputs.create-archives }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: rustnet-${{ matrix.build }}
path: target/${{ matrix.target }}/release/rustnet${{ contains(matrix.os, 'windows') && '.exe' || '' }}
if-no-files-found: error
# For release builds: create archive with README/LICENSE
- name: Create release archive
if: ${{ inputs.create-archives }}
shell: bash
env:
RUSTNET_BIN: ${{ contains(matrix.os, 'windows') && 'rustnet.exe' || 'rustnet' }}
run: |
staging="rustnet-${{ inputs.version }}-${{ matrix.target }}"
mkdir -p "$staging"
cp "target/${{ matrix.target }}/release/$RUSTNET_BIN" "$staging/"
if [ -d "crates/rustnet-core/assets" ] && [ -f "crates/rustnet-core/assets/services" ]; then
mkdir -p "$staging/assets"
cp "crates/rustnet-core/assets/services" "$staging/assets/"
fi
cp README.md "$staging/"
cp LICENSE "$staging/" 2>/dev/null || true
if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
7z a "$staging.zip" "$staging"
echo "ASSET=$staging.zip" >> $GITHUB_ENV
else
tar czf "$staging.tar.gz" "$staging"
echo "ASSET=$staging.tar.gz" >> $GITHUB_ENV
fi
- name: Upload release archive
if: ${{ inputs.create-archives }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: build-${{ matrix.target }}
path: ${{ env.ASSET }}
if-no-files-found: error
build-static:
permissions:
contents: read
name: build-linux-static-${{ matrix.arch }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: x86_64
runner: ubuntu-latest
target: x86_64-unknown-linux-musl
- arch: aarch64
runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
features: '--features default'
- arch: aarch64-android
runner: ubuntu-24.04-arm
target: aarch64-linux-android-musl # Note: it's not a real rust target but we rename the artifact
features: '--no-default-features'
- arch: x86_64-android
runner: ubuntu-latest
target: x86_64-linux-android-musl
features: '--no-default-features'
- arch: armv7-android
runner: ubuntu-latest
target: armv7-linux-android-musl
features: '--no-default-features'
- arch: x86-android
runner: ubuntu-latest
target: i686-linux-android-musl
features: '--no-default-features'
container:
image: rust:alpine
steps:
# x86_64: use standard checkout action
- name: Checkout repository
if: matrix.arch == 'x86_64' || matrix.arch == 'x86_64-android' || matrix.arch == 'armv7-android' || matrix.arch == 'x86-android'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# aarch64: JS actions don't work in Alpine containers on ARM runners
- name: Checkout repository (ARM workaround)
if: contains(matrix.arch, 'aarch64')
run: |
apk add --no-cache git
git config --global --add safe.directory /__w/rustnet/rustnet
git clone --depth 1 https://github.com/${{ github.repository }}.git .
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
git fetch --depth 1 origin tag "${{ github.ref_name }}"
git checkout "${{ github.ref_name }}"
fi
# x86_64: use composite action
- name: Build static binary
if: matrix.arch == 'x86_64'
uses: ./.github/actions/build-static
# aarch64 & android: inline build (composite actions don't work with ARM workaround/zig wrapper easily here)
- name: Install dependencies (ARM & Android)
if: matrix.arch != 'x86_64'
run: |
apk add --no-cache \
musl-dev libpcap-dev pkgconfig build-base perl \
elfutils-dev zlib-dev zlib-static zstd-dev zstd-static \
clang llvm linux-headers curl github-cli
rustup component add rustfmt
- name: Install cross-compiler and libpcap (armv7)
if: matrix.arch == 'armv7-android'
run: |
rustup target add armv7-unknown-linux-musleabihf
apk add zig flex bison bash
printf '%s\n' \
'#!/bin/bash' \
'args=()' \
'for arg in "$@"; do' \
' case "$arg" in --target=*) ;; *) args+=("$arg") ;; esac' \
'done' \
'exec zig cc -target arm-linux-musleabihf "${args[@]}"' \
> /usr/local/bin/arm-linux-musleabihf-gcc
chmod +x /usr/local/bin/arm-linux-musleabihf-gcc
# libpcap is pinned by version + sha256 (Dependabot does not track
# tarballs fetched in run steps, so bump the URL and the hash together).
curl -fsSL --retry 3 --retry-delay 5 \
-o libpcap.tar.gz https://www.tcpdump.org/release/libpcap-1.10.5.tar.gz
echo "37ced90a19a302a7f32e458224a00c365c117905c2cd35ac544b6880a81488f0 libpcap.tar.gz" | sha256sum -c -
tar xzf libpcap.tar.gz
cd libpcap-1.10.5
CC=arm-linux-musleabihf-gcc \
./configure --host=arm-linux-musleabihf \
--disable-shared --enable-static \
--disable-usb --disable-dbus --disable-bluetooth --disable-remote \
--prefix=/arm-sysroot
make -j$(nproc) && make install
cd ..
- name: Install cross-compiler and libpcap (x86/i686)
if: matrix.arch == 'x86-android'
run: |
rustup target add i686-unknown-linux-musl
apk add zig flex bison bash
printf '%s\n' \
'#!/bin/bash' \
'args=()' \
'for arg in "$@"; do' \
' case "$arg" in --target=*) ;; -Wl,-melf_i386) ;; *) args+=("$arg") ;; esac' \
'done' \
'exec zig cc -target x86-linux-musl "${args[@]}"' \
> /usr/local/bin/i686-linux-musl-gcc
chmod +x /usr/local/bin/i686-linux-musl-gcc
# libpcap is pinned by version + sha256 (Dependabot does not track
# tarballs fetched in run steps, so bump the URL and the hash together).
curl -fsSL --retry 3 --retry-delay 5 \
-o libpcap.tar.gz https://www.tcpdump.org/release/libpcap-1.10.5.tar.gz
echo "37ced90a19a302a7f32e458224a00c365c117905c2cd35ac544b6880a81488f0 libpcap.tar.gz" | sha256sum -c -
tar xzf libpcap.tar.gz
cd libpcap-1.10.5
CC=i686-linux-musl-gcc \
./configure --host=i686-linux-musl \
--disable-shared --enable-static \
--disable-usb --disable-dbus --disable-bluetooth --disable-remote \
--prefix=/x86-sysroot
make -j$(nproc) && make install
cd ..
- name: Build static binary (AArch64 / AArch64 Android)
if: matrix.arch == 'aarch64' || matrix.arch == 'aarch64-android'
env:
# -mno-outline-atomics: Prevent GCC from generating calls to __aarch64_ldadd4_sync etc.
# which aren't in Alpine's libatomic. Uses inline atomics instead.
CFLAGS: "-mno-outline-atomics"
CXXFLAGS: "-mno-outline-atomics"
RUSTFLAGS: "-C strip=symbols -C link-arg=-l:libzstd.a"
run: cargo build --release ${{ matrix.features }}
- name: Build static binary (x86_64 Android)
if: matrix.arch == 'x86_64-android'
env:
RUSTFLAGS: "-C strip=symbols -C link-arg=-l:libzstd.a"
run: cargo build --release ${{ matrix.features }}
- name: Build static binary (armv7-android)
if: matrix.arch == 'armv7-android'
env:
RUSTFLAGS: "-C strip=symbols -C link-self-contained=no"
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER: arm-linux-musleabihf-gcc
PKG_CONFIG_PATH: /arm-sysroot/lib/pkgconfig
PKG_CONFIG_SYSROOT_DIR: /arm-sysroot
PKG_CONFIG_ALLOW_CROSS: "1"
run: cargo build --release --target armv7-unknown-linux-musleabihf ${{ matrix.features }}
- name: Build static binary (x86-android)
if: matrix.arch == 'x86-android'
env:
RUSTFLAGS: "-C strip=symbols -C link-self-contained=no"
CARGO_TARGET_I686_UNKNOWN_LINUX_MUSL_LINKER: i686-linux-musl-gcc
PKG_CONFIG_PATH: /x86-sysroot/lib/pkgconfig
PKG_CONFIG_SYSROOT_DIR: /x86-sysroot
PKG_CONFIG_ALLOW_CROSS: "1"
run: cargo build --release --target i686-unknown-linux-musl ${{ matrix.features }}
- name: Verify static linking (ARM & Android)
if: matrix.arch != 'x86_64'
run: |
BIN="${{ (matrix.arch == 'armv7-android' && 'target/armv7-unknown-linux-musleabihf/release/rustnet') || (matrix.arch == 'x86-android' && 'target/i686-unknown-linux-musl/release/rustnet') || 'target/release/rustnet' }}"
file "$BIN"
file "$BIN" | grep -q "static.* linked" || \
(echo "ERROR: Binary is not statically linked" && exit 1)
# For test builds: upload raw binary
- name: Upload binary artifact
if: ${{ !inputs.create-archives }}
continue-on-error: ${{ matrix.arch != 'x86_64' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: rustnet-linux-static-${{ matrix.arch }}
path: ${{ (matrix.arch == 'armv7-android' && 'target/armv7-unknown-linux-musleabihf/release/rustnet') || (matrix.arch == 'x86-android' && 'target/i686-unknown-linux-musl/release/rustnet') || 'target/release/rustnet' }}
if-no-files-found: error
# For release builds: create archive
- name: Create release archive
if: ${{ inputs.create-archives }}
run: |
BIN="${{ (matrix.arch == 'armv7-android' && 'target/armv7-unknown-linux-musleabihf/release/rustnet') || (matrix.arch == 'x86-android' && 'target/i686-unknown-linux-musl/release/rustnet') || 'target/release/rustnet' }}"
staging="rustnet-${{ inputs.version }}-${{ matrix.target }}"
mkdir -p "$staging/assets"
cp "$BIN" "$staging/rustnet"
cp crates/rustnet-core/assets/services "$staging/assets/" 2>/dev/null || true
cp README.md "$staging/"
cp LICENSE "$staging/" 2>/dev/null || true
tar czf "$staging.tar.gz" "$staging"
- name: Upload release archive
if: ${{ inputs.create-archives }}
# JS actions don't work in Alpine containers on ARM runners;
# release.yml has dedicated upload-arm-static/upload-android-static jobs as fallback
continue-on-error: ${{ contains(matrix.arch, 'aarch64') }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: build-${{ matrix.target }}
path: rustnet-${{ inputs.version }}-${{ matrix.target }}.tar.gz
if-no-files-found: error
+72
View File
@@ -0,0 +1,72 @@
name: Update COPR Spec Version
on:
workflow_call:
workflow_dispatch:
permissions:
contents: write
jobs:
update-spec:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version from tag
id: version
run: |
# Extract version by removing 'v' prefix
VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Extracted version: $VERSION"
- name: Update RPM spec file
run: |
VERSION="${{ steps.version.outputs.version }}"
SPEC_FILE="rpm/rustnet.spec"
# Update the Version: line in the spec file
sed -i "s/^Version:.*/Version: $VERSION/" "$SPEC_FILE"
echo "Updated $SPEC_FILE to version $VERSION"
echo ""
echo "Version line:"
grep "^Version:" "$SPEC_FILE"
- name: Commit and push changes
run: |
VERSION="${{ steps.version.outputs.version }}"
# Configure git
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Check if there are changes to commit
if git diff --quiet rpm/rustnet.spec; then
echo "No changes to rpm/rustnet.spec (already at version $VERSION)"
exit 0
fi
# Commit and push
git add rpm/rustnet.spec
git commit -m "chore: update RPM spec to version $VERSION"
git push origin HEAD:main
echo "Pushed changes to main branch"
- name: Summary
run: |
echo "## 🎉 RPM Spec Updated" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Version**: ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "- **File**: rpm/rustnet.spec" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "COPR will automatically rebuild the package via webhook." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "[View COPR Builds →](https://copr.fedorainfracloud.org/coprs/domcyrus/rustnet/builds/)" >> $GITHUB_STEP_SUMMARY
+72
View File
@@ -0,0 +1,72 @@
name: Docker Build and Publish
on:
workflow_call:
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Log in to Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Enable Kubernetes pod/container attribution in the container image.
# It is dependency-free and runtime-gated (`--kubernetes auto`, the
# default, is inert outside a pod), so a single image serves both
# general container use and kubectl-rustnet. Native installs
# (cargo/brew/deb/rpm) leave the feature off and stay lean.
build-args: |
CARGO_FEATURES=kubernetes
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Generate artifact attestation
if: github.event_name != 'pull_request'
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
subject-digest: ${{ steps.build-and-push.outputs.digest }}
push-to-registry: true
+146
View File
@@ -0,0 +1,146 @@
name: Release to OBS
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
jobs:
release-obs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Get version
id: version
run: |
# Extract version by removing 'v' prefix if it exists, fallback to Cargo.toml
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/v}
else
# Read the binary's [package] version, not [workspace.package] (the
# latter is the 0.x library version and comes first in Cargo.toml).
VERSION=$(awk -F'"' '/^\[package\]/{p=1} p && /^version = /{print $2; exit}' Cargo.toml)
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Setup Linux dependencies
uses: ./.github/actions/setup-linux-deps
- name: Install Rust
uses: dtolnay/rust-toolchain@stable # unpinned: maintained branch by Rust team member
with:
toolchain: stable
- name: Install OBS dependencies
run: |
sudo apt-get update
sudo apt-get install -y osc obs-build build-essential
- name: Configure OBS credentials
run: |
cat <<EOF > ~/.oscrc
[general]
apiurl = https://api.opensuse.org
[https://api.opensuse.org]
user = ${{ secrets.OBS_USER }}
pass = ${{ secrets.OBS_PASS }}
EOF
chmod 600 ~/.oscrc
- name: Download source tarball
run: |
VERSION="${{ steps.version.outputs.version }}"
PACKAGE_NAME="rustnet"
# Output filename must match Source0's basename (v%{version}.tar.gz)
# so the offline OBS build can resolve it; the internal dir prefix
# stays rustnet-%{version}/ to match %autosetup -n.
RELEASE_TAG="v${VERSION}"
if git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then
echo "✓ Found release tag: $RELEASE_TAG"
git archive --format=tar --prefix="${PACKAGE_NAME}-${VERSION}/" "$RELEASE_TAG" | gzip > "v${VERSION}.tar.gz"
else
echo "⚠ Release tag $RELEASE_TAG not found, using HEAD"
git archive --format=tar --prefix="${PACKAGE_NAME}-${VERSION}/" HEAD | gzip > "v${VERSION}.tar.gz"
fi
- name: Vendor dependencies
run: |
VERSION="${{ steps.version.outputs.version }}"
PACKAGE_NAME="rustnet"
RELEASE_TAG="v${VERSION}"
mkdir -p build-vendor
if git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then
git archive --format=tar "$RELEASE_TAG" | tar -x -C build-vendor
else
git archive --format=tar HEAD | tar -x -C build-vendor
fi
cd build-vendor
# Capture the source-replacement config that cargo prints. The OBS
# build has no network, so it needs this at .cargo/config.toml to
# resolve crates from the vendored directory instead of crates.io.
mkdir -p .cargo
cargo vendor vendor > .cargo/config.toml
# Clean up static libs to reduce size
find vendor -name "*.a" -delete
find vendor -name "*.lib" -delete
# Bundle .cargo/config.toml alongside vendor/ so `%autosetup -a 1`
# drops both into the source tree for the offline build.
tar --use-compress-program="zstd -T0 -10" -cf ../vendor.tar.zst .cargo vendor
cd ..
rm -rf build-vendor
- name: Prepare and push to OBS
run: |
VERSION="${{ steps.version.outputs.version }}"
OBS_PROJECT="${{ secrets.OBS_PROJECT }}"
OBS_PACKAGE="rustnet"
if [ -z "$OBS_PROJECT" ]; then
echo "::error::OBS_PROJECT secret is not set!"
exit 1
fi
# Checkout OBS project
osc checkout "$OBS_PROJECT" "$OBS_PACKAGE"
cd "$OBS_PROJECT/$OBS_PACKAGE"
# Clean old files
rm -f *.tar.gz *.tar.zst *.spec
# Copy new files
cp ../../v${VERSION}.tar.gz .
cp ../../vendor.tar.zst .
cp ../../rpm/rustnet.spec .
# Update version in spec file
sed -i "s/^Version:.*/Version: $VERSION/" rustnet.spec
# Prepend a changelog entry. OBS has no rpmautospec, so the spec's
# %changelog is empty on SUSE and the changelog lives here instead.
STAMP="$(LC_ALL=C date -u '+%a %b %e %T %Z %Y')"
{
printf -- '-------------------------------------------------------------------\n'
printf '%s - %s@opensuse.org\n\n' "$STAMP" "${{ secrets.OBS_USER }}"
printf -- '- Update to version %s\n\n' "$VERSION"
[ -f rustnet.changes ] && cat rustnet.changes
} > rustnet.changes.new
mv rustnet.changes.new rustnet.changes
# Add new files, remove deleted ones
osc addremove
# Commit to OBS
osc commit -m "Update to version $VERSION"
+254
View File
@@ -0,0 +1,254 @@
name: Release to Ubuntu PPA
on:
workflow_call:
inputs:
tarball_suffix:
description: 'Tarball suffix (e.g., ds1, ds2)'
required: false
type: string
default: ''
workflow_dispatch:
inputs:
ubuntu_release:
description: 'Ubuntu release codename'
required: true
default: 'resolute'
type: choice
options:
- questing # 25.10
- resolute # 26.04 LTS
tarball_suffix:
description: 'Tarball suffix (e.g., ds1, ds2) - leave empty for new releases'
required: false
default: ''
type: string
permissions:
contents: read
env:
DEBEMAIL: cadetg@gmail.com
DEBFULLNAME: Marco Cadetg
PPA: ppa:domcyrus/rustnet
jobs:
set-matrix:
runs-on: ubuntu-24.04
outputs:
releases: ${{ steps.set.outputs.releases }}
steps:
- id: set
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo 'releases=["${{ inputs.ubuntu_release }}"]' >> $GITHUB_OUTPUT
echo "Dispatch: building only ${{ inputs.ubuntu_release }}"
else
echo 'releases=["questing","resolute"]' >> $GITHUB_OUTPUT
echo "Auto: building questing and resolute"
fi
build-and-upload:
needs: set-matrix
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
ubuntu_release: ${{ fromJSON(needs.set-matrix.outputs.releases) }}
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
debhelper \
devscripts \
dput \
gnupg \
libpcap-dev \
libelf-dev \
elfutils \
zlib1g-dev \
clang \
llvm \
pkg-config
- name: Install Rust
uses: dtolnay/rust-toolchain@stable # unpinned: maintained branch by Rust team member
with:
toolchain: stable
- name: Import GPG key
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
run: |
echo "$GPG_PRIVATE_KEY" | gpg --batch --import
gpg --list-secret-keys
- name: Get version
id: version
run: |
# Read the binary's [package] version, not [workspace.package]. Since
# the workspace split, Cargo.toml has two `version =` lines and the
# workspace one (0.x libs) comes first, so `head -1` read the wrong
# value. Anchor on the [package] section.
VERSION=$(awk -F'"' '/^\[package\]/{p=1} p && /^version = /{print $2; exit}' Cargo.toml)
# Add tarball suffix if provided (e.g., +ds1, +ds2)
TARBALL_SUFFIX="${{ inputs.tarball_suffix }}"
if [ -n "$TARBALL_SUFFIX" ]; then
TARBALL_VERSION="${VERSION}+${TARBALL_SUFFIX}"
echo "version=$TARBALL_VERSION" >> $GITHUB_OUTPUT
echo "Using tarball version: $TARBALL_VERSION"
else
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Using version: $VERSION"
fi
- name: Update changelog
run: |
VERSION="${{ steps.version.outputs.version }}"
# Per-series version suffix so each Ubuntu release has an independent
# version line on Launchpad. The `+series1` suffix sorts strictly
# higher than a plain `-1ubuntu1`, so future uploads always supersede
# any previously published package.
NEW_VERSION="${VERSION}-1ubuntu1+${{ matrix.ubuntu_release }}1"
DEBFULLNAME="${{ env.DEBFULLNAME }}" DEBEMAIL="${{ env.DEBEMAIL }}" \
dch --newversion "$NEW_VERSION" \
--distribution "${{ matrix.ubuntu_release }}" \
"Build for ${{ matrix.ubuntu_release }} - upstream release $VERSION"
echo "✓ Changelog set to $NEW_VERSION (${{ matrix.ubuntu_release }})"
- name: Pin Rust toolchain version per release
run: |
# Each Ubuntu release ships a different set of versioned rustc/cargo
# packages. We pin to the lowest version available on the target
# release that still satisfies our >= 1.88 floor (let-chains).
case "${{ matrix.ubuntu_release }}" in
questing) RUST_VERSION=1.88 ;;
resolute) RUST_VERSION=1.91 ;;
*) echo "::error::Unknown release ${{ matrix.ubuntu_release }} - add it to the case statement"; exit 1 ;;
esac
echo "Pinning to rustc-$RUST_VERSION / cargo-$RUST_VERSION for ${{ matrix.ubuntu_release }}"
sed -i -E "s/(cargo|rustc|rustdoc)-1\.[0-9]+/\1-${RUST_VERSION}/g" debian/control debian/rules
echo "--- debian/control build-depends ---"
grep -E "(cargo|rustc)-1\." debian/control || true
echo "--- debian/rules toolchain exports ---"
grep -E "(CARGO|RUSTC|RUSTDOC) *=" debian/rules || true
- name: Build source package
run: |
VERSION="${{ steps.version.outputs.version }}"
# Binary [package] version (drives the release tag to check out below).
BASE_VERSION=$(awk -F'"' '/^\[package\]/{p=1} p && /^version = /{print $2; exit}' Cargo.toml)
PACKAGE_NAME="rustnet-monitor"
# Create build directory
mkdir -p build-ppa
# Extract source from release tag
RELEASE_TAG="v${BASE_VERSION}"
if git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then
echo "✓ Found release tag: $RELEASE_TAG"
git archive --format=tar --prefix="${PACKAGE_NAME}-${VERSION}/" "$RELEASE_TAG" | tar -x -C build-ppa
else
echo "⚠ Release tag $RELEASE_TAG not found, using HEAD"
git archive --format=tar --prefix="${PACKAGE_NAME}-${VERSION}/" HEAD | tar -x -C build-ppa
fi
# Vendor dependencies separately from orig tarball
echo "Vendoring Rust dependencies..."
cd build-ppa/${PACKAGE_NAME}-${VERSION}
cargo vendor vendor
# Remove prebuilt static libraries (keep .dll for tests)
echo "Cleaning vendor directory..."
find vendor -name "*.a" -delete
find vendor -name "*.lib" -delete
# Pack vendor directory as separate tarball in debian/
echo "Creating vendor tarball..."
tar -cJf ../vendor.tar.xz vendor
rm -rf vendor
# Create orig tarball (without vendor directory)
echo "Creating orig tarball..."
cd ..
ORIG_TARBALL="${PACKAGE_NAME}_${VERSION}.orig.tar.gz"
tar -czf "${ORIG_TARBALL}" "${PACKAGE_NAME}-${VERSION}"
# Add debian directory and vendor tarball
cp -r "$GITHUB_WORKSPACE/debian" "${PACKAGE_NAME}-${VERSION}/"
mv vendor.tar.xz "${PACKAGE_NAME}-${VERSION}/debian/"
# Build source package
cd "${PACKAGE_NAME}-${VERSION}"
# Always use -sa to include orig tarball
# Launchpad will reuse existing file if hash matches
debuild -S -sa -d -us -uc
- name: Sign and upload
env:
GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }}
run: |
cd build-ppa
# Locate the source.changes file produced by debuild. The version
# suffix (+seriesN) is dynamic, so glob rather than reconstruct.
CHANGES_FILE=$(ls rustnet-monitor_*_source.changes | head -1)
if [ -z "$CHANGES_FILE" ]; then
echo "::error::No source.changes file found in build-ppa"
exit 1
fi
echo "Signing and uploading $CHANGES_FILE"
# Sign
debsign -k${GPG_KEY_ID} ${CHANGES_FILE}
# Verify
gpg --verify ${CHANGES_FILE}
# Upload to PPA
dput ${{ env.PPA }} ${CHANGES_FILE}
- name: Upload artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ppa-source-${{ matrix.ubuntu_release }}
path: |
build-ppa/*.dsc
build-ppa/*.tar.gz
build-ppa/*.tar.xz
build-ppa/*.changes
build-ppa/*.buildinfo
retention-days: 30
- name: Summary
run: |
cd build-ppa
CHANGES_FILE=$(ls rustnet-monitor_*_source.changes | head -1)
FULL_VERSION=$(echo "$CHANGES_FILE" | sed 's/rustnet-monitor_\(.*\)_source\.changes/\1/')
echo "## 🎉 PPA Upload Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Package**: rustnet-monitor" >> $GITHUB_STEP_SUMMARY
echo "- **Version**: $FULL_VERSION" >> $GITHUB_STEP_SUMMARY
echo "- **Ubuntu**: ${{ matrix.ubuntu_release }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Installation" >> $GITHUB_STEP_SUMMARY
echo '```bash' >> $GITHUB_STEP_SUMMARY
echo "sudo add-apt-repository ppa:domcyrus/rustnet" >> $GITHUB_STEP_SUMMARY
echo "sudo apt update && sudo apt install rustnet" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "[View PPA](https://launchpad.net/~domcyrus/+archive/ubuntu/rustnet/+packages)" >> $GITHUB_STEP_SUMMARY
+64
View File
@@ -0,0 +1,64 @@
name: Publish to crates.io
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y libpcap-dev libelf-dev zlib1g-dev clang llvm pkg-config
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable # unpinned: maintained branch by Rust team member
- name: Publish to crates.io
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
run: |
# True if $crate@$VERSION is already on crates.io. `cargo search` does
# fuzzy matching and prints `name = "version" # description`, so we
# anchor on the exact `name = "version"` prefix. A bare `grep VERSION`
# would false-match the version string appearing in a description, and
# `--limit 1` alone isn't guaranteed to be the exact crate.
is_published() {
cargo search "$1" --limit 20 \
| grep -qF "$1 = \"$2\""
}
# The workspace must be published in dependency order: rustnet-core
# first, then the crates that depend on it, then the binary last.
# Each crate's path deps also carry a version, so dependents resolve
# against crates.io once their dependencies are indexed.
for crate in rustnet-core rustnet-capture rustnet-host rustnet-monitor; do
VERSION=$(cargo metadata --no-deps --format-version 1 \
| jq -r --arg n "$crate" '.packages[] | select(.name == $n) | .version')
if is_published "$crate" "$VERSION"; then
echo "⚠️ $crate@$VERSION already published to crates.io, skipping"
continue
fi
echo "📦 Publishing $crate@$VERSION"
cargo publish -p "$crate"
# Wait for the new version to appear in the index before publishing
# a dependent that requires it.
if [ "$crate" != "rustnet-monitor" ]; then
for _ in $(seq 1 30); do
if is_published "$crate" "$VERSION"; then
break
fi
echo "⏳ waiting for $crate@$VERSION to appear on crates.io..."
sleep 10
done
fi
done
+817
View File
@@ -0,0 +1,817 @@
name: Release
# This workflow builds and packages Rustnet for multiple platforms.
#
# For macOS code signing and notarization, configure these GitHub repository secrets:
# - APPLE_DEVELOPER_CERTIFICATE_P12: Base64-encoded .p12 certificate file
# (Export from Keychain: Developer ID Application certificate → Export as .p12 → base64 encode)
# - APPLE_DEVELOPER_CERTIFICATE_PASSWORD: Password for the .p12 file
# - APPLE_ID_ISSUER_ID: App Store Connect API issuer ID (from App Store Connect → Users and Access → Keys)
# - APPLE_ID_KEY_ID: App Store Connect API key ID
# - APPLE_ID_KEY: App Store Connect API key content (.p8 file contents)
#
# Without these secrets, the DMG will be unsigned and users must use "Open Anyway" in
# System Settings → Privacy & Security to bypass Gatekeeper protection.
#
# To obtain Apple Developer credentials:
# 1. Join the Apple Developer Program ($99/year): https://developer.apple.com/programs/
# 2. Create a Developer ID Application certificate in your Apple Developer account
# 3. Create an App Store Connect API key with "Developer" role
# 4. Export certificate as .p12, encode with: base64 -i certificate.p12 | pbcopy
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
workflow_dispatch:
permissions:
contents: write
packages: write
attestations: write
id-token: write
env:
RUST_BACKTRACE: 1
RUSTNET_ASSET_DIR: assets
jobs:
# Build all platforms using shared workflow
build:
uses: ./.github/workflows/build-platforms.yml
with:
create-archives: true
strip-symbols: true
version: ${{ github.ref_name }}
create-release:
name: create-release
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Download all artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
path: artifacts
- name: Extract changelog for release
run: |
# Extract the version-specific section from CHANGELOG.md
VERSION="${{ github.ref_name }}"
# Remove 'v' prefix if present for matching changelog headers
VERSION_NUMBER="${VERSION#v}"
# Extract content between version headers
awk -v version="$VERSION_NUMBER" '
/^## \[/ {
if (found) exit
if ($0 ~ "\\[" version "\\]") {
found=1
next
}
}
found { print }
' CHANGELOG.md > release_notes.md
# Check if we extracted any content
if [ ! -s release_notes.md ]; then
echo "⚠️ No changelog entry found for $VERSION, will use auto-generated notes"
echo "USE_GENERATED_NOTES=true" >> "$GITHUB_ENV"
else
echo "✅ Extracted changelog content for $VERSION"
cat release_notes.md
fi
- name: Create Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Create the release if it doesn't exist
if ! gh release view ${{ github.ref_name }} 2>/dev/null; then
if [ "$USE_GENERATED_NOTES" = "true" ]; then
# Fallback to auto-generated notes if changelog extraction failed
gh release create ${{ github.ref_name }} \
--title "Release ${{ github.ref_name }}" \
--draft \
--generate-notes
else
# Use extracted changelog content
gh release create ${{ github.ref_name }} \
--title "Release ${{ github.ref_name }}" \
--draft \
--notes-file release_notes.md
fi
fi
# Upload all build artifacts
for artifact in artifacts/build-*/rustnet-*; do
gh release upload ${{ github.ref_name }} "$artifact" --clobber
done
# aarch64 static: JS actions don't work in Alpine containers on ARM runners,
# so the reusable workflow can't upload artifacts. Build and upload here instead,
# where we have contents: write from the workflow-level permissions.
upload-arm-static:
name: upload-arm-static
runs-on: ubuntu-24.04-arm
needs: create-release
container:
image: rust:alpine
steps:
- name: Checkout repository (ARM workaround)
run: |
apk add --no-cache git
git config --global --add safe.directory /__w/rustnet/rustnet
git clone --depth 1 "https://github.com/${{ github.repository }}.git" .
# shellcheck disable=SC2193
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
git fetch --depth 1 origin tag "${{ github.ref_name }}"
git checkout "${{ github.ref_name }}"
fi
- name: Install dependencies
run: |
apk add --no-cache \
musl-dev libpcap-dev pkgconfig build-base perl \
elfutils-dev zlib-dev zlib-static zstd-dev zstd-static \
clang llvm linux-headers
rustup component add rustfmt
- name: Build static binary
env:
CFLAGS: "-mno-outline-atomics"
CXXFLAGS: "-mno-outline-atomics"
RUSTFLAGS: "-C strip=symbols -C link-arg=-l:libzstd.a"
run: cargo build --release
- name: Verify static linking
run: |
file target/release/rustnet
file target/release/rustnet | grep -q "static.* linked" || \
(echo "ERROR: Binary is not statically linked" && exit 1)
- name: Create and upload release archive
run: |
staging="rustnet-${{ github.ref_name }}-aarch64-unknown-linux-musl"
mkdir -p "$staging/assets"
cp target/release/rustnet "$staging/"
cp crates/rustnet-core/assets/services "$staging/assets/" 2>/dev/null || true
cp README.md "$staging/"
cp LICENSE "$staging/" 2>/dev/null || true
tar czf "$staging.tar.gz" "$staging"
apk add --no-cache github-cli
gh release upload "${{ github.ref_name }}" "$staging.tar.gz" --clobber
env:
GH_TOKEN: ${{ github.token }}
upload-android-static:
name: upload-${{ matrix.arch }}-android-static
runs-on: ${{ matrix.runner }}
needs: create-release
container:
image: rust:alpine
strategy:
fail-fast: false
matrix:
include:
- arch: aarch64
runner: ubuntu-24.04-arm
artifact: aarch64-linux-android-musl
- arch: x86_64
runner: ubuntu-latest
artifact: x86_64-linux-android-musl
- arch: armv7
runner: ubuntu-latest
artifact: armv7-linux-android-musl
- arch: x86
runner: ubuntu-latest
artifact: i686-linux-android-musl
steps:
# aarch64: JS actions don't work in Alpine containers on ARM runners
- name: Checkout repository (aarch64 workaround)
if: matrix.arch == 'aarch64'
run: |
apk add --no-cache git
git config --global --add safe.directory /__w/rustnet/rustnet
git clone --depth 1 "https://github.com/${{ github.repository }}.git" .
# shellcheck disable=SC2193
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
git fetch --depth 1 origin tag "${{ github.ref_name }}"
git checkout "${{ github.ref_name }}"
fi
- name: Checkout repository
if: matrix.arch != 'aarch64'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install common dependencies
run: |
apk add --no-cache \
musl-dev pkgconfig build-base perl \
zlib-dev zlib-static \
clang llvm linux-headers github-cli curl
rustup component add rustfmt
# aarch64/x86_64: libpcap-dev provides a native static libpcap.
# zstd-static is required because Alpine's libpcap-dev links against zstd.
- name: Install libpcap (native)
if: matrix.arch == 'aarch64' || matrix.arch == 'x86_64'
run: apk add --no-cache libpcap-dev elfutils-dev zstd-dev zstd-static
# armv7: use zig cc as the cross-compiler (no external toolchain download needed).
# zig bundles musl libc for all targets, so no -l:libzstd.a needed.
- name: Install cross-compiler and libpcap (armv7)
if: matrix.arch == 'armv7'
run: |
rustup target add armv7-unknown-linux-musleabihf
apk add zig flex bison bash
# zig cc wrapper: cc-rs detects it as clang and passes --target=armv7-*
# which zig rejects (zig uses 'arm' not 'armv7'). Filter those args out.
# shellcheck disable=SC2016
printf '%s\n' \
'#!/bin/bash' \
'args=()' \
'for arg in "$@"; do' \
' case "$arg" in --target=*) ;; *) args+=("$arg") ;; esac' \
'done' \
'exec zig cc -target arm-linux-musleabihf "${args[@]}"' \
> /usr/local/bin/arm-linux-musleabihf-gcc
chmod +x /usr/local/bin/arm-linux-musleabihf-gcc
# libpcap is pinned by version + sha256 (Dependabot does not track
# tarballs fetched in run steps, so bump the URL and the hash together).
curl -fsSL --retry 3 --retry-delay 5 \
-o libpcap.tar.gz https://www.tcpdump.org/release/libpcap-1.10.5.tar.gz
echo "37ced90a19a302a7f32e458224a00c365c117905c2cd35ac544b6880a81488f0 libpcap.tar.gz" | sha256sum -c -
tar xzf libpcap.tar.gz
cd libpcap-1.10.5
CC=arm-linux-musleabihf-gcc \
./configure --host=arm-linux-musleabihf \
--disable-shared --enable-static \
--disable-usb --disable-dbus --disable-bluetooth --disable-remote \
--prefix=/arm-sysroot
make -j"$(nproc)" && make install
cd ..
# x86: use zig cc as the cross-compiler (no external toolchain download needed).
# zig bundles musl libc for all targets, so no -l:libzstd.a needed.
- name: Install cross-compiler and libpcap (x86/i686)
if: matrix.arch == 'x86'
run: |
rustup target add i686-unknown-linux-musl
apk add zig flex bison bash
# zig cc wrapper: cc-rs passes --target=i686-* which zig doesn't accept;
# filter those args and use -target x86-linux-musl instead.
# shellcheck disable=SC2016
printf '%s\n' \
'#!/bin/bash' \
'args=()' \
'for arg in "$@"; do' \
' case "$arg" in --target=*) ;; -Wl,-melf_i386) ;; *) args+=("$arg") ;; esac' \
'done' \
'exec zig cc -target x86-linux-musl "${args[@]}"' \
> /usr/local/bin/i686-linux-musl-gcc
chmod +x /usr/local/bin/i686-linux-musl-gcc
# libpcap is pinned by version + sha256 (Dependabot does not track
# tarballs fetched in run steps, so bump the URL and the hash together).
curl -fsSL --retry 3 --retry-delay 5 \
-o libpcap.tar.gz https://www.tcpdump.org/release/libpcap-1.10.5.tar.gz
echo "37ced90a19a302a7f32e458224a00c365c117905c2cd35ac544b6880a81488f0 libpcap.tar.gz" | sha256sum -c -
tar xzf libpcap.tar.gz
cd libpcap-1.10.5
CC=i686-linux-musl-gcc \
./configure --host=i686-linux-musl \
--disable-shared --enable-static \
--disable-usb --disable-dbus --disable-bluetooth --disable-remote \
--prefix=/x86-sysroot
make -j"$(nproc)" && make install
cd ..
- name: Build static binary (aarch64)
if: matrix.arch == 'aarch64'
env:
CFLAGS: "-mno-outline-atomics"
CXXFLAGS: "-mno-outline-atomics"
RUSTFLAGS: "-C strip=symbols -C link-arg=-l:libzstd.a"
run: cargo build --release --no-default-features
- name: Build static binary (x86_64)
if: matrix.arch == 'x86_64'
env:
RUSTFLAGS: "-C strip=symbols -C link-arg=-l:libzstd.a"
run: cargo build --release --no-default-features
- name: Build static binary (armv7)
if: matrix.arch == 'armv7'
env:
# link-self-contained=no: let zig provide musl CRT (avoid duplicate _start
# when both Rust's self-contained crt1.o and zig's crt1.o are linked)
RUSTFLAGS: "-C strip=symbols -C link-self-contained=no"
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER: arm-linux-musleabihf-gcc
PKG_CONFIG_PATH: /arm-sysroot/lib/pkgconfig
PKG_CONFIG_SYSROOT_DIR: /arm-sysroot
PKG_CONFIG_ALLOW_CROSS: "1"
run: cargo build --release --target armv7-unknown-linux-musleabihf --no-default-features
- name: Build static binary (x86/i686)
if: matrix.arch == 'x86'
env:
RUSTFLAGS: "-C strip=symbols -C link-self-contained=no"
CARGO_TARGET_I686_UNKNOWN_LINUX_MUSL_LINKER: i686-linux-musl-gcc
PKG_CONFIG_PATH: /x86-sysroot/lib/pkgconfig
PKG_CONFIG_SYSROOT_DIR: /x86-sysroot
PKG_CONFIG_ALLOW_CROSS: "1"
run: cargo build --release --target i686-unknown-linux-musl --no-default-features
- name: Verify static linking
run: |
BIN="${{ (matrix.arch == 'armv7' && 'target/armv7-unknown-linux-musleabihf/release/rustnet') || (matrix.arch == 'x86' && 'target/i686-unknown-linux-musl/release/rustnet') || 'target/release/rustnet' }}"
file "$BIN"
file "$BIN" | grep -q "static.* linked" || \
(echo "ERROR: Binary is not statically linked" && exit 1)
- name: Create release archive
run: |
VERSION="${{ github.ref_name }}"
ARTIFACT="${{ matrix.artifact }}"
case "${{ matrix.arch }}" in
armv7) BIN="target/armv7-unknown-linux-musleabihf/release/rustnet" ;;
x86) BIN="target/i686-unknown-linux-musl/release/rustnet" ;;
*) BIN="target/release/rustnet" ;;
esac
staging="rustnet-${VERSION}-${ARTIFACT}"
mkdir -p "$staging/assets"
cp "$BIN" "$staging/rustnet"
cp crates/rustnet-core/assets/services "$staging/assets/" 2>/dev/null || true
cp README.md "$staging/"
cp LICENSE "$staging/" 2>/dev/null || true
tar czf "$staging.tar.gz" "$staging"
- name: Upload to release
run: |
VERSION="${{ github.ref_name }}"
ARCHIVE="rustnet-${VERSION}-${{ matrix.artifact }}.tar.gz"
gh release upload "$VERSION" "$ARCHIVE" --repo "${{ github.repository }}" --clobber
env:
GH_TOKEN: ${{ github.token }}
# Publish the release (un-draft) after all assets are uploaded.
# This ensures downstream jobs (Homebrew, Chocolatey) can download assets.
publish-release:
name: publish-release
runs-on: ubuntu-latest
needs:
- create-release
- upload-arm-static
- upload-android-static
- package-installers
- package-macos
- package-windows
if: always() && needs.create-release.result == 'success'
steps:
- name: Publish release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release edit ${{ github.ref_name }} --draft=false --repo ${{ github.repository }}
# Gate job that waits for all publish steps to finish before firing
# downstream package-manager triggers. Without this gate, triggers that
# run as soon as publish-release completes race against publish-crates,
# publish-docker, update-copr, and release-ppa — and the downstream
# Homebrew/Chocolatey workflows bail because their "is release.yml still
# running?" safety check sees the parent run as in-progress.
all-published:
name: all-published
runs-on: ubuntu-latest
needs:
- publish-release
- publish-crates
- publish-docker
- update-copr
- release-ppa
- release-obs
steps:
- name: All publish steps complete
run: echo "Release pipeline fully published; safe to fire downstream triggers."
trigger-bsd-build:
name: trigger-bsd-build
runs-on: ubuntu-latest
needs: all-published
steps:
- name: Trigger FreeBSD build
run: |
gh api repos/domcyrus/rustnet-bsd/dispatches \
-f event_type=freebsd-build \
-f "client_payload[tag]=${{ github.ref_name }}" \
-f "client_payload[rustnet_ref]=${{ github.sha }}"
env:
GH_TOKEN: ${{ secrets.BSD_DISPATCH_TOKEN }}
trigger-homebrew-update:
name: trigger-homebrew-update
runs-on: ubuntu-latest
needs: all-published
steps:
- name: Trigger Homebrew formula update
run: |
gh workflow run update-formula.yml \
--repo domcyrus/homebrew-rustnet \
-f version=${{ github.ref_name }}
env:
GH_TOKEN: ${{ secrets.HOMEBREW_PAT }}
trigger-chocolatey-update:
name: trigger-chocolatey-update
runs-on: ubuntu-latest
needs: all-published
steps:
- name: Trigger Chocolatey package update
run: |
gh workflow run update-package.yml \
--repo domcyrus/rustnet-chocolatey \
-f version=${{ github.ref_name }}
env:
GH_TOKEN: ${{ secrets.CHOCOLATEY_PAT }}
# Dispatching a same-repo workflow via gh workflow run requires
# actions: write on the token. The top-level permissions block grants
# contents/packages/attestations/id-token only, so explicitly opt in here.
trigger-aur-update:
name: trigger-aur-update
runs-on: ubuntu-latest
needs: all-published
permissions:
actions: write
contents: read
steps:
- name: Trigger AUR package update
run: |
gh workflow run aur-update.yml \
--repo domcyrus/rustnet
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
package-installers:
name: package-installers
runs-on: ubuntu-22.04
needs: create-release
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Download build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
path: artifacts
- name: Setup Linux dependencies
uses: ./.github/actions/setup-linux-deps
- name: Install Rust and tools
uses: dtolnay/rust-toolchain@stable # unpinned: maintained branch by Rust team member
with:
targets: x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu,armv7-unknown-linux-gnueabihf
components: rustfmt
- name: Install packaging tools
run: |
cargo install cargo-deb cargo-generate-rpm
- name: Package Debian packages
run: |
mkdir -p installers
for arch in amd64 arm64 armhf; do
case "$arch" in
amd64) target="x86_64-unknown-linux-gnu" ;;
arm64) target="aarch64-unknown-linux-gnu" ;;
armhf) target="armv7-unknown-linux-gnueabihf" ;;
esac
# Extract binary and assets from artifact
tar -xzf "artifacts/build-$target/rustnet-${{ github.ref_name }}-$target.tar.gz"
mkdir -p "target/$target/release"
cp "rustnet-${{ github.ref_name }}-$target/rustnet" "target/$target/release/"
# Ensure services file exists for packaging
mkdir -p crates/rustnet-core/assets
if [ -f "rustnet-${{ github.ref_name }}-$target/assets/services" ]; then
cp "rustnet-${{ github.ref_name }}-$target/assets/services" crates/rustnet-core/assets/
fi
# Create deb package
cargo deb --no-build --no-strip --target "$target"
mv "target/$target/debian"/*.deb "installers/Rustnet_LinuxDEB_$arch.deb"
# Clean up for next iteration
rm -rf "rustnet-${{ github.ref_name }}-$target" "target/$target"
done
- name: Package RPM packages
run: |
for arch in x86_64 aarch64; do
target="$arch-unknown-linux-gnu"
# Extract binary and assets from artifact
tar -xzf "artifacts/build-$target/rustnet-${{ github.ref_name }}-$target.tar.gz"
mkdir -p "target/$target/release"
cp "rustnet-${{ github.ref_name }}-$target/rustnet" "target/$target/release/"
# Ensure services file exists for packaging
mkdir -p crates/rustnet-core/assets
if [ -f "rustnet-${{ github.ref_name }}-$target/assets/services" ]; then
cp "rustnet-${{ github.ref_name }}-$target/assets/services" crates/rustnet-core/assets/
fi
# Fix library linking if needed
patchelf --replace-needed libpcap.so.0.8 libpcap.so.1 "target/$target/release/rustnet" 2>/dev/null || true
# Create rpm package
cargo generate-rpm --target "$target"
mv "target/$target/generate-rpm"/*.rpm "installers/Rustnet_LinuxRPM_$arch.rpm"
# Clean up for next iteration
rm -rf "rustnet-${{ github.ref_name }}-$target" "target/$target"
done
- name: Upload installer packages
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Upload all installer packages
for installer in installers/*; do
gh release upload ${{ github.ref_name }} "$installer" --clobber
done
package-macos:
name: package-macos
runs-on: macos-latest
needs: create-release
strategy:
matrix:
include:
- arch: Intel
target: x86_64-apple-darwin
- arch: AppleSilicon
target: aarch64-apple-darwin
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install packaging tools
run: |
cargo install toml-cli
cargo install apple-codesign
brew install create-dmg
- name: Download build artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: build-${{ matrix.target }}
path: artifacts
- name: Package for macOS
run: |
# Extract binary
tar -xzf "artifacts/rustnet-${{ github.ref_name }}-${{ matrix.target }}.tar.gz"
# Get version and update plist
VERSION=$(toml get Cargo.toml package.version --raw)
sed -i'.bak' -e "s/0\.0\.0/${VERSION}/g" -e "s/fffffff/${GITHUB_SHA:0:7}/g" resources/packaging/macos/Info.plist
# Create app bundle
mkdir -p "Rustnet.app/Contents/"{MacOS,Resources/assets}
cp resources/packaging/macos/Info.plist "Rustnet.app/Contents/"
cp resources/packaging/macos/graphics/rustnet.icns "Rustnet.app/Contents/Resources/"
cp "rustnet-${{ github.ref_name }}-${{ matrix.target }}/rustnet" "Rustnet.app/Contents/MacOS/"
cp resources/packaging/macos/wrapper.sh "Rustnet.app/Contents/MacOS/"
cp "rustnet-${{ github.ref_name }}-${{ matrix.target }}/assets/services" "Rustnet.app/Contents/Resources/assets/"
chmod +x "Rustnet.app/Contents/MacOS/"{rustnet,wrapper.sh}
- name: Code sign and notarize app bundle
env:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}
APPLE_API_ISSUER: ${{ secrets.APPLE_ID_ISSUER_ID }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_ID_KEY_ID }}
APPLE_API_KEY: ${{ secrets.APPLE_ID_KEY }}
run: |
# Skip signing if secrets are not configured
if [ -z "$APPLE_CERTIFICATE_P12" ]; then
echo "⚠️ Code signing secrets not configured. DMG will be unsigned."
echo "⚠️ Users will need to use 'Open Anyway' in System Settings > Privacy & Security"
echo "SKIP_SIGNING=true" >> "$GITHUB_ENV"
exit 0
fi
# Decode certificate and save to file
echo "$APPLE_CERTIFICATE_P12" | base64 --decode > certificate.p12
# Create temporary JSON for API credentials
cat > api-key.json <<EOF
{
"issuer": "$APPLE_API_ISSUER",
"key_id": "$APPLE_API_KEY_ID",
"private_key": "$APPLE_API_KEY"
}
EOF
# Sign the app bundle with hardened runtime
rcodesign sign \
--p12-file certificate.p12 \
--p12-password "$APPLE_CERTIFICATE_PASSWORD" \
--code-signature-flags runtime \
Rustnet.app
# Create zip for notarization (required format)
ditto -c -k --keepParent Rustnet.app Rustnet.zip
# Submit for notarization and wait for result
rcodesign notary-submit \
--api-key-path api-key.json \
--wait \
Rustnet.zip
# Staple the notarization ticket to the app
rcodesign staple Rustnet.app
# Cleanup sensitive files
rm -f certificate.p12 api-key.json Rustnet.zip
- name: Create DMG
run: |
create-dmg \
--volname "Rustnet Installer" \
--background "resources/packaging/macos/graphics/dmg_bg.png" \
--window-pos 200 120 \
--window-size 900 450 \
--icon-size 100 \
--app-drop-link 620 240 \
--icon "Rustnet.app" 300 240 \
--hide-extension "Rustnet.app" \
"Rustnet_macOS_${{ matrix.arch }}.dmg" \
"Rustnet.app"
- name: Upload macOS package
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload ${{ github.ref_name }} "Rustnet_macOS_${{ matrix.arch }}.dmg" --clobber
package-windows:
name: package-windows
runs-on: windows-latest
needs: create-release
strategy:
matrix:
include:
- arch: 32-bit
target: i686-pc-windows-msvc
- arch: 64-bit
target: x86_64-pc-windows-msvc
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install dependencies
shell: powershell
run: |
Write-Host "::group::WiX Toolset"
Invoke-WebRequest `
-Uri "https://github.com/wixtoolset/wix3/releases/download/wix3112rtm/wix311-binaries.zip" `
-OutFile "$env:TEMP\wix-binaries.zip" -Verbose
# Verify the toolchain that builds the shipped MSI. Pinned by version +
# sha256; bump both together (Dependabot does not track this download).
$expected = "2c1888d5d1dba377fc7fa14444cf556963747ff9a0a289a3599cf09da03b9e2e"
$actual = (Get-FileHash "$env:TEMP\wix-binaries.zip" -Algorithm SHA256).Hash.ToLower()
if ($actual -ne $expected) {
throw "WiX zip checksum mismatch: expected $expected, got $actual"
}
Expand-Archive -LiteralPath "$env:TEMP\wix-binaries.zip" -DestinationPath "$env:TEMP\wix" -Verbose
# Add WiX to PATH for all subsequent steps
"$env:TEMP\wix" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
# Verify WiX installation
Write-Host "WiX tools installed:"
Get-ChildItem "$env:TEMP\wix\*.exe" | Select-Object Name
Write-Host "::endgroup::"
- name: Install Rust and tools
uses: dtolnay/rust-toolchain@stable # unpinned: maintained branch by Rust team member
with:
targets: ${{ matrix.target }}
- name: Install packaging tools
shell: bash
run: |
cargo install cargo-wix
cargo wix --version
- name: Download build artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: build-${{ matrix.target }}
path: artifacts
- name: Package for Windows
shell: powershell
run: |
# Extract binary and assets
Write-Host "Extracting artifact..."
Expand-Archive -LiteralPath "artifacts\rustnet-${{ github.ref_name }}-${{ matrix.target }}.zip" -DestinationPath . -Force
# Create target directory structure
New-Item -ItemType Directory -Path "target\${{ matrix.target }}\release" -Force
# Copy binary
Copy-Item -Path "rustnet-${{ github.ref_name }}-${{ matrix.target }}\rustnet.exe" -Destination "target\${{ matrix.target }}\release\" -Force
Write-Host "Binary copied to target directory"
# Copy services file if it exists
New-Item -ItemType Directory -Path "assets" -Force
if (Test-Path "rustnet-${{ github.ref_name }}-${{ matrix.target }}\assets\services") {
Copy-Item -Path "rustnet-${{ github.ref_name }}-${{ matrix.target }}\assets\services" -Destination "assets\" -Force
Write-Host "Services file copied successfully"
} else {
Write-Host "Warning: No services file found"
}
# Setup WiX configuration (cargo-wix expects main.wxs in wix/ directory)
Write-Host "Setting up WiX configuration..."
New-Item -ItemType Directory -Path "wix" -Force
Copy-Item -Path "resources\packaging\windows\wix\main.wxs" -Destination "wix\main.wxs" -Force
Write-Host "WiX configuration copied"
# Verify candle.exe and light.exe are in PATH
Write-Host "Checking WiX tools..."
Get-Command candle.exe -ErrorAction SilentlyContinue | Select-Object Source
Get-Command light.exe -ErrorAction SilentlyContinue | Select-Object Source
# Create MSI package. `-p rustnet-monitor` is required since the
# workspace split: cargo-wix refuses to guess the package in a
# workspace ("Workspace detected. Please pass a package name.").
Write-Host "Creating MSI package..."
cargo wix -p rustnet-monitor --no-build --nocapture --target ${{ matrix.target }}
# Find and rename the MSI file
$msiFiles = Get-ChildItem -Path "target\wix" -Filter "*.msi" -ErrorAction SilentlyContinue
if ($msiFiles) {
$msiFile = $msiFiles | Select-Object -First 1
Move-Item -Path $msiFile.FullName -Destination "Rustnet_Windows_${{ matrix.arch }}.msi" -Force
Write-Host "MSI package created: Rustnet_Windows_${{ matrix.arch }}.msi"
} else {
Write-Error "No MSI file found in target\wix\"
Write-Host "Contents of target directory:"
Get-ChildItem -Path "target" -Recurse -ErrorAction SilentlyContinue | Select-Object FullName
exit 1
}
- name: Upload Windows package
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload ${{ github.ref_name }} "Rustnet_Windows_${{ matrix.arch }}.msi" --clobber
publish-crates:
name: Publish to crates.io
needs: publish-release
uses: ./.github/workflows/publish.yml
secrets: inherit
publish-docker:
name: Publish Docker image
needs: publish-release
uses: ./.github/workflows/docker.yml
secrets: inherit
update-copr:
name: Update COPR spec
needs: publish-release
uses: ./.github/workflows/copr-update-spec.yml
secrets: inherit
release-ppa:
name: Release to PPA
needs: publish-release
uses: ./.github/workflows/ppa-release.yml
secrets: inherit
release-obs:
name: Release to OBS
needs: publish-release
uses: ./.github/workflows/obs-release.yml
secrets: inherit
+78
View File
@@ -0,0 +1,78 @@
name: Rust
on:
push:
branches: [ "main" ]
paths:
- 'src/**'
- 'crates/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'build.rs'
- 'benches/**'
- 'Dockerfile'
- 'deny.toml'
- '.github/workflows/rust.yml'
pull_request:
branches: [ "main" ]
paths:
- 'src/**'
- 'crates/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'build.rs'
- 'benches/**'
- 'Dockerfile'
- 'deny.toml'
- '.github/workflows/rust.yml'
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install dependencies
run: sudo apt-get update && sudo apt-get install -y libpcap-dev libelf-dev zlib1g-dev clang llvm pkg-config
- name: Check formatting
run: cargo fmt --check
# --workspace --all-targets so the library crates (rustnet-core/-capture/
# -host) are linted, built, and tested too. The workspace root is a real
# package (rustnet-monitor), so a bare `cargo {clippy,build,test}` only
# selects the binary and silently skips the library crates' lints, tests,
# and doctests.
- name: Run clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Build
run: cargo build --workspace --all-targets --verbose
- name: Run tests
run: cargo test --workspace --verbose
- name: Run cargo-deny
# Checks RustSec advisories and yanked crates (yanked = "deny" in
# deny.toml, so a withdrawn dependency pinned in Cargo.lock is caught),
# plus license policy, wildcard bans, and registry sources.
uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2.0.20
with:
command: check
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Build Docker image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
push: false
load: true
tags: rustnet:ci-test
- name: Verify Docker image
run: docker run --rm rustnet:ci-test --version
+25
View File
@@ -0,0 +1,25 @@
name: Security audit
# Scheduled advisory check against the committed Cargo.lock. PR/push CI
# (rust.yml) already runs the full `cargo deny check`, but new RustSec
# advisories can be published against an unchanged lockfile — this catches
# those without requiring a push. Licenses/bans/sources can only change with
# a lockfile change, so the scheduled job checks advisories only.
on:
schedule:
- cron: '37 5 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
advisories:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Check RustSec advisories
uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2.0.20
with:
command: check advisories
@@ -0,0 +1,46 @@
name: Test Platform Builds
# Test builds on all supported platforms.
# Uses the shared build-platforms workflow.
# Also triggers a FreeBSD test build in the rustnet-bsd repo.
on:
workflow_dispatch:
pull_request:
paths:
- 'Cargo.toml'
- 'Cargo.lock'
- 'build.rs'
- 'Cross.toml'
- 'src/**'
- '.github/workflows/test-platform-builds.yml'
- '.github/workflows/build-platforms.yml'
- '.github/actions/**'
permissions:
contents: read
jobs:
build:
uses: ./.github/workflows/build-platforms.yml
with:
create-archives: false
strip-symbols: false
trigger-freebsd-build:
name: trigger-freebsd-build
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- name: Trigger FreeBSD test build
run: |
REF="${{ github.sha }}"
TAG="test-$(date +%Y%m%d-%H%M%S)"
gh api repos/domcyrus/rustnet-bsd/dispatches \
-f event_type=freebsd-build \
-f "client_payload[tag]=$TAG" \
-f "client_payload[rustnet_ref]=$REF" \
-f "client_payload[create_release]=false"
env:
GH_TOKEN: ${{ secrets.BSD_DISPATCH_TOKEN }}
+50
View File
@@ -0,0 +1,50 @@
name: Update OUI Database
on:
schedule:
- cron: '0 6 1 * *' # 1st of every month at 06:00 UTC
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
update-oui:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Download latest IEEE OUI database
run: |
curl -fsSL 'https://standards-oui.ieee.org/oui/oui.txt' -o oui-raw.txt
grep '(base 16)' oui-raw.txt | sed 's/[[:space:]]*(base 16)[[:space:]]*/\t/' > oui-clean.txt
gzip -9 -c oui-clean.txt > crates/rustnet-core/assets/oui.gz
rm oui-raw.txt oui-clean.txt
- name: Create pull request if changed
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if git diff --quiet crates/rustnet-core/assets/oui.gz; then
echo "OUI database is already up to date."
exit 0
fi
BRANCH="update-oui-db"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$BRANCH"
git add crates/rustnet-core/assets/oui.gz
git commit -m "Update OUI vendor database"
git push -f origin "$BRANCH"
# Only create PR if one doesn't already exist for this branch
if ! gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number' | grep -q .; then
gh pr create \
--title "Update OUI vendor database" \
--body "Automated monthly update of the IEEE OUI (MAC vendor) database.
Source: https://standards-oui.ieee.org/oui/oui.txt" \
--label dependencies
fi
+24
View File
@@ -0,0 +1,24 @@
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Added by cargo
/target
.aider*
/logs
.venv
+490
View File
@@ -0,0 +1,490 @@
<p align="center"> <strong>English</strong> | <a href="ARCHITECTURE.zh-CN.md">简体中文</a></p>
# Architecture
This document describes the technical architecture and implementation details of RustNet.
## Table of Contents
- [Crate Structure](#crate-structure)
- [Multi-threaded Architecture](#multi-threaded-architecture)
- [Key Components](#key-components)
- [Platform-Specific Implementations](#platform-specific-implementations)
- [Performance Considerations](#performance-considerations)
- [Dependencies](#dependencies)
- [Security](#security)
## Crate Structure
RustNet is a Cargo workspace of four crates. The analysis logic, capture backend, and process attribution each live in their own reusable library crate; the binary composes them into the TUI application.
| Crate | Type | Responsibility |
| --- | --- | --- |
| [`rustnet-core`](crates/rustnet-core) | library | Platform- and capture-independent analysis core: packet parsing, protocol/connection types, deep packet inspection, link-layer parsers, connection merging, DNS/GeoIP/OUI lookups, and a reusable `ConnectionTracker` (live table + RTT + QUIC coalescing + lifecycle) for headless tools. Operates only on byte slices and parsed structures -- no libpcap, raw sockets, or OS process tables. |
| [`rustnet-capture`](crates/rustnet-capture) | library | libpcap/Npcap packet-capture backend: device selection, BPF filters, macOS PKTAP, TUN/TAP, and a raw-frame `PacketReader`. |
| [`rustnet-host`](crates/rustnet-host) | library | Per-connection process attribution behind one `ProcessLookup` trait: eBPF/procfs on Linux, PKTAP/lsof on macOS, the IP Helper API on Windows, and `sockstat` on FreeBSD. Owns the eBPF build tooling and bundled `vmlinux.h`. |
| `rustnet-monitor` (binary `rustnet`) | binary | The user-facing application: CLI, TUI, app event loop, sandboxing (Landlock/Seatbelt), and interface statistics. Dogfoods `ConnectionTracker` as the single source of truth. |
The package is named `rustnet-monitor` because the `rustnet` crate name is taken on crates.io; the installed binary is `rustnet`.
### Dependency Graph
```mermaid
flowchart TD
BIN[rustnet-monitor<br/>bin: rustnet]
CAP[rustnet-capture]
HOST[rustnet-host]
CORE[rustnet-core]
BIN --> CAP
BIN --> HOST
BIN --> CORE
CAP --> CORE
HOST --> CORE
```
The graph is acyclic: `rustnet-core` has no workspace dependencies, and both `rustnet-capture` and `rustnet-host` depend only on it. Keeping `rustnet-core` a leaf lets it be published and reused independently -- a headless front-end (e.g. a Prometheus exporter) can pair `rustnet-capture` + `rustnet-core` without the TUI.
### Re-export Facade
To keep the split internal to the binary, `src/network/mod.rs` re-exports `rustnet_core::network::*` and `rustnet_capture` (as `capture`), so existing `crate::network::*` paths, integration tests, and benches compile unchanged. The `src/network/platform` module still hosts the OS sandboxing (Landlock/Seatbelt) and interface-stats collectors, and wires in `rustnet-host`'s process lookup.
## Multi-threaded Architecture
RustNet uses a multi-threaded architecture for efficient packet processing:
```mermaid
flowchart LR
PC[Packet Capture<br/>libpcap]
CH([Crossbeam Channel])
PP[Packet Processors<br/>Thread 0..N]
PE[Process Enrichment<br/>Platform API]
DM[(DashMap)]
SP[Snapshot Provider]
UI[/RwLock&lt;Vec&lt;Connection&gt;&gt;<br/>for UI/]
CT[Cleanup Thread]
PC -- packets --> CH --> PP --> DM
PE --> DM
DM --> SP --> UI
DM --> CT
```
## Key Components
### 1. Packet Capture Thread
Uses libpcap to capture raw packets from the network interface. This thread runs independently and feeds packets into a Crossbeam channel for processing.
**Responsibilities:**
- Open network interface for packet capture (non-promiscuous, read-only mode)
- Apply BPF filters if needed
- Capture raw packets
- Stream packets to PCAP file if `--pcap-export` is enabled (direct disk write, no memory buffering)
- Feed parsed packets to the annotated PCAPNG writer if `--pcapng-export` is enabled (bounded best-effort queue)
- Send packets to processing queue
### 2. Packet Processors
Multiple worker threads (up to 4 by default, based on CPU cores) that parse packets and perform Deep Packet Inspection (DPI) analysis.
**Responsibilities:**
- Parse Ethernet, IP, TCP, UDP, ICMP, ARP headers
- Extract connection 5-tuple (protocol, src IP, src port, dst IP, dst port)
- Perform DPI to detect application protocols:
- HTTP with host information
- HTTPS/TLS with SNI (Server Name Indication)
- DNS queries and responses
- SSH connections with version detection
- FTP control channel with commands, response codes, username, server software, and system type
- QUIC protocol with CONNECTION_CLOSE frame detection
- MQTT with packet types, version, and client identifier
- BitTorrent handshakes and DHT messages
- STUN for WebRTC and NAT traversal
- NTP with version, mode, and stratum
- mDNS and LLMNR for local name resolution
- DHCP with message types and hostnames
- SNMP (v1, v2c, v3) with PDU types
- SSDP for UPnP device discovery
- NetBIOS Name Service and Datagram Service
- Track connection states and lifecycle
- Update connection metadata in DashMap
- Calculate bandwidth metrics
### 3. Process Enrichment
Platform-specific APIs to associate network connections with running processes. This component runs periodically to enrich connection data with process information.
**Responsibilities:**
- Map socket inodes to process IDs
- Resolve process names and command lines
- Update connection records with process information
- Handle permission-related fallbacks
See [Platform-Specific Implementations](#platform-specific-implementations) for details on each platform.
### 4. Snapshot Provider
Creates consistent snapshots of connection data for the UI at regular intervals (default: 1 second). This ensures the UI has a stable view of connections without race conditions.
**Responsibilities:**
- Read from DashMap at configured intervals
- Apply filtering based on user criteria (localhost, etc.)
- Sort connections based on user-selected column
- Create immutable snapshot for UI rendering
- Provide RwLock-protected Vec<Connection> for UI thread
### 5. Cleanup Thread
Removes inactive connections using smart, protocol-aware timeouts. This prevents memory leaks and keeps the connection list relevant. When `--pcap-export` is enabled, also streams connection metadata (PID, process name, timestamps) to a JSONL sidecar file as connections close.
**Timeout Strategy:**
#### TCP Connections
- **HTTP/HTTPS** (detected via DPI): **10 minutes** - supports HTTP keep-alive
- **SSH** (detected via DPI): **30 minutes** - accommodates long interactive sessions
- **Active established** (< 1 min idle): **10 minutes**
- **Idle established** (> 1 min idle): **5 minutes**
- **TIME_WAIT**: 30 seconds - standard TCP timeout
- **CLOSED**: 5 seconds - rapid cleanup
- **SYN_SENT, FIN_WAIT, etc.**: 30-60 seconds
#### UDP Connections
- **HTTP/3 (QUIC with HTTP)**: **10 minutes** - connection reuse
- **HTTPS/3 (QUIC with HTTPS)**: **10 minutes** - connection reuse
- **SSH over UDP**: **30 minutes** - long-lived sessions
- **DNS**: **30 seconds** - short-lived queries
- **Regular UDP**: **60 seconds** - standard timeout
#### QUIC Connections (Detected State)
- **Connected**: 3 minutes default, or the peer's `max_idle_timeout` transport parameter when present
- **With CONNECTION_CLOSE frame**: 1-10 seconds (based on close type)
- **Initial/Handshaking**: 60 seconds - allow connection establishment
- **Draining**: 10 seconds - RFC 9000 draining period
- **Closed**: 1 second - immediate cleanup
**Visual Staleness Indicators:**
Connections change color based on proximity to timeout:
- **White** (default): < 75% of timeout
- **Yellow**: 75-90% of timeout (warning)
- **Red**: > 90% of timeout (critical)
### 6. Rate Refresh Thread
Updates bandwidth calculations every second with gentle decay. This provides smooth bandwidth visualization without abrupt changes.
**Responsibilities:**
- Calculate bytes/second for download and upload
- Apply exponential decay to older measurements
- Update visual bandwidth indicators
- Maintain rolling window of packet rates
### 7. DashMap
Concurrent hashmap (`DashMap<ConnectionKey, Connection>`) for storing connection state. This lock-free data structure enables efficient concurrent access from multiple threads.
**Key Features:**
- Fine-grained locking (per-shard)
- No global lock contention
- Safe concurrent reads and writes
- High performance under concurrent load
## Platform-Specific Implementations
### Process Lookup
RustNet uses platform-specific APIs to associate network connections with processes:
#### Linux
**Standard Mode (procfs):**
- Parses `/proc/net/tcp` and `/proc/net/udp` to get socket inodes
- Iterates through `/proc/<pid>/fd/` to find socket file descriptors
- Maps inodes to process IDs and resolves process names from `/proc/<pid>/cmdline`
**eBPF Mode (Default on Linux):**
- Uses kernel eBPF programs attached to socket syscalls
- Captures socket creation events with process context
- Provides lower overhead than procfs scanning
- **Limitations:**
- Process names limited to 16 characters (kernel `comm` field)
- May show thread names instead of full executable names
- Multi-threaded applications show internal thread names
- **Capability requirements:**
- Modern Linux (5.8+): `CAP_NET_RAW` (packet capture), `CAP_BPF`, `CAP_PERFMON` (eBPF)
- Legacy Linux (pre-5.8): eBPF requires broad `CAP_SYS_ADMIN`; RustNet packages do not grant it automatically and fall back to procfs instead
- Note: CAP_NET_ADMIN is NOT required (uses read-only, non-promiscuous packet capture)
**Fallback Behavior:**
- If eBPF fails to load (permissions, kernel compatibility), automatically falls back to procfs mode
- TUI Statistics panel shows active detection method
#### macOS
**PKTAP Mode (with sudo):**
- Uses PKTAP (Packet Tap) kernel interface
- Extracts process information directly from packet metadata
- Requires root privileges (privileged kernel interface)
- Faster and more accurate than lsof
**lsof Mode (without sudo or fallback):**
- Uses `lsof -i -n -P` to list network connections
- Parses output to associate sockets with processes
- Higher CPU overhead but works without root
- Used automatically when PKTAP is unavailable
**Detection:**
- TUI Statistics panel shows "pktap" or "lsof" based on active method
- Automatically selects best available method
#### Windows
**IP Helper API:**
- Uses `GetExtendedTcpTable` and `GetExtendedUdpTable` from Windows IP Helper API
- Retrieves connection tables with process IDs
- Supports both IPv4 and IPv6 connections
- Resolves process names using `OpenProcess` and `QueryFullProcessImageNameW`
**Requirements:**
- May require Administrator privileges depending on system configuration
- Requires Npcap or WinPcap for packet capture
### Network Interfaces
The tool automatically detects and lists available network interfaces using platform-specific methods:
- **Linux**: Uses `netlink` or falls back to `/sys/class/net/`
- **macOS**: Uses `getifaddrs()` system call
- **Windows**: Uses `GetAdaptersInfo()` from IP Helper API
- **All platforms**: Falls back to pcap's `pcap_findalldevs()` when native methods fail
## Performance Considerations
### Multi-threaded Processing
Packet processing is distributed across multiple threads (up to 4 by default, based on CPU cores). This enables:
- Parallel packet parsing and DPI analysis
- Better utilization of multi-core systems
- Reduced latency for high packet rates
### Concurrent Data Structures
**DashMap** provides lock-free concurrent access with:
- Per-shard locking (16 shards by default)
- No global lock contention
- Read-heavy workload optimization
- Safe concurrent modifications
### Batch Processing
Packets are processed in batches to improve cache efficiency:
- Multiple packets processed before context switching
- Reduced system call overhead
- Better CPU cache utilization
### Selective DPI
Deep packet inspection can be disabled with `--no-dpi` for lower overhead:
- Reduces CPU usage by 20-40% on high-traffic networks
- Still tracks basic connection information
- Useful for performance-constrained environments
### Configurable Intervals
Adjust refresh rates based on your needs:
- **UI refresh**: Default 1000ms (adjustable with `--refresh-interval`)
- **Process enrichment**: Every 2 seconds
- **Cleanup check**: Every 5 seconds
- **Rate calculation**: Every 1 second
### Memory Management
**Connection cleanup** prevents unbounded memory growth:
- Protocol-aware timeouts remove stale connections
- Visual staleness warnings before removal
- Configurable timeout thresholds
**Snapshot isolation** prevents UI blocking:
- UI reads from immutable snapshots
- Background threads update DashMap concurrently
- No lock contention between UI and packet processing
## Dependencies
RustNet is built with the following key dependencies:
### Core Dependencies
- **ratatui** - Terminal user interface framework with full widget support
- **crossterm** - Cross-platform terminal manipulation
- **pcap** - Packet capture library bindings for libpcap/Npcap
- **pnet_datalink** - Network interface enumeration and low-level networking
### Concurrency & Threading
- **dashmap** - Concurrent hashmap with fine-grained locking
- **crossbeam** - Multi-threading utilities and lock-free channels
### Networking & Protocols
- **dns-lookup** - DNS resolution capabilities
- **maxminddb** - GeoIP database lookups (GeoLite2)
### Serialization
- **serde** / **serde_json** - JSON serialization for event logging and PCAP sidecar
### Command-line & Logging
- **clap** - Command-line argument parsing with derive features
- **simplelog** - Flexible logging framework
- **log** - Logging facade
- **anyhow** - Error handling and context
### Platform-Specific
- **procfs** (Linux) - Process information from /proc filesystem (runtime fallback)
- **libbpf-rs** (Linux) - eBPF program loading and management
- **landlock** (Linux) - Filesystem and network sandboxing
- **caps** (Linux) - Linux capability management
- **windows** (Windows) - Windows API bindings for IP Helper API
### Utilities
- **arboard** - Clipboard access for copying addresses
- **chrono** - Date and time handling
- **ring** - Cryptographic operations (for TLS/SNI parsing)
- **aes** - AES encryption support (for protocol detection)
- **flate2** - Gzip decompression (for compressed embedded data)
- **libc** - Low-level C bindings
## Embedded Data Files
RustNet embeds static lookup databases at compile time, avoiding runtime file dependencies. Both follow the same pattern: embed the file, parse into a `HashMap` at startup, expose a `lookup()` method.
### Service Lookup (`crates/rustnet-core/assets/services`)
Port-to-service-name mappings (e.g., 80/tcp -> http). Loaded by `ServiceLookup` in `crates/rustnet-core/src/network/services.rs` using `include_str!`.
### OUI Vendor Database (`crates/rustnet-core/assets/oui.gz`)
IEEE MA-L OUI prefix-to-vendor mappings for MAC address vendor resolution (e.g., `00:1B:63` -> Apple). Gzip-compressed to reduce binary size (~400KB compressed vs ~1.2MB raw). Decompressed at startup by `OuiLookup` in `crates/rustnet-core/src/network/oui.rs` using `include_bytes!` + `flate2`. Currently used for ARP connections only.
A GitHub Action (`.github/workflows/update-oui.yml`) updates this file monthly from the [IEEE public database](https://standards-oui.ieee.org/oui/oui.txt) and opens a PR if there are changes.
## Security
For security documentation including Landlock sandboxing, privilege requirements, and threat model, see [SECURITY.md](SECURITY.md).
## Comparison with Similar Tools
Network monitoring tools exist on a spectrum from simple connection listing to full packet forensics:
```
Simple ←─────────────────────────────────────────────────────→ Complex
netstat iftop bandwhich RustNet tcpdump Wireshark
│ │ │ │ │ │
└── Socket ┴── Bandwidth ──────────┴── Live DPI ┴── Capture ──┴── Forensics
state monitoring + Process & CLI & Deep
tracking Analysis
```
**RustNet's position**: Real-time connection monitoring with DPI and process identification - more capable than bandwidth monitors, more focused than forensic capture tools.
### Feature Comparison
| Feature | RustNet | bandwhich | sniffnet | iftop | netstat | ss | tcpdump/wireshark |
|---------|---------|-----------|----------|-------|---------|-----|-------------------|
| **Language** | Rust | Rust | Rust | C | C | C | C |
| **Interface** | TUI | TUI | GUI | TUI | CLI | CLI | CLI/GUI |
| **Real-time monitoring** | Yes | Yes | Yes | Yes | Snapshot | Snapshot | Yes |
| **Process identification** | Yes | Yes | No | No | Yes | Yes | No |
| **Deep Packet Inspection** | Yes | No | No | No | No | No | Yes |
| **SNI/Host extraction** | Yes | No | No | No | No | No | Yes |
| **Protocol state tracking** | Yes | No | Partial | No | Yes | Yes | Yes |
| **Bandwidth per connection** | Yes | Yes | Yes | Yes | No | No | No |
| **Connection filtering** | Yes | No | Yes | Yes | No | Yes | Yes (BPF) |
| **DNS reverse lookup** | Yes | Yes | Yes | Yes | No | No | Yes |
| **GeoIP lookup** | Yes | No | Yes | No | No | No | Yes |
| **Notifications** | No | No | Yes | No | No | No | No |
| **i18n (translations)** | No | No | Yes | No | No | No | No |
| **Cross-platform** | Linux, macOS, Windows, FreeBSD | Linux, macOS | Linux, macOS, Windows | Linux, macOS, BSD | All | Linux | All |
| **eBPF support** | Yes (Linux) | No | No | No | No | Yes | No |
| **Landlock sandboxing** | Yes (Linux) | No | No | No | No | No | No |
| **JSON event logging** | Yes | No | No | No | No | No | Yes |
| **PCAP export** | Yes (+ process sidecar / annotated PCAPNG) | No | Yes | No | No | No | Yes |
| **Packet capture** | libpcap | Raw sockets | libpcap | libpcap | Kernel | Kernel | libpcap |
### Tool Focus Areas
- **RustNet**: Real-time connection monitoring with DPI, protocol state tracking, and process identification in a TUI
- **bandwhich**: Bandwidth utilization by process/connection with minimal overhead
- **sniffnet**: Network traffic analysis with a graphical interface and notifications
- **iftop**: Interface bandwidth monitoring with per-host traffic display
- **netstat/ss**: System socket and connection state inspection (ss is the modern replacement for netstat on Linux)
- **tcpdump/wireshark/tshark**: Full packet capture and protocol analysis for deep debugging
### Choosing the Right Tool
| Your Goal | Best Tool |
|-----------|-----------|
| See which process is making a connection | RustNet |
| Decode packets byte-by-byte | Wireshark |
| Monitor connection states (SYN_SENT, ESTABLISHED, etc.) | RustNet |
| Extract files or credentials from traffic | Wireshark |
| Attribute network activity to specific applications | RustNet |
| Deep protocol dissection (3000+ protocols) | Wireshark |
| Quick terminal-based network overview | RustNet |
| Save captures with process attribution | RustNet (`--pcap-export` or `--pcapng-export`) |
| Save captures for deep analysis | Wireshark/tcpdump |
### RustNet and Wireshark: Different Strengths
The key difference: **RustNet knows which process owns each connection. Wireshark cannot.**
Wireshark operates at the packet capture layer (libpcap) - it sees raw network traffic but has no visibility into which application created it. RustNet combines packet capture with OS-level socket introspection (via eBPF on Linux, /proc, or platform APIs) to attribute every connection to its owning process.
| Capability | RustNet | Wireshark |
|------------|---------|-----------|
| Process identification | Yes (eBPF, procfs, platform APIs) | No |
| Connection state tracking | Native (TCP FSM, QUIC states) | Via dissectors |
| Protocol dissectors | ~15 common protocols | 3000+ protocols |
| Packet-level inspection | Metadata only | Full payload |
| Interface | TUI (terminal) | GUI |
| Capture to file | Yes (`--pcap-export`) | Yes (native) |
Both tools can run in real-time. Choose based on what you need to see:
- **"What is making this connection?"** → RustNet
- **"What's inside this packet?"** → Wireshark
### Bridging the Gap: PCAP Export with Process Attribution
RustNet can now export packet captures while preserving process attribution - something neither tcpdump nor Wireshark can do alone:
```bash
# Capture packets with RustNet (includes process tracking)
sudo rustnet -i eth0 --pcap-export capture.pcap
# Creates:
# capture.pcap - Standard PCAP file
# capture.pcap.connections.jsonl - Process attribution (PID, name, timestamps)
# Or write an annotated PCAPNG directly during live capture
sudo rustnet -i eth0 --pcapng-export annotated.pcapng
# Or enrich a classic PCAP after capture
python scripts/pcap_enrich.py capture.pcap -o enriched.pcapng
# Open in Wireshark - packets now show process info in comments
wireshark annotated.pcapng
```
This workflow gives you the best of both worlds:
- **RustNet's process attribution**: Know which application generated each packet
- **Wireshark's deep analysis**: Full protocol dissection with 3000+ analyzers
Native PCAPNG export embeds live best-effort packet comments directly. The enrichment script remains useful when cleanup-time sidecar metadata completeness is more important than producing a single file during capture.
See [USAGE.md - PCAP Export](USAGE.md#pcap-export) for detailed documentation.
+490
View File
@@ -0,0 +1,490 @@
<p align="center"><a href="ARCHITECTURE.md">English</a> | <strong>简体中文</strong></p>
# 架构
本文档描述 RustNet 的技术架构和实现细节。
## 目录
- [Crate 结构](#crate-structure)
- [多线程架构](#multi-threaded-architecture)
- [核心组件](#key-components)
- [平台特定实现](#platform-specific-implementations)
- [性能考量](#performance-considerations)
- [依赖项](#dependencies)
- [安全](#security)
## Crate 结构<a id="crate-structure"></a>
RustNet 是一个由四个 crate 组成的 Cargo 工作区。分析逻辑、捕获后端和进程归属各自位于独立的可复用库 crate 中;二进制 crate 将它们组合成 TUI 应用。
| Crate | 类型 | 职责 |
| --- | --- | --- |
| [`rustnet-core`](crates/rustnet-core) | 库 | 与平台和捕获无关的分析核心:数据包解析、协议/连接类型、深度包检测、链路层解析器、连接合并、DNS/GeoIP/OUI 查找,以及供无界面工具使用的可复用 `ConnectionTracker`(实时连接表 + RTT + QUIC 合并 + 生命周期)。仅操作字节切片和已解析的结构 —— 不依赖 libpcap、原始套接字或操作系统进程表。 |
| [`rustnet-capture`](crates/rustnet-capture) | 库 | 基于 libpcap/Npcap 的数据包捕获后端:设备选择、BPF 过滤器、macOS PKTAP、TUN/TAP,以及原始帧 `PacketReader`。 |
| [`rustnet-host`](crates/rustnet-host) | 库 | 单一 `ProcessLookup` trait 背后的按连接进程归属:Linux 上的 eBPF/procfs、macOS 上的 PKTAP/lsof、Windows 上的 IP Helper API,以及 FreeBSD 上的 `sockstat`。负责 eBPF 构建工具链及内置的 `vmlinux.h`。 |
| `rustnet-monitor`(二进制 `rustnet`) | 二进制 | 面向用户的应用:CLI、TUI、应用事件循环、沙箱(Landlock/Seatbelt)和接口统计。以 `ConnectionTracker` 作为唯一数据来源(dogfooding)。 |
包名为 `rustnet-monitor`,因为 `rustnet` 这个 crate 名称在 crates.io 上已被占用;安装后的二进制文件名为 `rustnet`
### 依赖关系图
```mermaid
flowchart TD
BIN[rustnet-monitor<br/>二进制: rustnet]
CAP[rustnet-capture]
HOST[rustnet-host]
CORE[rustnet-core]
BIN --> CAP
BIN --> HOST
BIN --> CORE
CAP --> CORE
HOST --> CORE
```
依赖关系是无环的:`rustnet-core` 没有任何工作区内依赖,`rustnet-capture``rustnet-host` 都仅依赖它。让 `rustnet-core` 保持为叶子节点,使其可以独立发布和复用 —— 无界面的前端(例如 Prometheus 导出器)可以仅组合 `rustnet-capture` + `rustnet-core` 而无需 TUI。
### 重导出门面
为了将拆分对二进制内部保持透明,`src/network/mod.rs` 重导出 `rustnet_core::network::*``rustnet_capture`(作为 `capture`),因此现有的 `crate::network::*` 路径、集成测试和基准测试无需改动即可编译。`src/network/platform` 模块仍承载操作系统沙箱(Landlock/Seatbelt)和接口统计采集器,并接入 `rustnet-host` 的进程查找。
## 多线程架构<a id="multi-threaded-architecture"></a>
RustNet 采用多线程架构以实现高效的数据包处理:
```mermaid
flowchart LR
PC[数据包捕获<br/>libpcap]
CH([Crossbeam Channel])
PP[数据包处理器<br/>线程 0..N]
PE[进程信息补全<br/>平台 API]
DM[(DashMap)]
SP[快照提供器]
UI[/RwLock&lt;Vec&lt;Connection&gt;&gt;<br/>供 UI 使用/]
CT[清理线程]
PC -- 数据包 --> CH --> PP --> DM
PE --> DM
DM --> SP --> UI
DM --> CT
```
## 核心组件<a id="key-components"></a>
### 1. 数据包捕获线程
使用 libpcap 从网络接口捕获原始数据包。该线程独立运行,将数据包送入 Crossbeam channel 进行处理。
**职责:**
- 打开网络接口进行数据包捕获(非混杂、只读模式)
- 按需应用 BPF 过滤器
- 捕获原始数据包
- 如果启用了 `--pcap-export`,将数据包流式写入 PCAP 文件(直接磁盘写入,无内存缓冲)
- 如果启用了 `--pcapng-export`,将解析后的数据包送入带注释 PCAPNG writer(有界 best-effort 队列)
- 将数据包发送到处理队列
### 2. 数据包处理器
多个工作线程(默认最多 4 个,基于 CPU 核心数)解析数据包并执行深度包检测(DPI)分析。
**职责:**
- 解析 Ethernet、IP、TCP、UDP、ICMP、ARP 头部
- 提取连接五元组(协议、源 IP、源端口、目的 IP、目的端口)
- 执行 DPI 检测应用协议:
- 带主机信息的 HTTP
- 带 SNIServer Name Indication)的 HTTPS/TLS
- DNS 查询和响应
- 带版本检测的 SSH 连接
- 带命令、响应代码、用户名、服务器软件和系统类型的 FTP 控制通道
- 带 CONNECTION_CLOSE 帧检测的 QUIC 协议
- 带报文类型、版本和客户端标识符的 MQTT
- BitTorrent 握手和 DHT 消息
- 用于 WebRTC 和 NAT 穿越的 STUN
- 带版本、模式和 stratum 的 NTP
- 用于本地名称解析的 mDNS 和 LLMNR
- 带消息类型和主机名的 DHCP
- 带 PDU 类型的 SNMPv1、v2c、v3
- 用于 UPnP 设备发现的 SSDP
- NetBIOS 名称服务和数据报服务
- 追踪连接状态和生命周期
- 在 DashMap 中更新连接元数据
- 计算带宽指标
### 3. 进程信息补全
使用平台特定的 API 将网络连接与运行中的进程关联。该组件定期运行,为连接数据补充进程信息。
**职责:**
- 将 socket inode 映射到进程 ID
- 解析进程名和命令行
- 用进程信息更新连接记录
- 处理与权限相关的回退
各平台的详情见[平台特定实现](#platform-specific-implementations)。
### 4. 快照提供器
按固定间隔(默认 1 秒)创建连接数据的一致快照供 UI 使用。这确保 UI 拥有稳定的连接视图,避免竞争条件。
**职责:**
- 按配置间隔从 DashMap 读取
- 根据用户条件应用过滤(localhost 等)
- 按用户选择的列排序连接
- 为 UI 渲染创建不可变快照
- 为 UI 线程提供 RwLock 保护的 Vec<Connection>
### 5. 清理线程
使用智能的、协议感知的超时机制移除不活跃的连接。这防止内存泄漏并保持连接列表的相关性。当启用 `--pcap-export` 时,连接关闭时还会将连接元数据(PID、进程名、时间戳)流式写入 JSONL sidecar 文件。
**超时策略:**
#### TCP 连接
- **HTTP/HTTPS**(通过 DPI 检测):**10 分钟** —— 支持 HTTP keep-alive
- **SSH**(通过 DPI 检测):**30 分钟** —— 适应长交互会话
- **活跃已建立**(< 1 分钟空闲):**10 分钟**
- **空闲已建立**(> 1 分钟空闲):**5 分钟**
- **TIME_WAIT**30 秒 —— 标准 TCP 超时
- **CLOSED**5 秒 —— 快速清理
- **SYN_SENT、FIN_WAIT 等**30-60 秒
#### UDP 连接
- **HTTP/3(带 HTTP 的 QUIC****10 分钟** —— 连接复用
- **HTTPS/3(带 HTTPS 的 QUIC****10 分钟** —— 连接复用
- **基于 UDP 的 SSH****30 分钟** —— 长会话
- **DNS****30 秒** —— 短查询
- **普通 UDP****60 秒** —— 标准超时
#### QUIC 连接(检测到的状态)
- **已连接**:默认 3 分钟,或当存在对端的 `max_idle_timeout` 传输参数时使用该值
- **带 CONNECTION_CLOSE 帧**1-10 秒(基于关闭类型)
- **Initial/Handshaking**60 秒 —— 允许连接建立
- **Draining**10 秒 —— RFC 9000 draining 周期
- **Closed**1 秒 —— 立即清理
**视觉陈旧度指示器:**
连接根据距离超时的远近改变颜色:
- **白色**(默认):< 75% 的超时时间
- **黄色**:75-90% 的超时时间(警告)
- **红色**:> 90% 的超时时间(严重)
### 6. 速率刷新线程
每秒更新带宽计算,采用温和衰减。这提供平滑的带宽可视化,避免突变。
**职责:**
- 计算下载和上传的字节/秒
- 对旧测量值应用指数衰减
- 更新可视化带宽指示器
- 维护包速率的滚动窗口
### 7. DashMap
并发哈希表(`DashMap<ConnectionKey, Connection>`)用于存储连接状态。这种无锁数据结构支持来自多个线程的高效并发访问。
**关键特性:**
- 细粒度锁定(per-shard
- 无全局锁竞争
- 安全的并发读写
- 高并发负载下的高性能
## 平台特定实现<a id="platform-specific-implementations"></a>
### 进程查找
RustNet 使用平台特定的 API 将网络连接与进程关联:
#### Linux
**标准模式(procfs):**
- 解析 `/proc/net/tcp``/proc/net/udp` 获取 socket inode
- 遍历 `/proc/<pid>/fd/` 查找 socket 文件描述符
- 将 inode 映射到进程 ID,并从 `/proc/<pid>/cmdline` 解析进程名
**eBPF 模式(Linux 默认):**
- 使用附加到 socket 系统调用的内核 eBPF 程序
- 捕获带进程上下文的 socket 创建事件
- 比 procfs 扫描开销更低
- **局限性:**
- 进程名限制为 16 个字符(内核 `comm` 字段)
- 可能显示线程名而非完整可执行文件名
- 多线程应用显示内部线程名
- **Linux capabilities 需求:**
- 现代 Linux5.8+):`CAP_NET_RAW`(包捕获)、`CAP_BPF``CAP_PERFMON`eBPF
- 旧版 Linuxpre-5.8):eBPF 需要宽泛的 `CAP_SYS_ADMIN`;RustNet 安装包不会自动授予它,并会回退到 procfs
- 注意:不需要 CAP_NET_ADMIN(使用只读、非混杂包捕获)
**回退行为:**
- 如果 eBPF 加载失败(权限、内核兼容性),自动回退到 procfs 模式
- TUI 统计面板显示当前使用的检测方法
#### macOS
**PKTAP 模式(使用 sudo 时):**
- 使用 PKTAPPacket Tap)内核接口
- 直接从包元数据提取进程信息
- 需要 root 特权(特权内核接口)
- 比 lsof 更快更准确
**lsof 模式(无 sudo 或回退时):**
- 使用 `lsof -i -n -P` 列出网络连接
- 解析输出以将 socket 与进程关联
- CPU 开销更高,但无需 root
- 当 PKTAP 不可用时自动使用
**检测:**
- TUI 统计面板根据当前方法显示 "pktap" 或 "lsof"
- 自动选择最佳可用方法
#### Windows
**IP Helper API**
- 使用 Windows IP Helper API 的 `GetExtendedTcpTable``GetExtendedUdpTable`
- 检索带进程 ID 的连接表
- 支持 IPv4 和 IPv6 连接
- 使用 `OpenProcess``QueryFullProcessImageNameW` 解析进程名
**需求:**
- 根据系统配置可能需要 Administrator 特权
- 包捕获需要 Npcap 或 WinPcap
### 网络接口
该工具使用平台特定的方法自动检测和列出可用网络接口:
- **Linux**:使用 `netlink` 或回退到 `/sys/class/net/`
- **macOS**:使用 `getifaddrs()` 系统调用
- **Windows**:使用 IP Helper API 的 `GetAdaptersInfo()`
- **所有平台**:当原生方法失败时回退到 pcap 的 `pcap_findalldevs()`
## 性能考量<a id="performance-considerations"></a>
### 多线程处理
数据包处理分布在多个线程上(默认最多 4 个,基于 CPU 核心数)。这实现了:
- 并行数据包解析和 DPI 分析
- 更好地利用多核系统
- 降低高包速率下的延迟
### 并发数据结构
**DashMap** 提供无锁并发访问,具备:
- Per-shard 锁定(默认 16 个 shard
- 无全局锁竞争
- 读密集型工作负载优化
- 安全的并发修改
### 批处理
数据包以批次处理以提高缓存效率:
- 上下文切换前处理多个数据包
- 减少系统调用开销
- 更好的 CPU 缓存利用率
### 选择性 DPI
可以使用 `--no-dpi` 禁用深度包检测以降低开销:
- 在高流量网络中降低 20-40% 的 CPU 使用率
- 仍然追踪基本连接信息
- 适用于性能受限的环境
### 可配置间隔
根据需求调整刷新率:
- **UI 刷新**:默认 1000ms(可通过 `--refresh-interval` 调整)
- **进程补全**:每 2 秒
- **清理检查**:每 5 秒
- **速率计算**:每 1 秒
### 内存管理
**连接清理**防止内存无界增长:
- 协议感知的超时移除陈旧连接
- 移除前的视觉陈旧度警告
- 可配置的超时阈值
**快照隔离**防止 UI 阻塞:
- UI 从不可变快照读取
- 后台线程并发更新 DashMap
- UI 和数据包处理之间无锁竞争
## 依赖项<a id="dependencies"></a>
RustNet 使用以下关键依赖构建:
### 核心依赖
- **ratatui** —— 终端用户界面框架,带完整控件支持
- **crossterm** —— 跨平台终端操作
- **pcap** —— libpcap/Npcap 的包捕获库绑定
- **pnet_datalink** —— 网络接口枚举和低层网络
### 并发与线程
- **dashmap** —— 细粒度锁定的并发哈希表
- **crossbeam** —— 多线程工具和无锁 channel
### 网络与协议
- **dns-lookup** —— DNS 解析能力
- **maxminddb** —— GeoIP 数据库查询(GeoLite2
### 序列化
- **serde** / **serde_json** —— 事件日志和 PCAP sidecar 的 JSON 序列化
### 命令行与日志
- **clap** —— 带 derive 特性的命令行参数解析
- **simplelog** —— 灵活的日志框架
- **log** —— 日志门面
- **anyhow** —— 错误处理和上下文
### 平台特定
- **procfs**Linux)— 从 /proc 文件系统获取进程信息(运行时回退)
- **libbpf-rs**Linux)— eBPF 程序加载和管理
- **landlock**Linux)— 文件系统和网络沙箱
- **caps**Linux)— Linux capabilities 管理
- **windows**Windows)— IP Helper API 的 Windows API 绑定
### 工具库
- **arboard** —— 复制地址的剪贴板访问
- **chrono** —— 日期和时间处理
- **ring** —— 加密操作(用于 TLS/SNI 解析)
- **aes** —— AES 加密支持(用于协议检测)
- **flate2** —— Gzip 解压(用于压缩的嵌入式数据)
- **libc** —— 低层 C 绑定
## 嵌入式数据文件
RustNet 在编译时嵌入静态查找数据库,避免运行时文件依赖。两者遵循相同的模式:嵌入文件,启动时解析为 `HashMap`,暴露 `lookup()` 方法。
### 服务查找(`crates/rustnet-core/assets/services`
端口到服务名的映射(例如 80/tcp -> http)。由 `crates/rustnet-core/src/network/services.rs` 中的 `ServiceLookup` 使用 `include_str!` 加载。
### OUI 厂商数据库(`crates/rustnet-core/assets/oui.gz`
IEEE MA-L OUI 前缀到厂商的映射,用于 MAC 地址厂商解析(例如 `00:1B:63` -> Apple)。Gzip 压缩以减小二进制体积(压缩后约 400KB,原始约 1.2MB)。由 `crates/rustnet-core/src/network/oui.rs` 中的 `OuiLookup` 使用 `include_bytes!` + `flate2` 在启动时解压。
GitHub Action`.github/workflows/update-oui.yml`)每月从 [IEEE 公开数据库](https://standards-oui.ieee.org/oui/oui.txt) 更新此文件,如有变更则自动打开 PR。
## 安全<a id="security"></a>
关于 Landlock 沙箱、权限需求和威胁模型的安全文档,参见 [SECURITY.zh-CN.md](SECURITY.zh-CN.md)。
## 与同类工具的对比<a id="comparison-with-similar-tools"></a>
网络监控工具存在于从简单连接列表到完整数据包取证的光谱上:
```
简单 ←─────────────────────────────────────────────────────→ 复杂
netstat iftop bandwhich RustNet tcpdump Wireshark
│ │ │ │ │ │
└── Socket ┴── Bandwidth ──────────┴── Live DPI ┴── Capture ──┴── Forensics
state monitoring + Process & CLI & Deep
tracking Analysis
```
**RustNet 的定位**:实时连接监控,带 DPI 和进程识别——比带宽监控器功能更强,比取证捕获工具更聚焦。
### 功能对比
| 功能 | RustNet | bandwhich | sniffnet | iftop | netstat | ss | tcpdump/wireshark |
|---------|---------|-----------|----------|-------|---------|-----|-------------------|
| **语言** | Rust | Rust | Rust | C | C | C | C |
| **界面** | TUI | TUI | GUI | TUI | CLI | CLI | CLI/GUI |
| **实时监控** | 是 | 是 | 是 | 是 | 快照 | 快照 | 是 |
| **进程识别** | 是 | 是 | 否 | 否 | 是 | 是 | 否 |
| **深度包检测** | 是 | 否 | 否 | 否 | 否 | 否 | 是 |
| **SNI/主机提取** | 是 | 否 | 否 | 否 | 否 | 否 | 是 |
| **协议状态追踪** | 是 | 否 | 部分 | 否 | 是 | 是 | 是 |
| **逐连接带宽** | 是 | 是 | 是 | 是 | 否 | 否 | 否 |
| **连接过滤** | 是 | 否 | 是 | 是 | 否 | 是 | 是(BPF |
| **DNS 反向解析** | 是 | 是 | 是 | 是 | 否 | 否 | 是 |
| **GeoIP 查询** | 是 | 否 | 是 | 否 | 否 | 否 | 是 |
| **通知** | 否 | 否 | 是 | 否 | 否 | 否 | 否 |
| **i18n(翻译)** | 否 | 否 | 是 | 否 | 否 | 否 | 否 |
| **跨平台** | Linux、macOS、Windows、FreeBSD | Linux、macOS | Linux、macOS、Windows | Linux、macOS、BSD | 全部 | Linux | 全部 |
| **eBPF 支持** | 是(Linux | 否 | 否 | 否 | 否 | 是 | 否 |
| **Landlock 沙箱** | 是(Linux | 否 | 否 | 否 | 否 | 否 | 否 |
| **JSON 事件日志** | 是 | 否 | 否 | 否 | 否 | 否 | 是 |
| **PCAP 导出** | 是(PCAP + sidecar,或带注释 PCAPNG | 否 | 是 | 否 | 否 | 否 | 是 |
| **数据包捕获** | libpcap | Raw sockets | libpcap | libpcap | Kernel | Kernel | libpcap |
### 工具聚焦领域
- **RustNet**TUI 中的实时连接监控,带 DPI、协议状态追踪和进程识别
- **bandwhich**:按进程/连接的带宽利用率,开销最小
- **sniffnet**:带图形界面和通知的网络流量分析
- **iftop**:带逐主机流量显示的接口带宽监控
- **netstat/ss**:系统 socket 和连接状态检查(ss 是 Linux 上 netstat 的现代替代品)
- **tcpdump/wireshark/tshark**:用于深度调试的完整数据包捕获和协议分析
### 如何选择合适的工具
| 你的目标 | 最佳工具 |
|-----------|-----------|
| 查看哪个进程正在建立连接 | RustNet |
| 逐字节解码数据包 | Wireshark |
| 监控连接状态(SYN_SENT、ESTABLISHED 等) | RustNet |
| 从流量中提取文件或凭据 | Wireshark |
| 将网络活动归因于特定应用 | RustNet |
| 深度协议解析(3000+ 协议) | Wireshark |
| 快速终端网络概览 | RustNet |
| 保存带进程归因的捕获 | RustNet(`--pcap-export``--pcapng-export` |
| 保存捕获用于深度分析 | Wireshark/tcpdump |
### RustNet 与 Wireshark:不同的强项
关键区别:**RustNet 知道每个连接属于哪个进程。Wireshark 不知道。**
Wireshark 在数据包捕获层(libpcap)运行——它看到原始网络流量,但不知道哪个应用创建了它。RustNet 将数据包捕获与 OS 级 socket 内省(通过 Linux eBPF、/proc 或平台 API)相结合,将每个连接归因于其所属进程。
| 能力 | RustNet | Wireshark |
|------------|---------|-----------|
| 进程识别 | 是(eBPF、procfs、平台 API | 否 |
| 连接状态追踪 | 原生(TCP FSM、QUIC 状态) | 通过解析器 |
| 协议解析器 | ~15 个常见协议 | 3000+ 协议 |
| 数据包级检查 | 仅元数据 | 完整 payload |
| 界面 | TUI(终端) | GUI |
| 捕获到文件 | 是(`--pcap-export``--pcapng-export` | 是(原生) |
两种工具都可以实时运行。根据你想看什么来选择:
- **"是什么在发起这个连接?"** → RustNet
- **"这个数据包里有什么?"** → Wireshark
### 弥合差距:带进程归因的 PCAP 导出
RustNet 现在可以在保留进程归因的同时导出数据包捕获——这是 tcpdump 和 Wireshark 单独都无法做到的:
```bash
# 使用 RustNet 捕获数据包(包含进程追踪)
sudo rustnet -i eth0 --pcap-export capture.pcap
# 创建:
# capture.pcap - 标准 PCAP 文件
# capture.pcap.connections.jsonl - 进程归因(PID、名称、时间戳)
# 用进程信息富化 PCAP 并创建注释过的 PCAPNG
python scripts/pcap_enrich.py capture.pcap -o annotated.pcapng
# 或者直接写出带 RustNet 数据包注释的 PCAPNG
sudo rustnet -i eth0 --pcapng-export annotated.pcapng
# 在 Wireshark 中打开 —— 数据包现在显示进程信息注释
wireshark annotated.pcapng
```
这个工作流让你兼得两者之长:
- **RustNet 的进程归因**:知道哪个应用生成了每个数据包
- **Wireshark 的深度分析**3000+ 解析器的完整协议解析
原生 PCAPNG 导出会直接嵌入实时 best-effort 数据包注释。富化脚本在清理阶段 sidecar 元数据完整性比捕获时生成单个文件更重要时仍然有用。
详见 [USAGE.zh-CN.md - PCAP 导出](USAGE.zh-CN.md#pcap-export)。
+601
View File
@@ -0,0 +1,601 @@
# Changelog
All notable changes to RustNet will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.4.0] - 2026-06-16
This release redesigns the TUI around a calmer visual hierarchy and, under the hood,
splits RustNet into a Cargo workspace of reusable library crates. Many of the TUI ideas
came from a detailed UI review by [@joshka](https://github.com/joshka) (Ratatui
maintainer) on our showcase submission
([ratatui/ratatui-website#1118](https://github.com/ratatui/ratatui-website/pull/1118)) —
thanks for the thoughtful feedback!
### Added
- **Theme Presets**: New `--theme` flag. The default `muted` preset keeps a single
cyan accent and reserves color for signals (state changes, staleness, live
bandwidth) and addresses; `--theme classic` restores the previous full-color palette (#377)
- **System Sidebar Toggle**: The System panel now has a fixed width and can be
hidden with the `i` key (auto-hidden on narrow terminals) (#377)
- **Details Continuity Strip**: The Details tab opens with a mini connection table
of the selected row and its neighbors; `j`/`k` flips through them without leaving
the tab, following the grouped order when process grouping is enabled (#377)
- **Direct-Jump Tab Shortcuts**: Jump straight to a tab with keys `1`-`5`, with
bracket cycle aliases (#318, thanks @obchain)
- **Connection List Scrollbar**: A scrollbar appears on the connection list when it
overflows the viewport (#365)
- **FTP Deep Packet Inspection**: Detect the FTP control channel and extract command
and response metadata (#266, thanks @0xghost42)
- **DNS / mDNS / LLMNR Response IPs**: Populate `response_ips` from A/AAAA answer
records and extend the extraction to mDNS and LLMNR responses (#319, #333, #341, thanks @0xghost42)
- **Log Identity Banner**: Emit a program identity banner and the module target on
every log line for easier diagnostics (#320, thanks @0xghost42)
- **Landlock v6 IPC Scoping** (Linux): Best-effort Landlock that scopes abstract-socket
and signal IPC on kernels that support it, falling back gracefully on older ABIs (#363)
- **`no_new_privs` Always Set + cargo-deny**: Always set `no_new_privs` at startup, and
adopt `cargo-deny` for supply-chain and license auditing in CI (#382)
- **openSUSE OBS Release Pipeline**: Automated openSUSE Build Service releases (#356)
### Changed
- **Stable Column Layout**: Column widths depend only on the terminal width — they
no longer shift while scrolling. Narrow terminals hide low-priority columns
instead of truncating cells; wide terminals distribute the spare width so the
table spans the full screen with the bandwidth column flush right (#377)
- **Merged Proto/App Column**: The Protocol column is merged into Application
("TCP·HTTPS"), and the status-dot column is gone — staleness now lives entirely
in the row styling (#377)
- **Custom Tab Bar and Borderless Sections**: Numbered tab bar with an accent
underline, a single-line filter prompt, and section headers in place of the
border-box-around-everything look (#377)
- **Dependencies**: Routine dependency and GitHub Actions updates across the cycle
(Dependabot, ~18 PRs)
### Fixed
- **Process attribution for short-lived and multithreaded processes** (Linux):
eBPF socket tracking now records the process name (thread-group leader)
instead of the calling thread's name, so connections from e.g. firefox or dig
no longer show up as "Socket Thread" or "isc-net-0000"; PID-to-name
resolution reads `/proc/<pid>/comm` on demand instead of waiting for the
periodic scan; and new connections are enriched on a fast 250ms tick, so
process names appear almost immediately instead of after up to 2 seconds (#376)
- **DLT_NULL Link Layer**: Strip the 4-byte address-family header before parsing
DLT_NULL/loopback captures (#394, thanks @0xghost42)
- **Terminal Restore on Panic**: Restore the terminal via a chained panic hook so a
panic no longer leaves the terminal in raw mode (#364)
- **Scrollbar Thumb**: The scrollbar thumb now reaches the bottom at max scroll (#366)
- **Landlock `/sys` Access**: Allow read access to `/sys` so interface statistics work
under the Landlock sandbox (#370)
- **Filter Mode Backspace**: Handle raw backspace characters in filter mode (#335, thanks @iccccccccccccc)
- **eBPF Error Surfacing**: Classify libbpf errors and surface them in the TUI (#255, #258)
- **Native Builds**: Skip cross-compile library paths on native builds (#259)
- **RPM Packaging**: Own the directories and hicolor icon dirs the package creates, and
require `libcap-progs` on openSUSE so the `%post` `setcap` runs (#357, #358, #359, #360)
### Performance
- **Per-Packet Allocations**: Cut per-packet allocations and snapshot copy-on-write
copies on the hot path (#380)
- **Core Types**: Add `Protocol::as_str()` and drop per-row/per-filter `to_string`
allocations (#392, thanks @obchain)
- **Connection Table**: Borrow the process name in `process_text` instead of cloning (#390, thanks @obchain)
- **Sparklines / Parsers**: Single-allocation sparkline getters, fewer redundant
collects in the HTTP and SSH parsers, and removed redundant clones in the render path
and sandbox init (#339, #345, #355, thanks @obchain)
### Internal
- **Cargo Workspace Split**: RustNet is now a four-crate workspace — `rustnet-core`
(packet parsing, protocol/DPI types, link-layer, connection merging, DNS/GeoIP/OUI
lookups), `rustnet-capture` (libpcap/Npcap capture backend), `rustnet-host`
(per-connection process attribution), and the `rustnet-monitor` binary. The three
libraries are now published to crates.io alongside the binary (#367)
### Documentation
- **Simplified Chinese**: Added a Simplified Chinese README translation and translated
the rest of the docs, plus zh-CN openSUSE Tumbleweed install instructions
(#263 thanks @whtis, #277 thanks @luojiyin1987, #361)
- **Install / Packaging Docs**: Nix and NixOS instructions and nixpkgs/NixOS-module
notes, Homebrew core formula pointer, Repology packaging overview, a Mermaid
architecture diagram, and a PR template with tightened contributor guidelines
(#264, #270, #281, #285, #286, #311, #332, #369)
- **Ubuntu 26.04 (Resolute) PPA**: Added the Resolute PPA build (#254, #256)
### Contributors
Special thanks to the contributors in this release:
- [@0xghost42](https://github.com/0xghost42) — FTP DPI, DNS/mDNS/LLMNR response-IP
extraction, the log identity banner, the DLT_NULL fix, and many DPI/eBPF refactors
(#266, #278, #279, #289, #290, #307, #309, #319, #320, #333, #341, #394)
- [@obchain](https://github.com/obchain) — performance and allocation cleanups across
the DPI parsers, render path, and core types, plus direct-jump tab shortcuts
(#292, #294, #296, #301, #303, #317, #318, #327, #339, #345, #355, #390, #392)
- [@iccccccccccccc](https://github.com/iccccccccccccc) — raw backspace handling in filter mode (#335)
- [@whtis](https://github.com/whtis) (HaiTao Wu) — Simplified Chinese README translation (#263)
- [@luojiyin1987](https://github.com/luojiyin1987) (luo jiyin) — Simplified Chinese documentation translation (#277)
## [1.3.0] - 2026-05-05
The headline of this release is a major TUI refresh. The tabs, stats panel, and details view have all been redesigned, with new per-field colors, a status dot, and address scope labels making it easier to read connections at a glance.
### Added
- **TUI Revamp**: Redesigned tabs, stats panel, and details view (#239)
- **Per-field Colors and Status Dot**: New per-field colors, status dot, and magenta panel borders for at-a-glance readability (#241)
- **Address Scope Labels**: Remote addresses are tagged PUBLIC, PRIVATE, etc. in the connection list (#251)
- **Reverse DNS Resolution by Default**: Reverse DNS resolution is now enabled by default. Use the new `--no-resolve-dns` flag to opt out (#245)
### Fixed
- **Sandbox Info on Overview**: Show the full sandbox details on the overview tab (#250)
- **Search Scope and Status Bars**: Scope the `/` search to Overview and tidy the status bars (#229, #230)
- **QUIC Initial Packet Parser**: Bounds-check `token_len` in the Initial packet parser (#244)
- **QUIC Varint Parser**: Bounds-check varint lengths and isolate parser panics (#232)
- **Release Pipeline**: Fix the downstream trigger race and AUR token permissions (#223)
### Changed
- **Demo Recording Automation**: Automate VHS recording for the demo GIF and README screenshots (#247)
- **OUI Vendor Database**: Refreshed IEEE OUI vendor database (#242)
- **Dependencies**: Bumped `rand` (0.8.5 to 0.8.6), `openssl` (0.10.75 to 0.10.78), `zip`, `libbpf-cargo`, and other rust-dependencies and actions group updates (#224, #225, #226, #227, #231, #233, #234, #238, #240, #243)
### Documentation
- **Windows Sandbox Terminology**: Accurate Windows sandbox terminology and roadmap entry (#237)
- **README Polish**: README hero polish, metadata tune-up, and accuracy fixes (#236)
- **Crate and Module Docs**: Expanded crate and module docs and tuned metadata for discoverability (#235)
## [1.2.0] - 2026-04-09
### Added
- **Windows Restricted Token Sandbox**: Drop privileges at startup on Windows using a restricted process token (#206)
- **macOS Seatbelt Sandboxing**: Apply a Seatbelt sandbox profile at startup on macOS, later tightened to restrict filesystem and IPC access (#196, #203)
- **Linux Sandbox Hardening**: Drop Linux capabilities and clear the ambient capability set after startup (#208)
- **Process Privilege in UI**: Show whether a process is privileged in the security section of the TUI (#197)
- **Filter: Exact Port Matching and Regex Support**: Filter syntax supports exact port matches and regex patterns (#195)
- **VLAN Support in PKTAP and SLL/SLL2**: Parse VLAN tags in PKTAP and SLL/SLL2 capture formats (#202)
- **VLAN Header in Layer 3 Extraction**: Account for VLAN headers when extracting layer 3 data (#199, thanks @deepakpjose)
- **IGMP Protocol Parsing**: Recognize and parse IGMP traffic (#209, thanks @deepakpjose)
- **Process Name for Wildcard /proc/net/ Entries**: Resolve process names for wildcard (`0.0.0.0`/`::`) entries in `/proc/net/` (#218, thanks @deepakpjose)
- **CI Supply-Chain Hardening**: Pin GitHub Actions to commit SHAs and verify Npcap installer checksums (#210)
- **Architecture Roadmap**: Added workspace split and macOS privilege separation roadmap docs (#211)
### Fixed
- **Default Interface Selection**: Use the active routing table to pick the default interface (#194, thanks @l1a)
- **Root Detection on Unix**: Use `geteuid()` instead of `getuid()` to detect root (#192, thanks @DeepChirp)
- **Release Pipeline Reliability**: Improved release workflow reliability, gated downstream jobs on `publish-release`, added checksum verification to AUR updates, and documented the no-retag policy (2a38f2d, 795f7a1, 002eb55, 8403a0f)
- **FreeBSD CI Dispatch**: Restrict FreeBSD dispatch to manual triggers only (#201)
### Changed
- **CPU Efficiency Improvements**: Substantial reductions in CPU usage across hot paths — rate calculation moved from per-update to per-refresh (#220), timeouts avoided to improve CPU performance (#213), threads given meaningful names to aid profiling (#212), and allocations reduced in sorting and snapshot paths (#222). Big thanks to [@deepakpjose](https://github.com/deepakpjose) for driving the CPU-efficiency work (#213, #220, #212) — these changes make RustNet noticeably lighter on the CPU.
- **FreeBSD Platform Cleanup**: Refactored FreeBSD platform support code (#205)
- **Dependencies**: Bumped `zip` (8.2.0 → 8.3.0 → 8.5.0), `clap_mangen`, `docker/login-action`, and other rust-dependencies group updates (#198, #200, #214, #216, #219, #221)
- **OUI Vendor Database**: Refreshed IEEE OUI vendor database (#215)
### Contributors
Special thanks to the external contributors in this release:
- [@deepakpjose](https://github.com/deepakpjose) — CPU-efficiency improvements and additional features (#199, #209, #212, #213, #218, #220)
- [@l1a](https://github.com/l1a) — default interface selection via active routing table (#194)
- [@DeepChirp](https://github.com/DeepChirp) — Unix root detection via `geteuid()` (#192)
## [1.1.0] - 2026-03-16
### Added
- **OUI Vendor Lookup for ARP**: Display MAC vendor names for ARP connections using IEEE OUI database (#183)
- **Historic Connections Toggle**: Toggle to show/hide historic (closed) connections (#184)
- **Mouse Support**: Mouse interaction support for TUI navigation (#170)
- **Security Hardening & Packet Stats**: Enhanced security hardening and packet statistics display in TUI (#169)
- **GeoIP City Lookup**: Show city-level geolocation for remote IPs using GeoLite2 City database (#168)
- **Android Build Support**: Native Android builds with static musl linking (#167)
- **Multi-Arch Android Builds**: Added armv7, x86_64, and x86 Android static build targets
- **MQTT Protocol Detection**: Deep packet inspection for MQTT protocol traffic (#161)
- **STUN Traffic Detection**: Detect STUN protocol traffic per RFC 5389/8489 (#160)
- **BitTorrent Traffic Detection**: Detect BitTorrent protocol traffic (#159)
- **ARP Performance Benchmarks**: Added criterion benchmarks for ARP-related operations (#188)
### Fixed
- **Undefined Behavior Fix**: Fix UB issues, remove clippy suppressions, add safety documentation (#187)
- **Light Terminal Readability**: Fix selection highlight unreadable on light terminal themes (#182)
- **Clipboard Warning**: Fix unused variable warning in copy_to_clipboard across platforms (#178)
- **Android Cross-Compilation**: Fix cross-compilation and release upload issues for Android targets (#174)
- **MQTT Detection Accuracy**: Restrict MQTT signature detection to CONNECT packets only (#164)
### Changed
- **Documentation**: Synced docs with implementation, added missing keyboard shortcuts (#190, #157)
- **CI/CD**: Staged release pipeline so downstream jobs wait for builds (#154), added FreeBSD coverage to PR builds (#158)
- **Dependencies**: Bumped chrono, http_req, zip, and various rust-dependencies groups
## [1.0.0] - 2026-02-09
### Added
- **GeoIP Location Support**: Show country codes for remote IPs using GeoLite2 databases with auto-discovery (#151)
- **PCAP Export with Process Attribution**: Export captured packets to PCAP files with a process attribution JSONL sidecar for Wireshark enrichment (#137)
- **eBPF-based ICMP PID Tracking**: Track process IDs for ICMP connections using eBPF on Linux (#136)
- **Process Detection Degradation Warnings**: Show warnings in the UI when process detection falls back to a less accurate method (#128)
- **ARM64 Musl Static Builds**: CI now produces arm64 musl static Linux builds with eBPF support
### Fixed
- **Service Name Precedence**: Corrected ordering when multiple service name sources conflict (#150)
- **Pointer Dereference Safety**: Use `as_ref()` for safer pointer dereference in macOS/FreeBSD interface stats (#147)
- **Clippy Warnings**: Resolve `unnecessary_unwrap` errors flagged by clippy (#144)
- **ICMP Dead Code**: Remove dead code warning in ICMP handling (#138)
- **GitHub Actions Permissions**: Add explicit permissions to all GitHub Actions workflows (#131)
- **Logging Initialization**: Set up logging level before privileges check for earlier diagnostic output (#143)
### Changed
- **SSH Heuristic Tightened**: Tighten SSH packet structure heuristic to reduce false positives (#135)
- **CI Reusable Workflows**: Share build logic via reusable workflow, remove redundant test-static-builds workflow
- **Chocolatey Automation**: Trigger Chocolatey package publish on release automatically
- **Code Alignment**: Refactoring and code alignment improvements (#149)
- **Dependencies**: Updated libbpf-rs to 0.26, bumped clap, time, zip, lru, and libc
- **Documentation**: Clarified RustNet vs Wireshark positioning, added PowerShell font troubleshooting, added JSON logging to feature comparison, added bandwhich to acknowledgments (#129, #130, #132, #133)
## [0.18.0] - 2026-01-07
### Added
- **Process Grouping**: Expandable tree view to group connections by process (`a` to toggle grouping, `Space` to expand/collapse)
- **Traffic Visualization Graph Tab**: New Graph tab with real-time network traffic graphs and bandwidth visualization (press `Tab` to cycle through tabs)
- **Network Health Visualization**: Health indicators in Graph tab showing connection quality metrics
- **Reverse DNS Hostnames**: Display reverse DNS names in Details tab and filter PTR traffic (`--resolve-dns` to enable, `d` to toggle display)
- **BPF Filter Support**: New `--bpf-filter` option for custom packet capture filtering (e.g., `--bpf-filter "port 443"`)
- **Clear All Connections**: New hotkey (`x`) to clear all tracked connections
- **Enhanced JSON Logging**: Added pid, process_name, service_name fields to JSON log output
- **New DPI Protocols**: NTP, mDNS, LLMNR, DHCP, SNMP, SSDP, NetBIOS protocol detection with enhanced ARP display
- **Static Musl Builds**: Linux static binary builds using musl for better portability
- **Platform-Specific Help**: CLI help now shows platform-specific options
### Fixed
- **macOS BPF Filter**: Skip PKTAP when BPF filter is specified to avoid conflicts
- **Linux Clipboard**: Handle clipboard access blocked by Landlock sandbox gracefully
- **Interface Stats**: Use safer pointer dereference in interface statistics
### Changed
- **FreeBSD Builds**: Moved to separate rustnet-bsd repository for native builds
- **CI Improvements**: Homebrew formula auto-update on release, AUR workflow on publish
- **Dependencies**: Updated ratatui to 0.30.0, various dependency updates
- **Documentation**: Added contribution guidelines, Chocolatey and Arch Linux installation instructions
## [0.17.0] - 2025-12-07
### Added
- **Landlock Sandbox for Linux**: Filesystem and network sandboxing for enhanced security
- Restricts filesystem access to `/proc` only after initialization
- Network sandbox blocks TCP bind/connect on kernel 6.4+
- Drops `CAP_NET_RAW` capability after pcap handle is opened
- New CLI options: `--no-sandbox` and `--sandbox-strict`
- Comprehensive security documentation in SECURITY.md
- **eBPF Thread Name Resolution**: Resolve eBPF thread names (e.g., 'Socket Thread') to main process names (e.g., 'firefox')
- Uses periodic procfs PID cache for resolution
- Falls back to eBPF name for short-lived processes
- **AUR Package Automation**: Automated Arch Linux AUR package publishing workflow
### Changed
- **Platform Code Reorganization**: Restructured platform-specific code into cleaner module hierarchy
- `src/network/platform/linux/` - Linux-specific code with eBPF and sandbox subdirectories
- `src/network/platform/macos/` - macOS-specific code
- `src/network/platform/freebsd/` - FreeBSD-specific code
- `src/network/platform/windows/` - Windows-specific code
- **QUIC DPI Simplification**: Unified SNI extraction helpers and simplified QUIC protocol handling
### Fixed
- **Test Determinism**: Made RateTracker tests deterministic with injectable timestamps
## [0.16.1] - 2025-11-22
### Fixed
- **Cross-Compilation**: Fixed eBPF build issues when cross-compiling to non-Linux platforms
- Made `libbpf-cargo` an optional build dependency
- Fixed `build.rs` to check TARGET environment variable instead of host platform
- Prevents Linux-specific dependencies from being built for FreeBSD, macOS, and Windows
- **FreeBSD Build**: Switched from cross-compilation to native FreeBSD VM builds
- Uses `vmactions/freebsd-vm` for native FreeBSD compilation
- Eliminates cross-compilation sysroot and library linking issues
- Ensures FreeBSD builds work reliably with native package manager
## [0.16.0] - 2025-11-22
### Added
- **Network Interface Statistics**: Real-time monitoring of network interface statistics across all platforms
- Cross-platform support for Linux, macOS, Windows, and FreeBSD
- Display of interface-level metrics including packets sent/received, bytes transferred, and errors
- Platform-specific implementations optimized for each operating system
- New interface statistics module with dedicated platform handlers
### Changed
- **Link Layer Parsing**: Refactored link layer parsing into modular components
- Separated link layer types (Ethernet, Linux SLL, Raw IP, TUN/TAP, PKTAP)
- Improved packet parsing architecture for better maintainability
- Enhanced support for various network interface types
### Fixed
- **Windows Interface Stats**: Fixed interface statistics collection on Windows platforms
- Improved reliability of Windows network adapter statistics
- Better handling of Windows-specific network interfaces
- **macOS Interface Stats**: Platform-specific improvements for macOS interface statistics
- Enhanced accuracy of macOS network interface metrics
- Better integration with macOS network stack
## [0.15.0] - 2025-10-25
### Added
- **Ubuntu PPA Packaging**: Official Ubuntu PPA repository for easy installation on Ubuntu/Debian-based distributions
- Automated GitHub Actions workflow for PPA releases
- Support for multiple Ubuntu versions
### Changed
- **Bandwidth Sorting**: Changed bandwidth sorting to use combined up+down total instead of separate up/down sorting
- Simpler sorting behavior: press `s` once to sort by total bandwidth
- Display still shows "Down/Up" with individual values
- Arrow indicator shows when sorting by combined bandwidth total
- **Packet Capture Permissions**: Removed CAP_NET_ADMIN and CAP_SYS_ADMIN requirements
- Uses read-only packet capture (non-promiscuous mode)
- Reduced security footprint with minimal required capabilities
### Fixed
- **Bandwidth Rate Tracking**: Improved accuracy and stability of bandwidth rate calculations
- More consistent rate measurements
- Better handling of network traffic bursts
## [0.14.0] - 2025-10-12
### Added
- **eBPF Enabled by Default on Linux**: eBPF support is now enabled by default on Linux builds for enhanced performance
- Provides faster socket tracking with reduced overhead
- Includes CO-RE (Compile Once - Run Everywhere) support
- Graceful fallback to procfs when eBPF is unavailable
- **JSON Logging for SIEM Integration**: New JSON-structured logging output for security information and event management systems
- Enables integration with enterprise monitoring and security platforms
- Structured log format for easier parsing and analysis
- **TUN/TAP Interface Support**: Added support for TUN/TAP virtual network interfaces
- Enables monitoring of VPN connections and virtual network devices
- Expands interface compatibility for complex network setups
- **Fedora COPR RPM Packaging**: Official Fedora COPR repository for easy installation on Fedora/RHEL-based distributions
### Fixed
- **High CPU Usage on Linux**: Eliminated excessive procfs scanning causing high CPU utilization
- Optimized process lookup frequency and caching strategy
- Significantly reduced system resource consumption during monitoring
### Changed
- **Build Dependencies**: Bundled vmlinux.h files to eliminate network dependency during builds
- Improves build reliability and offline build capability
- Reduces external dependencies for compilation
- **Documentation**: Restructured documentation into focused files with improved musl static build documentation
## [0.13.0] - 2025-10-04
### Added
- **Windows Process Identification**: Implemented full process lookup using Windows IP Helper API
- Uses GetExtendedTcpTable and GetExtendedUdpTable for connection-to-process mapping
- Resolves process names via OpenProcess and QueryFullProcessImageNameW
- Supports both TCP/UDP and IPv4/IPv6 connections
- Implements time-based caching with 2-second TTL for performance
- Migrated from winapi to windows crate (v0.59) for better maintainability
- **Privilege Detection**: Pre-flight privilege checking before network interface access
- Detects insufficient privileges on Linux, macOS, and Windows
- Provides platform-specific instructions (sudo, setcap, Docker flags)
- Shows errors before TUI initialization for better visibility
- Detects container environments with Docker-specific guidance
### Fixed
- **Packet Length Calculation**: Use actual packet length from IP headers instead of captured length
- Extracts Total Length field from IP headers for accurate byte counting
- Fixes severe undercounting for large packets (NFS, jumbo frames)
- Resolves issues with snaplen-limited capture buffers
### Changed
- **Documentation**: Updated ROADMAP.md and README.md with Windows process identification status and Arch Linux installation instructions
## [0.12.1] - 2025-10-02
### Changed
- **Build Configuration**: Improved crate metadata for crates.io publishing
- No functional changes to the binary or runtime behavior
- Enhanced package configuration for better crate ecosystem integration
## [0.12.0] - 2025-10-01
### Added
- **Vim-style Navigation**: Jump to beginning of connection list with `g` and end with `G` (Shift+g)
- **Table Sorting**: Comprehensive sorting functionality for all connection table columns
- Press `s` to cycle through sortable columns (Protocol, Local Address, Remote Address, State, Service, Application, Bandwidth ↓, Bandwidth ↑, Process)
- Press `S` (Shift+s) to toggle sort direction (ascending/descending)
- Visual indicators with arrows and cyan highlighting on active sort column
- Sort by download/upload bandwidth to find bandwidth hogs
- Alphabetical sorting for text columns
- **Port Display Toggle**: Press `p` to switch between service names and port numbers display
- **Connection Navigation Improvements**: Enhanced navigation with better visual cleanup indication
- **Localhost Filtering Control**: New `--show-localhost` command-line flag to override default localhost filtering
### Fixed
- **Windows Double Key Issue**: Fixed duplicate key event handling on Windows platforms
- **Windows MSI Runtime Dependencies**: Added startup check for missing Npcap/WinPcap DLLs
- Displays helpful error message with installation instructions when DLLs are missing
- Added winapi dependency for Windows DLL detection
- Updated README with runtime dependency information
- **Linux Interface Selection**: Fixed "any" interface selection on Linux
- Improved interface detection and validation
- Better error handling for interface configuration
- **Package Dependencies**: Removed unnecessary runtime dependencies (clang, llvm) from RPM and DEB packages
- Reduces installation footprint and dependency conflicts
- **Docker Build**: Removed armv7 architecture from Docker builds for improved stability
### Changed
- **Documentation**: Updated roadmap and README with new features and keyboard shortcuts
## [0.11.0] - 2025-09-30
### Added
- **Docker Support with eBPF**: Docker images now include eBPF support for enhanced performance
- Multi-architecture Docker builds (amd64, arm64)
- eBPF-enabled images for advanced socket tracking on Linux
- Optimized container builds with proper dependency management
- **Cross-Platform Packaging and Release Automation**: Comprehensive automated release workflow
- Automated DEB, RPM, DMG, and MSI package generation
- Cross-platform CI/CD improvements
### Fixed
- **RPM Package Dependencies**: Corrected libelf dependency specification in RPM packages
- **Windows MSI Packaging**: Fixed MSI installer generation issues
- **Release Workflow**: Resolved various release automation issues
## [0.10.0] - 2025-09-28
### Added
- **Rust Version Requirements**: Added minimum Rust version requirement (1.88.0+) for let-chains support
### Changed
- **Build Requirements**: Now requires Rust 1.88.0 or later for advanced language features
## [0.9.0] - 2025-09-18
### Added
- **Experimental eBPF Support for Linux**: Enhanced socket tracking with optional eBPF backend
- eBPF-based socket tracker with CO-RE (Compile Once - Run Everywhere) support
- Minimal vmlinux header (5.5KB instead of full 3.4MB file)
- Graceful fallback mechanism to procfs when eBPF unavailable
- Support for both IPv4 and IPv6 socket tracking
- Optional feature disabled by default (enable with `--features=ebpf`)
- Comprehensive capability checking for required permissions
- **Windows Platform Support**: Network monitoring capability on Windows (without process identification)
## [0.8.0] - 2025-09-11
### Added
- **SSH Deep Packet Inspection (DPI)**: Comprehensive SSH protocol analysis including:
- SSH version detection (SSH-1.x, SSH-2.0)
- Client and server software identification (OpenSSH, PuTTY, libssh, etc.)
- Connection state tracking: Banner, KeyExchange, Authentication, Established
- Algorithm detection and negotiation monitoring
- SSH-specific filtering with `ssh:` prefix in connection filters
- **Enhanced Filtering**: SSH connections now support detailed filtering by software name and connection state
### Improved
- **CI/CD**: Enhanced GitHub Actions with path-based triggers for more efficient workflows
- **Documentation**: Updated README with SSH DPI examples and state descriptions
## [0.7.0] - 2025-09-11
### Fixed
- SecureCRT backspace handling issue
## [0.6.0] - 2025-09-10
### Added
- Connection state filtering (ESTABLISHED, TIME_WAIT, etc.)
## [0.5.0] - 2025-01-09
### Added
- **Connection Filtering System**: New comprehensive filtering capability allowing users to filter connections by:
- Protocol type (TCP, UDP, ICMP)
- Local and remote IP addresses
- Local and remote ports
- Process names
- Service names
- Customizable filter expressions with intuitive UI
- **Enhanced Documentation**: Added asciinema demo recording for better user onboarding
- **Visual Demonstrations**: Added animated GIF showcasing RustNet functionality
### Fixed
- **README Improvements**: Fixed image syntax and formatting issues for better GitHub display
### Changed
- **User Interface**: Enhanced TUI to support dynamic filtering with keyboard shortcuts
- **Documentation**: Improved project presentation with visual aids and demonstrations
## [0.4.0] - 2025-01-29
### Improved
- Enhanced traffic monitoring with better rate tracking and byte counters
- Fixed Linux platform build warnings for improved compilation stability
- Corrected version display to use dynamic version from Cargo.toml instead of hardcoded value
## [0.3.0] - 2024-12-28
### Added
- Created `RELEASE.md` and `ROADMAP.md` for better project organization
- Enhanced memory efficiency through enum variant boxing
### Fixed
- Major clippy warning cleanup (97% reduction from 38 to 1 warnings)
- Refactored functions using `TransportParams` struct to reduce complexity
- Fixed collapsible if patterns and improved code readability
- Eliminated needless borrows and manual implementations
### Changed
- Moved release documentation to dedicated files
- Streamlined README to focus on user information
- Improved code organization and Rust best practices
## [0.2.0] - 2024-12-19
### Added
- **Enhanced PKTAP Support on macOS**: Comprehensive process identification using macOS PKTAP (Packet Tap) headers
- Direct extraction of process names and PIDs from kernel packet metadata
- Robust handling of 20-byte PKTAP process name fields with proper normalization
- Support for both `pth_comm` and `pth_e_comm` (effective command name) fields
- Fallback to `lsof` system commands when PKTAP data is unavailable
- **Process Data Immutability System**: Once process information is set from any source, it becomes immutable to prevent display inconsistencies
- **Advanced Process Name Normalization**: Handles all types of whitespace, control characters, and padding in process names
- **Comprehensive Debug Logging**: Extensive logging for PKTAP header processing, process name extraction, and data flow tracking
### Fixed
- **Process Display Stability on macOS**: Fixed issue where process names would change format during UI scrolling (e.g., "firefox (123)" → "firefox (123)")
- **PKTAP Header Processing**: Improved parsing of raw PKTAP packet headers with better error handling and validation
- **Process Name Consistency**: Eliminated race conditions and data inconsistencies in process name display
- **Whitespace Normalization**: Fixed handling of tabs, multiple spaces, unicode whitespace, and control characters in process names
### Changed
- **Process Enrichment Logic**: Modified to respect existing PKTAP data and only fill in missing information from `lsof`
- **UI Rendering Optimization**: Simplified process name rendering to use pre-normalized data from sources
- **Error Handling**: Enhanced error reporting for PKTAP processing and process lookup failures
### Technical Details
- Implemented `extract_process_name_from_bytes()` function for robust PKTAP process name extraction
- Added immutability enforcement in connection merge logic with violation detection
- Enhanced macOS process lookup with `normalize_process_name_robust()` function
- Improved byte-level debugging and logging for process identification troubleshooting
### Platform-Specific Improvements
- **macOS**: PKTAP now provides primary process identification with significant performance and accuracy improvements over `lsof`-only approach
- **Linux**: Process enrichment logic updated to work consistently with new immutability system
## [0.1.0] - 2024-XX-XX
### Added
- Initial release of RustNet
- Real-time network connection monitoring
- Deep packet inspection (DPI) for HTTP, HTTPS, DNS, SSH, and QUIC
- Cross-platform support (Linux, macOS, Windows)
- Terminal user interface with ratatui
- Multi-threaded packet processing
- Process identification using platform-specific APIs
- Service name resolution
- Configurable refresh intervals and filtering options
- Optional logging with multiple log levels
[Unreleased]: https://github.com/domcyrus/rustnet/compare/v1.4.0...HEAD
[1.4.0]: https://github.com/domcyrus/rustnet/compare/v1.3.0...v1.4.0
[1.3.0]: https://github.com/domcyrus/rustnet/compare/v1.2.0...v1.3.0
[1.2.0]: https://github.com/domcyrus/rustnet/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/domcyrus/rustnet/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/domcyrus/rustnet/compare/v0.18.0...v1.0.0
[0.18.0]: https://github.com/domcyrus/rustnet/compare/v0.17.0...v0.18.0
[0.17.0]: https://github.com/domcyrus/rustnet/compare/v0.16.1...v0.17.0
[0.16.1]: https://github.com/domcyrus/rustnet/compare/v0.15.0...v0.16.1
[0.15.0]: https://github.com/domcyrus/rustnet/compare/v0.14.0...v0.15.0
[0.14.0]: https://github.com/domcyrus/rustnet/compare/v0.13.0...v0.14.0
[0.13.0]: https://github.com/domcyrus/rustnet/compare/v0.12.1...v0.13.0
[0.12.1]: https://github.com/domcyrus/rustnet/compare/v0.12.0...v0.12.1
[0.12.0]: https://github.com/domcyrus/rustnet/compare/v0.11.0...v0.12.0
[0.11.0]: https://github.com/domcyrus/rustnet/compare/v0.10.0...v0.11.0
[0.10.0]: https://github.com/domcyrus/rustnet/compare/v0.9.0...v0.10.0
[0.9.0]: https://github.com/domcyrus/rustnet/compare/v0.8.0...v0.9.0
[0.8.0]: https://github.com/domcyrus/rustnet/compare/v0.7.0...v0.8.0
[0.7.0]: https://github.com/domcyrus/rustnet/compare/v0.6.0...v0.7.0
[0.6.0]: https://github.com/domcyrus/rustnet/compare/v0.5.0...v0.6.0
[0.5.0]: https://github.com/domcyrus/rustnet/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/domcyrus/rustnet/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/domcyrus/rustnet/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/domcyrus/rustnet/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/domcyrus/rustnet/releases/tag/v0.1.0
+120
View File
@@ -0,0 +1,120 @@
<p align="center"> <strong>English</strong> | <a href="CONTRIBUTING.zh-CN.md">简体中文</a></p>
# Contributing to RustNet
Pull requests are very welcome! Whether you're fixing bugs, adding features, improving documentation, or providing feedback, all contributions help make RustNet better.
## Project Scope
RustNet aims to stay small and fast. Not every protocol or feature belongs in the core tool, even when a contribution is well-written.
For Deep Packet Inspection in particular, we lean toward protocols that:
- A meaningful share of RustNet users will actually encounter on their networks.
- Produce visible, useful information (the plaintext window is wide enough to extract real metadata, not just to confirm the protocol exists before everything goes TLS).
- Fit the existing architecture without disproportionate maintenance cost.
If a protocol is rarely seen in modern traffic, almost always TLS-wrapped, or niche to a single user base, we may still close the PR with thanks even if the code is correct. Please open an issue first for any new protocol so we can sanity-check fit before you invest implementation time.
## Performance & Optimizations
If a PR is motivated by performance, it must show that the code is on a real hot path. A microbenchmark alone is **not** enough: it only proves a function got faster in isolation, not that the function matters to overall runtime.
- **Profile first.** Capture a flamegraph under real traffic (see [PROFILING.md](PROFILING.md)) and confirm the code shows up as meaningful CPU time.
- **A bench is supporting evidence, not proof.** A `cargo bench` / criterion comparison showing your change is faster is welcome, but it does not establish that the code is hot.
- **Cold-path micro-optimizations may be closed with thanks.** Optimizing code that runs rarely (connection setup, one-time parsing, error handling) adds review and maintenance cost without a meaningful gain.
## Development Workflow
We use the standard open-source fork and feature branch approach:
1. **Open an issue first** to discuss your proposed changes. For features and non-trivial refactors, please wait for a maintainer response before writing code. Typo fixes, small bug fixes, and documentation corrections do not need a prior issue.
2. Fork the repository
3. Clone your fork locally
4. Create a feature branch from `main` (`git checkout -b feature/your-feature`)
5. Make your changes
6. Push to your fork (`git push origin feature/your-feature`)
7. Open a Pull Request against `main` and reference the related issue
## Code Quality Requirements
Before submitting a PR, please ensure:
- **Unit tests**: Add tests for complex or critical code paths
- **No dead code**: Remove unused code, imports, and dependencies
- **Code style**: Follow the existing code style and patterns in the codebase
- **Clippy**: Fix all clippy warnings
```bash
cargo clippy --all-targets --all-features -- -D warnings
```
- **No clippy suppression**: Do not use `#[allow(clippy::...)]` to suppress warnings. Fix the underlying issue instead (e.g., reduce arguments, refactor code). If a suppression is truly unavoidable, discuss it in the PR.
- **Formatting**: Run the formatter
```bash
cargo fmt
```
- **Security audit**: Check for known vulnerabilities and policy violations in dependencies
```bash
cargo deny check
```
## CI Checks
When you open a PR, our CI pipeline will automatically run checks including:
- Clippy lints
- Code formatting verification
- Build on multiple platforms
- Test suite
Please ensure all CI checks pass before requesting a review.
## Dependency Policy
Please be conservative with dependencies:
- Don't add dependencies unless there's a good reason
- Prefer standard library solutions when possible
- If adding a dependency, explain the rationale in your PR description
- Consider the dependency's maintenance status and security track record
## Security
Security is important for a network monitoring tool:
- Keep security in mind when writing code
- Avoid introducing common vulnerabilities (injection, buffer issues, etc.)
- Be careful with user input and network data parsing
- Report security issues responsibly (see [SECURITY.md](SECURITY.md))
## PR Guidelines
- Write a clear description of what your changes do and why
- Link any related issues
- Keep PRs focused - one feature or fix per PR
- Be responsive to review feedback
- Verify locally before opening the PR. The PR template lists the exact commands.
- Add user-visible changes to the `## [Unreleased]` section of `CHANGELOG.md` in the same PR
## Duplicate Pull Requests
If two or more PRs address the same issue, the maintainers will evaluate them on their merits (code quality, test coverage, architectural fit) rather than submission order. The PR that best fits the project will be merged; others will be closed with thanks. If your PR is closed in favor of another, useful pieces from your work (documentation, tests, edge cases) may be ported over and credited.
To avoid duplicate work, please comment on the linked issue stating that you intend to work on it before you start writing code.
## AI-Assisted Contributions
AI-assisted contributions are welcome, provided you treat the output as your own work and take responsibility for it.
If you use an AI assistant (Copilot, Claude, ChatGPT, Cursor, or similar) to help write code or documentation:
- **Read every line** you are submitting. You are accountable for it.
- **Run the verification commands** listed in the PR template yourself. Do not submit code with a note that tests "could not be run locally" and ask the reviewer to verify.
- **Make sure the PR description matches the code.** If the model wrote the PR body, confirm it describes what the diff actually does.
- **Do not split a single change into many cosmetic per-file commits** to make the work look incremental. One logical change, one commit, with a real commit message.
- **Do not open a PR seconds after filing the issue that motivates it.** Allow time for the maintainers and other contributors to weigh in on the proposed approach.
PRs that show signs of unreviewed AI output (failing tests, fabricated APIs, unrelated bundled changes, mismatched descriptions) will be closed.
## Questions?
Feel free to open an issue if you have questions or want to discuss a potential contribution before starting work.
+119
View File
@@ -0,0 +1,119 @@
<p align="center"><a href="CONTRIBUTING.md">English</a> | <strong>简体中文</strong></p>
# 参与贡献
欢迎提交 Pull Request!无论你是修复 bug、添加功能、改进文档,还是提供反馈,所有贡献都能让 RustNet 变得更好。
## 项目范围<a id="project-scope"></a>
RustNet 追求小而快。并不是每个协议或功能都适合放在核心工具里,即使贡献的代码写得很好。
对于深度包检测(DPI),我们倾向于满足以下条件的协议:
- RustNet 用户中有相当一部分会在自己的网络中实际遇到。
- 能提取出可见且有用的信息(明文窗口足够宽,能提取出真正的元数据,而不仅仅是确认协议存在就进入 TLS)。
- 能以合理的维护成本融入现有架构。
如果某个协议在现代流量中很少见、几乎总是包裹在 TLS 里,或者仅服务于单一小众用户群,即使代码正确,我们也可能婉拒该 PR。请在实现任何新协议前先开一个 issue,以便我们在你投入实现时间之前确认其是否合适。
## 性能与优化<a id="performance-optimizations"></a>
如果一个 PR 的出发点是性能,它必须证明相关代码确实位于真正的热点路径上。仅有微基准测试是**不够**的:它只能证明某个函数在孤立测试中变快了,并不能说明该函数对整体运行时间有影响。
- **先做性能分析。** 在真实流量下采集火焰图(参见 [PROFILING.zh-CN.md](PROFILING.zh-CN.md)),确认相关代码确实占用了可观的 CPU 时间。
- **基准测试只是佐证,而非证明。** 欢迎附上 `cargo bench` / criterion 的对比结果来说明你的改动更快,但这并不能证明该代码是热点。
- **冷路径上的微优化可能会被婉拒并致谢。** 优化很少执行的代码(连接建立、一次性解析、错误处理)会增加 review 和维护成本,却没有有意义的收益。
## 开发流程<a id="development-workflow"></a>
我们采用标准的开源 fork 和功能分支方式:
1. **先开 issue** 讨论你打算做的改动。对于功能性和非平凡的代码重构,请在写代码之前等待维护者的回复。拼写错误、小型 bug 修复和文档勘误不需要先开 issue。
2. Fork 本仓库
3. 将 fork 克隆到本地
4.`main` 创建功能分支(`git checkout -b feature/your-feature`
5. 进行你的修改
6. 推送到你的 fork`git push origin feature/your-feature`
7.`main` 提交 Pull Request,并引用相关 issue
## 代码质量要求<a id="code-quality-requirements"></a>
在提交 PR 之前,请确保:
- **单元测试**:为复杂或关键的代码路径添加测试
- **无死代码**:删除未使用的代码、导入和依赖
- **代码风格**:遵循代码库中已有的代码风格和模式
- **Clippy**:修复所有 clippy 警告
```bash
cargo clippy --all-targets --all-features -- -D warnings
```
- **禁止 clippy 抑制**:不要使用 `#[allow(clippy::...)]` 来抑制警告。请修复根本问题(例如减少参数、重构代码)。如果确实无法避免,请在 PR 中说明。
- **格式化**:运行格式化工具
```bash
cargo fmt
```
- **安全审计**:检查依赖中的已知漏洞和策略违规
```bash
cargo deny check
```
## CI 检查<a id="ci-checks"></a>
当你提交 PR 时,我们的 CI 流水线会自动运行以下检查:
- Clippy lint
- 代码格式化验证
- 多平台构建
- 测试套件
请在请求 review 之前确保所有 CI 检查通过。
## 依赖策略<a id="dependency-policy"></a>
请谨慎添加依赖:
- 除非有充分的理由,否则不要添加依赖
- 尽可能优先使用标准库方案
- 如果必须添加依赖,请在 PR 描述中说明理由
- 考虑依赖的维护状态和安全记录
## 安全<a id="security"></a>
对于网络监控工具,安全至关重要:
- 编写代码时牢记安全
- 避免引入常见漏洞(注入、缓冲区问题等)
- 谨慎处理用户输入和网络数据解析
- 请负责任地报告安全问题(参见 [SECURITY.zh-CN.md](SECURITY.zh-CN.md)
## PR 指南<a id="pr-guidelines"></a>
- 清晰描述你的改动做了什么以及为什么
- 链接任何相关的 issue
- 保持 PR 聚焦——每个 PR 只做一件事
- 及时响应 review 反馈
- 在提交 PR 之前在本地验证。PR 模板中列出了需要执行的精确命令。
## 重复 Pull Request<a id="duplicate-pull-requests"></a>
如果两个或多个 PR 解决了同一个 issue,维护者将根据代码质量、测试覆盖率和架构契合度来评估,而不是提交顺序。最符合项目需求的 PR 将被合并;其他 PR 将被关闭并致谢。如果你的 PR 被另一个替代,你工作中的有用部分(文档、测试、边界情况)可能会被移植并给予署名。
为避免重复工作,请在开始编码之前,在相关 issue 下评论说明你有意处理它。
## AI 辅助贡献<a id="ai-assisted-contributions"></a>
欢迎使用 AI 辅助贡献,但前提是你将其输出视为自己的作品并对其负责。
如果你使用 AI 助手(Copilot、Claude、ChatGPT、Cursor 或类似工具)来辅助编写代码或文档:
- **阅读你提交的每一行**。你要对它负责。
- **自己运行 PR 模板中列出的验证命令**。不要提交代码后附带一句“无法在本地运行测试”并请 reviewer 验证。
- **确保 PR 描述与代码一致。** 如果模型写了 PR 正文,请确认它确实描述了 diff 实际做的事情。
- **不要把一个改动拆成多个仅涉及样式的逐文件提交** 来让工作看起来是增量完成的。一个逻辑改动,一个提交,附带有意义的提交信息。
- **不要在提交 issue 的下一秒就开 PR。** 给维护者和其他贡献者留出时间对你提出的方案发表意见。
那些显示出未经 review 的 AI 输出痕迹的 PR(测试失败、虚构 API、无关的捆绑改动、描述不匹配)将被关闭。
## 有问题?<a id="questions"></a>
如果你有任何问题,或者想在开始工作之前讨论潜在的贡献,欢迎随时开一个 issue。
+21
View File
@@ -0,0 +1,21 @@
# Contributors
We would like to thank the following people for their contributions to RustNet:
## Maintainer
- **Marco Cadetg** ([@domcyrus](https://github.com/domcyrus)) - Creator and maintainer
## Contributors
We would like to thank these people for their valuable contributions:
- **DeepChirp** ([@DeepChirp](https://github.com/DeepChirp)) - Code contributions
- **Conor O'Callaghan** ([@Conor0Callaghan](https://github.com/Conor0Callaghan)) - JSON/SIEM logging research and design input
- **Ken Tobias** ([@l1a](https://github.com/l1a)) - Code contributions
## Contributing
We welcome and appreciate all contributions! Whether you're fixing bugs, adding features, improving documentation, or helping with ideas and feedback, your contributions make RustNet better.
See our [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute.
Generated
+4142
View File
File diff suppressed because it is too large Load Diff
+245
View File
@@ -0,0 +1,245 @@
[workspace]
members = [".", "crates/rustnet-core", "crates/rustnet-capture", "crates/rustnet-host"]
resolver = "3"
# Shared metadata for every crate in the workspace. Members opt in with
# `<field>.workspace = true`. The binary keeps its own `version` (it tracks the
# user-facing 1.x release line, independent of the 0.x library crates).
[workspace.package]
version = "0.3.0"
authors = ["domcyrus"]
edition = "2024"
rust-version = "1.88.0" # Let-chains require Rust 1.88.0+
repository = "https://github.com/domcyrus/rustnet"
homepage = "https://github.com/domcyrus/rustnet"
license = "Apache-2.0"
# Single source of truth for the internal crate versions (and the few deps
# shared across crates). Bumping a library version is now a one-line edit here
# instead of one edit per member manifest. Members reference these with
# `<dep>.workspace = true`.
[workspace.dependencies]
rustnet-core = { version = "0.3.0", path = "crates/rustnet-core" }
rustnet-capture = { version = "0.3.0", path = "crates/rustnet-capture" }
rustnet-host = { version = "0.3.0", path = "crates/rustnet-host" }
anyhow = "1.0"
log = "0.4"
[package]
name = "rustnet-monitor"
version = "1.4.0"
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
description = "A cross-platform network monitoring terminal UI tool built with Rust"
repository.workspace = true
homepage.workspace = true
documentation = "https://docs.rs/rustnet-monitor"
readme = "README.md"
license.workspace = true
keywords = ["network", "monitoring", "tui", "ebpf", "packet-capture"]
categories = ["command-line-utilities", "network-programming", "visualization"]
exclude = [".github/", "scripts/", "tests/", "*.log", "target/", ".gitignore"]
[lib]
name = "rustnet_monitor"
path = "src/lib.rs"
[[bin]]
name = "rustnet"
path = "src/main.rs"
# The bin re-compiles every src/* module that lib.rs also re-exports.
# Running `cargo test` on the bin therefore duplicates every unit test
# under a different crate prefix, which breaks snapshot-test file paths
# (`rustnet__...` vs `rustnet_monitor__...`). Tests live in the lib and
# in `tests/`; the bin is just the entry point.
test = false
[dependencies]
rustnet-core.workspace = true
rustnet-capture.workspace = true
rustnet-host.workspace = true
anyhow.workspace = true
libc = "0.2"
arboard = { version = "3.6", features = ["wayland-data-control"] }
crossterm = "0.29"
crossbeam = "0.8"
dashmap = "6.2"
log.workspace = true
pcap = "2.4.0"
clap = { version = "4.6", features = ["derive"] }
simplelog = "0.12"
chrono = "0.4"
ratatui = { version = "0.30", features = ["all-widgets"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
regex-lite = "0.1"
# Note: dns-lookup, ring, aes, flate2, and maxminddb moved to rustnet-core,
# which is the only place they are used. They are re-exported transitively
# through the `crate::network` facade in src/network/mod.rs.
[target.'cfg(target_os = "linux")'.dependencies]
# procfs + libbpf-rs moved to the rustnet-host crate (process attribution).
landlock = { version = "0.4", optional = true }
caps = { version = "0.5", optional = true }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_NetworkManagement_IpHelper",
"Win32_NetworkManagement_Ndis",
"Win32_Networking_WinSock",
"Win32_Security",
"Win32_System_JobObjects",
"Win32_System_LibraryLoader",
"Win32_System_Threading",
] }
# FreeBSD support uses the system's sockstat command for process lookup
# and native libpcap (via pcap crate) for packet capture.
# No additional FreeBSD-specific dependencies required at this time.
[build-dependencies]
anyhow.workspace = true
clap = { version = "4.6", features = ["derive"] }
clap_complete = "4.6"
clap_mangen = "0.3"
[target.'cfg(windows)'.build-dependencies]
http_req = "0.14"
zip = "8.6"
sha2 = "0.11"
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_NetworkManagement_IpHelper",
"Win32_NetworkManagement_Ndis",
"Win32_Networking_WinSock",
"Win32_Security",
"Win32_System_LibraryLoader",
"Win32_System_Threading",
] }
[features]
# eBPF is enabled by default for enhanced performance on Linux.
# On non-Linux platforms, this feature has no effect as all eBPF code
# and dependencies are Linux-specific (guarded by target_os checks).
# Landlock provides security sandboxing on Linux 5.13+.
# macos-sandbox provides Seatbelt sandboxing on macOS 10.5+ (no extra deps).
default = ["ebpf", "landlock", "macos-sandbox"]
linux-default = ["ebpf"] # Deprecated: kept for backwards compatibility
# eBPF now lives in the rustnet-host crate; this is a passthrough so
# `--features ebpf` (and the default) keep enabling it as before.
ebpf = ["rustnet-host/ebpf"]
landlock = ["dep:landlock", "dep:caps"]
macos-sandbox = []
# Kubernetes pod and container resolution from cgroup paths and (Phase 2) the
# on-disk kubelet pods directory. Linux-only at runtime; no extra dependencies.
kubernetes = ["rustnet-core/kubernetes"]
# Minimal cross configuration to override dependency conflicts
[workspace.metadata.cross.build.env]
passthrough = [
"CARGO_INCREMENTAL",
"CARGO_NET_RETRY",
"CARGO_NET_TIMEOUT",
]
[package.metadata.deb]
maintainer = "domcyrus <domcyrus@example.com>"
copyright = "2024, domcyrus <domcyrus@example.com>"
license-file = ["LICENSE", "4"]
extended-description = """\
A real-time network monitoring terminal UI tool built with Rust.
Features:
- Real-time network monitoring with detailed state information
- Deep packet inspection for HTTP/HTTPS, DNS, SSH, and QUIC
- Connection lifecycle management with configurable timeouts
- Process identification and service name resolution
- Advanced filtering with vim/fzf-style search
- Multi-threaded processing for optimal performance
- eBPF-enhanced process detection on Linux (with automatic fallback)
"""
depends = "libpcap0.8, libelf1"
section = "net"
priority = "optional"
assets = [
[
"target/release/rustnet",
"usr/bin/",
"755",
],
[
"README.md",
"usr/share/doc/rustnet-monitor/",
"644",
],
[
"crates/rustnet-core/assets/services",
"usr/share/rustnet-monitor/",
"644",
],
[
"resources/packaging/linux/graphics/rustnet.png",
"usr/share/icons/hicolor/256x256/apps/",
"644",
],
[
"resources/packaging/linux/rustnet.desktop",
"usr/share/applications/",
"644",
],
]
conf-files = []
[package.metadata.generate-rpm]
assets = [
{ source = "target/release/rustnet", dest = "/usr/bin/rustnet", mode = "755" },
{ source = "README.md", dest = "/usr/share/doc/rustnet-monitor/README.md", mode = "644" },
{ source = "crates/rustnet-core/assets/services", dest = "/usr/share/rustnet-monitor/services", mode = "644" },
{ source = "resources/packaging/linux/graphics/rustnet.png", dest = "/usr/share/icons/hicolor/256x256/apps/rustnet.png", mode = "644" },
{ source = "resources/packaging/linux/rustnet.desktop", dest = "/usr/share/applications/rustnet.desktop", mode = "644" },
]
[package.metadata.generate-rpm.requires]
libpcap = "*"
elfutils-libelf = "*"
[package.metadata.wix]
upgrade-guid = "455c823b-9665-43e0-baa4-bd0fcb762463"
path-guid = "d3a2452e-f04f-4d4f-becf-c3580f49f8fc"
license = false
eula = false
[dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] }
insta = { version = "1", features = ["filters"] }
rustc-hash = "2"
[[bench]]
name = "packet_parsing"
harness = false
[[bench]]
name = "connection_merge"
harness = false
[[bench]]
name = "snapshot"
harness = false
[[bench]]
name = "rate_tracker"
harness = false
[[bench]]
name = "tracker_ingest"
harness = false
[profile.release]
lto = "thin"
codegen-units = 1
[profile.profiling]
inherits = "release"
debug = 2
strip = false
+21
View File
@@ -0,0 +1,21 @@
[target.aarch64-unknown-linux-gnu]
image = "ghcr.io/cross-rs/aarch64-unknown-linux-gnu:edge"
pre-build = [
"dpkg --add-architecture $CROSS_DEB_ARCH",
"apt-get update -y",
"apt-get install -y libelf-dev:arm64 zlib1g-dev:arm64 libpcap-dev:arm64 gcc-aarch64-linux-gnu protobuf-compiler libseccomp-dev:arm64 libbpf-dev clang llvm"
]
[target.armv7-unknown-linux-gnueabihf]
image = "ghcr.io/cross-rs/armv7-unknown-linux-gnueabihf:edge"
pre-build = [
"dpkg --add-architecture $CROSS_DEB_ARCH",
"apt-get update -y",
"apt-get install -y libelf-dev:armhf zlib1g-dev:armhf libpcap-dev:armhf gcc-arm-linux-gnueabihf protobuf-compiler libseccomp-dev:armhf libbpf-dev clang llvm"
]
[target.x86_64-unknown-freebsd]
# FreeBSD cross-compilation uses a Linux container with FreeBSD sysroot
# The sysroot should already contain libpcap
# No pre-build steps needed as we can't run FreeBSD package manager in Linux container
+111
View File
@@ -0,0 +1,111 @@
# Multi-stage Docker build for RustNet
# Base images are pinned by digest for reproducible, tamper-evident builds.
FROM rust:1.96-slim@sha256:31ee7fc65186be7e0e0ccb3f2ca305f14e4739e7642a1ae65753aa5d7b874523 AS builder
# Install rustfmt component (required for eBPF compilation)
RUN rustup component add rustfmt
# Install build dependencies
RUN apt-get update && apt-get install -y \
libpcap-dev \
libelf-dev \
zlib1g-dev \
clang \
llvm \
make \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /app
# Copy Cargo files first for better caching
COPY Cargo.toml Cargo.lock ./
# Copy build script (manpage/completions; npcap on Windows)
COPY build.rs ./
# Copy source code
COPY src ./src
COPY benches ./benches
# Workspace member crates. rustnet-core holds the baked-in oui.gz / services
# assets (include_bytes!/include_str!); rustnet-host holds the eBPF programs and
# bundled vmlinux headers compiled by its own build.rs.
COPY crates ./crates
# Build the application in release mode (eBPF is enabled by default on Linux).
# Optional features can be added with --build-arg CARGO_FEATURES=kubernetes
# (additive: default features stay on). The CI Kubernetes image variant passes
# CARGO_FEATURES=kubernetes; the default image leaves it empty.
ARG CARGO_FEATURES=""
RUN if [ -n "$CARGO_FEATURES" ]; then \
cargo build --release --features "$CARGO_FEATURES"; \
else \
cargo build --release; \
fi
# Runtime stage - use trixie-slim to match GLIBC version from builder
# Pinned by digest for a reproducible, tamper-evident base image.
FROM debian:trixie-slim@sha256:28de0877c2189802884ccd20f15ee41c203573bd87bb6b883f5f46362d24c5c2
# Install runtime dependencies
# libcap2-bin provides setcap, used below to grant packet-capture capabilities
# to the binary so it can run as a non-root user.
RUN apt-get update && apt-get install -y \
libpcap0.8 \
libelf1 \
zlib1g \
ca-certificates \
libcap2-bin \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user. The container runs as this user (see USER below);
# packet-capture privileges are granted via file capabilities on the binary
# rather than by running as root.
RUN useradd -r -s /bin/false rustnet
# Set working directory
WORKDIR /app
# Copy the binary from builder stage
COPY --from=builder /app/target/release/rustnet /usr/local/bin/rustnet
# Copy the services asset for reference (the binary already embeds it at build time)
COPY --from=builder /app/crates/rustnet-core/assets/services ./assets/services
# Create logs directory
RUN mkdir -p /app/logs && chown rustnet:rustnet /app/logs
# Set executable permissions and grant CAP_NET_RAW as a file capability so the
# binary can capture packets as a non-root user. NET_RAW is in Docker's default
# capability bounding set, so `docker run rustnet` works without extra flags.
#
# Only NET_RAW is baked in on purpose: a file capability that is NOT also in the
# container's bounding set makes execve() fail with EPERM. BPF/PERFMON are not
# in Docker's default set, so eBPF is handled at runtime instead (see below).
RUN chmod +x /usr/local/bin/rustnet \
&& setcap 'cap_net_raw=ep' /usr/local/bin/rustnet
# Expose no ports by default (rustnet is for monitoring, not serving)
# Network access is handled via host networking or packet capture capabilities
# Add labels for better image metadata
LABEL org.opencontainers.image.title="RustNet"
LABEL org.opencontainers.image.description="A cross-platform network monitoring tool with deep packet inspection"
LABEL org.opencontainers.image.source="https://github.com/domcyrus/rustnet"
LABEL org.opencontainers.image.licenses="Apache License, Version 2.0"
# RustNet runs as the non-root 'rustnet' user. CAP_NET_RAW is baked into the
# binary as a file capability and is part of Docker's default bounding set, so
# basic packet capture works out of the box:
# docker run rustnet
# eBPF-based process attribution needs BPF+PERFMON, which are NOT in the default
# bounding set and can't be granted to a non-root user via file capabilities.
# Enable eBPF by running as root with the extra caps (modern kernels 5.8+):
# docker run --user root --cap-add=BPF --cap-add=PERFMON rustnet
# Legacy kernels require broad CAP_SYS_ADMIN for eBPF. RustNet does not
# recommend granting it by default; without eBPF caps, rustnet falls back to
# /proc-based process detection.
# CAP_NET_ADMIN is NOT required (read-only, non-promiscuous capture).
USER rustnet
ENTRYPOINT ["rustnet"]
+61
View File
@@ -0,0 +1,61 @@
# Dockerfile for building static musl-linked RustNet binary
#
# Usage (with eBPF - default, recommended):
# docker build -f Dockerfile.static -t rustnet-static .
# docker run --rm -v $(pwd)/dist:/dist rustnet-static cp /build/target/release/rustnet /dist/
#
# Usage (without eBPF - smaller binary, ~5.2MB vs ~6.5MB):
# docker build -f Dockerfile.static --build-arg FEATURES="--no-default-features" -t rustnet-static .
FROM rust:alpine
ARG FEATURES=""
# Install build dependencies
# - musl-dev: musl C library headers
# - libpcap-dev: libpcap headers and static library (/usr/lib/libpcap.a)
# - pkgconfig: for finding library paths
# - build-base: basic build tools (make, gcc, etc.)
# - perl: required by some build scripts (ring crate)
# - elfutils-dev: libelf for eBPF (includes static library)
# - zlib-dev/zlib-static: compression library
# - zstd-dev/zstd-static: Zstandard compression (required by elfutils 0.189+)
# - clang/llvm: for eBPF compilation
# - linux-headers: kernel headers for eBPF
RUN apk add --no-cache \
musl-dev \
libpcap-dev \
pkgconfig \
build-base \
perl \
elfutils-dev \
zlib-dev \
zlib-static \
zstd-dev \
zstd-static \
clang \
llvm \
linux-headers
# Add rustfmt for eBPF skeleton generation
RUN rustup component add rustfmt
WORKDIR /build
# Copy source code
COPY . .
# Configure static linking for zstd
# This fixes the elfutils 0.189+ undeclared dependency on zstd
# See: https://github.com/libbpf/bpftool/issues/152
RUN mkdir -p .cargo && printf '[target.x86_64-unknown-linux-musl]\nrustflags = ["-C", "link-arg=-l:libzstd.a"]\n' > .cargo/config.toml
# Build configuration:
# - In Alpine/musl, binaries are statically linked by default
# - FEATURES arg controls whether eBPF is included (default) or disabled
# - --release: Optimized build
RUN cargo build --release ${FEATURES}
# Verify the binary is statically linked
RUN file target/release/rustnet && \
ldd target/release/rustnet 2>&1 || echo "Binary is statically linked"
+1160
View File
File diff suppressed because it is too large Load Diff
+1123
View File
File diff suppressed because it is too large Load Diff
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+196
View File
@@ -0,0 +1,196 @@
<p align="center"><strong>English</strong> | <a href="PROFILING.zh-CN.md">简体中文</a></p>
# RustNet Performance Profiling Guide
This guide explains how to profile RustNet to identify performance bottlenecks.
## Quick Start
### CPU Profiling with perf + flamegraph
The easiest way to profile CPU usage on Linux:
```bash
# 1. Install flamegraph tools
cargo install flamegraph
# 2. Build a release binary with debug symbols
# IMPORTANT: Debug symbols are required for meaningful flamegraphs!
CARGO_PROFILE_RELEASE_DEBUG=true cargo build --release
# Or add this to Cargo.toml temporarily:
# [profile.release]
# debug = true
# 3. Run with profiling (requires sudo for perf)
# Note: Use full path to flamegraph since sudo doesn't have your user's PATH
# IMPORTANT: Use -- before the command to profile
sudo -E ~/.cargo/bin/flamegraph -- ./target/release/rustnet
# Or specify interface and other args after the binary
sudo -E ~/.cargo/bin/flamegraph -- ./target/release/rustnet -i eth0
# Alternatively, preserve PATH for cleaner commands:
sudo env "PATH=$PATH" flamegraph -- ./target/release/rustnet
# 4. Open the generated flamegraph.svg in a browser
firefox flamegraph.svg
```
### Alternative: Using perf directly
If you prefer to use `perf` directly:
```bash
# Build with debug symbols
cargo build --release
# Record performance data (run for 30-60 seconds, then Ctrl+C to stop)
sudo perf record -F 99 -g ./target/release/rustnet -i eth0
# Generate flamegraph (requires FlameGraph scripts)
# Install from: https://github.com/brendangregg/FlameGraph
perf script | stackcollapse-perf.pl | flamegraph.pl > flamegraph.svg
# Or view in perf's TUI
sudo perf report
```
### Profiling a Running Instance
If RustNet is already running:
```bash
# Find the PID
ps aux | grep rustnet
# Profile the running process for 60 seconds
sudo -E ~/.cargo/bin/flamegraph -p <PID> --output rustnet-live.svg
# Or with perf directly
sudo perf record -F 99 -g -p <PID> sleep 60
sudo perf report
```
## Interpreting Flamegraphs
Look for:
- **Wide bars at the bottom**: Functions that consume a lot of total CPU time
- **Tall stacks**: Deep call chains (potential optimization targets)
- **Hot spots**: Functions with many samples (bright colors in some viewers)
Common hot spots:
- `packet_parser::parse_packet`: Normal - this is the core packet processing
- `DashMap::iter` or `iter_mut`: If this is a large portion, consider reducing iteration frequency
- `clone`: If excessive, reduce unnecessary cloning
- System calls (`read`, `write`, `ioctl`): Filesystem or network I/O overhead
## Criterion Benchmarks
Microbenchmarks for core operations live in `benches/`. Run them with:
| Benchmark | Command |
|-----------|---------|
| Packet parsing | `cargo bench --bench packet_parsing` |
| Connection merge | `cargo bench --bench connection_merge` |
| Snapshot creation | `cargo bench --bench snapshot` |
| All benchmarks | `cargo bench` |
| Struct sizes | `cargo test --lib struct_sizes -- --nocapture` |
Criterion produces HTML reports in `target/criterion/` with statistical comparison between runs.
## Ad-hoc Benchmarking
For consistent benchmarks:
```bash
# Run with consistent traffic
sudo ./target/release/rustnet --interface eth0 &
PID=$!
# Monitor CPU usage
top -p $PID
# Or use perf stat for detailed metrics
sudo perf stat -p $PID sleep 60
# Stop the application
sudo kill $PID
```
## Performance Regression Testing
After making changes, compare before/after:
```bash
# Baseline (before changes)
sudo perf stat -r 3 timeout 60s ./target/release/rustnet-before > /dev/null
# After changes
sudo perf stat -r 3 timeout 60s ./target/release/rustnet > /dev/null
```
Key metrics to compare:
- CPU cycles
- Instructions per cycle (IPC)
- Cache misses
- Context switches
## Troubleshooting Flamegraphs
### Empty or Single-Entry Flamegraph
If your flamegraph only shows "rustnet (100%)" with no details:
**Problem**: Debug symbols are missing from the release build.
**Solution**:
```bash
# Rebuild with debug symbols
CARGO_PROFILE_RELEASE_DEBUG=true cargo build --release
# Or add to Cargo.toml:
[profile.release]
debug = true
# Then re-profile
sudo -E ~/.cargo/bin/flamegraph -- ./target/release/rustnet
```
### Flamegraph Shows Only Kernel Functions
**Problem**: Running with insufficient permissions or perf can't access user-space symbols.
**Solution**:
```bash
# Check perf_event_paranoid setting
cat /proc/sys/kernel/perf_event_paranoid
# If it's > 1, temporarily lower it (requires root):
sudo sysctl kernel.perf_event_paranoid=1
# Or run as root
sudo -E ~/.cargo/bin/flamegraph -- ./target/release/rustnet
```
### Very Short Flamegraph (< 1000 samples)
**Problem**: Profiling session too short, not enough data collected.
**Solution**:
```bash
# Let rustnet run for at least 30-60 seconds before stopping
# The more network traffic, the better the profile
# For longer profiling:
timeout 60 sudo -E ~/.cargo/bin/flamegraph -- ./target/release/rustnet
```
## Debugging Slow TUI
If the TUI feels sluggish:
1. **Check refresh rate**: Default is 1000ms, can be adjusted with `--refresh-interval`
2. **Check connection count**: High connection counts increase sorting overhead
3. **Profile the UI loop**: Look for hot spots in `run_ui_loop`, `draw`, or `sort_connections`
4. **Monitor thread contention**: Check if packet processing threads are blocking the snapshot provider
+196
View File
@@ -0,0 +1,196 @@
<p align="center"><a href="PROFILING.md">English</a> | <strong>简体中文</strong></p>
# RustNet 性能分析指南
本指南介绍如何对 RustNet 进行性能分析,以定位性能瓶颈。
## 快速开始<a id="quick-start"></a>
### 使用 perf + flamegraph 进行 CPU 分析<a id="cpu-profiling-with-perf--flamegraph"></a>
在 Linux 上分析 CPU 占用最简单的方式:
```bash
# 1. 安装 flamegraph 工具
cargo install flamegraph
# 2. 构建带调试符号的 release 二进制
# 重要:要生成有意义的火焰图,必须包含调试符号!
CARGO_PROFILE_RELEASE_DEBUG=true cargo build --release
# 或临时在 Cargo.toml 中加入:
# [profile.release]
# debug = true
# 3. 在性能分析下运行(perf 需要 sudo)
# 注意:使用 flamegraph 的完整路径,因为 sudo 不会带上你用户的 PATH
# 重要:在要分析的命令前加 --
sudo -E ~/.cargo/bin/flamegraph -- ./target/release/rustnet
# 或在二进制之后指定接口及其他参数
sudo -E ~/.cargo/bin/flamegraph -- ./target/release/rustnet -i eth0
# 或者保留 PATH 以使用更简洁的命令:
sudo env "PATH=$PATH" flamegraph -- ./target/release/rustnet
# 4. 在浏览器中打开生成的 flamegraph.svg
firefox flamegraph.svg
```
### 备选方案:直接使用 perf<a id="alternative-using-perf-directly"></a>
如果你更习惯直接使用 `perf`
```bash
# 构建带调试符号的版本
cargo build --release
# 记录性能数据(运行 30-60 秒,然后按 Ctrl+C 停止)
sudo perf record -F 99 -g ./target/release/rustnet -i eth0
# 生成火焰图(需要 FlameGraph 脚本)
# 从以下地址安装:https://github.com/brendangregg/FlameGraph
perf script | stackcollapse-perf.pl | flamegraph.pl > flamegraph.svg
# 或在 perf 的 TUI 中查看
sudo perf report
```
### 分析正在运行的实例<a id="profiling-a-running-instance"></a>
如果 RustNet 已经在运行:
```bash
# 查找 PID
ps aux | grep rustnet
# 对运行中的进程分析 60 秒
sudo -E ~/.cargo/bin/flamegraph -p <PID> --output rustnet-live.svg
# 或直接使用 perf
sudo perf record -F 99 -g -p <PID> sleep 60
sudo perf report
```
## 解读火焰图<a id="interpreting-flamegraphs"></a>
重点关注:
- **底部的宽条**:消耗大量总 CPU 时间的函数
- **高耸的栈**:很深的调用链(潜在的优化目标)
- **热点**:采样次数很多的函数(在某些查看器中显示为鲜亮的颜色)
常见热点:
- `packet_parser::parse_packet`:正常——这是核心的数据包处理
- `DashMap::iter``iter_mut`:如果占比很大,考虑降低迭代频率
- `clone`:如果过多,减少不必要的克隆
- 系统调用(`read``write``ioctl`):文件系统或网络 I/O 开销
## Criterion 基准测试<a id="criterion-benchmarks"></a>
核心操作的微基准测试位于 `benches/`。运行方式:
| 基准测试 | 命令 |
|-----------|---------|
| 数据包解析 | `cargo bench --bench packet_parsing` |
| 连接合并 | `cargo bench --bench connection_merge` |
| 快照创建 | `cargo bench --bench snapshot` |
| 全部基准测试 | `cargo bench` |
| 结构体大小 | `cargo test --lib struct_sizes -- --nocapture` |
Criterion 会在 `target/criterion/` 中生成 HTML 报告,并对多次运行结果进行统计比较。
## 临时基准测试<a id="ad-hoc-benchmarking"></a>
要获得稳定一致的基准测试:
```bash
# 在稳定的流量下运行
sudo ./target/release/rustnet --interface eth0 &
PID=$!
# 监控 CPU 占用
top -p $PID
# 或使用 perf stat 获取详细指标
sudo perf stat -p $PID sleep 60
# 停止应用
sudo kill $PID
```
## 性能回归测试<a id="performance-regression-testing"></a>
在改动之后,对比改动前后:
```bash
# 基线(改动前)
sudo perf stat -r 3 timeout 60s ./target/release/rustnet-before > /dev/null
# 改动后
sudo perf stat -r 3 timeout 60s ./target/release/rustnet > /dev/null
```
需要对比的关键指标:
- CPU 周期数
- 每周期指令数(IPC
- 缓存未命中
- 上下文切换
## 火焰图问题排查<a id="troubleshooting-flamegraphs"></a>
### 火焰图为空或只有单个条目<a id="empty-or-single-entry-flamegraph"></a>
如果你的火焰图只显示 “rustnet (100%)” 而没有任何细节:
**问题**release 构建缺少调试符号。
**解决方案**
```bash
# 带调试符号重新构建
CARGO_PROFILE_RELEASE_DEBUG=true cargo build --release
# 或在 Cargo.toml 中加入:
[profile.release]
debug = true
# 然后重新分析
sudo -E ~/.cargo/bin/flamegraph -- ./target/release/rustnet
```
### 火焰图只显示内核函数<a id="flamegraph-shows-only-kernel-functions"></a>
**问题**:运行权限不足,或 perf 无法访问用户态符号。
**解决方案**
```bash
# 检查 perf_event_paranoid 设置
cat /proc/sys/kernel/perf_event_paranoid
# 如果它大于 1,临时调低(需要 root):
sudo sysctl kernel.perf_event_paranoid=1
# 或以 root 运行
sudo -E ~/.cargo/bin/flamegraph -- ./target/release/rustnet
```
### 火焰图过短(采样少于 1000 个)<a id="very-short-flamegraph--1000-samples"></a>
**问题**:分析会话太短,采集的数据不足。
**解决方案**
```bash
# 在停止前,让 rustnet 至少运行 30-60 秒
# 网络流量越多,分析结果越好
# 如需更长时间的分析:
timeout 60 sudo -E ~/.cargo/bin/flamegraph -- ./target/release/rustnet
```
## 排查 TUI 卡顿<a id="debugging-slow-tui"></a>
如果 TUI 感觉迟钝:
1. **检查刷新频率**:默认是 1000ms,可通过 `--refresh-interval` 调整
2. **检查连接数量**:连接数过高会增加排序开销
3. **分析 UI 循环**:在 `run_ui_loop``draw``sort_connections` 中查找热点
4. **监控线程争用**:检查数据包处理线程是否阻塞了快照提供者
+340
View File
@@ -0,0 +1,340 @@
<p align="center">
<h1 align="center">RustNet</h1>
<p align="center">
<strong>Per-process network monitoring for your terminal: live TCP, UDP, and QUIC connections with deep packet inspection, sandboxed by default.</strong>
</p>
<p align="center">
<a href="https://ratatui.rs/"><img src="https://ratatui.rs/built-with-ratatui/badge.svg" alt="Built With Ratatui"></a>
<a href="https://github.com/domcyrus/rustnet/actions"><img src="https://github.com/domcyrus/rustnet/workflows/Rust/badge.svg" alt="Build Status"></a>
<a href="https://crates.io/crates/rustnet-monitor"><img src="https://img.shields.io/crates/v/rustnet-monitor.svg" alt="Crates.io"></a>
<a href="https://github.com/domcyrus/rustnet/stargazers"><img src="https://img.shields.io/github/stars/domcyrus/rustnet?style=flat&logo=github" alt="GitHub Stars"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License"></a>
<a href="https://github.com/domcyrus/rustnet/releases"><img src="https://img.shields.io/github/v/release/domcyrus/rustnet.svg" alt="GitHub release"></a>
<a href="https://github.com/domcyrus/rustnet/pkgs/container/rustnet"><img src="https://img.shields.io/badge/docker-ghcr.io-blue?logo=docker" alt="Docker Image"></a>
</p>
</p>
<p align="center">
<strong>English</strong> | <a href="README.zh-CN.md">简体中文</a>
</p>
<p align="center">
<img src="./assets/rustnet.gif" alt="RustNet demo" width="800">
</p>
<p align="center">
<em>Real-time visibility into every connection your machine makes, who owns it, and what protocol it's speaking. No tcpdump, X11 forwarding, or root piping.</em>
</p>
## Features
- **Per-process attribution**: Every TCP, UDP, and QUIC connection mapped to its owning process, via eBPF on Linux, PKTAP on macOS, native APIs on Windows and FreeBSD. Wireshark and tcpdump can't do this; `netstat` / `ss` can't show live state.
- **Deep packet inspection**: Identify HTTP, HTTPS/TLS with SNI, DNS, SSH, FTP, QUIC, MQTT, BitTorrent, STUN, NTP, mDNS, LLMNR, DHCP, SNMP, SSDP, and NetBIOS, without external dissectors.
- **Annotated PCAPNG export**: `--pcapng-export` writes a Wireshark-ready capture with process, PID, direction, DPI/SNI, and GeoIP embedded as per-packet comments. Open it in Wireshark and every packet already names its owning process, with no post-processing. Classic `--pcap-export` with a JSONL sidecar for offline correlation is also available.
- **Security sandboxing**: Landlock (Linux 5.13+), Seatbelt (macOS), token privilege drop + job-object child-process block (Windows). Drops privileges immediately after libpcap initializes. See [SECURITY.md](SECURITY.md).
- **TCP network analytics**: Real-time retransmissions, out-of-order packets, and fast-retransmit detection, per-connection and aggregate.
- **Smart connection lifecycle**: Protocol-aware timeouts with white → yellow → red staleness indicators. Toggle `t` to keep historic (closed) connections visible for forensics.
- **Vim/fzf-style filtering**: `port:`, `src:`, `dst:`, `sni:`, `process:`, `state:`, `proto:`, plus regex via `/(?i)pattern/`.
- **GeoIP enrichment**: Country lookups via local MaxMind GeoLite2. No network calls.
- **Kubernetes attribution** (optional `kubernetes` feature): connections mapped to their pod, namespace, and container, shown in the details pane, JSON/PCAPNG exports, and the `pod:`, `ns:`, `container:` filters. Enabled in the official Docker image; on a cluster, use the [kubectl-rustnet](https://github.com/domcyrus/kubectl-rustnet) plugin to run it as an ephemeral debug pod. See [USAGE.md](USAGE.md#--kubernetes-mode-optional-feature).
- **Cross-platform**: Linux, macOS, Windows, FreeBSD.
## Why RustNet?
RustNet fills the gap between simple connection tools (`netstat`, `ss`) and packet analyzers (`Wireshark`, `tcpdump`):
- **Process attribution**: See which application owns each connection. Wireshark cannot provide this because it only sees packets, not sockets.
- **Connection-centric view**: Track states, bandwidth, and protocols per connection in real-time
- **SSH-friendly**: TUI works over SSH so you can quickly see what's happening on a remote server without forwarding X11 or capturing traffic
RustNet complements packet capture tools. Use RustNet to see *what's making connections*. For direct Wireshark inspection, `--pcapng-export` writes live best-effort packet comments with PID/process context. For cleanup-time correlation, use `--pcap-export` plus the JSONL sidecar and optional `scripts/pcap_enrich.py`. See [PCAP Export](USAGE.md#pcap-export) and [Comparison with Similar Tools](ARCHITECTURE.md#comparison-with-similar-tools) for details.
Built on ratatui, libpcap, eBPF (libbpf-rs), DashMap, crossbeam, ring, MaxMind GeoLite2, and Landlock. See [ARCHITECTURE.md](ARCHITECTURE.md#dependencies) for the full dependency breakdown.
<details>
<summary><b>eBPF Enhanced Process Identification (Linux Default)</b></summary>
RustNet uses kernel eBPF programs by default on Linux for enhanced performance and lower overhead process identification. However, this comes with important limitations:
**Process Name Limitations:**
- eBPF uses the kernel's `comm` field, which is limited to 16 characters
- Shows the task/thread command name, not the full executable path
- Multi-threaded applications often show thread names instead of the main process name
**Real-world Examples:**
- **Firefox**: May appear as "Socket Thread", "Web Content", "Isolated Web Co", or "MainThread"
- **Chrome**: May appear as "ThreadPoolForeg", "Chrome_IOThread", "BrokerProcess", or "SandboxHelper"
- **Electron apps**: Often show as "electron", "node", or internal thread names
- **System processes**: Show truncated names like "systemd-resolve" → "systemd-resolve"
**Fallback Behavior:**
- When eBPF fails to load or lacks sufficient permissions, RustNet automatically falls back to standard procfs-based process identification
- Standard mode provides full process names but with higher CPU overhead
- eBPF is enabled by default; no special build flags needed
To disable eBPF and use procfs-only mode, build with:
```bash
cargo build --release --no-default-features
```
See [ARCHITECTURE.md](ARCHITECTURE.md) for technical information.
</details>
<details>
<summary><b>Interface Statistics Monitoring</b></summary>
RustNet provides real-time network interface statistics across all supported platforms:
- **Overview Tab**: Shows active interfaces with current rates, errors, and drops
- **Interfaces Tab** (press `3`): Detailed table with comprehensive metrics for all interfaces
- **Cross-Platform**: Linux (sysfs), macOS/FreeBSD (getifaddrs), Windows (GetIfTable2 API)
- **Smart Filtering**: Windows automatically excludes virtual/filter adapters
See [USAGE.md](USAGE.md#interface-statistics) for detailed documentation on interpreting interface statistics and platform-specific behavior.
**Metrics Available:**
- Total bytes and packets (RX/TX)
- Error counters (receive and transmit)
- Packet drops (queue overflows)
- Collisions (legacy, rarely used on modern networks)
Stats are collected every 2 seconds in a background thread with minimal performance impact.
</details>
## Screenshots
<table>
<tr>
<td align="center"><strong>Overview</strong><br>Connections table with live stats and sparklines<br><img src="./assets/screenshots/overview.png" width="400"></td>
<td align="center"><strong>Details</strong><br>Per-connection SNI, cipher, GeoIP, DPI<br><img src="./assets/screenshots/details.png" width="400"></td>
</tr>
<tr>
<td align="center"><strong>Graph</strong><br>Traffic chart, app distribution, top processes<br><img src="./assets/screenshots/graph.png" width="400"></td>
<td align="center"><strong>Interfaces</strong><br>Per-interface RX/TX history with errors and drops<br><img src="./assets/screenshots/interfaces.png" width="400"></td>
</tr>
</table>
## Quick Start
### Installation
**Homebrew (macOS / Linux):**
```bash
brew install rustnet
```
**Ubuntu (25.10+):**
```bash
sudo add-apt-repository ppa:domcyrus/rustnet
sudo apt update && sudo apt install rustnet
```
**Fedora (42+):**
```bash
sudo dnf copr enable domcyrus/rustnet
sudo dnf install rustnet
```
**openSUSE Tumbleweed:**
```bash
sudo zypper addrepo https://download.opensuse.org/repositories/home:/domcyrus:/rustnet/openSUSE_Tumbleweed/home:domcyrus:rustnet.repo
sudo zypper refresh
sudo zypper install rustnet
```
**Arch Linux:**
```bash
sudo pacman -S rustnet
```
**Nix / NixOS:**
```bash
nix-shell -p rustnet
# Then inside the shell: sudo rustnet
```
**From crates.io:**
```bash
cargo install rustnet-monitor
```
**Windows (Chocolatey):**
```powershell
# Run in Administrator PowerShell
# Requires Npcap (https://npcap.com) installed with "WinPcap API-compatible Mode" enabled
choco install rustnet
```
**Other platforms:**
- **FreeBSD**: Download from [rustnet-bsd releases](https://github.com/domcyrus/rustnet-bsd/releases)
- **Docker, source builds, other Linux distros**: See [INSTALL.md](INSTALL.md) for detailed instructions
### Running RustNet
Packet capture requires elevated privileges:
```bash
# Quick start (all platforms)
sudo rustnet
# Linux: Grant capabilities to run without sudo (recommended)
sudo setcap 'cap_net_raw,cap_bpf,cap_perfmon+eip' $(which rustnet)
rustnet
```
**Common options:**
```bash
rustnet -i eth0 # Specify network interface
rustnet --show-localhost # Show localhost connections
rustnet --no-resolve-dns # Disable reverse DNS lookups (enabled by default)
rustnet -r 500 # Set refresh interval (ms)
rustnet --theme classic # Original full-color palette (default: muted)
rustnet --pcapng-export capture.pcapng # Annotated PCAPNG for Wireshark
```
See [INSTALL.md](INSTALL.md) for detailed permission setup and [USAGE.md](USAGE.md) for complete options.
> If you set capabilities but the TUI still shows `eBPF unavailable`, see
> [eBPF Unavailable Despite Capabilities Being Set](INSTALL.md#ebpf-unavailable-despite-capabilities-being-set)
> in the troubleshooting section.
## Keyboard Controls
| Key | Action |
|-----|--------|
| `q` | Quit (press twice to confirm) |
| `Ctrl+C` | Quit immediately |
| `x` | Clear all connections (press twice to confirm) |
| `Tab` or `]` | Next tab |
| `Shift+Tab` or `[` | Previous tab |
| `1``5` | Jump to Overview / Details / Interfaces / Graph / Help |
| `↑/k` `↓/j` | Navigate up/down |
| `g` `G` | Jump to first/last connection |
| `Enter` | View connection details |
| `Esc` | Go back or clear filter |
| `c` | Copy remote address |
| `p` | Toggle service names/ports |
| `d` | Toggle hostnames/IPs |
| `s` `S` | Cycle sort columns / toggle direction |
| `a` | Toggle process grouping |
| `Space` | Expand/collapse process group |
| `←/→` or `h/l` | Collapse/expand group |
| `PageUp/PageDown` or `Ctrl+B/F` | Page navigation |
| `t` | Toggle historic (closed) connections |
| `i` | Toggle the System info sidebar |
| `r` | Reset view (grouping, sort, filter) |
| `/` | Enter filter mode |
| `h` | Toggle help |
See [USAGE.md](USAGE.md) for detailed keyboard controls and navigation tips.
## Filtering & Sorting
**Quick filtering examples:**
```
/google # Search for "google" anywhere
/port:443 # Filter by port
/process:firefox # Filter by process
/state:established # Filter by connection state
/dport:443 sni:github.com # Combine filters
```
**Sorting:**
- Press `s` to cycle through sortable columns (Process, Addresses, Service, Application, State, Bandwidth)
- Press `S` (Shift+s) to toggle sort direction
- Find bandwidth hogs: Press `s` until "Bandwidth Total ↓" appears (sorts by combined up+down speed)
See [USAGE.md](USAGE.md) for complete filtering syntax and sorting guide.
<details>
<summary><b>Advanced Filtering Examples</b></summary>
**Keyword filters:**
- `port:44` - Ports containing "44" (443, 8080, 4433)
- `sport:80` - Source ports containing "80"
- `dport:443` - Destination ports containing "443"
- `src:192.168` - Source IPs containing "192.168"
- `dst:github.com` - Destinations containing "github.com"
- `process:ssh` - Process names containing "ssh"
- `sni:api` - SNI hostnames containing "api"
- `app:openssh` - SSH connections using OpenSSH
- `state:established` - Filter by protocol state
- `proto:tcp` - Filter by protocol type
**State filtering:**
- `state:syn_recv` - Half-open connections (SYN flood detection)
- `state:established` - Established connections only
- `state:quic_connected` - Active QUIC connections
- `state:dns_query` - DNS query connections
**Combined examples:**
- `sport:80 process:nginx` - Nginx connections from port 80
- `dport:443 sni:google.com` - HTTPS to Google
- `process:firefox state:quic_connected` - Firefox QUIC connections
- `dport:22 app:openssh state:established` - Established OpenSSH connections
</details>
<details>
<summary><b>Connection Lifecycle & Visual Indicators</b></summary>
RustNet uses smart timeouts and visual warnings before removing connections:
**Visual staleness indicators:**
- **White**: Active (< 75% of timeout)
- **Yellow**: Stale (75-90% of timeout)
- **Red**: Critical (> 90% of timeout)
**Protocol-aware timeouts:**
- **HTTP/HTTPS**: 10 minutes (supports keep-alive)
- **SSH**: 30 minutes (long sessions)
- **TCP active**: 10 minutes, idle: 5 minutes
- **QUIC connected**: 3 minutes (or peer's transport-param idle timeout, when present); `Initial`/`Handshaking`: 60 seconds
- **DNS**: 30 seconds
- **TCP CLOSED**: 5 seconds
Example: An HTTP connection turns yellow at 7.5 min, red at 9 min, and is removed at 10 min.
See [USAGE.md](USAGE.md) for complete timeout details.
</details>
## Documentation
- **[INSTALL.md](INSTALL.md)** - Detailed installation instructions for all platforms, permission setup, and troubleshooting
- **[USAGE.md](USAGE.md)** - Complete usage guide including command-line options, filtering, sorting, and logging
- **[SECURITY.md](SECURITY.md)** - Security features including Landlock sandboxing and privilege management
- **[ARCHITECTURE.md](ARCHITECTURE.md)** - Technical architecture, platform implementations, and performance details
- **[PROFILING.md](PROFILING.md)** - Performance profiling guide with flamegraph setup and optimization tips
- **[ROADMAP.md](ROADMAP.md)** - Planned features and future improvements
- **[RELEASE.md](RELEASE.md)** - Release process for maintainers
## Contributing
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute.
See [CONTRIBUTORS.md](CONTRIBUTORS.md) for a list of people who have contributed to this project.
## License
This project is licensed under the Apache License, Version 2.0 - see the [LICENSE](LICENSE) file for details.
## Acknowledgments
- Built with [ratatui](https://github.com/ratatui-org/ratatui) for the terminal UI
- Packet capture powered by [libpcap](https://www.tcpdump.org/)
- Inspired by tools like `tshark/wireshark/tcpdump`, `sniffnet`, `netstat`, `ss`, `iftop`, and [bandwhich](https://github.com/imsnif/bandwhich)
- Some code is vibe coded (OMG) / may the LLM gods be with you
---
## Documentation Moved
Some sections have been moved to dedicated files for better organization:
- **Permissions Setup**: Now in [INSTALL.md - Permissions Setup](INSTALL.md#permissions-setup)
- **Installation Instructions**: Now in [INSTALL.md](INSTALL.md)
- **Detailed Usage**: Now in [USAGE.md](USAGE.md)
- **Architecture Details**: Now in [ARCHITECTURE.md](ARCHITECTURE.md)
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`domcyrus/rustnet`
- 原始仓库:https://github.com/domcyrus/rustnet
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+337
View File
@@ -0,0 +1,337 @@
<p align="center">
<h1 align="center">RustNet</h1>
<p align="center">
<strong>面向终端的进程级网络监控工具:实时呈现 TCP、UDP、QUIC 连接,自带深度包检测,默认沙箱隔离运行。</strong>
</p>
<p align="center">
<a href="https://ratatui.rs/"><img src="https://ratatui.rs/built-with-ratatui/badge.svg" alt="Built With Ratatui"></a>
<a href="https://github.com/domcyrus/rustnet/actions"><img src="https://github.com/domcyrus/rustnet/workflows/Rust/badge.svg" alt="Build Status"></a>
<a href="https://crates.io/crates/rustnet-monitor"><img src="https://img.shields.io/crates/v/rustnet-monitor.svg" alt="Crates.io"></a>
<a href="https://github.com/domcyrus/rustnet/stargazers"><img src="https://img.shields.io/github/stars/domcyrus/rustnet?style=flat&logo=github" alt="GitHub Stars"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License"></a>
<a href="https://github.com/domcyrus/rustnet/releases"><img src="https://img.shields.io/github/v/release/domcyrus/rustnet.svg" alt="GitHub release"></a>
<a href="https://github.com/domcyrus/rustnet/pkgs/container/rustnet"><img src="https://img.shields.io/badge/docker-ghcr.io-blue?logo=docker" alt="Docker Image"></a>
</p>
</p>
<p align="center">
<a href="README.md">English</a> | <strong>简体中文</strong>
</p>
<p align="center">
<img src="./assets/rustnet.gif" alt="RustNet demo" width="800">
</p>
<p align="center">
<em>实时洞察机器对外发起的每一条连接:谁在使用它、走的是什么协议。无需 tcpdump,无需 X11 转发,也不必把 root 权限传递下去。</em>
</p>
## 功能特性
- **进程级归属识别**:每一条 TCP、UDP、QUIC 连接都能追溯到所属进程。Linux 使用 eBPFmacOS 使用 PKTAPWindows 与 FreeBSD 则走原生 API。Wireshark 与 tcpdump 做不到这一点;`netstat` / `ss` 也无法展示实时状态。
- **深度包检测**:无需外部解析器即可识别 HTTP、带 SNI 的 HTTPS/TLS、DNS、SSH、FTP、QUIC、MQTT、BitTorrent、STUN、NTP、mDNS、LLMNR、DHCP、SNMP、SSDP 及 NetBIOS。
- **安全沙箱**Linux 5.13+ 使用 LandlockmacOS 使用 SeatbeltWindows 通过 token 降权 + job-object 阻止子进程创建。libpcap 初始化完成后立即丢弃特权。详见 [SECURITY.zh-CN.md](SECURITY.zh-CN.md)。
- **TCP 网络分析**:实时统计重传、乱序包、快重传,既有逐连接视图也有汇总视图。
- **智能连接生命周期**:按协议设置超时,以白 → 黄 → 红的颜色指示过期程度。按 `t` 可保留历史(已关闭)连接以便事后追溯。
- **Vim / fzf 风格过滤**:支持 `port:``src:``dst:``sni:``process:``state:``proto:`,以及 `/(?i)pattern/` 形式的正则。
- **GeoIP 增强**:基于本地 MaxMind GeoLite2 数据库查询国家信息,不发起任何网络请求。
- **跨平台**Linux、macOS、Windows、FreeBSD。
## 为什么选 RustNet
RustNet 填补了简单连接工具(`netstat``ss`)与数据包分析器(`Wireshark``tcpdump`)之间的空白:
- **进程归属**:看清每条连接归哪个应用所有。Wireshark 看不到这一层,因为它只看包,不看 socket。
- **以连接为中心的视图**:逐连接实时追踪状态、带宽与协议。
- **SSH 友好**:TUI 可直接在 SSH 会话中运行,远端服务器上发生了什么一眼可见,不必转发 X11 或抓包再回传。
RustNet 与抓包工具是互补关系。用 RustNet 看清*谁在发起连接*;若要直接在 Wireshark 中查看,可用 `--pcapng-export` 写出带 RustNet 数据包注释的 PCAPNG;若更重视清理阶段的元数据完整性,可用 `--pcap-export` 加 JSONL sidecar,再借 `scripts/pcap_enrich.py` 富化。参见 [USAGE.zh-CN.md 的 PCAP 导出章节](USAGE.zh-CN.md#pcap-export) 与 [ARCHITECTURE.zh-CN.md 的同类工具对比章节](ARCHITECTURE.zh-CN.md#comparison-with-similar-tools)。
基于 ratatui、libpcap、eBPF(libbpf-rs)、DashMap、crossbeam、ring、MaxMind GeoLite2 与 Landlock 构建。完整依赖清单见 [ARCHITECTURE.zh-CN.md](ARCHITECTURE.zh-CN.md#dependencies)。
<details>
<summary><b>基于 eBPF 的增强型进程识别(Linux 默认)</b></summary>
RustNet 在 Linux 上默认使用内核 eBPF 程序进行进程识别,从而获得更高的性能与更低的开销。但这种方式也有一些重要的限制需要了解:
**进程名长度限制:**
- eBPF 使用内核的 `comm` 字段,该字段最多只有 16 个字符
- 显示的是任务 / 线程的命令名,而非完整的可执行路径
- 多线程应用往往展示线程名而非主进程名
**真实场景示例:**
- **Firefox**:可能显示为 "Socket Thread"、"Web Content"、"Isolated Web Co" 或 "MainThread"
- **Chrome**:可能显示为 "ThreadPoolForeg"、"Chrome_IOThread"、"BrokerProcess" 或 "SandboxHelper"
- **Electron 应用**:经常显示为 "electron"、"node" 或内部线程名
- **系统进程**:展示截断后的名字,如 "systemd-resolve" → "systemd-resolve"
**回退行为:**
- 当 eBPF 加载失败或权限不足时,RustNet 会自动回退到基于 procfs 的标准进程识别方式
- 标准模式可以拿到完整进程名,但 CPU 开销更高
- eBPF 默认启用,无需任何特殊编译参数
如需关闭 eBPF、仅使用 procfs 模式,请这样构建:
```bash
cargo build --release --no-default-features
```
技术细节见 [ARCHITECTURE.zh-CN.md](ARCHITECTURE.zh-CN.md)。
</details>
<details>
<summary><b>网络接口统计监控</b></summary>
RustNet 在所有支持的平台上提供实时的网络接口统计:
- **概览标签页**:展示当前活跃的接口,包含速率、错误数与丢包数
- **接口标签页**(按 `3`):以详细表格呈现各接口的完整指标
- **跨平台**Linux(sysfs)、macOS / FreeBSD(getifaddrs)、Windows(GetIfTable2 API)
- **智能过滤**:Windows 上自动剔除虚拟 / 过滤类适配器
如何解读接口统计以及各平台的差异,详见 [USAGE.zh-CN.md](USAGE.zh-CN.md#interface-statistics)。
**可用指标:**
- 总字节数与包数(RX / TX)
- 错误计数(收 / 发)
- 丢包数(队列溢出)
- 冲突数(传统指标,现代网络中很少出现)
数据由后台线程每 2 秒采集一次,对性能影响极小。
</details>
## 截图
<table>
<tr>
<td align="center"><strong>概览</strong><br>连接列表与实时统计、迷你折线图<br><img src="./assets/screenshots/overview.png" width="400"></td>
<td align="center"><strong>详情</strong><br>逐连接展示 SNI、加密套件、GeoIP、DPI<br><img src="./assets/screenshots/details.png" width="400"></td>
</tr>
<tr>
<td align="center"><strong>图表</strong><br>流量曲线、应用分布、Top 进程<br><img src="./assets/screenshots/graph.png" width="400"></td>
<td align="center"><strong>接口</strong><br>各接口 RX / TX 历史曲线、错误与丢包<br><img src="./assets/screenshots/interfaces.png" width="400"></td>
</tr>
</table>
## 快速上手
### 安装
**Homebrew(macOS / Linux):**
```bash
brew install rustnet
```
**Ubuntu(25.10+):**
```bash
sudo add-apt-repository ppa:domcyrus/rustnet
sudo apt update && sudo apt install rustnet
```
**Fedora(42+):**
```bash
sudo dnf copr enable domcyrus/rustnet
sudo dnf install rustnet
```
**openSUSE Tumbleweed:**
```bash
sudo zypper addrepo https://download.opensuse.org/repositories/home:/domcyrus:/rustnet/openSUSE_Tumbleweed/home:domcyrus:rustnet.repo
sudo zypper refresh
sudo zypper install rustnet
```
**Arch Linux:**
```bash
sudo pacman -S rustnet
```
**Nix / NixOS:**
```bash
nix-shell -p rustnet
# 然后在 shell 中执行: sudo rustnet
```
**通过 crates.io:**
```bash
cargo install rustnet-monitor
```
**Windows(Chocolatey):**
```powershell
# 需在管理员权限的 PowerShell 中执行
# 需要先安装 Npcap(https://npcap.com),并启用 "WinPcap API-compatible Mode"
choco install rustnet
```
**其他平台:**
- **FreeBSD**:从 [rustnet-bsd releases](https://github.com/domcyrus/rustnet-bsd/releases) 下载
- **Docker、源码构建、其他 Linux 发行版**:详见 [INSTALL.zh-CN.md](INSTALL.zh-CN.md)
### 运行 RustNet
抓包需要更高的权限:
```bash
# 快速启动(所有平台)
sudo rustnet
# Linux:为可执行文件赋予 Linux capabilities,即可免 sudo 运行(推荐)
sudo setcap 'cap_net_raw,cap_bpf,cap_perfmon+eip' $(which rustnet)
rustnet
```
**常用参数:**
```bash
rustnet -i eth0 # 指定网络接口
rustnet --show-localhost # 显示 localhost 上的连接
rustnet --no-resolve-dns # 关闭反向 DNS 解析(默认开启)
rustnet -r 500 # 设置刷新间隔(毫秒)
rustnet --theme classic # 原始全彩调色板(默认:muted)
rustnet --pcapng-export capture.pcapng # 导出带注释的 PCAPNG
```
权限配置详情见 [INSTALL.zh-CN.md](INSTALL.zh-CN.md),完整参数说明见 [USAGE.zh-CN.md](USAGE.zh-CN.md)。
> 如果已经设置了 Linux capabilities,但 TUI 仍然提示 `eBPF unavailable`,请参阅 [INSTALL.zh-CN.md 的排障章节](INSTALL.zh-CN.md#ebpf-unavailable-despite-capabilities-being-set)。
## 键盘控制
| 按键 | 作用 |
|-----|--------|
| `q` | 退出(连按两次确认) |
| `Ctrl+C` | 立即退出 |
| `x` | 清空所有连接(连按两次确认) |
| `Tab``]` | 下一个标签页 |
| `Shift+Tab``[` | 上一个标签页 |
| `1``5` | 直接跳转到 Overview / Details / Interfaces / Graph / Help |
| `↑/k` `↓/j` | 上下移动 |
| `g` `G` | 跳到第一条 / 最后一条连接 |
| `Enter` | 查看连接详情 |
| `Esc` | 返回或清除过滤器 |
| `c` | 复制远端地址 |
| `p` | 在服务名与端口之间切换 |
| `d` | 在主机名与 IP 之间切换 |
| `s` `S` | 切换排序列 / 切换排序方向 |
| `a` | 切换按进程分组 |
| `Space` | 展开 / 折叠进程分组 |
| `←/→``h/l` | 折叠 / 展开当前分组 |
| `PageUp/PageDown``Ctrl+B/F` | 翻页 |
| `t` | 切换是否显示历史(已关闭)连接 |
| `i` | 切换 System 信息侧边栏 |
| `r` | 重置视图(分组、排序、过滤) |
| `/` | 进入过滤模式 |
| `h` | 切换帮助 |
完整键位说明与导航技巧见 [USAGE.zh-CN.md](USAGE.zh-CN.md)。
## 过滤与排序
**快速过滤示例:**
```
/google # 全局搜索 "google"
/port:443 # 按端口过滤
/process:firefox # 按进程过滤
/state:established # 按连接状态过滤
/dport:443 sni:github.com # 组合多个过滤条件
```
**排序:**
-`s` 在可排序的列之间循环切换(进程、地址、服务、应用、状态、带宽)
-`S`(Shift+s)切换升序 / 降序
- 想抓出带宽大户:连续按 `s` 直到显示 "Bandwidth Total ↓"(按上下行合计速度排序)
完整的过滤语法与排序说明见 [USAGE.zh-CN.md](USAGE.zh-CN.md)。
<details>
<summary><b>高级过滤示例</b></summary>
**关键字过滤:**
- `port:44` —— 端口号包含 "44" 的连接(443、8080、4433)
- `sport:80` —— 源端口包含 "80"
- `dport:443` —— 目的端口包含 "443"
- `src:192.168` —— 源 IP 包含 "192.168"
- `dst:github.com` —— 目的地址包含 "github.com"
- `process:ssh` —— 进程名包含 "ssh"
- `sni:api` —— SNI 主机名包含 "api"
- `app:openssh` —— 使用 OpenSSH 的 SSH 连接
- `state:established` —— 按协议状态过滤
- `proto:tcp` —— 按协议类型过滤
**状态过滤:**
- `state:syn_recv` —— 半开连接(可用于发现 SYN flood)
- `state:established` —— 仅显示已建立的连接
- `state:quic_connected` —— 活跃的 QUIC 连接
- `state:dns_query` —— DNS 查询连接
**组合示例:**
- `sport:80 process:nginx` —— Nginx 从 80 端口发出的连接
- `dport:443 sni:google.com` —— 到 Google 的 HTTPS
- `process:firefox state:quic_connected` —— Firefox 的 QUIC 连接
- `dport:22 app:openssh state:established` —— 已建立的 OpenSSH 连接
</details>
<details>
<summary><b>连接生命周期与可视化指示</b></summary>
RustNet 在移除连接前会先用智能超时机制与颜色给出预警:
**过期程度的颜色指示:**
- **白色**:活跃(< 75% 的超时时间)
- **黄色**:开始过期(75% – 90% 的超时时间)
- **红色**:即将过期(> 90% 的超时时间)
**按协议设定的超时:**
- **HTTP / HTTPS**10 分钟(支持 keep-alive)
- **SSH**30 分钟(适配长会话)
- **TCP 活跃**10 分钟;**TCP 空闲**5 分钟
- **QUIC 已连接**3 分钟(若对端通过 transport 参数声明了 idle timeout,则以对端为准);`Initial` / `Handshaking` 阶段:60 秒
- **DNS**30 秒
- **TCP CLOSED**5 秒
举例:一条 HTTP 连接会在第 7.5 分钟变黄,第 9 分钟变红,第 10 分钟被移除。
完整超时说明见 [USAGE.zh-CN.md](USAGE.zh-CN.md)。
</details>
## 文档
- **[INSTALL.zh-CN.md](INSTALL.zh-CN.md)** —— 各平台的详细安装说明、权限配置与排障
- **[USAGE.zh-CN.md](USAGE.zh-CN.md)** —— 完整使用手册,涵盖命令行参数、过滤、排序与日志
- **[SECURITY.zh-CN.md](SECURITY.zh-CN.md)** —— 安全特性,包括 Landlock 沙箱与权限管理
- **[ARCHITECTURE.zh-CN.md](ARCHITECTURE.zh-CN.md)** —— 技术架构、各平台实现与性能细节
- **[CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md)** —— 贡献指南,包括工作流、质量要求与 AI 辅助贡献规范
- **[PROFILING.zh-CN.md](PROFILING.zh-CN.md)** —— 性能分析指南,含 flamegraph 配置与优化建议
- **[ROADMAP.md](ROADMAP.md)** —— 已规划的功能与后续改进
- **[RELEASE.md](RELEASE.md)** —— 维护者发布流程
## 参与贡献
欢迎贡献!请阅读 [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md) 了解贡献流程。
历来的贡献者名单见 [CONTRIBUTORS.md](CONTRIBUTORS.md)。
## 许可证
本项目采用 Apache License 2.0 许可证,详见 [LICENSE](LICENSE) 文件。
## 致谢
- 终端 UI 基于 [ratatui](https://github.com/ratatui-org/ratatui) 构建
- 抓包能力由 [libpcap](https://www.tcpdump.org/) 提供
- 灵感来自 `tshark/wireshark/tcpdump``sniffnet``netstat``ss``iftop`,以及 [bandwhich](https://github.com/imsnif/bandwhich)
- 部分代码靠手感写出(OMG)/ 愿 LLM 之神与你同在
---
## 已迁移的文档
部分章节已迁移到独立文件,以便更好地组织内容:
- **权限配置**:迁移至 [INSTALL.zh-CN.md 的权限配置章节](INSTALL.zh-CN.md#permissions-setup)
- **安装说明**:迁移至 [INSTALL.zh-CN.md](INSTALL.zh-CN.md)
- **详细用法**:迁移至 [USAGE.zh-CN.md](USAGE.zh-CN.md)
- **架构细节**:迁移至 [ARCHITECTURE.zh-CN.md](ARCHITECTURE.zh-CN.md)
+218
View File
@@ -0,0 +1,218 @@
# Release Process
This document is for maintainers releasing new versions of RustNet.
## Changelog Maintenance (ongoing, not at release time)
Notable changes are added to the `## [Unreleased]` section of `CHANGELOG.md` in
the same PR that makes them — don't wait until release time and reconstruct the
list from git history. Cutting a release then just renames that section (see
step 3 below). `pre-release-check.sh` warns if `[Unreleased]` still has content
after the rename, and the release workflow's notes extraction ignores it.
## Creating a New Release
### 1. Run Pre-Release Checks
After updating versions and changelog, run the pre-release validation script:
```bash
./scripts/pre-release-check.sh 1.2.0
```
This validates version consistency, changelog entries, code quality (fmt/clippy/test),
Dockerfile correctness, and git status. Fix any errors before proceeding.
### 2. Test Platform Builds
Before tagging, verify all platform builds succeed on the current main branch:
```bash
# Ensure you're on the main branch with latest changes
git checkout main
git pull origin main
```
1. Go to [Actions > Test Platform Builds](../../actions/workflows/test-platform-builds.yml)
2. Click "Run workflow"
3. Select `all` to test all platforms (including static Linux builds)
4. Wait for the workflow to complete successfully
This catches cross-platform and static linking issues before you invest time in release prep.
### 3. Prepare the Release
> **Two version tracks since the workspace split.** `Cargo.toml` carries two
> versions: the binary's `[package] version` (line ~30, the user-facing `1.x`
> line that tags and packages follow) and `[workspace.package] version` (line
> ~9, the `0.x` library crates `rustnet-core`/`-capture`/`-host`, single source
> of truth also referenced from `[workspace.dependencies]`). A normal feature
> release bumps **only the binary `[package] version`** and `rpm/rustnet.spec`.
> Bump the library version separately, and only when the libraries actually
> change in a release-worthy way.
Update the binary version in `Cargo.toml` and `rpm/rustnet.spec`, and turn the
accumulated `[Unreleased]` changelog section into the release entry:
```bash
# Update Cargo.toml [package] version (e.g., version = "1.4.0") — NOT the
# [workspace.package] version unless you intend to bump the library crates.
# Update rpm/rustnet.spec Version field (e.g., Version: 1.4.0)
# In CHANGELOG.md:
# 1. Rename "## [Unreleased]" to "## [0.3.0] - YYYY-MM-DD" (review/polish the entries)
# 2. Add a fresh, empty "## [Unreleased]" section above it
# 3. Update the comparison links at the bottom:
# [Unreleased]: https://github.com/domcyrus/rustnet/compare/v0.3.0...HEAD
# [0.3.0]: https://github.com/domcyrus/rustnet/compare/v0.2.0...v0.3.0
# Update Cargo.lock and test the build
cargo build --release
cargo test
```
### 4. Commit Release Changes
```bash
# Stage and commit the version and changelog changes
git add Cargo.toml Cargo.lock CHANGELOG.md rpm/rustnet.spec
git commit -m "Release v0.3.0
- Feature or fix summary here
- Another change here
- And more changes"
```
### 5. Create and Push Git Tag
```bash
# Create an annotated tag matching the version in Cargo.toml
git tag -a v0.3.0 -m "Release v0.3.0
- Feature or fix summary here
- Another change here
- And more changes"
# Push both the commit and the tag
git push origin main
git push origin v0.3.0
```
**That's it!** The GitHub Actions workflow will automatically:
- Build binaries for all platforms (Linux, macOS, Windows - multiple architectures)
- Create installer packages (DEB, RPM, DMG, MSI)
- Extract release notes from CHANGELOG.md
- Create a draft GitHub release with all artifacts attached
- Upload all binaries and installers to the release
- **Publish the release** (un-draft) once all assets are uploaded
- Trigger downstream package updates (Homebrew, Chocolatey, FreeBSD, PPA, COPR, AUR, Docker, crates.io)
### 6. Verify the Release
Once the GitHub Actions workflow completes (~15-20 minutes):
1. Go to the [GitHub repository releases page](https://github.com/domcyrus/rustnet/releases)
2. Verify the release is published (no longer a draft) with all assets
3. Review the automatically extracted release notes
4. Check downstream package updates completed (Homebrew, Chocolatey, etc.)
## Automated Release Workflow
The release process is fully automated via [`.github/workflows/release.yml`](.github/workflows/release.yml):
**Triggers:**
- Pushing a tag matching `v[0-9]+.[0-9]+.[0-9]+` (e.g., `v0.3.0`, `v1.2.3`)
- Manual workflow dispatch
**What it does:**
1. **Builds cross-platform binaries:**
- Linux: x64, ARM64, ARMv7 (with eBPF support)
- macOS: Intel (x64) and Apple Silicon (ARM64)
- Windows: 64-bit and 32-bit
2. **Creates installer packages:**
- **Linux:** DEB packages (amd64, arm64, armhf) and RPM packages (x86_64, aarch64)
- **macOS:** DMG installers with app bundles (supports code signing/notarization if secrets configured)
- **Windows:** MSI installers (64-bit and 32-bit)
3. **Extracts release notes:**
- Automatically parses `CHANGELOG.md` to extract the version-specific section
- Falls back to auto-generated notes if no changelog entry is found
4. **Creates GitHub release:**
- Creates a draft release with the tag name as title
- Attaches all binaries and installer packages
- Uses extracted changelog content as release notes
5. **Publishes the workspace to crates.io** (via
[`.github/workflows/publish.yml`](.github/workflows/publish.yml), after the
GitHub release is published): the four crates are published in dependency
order — `rustnet-core``rustnet-capture``rustnet-host`
`rustnet-monitor` — waiting for each to appear in the index before publishing
a dependent. The step is idempotent (it skips any `crate@version` already on
crates.io), so a re-run after a partial failure is safe. The library crates
use the `[workspace.package]` version; the binary uses its `[package]`
version.
## Important: Never Move a Tag After Release
**Never force-push or move a tag after the release pipeline has started.** Moving a tag
causes GitHub to regenerate source tarballs with different SHA checksums, which breaks
every downstream package manager that already cached the original checksums:
- **AUR/Homebrew/Chocolatey**: checksum verification failures for end users
- **Launchpad PPA**: rejects uploads with the same version but different file contents
- **crates.io**: already published and cannot be re-published with the same version
If a fix is needed after tagging, **create a patch release** (e.g., `v1.1.1`) instead.
## Release Checklist
Before pushing the tag, ensure:
- [ ] Pre-release checks pass: `./scripts/pre-release-check.sh x.y.z`
- [ ] Test Platform Builds workflow passes for all platforms (including static)
- [ ] Binary version updated in `Cargo.toml` `[package]` (not `[workspace.package]` unless bumping the library crates)
- [ ] Version number updated in `rpm/rustnet.spec` (line 5: `Version: x.y.z`)
- [ ] `Cargo.lock` updated (via `cargo build`)
- [ ] `CHANGELOG.md`: `[Unreleased]` renamed to `## [x.y.z] - YYYY-MM-DD`, a fresh empty `[Unreleased]` added, comparison links updated
- [ ] All tests pass (`cargo test`)
- [ ] Changes committed to main branch
- [ ] Git tag created and pushed
After GitHub Actions completes:
- [ ] Verify release is published (automatically un-drafted after all assets uploaded)
- [ ] Verify all platform binaries built successfully
- [ ] Verify all installer packages created (DEB, RPM, DMG, MSI)
- [ ] Verify Docker image pushed to ghcr.io
- [ ] Verify all four crates published to crates.io (`rustnet-monitor`, `rustnet-core`, `rustnet-capture`, `rustnet-host`) and docs.rs built
- [ ] Review automatically extracted release notes
- [ ] Verify Homebrew formula updated at https://github.com/domcyrus/homebrew-rustnet
- [ ] Verify Chocolatey package updated at https://github.com/domcyrus/rustnet-chocolatey
- [ ] Verify FreeBSD build at https://github.com/domcyrus/rustnet-bsd
- [ ] Announce release (if applicable)
## Maintenance: New Ubuntu and Fedora Releases
This is an occasional task, not part of every release. When a new Ubuntu interim or LTS, or a new Fedora release, is published and we want RustNet packages to ship for it:
1. **Ubuntu PPA**: add the new codename to the matrix in [`.github/workflows/ppa-release.yml`](.github/workflows/ppa-release.yml) (the `set-matrix` job's `releases=[...]` list and the `workflow_dispatch` choice options). Confirm the new series ships `rustc-1.88` (or whatever the current `rust-version` floor in `Cargo.toml` is). Reference: issue [#254](https://github.com/domcyrus/rustnet/issues/254) added Ubuntu 26.04 (Resolute) support.
2. **Fedora COPR**: add the new chroot in the COPR project settings at [https://copr.fedorainfracloud.org/coprs/domcyrus/rustnet/edit/](https://copr.fedorainfracloud.org/coprs/domcyrus/rustnet/edit/). The chroot list is managed in the COPR UI, not in this repo.
3. Trigger a `workflow_dispatch` of `Release to Ubuntu PPA` for the new codename to verify the build before relying on it from the next tagged release.
Conversely, when an older Ubuntu series is no longer worth supporting (no `rustc-1.88` in archive, or end of life), remove it from the same two locations and update [INSTALL.md](INSTALL.md) and [debian/README.md](debian/README.md) to match.
## Versioning
RustNet follows [Semantic Versioning (SemVer)](https://semver.org/):
- **MAJOR** version for incompatible API changes
- **MINOR** version for backward-compatible functionality additions
- **PATCH** version for backward-compatible bug fixes
Examples:
- `v0.1.0``v0.1.1` (bug fixes)
- `v0.1.1``v0.2.0` (new features)
- `v0.2.0``v1.0.0` (major changes, API stability)
+270
View File
@@ -0,0 +1,270 @@
# RustNet Roadmap
This document outlines the planned features and improvements for RustNet.
## Platform Support
- [x] **macOS Support**: Full support including:
- BPF device access and permissions setup
- PKTAP (Packet Tap) headers for process identification from packet metadata
- Fallback to `lsof` system commands for process-socket associations
- DMG installation packages for Apple Silicon and Intel
- Homebrew installation support
- [x] **Windows Support**: Full functionality working with:
- Npcap SDK and runtime integration
- MSI installation packages for 64-bit and 32-bit
- Process identification via Windows IP Helper API (GetExtendedTcpTable/GetExtendedUdpTable)
- [x] **FreeBSD Support**: Full support including:
- Process identification via `sockstat` command parsing
- BPF device access and permissions setup
- Native libpcap packet capture
- Cross-compilation support from Linux
- [ ] **FreeBSD Capsicum Full Sandbox** (`cap_enter()`): Replace per-FD `cap_rights_limit()` with full capability mode to prevent file access and data exfiltration. Requires:
- Switch from `sockstat` subprocess to `libprocstat(3)` library calls for process lookup (eliminates `fork()`/`execve()` dependency)
- Integrate `libcasper` for privileged sysctl access from inside capability mode (`kern.proc.filedesc` is blocked in `cap_enter()`)
- Architecture: pre-fork a Casper service before `cap_enter()`, communicate over socket pair at runtime
- Write FFI bindings for `libprocstat` and `libcasper` (no Rust crate exists)
- Link against `-lprocstat -lcasper -lcap_sysctl` (system libraries on FreeBSD 10+)
- [ ] **Windows Sandbox Hardening**: Strengthen the current privilege-drop + Job Object setup with process mitigation policies (`SetProcessMitigationPolicy`), low-integrity execution, and evaluation of `CreateRestrictedToken` / AppContainer.
- [ ] **macOS Seatbelt Hardening**: The current Seatbelt profile is allow-default with targeted denies (user homes, system credential stores, outbound TCP/UDP, `process-exec` except `lsof`). Tighten further:
- **Deny-by-default writes**: rustnet only writes its log/PCAP/JSONL output, so flip `file-write*` to deny-by-default with a small allowlist. This blocks root-level persistence (`/Library/LaunchDaemons`, `/Library/LaunchAgents`, `/private/etc` cron/launchd, etc.) that the current allow-default write policy leaves open. Needs on-host validation that the TUI's writes to the already-open tty and the `lsof` child still work.
- **More credential read denies**: system TCC database (`/Library/Application Support/com.apple.TCC`), Kerberos keytabs, `master.passwd`/`sudoers`, saved network/Wi-Fi configuration (`/Library/Preferences/SystemConfiguration`).
- **Eventual deny-by-default reads**: whitelist the dyld shared cache, system frameworks, `/dev/bpf*`, resolver/locale/timezone data, and the GeoIP paths. Strongest containment, but fragile across macOS releases — requires a multi-version on-host test pass before shipping.
- [ ] **Linux Sandbox Hardening (capabilities + Landlock network)**: Landlock already enforces deny-by-default filesystem access on the post-sandbox worker threads, but two gaps remain when rustnet runs as root:
- **Drop all non-essential capabilities** (or clear the bounding set via `PR_CAPBSET_DROP`) before spawning the worker threads. Today only `CAP_NET_RAW`/`CAP_BPF`/`CAP_PERFMON` are dropped, so a root-launched process retains `CAP_DAC_OVERRIDE`, `CAP_SYS_ADMIN`, `CAP_SYS_MODULE`, etc. — which Landlock does not cover (non-filesystem/non-TCP abuse such as loading kernel modules). Running non-root with a `cap_net_raw` file capability already avoids this; the hardening is for the common `sudo rustnet` case.
- **UDP egress is not blocked**: Landlock ABI v4 only governs TCP `bind`/`connect`, so UDP exfiltration remains possible (accepted as low risk today, since filesystem reads are tightly contained). Revisit when a newer Landlock ABI adds UDP support. Note macOS Seatbelt already blocks both TCP and UDP.
- [ ] **OpenBSD and NetBSD Support**: Future platforms to support
- [x] **Linux Process Identification**: **Experimental eBPF Support Implemented** - Basic eBPF-based process identification now available with `--features ebpf`. Provides efficient kernel-level process-to-connection mapping with lower overhead than procfs. Currently has limitations (see eBPF Improvements section below).
## eBPF Improvements (Linux)
The experimental eBPF support provides efficient process identification but has several areas for improvement:
### Current Limitations
- **Process Names Limited to 16 Characters**: Uses kernel `comm` field, causing truncation (e.g., "Firefox" → "Socket Thread")
- **Thread Names vs Process Names**: Shows thread command names instead of full executable names
### Planned Improvements
- **Hybrid eBPF + Procfs Approach**: Use eBPF for connection tracking, selectively lookup full process names via procfs for better accuracy
- **Full Executable Path Resolution**: Investigate accessing full process executable path from eBPF programs
- **Better Process-Thread Mapping**: Improve mapping from thread IDs to parent process information
- **Enhanced BTF Support**: Better compatibility across different kernel versions and distributions
- **Performance Optimizations**: Reduce eBPF map lookups and improve connection-to-process matching efficiency
- **Switch from kprobes to fentry/fexit or kprobe.multi**: Today we attach 7 kprobes via `perf_event_open(2)`, which is gated by `kernel.perf_event_paranoid`. On Debian 13 the default is `=3` (an out-of-tree patch that predates CAP_PERFMON and only honors CAP_SYS_ADMIN), so even with `cap_bpf,cap_perfmon+ep` set via `setcap`, attach fails with `-EACCES` until the user lowers the sysctl globally (issue #255). Debian bug #994044 was archived in 2025 without a fix, so this is unlikely to change in Forky (Debian 14, ~2027) without renewed pressure. Ubuntu has a parallel patch at `=4` that *was* updated to honor CAP_PERFMON in late 2025 (Jammy 5.15.0-165+, Noble 6.8.0-91+, Plucky 6.14.0-37+, Questing 6.17.0-14+, Resolute 6.18.0-8+), so Ubuntu users already work out of the box — Debian is the laggard. Both `BPF_PROG_TYPE_TRACING` (fentry/fexit, kernel ≥ 5.5) and `kprobe.multi` (fprobe-based, kernel ≥ 5.18) attach via `BPF_LINK_CREATE` and never call `perf_event_open` — they're gated only by CAP_BPF + CAP_PERFMON, so they would work out of the box on Debian regardless of the broken patch. fentry also gives entry args + return value in a single program (cleaner than kprobe + kretprobe). Mainline / Fedora / Arch / RHEL all default to paranoid=2 and the upstream direction (CAP_PERFMON, fprobe, BTF trampolines) is toward more privilege separation, not less, so this conversion is also future-aligned. Trade-off: raises minimum supported kernel from current (kprobes work back to ~4.x) to 5.5+ for fentry or 5.18+ for kprobe.multi — need to decide whether to keep a kprobe fallback for older kernels.
### Future Enhancements
- **Real-time Process Updates**: Track process name changes and executable updates
- **Container Support**: Better process identification within containerized environments
- **Security Context**: Include process security attributes (capabilities, SELinux context, etc.)
- **Cross-Namespace Attribution for Kubernetes**: The current procfs fallback reads `/proc/net/tcp` from the reader's network namespace, so under `hostNetwork: true` (as used by kubectl-rustnet) it never sees sockets owned by pods in their own netns. The kubernetes feature ships a scoped per-PID `/proc/<pid>/net/{tcp,tcp6,udp,udp6}` walker that covers TCP+UDP for kubepods PIDs, but it ticks at the enrichment interval and so misses sub-tick ephemeral flows. The complete fix lives in the eBPF layer: kprobes/fentry are netns-agnostic and fire at `connect()`/`accept()` time, but the current socket-tracker map is being pruned more aggressively than userspace can consume. Plan: extend map retention (or switch to a ring buffer of close events that userspace drains opportunistically), debug the "Map Lookup Miss" path under Kubernetes traffic patterns, and verify cross-namespace coverage end-to-end in a kind cluster. This work also benefits ICMP and raw-socket attribution, which procfs cannot reach.
## Features
### Monitoring & Protocol Support
- [x] **Real-time Network Monitoring**: Monitor active TCP, UDP, ICMP, and ARP connections
- [x] **Connection States**: Comprehensive state tracking for:
- TCP states (ESTABLISHED, SYN_SENT, TIME_WAIT, CLOSED, etc.)
- QUIC states (QUIC_INITIAL, QUIC_HANDSHAKE, QUIC_CONNECTED, QUIC_DRAINING)
- DNS states (DNS_QUERY, DNS_RESPONSE)
- SSH states (BANNER, KEYEXCHANGE, AUTHENTICATION, ESTABLISHED)
- Activity states (UDP_ACTIVE, UDP_IDLE, UDP_STALE)
- [x] **Deep Packet Inspection (DPI)**: Application protocol detection:
- HTTP with host information
- HTTPS/TLS with SNI (Server Name Indication)
- DNS queries and responses
- SSH connections with version detection, software identification, and state tracking
- QUIC protocol with CONNECTION_CLOSE frame detection and RFC 9000 compliance
- [ ] **DPI Enhancements**: Improve deep packet inspection capabilities:
- Support more protocols (e.g. FTP, SMTP, IMAP, etc.)
- CDP/LLDP (network device discovery protocols)
- LACP (Link Aggregation Control Protocol)
- More accurate SNI detection for QUIC/HTTPS
- [x] **Connection Lifecycle Management**: Smart protocol-aware timeouts with visual staleness indicators (yellow at 75%, red at 90%)
- [x] **Process Identification**: Associate network connections with running processes (with experimental eBPF support on Linux)
- [x] **Service Name Resolution**: Identify well-known services using port numbers
- [x] **Cross-platform Support**: Works on Linux, macOS, Windows, and FreeBSD
- [x] **DNS Reverse Lookup**: Add optional hostname resolution (toggle between IP and hostname display) - `--resolve-dns` flag with `d` key toggle
- [ ] **IPv6 Support**: Full IPv6 connection tracking and display, including DNS resolution (needs testing)
- [ ] **VLAN Tag Detection**: Parse 802.1Q VLAN tags from packet headers to identify VLAN configurations
- [ ] **Passive Host Discovery**: Infer local network hosts from observed ARP requests/replies and other broadcast traffic without active scanning
- [ ] **MAC Vendor Lookup (OUI)**: Resolve MAC addresses to hardware vendor names using a local OUI database (e.g. "Apple", "Intel", "Ubiquiti")
### Filtering & Search
- [x] **Advanced Filtering**: Real-time vim/fzf-style filtering with:
- Navigate while typing filters
- Fuzzy search across all connection fields including DPI data
- Keyword filters: `port:`, `src:`, `dst:`, `sni:`, `process:`, `sport:`, `dport:`, `ssh:`, `state:`
- State filtering for all protocol states
- Exact port matching by default (`port:22` matches only port 22)
- Regular expression support via `/pattern/` syntax on any filter value
### Sorting & Display
- [x] **Sorting**: Comprehensive table sorting with:
- Sort by all columns: Protocol, Local/Remote Address, State, Service, Application, Bandwidth (Down/Up), Process
- Intuitive left-to-right column cycling with `s` key
- Direction toggle with `S` (Shift+s) for ascending/descending
- Visual indicators: cyan/underlined active column, arrows showing direction
- Smart defaults: bandwidth descending (show hogs), text ascending (alphabetical)
- Bandwidth sorting: sorts by combined up+down bandwidth total
- Seamless integration with filtering
### Performance & Architecture
- [x] **Multi-threaded Processing**: Concurrent packet processing across multiple threads
- [x] **Optional Logging**: Detailed logging with configurable log levels (disabled by default)
### Packaging & Distribution
- [x] **Package Distribution**: Pre-built packages available:
- [x] **macOS DMG packages**: Apple Silicon and Intel (via GitHub Actions release workflow)
- [x] **Windows MSI packages**: 64-bit and 32-bit (via cargo-wix)
- [x] **Linux DEB packages**: amd64, arm64, armhf (via cargo-deb)
- [x] **Linux RPM packages**: x86_64, aarch64 (via cargo-generate-rpm)
- [x] **Cargo crates.io**: Published as `rustnet-monitor` (version 0.10.0+)
- [x] **Docker images**: Available on GitHub Container Registry with eBPF support
- [x] **Homebrew formula**: Available in separate tap repository (domcyrus/rustnet)
### Future Enhancements
- [ ] **Internationalization (i18n)**: Support for multiple languages in the UI
- [x] **Connection History**: Store and display historical connection data (toggle with `t` key, up to 5,000 archived connections)
- [x] **PCAP Export**: Export packets to PCAP file with process attribution sidecar (`--pcap-export`)
- Standard PCAP format compatible with Wireshark/tcpdump
- Streaming JSONL sidecar with PID, process name, timestamps
- Python enrichment script to create annotated PCAPNG
- [x] **Native Annotated PCAPNG Export**: Export a Wireshark-ready PCAPNG file with live best-effort RustNet packet comments (`--pcapng-export`)
- Per-packet comments include process/PID, direction, DPI/SNI, and GeoIP/ASN when available
- Uses true capture timestamps and bounded attribution retry
- [ ] **Enhanced PCAP Metadata**: Richer process information in sidecar file
- Process executable full path (not just name)
- Command line arguments
- Working directory
- User/UID information
- Parent process information
- [ ] **Configuration File**: Support for persistent configuration:
- Custom color themes and UI styling
- Default filters and sort preferences
- Default process grouping (start with `group: true` in config)
- Color mode preference (disable colors via config, complementing `--no-color` flag)
- Per-interface settings
- Keybinding customization
- [ ] **Connection Alerts**: Notifications for new connections or suspicious activity
- [x] **GeoIP Integration**: Geographical location of remote IPs
- [x] **GeoIP City-Level Resolution**: Extend GeoIP to include city-level location data using GeoLite2-City database
- [ ] **Protocol Statistics**: Summary view of protocol distribution
- [ ] **Rate Limiting Detection**: Identify connections with unusual traffic patterns
- [ ] **Bufferbloat Detection**: Measure latency under load to identify bufferbloat issues on the network
- [ ] **PCAP Import/Replay**: Load a PCAP file (with optional JSON process attribution sidecar) and replay it in the TUI for offline analysis. Enables remote monitoring workflows: capture on a remote host with `--pcap-export`, transfer files, and replay locally with full process-attributed view
- [ ] **Route Table Display**: Show the system routing table in a user-friendly view within the TUI
- [ ] **Privacy/Redact Mode**: Obfuscate sensitive information (IPs, MACs, hostnames) in the TUI for safe screenshots and sharing. Include option to export connection details from the details view to a text file with privacy redaction applied
## UI Improvements
- [x] **Terminal User Interface**: TUI built with ratatui with adjustable column widths
- [x] **Sortable Columns**: Keyboard-based sorting by all table columns
- [x] **Keyboard Controls**: Comprehensive keyboard navigation (q, Ctrl+C, x, Tab, arrows, j/k, g/G, PageUp/Down, Enter, Esc, c, p, s, S, h, /, a, r, Space)
- [x] **Connection Details View**: Detailed information about selected connections (Enter key)
- [x] **Help Screen**: Toggle help screen with keyboard shortcuts (h key)
- [x] **Clipboard Support**: Copy remote address to clipboard (c key)
- [x] **Service/Port Toggle**: Toggle between service names and port numbers (p key)
- [x] **Platform-Specific CLI Help**: Show only relevant options per platform (hide Linux sandbox options on macOS, hide PKTAP notes on Linux)
- [x] **Connection Grouping**: Group connections by process with expandable tree view (press `a` to toggle, aggregated stats, Space/arrows to expand/collapse)
- [x] **Reset View**: Reset all view settings (grouping, sort, filter) with `r` key
- [ ] **Resizable Columns**: Dynamic column width adjustment
- [ ] **ASCII Graphs**: Terminal-based graphs for bandwidth/packet visualization
- [ ] **Mouse Support**: Click to select connections
- [ ] **Split Pane View**: Show multiple views simultaneously
## Architecture
### Workspace Split
Restructure the single crate into a Cargo workspace (same GitHub repo) with clear separation of concerns:
- [x] **rustnet-monitor** (binary, bin name `rustnet`): CLI, TUI, app event
loop, sandboxing (Landlock/Seatbelt), and interface statistics -- the
user-facing application; process attribution is delegated to `rustnet-host`.
(Package stays `rustnet-monitor` because the `rustnet` crate name is taken on
crates.io; the installed binary is `rustnet`.)
- [x] **rustnet-core** (library): Packet parsing, protocol types, DPI,
link-layer parsers, connection merging, and DNS/GeoIP/OUI lookups -- the
reusable, platform-independent, capture-independent analysis core. Lives at
`crates/rustnet-core`. (Named `rustnet-core` rather than `rustnet-net` to
avoid the redundant "net-net"; verified available on crates.io.)
- [x] **rustnet-capture** (library): the libpcap/Npcap-based capture backend --
device selection, BPF filters, macOS PKTAP, TUN/TAP, and a raw-frame
`PacketReader`. Lives at `crates/rustnet-capture`. This is the **existing**
pcap code moved into its own crate (not a libpcap-free rewrite): the point of
the split is composability — a headless front-end (e.g. a Prometheus exporter)
can pair `rustnet-capture` + `rustnet-core` without the TUI, and a platform
wanting a bespoke capture path (e.g. the macOS pktap helper) can swap it out.
The macOS `DegradationReason` coupling was untangled by giving capture its own
`PktapUnavailable` enum, which the binary maps to its UI `DegradationReason`.
- [x] **rustnet-host** (library): Per-connection process attribution behind one
`ProcessLookup` trait -- eBPF/procfs on Linux, PKTAP/lsof on macOS, the IP
Helper API on Windows, and `sockstat` on FreeBSD. Lives at `crates/rustnet-host`
and owns the eBPF build tooling (the `socket_tracker.bpf.c` program and bundled
`vmlinux.h`). The binary injects PKTAP availability via `report_pktap_degradation`,
so the crate needs no dependency on `rustnet-capture`.
- [ ] **rustnet-helper** (binary): Minimal suid helper for macOS pktap privilege
separation (~100 lines, zero C deps — just `libc`). **Future work, not yet a
crate.** The root-gated pktap interface creation (`SIOCIFCREATE`) can only be
written and validated on real macOS hardware, so this is deferred until it can
be done for real rather than scaffolded. See "macOS Privilege Separation" below.
Benefits:
- Clean dependency boundaries (helper has zero C dependencies)
- `rustnet-core` becomes independently useful as a Rust network analysis library
- Compile times improve (parallel crate compilation)
- `cargo install rustnet-monitor` continues to work unchanged
**Status:** The workspace exists with `rustnet-monitor` (binary) depending on
`rustnet-core`, `rustnet-capture`, and `rustnet-host`. The binary's `src/network`
module re-exports `rustnet_core::network::*` and `rustnet_capture` (as `capture`)
so existing `crate::network::*` paths, integration tests, and benches are
unchanged. Net-only dependencies (`dns-lookup`, `ring`, `aes`, `flate2`,
`maxminddb`, `pnet_datalink`) and the baked-in `oui.gz` / `services` assets live
in `rustnet-core`; all pcap usage lives in `rustnet-capture`; and `procfs` /
`libbpf-rs` plus the eBPF programs and `vmlinux.h` live in `rustnet-host`.
`rustnet-core` also exposes a `ConnectionTracker` so headless tools can fold
captured packets into a live, lifecycle-managed connection table without the
TUI. Remaining work: the `rustnet-helper` macOS pktap suid helper (needs real
hardware).
### macOS Privilege Separation (pktap without root)
Currently pktap requires root because the macOS kernel enforces a root check (`SIOCIFCREATE` ioctl) when creating the pktap pseudo-interface. This is independent of BPF device permissions (ChmodBPF). The goal is to run the main RustNet process as a regular user while only the minimal helper runs privileged.
**Approach**: Small suid helper binary that:
1. Opens `/dev/bpf*` and creates the pktap interface (requires root)
2. Configures BPF device (bind interface, set buffer size, immediate mode)
3. Locks the device with `BIOCLOCK` (prevents further configuration changes)
4. Passes the BPF file descriptor to the unprivileged RustNet process via Unix socket (`SCM_RIGHTS`)
5. Drops privileges and exits
The main RustNet process reads packets directly from the received BPF fd using `read()` -- no libpcap needed on this path. The existing pktap header parser (`link_layer/pktap.rs`) already handles the packet format. BPF filter compilation is not needed since BPF filters are already incompatible with pktap.
On Linux/Windows/FreeBSD, nothing changes -- libpcap is used as today, with the existing capability-based privilege model on Linux.
Security properties:
- Helper is tiny (~100 lines of Rust, no C code) -- minimal attack surface as root
- `BIOCLOCK` prevents the unprivileged process from reconfiguring the capture device
- Seatbelt sandbox can still be applied to the main process after fd handoff
- Similar pattern to Wireshark's `dumpcap` but with a smaller privileged surface (no libpcap in the helper)
## Development
- [x] **Unit Tests**: Basic unit tests in 12+ source modules (DPI protocols, filtering, services, network capture, etc.)
- [x] **Integration Tests**: Platform-specific integration tests for Linux and macOS (tests/integration_tests.rs)
- [ ] **Comprehensive Test Coverage**: Expand test coverage across all modules
- [x] **CI/CD Pipeline**: Automated builds and releases for all platforms (GitHub Actions)
- [x] **Release workflow**: Multi-platform builds with cross-compilation
- [x] **Docker workflow**: Automated Docker image builds
- [x] **Rust workflow**: Basic CI checks
- [x] **Documentation**: Comprehensive README with usage guides, architecture overview, and troubleshooting
- [x] **Packaging/Distribution**: Create packages for easy installation on Linux, macOS, and Windows
- DMG packages with code signing
- MSI packages with code signing for Windows
+333
View File
@@ -0,0 +1,333 @@
<p align="center"><strong>English</strong> | <a href="SECURITY.zh-CN.md">简体中文</a></p>
# Security
RustNet processes untrusted network data, making defense-in-depth security critical. This document describes the security measures implemented.
## Table of Contents
- [Landlock Sandboxing (Linux)](#landlock-sandboxing-linux)
- [Seatbelt Sandboxing (macOS)](#seatbelt-sandboxing-macos)
- [FreeBSD Sandboxing](#freebsd-sandboxing)
- [Privilege Drop and Job Object Sandboxing (Windows)](#privilege-drop-and-job-object-sandboxing-windows)
- [Privilege Requirements](#privilege-requirements)
- [Read-Only Operation](#read-only-operation)
- [No External Communication](#no-external-communication)
- [Log File Privacy](#log-file-privacy)
- [eBPF Security](#ebpf-security)
- [Threat Model](#threat-model)
- [Supply Chain Security](#supply-chain-security)
- [Audit and Compliance](#audit-and-compliance)
- [Reporting Security Issues](#reporting-security-issues)
## Landlock Sandboxing (Linux)
On Linux 5.13+, RustNet uses [Landlock](https://landlock.io/) to restrict its own capabilities after initialization. This limits the damage if a vulnerability in packet parsing is exploited.
### What Gets Restricted
| Restriction | Kernel Version | Description |
|-------------|----------------|-------------|
| Filesystem | 5.13+ | Only `/proc` readable (for process identification) |
| Network | 6.4+ | TCP bind/connect blocked (RustNet is passive) |
| Capabilities | Any | `CAP_NET_RAW` dropped after pcap socket opened |
| Capabilities | Any | `CAP_BPF`, `CAP_PERFMON` dropped after eBPF programs loaded |
| Root uid | Any | When started as root (e.g. `sudo rustnet`), the process drops to the invoking user (`SUDO_UID`/`SUDO_GID`) or `nobody` after initialization |
| Privileges | 3.5+ | `PR_SET_NO_NEW_PRIVS` set by RustNet itself — always, even with `--no-sandbox` — prevents privilege escalation via setuid binaries |
### How It Works
1. **Initialization phase**: RustNet loads eBPF programs, opens packet capture handles, and creates log files
2. **Privilege lock**: `PR_SET_NO_NEW_PRIVS` is set (applied even when the sandbox is disabled)
3. **Capability drop**: `CAP_NET_RAW`, `CAP_BPF`, and `CAP_PERFMON` are removed from the process
4. **Root uid drop**: when running as root, the process switches to the invoking sudo user (or `nobody`) via `setresuid`/`setresgid`. Already-open capture sockets, eBPF programs, and log/export files keep working. This matters most on kernels without Landlock, where the uid drop is the main containment
5. **Landlock**: Restricts filesystem and network access
### Security Benefits
If an attacker exploits a vulnerability in DPI/packet parsing:
- Cannot read arbitrary files (credentials, configs, etc.)
- Cannot write to filesystem (except configured log paths)
- Cannot make outbound TCP connections (data exfiltration blocked)
- Cannot bind TCP ports (reverse shell blocked)
- Cannot create new raw sockets (capability dropped)
- Cannot escalate privileges via setuid binaries (`PR_SET_NO_NEW_PRIVS`, set even with `--no-sandbox`)
- Does not run as root: under `sudo rustnet` the process continues as the invoking user, so even on kernels without Landlock a compromise does not yield root
### CLI Options
```
--no-sandbox Disable Landlock sandboxing, capability dropping, and the
root uid drop (PR_SET_NO_NEW_PRIVS is still set)
--sandbox-strict Require full sandbox enforcement or exit
--no-uid-drop Keep running as root instead of dropping to
SUDO_UID/SUDO_GID (or nobody) after initialization
```
Trade-off of the root uid drop: the procfs fallback for process attribution can
then only inspect processes owned by the target user, and Kubernetes log
directories under `/var/log/pods` may become unreadable. The eBPF fast path
(the default) is unaffected. If you rely on procfs-only attribution (e.g. a
build without eBPF) and need to attribute other users' processes, use
`--no-uid-drop`.
### Graceful Degradation
- **Kernel < 5.13**: Sandboxing skipped, warning logged
- **Kernel 5.13-6.3**: Filesystem restrictions only
- **Kernel 6.4+**: Full filesystem + network restrictions
- **Docker**: Landlock may be restricted; app continues normally
## Seatbelt Sandboxing (macOS)
On macOS 10.5+, RustNet uses [Seatbelt](https://theapplewiki.com/wiki/Dev:Seatbelt) (`sandbox_init_with_parameters`) to restrict its own capabilities after initialization. This limits the damage if a vulnerability in packet parsing is exploited.
### What Gets Restricted
| Restriction | Description |
|-------------|-------------|
| Outbound network | TCP/UDP outbound blocked; Unix sockets (Mach IPC) allowed |
| Filesystem reads | User home directories blocked (`/Users`, `/var/root`); GeoIP paths explicitly allowed |
| Filesystem writes | All user home directories blocked (`/Users`, `/var/root`) |
| Filesystem writes | Only configured log, PCAP, and PCAPNG export paths writable |
| Process execution | All binaries blocked except `/usr/sbin/lsof` |
| Root uid | When started as root (e.g. `sudo rustnet`), the process drops to the invoking user (`SUDO_UID`/`SUDO_GID`) or `nobody` after initialization |
### How It Works
1. **Initialization phase**: RustNet opens packet capture handles (BPF/PKTAP) and creates log files
2. **Pre-create**: PCAP sidecar (`.connections.jsonl`) and PCAPNG export files are created before the sandbox so their paths are already valid allow targets, and are handed over to the uid-drop target so they stay writable after the drop
3. **Root uid drop**: when running as root, the process switches to the invoking sudo user (or `nobody`) via `setgid`/`setuid`. Already-open capture and log/export descriptors keep working
4. **Sandbox application**: `sandbox_init_with_parameters` is called; already-open file descriptors survive unchanged, only future operations are restricted
### Profile Strategy
RustNet uses an **allow-default** SBPL profile with targeted denies. A deny-default profile would require explicitly whitelisting all system libraries, Mach ports, locale data, fonts, and other OS internals — fragile and error-prone. Allow-default with targeted denies covers the primary threats (credential theft, data exfiltration, shell escapes) without operational risk. Specific deny rules block file reads/writes under user home directories, outbound network connections, and execution of all binaries except `/usr/sbin/lsof`.
### Output File Support
`--json-log`, `--pcap-export`, and `--pcapng-export` paths are passed to the SBPL profile as runtime parameters (`JSON_LOG_PATH`, `PCAP_PATH`, `PCAP_JSONL_PATH`, `PCAPNG_PATH`). The profile grants an explicit `allow file-write*` rule on each path, which takes precedence over the broader `/Users` deny rule via SBPL specificity. Unused parameters default to `/dev/null`.
All three flags work normally within the sandbox.
### Security Benefits
If an attacker exploits a vulnerability in DPI/packet parsing:
- Cannot read SSH keys, AWS credentials, browser profiles, or other credential files under `/Users`
- Cannot write to SSH keys, AWS credentials, browser profiles, or other credential files
- Cannot make outbound TCP/UDP connections (data exfiltration blocked)
- Cannot open new raw network sockets
- Cannot execute binaries (no shell escapes via `/bin/sh`, `/usr/bin/curl`, etc.)
- Does not run as root: under `sudo rustnet` the process continues as the invoking user
### CLI Options
```
--no-sandbox Disable Seatbelt sandboxing and the root uid drop
--sandbox-strict Require full sandbox enforcement or exit
--no-uid-drop Keep running as root instead of dropping to
SUDO_UID/SUDO_GID (or nobody) after initialization
```
Trade-off of the root uid drop: the default PKTAP attribution path is
unaffected (process metadata arrives in-band on the already-open capture fd),
but the lsof fallback (active when PKTAP is unavailable, e.g. with an explicit
`--interface`) then only sees the target user's processes. Use `--no-uid-drop`
if you rely on lsof attribution for other users' processes.
### Why BestEffort is Default
`sandbox_init_with_parameters` is a private (undocumented) macOS API. It has been stable since macOS 10.5 and is used by Chromium, Firefox, and Safari for process sandboxing, but it could theoretically change without notice. BestEffort degrades gracefully if the API behaves unexpectedly rather than preventing the app from running. Use `--sandbox-strict` to require sandboxing or abort.
### Clipboard Behavior
Unlike Linux Landlock, clipboard copy (`c` key) works normally under Seatbelt. macOS clipboard uses NSPasteboard, which communicates via Mach IPC over Unix domain sockets — the SBPL profile explicitly allows `(network-outbound (remote unix-socket))`.
On Linux, clipboard requires access to Wayland sockets (`/run/user/UID/wayland-0`) or X11 sockets (`/tmp/.X11-unix/`). Landlock's deny-default model blocks these because they are not in the write-path allowlist, so clipboard is unavailable when Landlock is active.
## FreeBSD Sandboxing
FreeBSD does not currently have sandboxing enabled. A full Capsicum sandbox using `cap_enter()` with `libcasper` for privileged process lookup is planned — see [ROADMAP.md](ROADMAP.md) for details.
### Root Uid Drop
Until Capsicum lands, the primary containment on FreeBSD is a root privilege drop: when started as root (e.g. `sudo rustnet`), the process drops to the invoking user (`SUDO_UID`/`SUDO_GID`) or `nobody` after the BPF capture devices are open, via `setresuid`/`setresgid`. Already-open capture and log/export descriptors keep working, and pre-created export files are handed over to the target user. Note that `doas` does not set `SUDO_UID`, so doas users get the `nobody` fallback.
Trade-off: process attribution uses `sockstat`, which as a non-root user only sees the target user's sockets. Use `--no-uid-drop` if you need attribution for other users' processes.
```
--no-uid-drop Keep running as root instead of dropping to
SUDO_UID/SUDO_GID (or nobody) after initialization
```
## Privilege Drop and Job Object Sandboxing (Windows)
On Windows, RustNet removes dangerous privileges from the process token and applies a Job Object to prevent child process creation after initialization.
### What Gets Restricted
| Restriction | Description |
|-------------|-------------|
| Privilege removal | SeDebugPrivilege, SeTakeOwnershipPrivilege, SeBackupPrivilege, SeRestorePrivilege, and other dangerous privileges permanently removed |
| Child processes | Job Object blocks creation of child processes (reverse shell, exec-based exfiltration) |
### How It Works
1. **Initialization phase**: RustNet opens Npcap handles and creates log files
2. **Privilege removal**: `AdjustTokenPrivileges` with `SE_PRIVILEGE_REMOVED` permanently strips dangerous privileges from the process token
3. **Job Object**: A Job Object with `JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 1` is applied, preventing any child process creation
### Security Benefits
If an attacker exploits a vulnerability in DPI/packet parsing:
- Cannot debug other processes (SeDebugPrivilege removed)
- Cannot take ownership of arbitrary files (SeTakeOwnershipPrivilege removed)
- Cannot bypass ACLs to read files (SeBackupPrivilege removed)
- Cannot spawn child processes (cmd.exe, powershell.exe, curl.exe — blocked by Job Object)
- Cannot load kernel drivers (SeLoadDriverPrivilege removed)
### Limitations
Windows sandboxing is weaker than Linux/macOS/FreeBSD:
- No filesystem restriction — Windows lacks a process-wide filesystem sandbox equivalent to Landlock or Seatbelt
- No network restriction — blocking outbound would break Npcap packet capture
- Privilege removal only affects privileges the elevated process held
### CLI Options
```
--no-sandbox Disable privilege removal and job object
--sandbox-strict Require full sandbox enforcement or exit
```
## Privilege Requirements
RustNet requires privileged access for packet capture:
| Platform | Requirement |
|----------|-------------|
| Linux | `CAP_NET_RAW` capability or root |
| macOS | Root or BPF group membership (`access_bpf` group) |
| Windows | Administrator (for Npcap) |
| FreeBSD | Root or BPF device access |
### Why Privileges Are Needed
- **Raw socket access** - Intercept network traffic at low level (read-only, non-promiscuous mode)
- **BPF device access** - Load packet filters into kernel
- **eBPF programs** - Optional kernel probes for enhanced process tracking (Linux only)
### Recommended: Capability-based Execution (Linux)
Instead of running as root, grant only the required capabilities:
```bash
# Modern Linux (5.8+): packet capture + eBPF
sudo setcap 'cap_net_raw,cap_bpf,cap_perfmon+eip' $(which rustnet)
# Packet capture only (no eBPF process detection)
sudo setcap cap_net_raw+eip $(which rustnet)
```
Legacy pre-5.8 kernels required `CAP_SYS_ADMIN` for eBPF operations. RustNet
does not grant this broad capability automatically; use `CAP_NET_RAW` only and
let eBPF fall back to procfs unless you explicitly accept the extra risk.
After sandbox application, `CAP_NET_RAW` and eBPF-loading capabilities are
dropped - the process retains only the minimum privileges needed.
## Read-Only Operation
RustNet only monitors traffic; it does not:
- Modify packets
- Block connections
- Inject traffic
- Alter routing tables
- Change firewall rules
The packet capture is opened in non-promiscuous, read-only mode.
## No External Communication
RustNet operates entirely locally:
- No telemetry or analytics
- No network requests (except monitored traffic)
- No cloud services or remote APIs
- All data stays on your system
## Log File Privacy
Log files may contain sensitive information:
- IP addresses and ports
- Hostnames and SNI data
- Process names and PIDs
- DNS queries and responses
**Best Practices:**
- Disable logging by default (no `--log-level` flag)
- Secure log directory permissions
- Implement log rotation and retention policies
- Review logs for sensitive data before sharing
## eBPF Security
When using eBPF for enhanced process detection (default on Linux):
- Requires additional kernel capabilities (`CAP_BPF`, `CAP_PERFMON`)
- eBPF programs are verified by kernel before loading
- Limited to read-only operations (no packet modification)
- Automatically falls back to procfs if eBPF fails
## Threat Model
**What RustNet protects against:**
- Unauthorized users cannot capture packets without proper permissions
- Capability-based permissions limit blast radius of compromise
- Landlock (Linux) and Seatbelt (macOS) sandboxes contain potential exploitation
**What RustNet does NOT protect against:**
- Users with packet capture permissions can see all unencrypted traffic
- Root/Administrator users can modify RustNet or capture packets directly
- Physical access to the machine enables packet capture
- Network-level attacks (RustNet is a monitoring tool, not a security appliance)
### Sandboxing as Root
Both Landlock (Linux) and Seatbelt (macOS) enforce restrictions even when RustNet runs as root (UID 0). Once applied, the sandbox cannot be reversed from within the process — on Linux, RustNet sets `PR_SET_NO_NEW_PRIVS` directly before applying any restrictions (Landlock requires and would set it as well), which is irreversible per-process and applied even with `--no-sandbox`.
However, sandboxing does **not** protect against supply chain attacks. A compromised binary would simply not apply the sandbox. Root can also:
- Pass `--no-sandbox` to skip sandboxing entirely (except `PR_SET_NO_NEW_PRIVS`)
- Unload the Landlock LSM kernel module
- Disable SIP on macOS (which controls sandbox enforcement)
- Use `ptrace` to modify a running process
For this reason, running with fine-grained capabilities (`setcap cap_net_raw=eip`) is strongly preferred over running as root.
## Supply Chain Security
RustNet takes the following measures to protect against supply chain attacks:
- **Dependency lockfile**: `Cargo.lock` is committed to the repository, pinning all transitive dependency versions and recording source checksums. This prevents silent version upgrades.
- **Security audit**: `cargo deny check` runs in CI on every push and pull request, checking dependencies against the RustSec Advisory Database and enforcing license, source, and wildcard-version policies (`deny.toml`). A scheduled daily workflow re-checks advisories against the committed `Cargo.lock`, so newly published advisories surface without requiring a push.
- **CI action pinning**: All GitHub Actions are pinned by commit SHA (not tags), preventing tag-rewriting attacks on upstream actions.
- **Conservative dependency policy**: New dependencies require justification and are reviewed for maintenance status and security track record (see `CONTRIBUTING.md`).
- **Build-time integrity**: The Windows Npcap SDK download in `build.rs` is verified against a hardcoded SHA256 checksum.
- **Code signing**: macOS releases are signed with an Apple Developer certificate and notarized.
- **Checksum verification**: All packaging workflows (Homebrew, Chocolatey, AUR) calculate and double-verify SHA256 checksums before publishing.
### Limitations
- `cargo install rustnet` fetches the latest compatible versions from crates.io and does **not** use `Cargo.lock`. Users building from source should verify the source tarball checksum.
- Build scripts (`build.rs`) and proc-macros execute arbitrary code at compile time. While all current dependencies are well-established crates, this is an inherent risk of the Rust build model.
## Audit and Compliance
For production environments:
- **Audit logging** of who runs RustNet with packet capture privileges
- **Network monitoring policies** and compliance with data protection regulations
- **User access reviews** for privileged network access
- **Automated capability management** via configuration management systems
## Reporting Security Issues
Please report security vulnerabilities via GitHub Issues or contact the maintainers directly.
+328
View File
@@ -0,0 +1,328 @@
<p align="center"><a href="SECURITY.md">English</a> | <strong>简体中文</strong></p>
# 安全
RustNet 处理不受信任的网络数据,因此纵深防御至关重要。本文档描述了已实现的安全措施。
## 目录
- [Landlock 沙箱(Linux](#landlock-sandboxing-linux)
- [Seatbelt 沙箱(macOS](#seatbelt-sandboxing-macos)
- [FreeBSD 沙箱](#freebsd-sandboxing)
- [权限剥离与 Job Object 沙箱(Windows](#privilege-drop-and-job-object-sandboxing-windows)
- [权限需求](#privilege-requirements)
- [只读操作](#read-only-operation)
- [不主动对外通信](#no-external-communication)
- [日志文件隐私](#log-file-privacy)
- [eBPF 安全](#ebpf-security)
- [威胁模型](#threat-model)
- [供应链安全](#supply-chain-security)
- [审计与合规](#audit-and-compliance)
- [报告安全问题](#reporting-security-issues)
## Landlock 沙箱(Linux<a id="landlock-sandboxing-linux"></a>
在 Linux 5.13+ 上,RustNet 使用 [Landlock](https://landlock.io/) 在初始化后限制自身的 Linux capabilities。这样即使包解析存在漏洞被利用,也能限制损害范围。
### 受限制的内容
| 限制项 | 内核版本 | 描述 |
|--------|----------|------|
| 文件系统 | 5.13+ | 仅 `/proc` 可读(用于进程识别) |
| 网络 | 6.4+ | 禁止 TCP bind/connectRustNet 为被动模式) |
| Linux capabilities | 任意 | pcap socket 打开后丢弃 `CAP_NET_RAW` |
| Linux capabilities | 任意 | eBPF 程序加载后丢弃 `CAP_BPF``CAP_PERFMON``CAP_SYS_ADMIN` |
| root uid | 任意 | 以 root 启动时(如 `sudo rustnet`),初始化完成后降权到调用用户(`SUDO_UID`/`SUDO_GID`)或 `nobody` |
| 特权 | 3.5+ | `PR_SET_NO_NEW_PRIVS` 由 RustNet 自身设置——始终生效,即使使用 `--no-sandbox`——防止通过 setuid 二进制文件提升特权 |
### 工作原理
1. **初始化阶段**RustNet 加载 eBPF 程序、打开包捕获句柄、创建日志文件
2. **特权锁定**:设置 `PR_SET_NO_NEW_PRIVS`(即使禁用沙箱也会应用)
3. **Linux capabilities 剥离**:移除 `CAP_NET_RAW``CAP_BPF``CAP_PERFMON``CAP_SYS_ADMIN`
4. **root uid 降权**:以 root 运行时,通过 `setresuid`/`setresgid` 切换到调用 sudo 的用户(或 `nobody`)。已打开的捕获 socket、eBPF 程序和日志/导出文件继续有效。在没有 Landlock 的内核上,这是主要的隔离手段
5. **Landlock**:限制文件系统和网络访问
### 安全收益
如果攻击者利用 DPI/包解析中的漏洞:
- 无法读取任意文件(凭据、配置等)
- 无法写入文件系统(除配置的日志路径外)
- 无法建立出站 TCP 连接(阻止数据外泄)
- 无法绑定 TCP 端口(阻止反向 shell)
- 无法创建新的 raw socketLinux capabilities 已剥离)
- 无法通过 setuid 二进制文件提升特权(`PR_SET_NO_NEW_PRIVS`,即使使用 `--no-sandbox` 也会设置)
- 不以 root 运行:使用 `sudo rustnet` 时进程会切换为调用用户,即使在没有 Landlock 的内核上,攻击者也无法获得 root
### CLI 选项
```
--no-sandbox 禁用 Landlock 沙箱、Linux capabilities 剥离和 root uid 降权
(仍会设置 PR_SET_NO_NEW_PRIVS
--sandbox-strict 要求完整沙箱强制生效,否则退出
--no-uid-drop 初始化后保持 root 运行,
不降权到 SUDO_UID/SUDO_GID(或 nobody
```
root uid 降权的权衡:降权后,procfs 回退路径的进程归属只能检查目标用户拥有的进程,
`/var/log/pods` 下的 Kubernetes 日志目录也可能不可读。eBPF 快速路径(默认)不受影响。
如果依赖纯 procfs 归属(如未启用 eBPF 的构建)且需要归属其他用户的进程,请使用
`--no-uid-drop`
### 优雅降级
- **Kernel < 5.13**:跳过沙箱,记录警告
- **Kernel 5.13-6.3**:仅文件系统限制
- **Kernel 6.4+**:完整的文件系统 + 网络限制
- **Docker**Landlock 可能受限;应用正常运行
## Seatbelt 沙箱(macOS<a id="seatbelt-sandboxing-macos"></a>
在 macOS 10.5+ 上,RustNet 使用 [Seatbelt](https://theapplewiki.com/wiki/Dev:Seatbelt)`sandbox_init_with_parameters`)在初始化后限制自身能力。这样即使包解析存在漏洞被利用,也能限制损害范围。
### 受限制的内容
| 限制项 | 描述 |
|--------|------|
| 出站网络 | TCP/UDP 出站被阻止;Unix socketMach IPC)允许 |
| 文件系统读取 | 禁止读取用户主目录(`/Users``/var/root`);GeoIP 路径显式允许 |
| 文件系统写入 | 禁止写入所有用户主目录(`/Users``/var/root` |
| 文件系统写入 | 仅配置的日志、JSON log、PCAP 和 PCAPNG 导出路径可写 |
| 进程执行 | 除 `/usr/sbin/lsof` 外,禁止执行所有二进制文件 |
| root uid | 以 root 启动时(如 `sudo rustnet`),初始化完成后降权到调用用户(`SUDO_UID`/`SUDO_GID`)或 `nobody` |
### 工作原理
1. **初始化阶段**RustNet 打开包捕获句柄(BPF/PKTAP)并创建日志文件
2. **预创建**PCAP sidecar 文件(`.connections.jsonl`)和 PCAPNG 输出文件在沙箱应用前创建,因此其路径已经是有效的允许目标;同时移交给降权目标用户,确保降权后仍可写入
3. **root uid 降权**:以 root 运行时,通过 `setgid`/`setuid` 切换到调用 sudo 的用户(或 `nobody`)。已打开的捕获和日志/导出文件描述符继续有效
4. **沙箱应用**:调用 `sandbox_init_with_parameters`;已打开的文件描述符保持不变,仅限制未来的操作
### 配置文件策略
RustNet 使用 **默认允许** 的 SBPL 配置文件配合针对性拒绝。拒绝默认的配置文件需要显式将所有系统库、Mach 端口、区域设置数据、字体和其他 OS 内部组件加入白名单——脆弱且容易出错。默认允许配合针对性拒绝覆盖了主要威胁(凭据窃取、数据外泄、shell 逃逸),同时避免操作风险。具体的拒绝规则阻止对用户主目录下的文件读/写、出站网络连接,以及除 `/usr/sbin/lsof` 外所有二进制文件的执行。
### 输出文件支持
`--json-log``--pcap-export``--pcapng-export` 路径通过运行时参数(`JSON_LOG_PATH``PCAP_PATH``PCAP_JSONL_PATH``PCAPNG_PATH`)传递给 SBPL 配置文件。配置文件为每个路径授予显式的 `allow file-write*` 规则,该规则通过 SBPL 的特异性优先于更宽泛的 `/Users` 拒绝规则。未使用的参数默认为 `/dev/null`
三个标志在沙箱内均可正常工作。
### 安全收益
如果攻击者利用 DPI/包解析中的漏洞:
- 无法读取 `/Users` 下的 SSH 密钥、AWS 凭据、浏览器配置文件或其他凭据文件
- 无法写入 `/Users` 下的 SSH 密钥、AWS 凭据、浏览器配置文件或其他凭据文件
- 无法建立出站 TCP/UDP 连接(阻止数据外泄)
- 无法打开新的 raw network socket
- 无法执行二进制文件(不能通过 `/bin/sh``/usr/bin/curl` 等逃逸 shell
- 不以 root 运行:使用 `sudo rustnet` 时进程会切换为调用用户
### CLI 选项
```
--no-sandbox 禁用 Seatbelt 沙箱和 root uid 降权
--sandbox-strict 要求完整沙箱强制生效,否则退出
--no-uid-drop 初始化后保持 root 运行,
不降权到 SUDO_UID/SUDO_GID(或 nobody
```
root uid 降权的权衡:默认的 PKTAP 归属路径不受影响(进程元数据随已打开的捕获
fd 带内到达),但 lsof 回退路径(PKTAP 不可用时启用,例如显式指定 `--interface`
降权后只能看到目标用户的进程。如果依赖 lsof 归属其他用户的进程,请使用
`--no-uid-drop`
### 为什么默认使用 BestEffort
`sandbox_init_with_parameters` 是 macOS 的私有(未公开)API。自 macOS 10.5 以来一直保持稳定,Chromium、Firefox 和 Safari 都使用它进行进程沙箱,但理论上可能在没有通知的情况下发生变化。BestEffort 在 API 行为异常时优雅降级,而不是阻止应用运行。使用 `--sandbox-strict` 可要求沙箱生效,否则中止。
### 剪贴板行为
与 Linux Landlock 不同,在 Seatbelt 下剪贴板复制(`c` 键)正常工作。macOS 剪贴板使用 NSPasteboard,通过 Mach IPC 在 Unix domain socket 上通信——SBPL 配置文件显式允许 `(network-outbound (remote unix-socket))`
在 Linux 上,剪贴板需要访问 Wayland socket`/run/user/UID/wayland-0`)或 X11 socket`/tmp/.X11-unix/`)。Landlock 的拒绝默认模型会阻止这些,因为它们不在写路径的允许列表中,因此当 Landlock 激活时剪贴板不可用。
## FreeBSD 沙箱<a id="freebsd-sandboxing"></a>
FreeBSD 当前未启用沙箱。计划使用 `cap_enter()` 配合 `libcasper` 实现完整的 Capsicum 沙箱,用于特权进程查找——详见 [ROADMAP.md](ROADMAP.md)。
### root uid 降权
在 Capsicum 落地之前,FreeBSD 上的主要隔离手段是 root 降权:以 root 启动时(如 `sudo rustnet`),BPF 捕获设备打开后,进程通过 `setresuid`/`setresgid` 降权到调用用户(`SUDO_UID`/`SUDO_GID`)或 `nobody`。已打开的捕获和日志/导出文件描述符继续有效,预创建的导出文件会移交给目标用户。注意 `doas` 不设置 `SUDO_UID`,因此 doas 用户会回退到 `nobody`
权衡:进程归属使用 `sockstat`,非 root 用户只能看到目标用户的 socket。如需归属其他用户的进程,请使用 `--no-uid-drop`
```
--no-uid-drop 初始化后保持 root 运行,
不降权到 SUDO_UID/SUDO_GID(或 nobody
```
## 权限剥离与 Job Object 沙箱(Windows<a id="privilege-drop-and-job-object-sandboxing-windows"></a>
在 Windows 上,RustNet 在初始化后从进程令牌中移除危险特权,并应用 Job Object 阻止子进程创建。
### 受限制的内容
| 限制项 | 描述 |
|--------|------|
| 特权移除 | 永久移除 SeDebugPrivilege、SeTakeOwnershipPrivilege、SeBackupPrivilege、SeRestorePrivilege 等危险特权 |
| 子进程 | Job Object 阻止创建子进程(反向 shell、基于 exec 的数据外泄) |
### 工作原理
1. **初始化阶段**RustNet 打开 Npcap 句柄并创建日志文件
2. **特权移除**`AdjustTokenPrivileges` 配合 `SE_PRIVILEGE_REMOVED` 永久从进程令牌中剥离危险特权
3. **Job Object**:应用 `JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 1` 的 Job Object,阻止任何子进程创建
### 安全收益
如果攻击者利用 DPI/包解析中的漏洞:
- 无法调试其他进程(SeDebugPrivilege 已移除)
- 无法取得任意文件的所有权(SeTakeOwnershipPrivilege 已移除)
- 无法通过 ACL 绕过读取文件(SeBackupPrivilege 已移除)
- 无法生成子进程(cmd.exe、powershell.exe、curl.exe —— 被 Job Object 阻止)
- 无法加载内核驱动(SeLoadDriverPrivilege 已移除)
### 局限性
Windows 沙箱弱于 Linux/macOS/FreeBSD
- 无文件系统限制 —— Windows 缺少与 Landlock 或 Seatbelt 等效的进程级文件系统沙箱
- 无网络限制 —— 阻止出站会中断 Npcap 包捕获
- 特权移除仅影响提升进程已拥有的特权
### CLI 选项
```
--no-sandbox 禁用特权移除和 Job Object
--sandbox-strict 要求完整沙箱强制生效,否则退出
```
## 权限需求<a id="privilege-requirements"></a>
RustNet 需要特权访问来捕获网络数据包:
| 平台 | 需求 |
|------|------|
| Linux | `CAP_NET_RAW` 这项 Linux capability 或 root |
| macOS | Root 或 BPF 组成员(`access_bpf` 组) |
| Windows | Administrator(用于 Npcap |
| FreeBSD | Root 或 BPF 设备访问 |
### 为什么需要特权
- **Raw socket 访问** —— 在低层拦截网络流量(只读、非混杂模式)
- **BPF 设备访问** —— 将包过滤器加载到内核
- **eBPF 程序** —— 可选的内核探针,用于增强进程追踪(仅限 Linux)
### 推荐:基于 Linux capabilities 的执行(Linux
与其以 root 运行,不如仅授予所需的 Linux capabilities
```bash
# 现代 Linux5.8+):包捕获 + eBPF
sudo setcap 'cap_net_raw,cap_bpf,cap_perfmon+eip' $(which rustnet)
# 仅包捕获(eBPF 会回退到 procfs
sudo setcap cap_net_raw+eip $(which rustnet)
```
旧版 pre-5.8 内核需要宽泛的 `CAP_SYS_ADMIN` 才能执行 eBPF 操作。RustNet
的安装包不会自动授予该 capability;除非你明确接受该风险,否则请只授予
`CAP_NET_RAW` 并使用 procfs 回退。沙箱应用后,`CAP_NET_RAW` 和 eBPF
加载相关 capabilities 会被丢弃——进程仅保留所需的最小特权。
## 只读操作<a id="read-only-operation"></a>
RustNet 仅监控流量,不会:
- 修改数据包
- 阻断连接
- 注入流量
- 更改路由表
- 更改防火墙规则
包捕获以非混杂、只读模式打开。
## 不主动对外通信<a id="no-external-communication"></a>
RustNet 完全在本地运行:
- 无遥测或分析
- 无网络请求(除监控的流量外)
- 无云服务或远程 API
- 所有数据保留在你的系统上
## 日志文件隐私<a id="log-file-privacy"></a>
日志文件可能包含敏感信息:
- IP 地址和端口
- 主机名和 SNI 数据
- 进程名和 PID
- DNS 查询和响应
**最佳实践:**
- 默认禁用日志记录(不使用 `--log-level` 标志)
- 保护日志目录权限
- 实施日志轮转和保留策略
- 分享前检查日志中的敏感数据
## eBPF 安全<a id="ebpf-security"></a>
使用 eBPF 进行增强型进程检测时(Linux 默认):
- 现代内核需要额外的 Linux capabilities`CAP_BPF``CAP_PERFMON`
- eBPF 程序在加载前由内核验证
- 仅限只读操作(不修改数据包)
- 如果 eBPF 失败,自动回退到 procfs
## 威胁模型<a id="threat-model"></a>
**RustNet 防护的内容:**
- 未经授权的用户无法在没有适当权限的情况下捕获数据包
- 基于 Linux capabilities 的权限限制了被入侵后的影响范围
- LandlockLinux)和 SeatbeltmacOS)沙箱限制潜在的漏洞利用
**RustNet 不防护的内容:**
- 拥有包捕获权限的用户可以看到所有未加密的流量
- Root/Administrator 用户可以直接修改 RustNet 或捕获数据包
- 对机器的物理访问可以捕获数据包
- 网络级攻击(RustNet 是监控工具,不是安全设备)
### 以 Root 身份运行时的沙箱
LandlockLinux)和 SeatbeltmacOS)即使在 RustNet 以 root(UID 0)运行时也会强制执行限制。沙箱一旦应用就无法从进程内部撤销——在 Linux 上,RustNet 会在应用任何限制之前直接设置 `PR_SET_NO_NEW_PRIVS`(Landlock 也需要并会同样设置它),该设置对每个进程不可逆,并且即使使用 `--no-sandbox` 也会应用。
然而,沙箱**不能**防护供应链攻击。被入侵的二进制文件可以直接不应用沙箱。Root 也可以:
- 传递 `--no-sandbox` 完全跳过沙箱(`PR_SET_NO_NEW_PRIVS` 除外)
- 卸载 Landlock LSM 内核模块
- 在 macOS 上禁用 SIP(控制沙箱强制执行)
- 使用 `ptrace` 修改运行中的进程
因此,强烈建议使用细粒度 Linux capabilities`setcap cap_net_raw=eip`)运行,而不是以 root 运行。
## 供应链安全<a id="supply-chain-security"></a>
RustNet 采取以下措施防护供应链攻击:
- **依赖锁文件**`Cargo.lock` 已提交到仓库,固定所有传递依赖版本并记录源校验和。这防止静默版本升级。
- **安全审计**`cargo deny check` 在每次 push 和 pull request 时于 CI 中运行,对照 RustSec Advisory Database 检查依赖,并强制执行许可证、来源和通配符版本策略(`deny.toml`)。一个每日定时工作流会针对已提交的 `Cargo.lock` 重新检查安全公告,因此新发布的公告无需 push 即可被发现。
- **CI action 固定**:所有 GitHub Actions 均通过 commit SHA(而非标签)固定,防止对上游 action 的标签重写攻击。
- **保守的依赖策略**:新依赖需要说明理由,并审查其维护状态和安全记录(参见 [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md))。
- **构建时完整性**Windows Npcap SDK 下载在 `build.rs` 中对照硬编码的 SHA256 校验和进行验证。
- **代码签名**macOS 发布版本使用 Apple Developer 证书签名并进行公证。
- **校验和验证**:所有打包工作流(Homebrew、Chocolatey、AUR)在发布前计算并双重验证 SHA256 校验和。
### 局限性
- `cargo install rustnet` 从 crates.io 获取最新兼容版本,并**不**使用 `Cargo.lock`。从源码构建的用户应验证源 tarball 校验和。
- 构建脚本(`build.rs`)和 proc-macros 在编译时执行任意代码。虽然所有当前依赖都是久经考验的 crate,但这是 Rust 构建模型的固有风险。
## 审计与合规<a id="audit-and-compliance"></a>
对于生产环境:
- 审计记录谁以包捕获权限运行 RustNet
- 网络监控策略和数据保护法规合规
- 对特权网络访问进行用户访问审查
- 通过配置管理系统实现自动化 Linux capabilities 管理
## 报告安全问题<a id="reporting-security-issues"></a>
请通过 GitHub Issues 报告安全漏洞,或直接与维护者联系。
+1189
View File
File diff suppressed because it is too large Load Diff
+1147
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="128" height="128" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="RustNet Internet Logo">
<!-- Outer circle (globe outline) -->
<circle cx="512" cy="512" r="450" fill="none" stroke="#f97316" stroke-width="40"/>
<!-- Latitude lines -->
<ellipse cx="512" cy="512" rx="450" ry="300" fill="none" stroke="#38bdf8" stroke-width="16"/>
<ellipse cx="512" cy="512" rx="450" ry="220" fill="none" stroke="#38bdf8" stroke-width="16"/>
<ellipse cx="512" cy="512" rx="450" ry="120" fill="none" stroke="#38bdf8" stroke-width="12"/>
<!-- Longitude lines -->
<ellipse cx="512" cy="512" rx="300" ry="450" fill="none" stroke="#38bdf8" stroke-width="16"/>
<ellipse cx="512" cy="512" rx="220" ry="450" fill="none" stroke="#38bdf8" stroke-width="16"/>
<ellipse cx="512" cy="512" rx="120" ry="450" fill="none" stroke="#38bdf8" stroke-width="12"/>
<!-- Horizontal + vertical axis -->
<line x1="62" y1="512" x2="962" y2="512" stroke="#38bdf8" stroke-width="12"/>
<line x1="512" y1="62" x2="512" y2="962" stroke="#38bdf8" stroke-width="12"/>
<!-- Text in center -->
<text x="512" y="560" font-family="monospace" font-weight="bold" font-size="200" text-anchor="middle" fill="#f97316">
RustNet
</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+113
View File
@@ -0,0 +1,113 @@
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
use rustnet_monitor::network::merge::merge_packet_into_connection;
use rustnet_monitor::network::parser::{TcpFlags, TcpHeaderInfo};
use rustnet_monitor::network::types::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::SystemTime;
/// Create a Connection with a RateTracker filled to `n_samples` entries.
fn make_connection_with_samples(n_samples: usize) -> Connection {
let local = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)), 54321);
let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 443);
let mut conn = Connection::new(
Protocol::Tcp,
local,
remote,
ProtocolState::Tcp(TcpState::Established),
);
// Simulate rate tracker updates to fill it with samples
for _ in 0..n_samples {
conn.bytes_sent += 100;
conn.bytes_received += 200;
conn.rate_tracker
.update(conn.bytes_sent, conn.bytes_received);
conn.packets_sent += 1;
conn.packets_received += 1;
}
conn
}
/// Create a minimal ParsedPacket for merge benchmarking.
fn make_parsed_packet() -> rustnet_monitor::network::parser::ParsedPacket {
rustnet_monitor::network::parser::ParsedPacket {
protocol: Protocol::Tcp,
local_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)), 54321),
remote_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 443),
tcp_header: Some(TcpHeaderInfo {
seq: 1000,
ack: 2000,
window: 65535,
flags: TcpFlags {
syn: false,
ack: true,
fin: false,
rst: false,
psh: true,
urg: false,
},
payload_len: 1400,
}),
protocol_state: ProtocolState::Tcp(TcpState::Established),
is_outgoing: true,
packet_len: 1400,
dpi_result: None,
process_name: None,
process_id: None,
}
}
fn bench_merge(c: &mut Criterion) {
let parsed = make_parsed_packet();
let now = SystemTime::now();
let mut group = c.benchmark_group("merge_packet");
for n_samples in [0, 100, 1000, 5000, 10000] {
// Benchmark the new in-place merge (no clone)
group.bench_with_input(
BenchmarkId::new("in_place_merge", n_samples),
&n_samples,
|b, &n| {
let mut conn = make_connection_with_samples(n);
b.iter(|| merge_packet_into_connection(&mut conn, &parsed, now));
},
);
// Keep clone benchmark for comparison with baseline
let conn = make_connection_with_samples(n_samples);
group.bench_with_input(
BenchmarkId::new("clone_only", n_samples),
&conn,
|b, conn| {
b.iter(|| conn.clone());
},
);
}
group.finish();
}
/// Compare the old String key construction (kept as a baseline) against the
/// compact `ConnectionKey` the tracker uses now: build + hash, no allocation.
fn bench_connection_key_format(c: &mut Criterion) {
use std::hash::BuildHasher;
let local = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)), 54321);
let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 443);
// Baseline: what every parsed packet used to allocate.
c.bench_function("connection_key_format_string", |b| {
b.iter(|| format!("TCP:{}-TCP:{}", local, remote));
});
// Now: construct the Copy key and hash it with the tracker's FxHash.
let parsed = make_parsed_packet();
let hasher = rustc_hash::FxBuildHasher;
c.bench_function("connection_key_struct_fxhash", |b| {
b.iter(|| hasher.hash_one(parsed.connection_key()));
});
}
criterion_group!(benches, bench_merge, bench_connection_key_format);
criterion_main!(benches);
+93
View File
@@ -0,0 +1,93 @@
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
use rustnet_monitor::network::parser::PacketParser;
use std::path::Path;
/// Load all raw packet bytes from the capture.pcap file.
fn load_packets_from_pcap(path: &str) -> Vec<Vec<u8>> {
let mut cap = pcap::Capture::from_file(path).expect("failed to open pcap file");
let mut packets = Vec::new();
while let Ok(packet) = cap.next_packet() {
packets.push(packet.data.to_vec());
}
packets
}
fn bench_parse_packet(c: &mut Criterion) {
if !Path::new("capture.pcap").exists() {
if std::env::var_os("CI").is_some()
|| std::env::var_os("RUSTNET_REQUIRE_BENCH_PCAP").is_some()
{
panic!("capture.pcap fixture is required for packet_parsing benchmark");
}
eprintln!(
"Skipping packet_parsing benchmark: capture.pcap not found \
(set RUSTNET_REQUIRE_BENCH_PCAP=1 to require it)"
);
return;
}
let packets = load_packets_from_pcap("capture.pcap");
assert!(!packets.is_empty(), "capture.pcap must contain packets");
// Configure parser with Linux SLL linktype (DLT 113) matching capture.pcap
let parser = PacketParser::new().with_linktype(113);
let mut group = c.benchmark_group("parse_packet");
group.throughput(Throughput::Elements(packets.len() as u64));
group.bench_function("all_packets", |b| {
b.iter(|| {
let mut parsed_count = 0u32;
for pkt in &packets {
if parser.parse_packet(pkt).is_some() {
parsed_count += 1;
}
}
parsed_count
});
});
group.finish();
// Per-packet benchmark (average cost of a single parse)
let mut single_group = c.benchmark_group("parse_single_packet");
single_group.throughput(Throughput::Elements(1));
// Pick a few representative packets (first TCP, first UDP if available)
let mut tcp_packet = None;
let mut udp_packet = None;
for pkt in &packets {
let result = parser.parse_packet(pkt);
if let Some(ref parsed) = result {
match parsed.protocol {
rustnet_monitor::network::types::Protocol::Tcp if tcp_packet.is_none() => {
tcp_packet = Some(pkt.clone());
}
rustnet_monitor::network::types::Protocol::Udp if udp_packet.is_none() => {
udp_packet = Some(pkt.clone());
}
_ => {}
}
}
if tcp_packet.is_some() && udp_packet.is_some() {
break;
}
}
if let Some(ref pkt) = tcp_packet {
single_group.bench_with_input(BenchmarkId::new("tcp", pkt.len()), pkt, |b, pkt| {
b.iter(|| parser.parse_packet(pkt));
});
}
if let Some(ref pkt) = udp_packet {
single_group.bench_with_input(BenchmarkId::new("udp", pkt.len()), pkt, |b, pkt| {
b.iter(|| parser.parse_packet(pkt));
});
}
single_group.finish();
}
criterion_group!(benches, bench_parse_packet);
criterion_main!(benches);
+221
View File
@@ -0,0 +1,221 @@
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
use rustnet_monitor::network::types::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// Create a Connection with a RateTracker filled to `n_samples` entries.
fn make_connection_with_samples(n_samples: usize) -> Connection {
let local = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)), 54321);
let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 443);
let mut conn = Connection::new(
Protocol::Tcp,
local,
remote,
ProtocolState::Tcp(TcpState::Established),
);
for i in 0..n_samples {
conn.bytes_sent += 100;
conn.bytes_received += 200;
conn.rate_tracker
.update(conn.bytes_sent, conn.bytes_received);
// Sprinkle in some pruning to keep the tracker realistic
if i % 500 == 0 {
conn.rate_tracker.prune();
}
}
conn
}
/// Benchmark the per-packet `update()` call on RateTracker.
/// This is the hot path — called for every packet received.
/// The Arc<VecDeque> change adds an `Arc::make_mut` atomic check here.
fn bench_rate_update(c: &mut Criterion) {
let mut group = c.benchmark_group("rate_tracker_update");
for n_samples in [0, 100, 1000, 5000] {
// Unique owner: simulates the normal packet-processing path where
// no snapshot clone is holding a shared reference.
group.bench_with_input(
BenchmarkId::new("unique_owner", n_samples),
&n_samples,
|b, &n| {
let mut conn = make_connection_with_samples(n);
let mut bytes_sent = conn.bytes_sent;
let mut bytes_recv = conn.bytes_received;
b.iter(|| {
bytes_sent += 100;
bytes_recv += 200;
conn.rate_tracker.update(bytes_sent, bytes_recv);
});
},
);
// Shared owner: simulates the case right after a full clone, where
// two Arcs point to the same VecDeque while the snapshot is alive
// (in the app the UI holds it for a full refresh interval). The
// first `update()` then triggers a full VecDeque copy via
// Arc::make_mut. The snapshot is threaded through the routine and
// returned so it stays alive during the update and its drop isn't
// measured.
group.bench_with_input(
BenchmarkId::new("after_snapshot_clone", n_samples),
&n_samples,
|b, &n| {
b.iter_batched(
|| {
let conn = make_connection_with_samples(n);
let snapshot = conn.clone(); // shared Arc, kept alive
(conn, snapshot)
},
|(mut conn, snapshot)| {
conn.bytes_sent += 100;
conn.bytes_received += 200;
conn.rate_tracker
.update(conn.bytes_sent, conn.bytes_received);
(conn, snapshot)
},
criterion::BatchSize::SmallInput,
);
},
);
// Detached snapshot: what the snapshot thread actually does now —
// snapshot_clone() drops the sample buffer, so the live tracker
// stays unique owner and the next update takes the fast path even
// while the snapshot is alive.
group.bench_with_input(
BenchmarkId::new("after_snapshot_clone_detached", n_samples),
&n_samples,
|b, &n| {
b.iter_batched(
|| {
let conn = make_connection_with_samples(n);
let snapshot = conn.snapshot_clone(); // no shared samples
(conn, snapshot)
},
|(mut conn, snapshot)| {
conn.bytes_sent += 100;
conn.bytes_received += 200;
conn.rate_tracker
.update(conn.bytes_sent, conn.bytes_received);
(conn, snapshot)
},
criterion::BatchSize::SmallInput,
);
},
);
}
group.finish();
}
/// Benchmark `refresh_rates()` (prune + rate calculation + smoothing).
/// Called once per second per connection from the refresh loop.
fn bench_refresh_rates(c: &mut Criterion) {
let mut group = c.benchmark_group("refresh_rates");
// 20000 = the sample cap: with O(1) window totals the curve must stay
// flat instead of growing with the sample count.
for n_samples in [0, 100, 1000, 5000, 20000] {
group.bench_with_input(
BenchmarkId::new("unique_owner", n_samples),
&n_samples,
|b, &n| {
let mut conn = make_connection_with_samples(n);
b.iter(|| {
conn.refresh_rates();
});
},
);
}
group.finish();
}
/// Benchmark Connection::clone() to measure the impact of Arc<VecDeque>
/// vs a plain VecDeque. With Arc, clone is O(1) for the samples field
/// (just a refcount bump). Without Arc, it's O(n_samples).
fn bench_connection_clone(c: &mut Criterion) {
let mut group = c.benchmark_group("connection_clone");
for n_samples in [0, 100, 1000, 5000, 10000] {
let conn = make_connection_with_samples(n_samples);
group.bench_with_input(BenchmarkId::new("clone", n_samples), &conn, |b, conn| {
b.iter(|| conn.clone());
});
}
group.finish();
}
/// Benchmark the snapshot-then-mutate cycle that happens in practice:
/// 1. Clone N connections for a UI snapshot
/// 2. Then update each original connection with a new packet
///
/// This measures the real-world cost: cheap clone (Arc refcount) followed
/// by a CoW deep-copy on first mutation.
fn bench_snapshot_then_update(c: &mut Criterion) {
let mut group = c.benchmark_group("snapshot_then_update");
for n_conns in [100, 1000, 5000] {
let connections: Vec<Connection> = (0..n_conns)
.map(|_| make_connection_with_samples(100))
.collect();
group.bench_with_input(
BenchmarkId::new("clone_all_then_update_all", n_conns),
&connections,
|b, connections| {
b.iter_batched(
|| connections.clone(),
|mut conns| {
// Step 1: snapshot clone (simulates UI snapshot)
let _snapshot: Vec<Connection> = conns.to_vec();
// Step 2: mutate originals (simulates incoming packets)
for conn in &mut conns {
conn.bytes_sent += 100;
conn.bytes_received += 200;
conn.rate_tracker
.update(conn.bytes_sent, conn.bytes_received);
}
},
criterion::BatchSize::LargeInput,
);
},
);
// Same cycle but with snapshot_clone(): the snapshot detaches from
// the sample buffers, so step 2 never pays the CoW deep copy.
group.bench_with_input(
BenchmarkId::new("snapshot_clone_all_then_update_all", n_conns),
&connections,
|b, connections| {
b.iter_batched(
|| connections.clone(),
|mut conns| {
let _snapshot: Vec<Connection> =
conns.iter().map(|c| c.snapshot_clone()).collect();
for conn in &mut conns {
conn.bytes_sent += 100;
conn.bytes_received += 200;
conn.rate_tracker
.update(conn.bytes_sent, conn.bytes_received);
}
},
criterion::BatchSize::LargeInput,
);
},
);
}
group.finish();
}
criterion_group!(
benches,
bench_rate_update,
bench_refresh_rates,
bench_connection_clone,
bench_snapshot_then_update,
);
criterion_main!(benches);
+93
View File
@@ -0,0 +1,93 @@
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
use dashmap::DashMap;
use rustnet_monitor::network::types::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// Populate a DashMap with `n` connections, each with a small number of rate samples.
fn populate_connections(n: usize) -> DashMap<String, Connection> {
let map = DashMap::new();
for i in 0..n {
let port = (i % 65000) as u16 + 1;
let octet3 = ((i / 256) % 256) as u8;
let octet4 = (i % 256) as u8;
let local = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)), port);
let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, octet3, octet4)), 443);
let key = format!("TCP:{}-TCP:{}", local, remote);
let mut conn = Connection::new(
Protocol::Tcp,
local,
remote,
ProtocolState::Tcp(TcpState::Established),
);
// Add a few rate samples to simulate a real connection
conn.bytes_sent = 5000;
conn.bytes_received = 15000;
conn.packets_sent = 10;
conn.packets_received = 30;
conn.rate_tracker
.update(conn.bytes_sent, conn.bytes_received);
map.insert(key, conn);
}
map
}
fn bench_snapshot(c: &mut Criterion) {
let mut group = c.benchmark_group("snapshot");
for n_conns in [100, 1000, 5000, 10000, 50000] {
let connections = populate_connections(n_conns);
// Benchmark: iterate + clone + collect (mirrors start_snapshot_provider)
group.bench_with_input(
BenchmarkId::new("clone_and_collect", n_conns),
&connections,
|b, connections| {
b.iter(|| {
let snapshot: Vec<Connection> = connections
.iter()
.map(|entry| entry.value().clone())
.collect();
snapshot
});
},
);
// Benchmark: snapshot_clone (drops rate samples) + collect — what
// start_snapshot_provider actually does now, leaving the live
// connections as unique owners of their sample buffers.
group.bench_with_input(
BenchmarkId::new("snapshot_clone_and_collect", n_conns),
&connections,
|b, connections| {
b.iter(|| {
let snapshot: Vec<Connection> = connections
.iter()
.map(|entry| entry.value().snapshot_clone())
.collect();
snapshot
});
},
);
// Benchmark: clone + collect + sort by created_at
group.bench_with_input(
BenchmarkId::new("clone_collect_sort", n_conns),
&connections,
|b, connections| {
b.iter(|| {
let mut snapshot: Vec<Connection> = connections
.iter()
.map(|entry| entry.value().clone())
.collect();
snapshot.sort_by_key(|a| a.created_at);
snapshot
});
},
);
}
group.finish();
}
criterion_group!(benches, bench_snapshot);
criterion_main!(benches);
+101
View File
@@ -0,0 +1,101 @@
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
use rustnet_monitor::network::parser::{ParsedPacket, TcpFlags, TcpHeaderInfo};
use rustnet_monitor::network::tracker::ConnectionTracker;
use rustnet_monitor::network::types::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::SystemTime;
/// Build a synthetic TCP data packet for one of `n_connections` flows.
///
/// Flows are distinguished by client port so the workload exercises the
/// tracker's key hashing and DashMap sharding the same way live traffic does.
fn make_packet(flow: u16, seq: u32) -> ParsedPacket {
let local_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)), 10000 + flow);
let remote_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 443);
ParsedPacket {
protocol: Protocol::Tcp,
local_addr,
remote_addr,
tcp_header: Some(TcpHeaderInfo {
seq,
ack: seq.wrapping_add(1),
window: 65535,
flags: TcpFlags {
syn: false,
ack: true,
fin: false,
rst: false,
psh: true,
urg: false,
},
payload_len: 1400,
}),
protocol_state: ProtocolState::Tcp(TcpState::Established),
is_outgoing: true,
packet_len: 1400,
dpi_result: None,
process_name: None,
process_id: None,
}
}
/// Interleaved packet stream: `flows` connections sending `packets_per_flow`
/// packets round-robin, mimicking concurrent flows rather than one flow at a
/// time.
fn make_workload(flows: u16, packets_per_flow: u32) -> Vec<ParsedPacket> {
let mut packets = Vec::with_capacity(flows as usize * packets_per_flow as usize);
for round in 0..packets_per_flow {
for flow in 0..flows {
packets.push(make_packet(flow, round * 1460));
}
}
packets
}
/// The canonical per-packet ingest cost: parse results folded into a fresh
/// tracker (mix of connection creation and in-place merge). This is the
/// before/after number for connection-key, timestamp, and limit-check work
/// on the packet path.
fn bench_tracker_ingest(c: &mut Criterion) {
let now = SystemTime::now();
let mut group = c.benchmark_group("tracker_ingest");
for (flows, per_flow) in [(100u16, 100u32), (1000, 50)] {
let packets = make_workload(flows, per_flow);
group.throughput(Throughput::Elements(packets.len() as u64));
group.bench_with_input(
BenchmarkId::new("fresh_tracker", format!("{flows}x{per_flow}")),
&packets,
|b, packets| {
b.iter(|| {
let tracker = ConnectionTracker::new();
for p in packets {
tracker.ingest_at(p, now);
}
tracker
});
},
);
}
group.finish();
// Steady state: every packet updates an existing connection (no creation).
let mut group = c.benchmark_group("tracker_ingest_steady");
let packets = make_workload(1000, 1);
let tracker = ConnectionTracker::new();
for p in &packets {
tracker.ingest_at(p, now);
}
group.throughput(Throughput::Elements(packets.len() as u64));
group.bench_function("existing_connections_1000", |b| {
b.iter(|| {
for p in &packets {
tracker.ingest_at(p, now);
}
});
});
group.finish();
}
criterion_group!(benches, bench_tracker_ingest);
criterion_main!(benches);
+180
View File
@@ -0,0 +1,180 @@
use anyhow::Result;
use std::{env, fs::File, path::PathBuf};
fn main() -> Result<()> {
// Generate shell completions and manpage
generate_assets()?;
// Add library search paths for cross-compilation
setup_cross_compilation_libs();
// eBPF program compilation now lives in the rustnet-host crate's build.rs.
#[cfg(target_os = "windows")]
download_windows_npcap_sdk()?;
println!("cargo:rerun-if-changed=src/cli.rs");
Ok(())
}
include!("src/cli.rs");
fn setup_cross_compilation_libs() {
let target = env::var("TARGET").unwrap_or_default();
let host = env::var("HOST").unwrap_or_default();
// Only apply hard-coded multiarch lib paths when actually cross-compiling.
// On native builds (e.g. Homebrew on Linux arm64) these paths would shadow
// package-manager-provided libraries and break linkage.
if host == target {
return;
}
match target.as_str() {
"aarch64-unknown-linux-gnu" => {
println!("cargo:rustc-link-search=native=/usr/lib/aarch64-linux-gnu");
println!("cargo:rustc-link-lib=elf");
println!("cargo:rustc-link-lib=z");
}
"armv7-unknown-linux-gnueabihf" => {
println!("cargo:rustc-link-search=native=/usr/lib/arm-linux-gnueabihf");
println!("cargo:rustc-link-lib=elf");
println!("cargo:rustc-link-lib=z");
}
"x86_64-unknown-freebsd" => {
// FreeBSD uses libpcap from base system (in /usr/lib)
// When cross-compiling, the sysroot should provide these
println!("cargo:rustc-link-lib=pcap");
}
_ => {
// For other targets, including native builds, let pkg-config handle it
}
}
}
fn generate_assets() -> Result<()> {
use clap::ValueEnum;
use clap_complete::Shell;
use clap_mangen::Man;
let mut cmd = build_cli();
// build into `RUSTNET_ASSET_DIR` with a fallback to `OUT_DIR`
let asset_dir: PathBuf = env::var_os("RUSTNET_ASSET_DIR")
.or_else(|| env::var_os("OUT_DIR"))
.ok_or_else(|| anyhow::anyhow!("OUT_DIR is unset"))?
.into();
// completion
for &shell in Shell::value_variants() {
clap_complete::generate_to(shell, &mut cmd, "rustnet", &asset_dir)?;
}
// manpage
let mut manpage_out = File::create(asset_dir.join("rustnet.1"))?;
let manpage = Man::new(cmd);
manpage.render(&mut manpage_out)?;
Ok(())
}
#[cfg(target_os = "windows")]
fn download_windows_npcap_sdk() -> Result<()> {
use sha2::{Digest, Sha256};
use std::{
fs,
io::{self, Write},
};
println!("cargo:rerun-if-changed=build.rs");
// get npcap SDK
const NPCAP_SDK: &str = "npcap-sdk-1.15.zip";
const NPCAP_SDK_SHA256: &str =
"52c7b9fb4abee3ad9fe739bb545c3efe77b731c8e127122bdf328eafdae3ed4f";
let npcap_sdk_download_url = format!("https://npcap.com/dist/{NPCAP_SDK}");
let cache_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?).join("target");
let npcap_sdk_cache_path = cache_dir.join(NPCAP_SDK);
let npcap_zip = match fs::read(&npcap_sdk_cache_path) {
// use cached (verify checksum)
Ok(zip_data) => {
eprintln!("Found cached npcap SDK");
verify_npcap_checksum(&zip_data)?;
zip_data
}
// download SDK
Err(_) => {
eprintln!("Downloading npcap SDK");
// download
let mut zip_data = vec![];
let _res = http_req::request::get(npcap_sdk_download_url, &mut zip_data)?;
// verify checksum before caching
verify_npcap_checksum(&zip_data)?;
// write cache
fs::create_dir_all(cache_dir)?;
let mut cache = fs::File::create(npcap_sdk_cache_path)?;
cache.write_all(&zip_data)?;
zip_data
}
};
fn verify_npcap_checksum(data: &[u8]) -> Result<()> {
let hash = Sha256::digest(data);
let actual = hash.iter().map(|b| format!("{b:02x}")).collect::<String>();
if actual != NPCAP_SDK_SHA256 {
anyhow::bail!(
"Npcap SDK checksum mismatch!\n Expected: {}\n Actual: {}\n\
The downloaded file may be corrupted or tampered with.",
NPCAP_SDK_SHA256,
actual
);
}
eprintln!("Npcap SDK checksum verified: {actual}");
Ok(())
}
// extract libraries based on target architecture
let target = env::var("TARGET").unwrap_or_else(|_| "unknown".to_string());
let (packet_lib_path, wpcap_lib_path) = if target.contains("aarch64") {
("Lib/ARM64/Packet.lib", "Lib/ARM64/wpcap.lib")
} else if target.contains("x86_64") {
("Lib/x64/Packet.lib", "Lib/x64/wpcap.lib")
} else if target.contains("i686") || target.contains("i586") {
("Lib/Packet.lib", "Lib/wpcap.lib")
} else {
panic!("Unsupported target: {}", target)
};
let mut archive = zip::ZipArchive::new(io::Cursor::new(npcap_zip))?;
// Extract Packet.lib
let mut packet_lib = archive.by_name(packet_lib_path)?;
let lib_dir = PathBuf::from(env::var("OUT_DIR")?).join("npcap_sdk");
fs::create_dir_all(&lib_dir)?;
let packet_lib_dest = lib_dir.join("Packet.lib");
let mut packet_file = fs::File::create(packet_lib_dest)?;
io::copy(&mut packet_lib, &mut packet_file)?;
drop(packet_lib);
// Extract wpcap.lib
let mut wpcap_lib = archive.by_name(wpcap_lib_path)?;
let wpcap_lib_dest = lib_dir.join("wpcap.lib");
let mut wpcap_file = fs::File::create(wpcap_lib_dest)?;
io::copy(&mut wpcap_lib, &mut wpcap_file)?;
println!(
"cargo:rustc-link-search=native={}",
lib_dir
.to_str()
.ok_or_else(|| anyhow::anyhow!("{lib_dir:?} is not valid UTF-8"))?
);
Ok(())
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "rustnet-capture"
version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
description = "libpcap/Npcap-based packet-capture backend for rustnet: device selection, BPF filters, macOS PKTAP, TUN/TAP, and a raw-frame reader"
repository.workspace = true
homepage.workspace = true
documentation = "https://docs.rs/rustnet-capture"
readme = "README.md"
license.workspace = true
keywords = ["network", "packet", "capture", "pcap", "bpf"]
categories = ["network-programming"]
[lib]
name = "rustnet_capture"
path = "src/lib.rs"
# This crate yields raw frames + DLT only; parsing is rustnet-core's job. It
# deliberately does NOT depend on rustnet-core, so a consumer wanting just a
# capture source isn't forced to compile the full analysis stack (ring, aes,
# maxminddb, ...). Pair it with rustnet-core at the application layer.
[dependencies]
anyhow.workspace = true
log.workspace = true
pcap = "2.4.0"
+32
View File
@@ -0,0 +1,32 @@
# rustnet-capture
The packet-capture backend of [RustNet](https://github.com/domcyrus/rustnet),
built on `libpcap` / `Npcap` via the [`pcap`](https://crates.io/crates/pcap)
crate.
This crate owns all of RustNet's pcap-based capture:
- network device selection (with sensible defaults that skip virtual/loopback
adapters),
- BPF-filter setup,
- the macOS **PKTAP** fast path that attaches process metadata to packets,
- TUN/TAP interface handling,
- and a simple [`PacketReader`] that yields raw link-layer frames plus the
libpcap data-link type (DLT).
## Why a separate crate?
It is intentionally decoupled from the analysis core (`rustnet-core`) and the
`rustnet` application so you can compose them differently:
- pair `rustnet-capture` + `rustnet-core` to build a **headless** tool (e.g. a
Prometheus exporter) with no terminal UI;
- or swap `rustnet-capture` out for a **bespoke capture path** (for example a
root-free macOS pktap helper) while still using `rustnet-core` for parsing.
Capture produces bytes; turning those bytes into connections, DPI results, and
GeoIP/DNS lookups is `rustnet-core`'s job.
## License
Apache-2.0
+607
View File
@@ -0,0 +1,607 @@
//! # rustnet-capture
//!
//! Packet-capture backend for [RustNet](https://github.com/domcyrus/rustnet),
//! built on `libpcap` / `Npcap` via the [`pcap`] crate. This crate owns all of
//! RustNet's pcap-based capture: device selection, BPF-filter setup, the macOS
//! PKTAP fast path for process metadata, TUN/TAP handling, and a simple
//! [`PacketReader`] that yields raw link-layer frames.
//!
//! It is deliberately separate from the analysis core (`rustnet-core`) and the
//! `rustnet` application so that alternative front-ends — e.g. a headless
//! Prometheus exporter — can pair capture with `rustnet-core` without pulling
//! in the TUI, and so that platforms wanting a bespoke capture path (e.g. a
//! root-free macOS pktap helper) can swap this crate out entirely.
//!
//! Capture yields raw bytes plus the libpcap data-link type (DLT); parsing
//! those bytes is `rustnet-core`'s job.
use anyhow::{Result, anyhow};
use pcap::{Active, Capture, Device, Error as PcapError};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// Why the macOS PKTAP fast path could not be used during capture setup.
///
/// PKTAP attaches process metadata to captured packets, but it requires root,
/// a usable BPF device, the default interface, and no BPF filter. When any of
/// those preconditions fail, capture records the reason here so the application
/// can surface it (the `rustnet` binary maps these to its UI-level
/// `DegradationReason`). Kept capture-native so this crate has no dependency on
/// the application's process-attribution types.
#[cfg(target_os = "macos")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PktapUnavailable {
/// Could not open the BPF device (typically a permission issue).
NoBpfDeviceAccess,
/// PKTAP device creation failed — almost always missing root privileges.
MissingRootPrivileges,
/// A specific interface was requested; PKTAP only works on the default path.
InterfaceSpecified,
/// A BPF filter was supplied, which is incompatible with PKTAP.
BpfFilterIncompatible,
}
/// Stores why PKTAP is not available on macOS (set during capture setup).
#[cfg(target_os = "macos")]
pub static PKTAP_DEGRADATION_REASON: std::sync::OnceLock<PktapUnavailable> =
std::sync::OnceLock::new();
/// Packet capture configuration
#[derive(Debug, Clone)]
pub struct CaptureConfig {
/// Network interface name (None for default)
pub interface: Option<String>,
/// Snapshot length (bytes to capture per packet)
pub snaplen: i32,
/// Buffer size for packet capture
pub buffer_size: i32,
/// Read timeout in milliseconds
pub timeout_ms: i32,
/// BPF filter string
pub filter: Option<String>,
}
impl Default for CaptureConfig {
fn default() -> Self {
Self {
interface: None,
snaplen: 1514, // Limit packet size to keep more in buffer
buffer_size: 20_000_000, // 20MB buffer
timeout_ms: 150, // 150ms timeout for UI responsiveness
filter: None, // Start without filter to ensure we see packets
}
}
}
/// Find the best active network device
fn find_best_device() -> Result<Device> {
let devices = Device::list().map_err(|e| {
anyhow!(
"Failed to list network devices: {}. This may indicate insufficient privileges.",
e
)
})?;
log::info!(
"Scanning {} devices for best active interface...",
devices.len()
);
// Log all devices for debugging
for d in &devices {
let has_valid_ip = d.addresses.iter().any(|addr| match &addr.addr {
std::net::IpAddr::V4(v4) => {
!v4.is_link_local() && !v4.is_loopback() && !v4.is_unspecified()
}
std::net::IpAddr::V6(v6) => {
!v6.is_loopback() && !v6.is_multicast() && !v6.is_unspecified()
}
});
log::debug!(
" Device: {} [up: {}, running: {}, has_ip: {}]",
d.name,
d.flags.is_up(),
d.flags.is_running(),
has_valid_ip
);
}
if devices.is_empty() {
return Err(anyhow!("No network devices found"));
}
// Find the best active device
let suitable_device = devices
.iter()
// First priority: up, running, has a valid IP address, and NOT virtual
.find(|d| {
// Check if it's a virtual/problematic interface
let desc_lower = d
.desc
.as_ref()
.map(|s| s.to_lowercase())
.unwrap_or_default();
let is_virtual = desc_lower.contains("hyper-v")
|| desc_lower.contains("vmware")
|| desc_lower.contains("virtualbox");
!d.name.starts_with("lo")
// Note: 'any' is excluded here because it's not a real interface
// Users can still specify '-i any' explicitly on Linux
&& d.name != "any"
&& !is_virtual // Skip virtual adapters in first priority too
&& d.flags.is_up()
&& d.flags.is_running()
&& d.addresses.iter().any(|addr| {
match &addr.addr {
std::net::IpAddr::V4(v4) => {
!v4.is_link_local() && !v4.is_loopback() && !v4.is_unspecified()
}
std::net::IpAddr::V6(_v6) => false, // Skip IPv6 for now
}
})
})
// Second priority: common active interface names
.or_else(|| {
devices.iter().find(|d| {
(d.name == "en0" || d.name == "en1" || d.name.starts_with("eth"))
&& d.flags.is_up()
&& d.addresses.iter().any(|addr| addr.addr.is_ipv4())
})
})
// Third priority: any up interface with valid addresses (excluding problematic ones)
.or_else(|| {
devices.iter().find(|d| {
// Check if it's a virtual/problematic interface
let desc_lower = d
.desc
.as_ref()
.map(|s| s.to_lowercase())
.unwrap_or_default();
let is_virtual = desc_lower.contains("hyper-v")
|| desc_lower.contains("virtual")
|| desc_lower.contains("vmware")
|| desc_lower.contains("virtualbox")
|| desc_lower.contains("loopback");
!d.name.starts_with("lo") &&
!d.name.starts_with("ap") && // Skip Apple's ap interfaces
!d.name.starts_with("awdl") && // Skip Apple Wireless Direct
!d.name.starts_with("llw") && // Skip Low latency WLAN
!d.name.starts_with("bridge") && // Skip bridges
// TUN/TAP interfaces now supported - removed utun/tun/tap exclusion
!d.name.starts_with("vmnet") && // Skip VM interfaces
// Note: 'any' is excluded here because it's not a real interface
// Users can still specify '-i any' explicitly on Linux
d.name != "any" &&
!is_virtual && // Skip virtual adapters
d.flags.is_up() &&
!d.addresses.is_empty()
})
})
.cloned();
match suitable_device {
Some(device) => {
log::info!(
"Selected active device: {} ({} addresses)",
device.name,
device.addresses.len()
);
for addr in &device.addresses {
log::debug!(" Address: {}", addr.addr);
}
Ok(device)
}
None => {
log::error!("No suitable active network device found!");
log::error!("Try specifying an interface manually with -i flag");
Err(anyhow!(
"No active network interface found. Use -i to specify one manually."
))
}
}
}
/// Setup packet capture with the given configuration
pub fn setup_packet_capture(config: CaptureConfig) -> Result<(Capture<Active>, String, i32)> {
// Try PKTAP first on macOS for process metadata, but only when:
// - No interface is explicitly specified
// - No BPF filter is specified (BPF filters don't work with PKTAP's linktype 149)
#[cfg(target_os = "macos")]
if config.interface.is_none() && config.filter.is_none() {
log::info!("Attempting to use PKTAP for process metadata on macOS");
match Capture::from_device("pktap") {
Ok(pktap_builder) => {
let pktap_cap = pktap_builder
.promisc(false) // PKTAP doesn't use promiscuous mode
.snaplen(config.snaplen)
.buffer_size(config.buffer_size)
.timeout(config.timeout_ms)
.immediate_mode(true)
.want_pktap(true)
.open();
match pktap_cap {
Ok(mut cap) => {
// Try to set direction for better performance (optional)
if let Err(e) = cap.direction(pcap::Direction::InOut) {
log::debug!("Could not set PKTAP direction: {}", e);
}
let linktype = cap.get_datalink();
log::info!(
"✓ PKTAP enabled successfully, linktype: {} ({})",
linktype.0,
if linktype.0 == 149 {
"Apple PKTAP"
} else {
"Unknown"
}
);
// Apply BPF filter if specified
if let Some(filter) = &config.filter {
log::info!("Applying BPF filter to PKTAP: {}", filter);
cap.filter(filter, true)?;
}
log::info!("PKTAP capture ready - process metadata will be available");
return Ok((cap, "pktap".to_string(), linktype.0));
}
Err(e) => {
log::warn!("Failed to open PKTAP capture: {}", e);
log::info!(
"PKTAP requires root privileges - run with 'sudo' for process metadata support"
);
log::info!(
"Falling back to regular capture (process detection will use lsof)"
);
// Store degradation reason - failed to open (permission issue)
let _ = PKTAP_DEGRADATION_REASON.set(PktapUnavailable::NoBpfDeviceAccess);
}
}
}
Err(e) => {
log::warn!("Failed to create PKTAP device: {}", e);
log::info!(
"PKTAP requires root privileges - run with 'sudo' for process metadata support"
);
log::info!("Falling back to regular capture (process detection will use lsof)");
// Store degradation reason - failed to create device (permission issue)
let _ = PKTAP_DEGRADATION_REASON.set(PktapUnavailable::MissingRootPrivileges);
}
}
}
// Track PKTAP degradation reasons for macOS
#[cfg(target_os = "macos")]
{
if config.interface.is_some() {
// Specific interface requested - PKTAP can't be used
let _ = PKTAP_DEGRADATION_REASON.set(PktapUnavailable::InterfaceSpecified);
}
if config.filter.is_some() {
log::warn!(
"BPF filter specified - using regular capture instead of PKTAP (BPF filters don't work with PKTAP)"
);
// Store degradation reason - BPF filter incompatible
let _ = PKTAP_DEGRADATION_REASON.set(PktapUnavailable::BpfFilterIncompatible);
}
}
// Fallback to regular capture (original code)
log::info!("Setting up regular packet capture");
let device = find_capture_device(&config.interface)?;
// Check if this is a TUN/TAP interface (for the log line below). This is a
// capture-side device concern, so we match the names here rather than pull
// all of `rustnet-core` into this crate just to label a log message. The
// actual TUN/TAP frame parsing still lives in `rustnet-core`.
let is_tun = device.name.starts_with("tun") || device.name.starts_with("utun");
let is_tap = device.name.starts_with("tap");
let is_tunnel = is_tun || is_tap;
let tunnel_type = if is_tun {
"TUN (Layer 3)"
} else if is_tap {
"TAP (Layer 2)"
} else {
"N/A"
};
log::info!(
"Setting up capture on device: {} ({}){}",
device.name,
device.desc.as_deref().unwrap_or("no description"),
if is_tunnel {
format!(" [Tunnel: {}]", tunnel_type)
} else {
String::new()
}
);
let device_name = device.name.clone();
// Create capture handle with promiscuous mode disabled
// We use non-promiscuous mode (read-only packet capture) which only requires CAP_NET_RAW
let cap = Capture::from_device(device)?
.promisc(false)
.snaplen(config.snaplen)
.buffer_size(config.buffer_size)
.timeout(config.timeout_ms)
.immediate_mode(true); // Parse packets ASAP
// Open the capture
let mut cap = cap.open()?;
// Apply BPF filter if specified
if let Some(filter) = &config.filter {
log::info!("Applying BPF filter: {}", filter);
cap.filter(filter, true)?;
}
// Note: We're not setting non-blocking mode as we're using timeout instead
let linktype = cap.get_datalink();
Ok((cap, device_name, linktype.0))
}
/// Validate that the specified interface exists (if provided)
/// This is useful for failing fast before starting capture threads
pub fn validate_interface(interface_name: &Option<String>) -> Result<()> {
if let Some(name) = interface_name {
// This will return an error if the interface doesn't exist
find_capture_device(&Some(name.clone()))?;
}
Ok(())
}
/// Find a capture device by name or return the default
fn find_capture_device(interface_name: &Option<String>) -> Result<Device> {
match interface_name {
Some(name) => {
log::info!("Looking for interface: {}", name);
// Special handling for 'any' interface
if name == "any" {
#[cfg(not(target_os = "linux"))]
{
return Err(anyhow!(
"The 'any' interface is only supported on Linux.\n\
On your platform, please specify a specific interface with -i <interface>.\n\
Run without -i to auto-detect the default interface."
));
}
#[cfg(target_os = "linux")]
{
log::info!("Using 'any' pseudo-interface to capture on all interfaces");
}
}
// List all devices
let devices = Device::list()?;
// Find exact match first
if let Some(device) = devices.iter().find(|d| d.name == *name) {
return Ok(device.clone());
}
// Try case-insensitive match
let name_lower = name.to_lowercase();
if let Some(device) = devices.iter().find(|d| d.name.to_lowercase() == name_lower) {
return Ok(device.clone());
}
// List available interfaces for error message
let available: Vec<String> = devices.iter().map(|d| d.name.clone()).collect();
Err(anyhow!(
"Interface '{}' not found. Available interfaces: {}",
name,
available.join(", ")
))
}
None => {
log::info!("No interface specified, using default");
// Resolve active interface via OS routing table by creating a connectionless UDP socket
if let Some(active_ip) = std::net::UdpSocket::bind("0.0.0.0:0")
.and_then(|s| {
let _ = s.connect("8.8.8.8:53");
s.local_addr()
})
.ok()
.map(|addr| addr.ip())
{
log::info!("Found active routed IP: {}", active_ip);
if let Ok(devices) = Device::list()
&& let Some(device) = devices
.into_iter()
.find(|d| d.addresses.iter().any(|a| a.addr == active_ip))
{
log::info!("Selected interface {} based on active route", device.name);
return Ok(device);
}
}
log::info!("Fallback: using libpcap default device logic");
// Try to get default device
match Device::lookup() {
Ok(Some(device)) => {
log::info!(
"Found default device: {} ({})",
device.name,
device.desc.as_deref().unwrap_or("no description")
);
// Check if the default device is actually active
let has_valid_ip = device.addresses.iter().any(|addr| {
match &addr.addr {
std::net::IpAddr::V4(v4) => {
!v4.is_link_local() && !v4.is_loopback() && !v4.is_unspecified()
}
std::net::IpAddr::V6(_v6) => false, // Skip IPv6 for now
}
});
// Check if it's a problematic interface type
// Note: 'any' is excluded on non-Linux platforms where it doesn't work
let is_problematic = device.name.starts_with("ap")
|| device.name.starts_with("awdl")
|| device.name.starts_with("llw")
|| device.name.starts_with("bridge")
// TUN/TAP interfaces now supported - removed utun/tun/tap check
|| device.name.starts_with("vmnet")
|| (device.name == "any" && !cfg!(target_os = "linux"))
|| device.flags.is_loopback();
if device.flags.is_up()
&& device.flags.is_running()
&& has_valid_ip
&& !is_problematic
{
log::info!("Default device appears active, using it");
Ok(device)
} else {
log::warn!(
"Default device '{}' is not suitable (up: {}, running: {}, has_ip: {}, problematic: {})",
device.name,
device.flags.is_up(),
device.flags.is_running(),
has_valid_ip,
is_problematic
);
log::info!("Looking for a better interface...");
// Fall through to the device selection logic below
find_best_device()
}
}
Ok(None) => {
log::info!("No default device found");
find_best_device()
}
Err(e) => Err(e.into()),
}
}
}
}
/// Simple packet reader that handles timeouts gracefully
pub struct PacketReader {
capture: Capture<Active>,
}
/// A captured link-layer frame with the timestamp reported by libpcap/Npcap.
#[derive(Debug, Clone)]
pub struct CapturedPacket {
pub data: Vec<u8>,
pub timestamp: SystemTime,
pub original_len: u32,
}
impl PacketReader {
pub fn new(capture: Capture<Active>) -> Self {
Self { capture }
}
/// Read next packet, returning None on timeout.
pub fn next_packet(&mut self) -> Result<Option<CapturedPacket>> {
match self.capture.next_packet() {
Ok(packet) => {
let ts = packet.header.ts;
Ok(Some(CapturedPacket {
data: packet.data.to_vec(),
timestamp: timeval_to_system_time(ts.tv_sec, ts.tv_usec),
original_len: packet.header.len,
}))
}
Err(PcapError::TimeoutExpired) => Ok(None),
Err(e) => Err(e.into()),
}
}
/// Get capture statistics
pub fn stats(&mut self) -> Result<CaptureStats> {
let stats = self.capture.stats()?;
let capture_stats = CaptureStats {
received: stats.received,
dropped: stats.dropped,
if_dropped: stats.if_dropped,
};
// Log dropped packets if any occurred
if capture_stats.total_dropped() > 0 {
log::debug!(
"Total {} packets dropped (kernel: {}, interface: {})",
capture_stats.total_dropped(),
capture_stats.dropped,
capture_stats.if_dropped
);
}
Ok(capture_stats)
}
}
fn timeval_to_system_time<S, U>(secs: S, usecs: U) -> SystemTime
where
S: Into<i64>,
U: Into<i64>,
{
let secs = secs.into();
let usecs = usecs.into().clamp(0, 999_999);
if secs < 0 {
UNIX_EPOCH
} else {
UNIX_EPOCH + Duration::from_secs(secs as u64) + Duration::from_micros(usecs as u64)
}
}
/// Packet capture statistics
#[derive(Debug, Clone, Default)]
pub struct CaptureStats {
pub received: u32,
pub dropped: u32,
/// Interface-level dropped packets (platform-specific)
pub if_dropped: u32,
}
impl CaptureStats {
/// Get total packets dropped (both kernel and interface level)
pub fn total_dropped(&self) -> u32 {
self.dropped.saturating_add(self.if_dropped)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = CaptureConfig::default();
assert_eq!(config.snaplen, 1514);
assert!(config.filter.is_none()); // Default starts without filter
}
#[test]
fn test_udp_routing_resolution_can_execute() {
// Sanity-check test to ensure the OS handles UDP metric routing cleanly.
// It's perfectly fine if this fails in hermetic CI environments without outbound routes.
if let Ok(socket) = std::net::UdpSocket::bind("0.0.0.0:0")
&& socket.connect("8.8.8.8:53").is_ok()
&& let Ok(addr) = socket.local_addr()
{
assert!(
!addr.ip().is_loopback(),
"Active routed IP should not be loopback"
);
assert!(
!addr.ip().is_unspecified(),
"Active routed IP should not be unspecified"
);
}
}
}
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "rustnet-core"
version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
description = "Network analysis library: packet parsing, protocol types, deep packet inspection, link-layer parsers, connection merging, and DNS/GeoIP/OUI lookups — the reusable core of rustnet"
repository.workspace = true
homepage.workspace = true
documentation = "https://docs.rs/rustnet-core"
readme = "README.md"
license.workspace = true
keywords = ["network", "packet", "dpi", "parser", "geoip"]
categories = ["network-programming", "parser-implementations"]
[lib]
name = "rustnet_core"
path = "src/lib.rs"
[dependencies]
anyhow.workspace = true
log.workspace = true
dashmap = "6.2"
rustc-hash = "2"
crossbeam = "0.8"
dns-lookup = "3.0"
pnet_datalink = "0.35"
maxminddb = "0.29"
flate2 = "1"
ring = "0.17"
aes = "0.9"
[features]
# Adds the Kubernetes pod/container metadata (`K8sInfo`) to `Connection`.
# Resolution itself lives in the rustnet binary; no extra dependencies.
kubernetes = []
+38
View File
@@ -0,0 +1,38 @@
# rustnet-core
The reusable network-analysis core of [RustNet](https://github.com/domcyrus/rustnet).
`rustnet-core` is the platform-independent, capture-independent layer that
turns bytes into meaningful network information. It has **no dependency on
libpcap, raw sockets, or OS process tables** — it operates on byte slices and
parsed structures, so it can be embedded in any tool that already has packets
to analyze.
## Features
- **Packet parsing** for Ethernet, Linux SLL/SLL2, PKTAP, raw IP, and TUN/TAP
link layers, plus IPv4/IPv6, TCP, UDP, ICMP, and IGMP.
- **Deep packet inspection** for HTTP, HTTPS/TLS (SNI extraction), DNS, SSH,
QUIC, NTP, mDNS, LLMNR, DHCP, SNMP, SSDP, NetBIOS, and more.
- **Connection merging** — fold parsed packets into long-lived connection
state with protocol-aware lifecycle tracking and TCP analytics
(retransmissions, out-of-order, fast-retransmit).
- **GeoIP** lookups against MaxMind GeoLite2 databases.
- **Reverse DNS** with background async resolution and caching.
- **OUI vendor** and **service-name** resolution from baked-in datasets (no
runtime files required — the data is embedded at compile time).
## Layout
Everything lives under the [`network`] module and is re-exported at the crate
root, so both `rustnet_core::network::types` and `rustnet_core::types` resolve
to the same place.
## Status
The public API is currently `0.x` and may change between minor versions while
the workspace split stabilizes.
## License
Apache-2.0
Binary file not shown.
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
//! # rustnet-core
//!
//! The reusable network-analysis core of [RustNet](https://github.com/domcyrus/rustnet):
//! packet parsing, protocol types, deep packet inspection (DPI), link-layer
//! parsers, connection merging, and DNS / GeoIP / OUI lookups.
//!
//! This crate is platform-independent and capture-independent — it operates on
//! byte slices and parsed structures, with no dependency on `libpcap`, raw
//! sockets, or OS process tables. Raw packet capture and platform-specific
//! process attribution live in the `rustnet` binary crate.
//!
//! ## Capabilities
//!
//! - **Packet parsing** for Ethernet, Linux SLL/SLL2, PKTAP, raw IP, and
//! TUN/TAP link layers, plus IPv4/IPv6, TCP, UDP, ICMP, and IGMP.
//! - **Deep packet inspection** for HTTP, HTTPS/TLS with SNI extraction,
//! DNS, SSH, QUIC, NTP, mDNS, LLMNR, DHCP, SNMP, SSDP, NetBIOS, and more.
//! - **Connection merging** — fold parsed packets into long-lived connection
//! state with protocol-aware lifecycle tracking and TCP analytics.
//! - **GeoIP** lookups against MaxMind GeoLite2 databases.
//! - **Reverse DNS** with background async resolution and caching.
//! - **OUI** vendor resolution and **service** name resolution (baked-in
//! datasets).
//!
//! ## Layout
//!
//! All modules live under [`network`]. They are also re-exported at the crate
//! root for convenience, so both `rustnet_core::network::types` and
//! `rustnet_core::types` resolve to the same module.
pub mod network;
// Flat re-exports so external users can write `rustnet_core::types` instead of
// `rustnet_core::network::types`. The `network` module remains the canonical
// home and keeps internal `crate::network::*` paths working unchanged.
pub use network::{
bogon, dns, dpi, geoip, interface_stats, link_layer, merge, oui, parser, protocol, services,
tracker, types,
};
+230
View File
@@ -0,0 +1,230 @@
//! Classification of an IP address into a routing/usage scope: globally
//! routable ("public"), or one of the RFC-reserved ranges (private, loopback,
//! link-local, multicast, documentation, etc.).
//!
//! Used to label remote endpoints in the UI so internal traffic is visually
//! distinguishable from public-Internet traffic. Pure passive classification:
//! no lookups, no I/O.
//!
//! The set of non-public categories is intentionally small but covers the
//! ranges users care about when scanning a connection list. Stable-Rust
//! `is_*` helpers on `Ipv4Addr` / `Ipv6Addr` only cover a subset, so the
//! checks here are done with explicit bit-mask comparisons to avoid relying
//! on unstable APIs.
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Scope {
Public,
Loopback,
Private,
LinkLocal,
Cgnat,
Multicast,
Broadcast,
Documentation,
Benchmarking,
Unspecified,
Reserved,
UniqueLocal,
Discard,
Ipv4Mapped,
}
impl Scope {
/// Short, all-caps tag suitable for inline display in a detail panel.
pub fn label(self) -> &'static str {
match self {
Scope::Public => "PUBLIC",
Scope::Loopback => "LOOPBACK",
Scope::Private => "PRIVATE",
Scope::LinkLocal => "LINK-LOCAL",
Scope::Cgnat => "CGNAT",
Scope::Multicast => "MULTICAST",
Scope::Broadcast => "BROADCAST",
Scope::Documentation => "DOCUMENTATION",
Scope::Benchmarking => "BENCHMARKING",
Scope::Unspecified => "UNSPECIFIED",
Scope::Reserved => "RESERVED",
Scope::UniqueLocal => "UNIQUE-LOCAL",
Scope::Discard => "DISCARD",
Scope::Ipv4Mapped => "IPV4-MAPPED",
}
}
}
pub fn classify(ip: IpAddr) -> Scope {
match ip {
IpAddr::V4(v4) => classify_v4(v4),
IpAddr::V6(v6) => classify_v6(v6),
}
}
fn classify_v4(ip: Ipv4Addr) -> Scope {
let octets = ip.octets();
let [a, b, _, _] = octets;
if ip.is_unspecified() {
return Scope::Unspecified;
}
if a == 127 {
return Scope::Loopback;
}
// RFC 1918
if a == 10 || (a == 172 && (16..=31).contains(&b)) || (a == 192 && b == 168) {
return Scope::Private;
}
if a == 169 && b == 254 {
return Scope::LinkLocal;
}
// RFC 6598 carrier-grade NAT: 100.64.0.0/10
if a == 100 && (64..=127).contains(&b) {
return Scope::Cgnat;
}
// Documentation: 192.0.2.0/24 (TEST-NET-1), 198.51.100.0/24 (TEST-NET-2),
// 203.0.113.0/24 (TEST-NET-3).
if (a == 192 && b == 0 && octets[2] == 2)
|| (a == 198 && b == 51 && octets[2] == 100)
|| (a == 203 && b == 0 && octets[2] == 113)
{
return Scope::Documentation;
}
// RFC 2544 benchmarking: 198.18.0.0/15
if a == 198 && (b == 18 || b == 19) {
return Scope::Benchmarking;
}
if octets == [255, 255, 255, 255] {
return Scope::Broadcast;
}
// 224.0.0.0/4 multicast
if (224..=239).contains(&a) {
return Scope::Multicast;
}
// 240.0.0.0/4 reserved (excluding the 255.255.255.255 broadcast above)
if a >= 240 {
return Scope::Reserved;
}
Scope::Public
}
fn classify_v6(ip: Ipv6Addr) -> Scope {
if ip.is_unspecified() {
return Scope::Unspecified;
}
if ip.is_loopback() {
return Scope::Loopback;
}
let segs = ip.segments();
// ::ffff:0:0/96 IPv4-mapped
if segs[0..5] == [0, 0, 0, 0, 0] && segs[5] == 0xffff {
return Scope::Ipv4Mapped;
}
// 100::/64 discard prefix (RFC 6666)
if segs[0] == 0x0100 && segs[1] == 0 && segs[2] == 0 && segs[3] == 0 {
return Scope::Discard;
}
// 2001:db8::/32 documentation
if segs[0] == 0x2001 && segs[1] == 0x0db8 {
return Scope::Documentation;
}
// ff00::/8 multicast
if (segs[0] >> 8) == 0xff {
return Scope::Multicast;
}
// fe80::/10 link-local
if (segs[0] & 0xffc0) == 0xfe80 {
return Scope::LinkLocal;
}
// fc00::/7 unique-local
if (segs[0] & 0xfe00) == 0xfc00 {
return Scope::UniqueLocal;
}
Scope::Public
}
#[cfg(test)]
mod tests {
use super::*;
fn v4(s: &str) -> IpAddr {
IpAddr::V4(s.parse().unwrap())
}
fn v6(s: &str) -> IpAddr {
IpAddr::V6(s.parse().unwrap())
}
// The three cases the user explicitly asked for.
#[test]
fn rfc1918_10_slash_8_is_private() {
assert_eq!(classify(v4("10.0.0.1")), Scope::Private);
assert_eq!(classify(v4("10.255.255.254")), Scope::Private);
assert_eq!(classify(v4("10.0.0.0")), Scope::Private);
assert_eq!(classify(v4("10.0.0.0")).label(), "PRIVATE");
}
#[test]
fn ipv4_169_254_slash_16_is_link_local() {
assert_eq!(classify(v4("169.254.1.1")), Scope::LinkLocal);
assert_eq!(classify(v4("169.254.255.255")), Scope::LinkLocal);
assert_eq!(classify(v4("169.254.0.0")).label(), "LINK-LOCAL");
// Sibling /16 must not match.
assert_eq!(classify(v4("169.253.1.1")), Scope::Public);
}
#[test]
fn ipv6_fe80_slash_10_is_link_local() {
assert_eq!(classify(v6("fe80::1")), Scope::LinkLocal);
assert_eq!(
classify(v6("febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff")),
Scope::LinkLocal
);
assert_eq!(classify(v6("fe80::1")).label(), "LINK-LOCAL");
// fec0::/10 site-local is deprecated and outside fe80::/10.
assert_eq!(classify(v6("fec0::1")), Scope::Public);
}
// One representative per remaining category.
#[test]
fn ipv4_other_categories() {
assert_eq!(classify(v4("172.16.0.1")), Scope::Private);
assert_eq!(classify(v4("192.168.1.1")), Scope::Private);
assert_eq!(classify(v4("172.32.0.1")), Scope::Public); // outside 172.16/12
assert_eq!(classify(v4("127.0.0.1")), Scope::Loopback);
assert_eq!(classify(v4("0.0.0.0")), Scope::Unspecified);
assert_eq!(classify(v4("100.64.0.1")), Scope::Cgnat);
assert_eq!(classify(v4("100.128.0.1")), Scope::Public); // outside 100.64/10
assert_eq!(classify(v4("224.0.0.251")), Scope::Multicast); // mDNS
assert_eq!(classify(v4("239.255.255.250")), Scope::Multicast);
assert_eq!(classify(v4("255.255.255.255")), Scope::Broadcast);
assert_eq!(classify(v4("192.0.2.1")), Scope::Documentation);
assert_eq!(classify(v4("198.51.100.5")), Scope::Documentation);
assert_eq!(classify(v4("203.0.113.7")), Scope::Documentation);
assert_eq!(classify(v4("198.18.0.1")), Scope::Benchmarking);
assert_eq!(classify(v4("240.0.0.1")), Scope::Reserved);
}
#[test]
fn ipv6_other_categories() {
assert_eq!(classify(v6("::")), Scope::Unspecified);
assert_eq!(classify(v6("::1")), Scope::Loopback);
assert_eq!(classify(v6("ff02::1")), Scope::Multicast);
assert_eq!(classify(v6("fc00::1")), Scope::UniqueLocal);
assert_eq!(classify(v6("fd00::1")), Scope::UniqueLocal);
assert_eq!(classify(v6("2001:db8::1")), Scope::Documentation);
assert_eq!(classify(v6("100::1")), Scope::Discard);
assert_eq!(classify(v6("::ffff:1.2.3.4")), Scope::Ipv4Mapped);
}
#[test]
fn public_ips_are_classified_public() {
assert_eq!(classify(v4("1.1.1.1")), Scope::Public);
assert_eq!(classify(v4("8.8.8.8")), Scope::Public);
assert_eq!(classify(v4("93.184.216.34")), Scope::Public);
assert_eq!(classify(v6("2606:4700:4700::1111")), Scope::Public);
assert_eq!(classify(v4("1.1.1.1")).label(), "PUBLIC");
}
}
+354
View File
@@ -0,0 +1,354 @@
//! DNS resolver with background async resolution and caching.
//!
//! Provides non-blocking reverse DNS lookups with an LRU cache to avoid
//! repeated lookups for the same IP address.
use crossbeam::channel::{self, Receiver, Sender};
use dashmap::DashMap;
use dns_lookup::lookup_addr;
use log::debug;
use std::net::IpAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::{Duration, Instant};
/// Resolution state for a cached entry
#[derive(Debug, Clone, PartialEq)]
pub enum ResolutionState {
/// Resolution is in progress
Pending,
/// Resolution succeeded
Resolved,
/// Resolution failed
Failed,
}
/// Cached hostname entry
#[derive(Debug, Clone)]
pub struct CachedHostname {
/// The resolved hostname, if successful
pub hostname: Option<String>,
/// When this entry was resolved
pub resolved_at: Instant,
/// Current resolution state
pub state: ResolutionState,
}
impl CachedHostname {
fn pending() -> Self {
Self {
hostname: None,
resolved_at: Instant::now(),
state: ResolutionState::Pending,
}
}
fn resolved(hostname: String) -> Self {
Self {
hostname: Some(hostname),
resolved_at: Instant::now(),
state: ResolutionState::Resolved,
}
}
fn failed() -> Self {
Self {
hostname: None,
resolved_at: Instant::now(),
state: ResolutionState::Failed,
}
}
}
/// Configuration for DNS resolver
#[derive(Debug, Clone)]
pub struct DnsResolverConfig {
/// Cache TTL for resolved hostnames (default: 5 minutes)
pub cache_ttl: Duration,
/// Cache TTL for failed lookups (default: 1 minute)
pub negative_cache_ttl: Duration,
/// Maximum cache size (default: 10000 entries)
pub max_cache_size: usize,
/// Number of resolver threads (default: 4)
pub resolver_threads: usize,
}
impl Default for DnsResolverConfig {
fn default() -> Self {
Self {
cache_ttl: Duration::from_secs(300), // 5 minutes
negative_cache_ttl: Duration::from_secs(60), // 1 minute
max_cache_size: 10000,
resolver_threads: 4,
}
}
}
/// Background DNS resolver with caching
pub struct DnsResolver {
/// Hostname cache: IP -> CachedHostname
cache: Arc<DashMap<IpAddr, CachedHostname>>,
/// Channel to send IPs for resolution
request_tx: Sender<IpAddr>,
/// Control flag for shutdown
should_stop: Arc<AtomicBool>,
/// Configuration
config: DnsResolverConfig,
}
impl DnsResolver {
/// Create a new DNS resolver with the given configuration
pub fn new(config: DnsResolverConfig) -> Self {
let cache = Arc::new(DashMap::new());
let (request_tx, request_rx) = channel::unbounded();
let should_stop = Arc::new(AtomicBool::new(false));
let resolver = Self {
cache: Arc::clone(&cache),
request_tx,
should_stop: Arc::clone(&should_stop),
config: config.clone(),
};
// Start resolver threads
resolver.start_resolver_threads(request_rx, &config);
// Start cache cleanup thread
resolver.start_cache_cleanup_thread();
resolver
}
/// Create a new DNS resolver with default configuration
pub fn with_defaults() -> Self {
Self::new(DnsResolverConfig::default())
}
/// Start background resolver threads
fn start_resolver_threads(&self, request_rx: Receiver<IpAddr>, config: &DnsResolverConfig) {
let num_threads = config.resolver_threads;
for i in 0..num_threads {
let rx = request_rx.clone();
let cache = Arc::clone(&self.cache);
let should_stop = Arc::clone(&self.should_stop);
let cache_ttl = config.cache_ttl;
let negative_cache_ttl = config.negative_cache_ttl;
thread::Builder::new()
.name(format!("dns-resolver-{}", i))
.spawn(move || {
debug!("DNS resolver thread {} started", i);
while !should_stop.load(Ordering::Relaxed) {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(ip) => {
// Skip if already resolved or pending
if let Some(entry) = cache.get(&ip) {
let age = entry.resolved_at.elapsed();
match entry.state {
ResolutionState::Pending => continue,
ResolutionState::Resolved if age < cache_ttl => continue,
ResolutionState::Failed if age < negative_cache_ttl => {
continue;
}
_ => {} // Expired, re-resolve
}
}
// Mark as pending
cache.insert(ip, CachedHostname::pending());
// Perform DNS lookup
match lookup_addr(&ip) {
Ok(hostname) => {
debug!("Resolved {} -> {}", ip, hostname);
cache.insert(ip, CachedHostname::resolved(hostname));
}
Err(e) => {
debug!("Failed to resolve {}: {}", ip, e);
cache.insert(ip, CachedHostname::failed());
}
}
}
Err(crossbeam::channel::RecvTimeoutError::Timeout) => continue,
Err(crossbeam::channel::RecvTimeoutError::Disconnected) => break,
}
}
debug!("DNS resolver thread {} stopping", i);
})
.expect("Failed to spawn DNS resolver thread");
}
}
/// Start cache cleanup thread to evict expired entries
fn start_cache_cleanup_thread(&self) {
let cache = Arc::clone(&self.cache);
let should_stop = Arc::clone(&self.should_stop);
let cache_ttl = self.config.cache_ttl;
let negative_cache_ttl = self.config.negative_cache_ttl;
let max_cache_size = self.config.max_cache_size;
thread::Builder::new()
.name("dns-cache-cleanup".to_string())
.spawn(move || {
debug!("DNS cache cleanup thread started");
while !should_stop.load(Ordering::Relaxed) {
thread::sleep(Duration::from_secs(30)); // Cleanup every 30 seconds
if should_stop.load(Ordering::Relaxed) {
break;
}
// Remove expired entries
cache.retain(|_, entry| {
let age = entry.resolved_at.elapsed();
match entry.state {
ResolutionState::Resolved => age < cache_ttl,
ResolutionState::Failed => age < negative_cache_ttl,
ResolutionState::Pending => age < Duration::from_secs(30), // Timeout pending
}
});
// If cache is too large, remove oldest entries
if cache.len() > max_cache_size {
let mut entries: Vec<_> =
cache.iter().map(|e| (*e.key(), e.resolved_at)).collect();
entries.sort_by_key(|(_, time)| *time);
let to_remove = cache.len() - max_cache_size;
for (ip, _) in entries.into_iter().take(to_remove) {
cache.remove(&ip);
}
}
debug!("DNS cache size: {}", cache.len());
}
debug!("DNS cache cleanup thread stopping");
})
.expect("Failed to spawn DNS cache cleanup thread");
}
/// Request resolution for an IP address (non-blocking)
pub fn request_resolution(&self, ip: IpAddr) {
// Don't resolve localhost or link-local
if ip.is_loopback() || is_link_local(&ip) {
return;
}
// Check if already in cache and not expired
if let Some(entry) = self.cache.get(&ip) {
let age = entry.resolved_at.elapsed();
match entry.state {
ResolutionState::Pending => return,
ResolutionState::Resolved if age < self.config.cache_ttl => return,
ResolutionState::Failed if age < self.config.negative_cache_ttl => return,
_ => {} // Expired
}
}
// Queue for resolution (ignore send errors - channel is unbounded)
let _ = self.request_tx.send(ip);
}
/// Get hostname for IP if resolved, otherwise return None
pub fn get_hostname(&self, ip: &IpAddr) -> Option<String> {
// Request resolution if not in cache
self.request_resolution(*ip);
// Return cached hostname if available
self.cache.get(ip).and_then(|entry| {
if entry.state == ResolutionState::Resolved {
entry.hostname.clone()
} else {
None
}
})
}
/// Stop the resolver
pub fn stop(&self) {
self.should_stop.store(true, Ordering::Relaxed);
}
}
impl Drop for DnsResolver {
fn drop(&mut self) {
self.stop();
}
}
/// Check if IP is link-local
fn is_link_local(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => v4.is_link_local(),
IpAddr::V6(v6) => {
// fe80::/10
let segments = v6.segments();
(segments[0] & 0xffc0) == 0xfe80
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cached_hostname_states() {
let pending = CachedHostname::pending();
assert_eq!(pending.state, ResolutionState::Pending);
assert!(pending.hostname.is_none());
let resolved = CachedHostname::resolved("example.com".to_string());
assert_eq!(resolved.state, ResolutionState::Resolved);
assert_eq!(resolved.hostname, Some("example.com".to_string()));
let failed = CachedHostname::failed();
assert_eq!(failed.state, ResolutionState::Failed);
assert!(failed.hostname.is_none());
}
#[test]
fn test_link_local_detection() {
// IPv4 link-local
assert!(is_link_local(&"169.254.1.1".parse().unwrap()));
assert!(!is_link_local(&"192.168.1.1".parse().unwrap()));
// IPv6 link-local
assert!(is_link_local(&"fe80::1".parse().unwrap()));
assert!(!is_link_local(&"2001:db8::1".parse().unwrap()));
}
#[test]
fn test_loopback_skip() {
let config = DnsResolverConfig {
resolver_threads: 1,
..Default::default()
};
let resolver = DnsResolver::new(config);
// Loopback should not be queued
resolver.request_resolution("127.0.0.1".parse().unwrap());
assert!(
resolver
.get_hostname(&"127.0.0.1".parse().unwrap())
.is_none()
);
resolver.stop();
}
#[test]
fn test_default_config() {
let config = DnsResolverConfig::default();
assert_eq!(config.cache_ttl, Duration::from_secs(300));
assert_eq!(config.negative_cache_ttl, Duration::from_secs(60));
assert_eq!(config.max_cache_size, 10000);
assert_eq!(config.resolver_threads, 4);
}
}
@@ -0,0 +1,673 @@
use crate::network::types::{BitTorrentInfo, BitTorrentType};
use std::fmt::Write as _;
/// BitTorrent protocol handshake prefix: length byte (19) + "BitTorrent protocol"
const BT_HANDSHAKE_PREFIX: &[u8] = b"\x13BitTorrent protocol";
/// Full handshake length: 1 (pstrlen) + 19 (pstr) + 8 (reserved) + 20 (info_hash) + 20 (peer_id)
const BT_HANDSHAKE_LEN: usize = 68;
/// Reserved byte bit positions for extension flags
const DHT_BIT_BYTE: usize = 7; // Byte 7, bit 0x01
const FAST_BIT_BYTE: usize = 7; // Byte 7, bit 0x04
const EXTENSION_BIT_BYTE: usize = 5; // Byte 5, bit 0x10
/// uTP header size (BEP 29)
const UTP_HEADER_LEN: usize = 20;
/// Maximum DHT method name length. Standard methods are short
/// (e.g., "ping", "find_node", "get_peers", "announce_peer").
const MAX_DHT_METHOD_LEN: usize = 64;
// --- TCP: Peer Handshake ---
/// Check if the payload starts with a BitTorrent peer handshake.
pub fn is_bittorrent_handshake(payload: &[u8]) -> bool {
payload.starts_with(BT_HANDSHAKE_PREFIX)
}
/// Analyze a BitTorrent TCP handshake payload and extract protocol details.
pub fn analyze_bittorrent(payload: &[u8]) -> Option<BitTorrentInfo> {
if !is_bittorrent_handshake(payload) {
return None;
}
let reserved = if payload.len() >= 28 {
Some(&payload[20..28])
} else {
None
};
let supports_dht = reserved.is_some_and(|r| r[DHT_BIT_BYTE] & 0x01 != 0);
let supports_fast = reserved.is_some_and(|r| r[FAST_BIT_BYTE] & 0x04 != 0);
let supports_extension = reserved.is_some_and(|r| r[EXTENSION_BIT_BYTE] & 0x10 != 0);
let info_hash = if payload.len() >= 48 {
let hash_bytes = &payload[28..48];
Some(hex_encode(hash_bytes))
} else {
None
};
let client = if payload.len() >= BT_HANDSHAKE_LEN {
let peer_id = &payload[48..68];
decode_client_name(peer_id)
} else {
None
};
Some(BitTorrentInfo {
protocol_type: BitTorrentType::Peer,
info_hash,
client,
dht_method: None,
supports_dht,
supports_extension,
supports_fast,
})
}
// --- UDP: DHT + uTP ---
/// Analyze a UDP payload for BitTorrent DHT or uTP traffic.
/// Tries DHT first (higher confidence), then uTP.
pub fn analyze_udp_bittorrent(payload: &[u8]) -> Option<BitTorrentInfo> {
if let Some(info) = analyze_dht(payload) {
return Some(info);
}
analyze_utp(payload)
}
/// Analyze a UDP payload for BitTorrent DHT (bencoded dictionary messages).
///
/// DHT messages are bencoded dicts containing:
/// - `y`: message type — `q` (query), `r` (response), `e` (error)
/// - `q`: method name (for queries) — `ping`, `find_node`, `get_peers`, `announce_peer`
/// - `t`: transaction ID
fn analyze_dht(payload: &[u8]) -> Option<BitTorrentInfo> {
// Must start with 'd' (bencoded dict) and end with 'e'
if payload.len() < 10 || payload[0] != b'd' || payload[payload.len() - 1] != b'e' {
return None;
}
// Must contain the message type key "1:y1:" followed by q/r/e
let pos = find_subsequence(payload, b"1:y1:")?;
if pos + 6 > payload.len() {
return None;
}
let msg_type_char = payload[pos + 5];
if msg_type_char != b'q' && msg_type_char != b'r' && msg_type_char != b'e' {
return None;
}
// Extract DHT method for queries
let dht_method = if msg_type_char == b'q' {
extract_dht_method(payload)
} else if msg_type_char == b'r' {
Some("response".to_string())
} else {
Some("error".to_string())
};
Some(BitTorrentInfo {
protocol_type: BitTorrentType::Dht,
info_hash: None,
client: None,
dht_method,
supports_dht: false,
supports_extension: false,
supports_fast: false,
})
}
/// Extract the DHT query method name from a bencoded payload.
/// Looks for "1:q" followed by a bencoded string like "4:ping" or "9:find_node".
fn extract_dht_method(payload: &[u8]) -> Option<String> {
let pos = find_subsequence(payload, b"1:q")?;
let after = pos + 3;
if after >= payload.len() {
return None;
}
// Parse the bencoded string length: digits followed by ':'
let mut len_end = after;
while len_end < payload.len() && payload[len_end].is_ascii_digit() {
len_end += 1;
}
if len_end == after || len_end >= payload.len() || payload[len_end] != b':' {
return None;
}
let len_str = std::str::from_utf8(&payload[after..len_end]).ok()?;
let str_len: usize = len_str.parse().ok()?;
if str_len > MAX_DHT_METHOD_LEN {
return None;
}
let str_start = len_end + 1;
let str_end = str_start + str_len;
if str_end > payload.len() {
return None;
}
std::str::from_utf8(&payload[str_start..str_end])
.ok()
.map(String::from)
}
/// Analyze a UDP payload for BitTorrent uTP (Micro Transport Protocol, BEP 29).
///
/// uTP header (20 bytes):
/// - Byte 0: type (upper 4 bits) | version (lower 4 bits, must be 1)
/// - Byte 1: extension
/// - Bytes 2-3: connection_id
/// - Bytes 4-7: timestamp_microseconds
/// - Bytes 8-11: timestamp_difference_microseconds
/// - Bytes 12-15: wnd_size
/// - Bytes 16-17: seq_nr
/// - Bytes 18-19: ack_nr
fn analyze_utp(payload: &[u8]) -> Option<BitTorrentInfo> {
if payload.len() < UTP_HEADER_LEN {
return None;
}
let first_byte = payload[0];
let version = first_byte & 0x0F;
let pkt_type = (first_byte >> 4) & 0x0F;
let extension = payload[1];
// Version must be 1
if version != 1 {
return None;
}
// Type must be 0-4: ST_DATA, ST_FIN, ST_STATE, ST_RESET, ST_SYN
if pkt_type > 4 {
return None;
}
// Extension byte should be small (0=none, 1=selective ack, 2=extension bits)
if extension > 2 {
return None;
}
// Real uTP connection IDs are randomly generated, so 0 is effectively
// never seen — but bytes 2-3 == 0 is exactly what a WireGuard handshake
// initiation looks like here (type byte 0x01 followed by three reserved
// zero bytes). Since this check runs on every unmatched UDP packet,
// require a non-zero connection ID so WireGuard rekeys are not
// classified as BitTorrent.
let connection_id = u16::from_be_bytes([payload[2], payload[3]]);
if connection_id == 0 {
return None;
}
// Window size sanity check — 0 is valid for ST_RESET but otherwise should be non-zero
let wnd_size = u32::from_be_bytes([payload[12], payload[13], payload[14], payload[15]]);
if pkt_type != 3 && wnd_size == 0 {
// ST_SYN with zero window is also suspicious
return None;
}
Some(BitTorrentInfo {
protocol_type: BitTorrentType::Utp,
info_hash: None,
client: None,
dht_method: None,
supports_dht: false,
supports_extension: false,
supports_fast: false,
})
}
// --- Helpers ---
/// Decode the BitTorrent client name from a 20-byte peer_id using the Azureus-style convention.
///
/// Azureus-style: `-XX1234-............` where XX is the client ID and 1234 is the version.
fn decode_client_name(peer_id: &[u8]) -> Option<String> {
if peer_id.len() >= 8 && peer_id[0] == b'-' && peer_id[7] == b'-' {
let client_id = std::str::from_utf8(&peer_id[1..3]).ok()?;
let version_bytes = &peer_id[3..7];
let name = match client_id {
"qB" => "qBittorrent",
"TR" => "Transmission",
"DE" => "Deluge",
"UT" => "uTorrent",
"lt" => "libtorrent",
"LT" => "libtorrent",
"AZ" => "Azureus",
"BT" => "BitTorrent",
"BI" => "BiglyBT",
"FD" => "Free Download Manager",
"KT" => "KTorrent",
"RB" => "rtorrent",
"WW" => "WebTorrent",
"FL" => "Flud",
"SD" => "Xunlei",
"TL" => "Tribler",
_ => client_id,
};
let version = format_version(version_bytes);
Some(format!("{name} {version}"))
} else if peer_id[0].is_ascii_alphanumeric() {
let id_char = peer_id[0] as char;
let name = match id_char {
'M' => "Mainline",
'S' => "Shadow",
'T' => "BitTornado",
'A' => "ABC",
_ => return None,
};
Some(name.to_string())
} else {
None
}
}
/// Format version bytes into a dotted version string.
fn format_version(bytes: &[u8]) -> String {
bytes
.iter()
.filter_map(|&b| {
if b.is_ascii_digit() {
Some((b - b'0').to_string())
} else if b.is_ascii_alphanumeric() {
Some((b as char).to_string())
} else {
None
}
})
.collect::<Vec<_>>()
.join(".")
}
/// Encode bytes as a lowercase hex string.
fn hex_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
// write! to a String never fails.
let _ = write!(out, "{b:02x}");
}
out
}
/// Find the first occurrence of a subsequence in a byte slice.
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
#[cfg(test)]
mod tests {
use super::*;
// --- TCP Handshake Tests ---
fn build_handshake(reserved: [u8; 8], info_hash: [u8; 20], peer_id: &[u8; 20]) -> Vec<u8> {
let mut payload = Vec::with_capacity(BT_HANDSHAKE_LEN);
payload.extend_from_slice(BT_HANDSHAKE_PREFIX);
payload.extend_from_slice(&reserved);
payload.extend_from_slice(&info_hash);
payload.extend_from_slice(peer_id);
payload
}
#[test]
fn test_handshake_detection() {
let payload = build_handshake([0; 8], [0xAB; 20], b"-qB4250-xxxxxxxxxxxx");
assert!(is_bittorrent_handshake(&payload));
}
#[test]
fn test_non_bittorrent_payloads() {
assert!(!is_bittorrent_handshake(b"GET / HTTP/1.1\r\n"));
assert!(!is_bittorrent_handshake(b"SSH-2.0-OpenSSH_8.9\r\n"));
assert!(!is_bittorrent_handshake(&[0x16, 0x03, 0x01, 0x00]));
assert!(!is_bittorrent_handshake(b""));
assert!(!is_bittorrent_handshake(&[0x13]));
}
#[test]
fn test_analyze_qbittorrent() {
let payload = build_handshake([0; 8], [0xDE; 20], b"-qB4250-xxxxxxxxxxxx");
let info = analyze_bittorrent(&payload).unwrap();
assert_eq!(info.protocol_type, BitTorrentType::Peer);
assert_eq!(info.client.as_deref(), Some("qBittorrent 4.2.5.0"));
assert_eq!(
info.info_hash.as_deref(),
Some("dededededededededededededededededededede") // 20 bytes = 40 hex chars
);
assert!(!info.supports_dht);
assert!(!info.supports_extension);
assert!(!info.supports_fast);
}
#[test]
fn test_analyze_transmission() {
let payload = build_handshake([0; 8], [0; 20], b"-TR3000-xxxxxxxxxxxx");
let info = analyze_bittorrent(&payload).unwrap();
assert_eq!(info.client.as_deref(), Some("Transmission 3.0.0.0"));
}
#[test]
fn test_analyze_deluge() {
let payload = build_handshake([0; 8], [0; 20], b"-DE0018-xxxxxxxxxxxx");
let info = analyze_bittorrent(&payload).unwrap();
assert_eq!(info.client.as_deref(), Some("Deluge 0.0.1.8"));
}
#[test]
fn test_analyze_utorrent() {
let payload = build_handshake([0; 8], [0; 20], b"-UT3560-xxxxxxxxxxxx");
let info = analyze_bittorrent(&payload).unwrap();
assert_eq!(info.client.as_deref(), Some("uTorrent 3.5.6.0"));
}
#[test]
fn test_analyze_libtorrent() {
let payload = build_handshake([0; 8], [0; 20], b"-lt0D60-xxxxxxxxxxxx");
let info = analyze_bittorrent(&payload).unwrap();
assert_eq!(info.client.as_deref(), Some("libtorrent 0.D.6.0"));
}
#[test]
fn test_dht_flag() {
let mut reserved = [0u8; 8];
reserved[7] = 0x01;
let payload = build_handshake(reserved, [0; 20], b"-qB4250-xxxxxxxxxxxx");
let info = analyze_bittorrent(&payload).unwrap();
assert!(info.supports_dht);
assert!(!info.supports_extension);
assert!(!info.supports_fast);
}
#[test]
fn test_extension_flag() {
let mut reserved = [0u8; 8];
reserved[5] = 0x10;
let payload = build_handshake(reserved, [0; 20], b"-qB4250-xxxxxxxxxxxx");
let info = analyze_bittorrent(&payload).unwrap();
assert!(!info.supports_dht);
assert!(info.supports_extension);
assert!(!info.supports_fast);
}
#[test]
fn test_fast_flag() {
let mut reserved = [0u8; 8];
reserved[7] = 0x04;
let payload = build_handshake(reserved, [0; 20], b"-qB4250-xxxxxxxxxxxx");
let info = analyze_bittorrent(&payload).unwrap();
assert!(!info.supports_dht);
assert!(!info.supports_extension);
assert!(info.supports_fast);
}
#[test]
fn test_all_flags() {
let mut reserved = [0u8; 8];
reserved[5] = 0x10;
reserved[7] = 0x05;
let payload = build_handshake(reserved, [0; 20], b"-qB4250-xxxxxxxxxxxx");
let info = analyze_bittorrent(&payload).unwrap();
assert!(info.supports_dht);
assert!(info.supports_extension);
assert!(info.supports_fast);
}
#[test]
fn test_partial_handshake_prefix_only() {
let payload = BT_HANDSHAKE_PREFIX.to_vec();
let info = analyze_bittorrent(&payload).unwrap();
assert!(info.info_hash.is_none());
assert!(info.client.is_none());
assert!(!info.supports_dht);
}
#[test]
fn test_partial_handshake_with_hash() {
let mut payload = Vec::new();
payload.extend_from_slice(BT_HANDSHAKE_PREFIX);
payload.extend_from_slice(&[0u8; 8]);
payload.extend_from_slice(&[0xAB; 20]);
let info = analyze_bittorrent(&payload).unwrap();
assert!(info.info_hash.is_some());
assert!(info.client.is_none());
}
#[test]
fn test_unknown_client_id() {
let payload = build_handshake([0; 8], [0; 20], b"-ZZ1234-xxxxxxxxxxxx");
let info = analyze_bittorrent(&payload).unwrap();
assert_eq!(info.client.as_deref(), Some("ZZ 1.2.3.4"));
}
#[test]
fn test_non_bt_returns_none() {
assert!(analyze_bittorrent(b"GET / HTTP/1.1\r\n").is_none());
assert!(analyze_bittorrent(b"SSH-2.0-OpenSSH\r\n").is_none());
assert!(analyze_bittorrent(b"").is_none());
}
#[test]
fn test_hex_encode() {
assert_eq!(hex_encode(&[0xDE, 0xAD, 0xBE, 0xEF]), "deadbeef");
assert_eq!(hex_encode(&[0x00, 0xFF]), "00ff");
}
#[test]
fn test_format_version() {
assert_eq!(format_version(b"4250"), "4.2.5.0");
assert_eq!(format_version(b"3000"), "3.0.0.0");
assert_eq!(format_version(b"0D60"), "0.D.6.0");
}
// --- DHT Tests ---
#[test]
fn test_dht_ping_query() {
let payload = b"d1:ad2:id20:abcdefghij0123456789e1:q4:ping1:t2:aa1:y1:qe";
let info = analyze_dht(payload).unwrap();
assert_eq!(info.protocol_type, BitTorrentType::Dht);
assert_eq!(info.dht_method.as_deref(), Some("ping"));
}
#[test]
fn test_dht_find_node_query() {
let payload = b"d1:ad2:id20:abcdefghij01234567896:target20:mnopqrstuvwxyz123456e1:q9:find_node1:t2:aa1:y1:qe";
let info = analyze_dht(payload).unwrap();
assert_eq!(info.dht_method.as_deref(), Some("find_node"));
}
#[test]
fn test_dht_get_peers_query() {
let payload = b"d1:ad2:id20:abcdefghij01234567899:info_hash20:mnopqrstuvwxyz123456e1:q9:get_peers1:t2:aa1:y1:qe";
let info = analyze_dht(payload).unwrap();
assert_eq!(info.dht_method.as_deref(), Some("get_peers"));
}
#[test]
fn test_dht_announce_peer_query() {
let payload = b"d1:ad2:id20:abcdefghij01234567899:info_hash20:mnopqrstuvwxyz1234564:porti6881e5:token8:aoeusnthe1:q13:announce_peer1:t2:aa1:y1:qe";
let info = analyze_dht(payload).unwrap();
assert_eq!(info.dht_method.as_deref(), Some("announce_peer"));
}
#[test]
fn test_dht_response() {
let payload = b"d1:rd2:id20:0123456789abcdefghij5:nodes208:...e1:t2:aa1:y1:re";
let info = analyze_dht(payload).unwrap();
assert_eq!(info.protocol_type, BitTorrentType::Dht);
assert_eq!(info.dht_method.as_deref(), Some("response"));
}
#[test]
fn test_dht_error() {
let payload = b"d1:eli201e23:A Generic Error Ocurrede1:t2:aa1:y1:ee";
let info = analyze_dht(payload).unwrap();
assert_eq!(info.dht_method.as_deref(), Some("error"));
}
#[test]
fn test_dht_rejects_non_bencode() {
assert!(analyze_dht(b"GET / HTTP/1.1\r\n").is_none());
assert!(analyze_dht(b"").is_none());
assert!(analyze_dht(b"d").is_none());
// Starts with 'd' but no message type key
assert!(analyze_dht(b"d3:foo3:bare").is_none());
}
// --- uTP Tests ---
/// Build a minimal uTP header.
fn build_utp_header(pkt_type: u8, version: u8, extension: u8, wnd_size: u32) -> Vec<u8> {
let mut payload = vec![0u8; UTP_HEADER_LEN];
payload[0] = (pkt_type << 4) | (version & 0x0F);
payload[1] = extension;
// connection_id at bytes 2-3: always random (non-zero) in real uTP
payload[2..4].copy_from_slice(&0x1234u16.to_be_bytes());
// wnd_size at bytes 12-15
payload[12..16].copy_from_slice(&wnd_size.to_be_bytes());
payload
}
#[test]
fn test_utp_rejects_wireguard_handshake_initiation() {
// WireGuard handshake initiation: message type 0x01, three reserved
// zero bytes, then a random sender index and ephemeral key. It
// passes every other uTP field check (version 1, type ST_DATA,
// extension 0, non-zero bytes at the wnd_size position), so the
// zero connection-id bytes are what must reject it.
let mut payload = vec![0u8; 148];
payload[0] = 0x01; // type=1 (handshake initiation), reserved bytes 1-3 zero
for (i, byte) in payload.iter_mut().enumerate().skip(4) {
*byte = (i * 31 % 251 + 1) as u8; // arbitrary non-zero key material
}
assert!(analyze_utp(&payload).is_none());
assert!(analyze_udp_bittorrent(&payload).is_none());
}
#[test]
fn test_utp_data_packet() {
let payload = build_utp_header(0, 1, 0, 65535); // ST_DATA
let info = analyze_utp(&payload).unwrap();
assert_eq!(info.protocol_type, BitTorrentType::Utp);
}
#[test]
fn test_utp_syn_packet() {
let payload = build_utp_header(4, 1, 0, 65535); // ST_SYN
let info = analyze_utp(&payload).unwrap();
assert_eq!(info.protocol_type, BitTorrentType::Utp);
}
#[test]
fn test_utp_state_packet() {
let payload = build_utp_header(2, 1, 0, 32768); // ST_STATE (ACK)
let info = analyze_utp(&payload).unwrap();
assert_eq!(info.protocol_type, BitTorrentType::Utp);
}
#[test]
fn test_utp_fin_packet() {
let payload = build_utp_header(1, 1, 0, 1024); // ST_FIN
let info = analyze_utp(&payload).unwrap();
assert_eq!(info.protocol_type, BitTorrentType::Utp);
}
#[test]
fn test_utp_reset_zero_window() {
// ST_RESET with zero window is valid
let payload = build_utp_header(3, 1, 0, 0);
let info = analyze_utp(&payload).unwrap();
assert_eq!(info.protocol_type, BitTorrentType::Utp);
}
#[test]
fn test_utp_with_selective_ack() {
let payload = build_utp_header(0, 1, 1, 65535); // extension=1 (SACK)
assert!(analyze_utp(&payload).is_some());
}
#[test]
fn test_utp_rejects_wrong_version() {
let payload = build_utp_header(0, 0, 0, 65535);
assert!(analyze_utp(&payload).is_none());
let payload = build_utp_header(0, 2, 0, 65535);
assert!(analyze_utp(&payload).is_none());
}
#[test]
fn test_utp_rejects_invalid_type() {
let payload = build_utp_header(5, 1, 0, 65535); // type 5 is invalid
assert!(analyze_utp(&payload).is_none());
}
#[test]
fn test_utp_rejects_bad_extension() {
let payload = build_utp_header(0, 1, 10, 65535); // extension=10 is suspicious
assert!(analyze_utp(&payload).is_none());
}
#[test]
fn test_utp_rejects_zero_window_non_reset() {
let payload = build_utp_header(0, 1, 0, 0); // ST_DATA with zero window
assert!(analyze_utp(&payload).is_none());
}
#[test]
fn test_utp_rejects_too_short() {
assert!(analyze_utp(&[0x01; 10]).is_none());
assert!(analyze_utp(&[]).is_none());
}
// --- Combined UDP analyzer ---
#[test]
fn test_udp_prefers_dht_over_utp() {
// A DHT message should be detected as DHT, not uTP
let payload = b"d1:ad2:id20:abcdefghij0123456789e1:q4:ping1:t2:aa1:y1:qe";
let info = analyze_udp_bittorrent(payload).unwrap();
assert_eq!(info.protocol_type, BitTorrentType::Dht);
}
#[test]
fn test_udp_falls_through_to_utp() {
let payload = build_utp_header(4, 1, 0, 65535);
let info = analyze_udp_bittorrent(&payload).unwrap();
assert_eq!(info.protocol_type, BitTorrentType::Utp);
}
#[test]
fn test_udp_returns_none_for_unknown() {
assert!(analyze_udp_bittorrent(b"GET / HTTP/1.1\r\n").is_none());
}
#[test]
fn test_hex_encode_lowercase_20_byte_info_hash() {
// A representative 20-byte info-hash (the size BitTorrent always uses).
let info_hash: [u8; 20] = [
0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x00, 0xff, 0x01, 0x02, 0x03, 0x04,
0x05, 0x06, 0x07, 0x08, 0x09, 0x0a,
];
assert_eq!(
hex_encode(&info_hash),
"123456789abcdef000ff0102030405060708090a"
);
}
#[test]
fn test_hex_encode_empty_slice() {
assert_eq!(hex_encode(&[]), "");
}
#[test]
fn test_hex_encode_pads_single_digit_bytes() {
// Locks the `{:02x}` padding contract — 0x00..=0x0f stay two chars.
assert_eq!(hex_encode(&[0x00, 0x0a, 0x0f, 0x10]), "000a0f10");
}
}
@@ -0,0 +1,227 @@
//! TLS Cipher Suite mappings
//!
//! This module provides mappings from cipher suite codes to their human-readable names.
//! The mappings are based on the IANA TLS Cipher Suite Registry and include commonly
//! used cipher suites from TLS 1.0 through TLS 1.3.
use std::collections::HashMap;
use std::sync::LazyLock;
/// Static mapping of cipher suite codes to their names
static CIPHER_SUITE_MAP: LazyLock<HashMap<u16, &'static str>> = LazyLock::new(|| {
let mut map = HashMap::new();
// TLS 1.3 Cipher Suites (RFC 8446)
map.insert(0x1301, "TLS_AES_128_GCM_SHA256");
map.insert(0x1302, "TLS_AES_256_GCM_SHA384");
map.insert(0x1303, "TLS_CHACHA20_POLY1305_SHA256");
map.insert(0x1304, "TLS_AES_128_CCM_SHA256");
map.insert(0x1305, "TLS_AES_128_CCM_8_SHA256");
// TLS 1.2 ECDHE Cipher Suites (RFC 5289, RFC 7905)
map.insert(0xc02b, "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256");
map.insert(0xc02c, "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384");
map.insert(0xc02f, "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256");
map.insert(0xc030, "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384");
map.insert(0xc009, "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA");
map.insert(0xc00a, "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA");
map.insert(0xc013, "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA");
map.insert(0xc014, "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA");
map.insert(0xc023, "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256");
map.insert(0xc024, "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384");
map.insert(0xc027, "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256");
map.insert(0xc028, "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384");
// ChaCha20-Poly1305 (RFC 7905)
map.insert(0xcca9, "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256");
map.insert(0xcca8, "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256");
map.insert(0xccaa, "TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256");
// DHE Cipher Suites
map.insert(0x009e, "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256");
map.insert(0x009f, "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384");
map.insert(0x0033, "TLS_DHE_RSA_WITH_AES_128_CBC_SHA");
map.insert(0x0039, "TLS_DHE_RSA_WITH_AES_256_CBC_SHA");
map.insert(0x0067, "TLS_DHE_RSA_WITH_AES_128_CBC_SHA256");
map.insert(0x006b, "TLS_DHE_RSA_WITH_AES_256_CBC_SHA256");
// RSA Cipher Suites (less preferred but still common)
map.insert(0x009c, "TLS_RSA_WITH_AES_128_GCM_SHA256");
map.insert(0x009d, "TLS_RSA_WITH_AES_256_GCM_SHA384");
map.insert(0x002f, "TLS_RSA_WITH_AES_128_CBC_SHA");
map.insert(0x0035, "TLS_RSA_WITH_AES_256_CBC_SHA");
map.insert(0x003c, "TLS_RSA_WITH_AES_128_CBC_SHA256");
map.insert(0x003d, "TLS_RSA_WITH_AES_256_CBC_SHA256");
// 3DES (Legacy)
map.insert(0x000a, "TLS_RSA_WITH_3DES_EDE_CBC_SHA");
map.insert(0x0016, "TLS_DHE_RSA_WITH_3DES_EDE_CBC_SHA");
map.insert(0xc008, "TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA");
map.insert(0xc012, "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA");
// RC4 (Deprecated but still seen)
map.insert(0x0004, "TLS_RSA_WITH_RC4_128_MD5");
map.insert(0x0005, "TLS_RSA_WITH_RC4_128_SHA");
map.insert(0xc007, "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA");
map.insert(0xc011, "TLS_ECDHE_RSA_WITH_RC4_128_SHA");
// PSK Cipher Suites (RFC 4279, RFC 5487)
map.insert(0x008c, "TLS_PSK_WITH_AES_128_CBC_SHA");
map.insert(0x008d, "TLS_PSK_WITH_AES_256_CBC_SHA");
map.insert(0x00a8, "TLS_PSK_WITH_AES_128_GCM_SHA256");
map.insert(0x00a9, "TLS_PSK_WITH_AES_256_GCM_SHA384");
// ECDHE-PSK
map.insert(0xc035, "TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA");
map.insert(0xc036, "TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA");
map.insert(0xc037, "TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA256");
map.insert(0xc038, "TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA384");
// ARIA Cipher Suites (RFC 6209)
map.insert(0xc03c, "TLS_RSA_WITH_ARIA_128_CBC_SHA256");
map.insert(0xc03d, "TLS_RSA_WITH_ARIA_256_CBC_SHA384");
map.insert(0xc060, "TLS_ECDHE_RSA_WITH_ARIA_128_GCM_SHA256");
map.insert(0xc061, "TLS_ECDHE_RSA_WITH_ARIA_256_GCM_SHA384");
// Camellia Cipher Suites (RFC 6367)
map.insert(0xc072, "TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_CBC_SHA256");
map.insert(0xc073, "TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_CBC_SHA384");
map.insert(0xc076, "TLS_ECDHE_RSA_WITH_CAMELLIA_128_CBC_SHA256");
map.insert(0xc077, "TLS_ECDHE_RSA_WITH_CAMELLIA_256_CBC_SHA384");
// NULL encryption (for testing/debugging)
map.insert(0x0001, "TLS_RSA_WITH_NULL_MD5");
map.insert(0x0002, "TLS_RSA_WITH_NULL_SHA");
map.insert(0x003b, "TLS_RSA_WITH_NULL_SHA256");
map
});
/// Get the human-readable name for a cipher suite code
///
/// # Arguments
/// * `code` - The cipher suite code (u16)
///
/// # Returns
/// * Some(name) if the cipher suite is known
/// * None if the cipher suite is not in our mapping
pub fn get_cipher_suite_name(code: u16) -> Option<&'static str> {
CIPHER_SUITE_MAP.get(&code).copied()
}
/// Format a cipher suite code with its name if known
///
/// # Arguments
/// * `code` - The cipher suite code (u16)
///
/// # Returns
/// * A formatted string like "TLS_AES_128_GCM_SHA256 (0x1301)" if known
/// * Just the hex code like "0x1234" if unknown
pub fn format_cipher_suite(code: u16) -> String {
match get_cipher_suite_name(code) {
Some(name) => format!("{} (0x{:04X})", name, code),
None => format!("0x{:04X}", code),
}
}
/// Check if a cipher suite is considered secure by modern standards
///
/// # Arguments
/// * `code` - The cipher suite code (u16)
///
/// # Returns
/// * true if the cipher suite is considered secure
/// * false if it's deprecated or insecure
pub fn is_secure_cipher_suite(code: u16) -> bool {
match code {
// TLS 1.3 suites are all secure
0x1301..=0x1305 => true,
// Modern TLS 1.2 ECDHE suites with AEAD
0xc02b | 0xc02c | 0xc02f | 0xc030 => true, // ECDHE + AES-GCM
0xcca8..=0xccaa => true, // ChaCha20-Poly1305
0x009e | 0x009f => true, // DHE-RSA with AES-GCM
// Everything else is either legacy or insecure
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tls13_cipher_suites() {
assert_eq!(
get_cipher_suite_name(0x1301),
Some("TLS_AES_128_GCM_SHA256")
);
assert_eq!(
get_cipher_suite_name(0x1302),
Some("TLS_AES_256_GCM_SHA384")
);
assert_eq!(
get_cipher_suite_name(0x1303),
Some("TLS_CHACHA20_POLY1305_SHA256")
);
}
#[test]
fn test_format_cipher_suite() {
assert_eq!(
format_cipher_suite(0x1301),
"TLS_AES_128_GCM_SHA256 (0x1301)"
);
assert_eq!(format_cipher_suite(0x9999), "0x9999");
}
#[test]
fn test_security_classification() {
assert!(is_secure_cipher_suite(0x1301)); // TLS 1.3
assert!(is_secure_cipher_suite(0xc02f)); // ECDHE-RSA-AES128-GCM-SHA256
assert!(!is_secure_cipher_suite(0x0004)); // RC4
assert!(!is_secure_cipher_suite(0x000a)); // 3DES
}
#[test]
fn test_unknown_cipher_suite() {
assert_eq!(get_cipher_suite_name(0xFFFF), None);
assert_eq!(format_cipher_suite(0xFFFF), "0xFFFF");
}
#[test]
fn test_aria_camellia_iana_names() {
// 0xC03C/0xC03D are RSA + CBC per IANA / RFC 6209; the
// ECDHE_ECDSA ARIA GCM suites live at 0xC05C/0xC05D.
assert_eq!(
get_cipher_suite_name(0xc03c),
Some("TLS_RSA_WITH_ARIA_128_CBC_SHA256")
);
assert_eq!(
get_cipher_suite_name(0xc03d),
Some("TLS_RSA_WITH_ARIA_256_CBC_SHA384")
);
// 0xC060/0xC061 are ECDHE_RSA ARIA GCM (already correct).
assert_eq!(
get_cipher_suite_name(0xc060),
Some("TLS_ECDHE_RSA_WITH_ARIA_128_GCM_SHA256")
);
// 0xC072-0xC077 are CBC per IANA / RFC 6367; the matching
// Camellia GCM suites live at 0xC086/0xC087/0xC08A/0xC08B.
assert_eq!(
get_cipher_suite_name(0xc072),
Some("TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_CBC_SHA256")
);
assert_eq!(
get_cipher_suite_name(0xc073),
Some("TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_CBC_SHA384")
);
assert_eq!(
get_cipher_suite_name(0xc076),
Some("TLS_ECDHE_RSA_WITH_CAMELLIA_128_CBC_SHA256")
);
assert_eq!(
get_cipher_suite_name(0xc077),
Some("TLS_ECDHE_RSA_WITH_CAMELLIA_256_CBC_SHA384")
);
}
}
+219
View File
@@ -0,0 +1,219 @@
//! DHCP (Dynamic Host Configuration Protocol) Deep Packet Inspection
//!
//! Parses DHCP packets according to RFC 2131.
//! DHCP uses UDP ports 67 (server) and 68 (client).
use crate::network::types::{DhcpInfo, DhcpMessageType};
/// Minimum DHCP packet size (fixed header + magic cookie)
const MIN_DHCP_SIZE: usize = 240;
/// DHCP magic cookie: 99.130.83.99 (0x63825363)
const DHCP_MAGIC_COOKIE: [u8; 4] = [0x63, 0x82, 0x53, 0x63];
/// DHCP option codes
const DHCP_OPT_HOSTNAME: u8 = 12;
const DHCP_OPT_MESSAGE_TYPE: u8 = 53;
const DHCP_OPT_END: u8 = 255;
/// Analyze a DHCP packet and extract key information.
///
/// Returns `None` if the packet is too small or doesn't have the DHCP magic cookie.
pub fn analyze_dhcp(payload: &[u8]) -> Option<DhcpInfo> {
// Early size check
if payload.len() < MIN_DHCP_SIZE {
return None;
}
// Verify DHCP magic cookie at offset 236-239
if payload[236..240] != DHCP_MAGIC_COOKIE {
return None;
}
// Extract client MAC address from bytes 28-33 (chaddr field, first 6 bytes)
let client_mac = format_mac(&payload[28..34]);
// Parse DHCP options starting at byte 240
let (message_type, hostname) = parse_dhcp_options(&payload[240..])?;
Some(DhcpInfo {
message_type,
hostname,
client_mac: Some(client_mac),
})
}
/// Format a 6-byte MAC address as a string
fn format_mac(bytes: &[u8]) -> String {
format!(
"{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]
)
}
/// Parse DHCP options and extract message type and hostname
fn parse_dhcp_options(options: &[u8]) -> Option<(DhcpMessageType, Option<String>)> {
let mut message_type = None;
let mut hostname = None;
let mut offset = 0;
while offset < options.len() {
let opt_code = options[offset];
// Option 255 = End
if opt_code == DHCP_OPT_END {
break;
}
// Option 0 = Pad (no length)
if opt_code == 0 {
offset += 1;
continue;
}
// Need at least 1 more byte for length
if offset + 1 >= options.len() {
break;
}
let opt_len = options[offset + 1] as usize;
// Bounds check for option data
if offset + 2 + opt_len > options.len() {
break;
}
let opt_data = &options[offset + 2..offset + 2 + opt_len];
match opt_code {
DHCP_OPT_MESSAGE_TYPE if opt_len >= 1 => {
message_type = Some(parse_message_type(opt_data[0]));
}
DHCP_OPT_HOSTNAME => {
if let Ok(name) = std::str::from_utf8(opt_data) {
hostname = Some(name.to_string());
}
}
_ => {}
}
offset += 2 + opt_len;
}
// Message type is required
Some((message_type?, hostname))
}
/// Parse DHCP message type from option 53 value
fn parse_message_type(value: u8) -> DhcpMessageType {
match value {
1 => DhcpMessageType::Discover,
2 => DhcpMessageType::Offer,
3 => DhcpMessageType::Request,
4 => DhcpMessageType::Decline,
5 => DhcpMessageType::Ack,
6 => DhcpMessageType::Nak,
7 => DhcpMessageType::Release,
8 => DhcpMessageType::Inform,
other => DhcpMessageType::Unknown(other),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn build_dhcp_packet(msg_type: u8, hostname: Option<&str>, mac: &[u8; 6]) -> Vec<u8> {
let mut packet = vec![0u8; 240];
// Set client MAC at offset 28
packet[28..34].copy_from_slice(mac);
// Set magic cookie at offset 236
packet[236..240].copy_from_slice(&DHCP_MAGIC_COOKIE);
// Add options
// Option 53: DHCP Message Type
packet.push(DHCP_OPT_MESSAGE_TYPE);
packet.push(1); // length
packet.push(msg_type);
// Option 12: Hostname (if provided)
if let Some(name) = hostname {
packet.push(DHCP_OPT_HOSTNAME);
packet.push(name.len() as u8);
packet.extend_from_slice(name.as_bytes());
}
// Option 255: End
packet.push(DHCP_OPT_END);
packet
}
#[test]
fn test_dhcp_discover() {
let mac = [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff];
let packet = build_dhcp_packet(1, Some("myhost"), &mac);
let info = analyze_dhcp(&packet).expect("should parse");
assert_eq!(info.message_type, DhcpMessageType::Discover);
assert_eq!(info.hostname, Some("myhost".to_string()));
assert_eq!(info.client_mac, Some("aa:bb:cc:dd:ee:ff".to_string()));
}
#[test]
fn test_dhcp_offer() {
let mac = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66];
let packet = build_dhcp_packet(2, None, &mac);
let info = analyze_dhcp(&packet).expect("should parse");
assert_eq!(info.message_type, DhcpMessageType::Offer);
assert!(info.hostname.is_none());
}
#[test]
fn test_dhcp_request() {
let mac = [0x00, 0x11, 0x22, 0x33, 0x44, 0x55];
let packet = build_dhcp_packet(3, Some("workstation-01"), &mac);
let info = analyze_dhcp(&packet).expect("should parse");
assert_eq!(info.message_type, DhcpMessageType::Request);
assert_eq!(info.hostname, Some("workstation-01".to_string()));
}
#[test]
fn test_dhcp_ack() {
let mac = [0x00, 0x00, 0x00, 0x00, 0x00, 0x01];
let packet = build_dhcp_packet(5, None, &mac);
let info = analyze_dhcp(&packet).expect("should parse");
assert_eq!(info.message_type, DhcpMessageType::Ack);
}
#[test]
fn test_dhcp_too_short() {
let packet = vec![0u8; 100];
assert!(analyze_dhcp(&packet).is_none());
}
#[test]
fn test_dhcp_bad_magic_cookie() {
let mut packet = vec![0u8; 250];
packet[236..240].copy_from_slice(&[0x00, 0x00, 0x00, 0x00]); // Wrong cookie
assert!(analyze_dhcp(&packet).is_none());
}
#[test]
fn test_dhcp_release() {
let mac = [0xde, 0xad, 0xbe, 0xef, 0x00, 0x01];
let packet = build_dhcp_packet(7, None, &mac);
let info = analyze_dhcp(&packet).expect("should parse");
assert_eq!(info.message_type, DhcpMessageType::Release);
}
#[test]
fn test_dhcp_inform() {
let mac = [0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc];
let packet = build_dhcp_packet(8, Some("printer"), &mac);
let info = analyze_dhcp(&packet).expect("should parse");
assert_eq!(info.message_type, DhcpMessageType::Inform);
assert_eq!(info.hostname, Some("printer".to_string()));
}
}
+661
View File
@@ -0,0 +1,661 @@
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use crate::network::types::{DnsInfo, DnsQueryType};
/// Maximum DNS name length per RFC 1035 section 2.3.4
const MAX_DNS_NAME_LEN: usize = 253;
/// Cap on how many answer records we will walk for a single packet. Real
/// resolver answers are well under this; the bound is here to keep a
/// malformed `ancount` from spinning the parser.
const MAX_ANSWERS_TO_PARSE: usize = 64;
/// Cap on response IPs we surface per packet. The UI only renders a short
/// list anyway, and the merge layer dedups across the flow, so anything
/// beyond this is noise we'd rather drop than allocate for.
const MAX_RESPONSE_IPS_PER_PACKET: usize = 16;
/// Cap on how many pointer indirections we follow while skipping a name in
/// the answer section. Per RFC 1035 these must not form cycles; this cap
/// keeps a crafted packet from looping forever.
const MAX_NAME_POINTER_HOPS: usize = 16;
/// Walk a DNS name in the answer section and return the offset of the
/// byte immediately after the name (where TYPE / CLASS / TTL / RDLENGTH
/// start). Compression pointers (0xC0-prefixed two-byte sequences) terminate
/// the name in-place, so the returned offset is two past the start of the
/// pointer. Returns `None` if the name is malformed or runs off the end of
/// the payload.
fn skip_dns_name(payload: &[u8], start: usize) -> Option<usize> {
let mut offset = start;
let mut hops = 0;
loop {
if offset >= payload.len() {
return None;
}
let label_len = payload[offset] as usize;
if label_len == 0 {
return Some(offset + 1);
}
if label_len & 0xC0 == 0xC0 {
// Pointer: two bytes total, name ends here at the call site.
if offset + 1 >= payload.len() {
return None;
}
hops += 1;
if hops > MAX_NAME_POINTER_HOPS {
return None;
}
return Some(offset + 2);
}
// Reject reserved length-octet top bits (0x40 / 0x80) — neither
// standard label nor pointer.
if label_len & 0xC0 != 0 {
return None;
}
let next = offset.checked_add(1)?.checked_add(label_len)?;
if next > payload.len() {
return None;
}
offset = next;
}
}
/// Parse a single question section and return `(query_name, query_type,
/// offset_after_question)`. The offset advances past QNAME + QTYPE (2) +
/// QCLASS (2); if QTYPE / QCLASS run off the end the offset still moves to
/// keep skip-only callers (qdcount > 1) bounds-safe.
fn parse_question(payload: &[u8], start: usize) -> (Option<String>, Option<DnsQueryType>, usize) {
let mut offset = start;
let mut name = String::new();
let mut name_over_limit = false;
// Parse domain name (label-by-label, with light pointer handling — we
// only need to terminate the walk, not fully resolve compressed labels).
while offset < payload.len() {
let label_len = payload[offset] as usize;
if label_len == 0 {
offset += 1;
break;
}
if label_len & 0xC0 == 0xC0 {
// Compressed name — skip for simplicity.
offset += 2;
break;
}
// Reject reserved length-octet top bits (0x40 / 0x80) — neither a
// standard label nor a pointer (RFC 1035 §3.3). Stop the walk so the
// invalid bytes are not pulled into the name, matching skip_dns_name.
if label_len & 0xC0 != 0 {
break;
}
if offset + 1 + label_len > payload.len() {
break;
}
if !name_over_limit {
if !name.is_empty() {
name.push('.');
}
if let Ok(label) = std::str::from_utf8(&payload[offset + 1..offset + 1 + label_len]) {
name.push_str(label);
}
// Enforce RFC 1035 maximum name length: stop accumulating, but
// keep walking the remaining labels so `offset` ends up past the
// whole QNAME — otherwise QTYPE/QCLASS would be read from name
// bytes and report a fabricated query type.
if name.len() > MAX_DNS_NAME_LEN {
name_over_limit = true;
}
}
offset += 1 + label_len;
}
let query_name = if name.is_empty() { None } else { Some(name) };
let mut query_type = None;
if offset + 2 <= payload.len() {
let qtype = u16::from_be_bytes([payload[offset], payload[offset + 1]]);
query_type = Some(match qtype {
1 => DnsQueryType::A,
2 => DnsQueryType::NS,
5 => DnsQueryType::CNAME,
6 => DnsQueryType::SOA,
12 => DnsQueryType::PTR,
13 => DnsQueryType::HINFO,
15 => DnsQueryType::MX,
16 => DnsQueryType::TXT,
17 => DnsQueryType::RP,
18 => DnsQueryType::AFSDB,
24 => DnsQueryType::SIG,
25 => DnsQueryType::KEY,
28 => DnsQueryType::AAAA,
29 => DnsQueryType::LOC,
33 => DnsQueryType::SRV,
35 => DnsQueryType::NAPTR,
36 => DnsQueryType::KX,
37 => DnsQueryType::CERT,
39 => DnsQueryType::DNAME,
42 => DnsQueryType::APL,
43 => DnsQueryType::DS,
44 => DnsQueryType::SSHFP,
45 => DnsQueryType::IPSECKEY,
46 => DnsQueryType::RRSIG,
47 => DnsQueryType::NSEC,
48 => DnsQueryType::DNSKEY,
49 => DnsQueryType::DHCID,
50 => DnsQueryType::NSEC3,
51 => DnsQueryType::NSEC3PARAM,
52 => DnsQueryType::TLSA,
53 => DnsQueryType::SMIMEA,
55 => DnsQueryType::HIP,
59 => DnsQueryType::CDS,
60 => DnsQueryType::CDNSKEY,
61 => DnsQueryType::OPENPGPKEY,
62 => DnsQueryType::CSYNC,
63 => DnsQueryType::ZONEMD,
64 => DnsQueryType::SVCB,
65 => DnsQueryType::HTTPS,
108 => DnsQueryType::EUI48,
109 => DnsQueryType::EUI64,
249 => DnsQueryType::TKEY,
250 => DnsQueryType::TSIG,
256 => DnsQueryType::URI,
257 => DnsQueryType::CAA,
32768 => DnsQueryType::TA,
32769 => DnsQueryType::DLV,
other => DnsQueryType::Other(other),
});
}
// Advance past QTYPE (2) and QCLASS (2). If they run past the payload,
// downstream walks' bounds checks will short-circuit cleanly.
offset = offset.saturating_add(4);
(query_name, query_type, offset)
}
/// Walk `count` resource records starting at `offset`, pushing A / AAAA
/// rdata into `ips` (subject to [`MAX_RESPONSE_IPS_PER_PACKET`]). Returns
/// the offset of the byte immediately after the last record successfully
/// walked, so callers can chain a second walk (e.g. ANCOUNT then ARCOUNT
/// for mDNS).
fn walk_a_aaaa_records(payload: &[u8], start: usize, count: usize, ips: &mut Vec<IpAddr>) -> usize {
let mut offset = start;
let count = count.min(MAX_ANSWERS_TO_PARSE);
for _ in 0..count {
let after_name = match skip_dns_name(payload, offset) {
Some(o) => o,
None => return offset,
};
// Fixed-size RR fields: TYPE (2) + CLASS (2) + TTL (4) + RDLENGTH (2) = 10.
if after_name
.checked_add(10)
.map(|e| e > payload.len())
.unwrap_or(true)
{
return offset;
}
let atype = u16::from_be_bytes([payload[after_name], payload[after_name + 1]]);
let rdlength =
u16::from_be_bytes([payload[after_name + 8], payload[after_name + 9]]) as usize;
let rdata_start = after_name + 10;
let rdata_end = match rdata_start.checked_add(rdlength) {
Some(e) if e <= payload.len() => e,
_ => return offset,
};
if ips.len() < MAX_RESPONSE_IPS_PER_PACKET {
match (atype, rdlength) {
(1, 4) => {
let octets: [u8; 4] = payload[rdata_start..rdata_end]
.try_into()
.expect("rdlength==4 and bounds checked above");
ips.push(IpAddr::V4(Ipv4Addr::from(octets)));
}
(28, 16) => {
let octets: [u8; 16] = payload[rdata_start..rdata_end]
.try_into()
.expect("rdlength==16 and bounds checked above");
ips.push(IpAddr::V6(Ipv6Addr::from(octets)));
}
_ => {
// CNAME, NS, SOA, PTR, SRV, TXT, … — not surfaced.
}
}
}
offset = rdata_end;
}
offset
}
/// Parsed DNS-shaped header counts. Surfaced as a thin internal helper so
/// the mDNS / LLMNR wrappers can also reach ARCOUNT without re-decoding.
pub(super) struct DnsHeaderCounts {
pub is_response: bool,
pub qdcount: u16,
pub ancount: u16,
pub arcount: u16,
}
/// Decode the four 16-bit counts at the start of a DNS-shaped header.
pub(super) fn dns_header_counts(payload: &[u8]) -> Option<DnsHeaderCounts> {
if payload.len() < 12 {
return None;
}
let flags = u16::from_be_bytes([payload[2], payload[3]]);
Some(DnsHeaderCounts {
is_response: (flags & 0x8000) != 0,
qdcount: u16::from_be_bytes([payload[4], payload[5]]),
ancount: u16::from_be_bytes([payload[6], payload[7]]),
arcount: u16::from_be_bytes([payload[10], payload[11]]),
})
}
/// Walk `qdcount` question sections starting at offset 12 and return the
/// `(query_name, query_type, offset_after_all_questions)` triple. The name
/// and type are taken from the **first** question (matching prior behaviour
/// and the DNS / mDNS / LLMNR convention of one question per packet);
/// subsequent questions are skipped only so the answer-walk starts at the
/// correct offset (#333: multi-question packets used to leave the offset
/// misaligned, which could surface bogus IPs from the answer walk).
pub(super) fn parse_questions_starting_at_header(
payload: &[u8],
qdcount: u16,
) -> (Option<String>, Option<DnsQueryType>, usize) {
let mut offset = 12;
let mut query_name = None;
let mut query_type = None;
for i in 0..qdcount {
let (name, qtype, next) = parse_question(payload, offset);
if i == 0 {
query_name = name;
query_type = qtype;
}
offset = next;
if offset > payload.len() {
break;
}
}
(query_name, query_type, offset)
}
pub fn analyze_dns(payload: &[u8]) -> Option<DnsInfo> {
let header = dns_header_counts(payload)?;
let (query_name, query_type, mut offset) =
parse_questions_starting_at_header(payload, header.qdcount);
let mut info = DnsInfo {
query_name,
query_type,
response_ips: Vec::new(),
is_response: header.is_response,
};
// Answer-section walk for A / AAAA records. Only runs on responses
// (QR bit set), since `DnsInfo.response_ips` is only meaningful for
// resolver answers.
if header.is_response {
offset = walk_a_aaaa_records(
payload,
offset,
header.ancount as usize,
&mut info.response_ips,
);
// `offset` is now positioned for callers that want to keep walking
// (e.g. mDNS's additional-records pass — see `analyze_dns_for_mdns`).
let _ = offset;
}
Some(info)
}
/// mDNS-specific variant of [`analyze_dns`]: like the DNS path but also
/// walks the **additional records** (ARCOUNT) and tolerates packets with
/// `qdcount == 0` (RFC 6762 §6 — typical mDNS announcements carry only
/// answers / additionals, no questions). The DNS / LLMNR path keeps the
/// stricter behaviour because their responses always echo the question.
pub(super) fn analyze_dns_for_mdns(payload: &[u8]) -> Option<DnsInfo> {
let header = dns_header_counts(payload)?;
let (query_name, query_type, mut offset) =
parse_questions_starting_at_header(payload, header.qdcount);
let mut info = DnsInfo {
query_name,
query_type,
response_ips: Vec::new(),
is_response: header.is_response,
};
if header.is_response {
offset = walk_a_aaaa_records(
payload,
offset,
header.ancount as usize,
&mut info.response_ips,
);
// RFC 6762: mDNS responses frequently place A / AAAA in the
// ADDITIONAL section (NSEC / negative responses, "known-answer
// suppression"-related additionals, etc.). Skip NSCOUNT (the
// authority section) records before reaching ADDITIONAL.
if let Some(after_authority) = skip_records(payload, offset, header_nscount(payload)) {
let _ = walk_a_aaaa_records(
payload,
after_authority,
header.arcount as usize,
&mut info.response_ips,
);
}
}
Some(info)
}
/// Decode the NSCOUNT (authority-records count) at bytes 8-9 of the header.
fn header_nscount(payload: &[u8]) -> u16 {
if payload.len() < 12 {
return 0;
}
u16::from_be_bytes([payload[8], payload[9]])
}
/// Skip `count` resource records starting at `offset` without collecting
/// rdata. Returns `None` on a malformed record so callers can stop chasing
/// trailing sections (e.g. don't walk ADDITIONAL if AUTHORITY is busted).
fn skip_records(payload: &[u8], start: usize, count: u16) -> Option<usize> {
let mut offset = start;
let count = (count as usize).min(MAX_ANSWERS_TO_PARSE);
for _ in 0..count {
let after_name = skip_dns_name(payload, offset)?;
if after_name.checked_add(10)? > payload.len() {
return None;
}
let rdlength =
u16::from_be_bytes([payload[after_name + 8], payload[after_name + 9]]) as usize;
let rdata_end = after_name.checked_add(10)?.checked_add(rdlength)?;
if rdata_end > payload.len() {
return None;
}
offset = rdata_end;
}
Some(offset)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_payload_safe() {
assert!(analyze_dns(&[]).is_none());
}
#[test]
fn test_short_payload_safe() {
assert!(analyze_dns(&[0; 5]).is_none());
}
#[test]
fn test_dns_name_length_limit() {
// Build a DNS packet with many 63-byte labels (exceeding 253 chars)
let mut payload = vec![0u8; 12]; // DNS header
// Set qdcount = 1
payload[5] = 1;
// Add 10 labels of 63 bytes each (630+ chars total, exceeds 253)
for _ in 0..10 {
payload.push(63); // label length
payload.extend_from_slice(&[b'a'; 63]);
}
payload.push(0); // null terminator
payload.extend_from_slice(&[0, 1, 0, 1]); // QTYPE A, QCLASS IN
let info = analyze_dns(&payload).unwrap();
if let Some(name) = &info.query_name {
// Name should be truncated near the RFC limit, not the full 630+ chars
assert!(name.len() <= MAX_DNS_NAME_LEN + 63 + 1);
}
// The walk must still consume the whole QNAME so QTYPE is read from
// the right offset — not fabricated from mid-name bytes.
assert_eq!(info.query_type, Some(DnsQueryType::A));
}
#[test]
fn test_normal_dns_query() {
// Build a simple query for "example.com"
let mut payload = vec![0u8; 12]; // DNS header
payload[5] = 1; // qdcount = 1
// "example" label
payload.push(7);
payload.extend_from_slice(b"example");
// "com" label
payload.push(3);
payload.extend_from_slice(b"com");
// null terminator
payload.push(0);
// QTYPE A (1), QCLASS IN (1)
payload.extend_from_slice(&[0, 1, 0, 1]);
let info = analyze_dns(&payload).unwrap();
assert_eq!(info.query_name, Some("example.com".to_string()));
assert_eq!(info.query_type, Some(DnsQueryType::A));
assert!(!info.is_response);
}
#[test]
fn test_question_rejects_reserved_label_bits() {
// A label octet whose top two bits are 01 (0x40) or 10 (0x80) is neither
// a standard label nor a compression pointer (RFC 1035 §3.3). The name
// walk must stop at it instead of reading it as a 64+ byte label and
// pulling the following bytes into query_name. skip_dns_name already
// rejects these; parse_question must be consistent.
let mut payload = vec![0u8; 12];
payload[5] = 1; // qdcount = 1
// valid "abc" label
payload.push(3);
payload.extend_from_slice(b"abc");
// reserved-bit label octet (0x40) followed by 64 bytes that must not be
// absorbed into the name
payload.push(0x40);
payload.extend_from_slice(&[b'x'; 64]);
// null terminator + QTYPE A / QCLASS IN
payload.push(0);
payload.extend_from_slice(&[0, 1, 0, 1]);
let info = analyze_dns(&payload).unwrap();
// Only the valid label before the reserved octet is kept.
assert_eq!(info.query_name, Some("abc".to_string()));
}
/// Helper: build a baseline question-section payload for `example.com / A`.
/// Returns the payload and the offset that points at the byte right after
/// the question section, where answer records start.
fn make_example_question(qr_bit: bool, ancount: u16) -> (Vec<u8>, usize) {
let mut payload = vec![0u8; 12];
// Flags: QR bit when this is a response
if qr_bit {
payload[2] = 0x80;
}
// qdcount = 1
payload[4] = 0;
payload[5] = 1;
// ancount
payload[6..8].copy_from_slice(&ancount.to_be_bytes());
// "example"
payload.push(7);
payload.extend_from_slice(b"example");
// "com"
payload.push(3);
payload.extend_from_slice(b"com");
// null terminator
payload.push(0);
// QTYPE A (1), QCLASS IN (1)
payload.extend_from_slice(&[0, 1, 0, 1]);
let answers_start = payload.len();
(payload, answers_start)
}
#[test]
fn test_response_with_single_a_record_populates_ip() {
// Response with one A record pointing to 93.184.216.34 (the public
// example.com address). The answer name uses a compression pointer
// back to the question, which is the common wire shape.
let (mut payload, _answers_start) = make_example_question(true, 1);
// NAME: pointer to offset 12 (start of question name).
payload.extend_from_slice(&[0xC0, 0x0C]);
// TYPE A, CLASS IN, TTL 60, RDLENGTH 4
payload.extend_from_slice(&[0, 1, 0, 1, 0, 0, 0, 60, 0, 4]);
// RDATA: 93.184.216.34
payload.extend_from_slice(&[93, 184, 216, 34]);
let info = analyze_dns(&payload).unwrap();
assert!(info.is_response);
assert_eq!(
info.response_ips,
vec![IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34))]
);
}
#[test]
fn test_response_with_aaaa_record_populates_ipv6() {
let (mut payload, _) = make_example_question(true, 1);
payload.extend_from_slice(&[0xC0, 0x0C]); // pointer to question name
// TYPE AAAA (28), CLASS IN, TTL 60, RDLENGTH 16
payload.extend_from_slice(&[0, 28, 0, 1, 0, 0, 0, 60, 0, 16]);
payload.extend_from_slice(&[
0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x01,
]);
let info = analyze_dns(&payload).unwrap();
assert_eq!(info.response_ips.len(), 1);
assert!(matches!(info.response_ips[0], IpAddr::V6(_)));
}
#[test]
fn test_response_mixed_records_collects_a_and_aaaa_skips_cname() {
// Three answers: CNAME (skipped — not surfaced via response_ips),
// A, AAAA. Order matters; the parser must walk past CNAME's
// rdata correctly to reach the IP records.
let (mut payload, _) = make_example_question(true, 3);
// 1) CNAME record. RDATA = pointer to "example.com" at offset 12 (2 bytes).
payload.extend_from_slice(&[0xC0, 0x0C]); // NAME
payload.extend_from_slice(&[0, 5, 0, 1, 0, 0, 0, 60, 0, 2]); // TYPE CNAME (5), CLASS IN, TTL, RDLENGTH 2
payload.extend_from_slice(&[0xC0, 0x0C]); // RDATA: compressed name pointer
// 2) A record 1.2.3.4
payload.extend_from_slice(&[0xC0, 0x0C]);
payload.extend_from_slice(&[0, 1, 0, 1, 0, 0, 0, 60, 0, 4]);
payload.extend_from_slice(&[1, 2, 3, 4]);
// 3) AAAA record ::1
payload.extend_from_slice(&[0xC0, 0x0C]);
payload.extend_from_slice(&[0, 28, 0, 1, 0, 0, 0, 60, 0, 16]);
payload.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]);
let info = analyze_dns(&payload).unwrap();
assert_eq!(
info.response_ips,
vec![
IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)),
IpAddr::V6(Ipv6Addr::LOCALHOST),
]
);
}
#[test]
fn test_query_packet_leaves_response_ips_empty() {
// QR bit clear ⇒ this is a question, not a response. Even if a
// (malformed) packet stuffs answer-shaped bytes after the question,
// the parser must not surface any IPs because the field is only
// meaningful for resolver answers.
let (mut payload, _) = make_example_question(false, 1);
payload.extend_from_slice(&[0xC0, 0x0C]);
payload.extend_from_slice(&[0, 1, 0, 1, 0, 0, 0, 60, 0, 4]);
payload.extend_from_slice(&[8, 8, 8, 8]);
let info = analyze_dns(&payload).unwrap();
assert!(!info.is_response);
assert!(info.response_ips.is_empty());
}
#[test]
fn test_truncated_rdata_does_not_panic() {
// Claims RDLENGTH 4 but only supplies 2 bytes of rdata. The walk
// must stop cleanly, not panic on the slice access.
let (mut payload, _) = make_example_question(true, 1);
payload.extend_from_slice(&[0xC0, 0x0C]);
payload.extend_from_slice(&[0, 1, 0, 1, 0, 0, 0, 60, 0, 4]);
payload.extend_from_slice(&[1, 2]); // only 2 bytes of rdata
let info = analyze_dns(&payload).unwrap();
assert!(info.response_ips.is_empty());
}
#[test]
fn test_multi_question_offset_lands_on_first_answer() {
// Two questions, one answer (A 1.2.3.4) for question 1.
// Pre-#333 only the first question was skipped, so the answer-walk
// offset would land inside the second question's bytes — usually
// either bailing out via skip_dns_name or, worse, surfacing bogus
// IPs. With multi-question skipping the parser must land on the
// real answer and return exactly one IP.
let mut payload = vec![0u8; 12];
payload[2] = 0x80; // QR bit
payload[5] = 2; // qdcount = 2
payload[7] = 1; // ancount = 1
// Q1: "example.com" / A
payload.push(7);
payload.extend_from_slice(b"example");
payload.push(3);
payload.extend_from_slice(b"com");
payload.push(0);
payload.extend_from_slice(&[0, 1, 0, 1]);
// Q2: "test.net" / AAAA
payload.push(4);
payload.extend_from_slice(b"test");
payload.push(3);
payload.extend_from_slice(b"net");
payload.push(0);
payload.extend_from_slice(&[0, 28, 0, 1]);
// Answer: pointer to Q1 name, A, IN, TTL 60, RDLENGTH 4, 1.2.3.4.
payload.extend_from_slice(&[0xC0, 0x0C]);
payload.extend_from_slice(&[0, 1, 0, 1, 0, 0, 0, 60, 0, 4]);
payload.extend_from_slice(&[1, 2, 3, 4]);
let info = analyze_dns(&payload).unwrap();
// First question wins for query_name / query_type — matches the
// single-question convention. The two-question hardening is purely
// about answer-walk offset correctness.
assert_eq!(info.query_name, Some("example.com".to_string()));
assert_eq!(info.query_type, Some(DnsQueryType::A));
assert_eq!(
info.response_ips,
vec![IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))]
);
}
#[test]
fn test_response_ips_capped_per_packet() {
// Build a response with more A records than the per-packet cap.
// The parser must surface at most MAX_RESPONSE_IPS_PER_PACKET IPs
// even when ancount and the wire payload are both larger.
let n: u16 = (MAX_RESPONSE_IPS_PER_PACKET as u16) + 4;
let (mut payload, _) = make_example_question(true, n);
for i in 0..n {
payload.extend_from_slice(&[0xC0, 0x0C]);
payload.extend_from_slice(&[0, 1, 0, 1, 0, 0, 0, 60, 0, 4]);
payload.extend_from_slice(&[10, 0, 0, (i & 0xFF) as u8]);
}
let info = analyze_dns(&payload).unwrap();
assert_eq!(info.response_ips.len(), MAX_RESPONSE_IPS_PER_PACKET);
}
}
+367
View File
@@ -0,0 +1,367 @@
//! FTP (File Transfer Protocol) Deep Packet Inspection
//!
//! Parses the plaintext FTP control channel (RFC 959, RFC 2389, RFC 2428).
//! Detection is keyed off port 21 plus a cheap start-line signature so non-
//! standard ports are still caught. The data channel (port 20 / passive) is
//! deliberately not inspected — payloads are arbitrary file bytes.
use crate::network::types::{FtpInfo, FtpMessageType};
/// Maximum bytes we ever scan to decide whether a payload looks like FTP.
const MAX_SNIFF_BYTES: usize = 1024;
/// Commands defined by RFC 959 / 2389 / 2428 / 4217. Matched case-insensitively
/// against the first whitespace-delimited token on the first line of the
/// payload.
const FTP_COMMANDS: &[&str] = &[
// RFC 959 access control
"USER", "PASS", "ACCT", "CWD", "CDUP", "SMNT", "QUIT", "REIN",
// RFC 959 transfer parameters
"PORT", "PASV", "TYPE", "STRU", "MODE", // RFC 959 service
"RETR", "STOR", "STOU", "APPE", "ALLO", "REST", "RNFR", "RNTO", "ABOR", "DELE", "RMD", "MKD",
"PWD", "LIST", "NLST", "SITE", "SYST", "STAT", "HELP", "NOOP",
// RFC 2389 (FEAT/OPTS)
"FEAT", "OPTS", // RFC 2428 (extended passive / port for IPv6)
"EPSV", "EPRT", // RFC 4217 (FTP over TLS)
"AUTH", "PBSZ", "PROT", "CCC", // RFC 3659 (size/mdtm/mlsd)
"SIZE", "MDTM", "MLSD", "MLST",
];
/// Commands accepted by the port-independent signature check. This is the
/// subset of [`FTP_COMMANDS`] that no other common line-based protocol
/// shares: SMTP claims `AUTH`/`QUIT`/`NOOP`/`HELP`, POP3 claims
/// `USER`/`PASS`/`STAT`/`LIST`/`RETR`/`DELE`, and NNTP claims `MODE`, so
/// matching those off port 21 would label mail and news flows as FTP.
const FTP_DISTINCTIVE_COMMANDS: &[&str] = &[
"ACCT", "CWD", "CDUP", "SMNT", "REIN", "PORT", "PASV", "TYPE", "STRU", "STOR", "STOU", "APPE",
"ALLO", "REST", "RNFR", "RNTO", "ABOR", "RMD", "MKD", "PWD", "NLST", "SITE", "SYST", "FEAT",
"OPTS", "EPSV", "EPRT", "PBSZ", "PROT", "CCC", "SIZE", "MDTM", "MLSD", "MLST",
];
/// Cheap heuristic that returns `true` when a payload's first line plausibly
/// belongs to the FTP control channel. Used so non-standard-port flows can
/// still be classified.
///
/// Only distinctively-FTP commands count as a signature. Server replies
/// (`220 <banner>`) are deliberately NOT matched here: the 3-digit reply
/// grammar is shared verbatim by SMTP, POP3-era protocols, and NNTP, so
/// off port 21 a reply line carries no FTP signal. Replies are still parsed
/// by [`analyze_ftp`] on the port-21 path.
pub fn is_ftp(payload: &[u8]) -> bool {
let line = first_line(payload);
if line.is_empty() {
return false;
}
let upper = first_token_upper(line);
if upper.is_empty() {
return false;
}
FTP_DISTINCTIVE_COMMANDS
.iter()
.any(|cmd| cmd.as_bytes() == upper)
}
/// Parse an FTP control-channel payload. Returns `None` when the payload does
/// not look like FTP.
pub fn analyze_ftp(payload: &[u8]) -> Option<FtpInfo> {
let line = first_line(payload);
if line.is_empty() {
return None;
}
// Server response branch: `CCC <text>` or `CCC-<text>` where CCC is a
// 3-digit reply code. A trailing `-` is the RFC 959 §4.2 continuation
// marker, signalling that the payload is multi-line.
if line.len() >= 4
&& line[0].is_ascii_digit()
&& line[1].is_ascii_digit()
&& line[2].is_ascii_digit()
&& (line[3] == b' ' || line[3] == b'-')
{
let code = std::str::from_utf8(&line[0..3]).ok()?.parse::<u16>().ok()?;
// RFC 959 §4.2: the first digit is 1-5 and the second 0-5. Anything
// else ("999 hi") is not an FTP reply.
if !(1..=5).contains(&(code / 100)) || (code / 10) % 10 > 5 {
return None;
}
let is_continuation = line[3] == b'-';
let message = std::str::from_utf8(&line[4..])
.ok()
.map(|s| s.trim().to_string());
// Software / system-type extraction is skipped on continuation lines
// (`220-Welcome to the FTP service.\r\n220 ProFTPD ...\r\n`) because
// the first line is human-greeting prose. vsftpd, ProFTPD, and
// Pure-FTPd all emit multi-line greetings by default, so honouring
// the continuation marker is critical — without it we tag
// `server_software = "Welcome"` on most real servers.
let server_software = if code == 220 && !is_continuation {
// RFC 959 §5.4 — service-ready greetings typically embed the
// FTP server software in the first whitespace-delimited token
// (`220 ProFTPD 1.3.7 ...`).
message.as_deref().map(extract_software_token)
} else {
None
};
// RFC 959 §4.2 — code 215 carries the system / OS name (`UNIX`,
// `Windows_NT`), NOT the FTP server software. Keeping the two
// separate avoids labelling "UNIX" under "Server Software" in the
// TUI.
let system_type = if code == 215 && !is_continuation {
message.as_deref().map(extract_software_token)
} else {
None
};
return Some(FtpInfo {
message_type: FtpMessageType::Response,
command: None,
args: None,
response_code: Some(code),
response_message: message,
username: None,
server_software,
system_type,
});
}
// Client request branch.
let upper = first_token_upper(line);
if upper.is_empty() {
return None;
}
let is_command = FTP_COMMANDS.iter().any(|cmd| cmd.as_bytes() == upper);
if !is_command {
return None;
}
// `first_token_upper` already proved every byte is ASCII alphabetic, so
// the UTF-8 validation inside `String::from_utf8` is a fast linear walk
// that always succeeds — but the `?` stays as a defensive guard against
// a future change that widens the accepted byte range. Taking the `Vec`
// by value avoids the redundant `.to_string()` copy that the previous
// `std::str::from_utf8(&upper).ok()?.to_string()` shape required.
let command = String::from_utf8(upper).ok()?;
// Trim leading command + whitespace to expose the argument.
let args = std::str::from_utf8(line)
.ok()
.map(|s| s.trim())
.and_then(|s| {
s.split_once(char::is_whitespace)
.map(|(_, rest)| rest.trim())
})
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
// RFC 959 §5.4: `USER` carries the login name, useful as a per-flow
// identity hint (plaintext anyway — FTP-AUTH/TLS encrypts later).
let username = if command == "USER" {
args.clone()
} else {
None
};
Some(FtpInfo {
message_type: FtpMessageType::Request,
command: Some(command),
args,
response_code: None,
response_message: None,
username,
server_software: None,
system_type: None,
})
}
fn first_line(payload: &[u8]) -> &[u8] {
let sniff = &payload[..payload.len().min(MAX_SNIFF_BYTES)];
match sniff.iter().position(|&b| b == b'\n') {
Some(end) => {
// Strip trailing CR if present.
if end > 0 && sniff[end - 1] == b'\r' {
&sniff[..end - 1]
} else {
&sniff[..end]
}
}
None => sniff,
}
}
fn first_token_upper(line: &[u8]) -> Vec<u8> {
let token_end = line
.iter()
.position(|&b| b == b' ' || b == b'\t' || b == b'\r')
.unwrap_or(line.len());
let token = &line[..token_end];
if token.is_empty() || token.len() > 6 {
// FTP commands are 3-4 letters; cap at 6 to keep the cost of
// upper-casing tight on hostile traffic.
return Vec::new();
}
if !token.iter().all(|b| b.is_ascii_alphabetic()) {
return Vec::new();
}
token.iter().map(|b| b.to_ascii_uppercase()).collect()
}
fn extract_software_token(message: &str) -> String {
// The greeting line is free-form. Heuristic: take the first whitespace-
// delimited token that contains a letter, strip surrounding punctuation.
// Falls back to the full message when no clean token is found.
for token in message.split_whitespace() {
let trimmed = token.trim_matches(|c: char| !c.is_ascii_alphanumeric());
if trimmed.chars().any(|c| c.is_ascii_alphabetic()) {
return trimmed.to_string();
}
}
message.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_server_greeting() {
// Replies are parsed on the port-21 path but are NOT a signature:
// the 3-digit grammar is shared with SMTP/POP3/NNTP.
let payload = b"220 ProFTPD 1.3.7 Server (Example) [::ffff:10.0.0.1]\r\n";
assert!(!is_ftp(payload));
let info = analyze_ftp(payload).expect("should parse");
assert!(matches!(info.message_type, FtpMessageType::Response));
assert_eq!(info.response_code, Some(220));
assert_eq!(info.server_software.as_deref(), Some("ProFTPD"));
}
#[test]
fn detects_continuation_response() {
let payload = b"220-Welcome to the FTP service.\r\n220 Ready.\r\n";
assert!(!is_ftp(payload));
let info = analyze_ftp(payload).expect("should parse");
assert_eq!(info.response_code, Some(220));
}
#[test]
fn signature_ignores_verbs_shared_with_mail_protocols() {
// SMTP traffic must not be classified as FTP off port 21.
assert!(!is_ftp(b"220 smtp.gmail.com ESMTP x1234\r\n"));
assert!(!is_ftp(b"EHLO mail.example.com\r\n"));
assert!(!is_ftp(b"AUTH LOGIN\r\n"));
// POP3 shares USER/PASS/RETR/LIST/DELE with FTP.
assert!(!is_ftp(b"USER alice\r\n"));
assert!(!is_ftp(b"RETR 1\r\n"));
// Distinctively-FTP commands still trigger the signature.
assert!(is_ftp(b"PASV\r\n"));
assert!(is_ftp(b"TYPE I\r\n"));
assert!(is_ftp(b"cwd /pub\r\n"));
}
#[test]
fn rejects_out_of_range_reply_codes() {
// RFC 959 reply codes are 1xx-5xx with second digit 0-5.
assert!(analyze_ftp(b"999 hello\r\n").is_none());
assert!(analyze_ftp(b"090 hello\r\n").is_none());
assert!(analyze_ftp(b"260-x\r\n").is_none());
}
#[test]
fn skips_software_extraction_on_220_continuation() {
// Default greeting on vsftpd / ProFTPD / Pure-FTPd is multi-line:
// the first line is `220-` continuation prose ("Welcome to..."),
// followed by `220 <software>` on a later line. We only see the
// first line at the DPI layer, so we must not pull "Welcome" out of
// it and label it as server software.
let payload = b"220-Welcome to the FTP service.\r\n220 ProFTPD 1.3.7\r\n";
let info = analyze_ftp(payload).expect("should parse");
assert_eq!(info.response_code, Some(220));
assert!(
info.server_software.is_none(),
"continuation lines must not populate server_software, got {:?}",
info.server_software
);
}
#[test]
fn parses_user_request() {
let payload = b"USER alice\r\n";
let info = analyze_ftp(payload).expect("should parse");
assert!(matches!(info.message_type, FtpMessageType::Request));
assert_eq!(info.command.as_deref(), Some("USER"));
assert_eq!(info.username.as_deref(), Some("alice"));
assert_eq!(info.args.as_deref(), Some("alice"));
}
#[test]
fn parses_retr_request() {
let payload = b"RETR /pub/file.iso\r\n";
let info = analyze_ftp(payload).expect("should parse");
assert_eq!(info.command.as_deref(), Some("RETR"));
assert_eq!(info.args.as_deref(), Some("/pub/file.iso"));
assert!(info.username.is_none());
}
#[test]
fn parses_noop_without_args() {
let payload = b"NOOP\r\n";
let info = analyze_ftp(payload).expect("should parse");
assert_eq!(info.command.as_deref(), Some("NOOP"));
assert!(info.args.is_none());
}
#[test]
fn parses_lowercase_command() {
let payload = b"quit\r\n";
let info = analyze_ftp(payload).expect("should parse");
assert_eq!(info.command.as_deref(), Some("QUIT"));
}
#[test]
fn parses_system_type_response() {
// RFC 959 §4.2: a `215` reply returns the OS / system type, NOT the
// FTP server software. Previously we tagged "UNIX" under
// `server_software`, which surfaced under the "Server Software"
// column in the TUI alongside greetings like "ProFTPD". They are
// different things; route 215 to a dedicated `system_type` field
// and confirm `server_software` stays unset for this reply.
let payload = b"215 UNIX Type: L8\r\n";
let info = analyze_ftp(payload).expect("should parse");
assert_eq!(info.response_code, Some(215));
assert!(
info.server_software.is_none(),
"215 must not populate server_software"
);
assert_eq!(info.system_type.as_deref(), Some("UNIX"));
}
#[test]
fn rejects_unknown_request() {
assert!(!is_ftp(b"FOOBAR baz\r\n"));
assert!(analyze_ftp(b"FOOBAR baz\r\n").is_none());
}
#[test]
fn rejects_http_payload() {
// OPTIONS is a real HTTP method; its argument must be a path, not a
// 3-digit token. The FTP detector should reject it.
let payload = b"GET /index.html HTTP/1.1\r\nHost: example.com\r\n\r\n";
assert!(!is_ftp(payload));
assert!(analyze_ftp(payload).is_none());
}
#[test]
fn rejects_short_response() {
assert!(!is_ftp(b"220"));
assert!(analyze_ftp(b"220").is_none());
}
#[test]
fn rejects_non_digit_prefix() {
// Looks like response prefix but first char is alphabetic.
assert!(!is_ftp(b"2X0 hello\r\n"));
}
#[test]
fn parses_epsv_extended_passive() {
let payload = b"EPSV\r\n";
let info = analyze_ftp(payload).expect("should parse");
assert_eq!(info.command.as_deref(), Some("EPSV"));
}
}
+218
View File
@@ -0,0 +1,218 @@
use crate::network::types::{HttpInfo, HttpVersion};
/// Analyze payload for HTTP protocol
pub fn analyze_http(payload: &[u8]) -> Option<HttpInfo> {
if !is_likely_http(payload) {
return None;
}
let mut info = HttpInfo {
version: HttpVersion::Http11,
method: None,
host: None,
path: None,
status_code: None,
user_agent: None,
};
// Safe string conversion for HTTP parsing
let text = String::from_utf8_lossy(payload);
// Drive the line iterator directly: the request/status line is the first
// element and headers are everything after it. Collecting into a `Vec<&str>`
// just to index `[0]` and `skip(1)` allocates one heap slice per parse.
let mut lines = text.lines();
let first_line = lines.next()?;
// Consume the first-line tokens lazily via iterator to avoid the
// `split_whitespace().collect::<Vec<_>>()` heap allocation. Requests
// have exactly 3 SP-delimited tokens; responses have 2 or 3 since the
// reason phrase is optional (RFC 9112 §4).
let mut tokens = first_line.split_whitespace();
let (tok0, tok1) = match (tokens.next(), tokens.next()) {
(Some(a), Some(b)) => (a, b),
_ => return None,
};
if first_line.starts_with("HTTP/") {
// Response line: HTTP/1.1 200 OK — the reason phrase may be empty,
// but the status code must be a 3-digit number.
info.version = parse_http_version(tok0)?;
info.status_code = Some(
tok1.parse::<u16>()
.ok()
.filter(|c| (100..=599).contains(c))?,
);
} else if is_http_method(tok0) {
// Request line: GET /path HTTP/1.1. The version token is required:
// SIP and RTSP share the same verbs ("OPTIONS sip:bob@example.com
// SIP/2.0"), so a method match alone is not HTTP.
info.version = parse_http_version(tokens.next()?)?;
info.method = Some(tok0.to_string());
info.path = Some(tok1.to_string());
} else {
return None; // Not valid HTTP
}
// Parse headers. HTTP field-names are case-insensitive (RFC 7230 §3.2),
// so compare in place with `eq_ignore_ascii_case` instead of allocating a
// lowercased copy of every header name just to match two ASCII literals.
for line in lines {
if line.is_empty() {
break; // End of headers
}
if let Some((key, value)) = line.split_once(':') {
let key = key.trim();
let value = value.trim();
if key.eq_ignore_ascii_case("host") {
info.host = Some(value.to_string());
} else if key.eq_ignore_ascii_case("user-agent") {
info.user_agent = Some(value.to_string());
}
}
}
Some(info)
}
/// Quick check if payload might be HTTP
fn is_likely_http(payload: &[u8]) -> bool {
if payload.len() < 4 {
return false;
}
// HTTP request methods
payload.starts_with(b"GET ") ||
payload.starts_with(b"POST ") ||
payload.starts_with(b"PUT ") ||
payload.starts_with(b"DELETE ") ||
payload.starts_with(b"HEAD ") ||
payload.starts_with(b"OPTIONS ") ||
payload.starts_with(b"CONNECT ") ||
payload.starts_with(b"TRACE ") ||
payload.starts_with(b"PATCH ") ||
// HTTP responses
payload.starts_with(b"HTTP/1.0 ") ||
payload.starts_with(b"HTTP/1.1 ") ||
payload.starts_with(b"HTTP/2 ")
}
fn is_http_method(s: &str) -> bool {
matches!(
s,
"GET" | "POST" | "PUT" | "DELETE" | "HEAD" | "OPTIONS" | "CONNECT" | "TRACE" | "PATCH"
)
}
fn parse_http_version(s: &str) -> Option<HttpVersion> {
match s {
"HTTP/1.0" => Some(HttpVersion::Http10),
"HTTP/1.1" => Some(HttpVersion::Http11),
"HTTP/2" => Some(HttpVersion::Http2),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_http_request() {
let payload = b"GET /index.html HTTP/1.1\r\nHost: example.com\r\n\r\n";
let info = analyze_http(payload).unwrap();
assert_eq!(info.method.as_deref(), Some("GET"));
assert_eq!(info.path.as_deref(), Some("/index.html"));
assert_eq!(info.host.as_deref(), Some("example.com"));
}
#[test]
fn test_http_response() {
let payload = b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n";
let info = analyze_http(payload).unwrap();
assert_eq!(info.status_code, Some(200));
assert!(info.method.is_none());
}
#[test]
fn test_http_start_line_token_extraction() {
// Lock the lazy-iterator token extraction: all three tokens from a
// well-formed request line must be parsed without a Vec allocation.
let req = b"POST /api/v1/upload HTTP/1.1\r\nHost: api.example.com\r\n\r\n";
let info = analyze_http(req).expect("POST request should parse");
assert_eq!(info.method.as_deref(), Some("POST"));
assert_eq!(info.path.as_deref(), Some("/api/v1/upload"));
assert_eq!(info.host.as_deref(), Some("api.example.com"));
// A truncated request line must be rejected: requests need all
// three tokens (method, target, version).
let truncated: [&[u8]; 2] = [
b"GET /index.html\r\n\r\n", // only 2 tokens
b"GET\r\n\r\n", // only 1 token
];
for payload in &truncated {
assert!(
analyze_http(payload).is_none(),
"truncated start line should not parse: {:?}",
std::str::from_utf8(payload).unwrap_or("<invalid utf8>")
);
}
}
#[test]
fn test_http_response_without_reason_phrase() {
// RFC 9112 §4: the reason phrase is optional. Real servers emit
// `HTTP/1.1 200 \r\n` (empty phrase) and some omit the trailing
// space entirely; both are valid responses.
for payload in [b"HTTP/1.1 200\r\n\r\n".as_slice(), b"HTTP/1.1 200 \r\n\r\n"] {
let info = analyze_http(payload).expect("reason phrase is optional");
assert_eq!(info.status_code, Some(200));
}
}
#[test]
fn test_non_http_text_protocols_rejected() {
// SIP and RTSP share request verbs with HTTP; the version token
// must be validated so they are not misclassified.
let sip = b"OPTIONS sip:bob@example.com SIP/2.0\r\nVia: SIP/2.0/TCP host\r\n\r\n";
assert!(analyze_http(sip).is_none());
let rtsp = b"OPTIONS rtsp://cam.local/stream RTSP/1.0\r\nCSeq: 1\r\n\r\n";
assert!(analyze_http(rtsp).is_none());
// A response-shaped line with a non-numeric status is not HTTP.
assert!(analyze_http(b"HTTP/1.1 OK 200\r\n\r\n").is_none());
}
#[test]
fn test_http_mixed_case_host_and_user_agent_headers() {
// HTTP/1.1 §3.2 makes field-names case-insensitive. Real-world traffic
// varies in capitalisation (`Host`, `HOST`, `host`, etc.) and the
// `eq_ignore_ascii_case` refactor relies on this invariant — lock it
// here so a future change that drops the case-insensitive compare
// fails this test instead of silently regressing.
let host_variants: [&[u8]; 4] = [
b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
b"GET / HTTP/1.1\r\nhost: example.com\r\n\r\n",
b"GET / HTTP/1.1\r\nHOST: example.com\r\n\r\n",
b"GET / HTTP/1.1\r\nhOsT: example.com\r\n\r\n",
];
for payload in &host_variants {
let info = analyze_http(payload).expect("should parse");
assert_eq!(info.host.as_deref(), Some("example.com"));
}
let ua_variants: [&[u8]; 4] = [
b"GET / HTTP/1.1\r\nUser-Agent: curl/8.5\r\n\r\n",
b"GET / HTTP/1.1\r\nuser-agent: curl/8.5\r\n\r\n",
b"GET / HTTP/1.1\r\nUSER-AGENT: curl/8.5\r\n\r\n",
b"GET / HTTP/1.1\r\nUser-AGENT: curl/8.5\r\n\r\n",
];
for payload in &ua_variants {
let info = analyze_http(payload).expect("should parse");
assert_eq!(info.user_agent.as_deref(), Some("curl/8.5"));
}
}
}
@@ -0,0 +1,483 @@
use crate::network::types::{HttpsInfo, TlsInfo, TlsVersion};
use log::debug;
/// Maximum TLS extensions to parse. Defense-in-depth against crafted packets
/// with many zero-length extensions designed to waste CPU.
const MAX_TLS_EXTENSIONS: usize = 100;
pub fn is_tls_handshake(payload: &[u8]) -> bool {
if payload.len() < 5 {
return false;
}
// TLS record header:
// - Content type (1 byte): 0x16 for handshake
// - Version (2 bytes): 0x0301-0x0304 for TLS 1.0-1.3
// - Length (2 bytes)
payload[0] == 0x16 && // Handshake content type
payload[1] == 0x03 && // Major version 3
(payload[2] >= 0x01 && payload[2] <= 0x04) // Minor version 1-4
}
pub fn analyze_https(payload: &[u8]) -> Option<HttpsInfo> {
// Need at least 5 bytes for the TLS record header
if payload.len() < 5 {
return None;
}
let mut info = TlsInfo::new();
// Check content type
let content_type = payload[0];
if content_type != 0x16 {
// Not a handshake record - still extract version
let record_version = version_from_bytes(payload[1], payload[2]);
info.version = record_version;
return Some(HttpsInfo {
tls_info: Some(info),
});
}
// Record layer version
let record_version = version_from_bytes(payload[1], payload[2]);
info.version = record_version;
// Get record length
let record_length = u16::from_be_bytes([payload[3], payload[4]]) as usize;
// Sanity check
if record_length > 16384 + 2048 {
return Some(HttpsInfo {
tls_info: Some(info),
});
}
// Calculate available data (handle fragmentation gracefully)
let available_data = (payload.len() - 5).min(record_length);
if available_data < 4 {
return Some(HttpsInfo {
tls_info: Some(info),
});
}
// Skip TLS record header (5 bytes)
let handshake_data = &payload[5..5 + available_data];
let handshake_type = handshake_data[0];
// Quick validation
if !matches!(handshake_type, 0x00..=0x18 | 0xfe) {
return Some(HttpsInfo {
tls_info: Some(info),
});
}
let handshake_length =
u32::from_be_bytes([0, handshake_data[1], handshake_data[2], handshake_data[3]]) as usize;
// Sanity check
if handshake_length > 16384 {
return Some(HttpsInfo {
tls_info: Some(info),
});
}
// Calculate how much handshake data we actually have
let handshake_available = (handshake_data.len() - 4).min(handshake_length);
if handshake_available == 0 {
return Some(HttpsInfo {
tls_info: Some(info),
});
}
match handshake_type {
0x01 => {
// Client Hello - this is where SNI and ALPN are
parse_client_hello(
&handshake_data[4..4 + handshake_available],
&mut info,
record_version,
);
}
0x02 => {
// Server Hello
parse_server_hello(&handshake_data[4..4 + handshake_available], &mut info);
}
_ => {
// Other handshake types we don't parse
}
}
if info.sni.is_some() || !info.alpn.is_empty() {
debug!("TLS: Found SNI={:?}, ALPN={:?}", info.sni, info.alpn);
}
Some(HttpsInfo {
tls_info: Some(info),
})
}
fn version_from_bytes(major: u8, minor: u8) -> Option<TlsVersion> {
match (major, minor) {
(0x03, 0x01) => Some(TlsVersion::Tls10),
(0x03, 0x02) => Some(TlsVersion::Tls11),
(0x03, 0x03) => Some(TlsVersion::Tls12),
(0x03, 0x04) => Some(TlsVersion::Tls13),
_ => None,
}
}
fn parse_client_hello(data: &[u8], info: &mut TlsInfo, record_version: Option<TlsVersion>) {
// Need at least 2 bytes for version
if data.len() < 2 {
return;
}
// Client version
let client_version = version_from_bytes(data[0], data[1]);
info.version = client_version.or(record_version);
// Need at least 34 bytes for version + random
if data.len() < 34 {
return;
}
// Skip random (32 bytes)
let mut offset = 34;
// Session ID - be lenient with bounds
if offset >= data.len() {
return;
}
let session_id_len = data[offset] as usize;
offset += 1 + session_id_len;
if offset >= data.len() {
return;
}
// Cipher suites
if offset + 2 > data.len() {
return;
}
let cipher_suites_len = u16::from_be_bytes([data[offset], data[offset + 1]]) as usize;
offset += 2 + cipher_suites_len;
if offset >= data.len() {
return;
}
// Compression methods
if offset >= data.len() {
return;
}
let compression_len = data[offset] as usize;
offset += 1 + compression_len;
if offset >= data.len() {
return;
}
// Extensions - this is what we really want
if offset + 2 > data.len() {
return;
}
let extensions_len = u16::from_be_bytes([data[offset], data[offset + 1]]) as usize;
offset += 2;
// Parse whatever extension data we have
if offset < data.len() {
let available_ext_data = &data[offset..data.len().min(offset + extensions_len)];
if !available_ext_data.is_empty() {
parse_extensions(available_ext_data, info, true);
}
}
}
fn parse_server_hello(data: &[u8], info: &mut TlsInfo) {
if data.len() < 2 {
return;
}
// Server version
let server_version = version_from_bytes(data[0], data[1]);
info.version = server_version;
if data.len() < 34 {
return;
}
// Skip random (32 bytes)
let mut offset = 34;
// Session ID length
if offset >= data.len() {
return;
}
let session_id_len = data[offset] as usize;
offset += 1 + session_id_len;
if offset >= data.len() {
return;
}
// Cipher suite (2 bytes)
if offset + 2 > data.len() {
return;
}
let cipher = u16::from_be_bytes([data[offset], data[offset + 1]]);
info.cipher_suite = Some(cipher);
offset += 2;
// Compression method (1 byte)
if offset >= data.len() {
return;
}
offset += 1;
// Extensions (optional)
if offset + 2 > data.len() {
return;
}
let extensions_len = u16::from_be_bytes([data[offset], data[offset + 1]]) as usize;
offset += 2;
// Parse whatever extension data we have
if offset < data.len() {
let available_ext_data = &data[offset..data.len().min(offset + extensions_len)];
if !available_ext_data.is_empty() {
parse_extensions(available_ext_data, info, false);
}
}
}
fn parse_extensions(data: &[u8], info: &mut TlsInfo, is_client_hello: bool) {
let mut offset = 0;
let mut extensions_parsed = 0;
while offset + 4 <= data.len() {
extensions_parsed += 1;
if extensions_parsed > MAX_TLS_EXTENSIONS {
break;
}
let ext_type = u16::from_be_bytes([data[offset], data[offset + 1]]);
let ext_len = u16::from_be_bytes([data[offset + 2], data[offset + 3]]) as usize;
// Calculate how much extension data we actually have
let available_ext_len = data.len().saturating_sub(offset + 4);
let ext_data_len = ext_len.min(available_ext_len);
if ext_data_len > 0 {
let ext_data = &data[offset + 4..offset + 4 + ext_data_len];
match ext_type {
0x0000 if is_client_hello => {
// SNI (Server Name Indication)
if let Some(sni) = parse_sni_extension_resilient(ext_data) {
info.sni = Some(sni);
}
}
0x0010 => {
// ALPN (Application-Layer Protocol Negotiation)
if let Some(alpn) = parse_alpn_extension_resilient(ext_data)
&& !alpn.is_empty()
{
info.alpn = alpn;
}
}
0x002b => {
// Supported Versions
if let Some(version) =
parse_supported_versions_resilient(ext_data, is_client_hello)
{
info.version = Some(version);
}
}
_ => {
// Skip unknown extensions
}
}
}
// Move to next extension (use declared length, not actual)
offset += 4 + ext_len;
// But stop if we've gone past available data
if offset > data.len() {
break;
}
}
}
fn parse_sni_extension_resilient(data: &[u8]) -> Option<String> {
if data.len() < 5 {
return None;
}
// Server name list length (2 bytes)
let _list_len = u16::from_be_bytes([data[0], data[1]]) as usize;
// Check name type (should be 0x00 for hostname)
if data[2] != 0x00 {
return None;
}
// Name length
let name_len = u16::from_be_bytes([data[3], data[4]]) as usize;
// Extract whatever hostname data we have
let available_len = data.len().saturating_sub(5);
let actual_len = name_len.min(available_len);
if actual_len > 0 {
let hostname_bytes = &data[5..5 + actual_len];
// Try to parse as UTF-8
if let Ok(hostname) = std::str::from_utf8(hostname_bytes) {
// Basic validation - at least check it looks like a hostname
if hostname.chars().all(|c| c.is_ascii_graphic() || c == '.') {
let result = if actual_len < name_len {
format!("{}[PARTIAL]", hostname)
} else {
hostname.to_string()
};
return Some(result);
}
}
}
None
}
fn parse_alpn_extension_resilient(data: &[u8]) -> Option<Vec<String>> {
if data.len() < 2 {
return None;
}
let mut protocols = Vec::new();
// ALPN list length
let alpn_len = u16::from_be_bytes([data[0], data[1]]) as usize;
let mut offset = 2;
let list_end = 2 + alpn_len.min(data.len() - 2);
while offset < list_end && offset < data.len() {
let proto_len = data[offset] as usize;
offset += 1;
let available_len = list_end
.saturating_sub(offset)
.min(data.len().saturating_sub(offset));
let actual_len = proto_len.min(available_len);
if actual_len > 0
&& let Ok(proto) = std::str::from_utf8(&data[offset..offset + actual_len])
{
if actual_len < proto_len {
protocols.push(format!("{}[PARTIAL]", proto));
} else {
protocols.push(proto.to_string());
}
}
offset += proto_len;
if offset >= data.len() {
break;
}
}
if protocols.is_empty() {
None
} else {
Some(protocols)
}
}
fn parse_supported_versions_resilient(data: &[u8], is_client_hello: bool) -> Option<TlsVersion> {
if is_client_hello {
// Client sends a list of supported versions
if data.is_empty() {
return None;
}
let list_len = data[0] as usize;
let mut offset = 1;
let mut best_version: Option<TlsVersion> = None;
while offset + 1 < data.len() && offset < 1 + list_len {
if let Some(version) = version_from_bytes(data[offset], data[offset + 1]) {
best_version = match (best_version, version) {
(None, v) => Some(v),
(Some(v1), v2) => {
// Simple comparison - return the higher version
if version_to_priority(v2) > version_to_priority(v1) {
Some(v2)
} else {
Some(v1)
}
}
};
}
offset += 2;
}
best_version
} else {
// Server sends a single selected version
if data.len() < 2 {
return None;
}
version_from_bytes(data[0], data[1])
}
}
fn version_to_priority(version: TlsVersion) -> u8 {
match version {
TlsVersion::Tls10 => 1,
TlsVersion::Tls11 => 2,
TlsVersion::Tls12 => 3,
TlsVersion::Tls13 => 4,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_partial_sni_extraction() {
// Simulate a truncated SNI extension
let partial_sni = vec![
0x00, 0x10, // List length: 16
0x00, // Name type: host_name
0x00, 0x0d, // Name length: 13
b'e', b'x', b'a', b'm', b'p', // Only 5 bytes of "example.com"
];
let result = parse_sni_extension_resilient(&partial_sni);
assert!(result.is_some());
let sni = result.unwrap();
assert!(sni.starts_with("examp"));
assert!(sni.contains("PARTIAL"));
}
#[test]
fn test_partial_alpn_extraction() {
// Simulate a truncated ALPN extension
let partial_alpn = vec![
0x00, 0x0e, // List length: 14
0x08, b'h', b't', b't', b'p', // Only partial "http/1.1"
];
let result = parse_alpn_extension_resilient(&partial_alpn);
assert!(result.is_some());
let protocols = result.unwrap();
assert!(!protocols.is_empty());
assert!(protocols[0].contains("PARTIAL"));
}
}
@@ -0,0 +1,146 @@
//! LLMNR (Link-Local Multicast Name Resolution) Deep Packet Inspection
//!
//! Parses LLMNR packets according to RFC 4795.
//! LLMNR uses UDP port 5355 and shares the same wire format as DNS.
use crate::network::types::LlmnrInfo;
use super::dns;
/// Analyze an LLMNR packet and extract key information.
///
/// LLMNR uses the same packet format as DNS, so we reuse the DNS parser.
/// Returns `None` if the packet cannot be parsed as DNS.
pub fn analyze_llmnr(payload: &[u8]) -> Option<LlmnrInfo> {
// Reuse DNS parser - LLMNR has the same wire format
let dns_info = dns::analyze_dns(payload)?;
Some(LlmnrInfo {
query_name: dns_info.query_name,
query_type: dns_info.query_type,
is_response: dns_info.is_response,
response_ips: dns_info.response_ips,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::network::types::DnsQueryType;
fn build_llmnr_query(name: &str, qtype: u16) -> Vec<u8> {
let mut packet = Vec::new();
// DNS header (12 bytes)
packet.extend_from_slice(&[0x00, 0x01]); // Transaction ID
packet.extend_from_slice(&[0x00, 0x00]); // Flags (query)
packet.extend_from_slice(&[0x00, 0x01]); // Questions: 1
packet.extend_from_slice(&[0x00, 0x00]); // Answer RRs: 0
packet.extend_from_slice(&[0x00, 0x00]); // Authority RRs: 0
packet.extend_from_slice(&[0x00, 0x00]); // Additional RRs: 0
// Question section - encode name (single label for LLMNR)
packet.push(name.len() as u8);
packet.extend_from_slice(name.as_bytes());
packet.push(0x00); // Null terminator
// Query type and class
packet.extend_from_slice(&qtype.to_be_bytes());
packet.extend_from_slice(&[0x00, 0x01]); // Class: IN
packet
}
fn build_llmnr_response(name: &str, qtype: u16) -> Vec<u8> {
let mut packet = Vec::new();
// DNS header (12 bytes)
packet.extend_from_slice(&[0x00, 0x01]); // Transaction ID
packet.extend_from_slice(&[0x80, 0x00]); // Flags (response)
packet.extend_from_slice(&[0x00, 0x01]); // Questions: 1
packet.extend_from_slice(&[0x00, 0x00]); // Answer RRs: 0
packet.extend_from_slice(&[0x00, 0x00]); // Authority RRs: 0
packet.extend_from_slice(&[0x00, 0x00]); // Additional RRs: 0
// Question section
packet.push(name.len() as u8);
packet.extend_from_slice(name.as_bytes());
packet.push(0x00);
packet.extend_from_slice(&qtype.to_be_bytes());
packet.extend_from_slice(&[0x00, 0x01]);
packet
}
#[test]
fn test_llmnr_query() {
let packet = build_llmnr_query("workstation", 1); // A query
let info = analyze_llmnr(&packet).expect("should parse");
assert_eq!(info.query_name, Some("workstation".to_string()));
assert_eq!(info.query_type, Some(DnsQueryType::A));
assert!(!info.is_response);
}
#[test]
fn test_llmnr_response() {
let packet = build_llmnr_response("fileserver", 1);
let info = analyze_llmnr(&packet).expect("should parse");
assert_eq!(info.query_name, Some("fileserver".to_string()));
assert!(info.is_response);
}
#[test]
fn test_llmnr_aaaa_query() {
let packet = build_llmnr_query("mypc", 28); // AAAA query
let info = analyze_llmnr(&packet).expect("should parse");
assert_eq!(info.query_type, Some(DnsQueryType::AAAA));
}
#[test]
fn test_llmnr_too_short() {
let packet = [0u8; 8];
assert!(analyze_llmnr(&packet).is_none());
}
/// Build an LLMNR response that echoes the question and supplies an A
/// record. RFC 4795 §2.1: LLMNR responses re-include the question.
fn build_llmnr_response_with_a(name: &str, ip: [u8; 4]) -> Vec<u8> {
let mut packet = Vec::new();
// Header: txid 1, flags response, qdcount=1, ancount=1, ns=0, ar=0.
packet.extend_from_slice(&[0x00, 0x01, 0x80, 0x00, 0x00, 0x01, 0x00, 0x01]);
packet.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
// Question (single label).
packet.push(name.len() as u8);
packet.extend_from_slice(name.as_bytes());
packet.push(0x00);
packet.extend_from_slice(&[0x00, 0x01, 0x00, 0x01]); // QTYPE A, QCLASS IN
// Answer: NAME pointer back to offset 12, TYPE A, CLASS IN, TTL, RDLENGTH 4, RDATA.
packet.extend_from_slice(&[
0xC0, 0x0C, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x78, 0x00, 0x04,
]);
packet.extend_from_slice(&ip);
packet
}
#[test]
fn test_llmnr_response_populates_response_ips() {
let packet = build_llmnr_response_with_a("workstation", [192, 168, 1, 42]);
let info = analyze_llmnr(&packet).expect("should parse");
assert!(info.is_response);
assert_eq!(info.query_name, Some("workstation".to_string()));
assert_eq!(
info.response_ips,
vec![std::net::IpAddr::V4(std::net::Ipv4Addr::new(
192, 168, 1, 42
))]
);
}
#[test]
fn test_llmnr_query_leaves_response_ips_empty() {
let packet = build_llmnr_query("workstation", 1);
let info = analyze_llmnr(&packet).expect("should parse");
assert!(!info.is_response);
assert!(info.response_ips.is_empty());
}
}
+195
View File
@@ -0,0 +1,195 @@
//! mDNS (Multicast DNS) Deep Packet Inspection
//!
//! Parses mDNS packets according to RFC 6762.
//! mDNS uses UDP port 5353 and shares the same wire format as DNS.
use crate::network::types::MdnsInfo;
use super::dns;
/// Analyze an mDNS packet and extract key information.
///
/// mDNS uses the same packet format as DNS, so we reuse the DNS parser.
/// We route through the mDNS-aware variant so the answer walk also runs
/// when `qdcount == 0` (RFC 6762 §6) and so the parser also walks
/// ADDITIONAL records, where mDNS frequently carries A / AAAA rdata.
///
/// Returns `None` if the packet cannot be parsed as DNS.
pub fn analyze_mdns(payload: &[u8]) -> Option<MdnsInfo> {
let dns_info = dns::analyze_dns_for_mdns(payload)?;
Some(MdnsInfo {
query_name: dns_info.query_name,
query_type: dns_info.query_type,
is_response: dns_info.is_response,
response_ips: dns_info.response_ips,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::network::types::DnsQueryType;
fn build_mdns_query(name: &str, qtype: u16) -> Vec<u8> {
let mut packet = Vec::new();
// DNS header (12 bytes)
packet.extend_from_slice(&[0x00, 0x00]); // Transaction ID
packet.extend_from_slice(&[0x00, 0x00]); // Flags (query)
packet.extend_from_slice(&[0x00, 0x01]); // Questions: 1
packet.extend_from_slice(&[0x00, 0x00]); // Answer RRs: 0
packet.extend_from_slice(&[0x00, 0x00]); // Authority RRs: 0
packet.extend_from_slice(&[0x00, 0x00]); // Additional RRs: 0
// Question section - encode domain name
for label in name.split('.') {
packet.push(label.len() as u8);
packet.extend_from_slice(label.as_bytes());
}
packet.push(0x00); // Null terminator
// Query type and class
packet.extend_from_slice(&qtype.to_be_bytes());
packet.extend_from_slice(&[0x00, 0x01]); // Class: IN
packet
}
fn build_mdns_response(name: &str, qtype: u16) -> Vec<u8> {
let mut packet = Vec::new();
// DNS header (12 bytes)
packet.extend_from_slice(&[0x00, 0x00]); // Transaction ID
packet.extend_from_slice(&[0x84, 0x00]); // Flags (response, authoritative)
packet.extend_from_slice(&[0x00, 0x01]); // Questions: 1
packet.extend_from_slice(&[0x00, 0x00]); // Answer RRs: 0
packet.extend_from_slice(&[0x00, 0x00]); // Authority RRs: 0
packet.extend_from_slice(&[0x00, 0x00]); // Additional RRs: 0
// Question section
for label in name.split('.') {
packet.push(label.len() as u8);
packet.extend_from_slice(label.as_bytes());
}
packet.push(0x00);
packet.extend_from_slice(&qtype.to_be_bytes());
packet.extend_from_slice(&[0x00, 0x01]);
packet
}
#[test]
fn test_mdns_query() {
let packet = build_mdns_query("_http._tcp.local", 12); // PTR query
let info = analyze_mdns(&packet).expect("should parse");
assert_eq!(info.query_name, Some("_http._tcp.local".to_string()));
assert_eq!(info.query_type, Some(DnsQueryType::PTR));
assert!(!info.is_response);
}
#[test]
fn test_mdns_response() {
let packet = build_mdns_response("mydevice.local", 1); // A record
let info = analyze_mdns(&packet).expect("should parse");
assert_eq!(info.query_name, Some("mydevice.local".to_string()));
assert_eq!(info.query_type, Some(DnsQueryType::A));
assert!(info.is_response);
}
#[test]
fn test_mdns_too_short() {
let packet = [0u8; 5];
assert!(analyze_mdns(&packet).is_none());
}
#[test]
fn test_mdns_srv_query() {
let packet = build_mdns_query("_printer._tcp.local", 33); // SRV query
let info = analyze_mdns(&packet).expect("should parse");
assert_eq!(info.query_name, Some("_printer._tcp.local".to_string()));
assert_eq!(info.query_type, Some(DnsQueryType::SRV));
}
/// Build an mDNS announcement (RFC 6762 §6: qdcount=0, ancount>=1).
/// `name` is encoded uncompressed inside each answer record.
fn build_mdns_announcement_a(name: &str, ip: [u8; 4]) -> Vec<u8> {
let mut packet = Vec::new();
// Header: txid 0, flags response, qdcount=0, ancount=1, nscount=0, arcount=0.
packet.extend_from_slice(&[0x00, 0x00, 0x84, 0x00, 0x00, 0x00, 0x00, 0x01]);
packet.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
// Answer: NAME uncompressed.
for label in name.split('.') {
packet.push(label.len() as u8);
packet.extend_from_slice(label.as_bytes());
}
packet.push(0x00);
// TYPE A, CLASS IN (cache-flush bit cleared), TTL 120, RDLENGTH 4, RDATA.
packet.extend_from_slice(&[0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x78, 0x00, 0x04]);
packet.extend_from_slice(&ip);
packet
}
#[test]
fn test_mdns_announcement_with_qdcount_zero_collects_a_record() {
// RFC 6762 §6: typical mDNS announcement has qdcount=0 and ancount>=1.
// Pre-#333 the answer walk lived inside `qdcount > 0`, so this
// packet returned an empty `response_ips`.
let packet = build_mdns_announcement_a("printer.local", [192, 168, 1, 50]);
let info = analyze_mdns(&packet).expect("should parse");
assert!(info.is_response);
assert!(info.query_name.is_none());
assert_eq!(
info.response_ips,
vec![std::net::IpAddr::V4(std::net::Ipv4Addr::new(
192, 168, 1, 50
))]
);
}
#[test]
fn test_mdns_collects_a_record_from_additional_section() {
// mDNS often carries A / AAAA in the ADDITIONAL section (arcount)
// rather than answers — e.g. when responding to a PTR with the
// SRV target's address records. Build: qdcount=0, ancount=1
// (PTR), nscount=0, arcount=1 (A).
let mut packet = Vec::new();
packet.extend_from_slice(&[0x00, 0x00, 0x84, 0x00, 0x00, 0x00, 0x00, 0x01]);
packet.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]);
// ANSWER: PTR "_http._tcp.local" → "mybox._http._tcp.local"
let ptr_name = "_http._tcp.local";
for label in ptr_name.split('.') {
packet.push(label.len() as u8);
packet.extend_from_slice(label.as_bytes());
}
packet.push(0x00);
// TYPE PTR (12), CLASS IN, TTL, RDLENGTH 2 (compressed pointer back).
packet.extend_from_slice(&[0x00, 0x0C, 0x00, 0x01, 0x00, 0x00, 0x00, 0x78, 0x00, 0x02]);
packet.extend_from_slice(&[0xC0, 0x0C]);
// ADDITIONAL: A record for "host.local" → 10.0.0.5.
let a_name = "host.local";
for label in a_name.split('.') {
packet.push(label.len() as u8);
packet.extend_from_slice(label.as_bytes());
}
packet.push(0x00);
packet.extend_from_slice(&[0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x78, 0x00, 0x04]);
packet.extend_from_slice(&[10, 0, 0, 5]);
let info = analyze_mdns(&packet).expect("should parse");
assert_eq!(
info.response_ips,
vec![std::net::IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, 5))]
);
}
#[test]
fn test_mdns_query_packet_response_ips_empty() {
// Even if the wire has bytes after the question that look
// answer-shaped, a query (QR bit clear) must not surface IPs.
let packet = build_mdns_query("_http._tcp.local", 12);
let info = analyze_mdns(&packet).expect("should parse");
assert!(!info.is_response);
assert!(info.response_ips.is_empty());
}
}
+266
View File
@@ -0,0 +1,266 @@
use crate::network::types::{ApplicationProtocol, QuicInfo};
use log::{debug, warn};
mod bittorrent;
mod cipher_suites;
mod dhcp;
mod dns;
mod ftp;
mod http;
mod https;
mod llmnr;
mod mdns;
mod mqtt;
mod netbios;
mod ntp;
mod quic;
mod snmp;
mod ssdp;
mod ssh;
mod stun;
pub use cipher_suites::{format_cipher_suite, is_secure_cipher_suite};
pub use quic::{is_partial_sni, try_extract_tls_from_reassembler};
// Well-known port numbers used for DPI protocol detection.
const PORT_SSH: u16 = 22;
const PORT_DNS: u16 = 53;
const PORT_FTP: u16 = 21;
const PORT_DHCP_SERVER: u16 = 67;
const PORT_DHCP_CLIENT: u16 = 68;
const PORT_NTP: u16 = 123;
const PORT_NETBIOS_NS: u16 = 137;
const PORT_NETBIOS_DGM: u16 = 138;
const PORT_SNMP: u16 = 161;
const PORT_SNMP_TRAP: u16 = 162;
const PORT_HTTPS: u16 = 443;
const PORT_MQTT: u16 = 1883;
const PORT_SSDP: u16 = 1900;
const PORT_MDNS: u16 = 5353;
const PORT_STUN: u16 = 3478;
const PORT_STUN_TLS: u16 = 5349;
const PORT_LLMNR: u16 = 5355;
/// Result of DPI analysis
#[derive(Debug, Clone)]
pub struct DpiResult {
pub application: ApplicationProtocol,
}
/// Analyze a TCP packet payload
pub fn analyze_tcp_packet(
payload: &[u8],
local_port: u16,
remote_port: u16,
_is_outgoing: bool,
) -> Option<DpiResult> {
if payload.is_empty() {
return None;
}
// Try protocols in order of likelihood/speed
// 1. Check for HTTP (fast string matching)
if let Some(http_result) = http::analyze_http(payload) {
return Some(DpiResult {
application: ApplicationProtocol::Http(http_result),
});
}
// 2. Check for TLS/HTTPS (port 443 or TLS handshake)
if (local_port == PORT_HTTPS || remote_port == PORT_HTTPS || https::is_tls_handshake(payload))
&& let Some(tls_result) = https::analyze_https(payload)
{
return Some(DpiResult {
application: ApplicationProtocol::Https(tls_result),
});
}
// 3. Check for BitTorrent (handshake signature \x13BitTorrent protocol)
if bittorrent::is_bittorrent_handshake(payload)
&& let Some(bt_result) = bittorrent::analyze_bittorrent(payload)
{
return Some(DpiResult {
application: ApplicationProtocol::BitTorrent(bt_result),
});
}
// 4. Check for MQTT (port 1883 or MQTT signature)
if (local_port == PORT_MQTT || remote_port == PORT_MQTT || mqtt::is_mqtt_packet(payload))
&& let Some(mqtt_result) = mqtt::analyze_mqtt(payload)
{
return Some(DpiResult {
application: ApplicationProtocol::Mqtt(mqtt_result),
});
}
// 5. Check for SSH (port 22 or SSH banner)
if (local_port == PORT_SSH || remote_port == PORT_SSH || ssh::is_likely_ssh(payload))
&& let Some(ssh_result) = ssh::analyze_ssh(payload, _is_outgoing)
{
return Some(DpiResult {
application: ApplicationProtocol::Ssh(ssh_result),
});
}
// 6. Check for FTP control channel (port 21 plaintext / AUTH TLS upgrade,
// or signature). Off port 21 only distinctively-FTP commands count as
// a signature; 3-digit reply lines are shared with SMTP/POP3/NNTP and
// are only classified on port 21.
//
// Implicit FTPS on port 990 is intentionally NOT routed here: that
// flow is TLS from the very first byte and falls through to the
// HTTPS/TLS branch. AUTH TLS on port 21 is captured via the `AUTH`
// command in the plaintext control channel before the upgrade.
if (local_port == PORT_FTP || remote_port == PORT_FTP || ftp::is_ftp(payload))
&& let Some(ftp_result) = ftp::analyze_ftp(payload)
{
return Some(DpiResult {
application: ApplicationProtocol::Ftp(ftp_result),
});
}
// More protocols here...
None
}
/// Analyze a UDP packet payload
pub fn analyze_udp_packet(
payload: &[u8],
local_port: u16,
remote_port: u16,
_is_outgoing: bool,
) -> Option<DpiResult> {
if payload.is_empty() {
return None;
}
// 1. DNS (port 53)
if (local_port == PORT_DNS || remote_port == PORT_DNS)
&& let Some(dns_result) = dns::analyze_dns(payload)
{
return Some(DpiResult {
application: ApplicationProtocol::Dns(dns_result),
});
}
// 2. QUIC/HTTP3 (port 443)
if (local_port == PORT_HTTPS || remote_port == PORT_HTTPS) && quic::is_quic_packet(payload) {
let quic_info = quic::parse_quic_packet(payload);
if let Some(quic_info) = quic_info {
debug!("QUIC packet detected: {:?}", quic_info);
return Some(DpiResult {
application: ApplicationProtocol::Quic(Box::new(quic_info)),
});
} else {
warn!("Failed to parse QUIC packet");
let empty_quic_info = QuicInfo::new(0);
return Some(DpiResult {
application: ApplicationProtocol::Quic(Box::new(empty_quic_info)),
});
}
}
// 3. mDNS (port 5353)
if (local_port == PORT_MDNS || remote_port == PORT_MDNS)
&& let Some(mdns_result) = mdns::analyze_mdns(payload)
{
return Some(DpiResult {
application: ApplicationProtocol::Mdns(mdns_result),
});
}
// 4. DHCP (ports 67-68)
if matches!(
(local_port, remote_port),
(PORT_DHCP_SERVER, _)
| (PORT_DHCP_CLIENT, _)
| (_, PORT_DHCP_SERVER)
| (_, PORT_DHCP_CLIENT)
) && let Some(dhcp_result) = dhcp::analyze_dhcp(payload)
{
return Some(DpiResult {
application: ApplicationProtocol::Dhcp(dhcp_result),
});
}
// 5. NTP (port 123)
if (local_port == PORT_NTP || remote_port == PORT_NTP)
&& let Some(ntp_result) = ntp::analyze_ntp(payload)
{
return Some(DpiResult {
application: ApplicationProtocol::Ntp(ntp_result),
});
}
// 6. LLMNR (port 5355)
if (local_port == PORT_LLMNR || remote_port == PORT_LLMNR)
&& let Some(llmnr_result) = llmnr::analyze_llmnr(payload)
{
return Some(DpiResult {
application: ApplicationProtocol::Llmnr(llmnr_result),
});
}
// 7. SSDP (port 1900)
if (local_port == PORT_SSDP || remote_port == PORT_SSDP)
&& let Some(ssdp_result) = ssdp::analyze_ssdp(payload)
{
return Some(DpiResult {
application: ApplicationProtocol::Ssdp(ssdp_result),
});
}
// 8. NetBIOS-NS (port 137)
if (local_port == PORT_NETBIOS_NS || remote_port == PORT_NETBIOS_NS)
&& let Some(netbios_result) = netbios::analyze_netbios_ns(payload)
{
return Some(DpiResult {
application: ApplicationProtocol::NetBios(netbios_result),
});
}
// 9. NetBIOS-DGM (port 138)
if (local_port == PORT_NETBIOS_DGM || remote_port == PORT_NETBIOS_DGM)
&& let Some(netbios_result) = netbios::analyze_netbios_dgm(payload)
{
return Some(DpiResult {
application: ApplicationProtocol::NetBios(netbios_result),
});
}
// 10. SNMP (ports 161-162)
if matches!(
(local_port, remote_port),
(PORT_SNMP, _) | (PORT_SNMP_TRAP, _) | (_, PORT_SNMP) | (_, PORT_SNMP_TRAP)
) && let Some(snmp_result) = snmp::analyze_snmp(payload)
{
return Some(DpiResult {
application: ApplicationProtocol::Snmp(snmp_result),
});
}
// 11. STUN (port 3478/5349 or magic cookie detection for non-standard ports)
if (local_port == PORT_STUN
|| remote_port == PORT_STUN
|| local_port == PORT_STUN_TLS
|| remote_port == PORT_STUN_TLS
|| stun::is_likely_stun(payload))
&& let Some(stun_result) = stun::analyze_stun(payload)
{
return Some(DpiResult {
application: ApplicationProtocol::Stun(stun_result),
});
}
// 12. BitTorrent DHT / uTP (no port gating — signature-based)
if let Some(bt_result) = bittorrent::analyze_udp_bittorrent(payload) {
return Some(DpiResult {
application: ApplicationProtocol::BitTorrent(bt_result),
});
}
None
}
+544
View File
@@ -0,0 +1,544 @@
use crate::network::types::{MqttInfo, MqttPacketType, MqttVersion};
use log::debug;
/// Quick check if payload looks like an MQTT packet.
pub fn is_mqtt_packet(payload: &[u8]) -> bool {
if payload.len() < 2 {
return false;
}
let packet_type = payload[0] >> 4;
let flags = payload[0] & 0x0F;
// Valid MQTT packet types are 1-15 (AUTH=15 was added in MQTT 5)
if !(1..=15).contains(&packet_type) {
return false;
}
// Validate flags for packet types with fixed flag requirements (MQTT
// spec §2.1.2): every type except PUBLISH(3), whose flags carry
// DUP/QoS/RETAIN, has reserved flags — 0x02 for PUBREL(6),
// SUBSCRIBE(8), and UNSUBSCRIBE(10), 0x00 for the rest.
if packet_type != 3 {
let expected = match packet_type {
6 | 8 | 10 => 0x02,
_ => 0x00,
};
if flags != expected {
return false;
}
}
// Validate remaining length encoding and check it's plausible
let Some((remaining_len, header_bytes)) = decode_remaining_length(payload, 1) else {
return false;
};
// The total packet size should match what we see
let total = 1 + header_bytes + remaining_len;
if total > payload.len() + 4 {
// Allow a small overshoot since we may have a partial payload,
// but reject wildly wrong lengths (e.g. HTTP text decoded as huge length)
return false;
}
// Only CONNECT packets have a strong enough signature for port-independent detection
// (they contain "MQTT" or "MQIsdp" protocol name string).
// All other types (PINGREQ, UNSUBSCRIBE, etc.) have weak 2-byte signatures that
// easily match random binary data (e.g. BitTorrent file transfers).
// Those types are still detected via port-based matching (port 1883) in the caller.
packet_type == 1 && has_mqtt_protocol_name(payload)
}
/// Full MQTT packet analysis.
pub fn analyze_mqtt(payload: &[u8]) -> Option<MqttInfo> {
if payload.len() < 2 {
return None;
}
let type_byte = payload[0] >> 4;
let flags = payload[0] & 0x0F;
let (remaining_len, header_len) = decode_remaining_length(payload, 1)?;
let packet_type = match type_byte {
1 => MqttPacketType::Connect,
2 => MqttPacketType::Connack,
3 => MqttPacketType::Publish,
4 => MqttPacketType::Puback,
5 => MqttPacketType::Pubrec,
6 => MqttPacketType::Pubrel,
7 => MqttPacketType::Pubcomp,
8 => MqttPacketType::Subscribe,
9 => MqttPacketType::Suback,
10 => MqttPacketType::Unsubscribe,
11 => MqttPacketType::Unsuback,
12 => MqttPacketType::Pingreq,
13 => MqttPacketType::Pingresp,
14 => MqttPacketType::Disconnect,
15 => MqttPacketType::Auth,
_ => return None,
};
let var_start = 1 + header_len;
let packet_end = var_start + remaining_len;
// Clamp to actual payload size
let available = &payload[..payload.len().min(packet_end)];
let mut info = MqttInfo {
version: None,
packet_type,
client_id: None,
topic: None,
qos: None,
};
match packet_type {
MqttPacketType::Connect => {
parse_connect(available, var_start, &mut info);
}
MqttPacketType::Publish => {
let qos = (flags >> 1) & 0x03;
info.qos = Some(qos);
parse_publish_topic(available, var_start, &mut info);
}
MqttPacketType::Subscribe => {
info.qos = Some(1); // SUBSCRIBE always uses QoS 1
}
MqttPacketType::Pingreq | MqttPacketType::Pingresp => {
// No variable header or payload
}
_ => {}
}
debug!("MQTT analysis result: {:?}", info);
Some(info)
}
/// Decode MQTT variable-length encoding starting at `offset`.
/// Returns (decoded_length, bytes_consumed).
fn decode_remaining_length(payload: &[u8], offset: usize) -> Option<(usize, usize)> {
let mut multiplier: usize = 1;
let mut value: usize = 0;
for i in 0..4 {
let idx = offset + i;
if idx >= payload.len() {
return None;
}
let byte = payload[idx] as usize;
value += (byte & 0x7F) * multiplier;
multiplier *= 128;
if byte & 0x80 == 0 {
return Some((value, i + 1));
}
}
None // More than 4 bytes is invalid
}
/// Check if payload contains "MQTT" or "MQIsdp" protocol name in a CONNECT packet.
fn has_mqtt_protocol_name(payload: &[u8]) -> bool {
if payload.len() < 8 {
return false;
}
let (_, header_len) = decode_remaining_length(payload, 1).unwrap_or((0, 1));
let var_start = 1 + header_len;
// Protocol name is a length-prefixed string
if var_start + 2 > payload.len() {
return false;
}
let name_len = u16::from_be_bytes([payload[var_start], payload[var_start + 1]]) as usize;
let name_start = var_start + 2;
let name_end = name_start + name_len;
if name_end > payload.len() {
return false;
}
let name = &payload[name_start..name_end];
name == b"MQTT" || name == b"MQIsdp"
}
/// Parse a CONNECT packet to extract version, client ID.
fn parse_connect(payload: &[u8], var_start: usize, info: &mut MqttInfo) {
// Protocol Name (length-prefixed string)
if var_start + 2 > payload.len() {
return;
}
let name_len = u16::from_be_bytes([payload[var_start], payload[var_start + 1]]) as usize;
let after_name = var_start + 2 + name_len;
// Protocol Level byte
if after_name >= payload.len() {
return;
}
let level = payload[after_name];
info.version = match level {
3 => Some(MqttVersion::V31),
4 => Some(MqttVersion::V311),
5 => Some(MqttVersion::V50),
_ => None,
};
// Connect Flags (1 byte) + Keep Alive (2 bytes) = 3 bytes after level
let payload_start = after_name + 4; // level(1) + flags(1) + keepalive(2)
// For MQTT v5, skip properties length
let payload_start = if level == 5 {
skip_mqtt5_properties(payload, payload_start)
} else {
payload_start
};
// Client ID (length-prefixed string)
if payload_start + 2 > payload.len() {
return;
}
let client_id_len =
u16::from_be_bytes([payload[payload_start], payload[payload_start + 1]]) as usize;
let client_id_start = payload_start + 2;
let client_id_end = client_id_start + client_id_len;
if client_id_end > payload.len() {
return;
}
if let Ok(client_id) = std::str::from_utf8(&payload[client_id_start..client_id_end])
&& !client_id.is_empty()
{
info.client_id = Some(client_id.to_string());
}
}
/// Parse the topic from a PUBLISH packet.
fn parse_publish_topic(payload: &[u8], var_start: usize, info: &mut MqttInfo) {
if var_start + 2 > payload.len() {
return;
}
let topic_len = u16::from_be_bytes([payload[var_start], payload[var_start + 1]]) as usize;
let topic_start = var_start + 2;
let topic_end = topic_start + topic_len;
if topic_end > payload.len() {
return;
}
if let Ok(topic) = std::str::from_utf8(&payload[topic_start..topic_end])
&& !topic.is_empty()
{
info.topic = Some(topic.to_string());
}
}
/// Skip MQTT v5 properties section, returning the new offset.
fn skip_mqtt5_properties(payload: &[u8], offset: usize) -> usize {
if let Some((prop_len, consumed)) = decode_remaining_length(payload, offset) {
offset + consumed + prop_len
} else {
offset
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Helper: build a CONNECT packet.
fn build_connect(protocol_name: &[u8], level: u8, client_id: &str) -> Vec<u8> {
let name_len = protocol_name.len();
// variable header: name_len(2) + name + level(1) + flags(1) + keepalive(2) + client_id_len(2) + client_id
let var_len = 2 + name_len + 1 + 1 + 2 + 2 + client_id.len();
let mut pkt = Vec::new();
pkt.push(0x10); // CONNECT type (1 << 4)
// Remaining length (simple single-byte for test payloads < 128)
pkt.push(var_len as u8);
// Protocol name
pkt.extend_from_slice(&(name_len as u16).to_be_bytes());
pkt.extend_from_slice(protocol_name);
// Protocol level
pkt.push(level);
// Connect flags (clean session)
pkt.push(0x02);
// Keep alive
pkt.extend_from_slice(&60u16.to_be_bytes());
// Client ID
pkt.extend_from_slice(&(client_id.len() as u16).to_be_bytes());
pkt.extend_from_slice(client_id.as_bytes());
pkt
}
/// Helper: build a PUBLISH packet.
fn build_publish(topic: &str, qos: u8, payload_data: &[u8]) -> Vec<u8> {
let flags = (qos & 0x03) << 1;
let packet_id_len = if qos > 0 { 2 } else { 0 };
let var_len = 2 + topic.len() + packet_id_len + payload_data.len();
let mut pkt = Vec::new();
pkt.push(0x30 | flags); // PUBLISH type (3 << 4) | flags
pkt.push(var_len as u8);
// Topic
pkt.extend_from_slice(&(topic.len() as u16).to_be_bytes());
pkt.extend_from_slice(topic.as_bytes());
// Packet ID (for QoS > 0)
if qos > 0 {
pkt.extend_from_slice(&1u16.to_be_bytes());
}
pkt.extend_from_slice(payload_data);
pkt
}
#[test]
fn test_empty_payload_safe() {
// This simulates a potential DoS attack with an empty packet
// Should return None, not panic
assert!(analyze_mqtt(&[]).is_none());
}
#[test]
fn test_connect_v311() {
let pkt = build_connect(b"MQTT", 4, "my-client");
assert!(is_mqtt_packet(&pkt));
let info = analyze_mqtt(&pkt).unwrap();
assert_eq!(info.packet_type, MqttPacketType::Connect);
assert_eq!(info.version, Some(MqttVersion::V311));
assert_eq!(info.client_id.as_deref(), Some("my-client"));
}
#[test]
fn test_connect_v31_mqisdp() {
let pkt = build_connect(b"MQIsdp", 3, "old-device");
assert!(is_mqtt_packet(&pkt));
let info = analyze_mqtt(&pkt).unwrap();
assert_eq!(info.packet_type, MqttPacketType::Connect);
assert_eq!(info.version, Some(MqttVersion::V31));
assert_eq!(info.client_id.as_deref(), Some("old-device"));
}
#[test]
fn test_connect_v50() {
// Build a v5 CONNECT with properties length = 0
let mut pkt = Vec::new();
pkt.push(0x10); // CONNECT
// We'll fill remaining length at the end
let var_header_start = pkt.len();
pkt.push(0); // placeholder
// Protocol name
pkt.extend_from_slice(&4u16.to_be_bytes());
pkt.extend_from_slice(b"MQTT");
// Level 5
pkt.push(5);
// Flags
pkt.push(0x02);
// Keep alive
pkt.extend_from_slice(&60u16.to_be_bytes());
// Properties length = 0
pkt.push(0);
// Client ID
let client = "v5-client";
pkt.extend_from_slice(&(client.len() as u16).to_be_bytes());
pkt.extend_from_slice(client.as_bytes());
// Fix remaining length
pkt[var_header_start] = (pkt.len() - var_header_start - 1) as u8;
let info = analyze_mqtt(&pkt).unwrap();
assert_eq!(info.version, Some(MqttVersion::V50));
assert_eq!(info.client_id.as_deref(), Some("v5-client"));
}
#[test]
fn test_connack() {
// CONNACK: type 2, remaining length 2, session present=0, return code=0
// Not detected by is_mqtt_packet (only CONNECT has strong signature),
// but analyze_mqtt still parses it (used via port-based detection path)
let pkt = vec![0x20, 0x02, 0x00, 0x00];
assert!(!is_mqtt_packet(&pkt));
let info = analyze_mqtt(&pkt).unwrap();
assert_eq!(info.packet_type, MqttPacketType::Connack);
}
#[test]
fn test_publish_qos0() {
let pkt = build_publish("home/temp", 0, b"22.5");
let info = analyze_mqtt(&pkt).unwrap();
assert_eq!(info.packet_type, MqttPacketType::Publish);
assert_eq!(info.topic.as_deref(), Some("home/temp"));
assert_eq!(info.qos, Some(0));
}
#[test]
fn test_publish_qos1() {
let pkt = build_publish("sensors/humidity", 1, b"65");
let info = analyze_mqtt(&pkt).unwrap();
assert_eq!(info.packet_type, MqttPacketType::Publish);
assert_eq!(info.topic.as_deref(), Some("sensors/humidity"));
assert_eq!(info.qos, Some(1));
}
#[test]
fn test_subscribe() {
// SUBSCRIBE: type 8, flags 0x02, packet id, topic filter
let topic = "home/#";
let var_len = 2 + 2 + topic.len() + 1; // packet_id + topic_len + topic + qos
let mut pkt = vec![0x82, var_len as u8];
pkt.extend_from_slice(&1u16.to_be_bytes()); // packet ID
pkt.extend_from_slice(&(topic.len() as u16).to_be_bytes());
pkt.extend_from_slice(topic.as_bytes());
pkt.push(0x01); // QoS 1
let info = analyze_mqtt(&pkt).unwrap();
assert_eq!(info.packet_type, MqttPacketType::Subscribe);
assert_eq!(info.qos, Some(1));
}
#[test]
fn test_pingreq() {
let pkt = vec![0xC0, 0x00];
assert!(!is_mqtt_packet(&pkt));
let info = analyze_mqtt(&pkt).unwrap();
assert_eq!(info.packet_type, MqttPacketType::Pingreq);
}
#[test]
fn test_pingresp() {
let pkt = vec![0xD0, 0x00];
assert!(!is_mqtt_packet(&pkt));
let info = analyze_mqtt(&pkt).unwrap();
assert_eq!(info.packet_type, MqttPacketType::Pingresp);
}
#[test]
fn test_disconnect() {
let pkt = vec![0xE0, 0x00];
let info = analyze_mqtt(&pkt).unwrap();
assert_eq!(info.packet_type, MqttPacketType::Disconnect);
}
#[test]
fn test_invalid_type() {
// Type 0 is reserved/invalid
let pkt = vec![0x00, 0x00];
assert!(!is_mqtt_packet(&pkt));
assert!(analyze_mqtt(&pkt).is_none());
}
#[test]
fn test_type_15_auth() {
// Type 15 is AUTH in MQTT 5. Not signature-detected (only CONNECT
// is), but the port-based path must classify it.
let pkt = vec![0xF0, 0x00];
assert!(!is_mqtt_packet(&pkt));
let info = analyze_mqtt(&pkt).unwrap();
assert_eq!(info.packet_type, MqttPacketType::Auth);
}
#[test]
fn test_qos2_flow_packets() {
// PUBREC / PUBREL / PUBCOMP make up the QoS 2 delivery flow;
// PUBREL carries the reserved flags 0x02 (MQTT spec §2.1.2).
let pubrec = vec![0x50, 0x02, 0x00, 0x01];
assert_eq!(
analyze_mqtt(&pubrec).unwrap().packet_type,
MqttPacketType::Pubrec
);
let pubrel = vec![0x62, 0x02, 0x00, 0x01];
assert_eq!(
analyze_mqtt(&pubrel).unwrap().packet_type,
MqttPacketType::Pubrel
);
let pubcomp = vec![0x70, 0x02, 0x00, 0x01];
assert_eq!(
analyze_mqtt(&pubcomp).unwrap().packet_type,
MqttPacketType::Pubcomp
);
}
#[test]
fn test_too_short() {
assert!(!is_mqtt_packet(&[]));
assert!(!is_mqtt_packet(&[0x10]));
assert!(analyze_mqtt(&[]).is_none());
}
#[test]
fn test_connect_without_mqtt_name_rejected() {
// CONNECT type but garbage protocol name
let pkt = vec![0x10, 0x08, 0x00, 0x04, b'X', b'Y', b'Z', b'W', 4, 0];
assert!(!is_mqtt_packet(&pkt));
}
#[test]
fn test_non_connect_not_detected_by_signature() {
// Non-CONNECT packets are not detected by is_mqtt_packet (signature-based).
// They are only detected via port 1883 matching in the caller.
let pkt = vec![0x20, 0x02, 0x00, 0x00]; // CONNACK
assert!(!is_mqtt_packet(&pkt));
}
#[test]
fn test_multibyte_remaining_length() {
// Non-CONNECT with multi-byte length should NOT be detected by signature
let mut pkt = vec![0xC0, 0xC8, 0x01]; // PINGREQ with 200-byte remaining
pkt.extend(vec![0; 200]);
assert!(!is_mqtt_packet(&pkt));
// Multi-byte remaining length decoding is still exercised via analyze_mqtt
let info = analyze_mqtt(&pkt).unwrap();
assert_eq!(info.packet_type, MqttPacketType::Pingreq);
}
#[test]
fn test_invalid_remaining_length() {
// All continuation bits set (> 4 bytes) — invalid
let pkt = vec![0x20, 0x80, 0x80, 0x80, 0x80, 0x01];
assert!(!is_mqtt_packet(&pkt));
}
#[test]
fn test_connect_empty_client_id() {
let pkt = build_connect(b"MQTT", 4, "");
let info = analyze_mqtt(&pkt).unwrap();
assert_eq!(info.packet_type, MqttPacketType::Connect);
assert_eq!(info.client_id, None); // empty string stored as None
}
#[test]
fn test_http_not_mqtt() {
let payload = b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n";
assert!(!is_mqtt_packet(payload));
}
#[test]
fn test_binary_data_not_false_positive_mqtt() {
// Bytes resembling MQTT PINGREQ from random BitTorrent file transfer data
assert!(!is_mqtt_packet(&[0xC0, 0x00]));
assert!(!is_mqtt_packet(&[0xC0, 0x00, 0xDE, 0xAD, 0xBE, 0xEF]));
// Bytes resembling MQTT UNSUBSCRIBE
assert!(!is_mqtt_packet(&[0xA2, 0x05, 0x00, 0x01, 0x00, 0x01, 0x00]));
// Bytes resembling MQTT PUBLISH
assert!(!is_mqtt_packet(&[
0x30, 0x0A, 0x00, 0x04, b't', b'e', b's', b't'
]));
// Bytes resembling MQTT DISCONNECT
assert!(!is_mqtt_packet(&[0xE0, 0x00]));
}
}
@@ -0,0 +1,337 @@
//! NetBIOS Deep Packet Inspection
//!
//! Parses NetBIOS Name Service (UDP 137) and Datagram Service (UDP 138) packets.
use crate::network::types::{NetBiosInfo, NetBiosOpcode, NetBiosService};
/// Minimum NetBIOS Name Service packet size
const MIN_NBNS_SIZE: usize = 12;
/// Datagram Service header sizes per RFC 1002 §4.4: DIRECT_UNIQUE /
/// DIRECT_GROUP / BROADCAST messages have a 14-byte header (through
/// PACKET_OFFSET), QUERY REQUEST/RESPONSE messages a 10-byte header
/// (through SOURCE_PORT), and a DATAGRAM ERROR is 11 bytes (10-byte
/// header plus the error code).
const NBDGM_DIRECT_HEADER: usize = 14;
const NBDGM_QUERY_HEADER: usize = 10;
const NBDGM_ERROR_SIZE: usize = 11;
/// Analyze a NetBIOS Name Service packet (UDP port 137).
///
/// Returns `None` if the packet is too small or invalid.
pub fn analyze_netbios_ns(payload: &[u8]) -> Option<NetBiosInfo> {
if payload.len() < MIN_NBNS_SIZE {
return None;
}
// Parse header flags at bytes 2-3
let flags = u16::from_be_bytes([payload[2], payload[3]]);
// Extract opcode from flags (bits 11-14)
let opcode_value = ((flags >> 11) & 0x0F) as u8;
let is_response = (flags & 0x8000) != 0;
let opcode = if is_response {
NetBiosOpcode::Response
} else {
parse_opcode(opcode_value)
};
// Try to decode NetBIOS name if present
let name = if payload.len() > 12 {
decode_netbios_name(&payload[12..])
} else {
None
};
Some(NetBiosInfo {
service: NetBiosService::NameService,
opcode,
name,
})
}
/// Analyze a NetBIOS Datagram Service packet (UDP port 138).
///
/// Returns `None` if the packet is too small or invalid.
pub fn analyze_netbios_dgm(payload: &[u8]) -> Option<NetBiosInfo> {
// Message type at byte 0
let msg_type = *payload.first()?;
// RFC 1002 §4.4: header size — and therefore where the encoded name
// starts — depends on the message type.
let (opcode, min_size, name_offset) = match msg_type {
// §4.4.1 DIRECT_UNIQUE / DIRECT_GROUP / BROADCAST: message
// delivery, SOURCE_NAME follows the 14-byte header
0x10..=0x12 => (
NetBiosOpcode::Datagram,
NBDGM_DIRECT_HEADER,
Some(NBDGM_DIRECT_HEADER),
),
// §4.4.2 DATAGRAM ERROR: header + error code, no name
0x13 => (NetBiosOpcode::Error, NBDGM_ERROR_SIZE, None),
// §4.4.3 DATAGRAM QUERY REQUEST: DESTINATION_NAME follows the
// 10-byte header
0x14 => (
NetBiosOpcode::Query,
NBDGM_QUERY_HEADER,
Some(NBDGM_QUERY_HEADER),
),
// §4.4.3 POSITIVE / NEGATIVE QUERY RESPONSE
0x15 | 0x16 => (
NetBiosOpcode::Response,
NBDGM_QUERY_HEADER,
Some(NBDGM_QUERY_HEADER),
),
other => (NetBiosOpcode::Unknown(other), NBDGM_QUERY_HEADER, None),
};
if payload.len() < min_size {
return None;
}
let name = name_offset
.and_then(|off| payload.get(off..))
.and_then(decode_netbios_name);
Some(NetBiosInfo {
service: NetBiosService::DatagramService,
opcode,
name,
})
}
/// Parse NetBIOS opcode from the flags field
fn parse_opcode(value: u8) -> NetBiosOpcode {
match value {
0 => NetBiosOpcode::Query,
5 => NetBiosOpcode::Registration,
6 => NetBiosOpcode::Release,
7 => NetBiosOpcode::Wack,
8 => NetBiosOpcode::Refresh,
other => NetBiosOpcode::Unknown(other),
}
}
/// Decode a NetBIOS "first-level" encoded name.
///
/// NetBIOS names are encoded as 32 bytes representing 16 characters:
/// - Each character is split into two nibbles
/// - Each nibble is encoded as 'A' + nibble_value
fn decode_netbios_name(data: &[u8]) -> Option<String> {
// Need at least length byte + 32 encoded bytes
if data.is_empty() {
return None;
}
let name_len = data[0] as usize;
// Standard NetBIOS encoded name is 32 bytes
if name_len != 32 || data.len() < 33 {
return None;
}
let mut name = String::with_capacity(16);
for i in 0..16 {
let idx = 1 + i * 2;
if idx + 1 >= data.len() {
break;
}
let hi = data[idx];
let lo = data[idx + 1];
// Validate encoding (should be 'A'-'P' range)
if !(b'A'..=b'P').contains(&hi) || !(b'A'..=b'P').contains(&lo) {
return None;
}
let hi_nibble = (hi - b'A') << 4;
let lo_nibble = lo - b'A';
let c = hi_nibble | lo_nibble;
// Keep only printable ASCII: this naturally drops the trailing
// service-type suffix (0x00/0x1B/etc.) and padding spaces (0x20),
// since `is_ascii_graphic` covers 0x21..=0x7E exclusively.
if c.is_ascii_graphic() {
name.push(c as char);
}
}
if name.is_empty() { None } else { Some(name) }
}
#[cfg(test)]
mod tests {
use super::*;
fn encode_netbios_name(name: &str) -> Vec<u8> {
let mut encoded = Vec::with_capacity(33);
encoded.push(32); // Length byte
// Pad name to 15 chars + suffix byte
let padded: Vec<u8> = name
.bytes()
.chain(std::iter::repeat(0x20))
.take(16)
.collect();
for &b in &padded {
encoded.push(b'A' + ((b >> 4) & 0x0F));
encoded.push(b'A' + (b & 0x0F));
}
encoded
}
fn build_nbns_query(name: &str) -> Vec<u8> {
let mut packet = Vec::new();
// Transaction ID
packet.extend_from_slice(&[0x00, 0x01]);
// Flags: Query (opcode 0)
packet.extend_from_slice(&[0x01, 0x10]);
// Question count: 1
packet.extend_from_slice(&[0x00, 0x01]);
// Answer, Authority, Additional counts: 0
packet.extend_from_slice(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
// Encoded name
packet.extend_from_slice(&encode_netbios_name(name));
// Null terminator and QTYPE/QCLASS
packet.push(0x00);
packet.extend_from_slice(&[0x00, 0x20, 0x00, 0x01]);
packet
}
fn build_nbns_response(name: &str) -> Vec<u8> {
let mut packet = Vec::new();
// Transaction ID
packet.extend_from_slice(&[0x00, 0x01]);
// Flags: Response
packet.extend_from_slice(&[0x85, 0x00]);
// Counts
packet.extend_from_slice(&[0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]);
// Encoded name
packet.extend_from_slice(&encode_netbios_name(name));
packet
}
#[test]
fn test_nbns_query() {
let packet = build_nbns_query("WORKSTATION");
let info = analyze_netbios_ns(&packet).expect("should parse");
assert_eq!(info.service, NetBiosService::NameService);
assert_eq!(info.opcode, NetBiosOpcode::Query);
assert_eq!(info.name, Some("WORKSTATION".to_string()));
}
#[test]
fn test_nbns_response() {
let packet = build_nbns_response("FILESERVER");
let info = analyze_netbios_ns(&packet).expect("should parse");
assert_eq!(info.service, NetBiosService::NameService);
assert_eq!(info.opcode, NetBiosOpcode::Response);
assert_eq!(info.name, Some("FILESERVER".to_string()));
}
#[test]
fn test_nbns_too_short() {
let packet = [0u8; 5];
assert!(analyze_netbios_ns(&packet).is_none());
}
#[test]
fn test_nbdgm_direct() {
let mut packet = vec![0u8; 14];
packet[0] = 0x10; // Direct unique datagram
packet.extend_from_slice(&encode_netbios_name("SENDERPC")); // SOURCE_NAME
let info = analyze_netbios_dgm(&packet).expect("should parse");
assert_eq!(info.service, NetBiosService::DatagramService);
assert_eq!(info.opcode, NetBiosOpcode::Datagram);
assert_eq!(info.name, Some("SENDERPC".to_string()));
}
#[test]
fn test_nbdgm_broadcast_is_datagram_not_registration() {
// 0x12 is a broadcast datagram (RFC 1002 §4.4.1) — it has nothing
// to do with NBNS name registration.
let mut packet = vec![0u8; 14];
packet[0] = 0x12;
let info = analyze_netbios_dgm(&packet).expect("should parse");
assert_eq!(info.opcode, NetBiosOpcode::Datagram);
}
#[test]
fn test_nbdgm_query_request_name_at_offset_10() {
// §4.4.3 messages have a 10-byte header; DESTINATION_NAME starts at
// offset 10, not 14.
let mut packet = vec![0u8; 10];
packet[0] = 0x14; // DATAGRAM QUERY REQUEST
packet.extend_from_slice(&encode_netbios_name("FILESERVER"));
let info = analyze_netbios_dgm(&packet).expect("should parse");
assert_eq!(info.opcode, NetBiosOpcode::Query);
assert_eq!(info.name, Some("FILESERVER".to_string()));
}
#[test]
fn test_nbdgm_error_datagram() {
// A spec-conformant DATAGRAM ERROR is exactly 11 bytes
// (§4.4.2: 10-byte header + 1-byte error code).
let mut packet = vec![0u8; 11];
packet[0] = 0x13;
packet[10] = 0x82; // ERR_SOURCE_NAME_BAD_FORMAT
let info = analyze_netbios_dgm(&packet).expect("should parse");
assert_eq!(info.opcode, NetBiosOpcode::Error);
assert_eq!(info.name, None);
}
#[test]
fn test_nbdgm_too_short() {
let packet = [0u8; 5];
assert!(analyze_netbios_dgm(&packet).is_none());
}
#[test]
fn test_decode_netbios_name() {
// Encode "TEST"
let encoded = encode_netbios_name("TEST");
let name = decode_netbios_name(&encoded).expect("should decode");
assert_eq!(name, "TEST");
}
#[test]
fn test_decode_netbios_name_with_padding() {
// Encode "PC" - should trim padding
let encoded = encode_netbios_name("PC");
let name = decode_netbios_name(&encoded).expect("should decode");
assert_eq!(name, "PC");
}
#[test]
fn test_decode_netbios_name_strips_service_type_suffix() {
// Real NetBIOS names embed a service-type byte at the 16th position
// (workstation=0x00, file_server=0x20, master_browser=0x1D, etc.).
// The non-printable suffix must NOT appear in the decoded name —
// decoding only keeps `is_ascii_graphic` characters (0x21..=0x7E).
let mut encoded = Vec::with_capacity(33);
encoded.push(32);
let padded = b"WORKSTATION "; // 15 chars
for &b in padded {
encoded.push(b'A' + ((b >> 4) & 0x0F));
encoded.push(b'A' + (b & 0x0F));
}
// 16th byte = service-type 0x00 (workstation)
encoded.push(b'A');
encoded.push(b'A');
let decoded = decode_netbios_name(&encoded).expect("should decode");
assert_eq!(decoded, "WORKSTATION");
}
}
+108
View File
@@ -0,0 +1,108 @@
//! NTP (Network Time Protocol) Deep Packet Inspection
//!
//! Parses NTP packets according to RFC 5905.
//! NTP uses UDP port 123.
use crate::network::types::{NtpInfo, NtpMode};
/// Minimum NTP packet size (48 bytes for NTPv3/v4)
const MIN_NTP_PACKET_SIZE: usize = 48;
/// Analyze an NTP packet and extract key information.
///
/// Returns `None` if the packet is too small or has invalid version.
pub fn analyze_ntp(payload: &[u8]) -> Option<NtpInfo> {
// Early size check - NTP packets are at least 48 bytes
if payload.len() < MIN_NTP_PACKET_SIZE {
return None;
}
// First byte contains: LI (2 bits) | VN (3 bits) | Mode (3 bits)
let first_byte = payload[0];
let version = (first_byte >> 3) & 0x07;
let mode = first_byte & 0x07;
let stratum = payload[1];
// Validate version (1-4 are valid, 3 and 4 are common)
if !(1..=4).contains(&version) {
return None;
}
Some(NtpInfo {
version,
mode: NtpMode::from(mode),
stratum,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn build_ntp_packet(version: u8, mode: u8, stratum: u8) -> [u8; 48] {
let mut packet = [0u8; 48];
// LI=0 (2 bits), VN=version (3 bits), Mode=mode (3 bits)
packet[0] = (version << 3) | mode;
packet[1] = stratum;
packet
}
#[test]
fn test_ntp_client_request() {
let packet = build_ntp_packet(4, 3, 0); // v4, client mode, stratum 0
let info = analyze_ntp(&packet).expect("should parse");
assert_eq!(info.version, 4);
assert_eq!(info.mode, NtpMode::Client);
assert_eq!(info.stratum, 0);
}
#[test]
fn test_ntp_server_response() {
let packet = build_ntp_packet(4, 4, 2); // v4, server mode, stratum 2
let info = analyze_ntp(&packet).expect("should parse");
assert_eq!(info.version, 4);
assert_eq!(info.mode, NtpMode::Server);
assert_eq!(info.stratum, 2);
}
#[test]
fn test_ntp_v3_broadcast() {
let packet = build_ntp_packet(3, 5, 1); // v3, broadcast mode, stratum 1
let info = analyze_ntp(&packet).expect("should parse");
assert_eq!(info.version, 3);
assert_eq!(info.mode, NtpMode::Broadcast);
assert_eq!(info.stratum, 1);
}
#[test]
fn test_ntp_too_short() {
let packet = [0x23; 10]; // Too short
assert!(analyze_ntp(&packet).is_none());
}
#[test]
fn test_ntp_invalid_version_zero() {
let packet = build_ntp_packet(0, 3, 0); // Invalid version 0
assert!(analyze_ntp(&packet).is_none());
}
#[test]
fn test_ntp_invalid_version_high() {
let packet = build_ntp_packet(7, 3, 0); // Invalid version 7
assert!(analyze_ntp(&packet).is_none());
}
#[test]
fn test_ntp_symmetric_active() {
let packet = build_ntp_packet(4, 1, 3);
let info = analyze_ntp(&packet).expect("should parse");
assert_eq!(info.mode, NtpMode::SymmetricActive);
}
#[test]
fn test_ntp_symmetric_passive() {
let packet = build_ntp_packet(4, 2, 3);
let info = analyze_ntp(&packet).expect("should parse");
assert_eq!(info.mode, NtpMode::SymmetricPassive);
}
}
File diff suppressed because it is too large Load Diff
+424
View File
@@ -0,0 +1,424 @@
//! SNMP (Simple Network Management Protocol) Deep Packet Inspection
//!
//! Parses SNMP packets (v1, v2c, v3) using simplified BER decoding.
//! SNMP uses UDP ports 161 (agent) and 162 (trap).
use crate::network::types::{SnmpInfo, SnmpPduType, SnmpVersion};
/// Minimum SNMP packet size
const MIN_SNMP_SIZE: usize = 10;
/// BER tag types
const BER_SEQUENCE: u8 = 0x30;
const BER_INTEGER: u8 = 0x02;
const BER_OCTET_STRING: u8 = 0x04;
/// SNMP PDU types (context-specific tags)
const PDU_GET_REQUEST: u8 = 0xA0;
const PDU_GET_NEXT_REQUEST: u8 = 0xA1;
const PDU_GET_RESPONSE: u8 = 0xA2;
const PDU_SET_REQUEST: u8 = 0xA3;
const PDU_TRAP_V1: u8 = 0xA4;
const PDU_GET_BULK_REQUEST: u8 = 0xA5;
const PDU_INFORM_REQUEST: u8 = 0xA6;
const PDU_TRAP_V2: u8 = 0xA7;
const PDU_REPORT: u8 = 0xA8;
/// Analyze an SNMP packet and extract key information.
///
/// Returns `None` if the packet is not valid SNMP.
pub fn analyze_snmp(payload: &[u8]) -> Option<SnmpInfo> {
if payload.len() < MIN_SNMP_SIZE {
return None;
}
// SNMP message is wrapped in a SEQUENCE
if payload[0] != BER_SEQUENCE {
return None;
}
// Parse SEQUENCE length
let (seq_len, mut offset) = parse_ber_length(&payload[1..])?;
offset += 1; // Account for SEQUENCE tag
// Check we have enough data
if offset + seq_len > payload.len() {
return None;
}
// Parse version (INTEGER)
if offset >= payload.len() || payload[offset] != BER_INTEGER {
return None;
}
offset += 1;
let (version_len, len_bytes) = parse_ber_length(&payload[offset..])?;
offset += len_bytes;
if offset + version_len > payload.len() {
return None;
}
let version = parse_version(&payload[offset..offset + version_len])?;
offset += version_len;
// Parse community string for v1/v2c (OCTET STRING). It is mandatory in
// both versions (RFC 1157 §4, RFC 3416), so its absence means the
// payload is not SNMP.
let (community, pdu_type) = match version {
SnmpVersion::V1 | SnmpVersion::V2c => {
if offset >= payload.len() || payload[offset] != BER_OCTET_STRING {
return None;
}
offset += 1;
let (comm_len, len_bytes) = parse_ber_length(&payload[offset..])?;
offset += len_bytes;
if offset + comm_len > payload.len() {
return None;
}
let community_bytes = &payload[offset..offset + comm_len];
offset += comm_len;
let community = std::str::from_utf8(community_bytes)
.ok()
.map(|s| s.to_string());
// The PDU tag directly follows the community string.
(community, pdu_type_at(payload, offset)?)
}
SnmpVersion::V3 => (None, v3_pdu_type(payload, offset)?),
};
Some(SnmpInfo {
version,
community,
pdu_type,
})
}
/// Parse BER length encoding.
/// Returns (length, bytes consumed).
fn parse_ber_length(data: &[u8]) -> Option<(usize, usize)> {
if data.is_empty() {
return None;
}
let first = data[0];
if first < 0x80 {
// Short form: length is in first byte
Some((first as usize, 1))
} else if first == 0x80 {
// Indefinite form - not supported
None
} else {
// Long form: first byte tells how many bytes encode the length
let num_bytes = (first & 0x7F) as usize;
if num_bytes > 4 || data.len() < 1 + num_bytes {
return None;
}
let mut length: usize = 0;
for i in 0..num_bytes {
length = (length << 8) | (data[1 + i] as usize);
}
Some((length, 1 + num_bytes))
}
}
/// Parse SNMP version from INTEGER value
fn parse_version(data: &[u8]) -> Option<SnmpVersion> {
if data.is_empty() {
return None;
}
// Version is encoded as 0=v1, 1=v2c, 3=v3
match data[data.len() - 1] {
0 => Some(SnmpVersion::V1),
1 => Some(SnmpVersion::V2c),
3 => Some(SnmpVersion::V3),
_ => None,
}
}
/// Read the PDU tag expected at exactly `offset`. Structural — never scans:
/// a byte in the 0xA0-0xA8 range inside a length or INTEGER value (e.g. a
/// request-id) must not be mistaken for a PDU tag.
fn pdu_type_at(payload: &[u8], offset: usize) -> Option<SnmpPduType> {
let pdu_type = match *payload.get(offset)? {
PDU_GET_REQUEST => SnmpPduType::GetRequest,
PDU_GET_NEXT_REQUEST => SnmpPduType::GetNextRequest,
PDU_GET_RESPONSE => SnmpPduType::GetResponse,
PDU_SET_REQUEST => SnmpPduType::SetRequest,
PDU_TRAP_V1 => SnmpPduType::Trap,
PDU_GET_BULK_REQUEST => SnmpPduType::GetBulkRequest,
PDU_INFORM_REQUEST => SnmpPduType::InformRequest,
PDU_TRAP_V2 => SnmpPduType::TrapV2,
PDU_REPORT => SnmpPduType::Report,
_ => return None,
};
Some(pdu_type)
}
/// Walk the SNMPv3 message structure (RFC 3412 §6) to the PDU tag:
/// msgGlobalData (SEQUENCE) and msgSecurityParameters (OCTET STRING) are
/// skipped whole, then msgData is either a plaintext ScopedPDU — a SEQUENCE
/// holding contextEngineID, contextName, and the PDU — or an encrypted
/// OCTET STRING, in which case the PDU type is not visible.
fn v3_pdu_type(payload: &[u8], offset: usize) -> Option<SnmpPduType> {
let offset = skip_tlv(payload, offset, BER_SEQUENCE)?; // msgGlobalData
let offset = skip_tlv(payload, offset, BER_OCTET_STRING)?; // msgSecurityParameters
match *payload.get(offset)? {
BER_SEQUENCE => {
// Plaintext ScopedPDU: enter the SEQUENCE, then skip
// contextEngineID and contextName.
let mut offset = offset + 1;
let (_, len_bytes) = parse_ber_length(&payload[offset..])?;
offset += len_bytes;
let offset = skip_tlv(payload, offset, BER_OCTET_STRING)?; // contextEngineID
let offset = skip_tlv(payload, offset, BER_OCTET_STRING)?; // contextName
pdu_type_at(payload, offset)
}
BER_OCTET_STRING => Some(SnmpPduType::Encrypted),
_ => None,
}
}
/// Skip one TLV with the expected tag, returning the offset just past it.
fn skip_tlv(payload: &[u8], offset: usize, expected_tag: u8) -> Option<usize> {
if *payload.get(offset)? != expected_tag {
return None;
}
let offset = offset + 1;
let (len, len_bytes) = parse_ber_length(&payload[offset..])?;
let end = offset.checked_add(len_bytes)?.checked_add(len)?;
(end <= payload.len()).then_some(end)
}
#[cfg(test)]
mod tests {
use super::*;
fn build_snmp_v1_get(community: &str) -> Vec<u8> {
let mut packet = Vec::new();
// SEQUENCE
packet.push(BER_SEQUENCE);
// We'll update length later
let len_pos = packet.len();
packet.push(0x00); // Placeholder
// Version: INTEGER 0 (v1)
packet.push(BER_INTEGER);
packet.push(0x01);
packet.push(0x00);
// Community: OCTET STRING
packet.push(BER_OCTET_STRING);
packet.push(community.len() as u8);
packet.extend_from_slice(community.as_bytes());
// GetRequest PDU
packet.push(PDU_GET_REQUEST);
packet.push(0x10); // PDU length
// Request ID
packet.push(BER_INTEGER);
packet.push(0x04);
packet.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]);
// Error status
packet.push(BER_INTEGER);
packet.push(0x01);
packet.push(0x00);
// Error index
packet.push(BER_INTEGER);
packet.push(0x01);
packet.push(0x00);
// Variable bindings (empty)
packet.push(BER_SEQUENCE);
packet.push(0x00);
// Update length
packet[len_pos] = (packet.len() - len_pos - 1) as u8;
packet
}
fn build_snmp_v2c_response(community: &str) -> Vec<u8> {
let mut packet = Vec::new();
packet.push(BER_SEQUENCE);
let len_pos = packet.len();
packet.push(0x00);
// Version: 1 (v2c)
packet.push(BER_INTEGER);
packet.push(0x01);
packet.push(0x01);
// Community
packet.push(BER_OCTET_STRING);
packet.push(community.len() as u8);
packet.extend_from_slice(community.as_bytes());
// GetResponse PDU
packet.push(PDU_GET_RESPONSE);
packet.push(0x10);
packet.push(BER_INTEGER);
packet.push(0x04);
packet.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]);
packet.push(BER_INTEGER);
packet.push(0x01);
packet.push(0x00);
packet.push(BER_INTEGER);
packet.push(0x01);
packet.push(0x00);
packet.push(BER_SEQUENCE);
packet.push(0x00);
packet[len_pos] = (packet.len() - len_pos - 1) as u8;
packet
}
#[test]
fn test_snmp_v1_get() {
let packet = build_snmp_v1_get("public");
let info = analyze_snmp(&packet).expect("should parse");
assert_eq!(info.version, SnmpVersion::V1);
assert_eq!(info.community, Some("public".to_string()));
assert_eq!(info.pdu_type, SnmpPduType::GetRequest);
}
#[test]
fn test_snmp_v2c_response() {
let packet = build_snmp_v2c_response("private");
let info = analyze_snmp(&packet).expect("should parse");
assert_eq!(info.version, SnmpVersion::V2c);
assert_eq!(info.community, Some("private".to_string()));
assert_eq!(info.pdu_type, SnmpPduType::GetResponse);
}
#[test]
fn test_snmp_too_short() {
let packet = [0x30, 0x05, 0x02, 0x01, 0x00];
assert!(analyze_snmp(&packet).is_none());
}
#[test]
fn test_snmp_not_sequence() {
let packet = [
0x31, 0x10, 0x02, 0x01, 0x00, 0x04, 0x06, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63,
];
assert!(analyze_snmp(&packet).is_none());
}
#[test]
fn test_ber_length_short() {
let data = [0x10];
let (len, bytes) = parse_ber_length(&data).unwrap();
assert_eq!(len, 16);
assert_eq!(bytes, 1);
}
#[test]
fn test_ber_length_long() {
let data = [0x82, 0x01, 0x00]; // 256
let (len, bytes) = parse_ber_length(&data).unwrap();
assert_eq!(len, 256);
assert_eq!(bytes, 3);
}
/// Build an SNMPv3 message. `scoped_pdu_plaintext` selects between a
/// plaintext ScopedPDU (carrying a GetRequest) and an encrypted one.
fn build_snmp_v3(scoped_pdu_plaintext: bool) -> Vec<u8> {
let mut packet = Vec::new();
packet.push(BER_SEQUENCE);
let len_pos = packet.len();
packet.push(0x00);
// Version: INTEGER 3 (v3)
packet.push(BER_INTEGER);
packet.push(0x01);
packet.push(0x03);
// msgGlobalData SEQUENCE: msgID, msgMaxSize, msgFlags, msgSecurityModel.
// The msgID value 0x12A34456 deliberately contains a 0xA3 byte — the
// old scanning detector misread it as a SetRequest PDU tag.
packet.push(BER_SEQUENCE);
packet.push(0x10);
packet.push(BER_INTEGER);
packet.push(0x04);
packet.extend_from_slice(&[0x12, 0xA3, 0x44, 0x56]); // msgID
packet.push(BER_INTEGER);
packet.push(0x02);
packet.extend_from_slice(&[0x05, 0xDC]); // msgMaxSize 1500
packet.push(BER_OCTET_STRING);
packet.push(0x01);
packet.push(0x04); // msgFlags: reportable
packet.push(BER_INTEGER);
packet.push(0x01);
packet.push(0x03); // msgSecurityModel: USM
// msgSecurityParameters: opaque OCTET STRING
packet.push(BER_OCTET_STRING);
packet.push(0x02);
packet.extend_from_slice(&[0x30, 0x00]);
if scoped_pdu_plaintext {
// ScopedPDU SEQUENCE: contextEngineID, contextName, PDU
packet.push(BER_SEQUENCE);
packet.push(0x0D);
packet.push(BER_OCTET_STRING);
packet.push(0x02);
packet.extend_from_slice(&[0x80, 0x01]); // contextEngineID
packet.push(BER_OCTET_STRING);
packet.push(0x00); // contextName (empty)
packet.push(PDU_GET_REQUEST);
packet.push(0x05);
packet.push(BER_INTEGER);
packet.push(0x01);
packet.push(0x01); // request-id
packet.push(BER_SEQUENCE);
packet.push(0x00); // varbinds (empty)
} else {
// Encrypted ScopedPDU: opaque OCTET STRING
packet.push(BER_OCTET_STRING);
packet.push(0x04);
packet.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
}
packet[len_pos] = (packet.len() - len_pos - 1) as u8;
packet
}
#[test]
fn test_snmp_v3_plaintext_scoped_pdu() {
let packet = build_snmp_v3(true);
let info = analyze_snmp(&packet).expect("should parse");
assert_eq!(info.version, SnmpVersion::V3);
assert_eq!(info.community, None);
assert_eq!(info.pdu_type, SnmpPduType::GetRequest);
}
#[test]
fn test_snmp_v3_encrypted_scoped_pdu() {
let packet = build_snmp_v3(false);
let info = analyze_snmp(&packet).expect("should parse");
assert_eq!(info.version, SnmpVersion::V3);
assert_eq!(info.pdu_type, SnmpPduType::Encrypted);
}
#[test]
fn test_pdu_tag_not_scanned_from_value_bytes() {
// A v1 message whose community is followed by a non-PDU byte must be
// rejected — the detector must not scan ahead for a 0xA0-0xA8 byte.
let mut packet = build_snmp_v1_get("public");
// Overwrite the PDU tag with an INTEGER tag: still not SNMP.
let pdu_pos = packet.iter().position(|&b| b == PDU_GET_REQUEST).unwrap();
packet[pdu_pos] = BER_INTEGER;
assert!(analyze_snmp(&packet).is_none());
}
}
+169
View File
@@ -0,0 +1,169 @@
//! SSDP (Simple Service Discovery Protocol) Deep Packet Inspection
//!
//! Parses SSDP packets used for UPnP device discovery.
//! SSDP uses UDP port 1900 and has an HTTP-like text format.
use crate::network::types::{SsdpInfo, SsdpMethod};
/// Minimum SSDP packet size for detection
const MIN_SSDP_SIZE: usize = 10;
/// Analyze an SSDP packet and extract key information.
///
/// SSDP uses HTTP-like text format with methods like M-SEARCH and NOTIFY.
/// Returns `None` if the packet is not valid SSDP.
pub fn analyze_ssdp(payload: &[u8]) -> Option<SsdpInfo> {
if payload.len() < MIN_SSDP_SIZE {
return None;
}
// SSDP is ASCII text - check if it looks like text
if !payload.iter().take(20).all(|&b| b.is_ascii()) {
return None;
}
// Convert to string for parsing
let text = std::str::from_utf8(payload).ok()?;
let first_line = text.lines().next()?;
// Detect method from first line
let method = if first_line.starts_with("M-SEARCH") {
SsdpMethod::MSearch
} else if first_line.starts_with("NOTIFY") {
SsdpMethod::Notify
} else if first_line.starts_with("HTTP/1.1 200") || first_line.starts_with("HTTP/1.0 200") {
SsdpMethod::Response
} else {
return None;
};
// Extract service type from ST or NT header. Compare the 3-byte prefix
// with `eq_ignore_ascii_case` so we don't allocate a lowercased copy
// of every header line just to check two ASCII names.
let mut service_type = None;
for line in text.lines().skip(1) {
let is_st_or_nt = line.get(..3).is_some_and(|prefix| {
prefix.eq_ignore_ascii_case("st:") || prefix.eq_ignore_ascii_case("nt:")
});
if is_st_or_nt {
// Extract value after the colon
if let Some(value) = line.get(3..) {
let trimmed = value.trim();
if !trimmed.is_empty() {
service_type = Some(trimmed.to_string());
break;
}
}
}
}
Some(SsdpInfo {
method,
service_type,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ssdp_msearch() {
let packet = b"M-SEARCH * HTTP/1.1\r\n\
HOST: 239.255.255.250:1900\r\n\
MAN: \"ssdp:discover\"\r\n\
MX: 3\r\n\
ST: ssdp:all\r\n\
\r\n";
let info = analyze_ssdp(packet).expect("should parse");
assert_eq!(info.method, SsdpMethod::MSearch);
assert_eq!(info.service_type, Some("ssdp:all".to_string()));
}
#[test]
fn test_ssdp_notify() {
let packet = b"NOTIFY * HTTP/1.1\r\n\
HOST: 239.255.255.250:1900\r\n\
CACHE-CONTROL: max-age=1800\r\n\
LOCATION: http://192.168.1.1:80/desc.xml\r\n\
NT: upnp:rootdevice\r\n\
NTS: ssdp:alive\r\n\
\r\n";
let info = analyze_ssdp(packet).expect("should parse");
assert_eq!(info.method, SsdpMethod::Notify);
assert_eq!(info.service_type, Some("upnp:rootdevice".to_string()));
}
#[test]
fn test_ssdp_response() {
let packet = b"HTTP/1.1 200 OK\r\n\
CACHE-CONTROL: max-age=1800\r\n\
ST: urn:schemas-upnp-org:device:MediaRenderer:1\r\n\
LOCATION: http://192.168.1.100:8080/desc.xml\r\n\
\r\n";
let info = analyze_ssdp(packet).expect("should parse");
assert_eq!(info.method, SsdpMethod::Response);
assert_eq!(
info.service_type,
Some("urn:schemas-upnp-org:device:MediaRenderer:1".to_string())
);
}
#[test]
fn test_ssdp_no_service_type() {
let packet = b"M-SEARCH * HTTP/1.1\r\n\
HOST: 239.255.255.250:1900\r\n\
MAN: \"ssdp:discover\"\r\n\
\r\n";
let info = analyze_ssdp(packet).expect("should parse");
assert_eq!(info.method, SsdpMethod::MSearch);
assert!(info.service_type.is_none());
}
#[test]
fn test_ssdp_too_short() {
let packet = b"M-SEA";
assert!(analyze_ssdp(packet).is_none());
}
#[test]
fn test_ssdp_not_text() {
let packet = [0x00, 0x01, 0x02, 0x03, 0xff, 0xfe, 0x00, 0x00, 0x00, 0x00];
assert!(analyze_ssdp(&packet).is_none());
}
#[test]
fn test_ssdp_not_ssdp() {
let packet = b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n";
assert!(analyze_ssdp(packet).is_none());
}
#[test]
fn test_ssdp_mixed_case_st_and_nt_headers() {
// SSDP / UPnP servers in the wild use varied capitalisation for
// header names (HTTP §3.2 lets the field-name be case-insensitive).
// Lock the invariant the `eq_ignore_ascii_case` refactor relies on:
// any case mix on the 2-byte field-name still extracts the value.
let st_variants = [
b"M-SEARCH * HTTP/1.1\r\nST: ssdp:all\r\n\r\n".to_vec(),
b"M-SEARCH * HTTP/1.1\r\nSt: ssdp:all\r\n\r\n".to_vec(),
b"M-SEARCH * HTTP/1.1\r\nsT: ssdp:all\r\n\r\n".to_vec(),
b"M-SEARCH * HTTP/1.1\r\nst: ssdp:all\r\n\r\n".to_vec(),
];
for packet in &st_variants {
let info = analyze_ssdp(packet).expect("should parse");
assert_eq!(info.service_type, Some("ssdp:all".to_string()));
}
let nt_variants = [
b"NOTIFY * HTTP/1.1\r\nNT: upnp:rootdevice\r\n\r\n".to_vec(),
b"NOTIFY * HTTP/1.1\r\nNt: upnp:rootdevice\r\n\r\n".to_vec(),
b"NOTIFY * HTTP/1.1\r\nnT: upnp:rootdevice\r\n\r\n".to_vec(),
b"NOTIFY * HTTP/1.1\r\nnt: upnp:rootdevice\r\n\r\n".to_vec(),
];
for packet in &nt_variants {
let info = analyze_ssdp(packet).expect("should parse");
assert_eq!(info.service_type, Some("upnp:rootdevice".to_string()));
}
}
}
+540
View File
@@ -0,0 +1,540 @@
use crate::network::types::{SshConnectionState, SshInfo, SshVersion};
use log::debug;
/// Analyze payload for SSH protocol
/// is_outgoing: true if this packet is from client to server
pub fn analyze_ssh(payload: &[u8], is_outgoing: bool) -> Option<SshInfo> {
if !is_likely_ssh(payload) {
return None;
}
let mut info = SshInfo {
version: None,
client_software: None,
server_software: None,
connection_state: SshConnectionState::Banner,
algorithms: Vec::new(),
auth_method: None,
};
// Convert payload to string for banner analysis. Drive the line iterator
// directly — the loop below is the only consumer, so materializing every
// line into a `Vec<&str>` first just wastes a heap slice per parse. The
// empty-payload case falls through to the loop and is a no-op.
let text = String::from_utf8_lossy(payload);
// Parse SSH banner(s) and assign based on packet direction
for line in text.lines() {
if let Some(banner_info) = parse_ssh_banner(line) {
// Use packet direction to distinguish client vs server
if is_outgoing {
// Outgoing packet: client to server, so this banner is from client
if info.client_software.is_none() {
info.client_software = Some(banner_info.software);
info.version = Some(banner_info.version);
}
} else {
// Incoming packet: server to client, so this banner is from server
if info.server_software.is_none() {
info.server_software = Some(banner_info.software);
info.version = Some(banner_info.version);
}
}
}
}
// Detect SSH message types for connection state
// Look for SSH packet structures throughout the payload. A packet
// signature needs 6 bytes, so the last valid start offset is
// `len - 6`; the range end must therefore be `len - 5` (exclusive).
// Using `saturating_sub(6)` stopped at `len - 7` and never inspected
// the final 6-byte window, so a packet whose signature sat at the very
// end of the payload (including a payload that is exactly 6 bytes) was
// missed.
let mut found_packet_state = false;
for i in 0..payload.len().saturating_sub(5) {
if payload.len() >= i + 6 {
// Validate this looks like a real SSH packet structure
if is_valid_ssh_packet_at_offset(payload, i) {
let msg_type = payload[i + 5];
match msg_type {
20 => {
info.connection_state = SshConnectionState::KeyExchange;
debug!("SSH: Detected KEXINIT message at offset {}", i);
found_packet_state = true;
break;
}
21 => {
info.connection_state = SshConnectionState::KeyExchange;
debug!("SSH: Detected NEWKEYS message at offset {}", i);
found_packet_state = true;
break;
}
50 => {
info.connection_state = SshConnectionState::Authentication;
debug!("SSH: Detected USERAUTH_REQUEST message at offset {}", i);
found_packet_state = true;
break;
}
51 => {
info.connection_state = SshConnectionState::Authentication;
debug!("SSH: Detected USERAUTH_FAILURE message at offset {}", i);
found_packet_state = true;
break;
}
52 => {
info.connection_state = SshConnectionState::Established;
debug!("SSH: Detected USERAUTH_SUCCESS message at offset {}", i);
found_packet_state = true;
break;
}
90..=127 => {
info.connection_state = SshConnectionState::Established;
debug!("SSH: Detected connection protocol message at offset {}", i);
found_packet_state = true;
break;
}
_ => {
// Continue searching
}
}
}
}
}
// If we didn't find a packet state and we have banner info, default to Banner state
if !found_packet_state && (info.server_software.is_some() || info.client_software.is_some()) {
info.connection_state = SshConnectionState::Banner;
}
// Try to extract algorithm information. `parse_kexinit_algorithms` is a
// substring scan, so it works equally on a real KEXINIT message (msg
// type 20) and on free-form banner/text content — there's no need to
// branch on whether the payload looks like a KEXINIT.
if let Some(algorithms) = parse_kexinit_algorithms(payload) {
info.algorithms = algorithms;
}
debug!("SSH analysis result: {:?}", info);
Some(info)
}
/// Check if payload might be SSH
pub fn is_likely_ssh(payload: &[u8]) -> bool {
if payload.len() < 4 {
return false;
}
// SSH banner identification string
payload.starts_with(b"SSH-1.") ||
payload.starts_with(b"SSH-2.") ||
// Sometimes we might see SSH packets without banners
is_ssh_packet_structure(payload)
}
/// Parse SSH banner line
fn parse_ssh_banner(line: &str) -> Option<BannerInfo> {
if !line.starts_with("SSH-") {
return None;
}
let parts: Vec<&str> = line.splitn(3, '-').collect();
if parts.len() < 2 {
return None;
}
let version = match parts[1] {
"1.99" | "2.0" => SshVersion::V2,
v if v.starts_with("1.") => SshVersion::V1,
_ => SshVersion::V2, // Default to V2 for unknown versions
};
let software = if parts.len() >= 3 {
parts[2].trim().to_string()
} else {
"Unknown".to_string()
};
Some(BannerInfo { version, software })
}
/// Check if payload has SSH packet structure
fn is_ssh_packet_structure(payload: &[u8]) -> bool {
if payload.len() < 6 {
return false;
}
is_valid_ssh_packet_at_offset(payload, 0)
}
/// Check if there's a valid SSH packet structure at the given offset
fn is_valid_ssh_packet_at_offset(payload: &[u8], offset: usize) -> bool {
if payload.len() < offset + 6 {
return false;
}
// SSH packet format:
// 4 bytes: packet length
// 1 byte: padding length
// 1+ bytes: payload (message type + data)
// N bytes: padding
let packet_length = u32::from_be_bytes([
payload[offset],
payload[offset + 1],
payload[offset + 2],
payload[offset + 3],
]);
let padding_length = payload[offset + 4] as u32;
let msg_type = payload[offset + 5];
// 1. Realistic packet size (min 8 bytes for smallest valid packet)
if !(8..=35000).contains(&packet_length) {
return false;
}
// 2. RFC 4253 requires minimum 4 bytes padding
if padding_length < 4 {
return false;
}
// 3. Padding must fit within packet (padding + 1 for msg_type < packet_length)
if padding_length + 1 >= packet_length {
return false;
}
// 4. Only known SSH message types (not the full 1..=127 range)
matches!(
msg_type,
1..=4 | // transport: disconnect, ignore, unimplemented, debug
20..=21 | // kex: kexinit, newkeys
50..=52 | // auth: userauth request/failure/success
80 | // connection: global request
90..=100 // channel: open/confirm/data/eof/close/request/success/failure
)
}
/// Parse algorithms from KEXINIT message
fn parse_kexinit_algorithms(payload: &[u8]) -> Option<Vec<String>> {
// This is a simplified version - full KEXINIT parsing is quite complex
// We'll just try to extract some common algorithm names
let text = String::from_utf8_lossy(payload);
let mut algorithms = Vec::new();
// Look for common SSH algorithms
let common_algos = [
"diffie-hellman-group14-sha256",
"ecdh-sha2-nistp256",
"aes128-ctr",
"aes256-ctr",
"aes128-gcm",
"aes256-gcm",
"ssh-rsa",
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"hmac-sha2-256",
"hmac-sha2-512",
];
for algo in &common_algos {
if text.contains(algo) {
algorithms.push(algo.to_string());
}
}
if algorithms.is_empty() {
None
} else {
Some(algorithms)
}
}
/// Helper struct for banner parsing
struct BannerInfo {
version: SshVersion,
software: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_openssh_banner() {
let payload = b"SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.1\r\n";
let info = analyze_ssh(payload, false).unwrap();
assert_eq!(info.version, Some(SshVersion::V2));
assert_eq!(
info.server_software.as_deref(),
Some("OpenSSH_8.9p1 Ubuntu-3ubuntu0.1")
);
assert_eq!(info.connection_state, SshConnectionState::Banner);
}
#[test]
fn test_putty_banner() {
let payload = b"SSH-2.0-PuTTY_Release_0.76\r\n";
let info = analyze_ssh(payload, false).unwrap();
assert_eq!(info.version, Some(SshVersion::V2));
assert_eq!(info.server_software.as_deref(), Some("PuTTY_Release_0.76"));
}
#[test]
fn test_ssh1_banner() {
let payload = b"SSH-1.99-libssh_0.8.9\r\n";
let info = analyze_ssh(payload, false).unwrap();
assert_eq!(info.version, Some(SshVersion::V2)); // 1.99 maps to V2
assert_eq!(info.server_software.as_deref(), Some("libssh_0.8.9"));
}
#[test]
fn test_kexinit_detection() {
// Simplified KEXINIT packet structure
let mut payload = vec![0, 0, 0, 100]; // packet length
payload.push(10); // padding length
payload.push(20); // SSH_MSG_KEXINIT
payload.extend_from_slice(&[0; 94]); // rest of packet
let info = analyze_ssh(&payload, false).unwrap();
assert_eq!(info.connection_state, SshConnectionState::KeyExchange);
}
#[test]
fn test_userauth_success() {
let mut payload = vec![0, 0, 0, 20]; // packet length
payload.push(5); // padding length
payload.push(52); // SSH_MSG_USERAUTH_SUCCESS
payload.extend_from_slice(&[0; 14]); // padding
let info = analyze_ssh(&payload, false).unwrap();
assert_eq!(info.connection_state, SshConnectionState::Established);
}
#[test]
fn test_non_ssh_payload() {
let payload = b"HTTP/1.1 200 OK\r\n";
assert!(analyze_ssh(payload, false).is_none());
}
#[test]
fn test_partial_ssh_banner() {
let payload = b"SSH-2.0-Open";
let info = analyze_ssh(payload, false).unwrap();
assert_eq!(info.version, Some(SshVersion::V2));
}
#[test]
fn test_ssh_banner_parsing() {
assert!(parse_ssh_banner("SSH-2.0-OpenSSH_8.9").is_some());
assert!(parse_ssh_banner("SSH-1.5-oldversion").is_some());
assert!(parse_ssh_banner("HTTP/1.1 200 OK").is_none());
assert!(parse_ssh_banner("").is_none());
}
#[test]
fn test_is_likely_ssh() {
assert!(is_likely_ssh(b"SSH-2.0-OpenSSH"));
assert!(is_likely_ssh(b"SSH-1.99-libssh"));
assert!(!is_likely_ssh(b"HTTP/1.1"));
assert!(!is_likely_ssh(b"GET /"));
assert!(!is_likely_ssh(b""));
}
#[test]
fn test_ssh_packet_structure() {
// Valid SSH packet: packet_len=20, padding_len=5, msg_type=50 (USERAUTH_REQUEST)
let valid_packet = vec![0, 0, 0, 20, 5, 50, 0, 0, 0, 0];
assert!(is_ssh_packet_structure(&valid_packet));
// Valid: msg_type=20 (KEXINIT)
let kexinit_packet = vec![0, 0, 0, 20, 5, 20, 0, 0, 0, 0];
assert!(is_ssh_packet_structure(&kexinit_packet));
// Valid: msg_type=90 (channel open)
let channel_packet = vec![0, 0, 0, 20, 5, 90, 0, 0, 0, 0];
assert!(is_ssh_packet_structure(&channel_packet));
// Invalid: padding too small (< 4, RFC 4253 requires min 4)
let bad_padding = vec![0, 0, 0, 20, 2, 50, 0, 0, 0, 0];
assert!(!is_ssh_packet_structure(&bad_padding));
// Invalid: unknown message type (70 is not a known SSH message)
let bad_msg_type = vec![0, 0, 0, 20, 5, 70, 0, 0, 0, 0];
assert!(!is_ssh_packet_structure(&bad_msg_type));
// Invalid: packet too small (< 8)
let too_small = vec![0, 0, 0, 4, 4, 50, 0, 0, 0, 0];
assert!(!is_ssh_packet_structure(&too_small));
// Invalid: padding larger than packet allows
let bad_ratio = vec![0, 0, 0, 10, 10, 50, 0, 0, 0, 0];
assert!(!is_ssh_packet_structure(&bad_ratio));
// Invalid: unrealistic lengths
let invalid_packet = vec![255, 255, 255, 255, 255, 255];
assert!(!is_ssh_packet_structure(&invalid_packet));
}
#[test]
fn test_various_ssh_implementations() {
// Test different SSH software banners
let test_cases = vec![
("SSH-2.0-OpenSSH_7.4", SshVersion::V2, "OpenSSH_7.4"),
("SSH-2.0-libssh2_1.9.0", SshVersion::V2, "libssh2_1.9.0"),
(
"SSH-2.0-WinSCP_release_5.19.6",
SshVersion::V2,
"WinSCP_release_5.19.6",
),
("SSH-2.0-Paramiko_2.8.0", SshVersion::V2, "Paramiko_2.8.0"),
("SSH-1.99-Cisco-1.25", SshVersion::V2, "Cisco-1.25"), // 1.99 maps to V2
("SSH-1.5-1.2.27", SshVersion::V1, "1.2.27"),
];
for (banner, expected_version, expected_software) in test_cases {
let payload = format!("{}\r\n", banner).into_bytes();
let info = analyze_ssh(&payload, false).unwrap();
assert_eq!(
info.version,
Some(expected_version),
"Failed for banner: {}",
banner
);
assert_eq!(
info.server_software.as_deref(),
Some(expected_software),
"Failed for banner: {}",
banner
);
assert_eq!(info.connection_state, SshConnectionState::Banner);
}
}
#[test]
fn test_ssh_connection_states() {
// Test KEXINIT detection
let mut kexinit_packet = vec![0, 0, 0, 100, 10, 20]; // packet_len, padding_len, SSH_MSG_KEXINIT
kexinit_packet.extend(vec![0; 94]); // fill the packet
let info = analyze_ssh(&kexinit_packet, false).unwrap();
assert_eq!(info.connection_state, SshConnectionState::KeyExchange);
// Test USERAUTH_REQUEST
let mut userauth_packet = vec![0, 0, 0, 50, 8, 50]; // SSH_MSG_USERAUTH_REQUEST
userauth_packet.extend(vec![0; 44]);
let info = analyze_ssh(&userauth_packet, false).unwrap();
assert_eq!(info.connection_state, SshConnectionState::Authentication);
// Test USERAUTH_SUCCESS
let mut success_packet = vec![0, 0, 0, 20, 5, 52]; // SSH_MSG_USERAUTH_SUCCESS
success_packet.extend(vec![0; 14]);
let info = analyze_ssh(&success_packet, false).unwrap();
assert_eq!(info.connection_state, SshConnectionState::Established);
// Test connection protocol message
let mut conn_packet = vec![0, 0, 0, 30, 6, 95]; // Some connection protocol message
conn_packet.extend(vec![0; 24]);
let info = analyze_ssh(&conn_packet, false).unwrap();
assert_eq!(info.connection_state, SshConnectionState::Established);
}
#[test]
fn test_malformed_ssh_packets() {
// Empty payload
assert!(analyze_ssh(&[], false).is_none());
// Too short payload
assert!(analyze_ssh(&[1, 2, 3], false).is_none());
// Invalid SSH banner
let invalid_banner = b"HTTP/1.1 200 OK\r\n";
assert!(analyze_ssh(invalid_banner, false).is_none());
// Malformed SSH banner (missing parts)
let malformed_banner = b"SSH-\r\n";
assert!(analyze_ssh(malformed_banner, false).is_none());
}
#[test]
fn test_algorithm_detection() {
// Create a payload that contains some SSH algorithms in the text
let payload_with_algos =
b"SSH-2.0-test\r\nsome data aes128-ctr ssh-ed25519 hmac-sha2-256 more data";
let info = analyze_ssh(payload_with_algos, false).unwrap();
assert!(!info.algorithms.is_empty());
// Should contain some of the algorithms we look for
assert!(info.algorithms.iter().any(|a| a.contains("aes128-ctr")));
}
#[test]
fn test_edge_cases() {
// Banner with no software info
let minimal_banner = b"SSH-2.0\r\n";
let info = analyze_ssh(minimal_banner, false);
// Should still parse successfully but with minimal info
assert!(info.is_some());
// Very long banner (should still work)
let long_banner = format!("SSH-2.0-{}\r\n", "x".repeat(200)).into_bytes();
let info = analyze_ssh(&long_banner, false);
assert!(info.is_some());
// Banner with special characters
let special_banner = b"SSH-2.0-OpenSSH_8.9p1-Ubuntu-3~20.04.3\r\n";
let info = analyze_ssh(special_banner, false).unwrap();
assert_eq!(info.version, Some(SshVersion::V2));
}
#[test]
fn test_client_server_software_distinction() {
// Test server banner (incoming packet)
let server_banner = b"SSH-2.0-OpenSSH_9.9\r\n";
let server_info = analyze_ssh(server_banner, false).unwrap();
assert!(server_info.server_software.is_some());
assert!(server_info.client_software.is_none());
assert_eq!(server_info.server_software.as_ref().unwrap(), "OpenSSH_9.9");
// Test client banner (outgoing packet)
let client_banner = b"SSH-2.0-OpenSSH_9.8\r\n";
let client_info = analyze_ssh(client_banner, true).unwrap();
assert!(client_info.client_software.is_some());
assert!(client_info.server_software.is_none());
assert_eq!(client_info.client_software.as_ref().unwrap(), "OpenSSH_9.8");
}
#[test]
fn test_mixed_content() {
// Test payload that has both banner and packet data
// Banner: "SSH-2.0-OpenSSH_8.9\r\n" (21 bytes)
// Packet: \x00\x00\x00\x14\x05\x32 (packet_len=20, padding_len=5, msg_type=50/0x32)
let mixed_payload = b"SSH-2.0-OpenSSH_8.9\r\n\x00\x00\x00\x14\x05\x32additional data here";
let info = analyze_ssh(mixed_payload, false).unwrap();
assert_eq!(info.version, Some(SshVersion::V2));
assert!(info.server_software.is_some());
// The packet structure starts at offset 21, so message type is at offset 26
// Should detect the SSH_MSG_USERAUTH_REQUEST (50/0x32) in the packet data
assert_eq!(info.connection_state, SshConnectionState::Authentication);
}
#[test]
fn test_packet_state_at_last_window() {
// Banner (14 bytes) followed by a valid KEXINIT packet signature
// occupying exactly the final 6 bytes of the payload:
// packet_len=12 (0x0000000C), padding_len=4 (0x04), msg_type=20 (0x14)
// The signature starts at offset len-6, i.e. the last valid start
// offset. The previous `saturating_sub(6)` loop bound stopped one
// offset short and never inspected this window, leaving the state at
// Banner; the scan must now reach it and report KeyExchange.
let payload = b"SSH-2.0-Test\r\n\x00\x00\x00\x0c\x04\x14";
assert_eq!(payload.len(), 20);
let info = analyze_ssh(payload, false).unwrap();
assert!(info.server_software.is_some());
assert_eq!(info.connection_state, SshConnectionState::KeyExchange);
}
}
+314
View File
@@ -0,0 +1,314 @@
//! STUN (Session Traversal Utilities for NAT) Deep Packet Inspection
//!
//! Parses STUN packets according to RFC 5389 / RFC 8489.
//! STUN uses UDP port 3478 (and 5349 for STUN over TLS).
use crate::network::types::{StunInfo, StunMessageClass, StunMethod};
/// STUN header is exactly 20 bytes
const STUN_HEADER_SIZE: usize = 20;
/// Magic cookie value (RFC 5389 section 6)
const STUN_MAGIC_COOKIE: u32 = 0x2112_A442;
/// STUN attribute type: SOFTWARE (0x8022)
const ATTR_SOFTWARE: u16 = 0x8022;
/// Maximum SOFTWARE attribute length to prevent allocating huge strings
const MAX_SOFTWARE_LEN: usize = 128;
/// Analyze a STUN packet and extract key information.
///
/// Returns `None` if the packet is too small, lacks the magic cookie,
/// or has invalid structural properties.
pub fn analyze_stun(payload: &[u8]) -> Option<StunInfo> {
if payload.len() < STUN_HEADER_SIZE {
return None;
}
// First two bits must be 0b00 (distinguishes STUN from DTLS/RTP/RTCP)
if payload[0] & 0xC0 != 0x00 {
return None;
}
// Verify magic cookie at bytes 4-7
let cookie = u32::from_be_bytes([payload[4], payload[5], payload[6], payload[7]]);
if cookie != STUN_MAGIC_COOKIE {
return None;
}
// Message length (bytes 2-3) must be multiple of 4
let message_length = u16::from_be_bytes([payload[2], payload[3]]) as usize;
if !message_length.is_multiple_of(4) {
return None;
}
// Verify we have enough payload for the declared length
if payload.len() < STUN_HEADER_SIZE + message_length {
return None;
}
// Decode message type (bytes 0-1, 14 bits after masking top 2 bits)
let msg_type = u16::from_be_bytes([payload[0] & 0x3F, payload[1]]);
// Extract class bits: C1 is bit 8, C0 is bit 4 (RFC 5389 section 6).
// Both extractions mask to a single bit, so `class_bits` is bounded
// to 0..=3 by construction — the 0b11 arm covers the only remaining
// value, which lets us drop the `unreachable!()` catch-all.
let c0 = ((msg_type >> 4) & 0x1) as u8;
let c1 = ((msg_type >> 8) & 0x1) as u8;
let class_bits = (c1 << 1) | c0;
let message_class = match class_bits {
0b00 => StunMessageClass::Request,
0b01 => StunMessageClass::Indication,
0b10 => StunMessageClass::SuccessResponse,
_ => StunMessageClass::ErrorResponse, // 0b11; class_bits ∈ {0,1,2,3}
};
// Extract method: remove the class bits from msg_type
let m0_3 = msg_type & 0x000F;
let m4_6 = (msg_type >> 1) & 0x0070;
let m7_11 = (msg_type >> 2) & 0x0F80;
let method_value = m7_11 | m4_6 | m0_3;
let method = match method_value {
0x0001 => StunMethod::Binding,
other => StunMethod::Unknown(other),
};
// Extract 96-bit transaction ID (bytes 8-19)
let mut transaction_id = [0u8; 12];
transaction_id.copy_from_slice(&payload[8..20]);
// Best-effort attribute parsing for SOFTWARE
let software = parse_software_attribute(payload, message_length);
Some(StunInfo {
message_class,
method,
transaction_id,
software,
})
}
/// Check if a packet looks like STUN without full parsing.
/// Used for non-standard port detection where we want a quick probe.
pub fn is_likely_stun(payload: &[u8]) -> bool {
if payload.len() < STUN_HEADER_SIZE {
return false;
}
if payload[0] & 0xC0 != 0x00 {
return false;
}
let cookie = u32::from_be_bytes([payload[4], payload[5], payload[6], payload[7]]);
cookie == STUN_MAGIC_COOKIE
}
/// Walk STUN attributes looking for the SOFTWARE attribute (0x8022).
fn parse_software_attribute(payload: &[u8], message_length: usize) -> Option<String> {
let attrs_end = STUN_HEADER_SIZE + message_length;
let mut offset = STUN_HEADER_SIZE;
while offset + 4 <= attrs_end {
let attr_type = u16::from_be_bytes([payload[offset], payload[offset + 1]]);
let attr_length = u16::from_be_bytes([payload[offset + 2], payload[offset + 3]]) as usize;
offset += 4;
if offset + attr_length > attrs_end {
return None;
}
if attr_type == ATTR_SOFTWARE {
let effective_len = attr_length.min(MAX_SOFTWARE_LEN);
return std::str::from_utf8(&payload[offset..offset + effective_len])
.ok()
.map(|s| s.trim_end_matches('\0').to_string());
}
// Advance to next attribute, padding to 4-byte boundary
let padded_length = (attr_length + 3) & !3;
offset += padded_length;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a STUN packet with the given class, method, and body (attributes).
fn build_stun_packet(class: u8, method: u16, msg_body: &[u8]) -> Vec<u8> {
let mut packet = Vec::new();
// Encode message type from class + method (RFC 5389 section 6)
let c0 = class & 0x1;
let c1 = (class >> 1) & 0x1;
let m0_3 = method & 0x000F;
let m4_6 = (method & 0x0070) << 1;
let m7_11 = (method & 0x0F80) << 2;
let msg_type = m7_11 | ((c1 as u16) << 8) | m4_6 | ((c0 as u16) << 4) | m0_3;
packet.push((msg_type >> 8) as u8);
packet.push((msg_type & 0xFF) as u8);
// Message length
let body_len = msg_body.len() as u16;
packet.push((body_len >> 8) as u8);
packet.push((body_len & 0xFF) as u8);
// Magic cookie
packet.extend_from_slice(&STUN_MAGIC_COOKIE.to_be_bytes());
// Transaction ID (12 bytes)
packet.extend_from_slice(&[
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C,
]);
packet.extend_from_slice(msg_body);
packet
}
/// Build a SOFTWARE attribute (0x8022) with proper TLV + padding.
fn build_software_attr(software: &str) -> Vec<u8> {
let mut attr = Vec::new();
attr.push(0x80);
attr.push(0x22);
let len = software.len() as u16;
attr.push((len >> 8) as u8);
attr.push((len & 0xFF) as u8);
attr.extend_from_slice(software.as_bytes());
while attr.len() % 4 != 0 {
attr.push(0x00);
}
attr
}
#[test]
fn test_empty_payload_safe() {
assert!(analyze_stun(&[]).is_none());
}
#[test]
fn test_short_payload_safe() {
assert!(analyze_stun(&[0x00, 0x01]).is_none());
}
#[test]
fn test_binding_request() {
let packet = build_stun_packet(0, 0x0001, &[]);
let info = analyze_stun(&packet).expect("should parse");
assert_eq!(info.message_class, StunMessageClass::Request);
assert_eq!(info.method, StunMethod::Binding);
assert_eq!(info.transaction_id, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
assert!(info.software.is_none());
}
#[test]
fn test_binding_success_response() {
let packet = build_stun_packet(2, 0x0001, &[]);
let info = analyze_stun(&packet).expect("should parse");
assert_eq!(info.message_class, StunMessageClass::SuccessResponse);
assert_eq!(info.method, StunMethod::Binding);
}
#[test]
fn test_binding_error_response() {
let packet = build_stun_packet(3, 0x0001, &[]);
let info = analyze_stun(&packet).expect("should parse");
assert_eq!(info.message_class, StunMessageClass::ErrorResponse);
assert_eq!(info.method, StunMethod::Binding);
}
#[test]
fn test_binding_indication() {
let packet = build_stun_packet(1, 0x0001, &[]);
let info = analyze_stun(&packet).expect("should parse");
assert_eq!(info.message_class, StunMessageClass::Indication);
}
#[test]
fn test_with_software_attribute() {
let software_attr = build_software_attr("TestAgent/1.0");
let packet = build_stun_packet(2, 0x0001, &software_attr);
let info = analyze_stun(&packet).expect("should parse");
assert_eq!(info.software, Some("TestAgent/1.0".to_string()));
}
#[test]
fn test_too_short() {
let packet = [0x00; 10];
assert!(analyze_stun(&packet).is_none());
}
#[test]
fn test_wrong_magic_cookie() {
let mut packet = build_stun_packet(0, 0x0001, &[]);
packet[4] = 0xFF;
assert!(analyze_stun(&packet).is_none());
}
#[test]
fn test_first_two_bits_nonzero() {
let mut packet = build_stun_packet(0, 0x0001, &[]);
packet[0] |= 0x80;
assert!(analyze_stun(&packet).is_none());
}
#[test]
fn test_message_length_not_multiple_of_4() {
let mut packet = build_stun_packet(0, 0x0001, &[]);
packet[2] = 0x00;
packet[3] = 0x03;
assert!(analyze_stun(&packet).is_none());
}
#[test]
fn test_message_length_exceeds_payload() {
let mut packet = build_stun_packet(0, 0x0001, &[]);
packet[2] = 0x00;
packet[3] = 0x64;
assert!(analyze_stun(&packet).is_none());
}
#[test]
fn test_unknown_method() {
let packet = build_stun_packet(0, 0x0003, &[]);
let info = analyze_stun(&packet).expect("should parse");
assert_eq!(info.method, StunMethod::Unknown(0x0003));
}
#[test]
fn test_class_bits_exhaustive_for_unknown_method() {
// Lock the invariant the refactor relies on: class_bits is bounded
// to 0..=3 by construction (each of c0/c1 is masked to one bit).
// Pair every class with a non-Binding method to confirm the class
// decode is independent of method recognition — otherwise a future
// change to method handling could mask a class-bit regression.
for (class, expected) in [
(0u8, StunMessageClass::Request),
(1u8, StunMessageClass::Indication),
(2u8, StunMessageClass::SuccessResponse),
(3u8, StunMessageClass::ErrorResponse),
] {
let packet = build_stun_packet(class, 0x0003, &[]); // unknown method
let info = analyze_stun(&packet).expect("should parse");
assert_eq!(info.message_class, expected);
assert_eq!(info.method, StunMethod::Unknown(0x0003));
}
}
#[test]
fn test_is_likely_stun() {
let packet = build_stun_packet(0, 0x0001, &[]);
assert!(is_likely_stun(&packet));
let not_stun = [0x80u8; 20];
assert!(!is_likely_stun(&not_stun));
let too_short = [0x00u8; 10];
assert!(!is_likely_stun(&too_short));
}
}
+469
View File
@@ -0,0 +1,469 @@
//! GeoIP resolver with caching for Country and ASN lookups.
//!
//! Provides GeoIP lookups using MaxMind databases with an LRU cache
//! to avoid repeated lookups for the same IP address.
use dashmap::DashMap;
use log::{debug, info, warn};
use maxminddb::{Reader, geoip2};
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
/// GeoIP information for an IP address
#[derive(Debug, Clone, Default)]
pub struct GeoIpInfo {
/// ISO 3166-1 alpha-2 country code (e.g., "US", "DE", "JP")
pub country_code: Option<String>,
/// Country name in English (e.g., "United States", "Germany")
pub country_name: Option<String>,
/// Autonomous System Number (e.g., 15169 for Google)
pub asn: Option<u32>,
/// AS Organization name (e.g., "GOOGLE")
pub as_org: Option<String>,
/// postal code (e.g., "94043")
pub postal_code: Option<String>,
/// city name (e.g., "Mountain View")
pub city: Option<String>,
}
impl GeoIpInfo {
/// Check if any GeoIP data is available
pub fn has_data(&self) -> bool {
self.country_code.is_some() || self.asn.is_some() || self.city.is_some()
}
/// Get just the country code or "-" if unavailable
pub fn country_display(&self) -> &str {
self.country_code.as_deref().unwrap_or("-")
}
}
/// Cached GeoIP entry
#[derive(Debug, Clone)]
struct CachedGeoIp {
/// The resolved GeoIP info
info: GeoIpInfo,
/// When this entry was cached
cached_at: Instant,
}
/// Configuration for GeoIP resolver
#[derive(Debug, Clone)]
pub struct GeoIpConfig {
/// Path to GeoLite2-Country.mmdb database
pub country_db_path: Option<PathBuf>,
/// Path to GeoLite2-ASN.mmdb database
pub asn_db_path: Option<PathBuf>,
/// Path to GeoLite2-City.mmdb database
pub city_db_path: Option<PathBuf>,
/// Cache TTL (default: 1 hour - GeoIP data rarely changes)
pub cache_ttl: Duration,
/// Maximum cache size (default: 50000 entries)
pub max_cache_size: usize,
}
impl Default for GeoIpConfig {
fn default() -> Self {
Self {
country_db_path: None,
asn_db_path: None,
city_db_path: None,
cache_ttl: Duration::from_secs(3600), // 1 hour
max_cache_size: 50000,
}
}
}
/// GeoIP resolver with caching
pub struct GeoIpResolver {
/// Country database reader
country_reader: Option<Reader<Vec<u8>>>,
/// ASN database reader
asn_reader: Option<Reader<Vec<u8>>>,
/// City database reader
city_reader: Option<Reader<Vec<u8>>>,
/// Cache: IP -> CachedGeoIp
cache: Arc<DashMap<IpAddr, CachedGeoIp>>,
/// Configuration
config: GeoIpConfig,
}
impl GeoIpResolver {
/// Create a new GeoIP resolver with the given configuration
pub fn new(config: GeoIpConfig) -> Self {
let country_reader =
config
.country_db_path
.as_ref()
.and_then(|path| match Reader::open_readfile(path) {
Ok(reader) => {
info!("Loaded GeoIP Country database from: {:?}", path);
Some(reader)
}
Err(e) => {
warn!(
"Failed to load GeoIP Country database from {:?}: {}",
path, e
);
None
}
});
let asn_reader =
config
.asn_db_path
.as_ref()
.and_then(|path| match Reader::open_readfile(path) {
Ok(reader) => {
info!("Loaded GeoIP ASN database from: {:?}", path);
Some(reader)
}
Err(e) => {
warn!("Failed to load GeoIP ASN database from {:?}: {}", path, e);
None
}
});
let city_reader =
config
.city_db_path
.as_ref()
.and_then(|path| match Reader::open_readfile(path) {
Ok(reader) => {
info!("Loaded GeoIP City database from: {:?}", path);
Some(reader)
}
Err(e) => {
warn!("Failed to load GeoIP City database from {:?}: {}", path, e);
None
}
});
Self {
country_reader,
asn_reader,
city_reader,
cache: Arc::new(DashMap::new()),
config,
}
}
/// Try to auto-discover and load databases from common paths
pub fn with_auto_discovery() -> Self {
let mut config = GeoIpConfig::default();
// Common paths to search for databases
let search_paths = Self::get_search_paths();
for base_path in search_paths {
// Try Country database
if config.country_db_path.is_none() {
let country_path = base_path.join("GeoLite2-Country.mmdb");
if country_path.exists() {
config.country_db_path = Some(country_path);
}
}
// Try ASN database
if config.asn_db_path.is_none() {
let asn_path = base_path.join("GeoLite2-ASN.mmdb");
if asn_path.exists() {
config.asn_db_path = Some(asn_path);
}
}
// Try City database
if config.city_db_path.is_none() {
let city_path = base_path.join("GeoLite2-City.mmdb");
if city_path.exists() {
config.city_db_path = Some(city_path);
}
}
// Stop if all three found
if config.country_db_path.is_some()
&& config.asn_db_path.is_some()
&& config.city_db_path.is_some()
{
break;
}
}
Self::new(config)
}
/// Get common search paths for GeoIP databases
///
/// This is public so that the Landlock sandbox can whitelist these paths
/// for read access.
pub fn get_search_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
// Current directory / resources
paths.push(PathBuf::from("resources/geoip2"));
paths.push(PathBuf::from("."));
// XDG data directory
if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME") {
paths.push(PathBuf::from(&xdg_data).join("rustnet/geoip"));
paths.push(PathBuf::from(xdg_data).join("GeoIP"));
}
// Home directory
if let Ok(home) = std::env::var("HOME") {
let home_path = PathBuf::from(&home);
paths.push(home_path.join(".local/share/rustnet/geoip"));
paths.push(home_path.join(".local/share/GeoIP"));
}
// System paths
paths.push(PathBuf::from("/usr/share/GeoIP"));
paths.push(PathBuf::from("/usr/local/share/GeoIP"));
paths.push(PathBuf::from("/opt/homebrew/share/GeoIP"));
paths.push(PathBuf::from("/var/lib/GeoIP"));
// Windows paths
#[cfg(target_os = "windows")]
{
if let Ok(program_data) = std::env::var("ProgramData") {
paths.push(PathBuf::from(program_data).join("GeoIP"));
}
}
paths
}
/// Check if the resolver has any databases loaded
pub fn is_available(&self) -> bool {
self.country_reader.is_some() || self.asn_reader.is_some() || self.city_reader.is_some()
}
/// Check which databases are available.
/// Returns (has_location, has_asn, has_city) where has_location is true when
/// either the country or city database is loaded (city DB is a superset of country).
pub fn get_status(&self) -> (bool, bool, bool) {
let has_location = self.country_reader.is_some() || self.city_reader.is_some();
(
has_location,
self.asn_reader.is_some(),
self.city_reader.is_some(),
)
}
/// Lookup GeoIP information for an IP address
pub fn lookup(&self, ip: IpAddr) -> GeoIpInfo {
// Skip private/local addresses
if is_private_or_local(&ip) {
return GeoIpInfo::default();
}
// Check cache first
if let Some(cached) = self.cache.get(&ip)
&& cached.cached_at.elapsed() < self.config.cache_ttl
{
return cached.info.clone();
}
// Perform lookup
let info = self.do_lookup(ip);
// Cache the result
self.cache.insert(
ip,
CachedGeoIp {
info: info.clone(),
cached_at: Instant::now(),
},
);
// Evict old entries if cache is too large
if self.cache.len() > self.config.max_cache_size {
self.evict_oldest_entries();
}
info
}
/// Perform the actual database lookup
fn do_lookup(&self, ip: IpAddr) -> GeoIpInfo {
let mut info = GeoIpInfo::default();
// Country lookup
if let Some(ref reader) = self.country_reader
&& let Ok(Some(country)) = reader
.lookup(ip)
.and_then(|r| r.decode::<geoip2::Country>())
{
let c = &country.country;
info.country_code = c.iso_code.map(|s| s.to_string());
info.country_name = c.names.english.map(|s| s.to_string());
}
// ASN lookup
if let Some(ref reader) = self.asn_reader
&& let Ok(Some(asn)) = reader.lookup(ip).and_then(|r| r.decode::<geoip2::Asn>())
{
info.asn = asn.autonomous_system_number;
info.as_org = asn.autonomous_system_organization.map(|s| s.to_string());
}
// City lookup (City DB is a superset of Country — also fills country fields as fallback)
if let Some(ref reader) = self.city_reader
&& let Ok(Some(city)) = reader.lookup(ip).and_then(|r| r.decode::<geoip2::City>())
{
info.postal_code = city.postal.code.map(|s| s.to_string());
// NOTE: City names can be in multiple languages, we take English if available
info.city = city.city.names.english.map(|s| s.to_string());
// Fall back to country info from City DB if Country DB was not loaded
if info.country_code.is_none() {
info.country_code = city.country.iso_code.map(|s| s.to_string());
info.country_name = city.country.names.english.map(|s| s.to_string());
}
}
info
}
/// Evict oldest entries from cache
fn evict_oldest_entries(&self) {
let target_size = self.config.max_cache_size * 3 / 4; // Evict to 75%
let mut entries: Vec<_> = self.cache.iter().map(|e| (*e.key(), e.cached_at)).collect();
let to_remove = self.cache.len().saturating_sub(target_size);
if to_remove == 0 {
return;
}
entries.select_nth_unstable_by_key(to_remove - 1, |(_, time)| *time);
for (ip, _) in entries.into_iter().take(to_remove) {
self.cache.remove(&ip);
}
debug!(
"GeoIP cache evicted {} entries, now {} entries",
to_remove,
self.cache.len()
);
}
}
/// Check if IP is private, local, or reserved
fn is_private_or_local(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_broadcast()
|| v4.is_documentation()
|| v4.is_unspecified()
// 100.64.0.0/10 (CGNAT)
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64)
}
IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unspecified()
// Link-local (fe80::/10)
|| ((v6.segments()[0] & 0xffc0) == 0xfe80)
// Unique local (fc00::/7)
|| ((v6.segments()[0] & 0xfe00) == 0xfc00)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_geoip_info_has_data() {
let info = GeoIpInfo {
country_code: Some("US".to_string()),
country_name: Some("United States".to_string()),
asn: Some(15169),
as_org: Some("GOOGLE".to_string()),
postal_code: Some("94043".to_string()),
city: Some("Mountain View".to_string()),
};
assert!(info.has_data());
assert_eq!(info.country_display(), "US");
}
#[test]
fn test_geoip_info_country_only() {
let info = GeoIpInfo {
country_code: Some("DE".to_string()),
country_name: Some("Germany".to_string()),
asn: None,
as_org: None,
postal_code: None,
city: None,
};
assert!(info.has_data());
assert_eq!(info.country_display(), "DE");
}
#[test]
fn test_geoip_info_asn_only() {
let info = GeoIpInfo {
country_code: None,
country_name: None,
asn: Some(13335),
as_org: Some("CLOUDFLARENET".to_string()),
postal_code: None,
city: None,
};
assert!(info.has_data());
assert_eq!(info.country_display(), "-");
}
#[test]
fn test_geoip_info_empty() {
let info = GeoIpInfo::default();
assert_eq!(info.country_display(), "-");
assert!(!info.has_data());
}
#[test]
fn test_private_ip_detection() {
// IPv4 private
assert!(is_private_or_local(&"192.168.1.1".parse().unwrap()));
assert!(is_private_or_local(&"10.0.0.1".parse().unwrap()));
assert!(is_private_or_local(&"172.16.0.1".parse().unwrap()));
assert!(is_private_or_local(&"127.0.0.1".parse().unwrap()));
assert!(is_private_or_local(&"169.254.1.1".parse().unwrap())); // Link-local
// CGNAT range
assert!(is_private_or_local(&"100.64.0.1".parse().unwrap()));
assert!(is_private_or_local(&"100.127.255.255".parse().unwrap()));
// Public IPs
assert!(!is_private_or_local(&"8.8.8.8".parse().unwrap()));
assert!(!is_private_or_local(&"1.1.1.1".parse().unwrap()));
// IPv6
assert!(is_private_or_local(&"::1".parse().unwrap())); // Loopback
assert!(is_private_or_local(&"fe80::1".parse().unwrap())); // Link-local
assert!(is_private_or_local(&"fc00::1".parse().unwrap())); // Unique local
assert!(!is_private_or_local(
&"2001:4860:4860::8888".parse().unwrap()
)); // Google DNS
}
#[test]
fn test_country_display() {
let info = GeoIpInfo {
country_code: Some("JP".to_string()),
..Default::default()
};
assert_eq!(info.country_display(), "JP");
let empty = GeoIpInfo::default();
assert_eq!(empty.country_display(), "-");
}
}
@@ -0,0 +1,162 @@
use std::io;
use std::time::SystemTime;
/// Statistics for a network interface
#[derive(Debug, Clone)]
pub struct InterfaceStats {
pub interface_name: String,
pub rx_bytes: u64,
pub tx_bytes: u64,
pub rx_packets: u64,
pub tx_packets: u64,
pub rx_errors: u64,
pub tx_errors: u64,
pub rx_dropped: u64,
pub tx_dropped: u64,
pub collisions: u64,
pub timestamp: SystemTime,
}
impl InterfaceStats {
/// Calculate rates from two snapshots
pub fn calculate_rates(&self, previous: &InterfaceStats) -> InterfaceRates {
let duration = self
.timestamp
.duration_since(previous.timestamp)
.unwrap_or_default()
.as_secs_f64();
if duration == 0.0 {
return InterfaceRates::default();
}
InterfaceRates {
rx_bytes_per_sec: ((self.rx_bytes.saturating_sub(previous.rx_bytes)) as f64 / duration)
as u64,
tx_bytes_per_sec: ((self.tx_bytes.saturating_sub(previous.tx_bytes)) as f64 / duration)
as u64,
}
}
}
/// Rate calculations for interface statistics
#[derive(Debug, Clone, Default)]
pub struct InterfaceRates {
pub rx_bytes_per_sec: u64,
pub tx_bytes_per_sec: u64,
}
/// Trait for platform-specific interface statistics providers
pub trait InterfaceStatsProvider: Send + Sync {
/// Get statistics for all available interfaces
fn get_all_stats(&self) -> Result<Vec<InterfaceStats>, io::Error>;
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn test_rate_calculation() {
let t1 = SystemTime::now();
let t2 = t1 + Duration::from_secs(1);
let stats1 = InterfaceStats {
interface_name: "test".to_string(),
rx_bytes: 1000,
tx_bytes: 500,
rx_packets: 10,
tx_packets: 5,
rx_errors: 0,
tx_errors: 0,
rx_dropped: 0,
tx_dropped: 0,
collisions: 0,
timestamp: t1,
};
let stats2 = InterfaceStats {
interface_name: "test".to_string(),
rx_bytes: 2000,
tx_bytes: 1000,
rx_packets: 20,
tx_packets: 10,
rx_errors: 0,
tx_errors: 0,
rx_dropped: 0,
tx_dropped: 0,
collisions: 0,
timestamp: t2,
};
let rates = stats2.calculate_rates(&stats1);
assert_eq!(rates.rx_bytes_per_sec, 1000);
assert_eq!(rates.tx_bytes_per_sec, 500);
}
#[test]
fn test_rate_calculation_zero_duration() {
let t = SystemTime::now();
let stats1 = InterfaceStats {
interface_name: "test".to_string(),
rx_bytes: 1000,
tx_bytes: 500,
rx_packets: 10,
tx_packets: 5,
rx_errors: 0,
tx_errors: 0,
rx_dropped: 0,
tx_dropped: 0,
collisions: 0,
timestamp: t,
};
let stats2 = stats1.clone();
let rates = stats2.calculate_rates(&stats1);
assert_eq!(rates.rx_bytes_per_sec, 0);
assert_eq!(rates.tx_bytes_per_sec, 0);
}
#[test]
fn test_rate_calculation_with_counter_wrapping() {
let t1 = SystemTime::now();
let t2 = t1 + Duration::from_secs(1);
let stats1 = InterfaceStats {
interface_name: "test".to_string(),
rx_bytes: 1000,
tx_bytes: 500,
rx_packets: 10,
tx_packets: 5,
rx_errors: 0,
tx_errors: 0,
rx_dropped: 0,
tx_dropped: 0,
collisions: 0,
timestamp: t1,
};
// Simulate counter reset (should use saturating_sub to avoid panic)
let stats2 = InterfaceStats {
interface_name: "test".to_string(),
rx_bytes: 500, // Less than previous
tx_bytes: 250,
rx_packets: 5,
tx_packets: 2,
rx_errors: 0,
tx_errors: 0,
rx_dropped: 0,
tx_dropped: 0,
collisions: 0,
timestamp: t2,
};
let rates = stats2.calculate_rates(&stats1);
// Should result in 0 due to saturating_sub
assert_eq!(rates.rx_bytes_per_sec, 0);
assert_eq!(rates.tx_bytes_per_sec, 0);
}
}
@@ -0,0 +1,136 @@
//! Ethernet (IEEE 802.3) frame parsing
//!
//! Handles DLT_EN10MB (Ethernet) frames with 14-byte headers and
//! 802.1Q VLAN-tagged frames with 18-byte headers.
use crate::network::parser::{PacketParser, ParsedPacket};
/// Extract the effective EtherType and payload offset from an Ethernet frame.
///
/// Returns `(ethertype, offset)` where `offset` is the number of bytes before
/// the IP/ARP payload:
/// - Standard frame: offset = 14
/// - 802.1Q VLAN-tagged frame: offset = 18 (extra 4-byte VLAN tag)
fn extract_ethertype(data: &[u8]) -> Option<(u16, usize)> {
let ethertype = u16::from_be_bytes([data[12], data[13]]);
if ethertype == 0x8100 {
if data.len() < 18 {
log::debug!("VLAN frame too small: {} bytes", data.len());
return None;
}
let vlan_id = u16::from_be_bytes([data[14], data[15]]) & 0x0FFF;
log::trace!("Ethernet: 802.1Q VLAN tag detected (VID={})", vlan_id);
Some((u16::from_be_bytes([data[16], data[17]]), 18))
} else {
Some((ethertype, 14))
}
}
/// Parse an Ethernet frame and extract the network layer packet
///
/// Standard Ethernet frame format (14 bytes):
/// - Destination MAC (6 bytes)
/// - Source MAC (6 bytes)
/// - EtherType (2 bytes)
///
/// 802.1Q VLAN-tagged frame format (18 bytes):
/// - Destination MAC (6 bytes)
/// - Source MAC (6 bytes)
/// - TPID: 0x8100 (2 bytes)
/// - TCI: PCP (3 bits) | DEI (1 bit) | VID (12 bits) (2 bytes)
/// - Inner EtherType (2 bytes)
///
/// Returns the parsed packet if successful
pub fn parse(
data: &[u8],
parser: &PacketParser,
process_name: Option<String>,
process_id: Option<u32>,
) -> Option<ParsedPacket> {
if data.len() < 14 {
log::debug!("Ethernet frame too small: {} bytes", data.len());
return None;
}
let (ethertype, offset) = extract_ethertype(data)?;
match ethertype {
0x0800 => {
// IPv4
log::trace!("Ethernet: IPv4 packet detected");
parser.parse_ipv4_packet_inner(data, offset, process_name, process_id)
}
0x86dd => {
// IPv6
log::trace!("Ethernet: IPv6 packet detected");
parser.parse_ipv6_packet_inner(data, offset, process_name, process_id)
}
0x0806 => {
// ARP
log::trace!("Ethernet: ARP packet detected");
parser.parse_arp_packet_inner(data, offset, process_name, process_id)
}
_ => {
log::debug!("Ethernet: Unknown EtherType: 0x{:04x}", ethertype);
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ethernet_frame_too_small() {
// Ethernet frames must be at least 14 bytes
let small_frame = vec![0x00, 0x11, 0x22];
let parser = PacketParser::new();
assert!(parse(&small_frame, &parser, None, None).is_none());
}
#[test]
fn test_extract_ethertype_vlan() {
// Build an 18-byte 802.1Q VLAN-tagged frame:
// bytes 0-11: MACs (zeroed)
// bytes 12-13: TPID 0x8100
// bytes 14-15: TCI (VID = 42)
// bytes 16-17: inner EtherType 0x0800 (IPv4)
let mut frame = vec![0u8; 18];
frame[12] = 0x81;
frame[13] = 0x00;
frame[14] = 0x00;
frame[15] = 0x2a; // VID = 42
frame[16] = 0x08;
frame[17] = 0x00;
let (ethertype, offset) = extract_ethertype(&frame).unwrap();
assert_eq!(ethertype, 0x0800);
assert_eq!(offset, 18);
}
#[test]
fn test_extract_ethertype_vlan_ipv6() {
let mut frame = vec![0u8; 18];
frame[12] = 0x81;
frame[13] = 0x00;
frame[14] = 0x00;
frame[15] = 0x2a; // VID = 42
frame[16] = 0x86;
frame[17] = 0xdd; // inner EtherType = IPv6
let (ethertype, offset) = extract_ethertype(&frame).unwrap();
assert_eq!(ethertype, 0x86dd);
assert_eq!(offset, 18);
}
#[test]
fn test_extract_ethertype_vlan_too_small() {
// VLAN frame must be at least 18 bytes; 17 should return None
let mut frame = vec![0u8; 17];
frame[12] = 0x81;
frame[13] = 0x00;
assert!(extract_ethertype(&frame).is_none());
}
}
@@ -0,0 +1,194 @@
//! Linux "cooked" capture parsing
//!
//! Handles DLT_LINUX_SLL (113) and DLT_LINUX_SLL2 (276)
//! Used by the Linux "any" pseudo-interface
use crate::network::parser::{PacketParser, ParsedPacket};
/// Parse Linux Cooked Capture v1 packet (DLT_LINUX_SLL)
///
/// Header format (16 bytes):
/// - Packet type (2 bytes)
/// - ARPHRD type (2 bytes)
/// - Link-layer address length (2 bytes)
/// - Link-layer address (8 bytes)
/// - Protocol type (2 bytes) - EtherType
///
/// IP payload starts at byte 16
pub fn parse_sll(
data: &[u8],
parser: &PacketParser,
process_name: Option<String>,
process_id: Option<u32>,
) -> Option<ParsedPacket> {
if data.len() < 16 {
log::debug!("Linux SLL packet too small: {} bytes", data.len());
return None;
}
// Protocol type is at bytes 14-15 (EtherType)
let protocol = u16::from_be_bytes([data[14], data[15]]);
match protocol {
0x0800 => {
// IPv4 - payload starts at byte 16
log::trace!("Linux SLL: IPv4 packet detected");
let ip_data = &data[16..];
parser.parse_raw_ipv4_packet(ip_data, process_name, process_id)
}
0x86dd => {
// IPv6 - payload starts at byte 16
log::trace!("Linux SLL: IPv6 packet detected");
let ip_data = &data[16..];
parser.parse_raw_ipv6_packet(ip_data, process_name, process_id)
}
0x0806 => {
// ARP - payload starts at byte 16
log::trace!("Linux SLL: ARP packet detected");
parser.parse_arp_packet_with_offset(data, 16, process_name, process_id)
}
0x8100 => {
// 802.1Q VLAN-tagged (reconstructed by libpcap from kernel metadata)
// Layout after reconstruction: SLL header (14 bytes) + TPID (2) + TCI (2) + inner EtherType (2)
if data.len() < 20 {
log::debug!("Linux SLL: VLAN frame too small: {} bytes", data.len());
return None;
}
let inner_proto = u16::from_be_bytes([data[18], data[19]]);
match inner_proto {
0x0800 => {
log::trace!("Linux SLL: VLAN-tagged IPv4 packet detected");
parser.parse_raw_ipv4_packet(&data[20..], process_name, process_id)
}
0x86dd => {
log::trace!("Linux SLL: VLAN-tagged IPv6 packet detected");
parser.parse_raw_ipv6_packet(&data[20..], process_name, process_id)
}
0x0806 => {
log::trace!("Linux SLL: VLAN-tagged ARP packet detected");
parser.parse_arp_packet_with_offset(data, 20, process_name, process_id)
}
_ => None,
}
}
_ => {
log::debug!("Linux SLL: Unknown protocol: 0x{:04x}", protocol);
None
}
}
}
/// Parse Linux Cooked Capture v2 packet (DLT_LINUX_SLL2)
///
/// Header format (20 bytes):
/// - Protocol type (2 bytes) - EtherType
/// - Reserved (2 bytes)
/// - Interface index (4 bytes)
/// - ARPHRD type (2 bytes)
/// - Packet type (1 byte)
/// - Link-layer address length (1 byte)
/// - Link-layer address (8 bytes)
///
/// IP payload starts at byte 20
pub fn parse_sll2(
data: &[u8],
parser: &PacketParser,
process_name: Option<String>,
process_id: Option<u32>,
) -> Option<ParsedPacket> {
if data.len() < 20 {
log::debug!("Linux SLL2 packet too small: {} bytes", data.len());
return None;
}
// Protocol type is at bytes 0-1 (EtherType)
let protocol = u16::from_be_bytes([data[0], data[1]]);
match protocol {
0x0800 => {
// IPv4 - payload starts at byte 20
log::trace!("Linux SLL2: IPv4 packet detected");
let ip_data = &data[20..];
parser.parse_raw_ipv4_packet(ip_data, process_name, process_id)
}
0x86dd => {
// IPv6 - payload starts at byte 20
log::trace!("Linux SLL2: IPv6 packet detected");
let ip_data = &data[20..];
parser.parse_raw_ipv6_packet(ip_data, process_name, process_id)
}
0x0806 => {
// ARP - payload starts at byte 20
log::trace!("Linux SLL2: ARP packet detected");
parser.parse_arp_packet_with_offset(data, 20, process_name, process_id)
}
0x8100 => {
// 802.1Q VLAN-tagged (visible when rx-vlan-offload is disabled;
// libpcap does not reconstruct VLAN tags for SLL2, see libpcap#1105)
// Layout: SLL2 header (20 bytes) + TCI (2) + inner EtherType (2)
if data.len() < 24 {
log::debug!("Linux SLL2: VLAN frame too small: {} bytes", data.len());
return None;
}
let inner_proto = u16::from_be_bytes([data[22], data[23]]);
match inner_proto {
0x0800 => {
log::trace!("Linux SLL2: VLAN-tagged IPv4 packet detected");
parser.parse_raw_ipv4_packet(&data[24..], process_name, process_id)
}
0x86dd => {
log::trace!("Linux SLL2: VLAN-tagged IPv6 packet detected");
parser.parse_raw_ipv6_packet(&data[24..], process_name, process_id)
}
0x0806 => {
log::trace!("Linux SLL2: VLAN-tagged ARP packet detected");
parser.parse_arp_packet_with_offset(data, 24, process_name, process_id)
}
_ => None,
}
}
_ => {
log::debug!("Linux SLL2: Unknown protocol: 0x{:04x}", protocol);
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sll_packet_too_small() {
let small_packet = vec![0x00; 10];
let parser = PacketParser::new();
assert!(parse_sll(&small_packet, &parser, None, None).is_none());
}
#[test]
fn test_sll2_packet_too_small() {
let small_packet = vec![0x00; 15];
let parser = PacketParser::new();
assert!(parse_sll2(&small_packet, &parser, None, None).is_none());
}
#[test]
fn test_sll_vlan_too_small() {
// SLL VLAN frame needs at least 20 bytes (16 header + 4 VLAN tag)
let mut packet = vec![0x00; 19];
packet[14] = 0x81; // Protocol = 0x8100 (VLAN)
packet[15] = 0x00;
let parser = PacketParser::new();
assert!(parse_sll(&packet, &parser, None, None).is_none());
}
#[test]
fn test_sll2_vlan_too_small() {
// SLL2 VLAN frame needs at least 24 bytes (20 header + 4 VLAN tag)
let mut packet = vec![0x00; 23];
packet[0] = 0x81; // Protocol = 0x8100 (VLAN)
packet[1] = 0x00;
let parser = PacketParser::new();
assert!(parse_sll2(&packet, &parser, None, None).is_none());
}
}
@@ -0,0 +1,173 @@
//! Link layer (Layer 2) packet parsing
//!
//! This module handles data-link layer protocols and extracts network-layer packets:
//! - Ethernet (DLT_EN10MB)
//! - Linux Cooked Capture v1 and v2 (DLT_LINUX_SLL, DLT_LINUX_SLL2)
//! - Raw IP packets (DLT_RAW, LINKTYPE_IPV4, LINKTYPE_IPV6)
//! - TUN/TAP interfaces
//! - PKTAP (macOS process metadata)
pub mod ethernet;
pub mod linux_sll;
#[cfg(target_os = "macos")]
pub mod pktap;
pub mod raw_ip;
pub mod tun_tap;
/// Data Link Type (DLT) constants
/// These match the values from libpcap
pub mod dlt {
pub const EN10MB: i32 = 1; // Ethernet
pub const RAW: i32 = 12; // Raw IP (no link layer)
pub const NULL: i32 = 0; // BSD loopback (sometimes used by TUN)
pub const LINUX_SLL: i32 = 113; // Linux "cooked" capture v1
pub const PKTAP: i32 = 149; // Apple PKTAP (DLT_USER2)
pub const PKTAP_STANDARD: i32 = 258; // Standard PKTAP
pub const LINUX_SLL2: i32 = 276; // Linux "cooked" capture v2
// Link type values for raw IP packets
pub const LINKTYPE_RAW: i32 = 101; // Raw IPv4/IPv6
pub const LINKTYPE_IPV4: i32 = 228; // Raw IPv4 only
pub const LINKTYPE_IPV6: i32 = 229; // Raw IPv6 only
}
/// Link layer type enum for identifying interface types
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinkLayerType {
Ethernet,
RawIP,
LinuxSLL,
LinuxSLL2,
Pktap,
Tun,
Tap,
Unknown,
}
impl LinkLayerType {
/// Determine link layer type from DLT value
pub fn from_dlt(dlt: i32) -> Self {
match dlt {
dlt::EN10MB => LinkLayerType::Ethernet,
dlt::RAW | dlt::NULL | dlt::LINKTYPE_RAW | dlt::LINKTYPE_IPV4 | dlt::LINKTYPE_IPV6 => {
LinkLayerType::RawIP
}
dlt::LINUX_SLL => LinkLayerType::LinuxSLL,
dlt::LINUX_SLL2 => LinkLayerType::LinuxSLL2,
dlt::PKTAP | dlt::PKTAP_STANDARD => LinkLayerType::Pktap,
_ => LinkLayerType::Unknown,
}
}
/// Determine link layer type from both DLT value and interface name
///
/// This is more accurate than `from_dlt()` alone because it can distinguish
/// TUN/TAP interfaces from regular interfaces that use the same DLT codes.
///
/// # Example
///
/// ```rust,ignore
/// let link_type = LinkLayerType::from_dlt_and_name(12, "tun0");
/// assert!(matches!(link_type, LinkLayerType::Tun));
///
/// let link_type = LinkLayerType::from_dlt_and_name(1, "tap0");
/// assert!(matches!(link_type, LinkLayerType::Tap));
/// ```
pub fn from_dlt_and_name(dlt: i32, interface_name: &str) -> Self {
// Check if this is a TUN/TAP interface by name
if tun_tap::is_tun_interface(interface_name) {
return LinkLayerType::Tun;
}
if tun_tap::is_tap_interface(interface_name) {
return LinkLayerType::Tap;
}
// Otherwise, use DLT-based detection
Self::from_dlt(dlt)
}
/// Check if this link type represents a TUN/TAP interface
pub fn is_tunnel(&self) -> bool {
matches!(self, LinkLayerType::Tun | LinkLayerType::Tap)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_linktype_from_dlt() {
assert_eq!(
LinkLayerType::from_dlt(dlt::EN10MB),
LinkLayerType::Ethernet
);
assert_eq!(LinkLayerType::from_dlt(dlt::RAW), LinkLayerType::RawIP);
assert_eq!(LinkLayerType::from_dlt(dlt::NULL), LinkLayerType::RawIP);
assert_eq!(
LinkLayerType::from_dlt(dlt::LINUX_SLL),
LinkLayerType::LinuxSLL
);
assert_eq!(
LinkLayerType::from_dlt(dlt::LINUX_SLL2),
LinkLayerType::LinuxSLL2
);
assert_eq!(LinkLayerType::from_dlt(dlt::PKTAP), LinkLayerType::Pktap);
assert_eq!(
LinkLayerType::from_dlt(dlt::PKTAP_STANDARD),
LinkLayerType::Pktap
);
assert_eq!(
LinkLayerType::from_dlt(dlt::LINKTYPE_RAW),
LinkLayerType::RawIP
);
assert_eq!(
LinkLayerType::from_dlt(dlt::LINKTYPE_IPV4),
LinkLayerType::RawIP
);
assert_eq!(
LinkLayerType::from_dlt(dlt::LINKTYPE_IPV6),
LinkLayerType::RawIP
);
assert_eq!(LinkLayerType::from_dlt(999), LinkLayerType::Unknown);
}
#[test]
fn test_is_tunnel() {
assert!(LinkLayerType::Tun.is_tunnel());
assert!(LinkLayerType::Tap.is_tunnel());
assert!(!LinkLayerType::Ethernet.is_tunnel());
assert!(!LinkLayerType::RawIP.is_tunnel());
assert!(!LinkLayerType::LinuxSLL.is_tunnel());
assert!(!LinkLayerType::Pktap.is_tunnel());
}
#[test]
fn test_from_dlt_and_name() {
// Test TUN interface detection
assert_eq!(
LinkLayerType::from_dlt_and_name(dlt::RAW, "tun0"),
LinkLayerType::Tun
);
assert_eq!(
LinkLayerType::from_dlt_and_name(dlt::RAW, "utun0"),
LinkLayerType::Tun
);
// Test TAP interface detection
assert_eq!(
LinkLayerType::from_dlt_and_name(dlt::EN10MB, "tap0"),
LinkLayerType::Tap
);
// Test regular interface (not TUN/TAP)
assert_eq!(
LinkLayerType::from_dlt_and_name(dlt::EN10MB, "eth0"),
LinkLayerType::Ethernet
);
assert_eq!(
LinkLayerType::from_dlt_and_name(dlt::RAW, "wlan0"),
LinkLayerType::RawIP
);
}
}
@@ -0,0 +1,288 @@
// PKTAP (Packet Tap) support for macOS
// Provides process identification for network packets
use log::{debug, warn};
use std::mem;
/// Largest valid Darwin PID. macOS wraps PIDs at 100000, so a live PID is
/// always in `1..=99999`.
const DARWIN_PID_MAX: u32 = 99_999;
/// PKTAP header structure as defined by Apple
/// Based on the LINKTYPE_PKTAP specification and Apple's pktap.h
#[repr(C)]
#[derive(Debug, Clone)]
pub struct PktapHeader {
pub pth_length: u32, // Total header length (minimum 108 bytes)
pub pth_type_next: u32, // Type of next header
pub pth_dlt: u32, // DLT type of actual packet (e.g., DLT_EN10MB)
pub pth_ifname: [u8; 24], // Interface name (null-terminated)
pub pth_flags: u32, // Flags
pub pth_protocol_family: u16, // Protocol family (e.g., PF_INET)
pub pth_frame_pre_length: u16, // Frame prefix length
pub pth_frame_post_length: u16, // Frame postfix length
pub pth_iftype: u16, // Interface type
pub pth_unit: u16, // Interface unit
pub pth_epid: u32, // Effective process ID
pub pth_comm: [u8; 20], // Command name (process name)
pub pth_svc_class: u32, // Service class
pub pth_flowid: u32, // Flow ID
pub pth_ipproto: u32, // IP protocol (e.g., IPPROTO_TCP)
pub pth_pid: u32, // Process ID
pub pth_e_comm: [u8; 20], // Effective command name
// Note: There may be additional fields after this
}
impl PktapHeader {
/// Parse PKTAP header from raw packet data
pub fn from_bytes(data: &[u8]) -> Option<Self> {
// Check minimum size
if data.len() < mem::size_of::<PktapHeader>() {
debug!("Packet too small for PKTAP header: {} bytes", data.len());
return None;
}
// Parse the header as little-endian. PKTAP is macOS-only (Apple's DLT_PKTAP),
// and all macOS platforms (x86_64 and ARM64) are little-endian.
let length = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
// Sanity check the length field
if length < 108 || length as usize > data.len() {
debug!(
"Invalid PKTAP header length: {} (packet size: {})",
length,
data.len()
);
return None;
}
// SAFETY: We verified data.len() >= size_of::<PktapHeader>() above.
// Using read_unaligned because data (&[u8]) may not satisfy PktapHeader's
// alignment requirement from its u32 fields. PKTAP is macOS-only and the
// wire format is little-endian, so reading the struct natively on a
// little-endian host already yields the correct field values — no
// explicit byte-swap is required.
let header = unsafe { std::ptr::read_unaligned(data.as_ptr() as *const PktapHeader) };
Some(header)
}
/// Extract process information from the header using the correct offsets
/// Based on our successful test: process name at offset 56, PID at offset 52
pub fn get_process_info(&self) -> (Option<String>, Option<u32>) {
// Extract process name from pth_comm (offset 56, length 20)
let process_name = extract_process_name_from_bytes(&self.pth_comm);
// Extract PID - use pth_epid which is at the right offset (52)
// Based on our test, the PID was consistently at offset 52.
// PKTAP is macOS-only and Darwin PIDs are at most five digits (the
// kernel wraps PIDs at 100000), so a valid PID is 1..=99999. The old
// `< 65535` ceiling was a u16 assumption that dropped legitimate high
// PIDs on busy systems, leaving those packets with no process
// attribution.
let pid = if self.pth_epid != 0 && self.pth_epid <= DARWIN_PID_MAX {
Some(self.pth_epid)
} else if self.pth_pid != 0 && self.pth_pid <= DARWIN_PID_MAX {
Some(self.pth_pid)
} else {
None
};
// Also try effective command name if regular one is empty
let final_process_name = if process_name.is_none() {
extract_process_name_from_bytes(&self.pth_e_comm)
} else {
process_name
};
debug!(
"PKTAP process info: name={:?}, pid={:?}",
final_process_name, pid
);
(final_process_name, pid)
}
/// Get the interface name
pub fn get_interface(&self) -> String {
std::str::from_utf8(&self.pth_ifname)
.unwrap_or("")
.trim_end_matches('\0')
.trim()
.to_string()
}
/// Get the offset where the actual packet data starts
pub fn payload_offset(&self) -> usize {
self.pth_length as usize
}
/// Get the DLT type of the inner packet
pub fn inner_dlt(&self) -> u32 {
self.pth_dlt
}
/// Check if this PKTAP header looks valid
pub fn is_valid(&self) -> bool {
// Basic sanity checks
self.pth_length >= 108 &&
self.pth_length <= 4096 && // Reasonable upper bound
self.pth_dlt > 0 &&
self.pth_dlt < 1000 // Reasonable DLT range
}
}
/// Extract and normalize process name from raw PKTAP bytes
/// Handles all types of padding: null bytes, spaces, tabs, and other whitespace
fn extract_process_name_from_bytes(bytes: &[u8; 20]) -> Option<String> {
// First, find the actual string content
let mut end_pos = bytes.len();
for (i, &byte) in bytes.iter().enumerate() {
if byte == 0 {
end_pos = i;
break;
}
}
// Convert bytes to string, handling invalid UTF-8
let raw_str = std::str::from_utf8(&bytes[..end_pos]).ok()?;
// Apply aggressive normalization
let normalized = raw_str
.chars()
.map(|c| {
if c.is_whitespace() || c.is_control() {
' ' // Convert whitespace and control characters to space
} else {
c
}
})
.collect::<String>()
.split_whitespace() // Split on any whitespace
.collect::<Vec<&str>>()
.join(" "); // Join with single spaces
if normalized.is_empty() || !normalized.chars().all(|c| c.is_ascii_graphic() || c == ' ') {
debug!(
"🚫 Rejected PKTAP process name: raw='{:?}', normalized='{}'",
raw_str, normalized
);
None
} else {
debug!(
"✅ Extracted PKTAP process name: raw='{:?}' -> normalized='{}'",
raw_str, normalized
);
Some(normalized)
}
}
/// Check if the given linktype represents PKTAP data
pub fn is_pktap_linktype(linktype: i32) -> bool {
match linktype {
149 => true, // DLT_USER2 (Apple's PKTAP on Darwin)
258 => true, // DLT_PKTAP (standard)
_ => false,
}
}
/// Try to extract PKTAP metadata and payload from a packet
pub fn parse_pktap_packet(data: &[u8]) -> Option<(PktapHeader, &[u8])> {
let header = PktapHeader::from_bytes(data)?;
if !header.is_valid() {
warn!("Invalid PKTAP header detected");
return None;
}
let payload_offset = header.payload_offset();
if data.len() <= payload_offset {
warn!(
"PKTAP header claims payload at offset {} but packet is only {} bytes",
payload_offset,
data.len()
);
return None;
}
let payload = &data[payload_offset..];
debug!(
"PKTAP: header_len={}, inner_dlt={}, payload_len={}",
header.pth_length,
header.pth_dlt,
payload.len()
);
Some((header, payload))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pktap_linktype_detection() {
assert!(is_pktap_linktype(149)); // DLT_USER2
assert!(is_pktap_linktype(258)); // DLT_PKTAP
assert!(!is_pktap_linktype(1)); // DLT_EN10MB
assert!(!is_pktap_linktype(12)); // DLT_RAW
}
#[test]
fn test_pktap_header_size() {
// Ensure our struct size matches expectations
assert!(mem::size_of::<PktapHeader>() >= 108);
}
#[test]
fn test_invalid_pktap_data() {
// Too small
let small_data = [0u8; 50];
assert!(PktapHeader::from_bytes(&small_data).is_none());
// Invalid length field
let mut bad_data = [0u8; 200];
bad_data[0] = 50; // Length too small
assert!(PktapHeader::from_bytes(&bad_data).is_none());
}
fn header_with_pids(epid: u32, pid: u32) -> PktapHeader {
PktapHeader {
pth_length: 108,
pth_type_next: 0,
pth_dlt: 0,
pth_ifname: [0u8; 24],
pth_flags: 0,
pth_protocol_family: 0,
pth_frame_pre_length: 0,
pth_frame_post_length: 0,
pth_iftype: 0,
pth_unit: 0,
pth_epid: epid,
pth_comm: [0u8; 20],
pth_svc_class: 0,
pth_flowid: 0,
pth_ipproto: 0,
pth_pid: pid,
pth_e_comm: [0u8; 20],
}
}
#[test]
fn test_get_process_info_accepts_high_pid() {
// PIDs above the old 65535 (u16) ceiling are valid on macOS (Darwin
// wraps PIDs at 100000); they must still be attributed, not dropped.
assert_eq!(
header_with_pids(70_000, 0).get_process_info().1,
Some(70_000)
);
// The pth_pid fallback path also honours a high PID when pth_epid is unset.
assert_eq!(
header_with_pids(0, 99_999).get_process_info().1,
Some(99_999)
);
// The zero sentinel and out-of-range garbage still yield no PID.
assert_eq!(header_with_pids(0, 0).get_process_info().1, None);
assert_eq!(header_with_pids(100_000, 0).get_process_info().1, None);
}
}
@@ -0,0 +1,117 @@
//! Raw IP packet parsing (no link-layer header)
//!
//! Handles DLT_RAW (12), DLT_NULL (0), LINKTYPE_RAW (101),
//! LINKTYPE_IPV4 (228), and LINKTYPE_IPV6 (229)
//!
//! Used by TUN devices which operate at Layer 3 (network layer)
use crate::network::parser::{PacketParser, ParsedPacket};
/// Parse a raw IP packet (auto-detect IPv4 or IPv6)
///
/// TUN devices typically send raw IP packets without any link-layer header.
/// The first nibble of the packet indicates the IP version.
pub fn parse(
data: &[u8],
parser: &PacketParser,
process_name: Option<String>,
process_id: Option<u32>,
) -> Option<ParsedPacket> {
if data.is_empty() {
log::debug!("Raw IP: Empty packet");
return None;
}
// Check IP version from first nibble
let version = data[0] >> 4;
match version {
4 => {
log::trace!("Raw IP: IPv4 packet detected");
parser.parse_raw_ipv4_packet(data, process_name, process_id)
}
6 => {
log::trace!("Raw IP: IPv6 packet detected");
parser.parse_raw_ipv6_packet(data, process_name, process_id)
}
_ => {
log::debug!("Raw IP: Unknown IP version: {}", version);
None
}
}
}
/// Parse a raw IPv4 packet only
///
/// Used when the link type explicitly indicates IPv4 (LINKTYPE_IPV4 = 228)
pub fn parse_ipv4(
data: &[u8],
parser: &PacketParser,
process_name: Option<String>,
process_id: Option<u32>,
) -> Option<ParsedPacket> {
if data.is_empty() || data.len() < 20 {
log::debug!("Raw IPv4: Packet too small");
return None;
}
let version = data[0] >> 4;
if version != 4 {
log::warn!("Raw IPv4: Expected IPv4 but got version {}", version);
return None;
}
log::trace!("Raw IPv4: Parsing IPv4 packet");
parser.parse_raw_ipv4_packet(data, process_name, process_id)
}
/// Parse a raw IPv6 packet only
///
/// Used when the link type explicitly indicates IPv6 (LINKTYPE_IPV6 = 229)
pub fn parse_ipv6(
data: &[u8],
parser: &PacketParser,
process_name: Option<String>,
process_id: Option<u32>,
) -> Option<ParsedPacket> {
if data.is_empty() || data.len() < 40 {
log::debug!("Raw IPv6: Packet too small");
return None;
}
let version = data[0] >> 4;
if version != 6 {
log::warn!("Raw IPv6: Expected IPv6 but got version {}", version);
return None;
}
log::trace!("Raw IPv6: Parsing IPv6 packet");
parser.parse_raw_ipv6_packet(data, process_name, process_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_raw_ip_empty_packet() {
let empty = vec![];
let parser = PacketParser::new();
assert!(parse(&empty, &parser, None, None).is_none());
}
#[test]
fn test_raw_ipv4_version_check() {
// Create a minimal IPv6 packet but try to parse as IPv4
let ipv6_packet = vec![0x60, 0x00, 0x00, 0x00]; // Version 6
let parser = PacketParser::new();
assert!(parse_ipv4(&ipv6_packet, &parser, None, None).is_none());
}
#[test]
fn test_raw_ipv6_version_check() {
// Create a minimal IPv4 packet but try to parse as IPv6
let ipv4_packet = vec![0x45, 0x00, 0x00, 0x00]; // Version 4
let parser = PacketParser::new();
assert!(parse_ipv6(&ipv4_packet, &parser, None, None).is_none());
}
}
@@ -0,0 +1,221 @@
//! TUN/TAP interface support
//!
//! TUN (Layer 3) and TAP (Layer 2) virtual network interfaces
//!
//! - TUN: Operates at IP layer, carries raw IP packets (DLT_RAW or DLT_NULL)
//! - TAP: Operates at Ethernet layer, carries Ethernet frames (DLT_EN10MB)
use crate::network::link_layer::{ethernet, raw_ip};
use crate::network::parser::{PacketParser, ParsedPacket};
/// Detect if an interface name is a TUN interface
///
/// TUN interface naming conventions:
/// - Linux: `tun0`, `tun1`, etc.
/// - macOS: `utun0`, `utun1`, etc.
/// - BSD: `tun0`, `tun1`, etc.
pub fn is_tun_interface(name: &str) -> bool {
name.starts_with("tun") || name.starts_with("utun")
}
/// Detect if an interface name is a TAP interface
///
/// TAP interface naming conventions:
/// - Linux: `tap0`, `tap1`, etc.
/// - macOS: `tap0`, `tap1`, etc. (requires third-party drivers)
/// - BSD: `tap0`, `tap1`, etc.
pub fn is_tap_interface(name: &str) -> bool {
name.starts_with("tap")
}
/// Detect if an interface name is any tunnel interface (TUN or TAP)
pub fn is_tunnel_interface(name: &str) -> bool {
is_tun_interface(name) || is_tap_interface(name)
}
/// Parse a TUN packet (raw IP, no link-layer header)
///
/// TUN devices operate at the network layer (Layer 3) and carry raw IP packets.
/// This is a convenience wrapper around raw_ip::parse()
pub fn parse_tun(
data: &[u8],
parser: &PacketParser,
process_name: Option<String>,
process_id: Option<u32>,
) -> Option<ParsedPacket> {
log::trace!("TUN: Parsing raw IP packet ({} bytes)", data.len());
raw_ip::parse(data, parser, process_name, process_id)
}
/// Parse a TAP packet (Ethernet frame with full header)
///
/// TAP devices operate at the data-link layer (Layer 2) and carry Ethernet frames.
/// This is a convenience wrapper around ethernet::parse()
pub fn parse_tap(
data: &[u8],
parser: &PacketParser,
process_name: Option<String>,
process_id: Option<u32>,
) -> Option<ParsedPacket> {
log::trace!("TAP: Parsing Ethernet frame ({} bytes)", data.len());
ethernet::parse(data, parser, process_name, process_id)
}
/// Determine the appropriate parser based on DLT type
///
/// This is the main entry point for parsing TUN/TAP packets when you know the DLT type.
pub fn parse_by_dlt(
data: &[u8],
dlt: i32,
parser: &PacketParser,
process_name: Option<String>,
process_id: Option<u32>,
) -> Option<ParsedPacket> {
match dlt {
// DLT_NULL - BSD/macOS loopback: a 4-byte address-family header (host
// byte order) precedes the raw IP packet, so strip it before parsing.
// The inner IP version nibble selects v4/v6, so the family value itself
// need not be decoded (its endianness varies by platform). Without this
// skip, raw IP parsing reads the family header's first byte as the IP
// version and silently drops every loopback packet.
0 => {
log::trace!("TUN/TAP: DLT_NULL (0)");
if data.len() < 4 {
return None;
}
parse_tun(&data[4..], parser, process_name, process_id)
}
// TAP devices use Ethernet (Layer 2)
1 => {
log::trace!("TUN/TAP: DLT_EN10MB (1) - TAP");
parse_tap(data, parser, process_name, process_id)
}
// DLT_RAW - Raw IP packets (no link layer)
12 | 101 => {
log::trace!("TUN/TAP: DLT_RAW ({}) - TUN", dlt);
parse_tun(data, parser, process_name, process_id)
}
// IPv4-only packets
228 => {
log::trace!("TUN/TAP: LINKTYPE_IPV4 (228) - TUN");
raw_ip::parse_ipv4(data, parser, process_name, process_id)
}
// IPv6-only packets
229 => {
log::trace!("TUN/TAP: LINKTYPE_IPV6 (229) - TUN");
raw_ip::parse_ipv6(data, parser, process_name, process_id)
}
_ => {
log::warn!("TUN/TAP: Unsupported DLT type: {}", dlt);
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tun_interface_detection() {
assert!(is_tun_interface("tun0"));
assert!(is_tun_interface("tun1"));
assert!(is_tun_interface("tun10"));
assert!(is_tun_interface("utun0")); // macOS
assert!(is_tun_interface("utun1"));
assert!(!is_tun_interface("eth0"));
assert!(!is_tun_interface("tap0"));
assert!(!is_tun_interface("wlan0"));
}
#[test]
fn test_tap_interface_detection() {
assert!(is_tap_interface("tap0"));
assert!(is_tap_interface("tap1"));
assert!(is_tap_interface("tap10"));
assert!(!is_tap_interface("tun0"));
assert!(!is_tap_interface("eth0"));
assert!(!is_tap_interface("wlan0"));
}
#[test]
fn test_tunnel_interface_detection() {
assert!(is_tunnel_interface("tun0"));
assert!(is_tunnel_interface("tap0"));
assert!(is_tunnel_interface("utun0"));
assert!(!is_tunnel_interface("eth0"));
assert!(!is_tunnel_interface("wlan0"));
assert!(!is_tunnel_interface("lo"));
}
#[test]
fn test_parse_by_dlt() {
use crate::network::parser::PacketParser;
let parser = PacketParser::new();
// Test with empty packet - should return None
let empty = vec![];
assert!(parse_by_dlt(&empty, 12, &parser, None, None).is_none());
assert!(parse_by_dlt(&empty, 1, &parser, None, None).is_none());
}
#[test]
fn test_parse_tun_tap() {
use crate::network::parser::PacketParser;
let parser = PacketParser::new();
let empty = vec![];
// TUN parsing should fail on empty packet
assert!(parse_tun(&empty, &parser, None, None).is_none());
// TAP parsing should fail on empty packet
assert!(parse_tap(&empty, &parser, None, None).is_none());
}
#[test]
fn test_parse_by_dlt_null_strips_loopback_family_header() {
use crate::network::parser::PacketParser;
use crate::network::types::Protocol;
let parser = PacketParser::new();
// Inner IPv4/UDP packet: 192.168.1.100:1234 -> 8.8.8.8:53 (DNS).
let inner_ip: &[u8] = &[
// IPv4 header (protocol 0x11 = UDP)
0x45, 0x00, 0x00, 0x20, 0x00, 0x01, 0x00, 0x00, 0x40, 0x11, 0x00, 0x00, 192, 168, 1,
100, 8, 8, 8, 8, // UDP header: src 1234, dst 53
0x04, 0xd2, 0x00, 0x35, 0x00, 0x0c, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04,
];
// A DLT_NULL frame is a 4-byte address-family header (AF_INET = 2, host
// byte order / little-endian here) followed by the raw IP packet.
let mut null_frame = vec![0x02, 0x00, 0x00, 0x00];
null_frame.extend_from_slice(inner_ip);
// The family header's first byte (0x02) has IP version nibble 0, so
// parsing the frame as raw IP without stripping the header drops it.
// This is exactly what the old DLT_NULL arm did.
assert!(
raw_ip::parse(&null_frame, &parser, None, None).is_none(),
"raw IP parse must fail when the 4-byte family header is not stripped",
);
// parse_by_dlt(DLT_NULL) must strip the header and parse the inner packet.
let parsed = parse_by_dlt(&null_frame, 0, &parser, None, None)
.expect("DLT_NULL loopback frame should parse after stripping the family header");
assert_eq!(parsed.protocol, Protocol::Udp);
// Orientation (which side is local) depends on configured local IPs, so
// assert on the port set rather than a fixed local/remote split.
let ports = [parsed.local_addr.port(), parsed.remote_addr.port()];
assert!(
ports.contains(&1234) && ports.contains(&53),
"inner UDP ports 1234/53 must survive the header strip, got {ports:?}",
);
// A truncated DLT_NULL frame (header only / shorter) must not panic.
assert!(parse_by_dlt(&[0x02, 0x00, 0x00, 0x00], 0, &parser, None, None).is_none());
assert!(parse_by_dlt(&[0x02, 0x00], 0, &parser, None, None).is_none());
}
}
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
//! Networking core: link-layer/IP/transport parsers, deep packet inspection
//! (HTTP, TLS SNI, DNS, SSH, QUIC, NTP, mDNS, LLMNR, DHCP, SNMP, SSDP,
//! NetBIOS), connection merging, GeoIP lookups, OUI vendor resolution,
//! interface-statistics traits, and the shared connection/protocol types.
//!
//! This is the platform-independent, capture-independent analysis layer.
//! Raw packet capture lives in the `rustnet-capture` crate and platform-specific
//! process attribution in the `rustnet-host` crate.
pub mod bogon;
pub mod dns;
pub mod dpi;
pub mod geoip;
pub mod interface_stats;
pub mod link_layer;
pub mod merge;
pub mod oui;
pub mod parser;
pub mod protocol;
pub mod services;
pub mod tracker;
pub mod types;

Some files were not shown because too many files have changed in this diff Show More