Merge pull request #3020 from shadowsocks/test/ss-redir-qemu-e2e

Add end-to-end test for ss-redir transparent proxy via QEMU
This commit is contained in:
Max Lv
2026-02-08 09:55:41 +08:00
committed by GitHub
4 changed files with 488 additions and 0 deletions
+7
View File
@@ -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
+168
View File
@@ -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"
+8
View File
@@ -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"
}
+305
View File
@@ -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