diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3bed63bb..8623863a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,3 +45,10 @@ jobs: - name: Stress test run: | python3 tests/stress_test.py --bin build/shared/bin/ --size 10 + + - name: ss-redir transparent proxy test (QEMU) + if: runner.os == 'Linux' + run: | + sudo apt-get install -y qemu-system-x86 + bash tests/test_redir_qemu.sh build/shared/bin/ + timeout-minutes: 8 diff --git a/tests/qemu/guest-init.sh b/tests/qemu/guest-init.sh new file mode 100755 index 00000000..e6c59a51 --- /dev/null +++ b/tests/qemu/guest-init.sh @@ -0,0 +1,168 @@ +#!/bin/bash +# +# Guest init script (PID 1) for ss-redir transparent proxy test. +# Runs inside a QEMU Alpine Linux VM. +# +# Flow: +# 1. Mount filesystems and load kernel modules +# 2. Configure networking (QEMU user-mode: guest 10.0.2.15, host 10.0.2.2) +# 3. Start ss-redir pointing at ss-server on the host +# 4. Run ss-nat to set up iptables REDIRECT rules +# 5. Test with curl through the transparent proxy +# 6. Print REDIR_TEST_PASS or REDIR_TEST_FAIL to serial console +# + +set -e + +# --- helpers ---------------------------------------------------------------- + +pass() { + echo "" + echo "=========================================" + echo "REDIR_TEST_PASS" + echo "=========================================" + sync + echo o > /proc/sysrq-trigger + sleep 10 +} + +fail() { + local msg="${1:-unknown error}" + echo "" + echo "=========================================" + echo "REDIR_TEST_FAIL: $msg" + echo "=========================================" + sync + echo o > /proc/sysrq-trigger + sleep 10 +} + +# --- mount filesystems ------------------------------------------------------ + +mount -t proc proc /proc +mount -t sysfs sysfs /sys +mount -t devtmpfs devtmpfs /dev +mkdir -p /dev/pts /tmp /run +mount -t devpts devpts /dev/pts +mount -t tmpfs tmpfs /tmp +mount -t tmpfs tmpfs /run + +# Enable sysrq for clean poweroff +echo 1 > /proc/sys/kernel/sysrq + +echo "=== Guest init starting ===" + +# --- load kernel modules ---------------------------------------------------- + +for mod in \ + virtio_net \ + ip_tables iptable_nat iptable_filter \ + nf_nat nf_conntrack \ + xt_REDIRECT xt_set xt_tcpudp \ + ip_set ip_set_hash_net; do + modprobe "$mod" 2>/dev/null || echo "WARN: failed to load $mod (may be built-in)" +done + +echo "=== Kernel modules loaded ===" + +# --- configure network ------------------------------------------------------ + +# Find the non-lo network interface +IFACE="" +for f in /sys/class/net/*/type; do + iface=$(basename "$(dirname "$f")") + if [ "$iface" != "lo" ]; then + IFACE="$iface" + break + fi +done + +if [ -z "$IFACE" ]; then + fail "no network interface found" +fi + +echo "=== Using network interface: $IFACE ===" + +ip link set lo up +ip link set "$IFACE" up +ip addr add 10.0.2.15/24 dev "$IFACE" +ip route add default via 10.0.2.2 + +# DNS (QEMU user-mode DNS forwarder) +echo "nameserver 10.0.2.3" > /etc/resolv.conf + +# Wait for link to come up +sleep 2 + +# Connectivity check +echo "=== Checking connectivity to host ===" +if ! ping -c 2 -W 5 10.0.2.2; then + fail "cannot reach host at 10.0.2.2" +fi + +echo "=== Host reachable ===" + +# --- start ss-redir --------------------------------------------------------- + +echo "=== Starting ss-redir ===" +/usr/bin/ss-redir -c /etc/ss-redir.json -v & +SS_REDIR_PID=$! +sleep 3 + +if ! kill -0 "$SS_REDIR_PID" 2>/dev/null; then + fail "ss-redir died immediately" +fi + +echo "=== ss-redir running (PID $SS_REDIR_PID) ===" + +# --- run ss-nat (set up iptables REDIRECT) ---------------------------------- + +echo "=== Running ss-nat ===" +if ! /usr/bin/ss-nat -s 10.0.2.2 -l 1080 -o; then + fail "ss-nat failed" +fi + +echo "=== ss-nat rules applied ===" + +# Show iptables state for debugging +echo "=== iptables -t nat -L -n ===" +iptables -t nat -L -n 2>&1 || true +echo "=== ipset list ===" +ipset list 2>&1 || true + +# --- test with curl --------------------------------------------------------- + +echo "=== Testing transparent proxy with curl ===" + +# Try multiple URLs for reliability (captive portal detection endpoints +# are lightweight and highly available) + +# Test 1: Mozilla captive portal +echo "--- Test 1: detectportal.firefox.com ---" +BODY=$(curl -s --connect-timeout 15 -m 30 http://detectportal.firefox.com/success.txt 2>&1) || true +echo "Response: $BODY" +if echo "$BODY" | grep -q "success"; then + echo "Test 1 PASSED" + pass +fi + +# Test 2: Google (expect redirect or 200) +echo "--- Test 2: www.google.com ---" +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 15 -m 30 http://www.google.com/ 2>&1) || true +echo "HTTP code: $HTTP_CODE" +if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "301" ] || [ "$HTTP_CODE" = "302" ]; then + echo "Test 2 PASSED" + pass +fi + +# Test 3: Apple captive portal +echo "--- Test 3: captive.apple.com ---" +BODY=$(curl -s --connect-timeout 15 -m 30 http://captive.apple.com/ 2>&1) || true +echo "Response: $BODY" +if echo "$BODY" | grep -qi "success"; then + echo "Test 3 PASSED" + pass +fi + +# If we get here, all tests failed +fail "all curl tests failed" diff --git a/tests/redir.json b/tests/redir.json new file mode 100644 index 00000000..bf6595d2 --- /dev/null +++ b/tests/redir.json @@ -0,0 +1,8 @@ +{ + "server":"10.0.2.2", + "server_port":8389, + "local_port":1080, + "password":"test_redir_password", + "timeout":60, + "method":"aes-256-gcm" +} diff --git a/tests/test_redir_qemu.sh b/tests/test_redir_qemu.sh new file mode 100755 index 00000000..2681fab1 --- /dev/null +++ b/tests/test_redir_qemu.sh @@ -0,0 +1,305 @@ +#!/bin/bash +# +# End-to-end test for ss-redir transparent proxy using QEMU. +# +# Architecture: +# Host: ss-server listens on 0.0.0.0:8389 +# QEMU guest (Alpine Linux): +# - ss-redir connects to host ss-server via 10.0.2.2:8389 +# - ss-nat sets up iptables OUTPUT chain REDIRECT to ss-redir +# - curl through the transparent proxy verifies the full chain +# +# Usage: bash tests/test_redir_qemu.sh [BIN_DIR] +# BIN_DIR: directory containing ss-server and ss-redir binaries +# (default: build/shared/bin/) +# +# Requirements (Linux only): +# - qemu-system-x86_64 +# - Internet access (for Alpine download and curl test targets) +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +BIN_DIR="${1:-$PROJECT_DIR/build/shared/bin}" + +# Resolve to absolute path +BIN_DIR="$(cd "$BIN_DIR" && pwd)" + +WORK_DIR="" +SS_SERVER_PID="" +QEMU_PID="" + +ALPINE_VERSION="3.21" +ALPINE_ARCH="x86_64" +ALPINE_MIRROR="https://dl-cdn.alpinelinux.org/alpine" +ALPINE_MINIROOTFS="alpine-minirootfs-${ALPINE_VERSION}.0-${ALPINE_ARCH}.tar.gz" +ALPINE_URL="${ALPINE_MIRROR}/v${ALPINE_VERSION}/releases/${ALPINE_ARCH}/${ALPINE_MINIROOTFS}" + +TIMEOUT=240 +SS_SERVER_PORT=8389 +SS_PASSWORD="test_redir_password" +SS_METHOD="aes-256-gcm" + +# --- helpers ---------------------------------------------------------------- + +log() { + echo "=== [test_redir_qemu] $* ===" +} + +die() { + echo "FATAL: $*" >&2 + exit 1 +} + +cleanup() { + log "Cleaning up" + if [ -n "$SS_SERVER_PID" ] && kill -0 "$SS_SERVER_PID" 2>/dev/null; then + kill "$SS_SERVER_PID" 2>/dev/null || true + wait "$SS_SERVER_PID" 2>/dev/null || true + fi + if [ -n "$QEMU_PID" ] && kill -0 "$QEMU_PID" 2>/dev/null; then + kill "$QEMU_PID" 2>/dev/null || true + wait "$QEMU_PID" 2>/dev/null || true + fi + if [ -n "$WORK_DIR" ] && [ -d "$WORK_DIR" ]; then + sudo rm -rf "$WORK_DIR" + fi +} + +trap cleanup EXIT + +# --- pre-flight checks ------------------------------------------------------ + +if [ "$(uname -s)" != "Linux" ]; then + die "This test requires Linux (iptables, QEMU)" +fi + +if ! command -v qemu-system-x86_64 >/dev/null 2>&1; then + die "qemu-system-x86_64 not found. Install with: sudo apt-get install qemu-system-x86" +fi + +if [ ! -x "$BIN_DIR/ss-server" ]; then + die "ss-server not found at $BIN_DIR/ss-server" +fi + +if [ ! -x "$BIN_DIR/ss-redir" ]; then + die "ss-redir not found at $BIN_DIR/ss-redir" +fi + +# --- create work directory -------------------------------------------------- + +WORK_DIR="$(mktemp -d /tmp/ss-redir-test.XXXXXX)" +log "Work directory: $WORK_DIR" + +# --- phase 1: collect ss-redir and its shared library dependencies ---------- + +log "Collecting ss-redir shared library dependencies" + +# Copy ss-redir binary +SYSROOT="$WORK_DIR/sysroot" +mkdir -p "$SYSROOT/usr/bin" +cp "$BIN_DIR/ss-redir" "$SYSROOT/usr/bin/ss-redir" + +# Copy all shared library dependencies (including the dynamic linker) +ldd "$BIN_DIR/ss-redir" | while read -r line; do + # Parse lines like: libev.so.4 => /lib/x86_64-linux-gnu/libev.so.4 (0x...) + # or: /lib64/ld-linux-x86-64.so.2 (0x...) + lib_path="" + if echo "$line" | grep -q "=>"; then + lib_path=$(echo "$line" | awk '{print $3}') + elif echo "$line" | grep -qE "^\s*/"; then + lib_path=$(echo "$line" | awk '{print $1}') + fi + if [ -n "$lib_path" ] && [ -f "$lib_path" ]; then + dest="$SYSROOT$lib_path" + mkdir -p "$(dirname "$dest")" + cp "$lib_path" "$dest" + fi +done + +log "Shared libraries collected into sysroot" + +# --- phase 2: assemble Alpine rootfs ---------------------------------------- + +log "Downloading Alpine minirootfs" + +ROOTFS="$WORK_DIR/rootfs" +mkdir -p "$ROOTFS" + +curl -sSL "$ALPINE_URL" -o "$WORK_DIR/$ALPINE_MINIROOTFS" +sudo tar xzf "$WORK_DIR/$ALPINE_MINIROOTFS" -C "$ROOTFS" + +log "Installing packages in Alpine rootfs" + +# Set up DNS for chroot +sudo cp /etc/resolv.conf "$ROOTFS/etc/resolv.conf" + +# Mount necessary filesystems for chroot +sudo mount --bind /dev "$ROOTFS/dev" +sudo mount --bind /proc "$ROOTFS/proc" +sudo mount --bind /sys "$ROOTFS/sys" + +# Install packages inside chroot +sudo chroot "$ROOTFS" /sbin/apk add --no-cache \ + iptables ipset curl iproute2 kmod bash linux-virt + +# Unmount after package installation +sudo umount "$ROOTFS/sys" 2>/dev/null || true +sudo umount "$ROOTFS/proc" 2>/dev/null || true +sudo umount "$ROOTFS/dev" 2>/dev/null || true + +# Extract kernel +VMLINUZ=$(find "$ROOTFS/boot" -name 'vmlinuz-*' -type f | head -1) +if [ -z "$VMLINUZ" ]; then + die "No vmlinuz found in Alpine rootfs" +fi +cp "$VMLINUZ" "$WORK_DIR/vmlinuz" +log "Kernel: $(basename "$VMLINUZ")" + +# --- phase 3: inject artifacts into rootfs ---------------------------------- + +log "Injecting test artifacts into rootfs" + +# ss-redir binary and its glibc shared library dependencies +# These go into the rootfs at their original absolute paths so the +# ELF dynamic linker (PT_INTERP) can find everything. +sudo cp -a "$SYSROOT/." "$ROOTFS/" + +# ss-nat script +sudo cp "$PROJECT_DIR/src/ss-nat" "$ROOTFS/usr/bin/ss-nat" +sudo chmod 755 "$ROOTFS/usr/bin/ss-nat" + +# Config file +sudo cp "$SCRIPT_DIR/redir.json" "$ROOTFS/etc/ss-redir.json" + +# Guest init script (becomes PID 1) +sudo cp "$SCRIPT_DIR/qemu/guest-init.sh" "$ROOTFS/init" +sudo chmod 755 "$ROOTFS/init" + +# --- phase 4: pack initramfs ----------------------------------------------- + +log "Packing initramfs" + +(cd "$ROOTFS" && sudo find . | sudo cpio -o -H newc 2>/dev/null | gzip -1 > "$WORK_DIR/initramfs.gz") + +log "Initramfs size: $(du -h "$WORK_DIR/initramfs.gz" | cut -f1)" + +# --- phase 5: start ss-server on host -------------------------------------- + +log "Starting ss-server on host" + +"$BIN_DIR/ss-server" \ + -s 0.0.0.0 \ + -p "$SS_SERVER_PORT" \ + -k "$SS_PASSWORD" \ + -m "$SS_METHOD" \ + -v & +SS_SERVER_PID=$! + +sleep 2 + +if ! kill -0 "$SS_SERVER_PID" 2>/dev/null; then + die "ss-server failed to start" +fi + +log "ss-server running (PID $SS_SERVER_PID)" + +# --- phase 6: boot QEMU ---------------------------------------------------- + +log "Booting QEMU" + +KVM_FLAG="" +if [ -e /dev/kvm ] && [ -r /dev/kvm ] && [ -w /dev/kvm ]; then + KVM_FLAG="-enable-kvm" + log "KVM acceleration available" +else + log "KVM not available, using TCG (slower)" +fi + +SERIAL_LOG="$WORK_DIR/serial.log" + +qemu-system-x86_64 \ + $KVM_FLAG \ + -m 512 \ + -kernel "$WORK_DIR/vmlinuz" \ + -initrd "$WORK_DIR/initramfs.gz" \ + -append "console=ttyS0 init=/init panic=1" \ + -nographic \ + -serial "file:$SERIAL_LOG" \ + -monitor none \ + -net nic,model=virtio \ + -net user \ + -no-reboot & +QEMU_PID=$! + +log "QEMU running (PID $QEMU_PID)" + +# --- phase 7: monitor serial log ------------------------------------------- + +log "Waiting for test result (timeout: ${TIMEOUT}s)" + +ELAPSED=0 +POLL_INTERVAL=2 +RESULT="" + +while [ $ELAPSED -lt $TIMEOUT ]; do + if [ -f "$SERIAL_LOG" ]; then + if grep -q "REDIR_TEST_PASS" "$SERIAL_LOG" 2>/dev/null; then + RESULT="PASS" + break + fi + if grep -q "REDIR_TEST_FAIL" "$SERIAL_LOG" 2>/dev/null; then + RESULT="FAIL" + break + fi + fi + + # Check if QEMU is still running + if ! kill -0 "$QEMU_PID" 2>/dev/null; then + RESULT="QEMU_DIED" + break + fi + + sleep $POLL_INTERVAL + ELAPSED=$((ELAPSED + POLL_INTERVAL)) +done + +# --- phase 8: report ------------------------------------------------------- + +echo "" +echo "============================================================" +echo "Serial console output:" +echo "============================================================" +if [ -f "$SERIAL_LOG" ]; then + cat "$SERIAL_LOG" +else + echo "(no serial log file)" +fi +echo "============================================================" +echo "" + +case "$RESULT" in + PASS) + log "TEST PASSED" + exit 0 + ;; + FAIL) + FAIL_MSG=$(grep "REDIR_TEST_FAIL" "$SERIAL_LOG" 2>/dev/null || echo "unknown") + log "TEST FAILED: $FAIL_MSG" + exit 1 + ;; + QEMU_DIED) + log "TEST FAILED: QEMU exited unexpectedly" + exit 1 + ;; + *) + log "TEST FAILED: timeout after ${TIMEOUT}s" + # Kill QEMU if still running + if kill -0 "$QEMU_PID" 2>/dev/null; then + kill "$QEMU_PID" 2>/dev/null || true + fi + exit 1 + ;; +esac