diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 1086979d..c298cf19 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -5,14 +5,15 @@ permissions: on: push: - branches: [ master, dev ] + branches: [ master, dev, Linux-ebpf ] pull_request: - branches: [ master, dev ] + branches: [ master, dev, Linux-ebpf ] workflow_dispatch: jobs: build: runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: read actions: read @@ -28,12 +29,27 @@ jobs: build-essential \ gcc \ make \ - libnetfilter-queue-dev \ - libnfnetlink-dev \ + clang \ + linux-tools-common \ + linux-tools-generic \ + libbpf-dev \ + libelf-dev \ + zlib1g-dev \ libgtk-3-dev \ pkg-config shell: bash + - name: Locate bpftool + # On Ubuntu "bpftool" is only a virtual package; the real binary ships in linux-tools-* and is not on PATH + # for the runner's cloud kernel, so find it explicitly. + run: | + shopt -s nullglob + candidates=(/usr/lib/linux-tools/*/bpftool) + [ ${#candidates[@]} -gt 0 ] || { echo "bpftool binary not found"; ls /usr/lib/linux-tools 2>&1 || true; exit 1; } + echo "BPFTOOL=${candidates[0]}" >> "$GITHUB_ENV" + "${candidates[0]}" version | head -1 + shell: bash + - name: Verify dependencies run: | echo "=== Checking GCC ===" @@ -48,15 +64,30 @@ jobs: echo "=== Checking GTK3 ===" pkg-config --modversion gtk+-3.0 echo "" - echo "=== Checking libnetfilter_queue ===" - pkg-config --modversion libnetfilter_queue || echo "Package info not available, but headers should be present" + echo "=== Checking clang (eBPF compiler) ===" + clang --version | head -1 + echo "" + echo "=== Checking bpftool (skeleton generator) ===" + "$BPFTOOL" version | head -1 + echo "" + echo "=== Checking libbpf ===" + pkg-config --modversion libbpf + shell: bash + + - name: Unit tests (rule engine, DNS parser, relay) + timeout-minutes: 15 + run: | + cd Linux/src + make test-rules BUILD_DIR="$RUNNER_TEMP/pb-tests" + cd tests + TMPDIR="$RUNNER_TEMP" ./run_relay_test.sh plain shell: bash - name: Build project run: | cd Linux chmod +x build.sh - ./build.sh + WERROR=1 ./build.sh shell: bash - name: Verify build output @@ -84,8 +115,82 @@ jobs: echo "✓ GUI built successfully" file Linux/output/ProxyBridgeGUI else - echo "⚠ GUI build skipped (GTK3 not available or build failed)" + echo "✗ GUI build failed (GTK3 is installed on this runner)" + exit 1 + fi + shell: bash + + - name: Smoke test the built binaries + run: | + cd Linux/output + # capture output first: `cmd | grep -q` can fail under pipefail when grep exits early (SIGPIPE) + help_out="$(LD_LIBRARY_PATH="$PWD" ./ProxyBridge --help)" + grep -q -- "--proxy" <<<"$help_out" && echo "✓ CLI runs and shows its help" + cli_deps="$(LD_LIBRARY_PATH="$PWD" ldd ./ProxyBridge)" + grep -q libproxybridge <<<"$cli_deps" && echo "✓ CLI links libproxybridge.so" + # the GUI must carry an $ORIGIN runpath so it finds the library in its own directory + gui_dyn="$(readelf -d ./ProxyBridgeGUI)" + grep -q 'RUNPATH.*\$ORIGIN' <<<"$gui_dyn" && echo "✓ GUI runpath is \$ORIGIN" + # the library must export the public API + exports="$(nm -D libproxybridge.so)" + for sym in ProxyBridge_Start ProxyBridge_Stop ProxyBridge_AddRule ProxyBridge_AddProxy ProxyBridge_ValidateRule; do + grep -q " T $sym" <<<"$exports" || { echo "✗ missing export $sym"; exit 1; } + done + echo "✓ public API exported" + # no iptables/NFQUEUE dependency may come back + lib_deps="$(ldd libproxybridge.so)" + if grep -qi netfilter <<<"$lib_deps"; then echo "✗ libnetfilter dependency is back"; exit 1; fi + echo "✓ no libnetfilter dependency" + shell: bash + + - name: Portability - run the binaries on older distributions (issue 146) + # BIND_NOW makes the loader resolve every glibc symbol at start-up, so a clean --help run in these images proves the + # binaries do not need a newer glibc than Ubuntu 22.04 / Debian 12 / RHEL 9 era systems provide. + run: | + set -e + out="$PWD/Linux/output" + run_in() { # image, install command + echo "::group::$1" + docker run --rm -v "$out:/out:ro" "$1" sh -c "$2 >/dev/null 2>&1 && + LD_LIBRARY_PATH=/out /out/ProxyBridge --help | grep -q -- '--proxy' && echo 'CLI starts' && + missing=\$(LD_LIBRARY_PATH=/out ldd /out/ProxyBridge /out/libproxybridge.so /out/ProxyBridgeGUI 2>&1 | grep 'not found' || true) && + if [ -n \"\$missing\" ]; then echo \"missing: \$missing\"; exit 1; fi && echo 'all libraries resolve'" + echo "::endgroup::" + } + run_in ubuntu:22.04 "apt-get update && apt-get install -y libelf1 zlib1g libgtk-3-0" + run_in debian:12 "apt-get update && apt-get install -y libelf1 zlib1g libgtk-3-0" + run_in fedora:40 "dnf install -y elfutils-libelf zlib gtk3" + shell: bash + + - name: Verify the build left the source tree clean + run: | + # build.sh compiles in a temp directory: no untracked or ignored file may appear outside Linux/output/ + status="$(git status --porcelain --ignored)" + dirty="$(grep -E '^(\?\?|!!)' <<<"$status" | grep -v 'Linux/output' || true)" + if [ -n "$dirty" ]; then + echo "✗ build left files outside Linux/output:" + echo "$dirty" + exit 1 fi + echo "✓ source tree clean (only Linux/output/ was written)" + shell: bash + + - name: End-to-end tests with the real kernel datapath (best effort) + # Needs root, cgroup v2 and a BTF kernel. GitHub-hosted runners usually have them, but this is not guaranteed, + # so a failure here is reported without failing the build. + continue-on-error: true + timeout-minutes: 15 + run: | + sudo apt-get install -y dnsutils >/dev/null + uname -r + ls /sys/kernel/btf/vmlinux && mount | grep -q "type cgroup2" || { echo "kernel lacks BTF or cgroup2: skipping"; exit 0; } + fail=0 + for t in e2e e2e_rules e2e_domains e2e_security api_live; do + echo "::group::$t" + sudo env PB_DIR="$PWD/Linux/output" BPFTOOL="$BPFTOOL" bash "Linux/tests/$t.sh" || fail=1 + echo "::endgroup::" + done + exit $fail shell: bash - name: Upload artifacts diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index ef925f66..75527119 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -11,6 +11,7 @@ permissions: jobs: build-and-release: runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: write actions: read @@ -27,12 +28,27 @@ jobs: build-essential \ gcc \ make \ - libnetfilter-queue-dev \ - libnfnetlink-dev \ + clang \ + linux-tools-common \ + linux-tools-generic \ + libbpf-dev \ + libelf-dev \ + zlib1g-dev \ libgtk-3-dev \ pkg-config shell: bash + - name: Locate bpftool + # On Ubuntu "bpftool" is only a virtual package; the real binary ships in linux-tools-* and is not on PATH + # for the runner's cloud kernel, so find it explicitly. + run: | + shopt -s nullglob + candidates=(/usr/lib/linux-tools/*/bpftool) + [ ${#candidates[@]} -gt 0 ] || { echo "bpftool binary not found"; ls /usr/lib/linux-tools 2>&1 || true; exit 1; } + echo "BPFTOOL=${candidates[0]}" >> "$GITHUB_ENV" + "${candidates[0]}" version | head -1 + shell: bash + - name: Build project run: | cd Linux diff --git a/.gitignore b/.gitignore index eb335bf3..f3c5aea6 100644 --- a/.gitignore +++ b/.gitignore @@ -99,6 +99,28 @@ Linux/cli/main.o Linux/cli/proxybridge-cli Linux/src/libproxybridge.so Linux/src/ProxyBridge.o +Linux/src/*.o +Linux/src/proxybridge.skel.h +Linux/src/bpf/*.o +Linux/cli/ProxyBridge +Linux/gui/*.o +Linux/gui/ProxyBridgeGUI +Linux/ProxyBridgeGUI +Linux/src/tests/rules_test +Linux/src/dns_test +Linux/src/rules_test +Linux/src/tests/dns_test +Linux/**/*.o +Linux/**/*.skel.h +Linux/**/vmlinux.h +Linux/gui/tests/config_test +Linux/src/tests/relay_test +Linux/src/tests/relay_test_* +Linux/tests/hookbench +Linux/tests/thrbench +Linux/tests/api_live_test +Linux/**/__pycache__/ +!Linux/tests/*.py Android/** iOS/** # Windows build intermediates (MSVC import lib / export file / objects) diff --git a/Linux/README.md b/Linux/README.md index 11ef83f9..79a1cff8 100644 --- a/Linux/README.md +++ b/Linux/README.md @@ -34,7 +34,7 @@ curl -Lo deploy.sh https://raw.githubusercontent.com/InterceptSuite/ProxyBridge/ This script will: - Download the latest release from GitHub automatically - Detect your Linux distribution -- Install all required dependencies (libnetfilter-queue, iptables, GTK3) +- Install all required dependencies (libelf, zlib, GTK3) - Install ProxyBridge CLI, GUI, and library to system paths - Update library cache (ldconfig) - Verify installation @@ -63,7 +63,7 @@ This script will: ``` The setup script will: -- Install runtime dependencies (libnetfilter-queue, iptables, GTK3) +- Install runtime dependencies (libelf, zlib, GTK3) - Copy binaries to `/usr/local/bin` (ProxyBridge CLI and GUI) - Copy library to `/usr/local/lib` (libproxybridge.so) - Update library cache @@ -137,8 +137,16 @@ sudo ProxyBridge --proxy socks5://127.0.0.1:1080 \ sudo ProxyBridge --proxy socks5://192.168.1.4:4444 \ --rule "curl:3.19.110.0-3.19.115.255:*:TCP:PROXY" -# Cleanup after crash (removes iptables rules) -sudo ProxyBridge --cleanup +# Several proxies, each rule picks its own (name=type://[user:pass@]host:port) +sudo ProxyBridge \ + --proxy tor=socks5://127.0.0.1:9050 \ + --proxy burp=http://127.0.0.1:8080 \ + --rule "firefox:*:*:BOTH:PROXY=tor" \ + --rule "curl;wget:*:*:TCP:PROXY=burp" \ + --rule "*:10.0.0.0/8:*:BOTH:DIRECT" + +# IPv6 and CIDR targets +sudo ProxyBridge --rule "curl:2001:db8::/32;10.0.0.0/8:443:TCP:PROXY" ``` #### Command Line Options @@ -146,86 +154,75 @@ sudo ProxyBridge --cleanup ``` sudo ProxyBridge --help + ____ ____ _ _ | _ \ _ __ _____ ___ _ | __ ) _ __(_) __| | __ _ ___ | |_) | '__/ _ \ \/ / | | | | _ \| '__| |/ _` |/ _` |/ _ \ | __/| | | (_) > <| |_| | | |_) | | | | (_| | (_| | __/ |_| |_| \___/_/\_\\__, | |____/|_| |_|\__,_|\__, |\___| - |___/ |___/ V3.2.0 + |___/ |___/ V4.0 Universal proxy client for Linux applications - Author: Sourav Kalal/InterceptSuite - GitHub: https://github.com/InterceptSuite/ProxyBridge + Author: Sourav Kalal/InterceptSuite + GitHub: https://github.com/InterceptSuite/ProxyBridge USAGE: - ProxyBridge [OPTIONS] + cli/ProxyBridge [OPTIONS] OPTIONS: - --proxy Proxy server URL with optional authentication - Format: type://ip:port or type://ip:port:username:password + --proxy Add a proxy (repeatable). The first one is the default. + Format: [name=]type://[user:pass@]host:port Examples: socks5://127.0.0.1:1080 - http://proxy.com:8080:myuser:mypass + work=http://alice:secret@proxy.corp:8080 + tor=socks5://[::1]:9050 + Legacy form type://host:port:user:pass is still accepted. Default: socks5://127.0.0.1:4444 - --rule Traffic routing rule (can be specified multiple times) - Format: process:hosts:ports:protocol:action - process - Process name(s): curl, cur*, *, or multiple separated by ; - hosts - IP/host(s): *, google.com, 192.168.*.*, or multiple separated by ; or , - ports - Port(s): *, 443, 80;8080, 80-100, or multiple separated by ; or , + --rule Traffic routing rule (repeatable, first match wins) + Format: process:hosts:ports:protocol:action[=proxy] + process - name or path glob(s): curl, chrom*, *, or several separated by ; or , + hosts - IPv4/IPv6, CIDR (10.0.0.0/8), ranges (1.2.3.4-1.2.3.9), + octet patterns (192.168.*.*) or *; several separated by ; or , + ports - *, 443, 80;8080, 8000-9000 protocol - TCP, UDP, or BOTH - action - PROXY, DIRECT, or BLOCK + action - PROXY, DIRECT, or BLOCK; PROXY=name (or PROXY=2) picks a specific proxy Examples: curl:*:*:TCP:PROXY - curl;wget:*:*:TCP:PROXY - *:*:53:UDP:PROXY - firefox:*:80;443:TCP:DIRECT + firefox:*:*:BOTH:PROXY=work + curl;wget:10.0.0.0/8:*:TCP:DIRECT + *:*:53:UDP:PROXY=tor - --dns-via-proxy Route DNS queries through proxy - Values: true, false, 1, 0 - Default: true + --dns-via-proxy Route DNS queries through the proxy (true/false/1/0, default true) - --verbose Logging verbosity level - 0 - No logs (default) - 1 - Show log messages only - 2 - Show connection events only - 3 - Show both logs and connections + --verbose 0 none (default), 1 logs, 2 connections, 3 both - --cleanup Cleanup resources (iptables, etc.) from crashed instance - Use if ProxyBridge crashed without proper cleanup + --cleanup No-op, kept for compatibility: the eBPF programs detach automatically --help, -h Show this help message EXAMPLES: - # Basic usage with default proxy - sudo ProxyBridge --rule curl:*:*:TCP:PROXY - - # Multiple rules with custom proxy - sudo ProxyBridge --proxy socks5://192.168.1.10:1080 \ - --rule curl:*:*:TCP:PROXY \ - --rule wget:*:*:TCP:PROXY \ - --verbose 2 + sudo cli/ProxyBridge --rule curl:*:*:TCP:PROXY - # Route DNS through proxy with multiple apps - sudo ProxyBridge --proxy socks5://127.0.0.1:1080 \ - --rule "curl;wget;firefox:*:*:BOTH:PROXY" \ - --dns-via-proxy true --verbose 3 + sudo cli/ProxyBridge --proxy socks5://192.168.1.10:1080 --proxy work=http://proxy.corp:8080 \ + --rule firefox:*:*:BOTH:PROXY --rule curl:*:*:TCP:PROXY=work --verbose 2 NOTE: - ProxyBridge requires root privileges to use nfqueue. - Run with 'sudo' or as root user. - + ProxyBridge needs root and a Linux kernel >= 5.11 with BTF and cgroup v2 (eBPF datapath). + Credentials given on the command line are visible to other users via ps. ``` #### Rule Format -**Format:** `process:hosts:ports:protocol:action` +**Format:** `process:hosts:ports:protocol:action[=proxy]` -- **process** - Process name(s): `curl`, `curl;wget;firefox`, `fire*`, or `*` -- **hosts** - Target IP/hostname(s): `*`, `192.168.1.1`, `192.168.*.*`, `10.10.1.1-10.10.255.255`, or `192.168.1.1;10.10.10.10` -- **ports** - Target port(s): `*`, `443`, `80;443;8080`, `80-8000`, or `80;443;8000-9000` +- **process** - Name or path glob(s): `curl`, `curl;wget;firefox`, `fire*`, `*chrome*`, `/usr/bin/*`, or `*`. Matched against the executable's file name and full path, case-sensitive +- **hosts** - `*`, IPv4/IPv6 addresses, CIDR (`10.0.0.0/8`, `2001:db8::/32`), ranges (`10.10.1.1-10.10.255.255`), octet patterns (`192.168.*.*`, `10.1-5.*.*`), **domain names** (`github.com`, `*.github.com`, `api?.example.org`), or a list mixing any of these separated by `;` or `,`. A plain domain matches exactly that name; use `*.` for subdomains. See [Domain rules](#domain-rules) +- **ports** - `*`, `443`, `80;443;8080`, `80-8000`, or `80;443;8000-9000` - **protocol** - `TCP`, `UDP`, or `BOTH` -- **action** - `PROXY`, `DIRECT`, or `BLOCK` +- **action** - `PROXY`, `DIRECT`, or `BLOCK`. `PROXY=name` (or `PROXY=2`, the 1-based position of a `--proxy`) uses a specific proxy; plain `PROXY` uses the first one + +Rules are evaluated **in order and the first match wins** (put specific rules before general ones). **Examples:** ```bash @@ -239,10 +236,19 @@ NOTE: --rule "fire*:*:*:TCP:PROXY" # Matches firefox, firebird, etc. # Target specific IPs and ports ---rule "curl:192.168.*;10.10.*.*:80;443;8080:TCP:PROXY" +--rule "curl:192.168.*.*;10.10.*.*:80;443;8080:TCP:PROXY" + +# Specific proxy per rule +--rule "firefox:*:*:BOTH:PROXY=tor" -# IP range matching +# Domain names (exact and wildcard), each rule with its own proxy +--rule "firefox:*.github.com;github.com:443:TCP:PROXY=work" +--rule "*:*.internal.corp:*:BOTH:PROXY=burp" + +# IP range / CIDR / IPv6 --rule "curl:3.19.110.0-3.19.115.255:*:TCP:PROXY" +--rule "curl:10.0.0.0/8:*:TCP:DIRECT" +--rule "curl:2001:db8::/32:443:TCP:PROXY" # Allow direct connection (bypass proxy) --rule "burpsuite:*:*:TCP:DIRECT" @@ -250,9 +256,39 @@ NOTE: **Notes:** - Process names are case-sensitive on Linux -- Use `*` as the process name to set a default action for all traffic -- Press `Ctrl+C` to stop ProxyBridge -- On crash, use `--cleanup` flag to remove iptables rules +- Interpreted programs (python, node, java) are identified by the interpreter binary, e.g. `python3.12` +- A `PROXY` rule whose proxy does not exist is treated as `BLOCK` (traffic never leaks around a missing proxy) +- A local proxy is never proxied through itself: processes that listen on a configured proxy address (for example Burp, mitmproxy, Tor or `ssh -D` on 127.0.0.1) are exempted automatically, so a rule with process `*` cannot create a loop. The exemption is per process, not per program, and is refreshed every two seconds +- Press `Ctrl+C` to stop ProxyBridge; the kernel hooks are removed automatically, even if ProxyBridge crashes + +## Domain rules + +Applications resolve a name themselves and then connect to an IP address, so the kernel only ever sees the address. To apply domain rules ProxyBridge builds, like the macOS version, an **IP → domain-name map from the DNS answers the application received**: + +1. When at least one enabled rule contains a domain pattern, the DNS traffic (UDP and TCP, port 53) of every process covered by such a rule is routed through ProxyBridge's relay, which forwards it to the original resolver unchanged (directly, or through a proxy if your rules say so) and reads the answers. +2. A and CNAME records are recorded (`www.shop.test -> cdn.edge.net -> 198.51.100.7` makes the address match rules for **either** name). The mapping is installed in the kernel **before** the answer is delivered to the application, so even the very first connection already matches. +3. Names are also loaded from `/etc/hosts` at start. +4. The map is bounded (32,768 addresses, oldest evicted first). It has no TTL: an address keeps its names until it is evicted, so applications that cache DNS answers keep matching. + +Cost: DNS from covered processes takes one extra loopback hop (about +90 µs per query measured on a loopback test server); processes not covered by any domain rule are not touched at all. + +**What domain rules cannot match** (the application never reveals the name to the system): +- DNS-over-HTTPS / DNS-over-TLS (Firefox "Secure DNS", Chrome "Secure DNS", systemd-resolved with DoT): the answers are encrypted. Turn them off in the application or use IP rules +- Addresses that are hard-coded or taken from a config file instead of being resolved +- IPv6 destinations (`AAAA` records are not mapped yet) +- Shared hosting: if two names share one IP, whichever name was resolved last matches; a rule for the other name matches too + +## Multiple proxies + +Add as many `--proxy` entries (CLI) or proxies (GUI) as you need; each rule chooses its own with `PROXY=name`. Proxy hosts may be IP addresses or **host names** (resolved with a 5 s deadline and a cache, so a slow resolver cannot stall traffic). A rule that references a proxy that no longer exists blocks instead of leaking. + +## Security model + +- **Redirected TCP connections** are bound to the client's source address and port. A process that connects to the relay from a different loopback address (for example `127.0.0.2`) cannot claim another process's pending flow, and an entry is consumed on first use. +- **Redirected UDP flows** are addressed by a loopback flow address that the relay maps back to the original destination. These addresses are not secret (they are visible in `/proc/net/udp`), so on a **multi-user host** another local user can send datagrams to the relay's UDP port and have them forwarded to a destination that some other process is already talking to, through the same proxy. Run ProxyBridge on machines you trust locally. +- A rule that needs a proxy that does not exist **blocks** instead of letting traffic through. +- The relay listens on loopback only (UDP is additionally bound to the `lo` device). Other network namespaces (containers) are never redirected. +- Proxy passwords are kept in `/etc/proxybridge/config.ini` (mode 0600, root). On the command line, `--proxy user:pass@host` is wiped from the process list after parsing, but a shell history can still keep it; prefer the GUI or a config file for secrets. ## Use Cases @@ -266,34 +302,28 @@ NOTE: ## Current Limitations -- **IPv4 only** (IPv6 not supported) -- **WSL (Windows Subsystem for Linux) NOT supported** - Neither WSL1 nor WSL2 work with ProxyBridge: - - **WSL2**: Kernel does not support `nfnetlink_queue` module - - Extension shows as "builtin" but is non-functional at runtime - - NFQUEUE handle creation fails - - **WSL1**: Uses a translation layer instead of a real Linux kernel - - Does not support Netfilter/iptables properly - - NFQUEUE is completely unavailable - - **Works on:** Native Linux, VirtualBox VMs, VMware, cloud instances (AWS, GCP, Azure), bare-metal servers - - **Does NOT work on:** WSL1, WSL2, Docker containers with limited capabilities - - **Windows users:** Use the Windows version of ProxyBridge instead - it's specifically designed for Windows and works perfectly -- **Root privileges required** - NFQUEUE and iptables require root access -- **GUI requires GTK3** - Command-line interface works without GUI dependencies +- **Needs a recent kernel**: Linux 5.11+ with BTF (`/sys/kernel/btf/vmlinux`) and cgroup v2 mounted (default on current Ubuntu, Debian, Fedora, Arch). Older kernels are not supported +- **Root privileges required** - loading the eBPF programs needs root (CAP_BPF/CAP_SYS_ADMIN) +- **Native IPv6 UDP to a PROXY rule fails closed** - an unconnected UDP socket sending to a native IPv6 address cannot be redirected (kernel limitation); the `sendto()` returns `EPERM` and applications fall back to IPv4/TCP. Connected IPv6 UDP sockets, IPv6 TCP and UDP to IPv4 destinations through dual-stack sockets all work +- **IPv4 is the fully supported path**; IPv6 (TCP, connected UDP) works for IP rules but has no domain matching and the limits above +- **IPv6-only (`IPV6_V6ONLY`) sockets** connecting to a proxied destination fail (the redirect uses a v4-mapped loopback address) +- **Domain rules only see names the application resolved through DNS that ProxyBridge could observe** - see [Domain rules](#domain-rules) for what that means (DoH/DoT and hard-coded IPs are not matched) +- **Only new connections are affected** - connections that were already open when a rule was added/changed keep their route until they reconnect +- **WSL is not supported** - WSL1/WSL2 kernels do not ship BTF/cgroup-BPF. Use native Linux, VMs, cloud instances or bare metal +- **GUI requires GTK3** - the command-line interface works without GUI dependencies ## Things to Note -- **DNS Traffic Handling**: DNS traffic on TCP and UDP port 53 is handled separately from proxy rules. Even if you configure rules for port 53, they will be ignored. Instead, DNS routing is controlled by the `--dns-via-proxy` flag (enabled by default). When enabled, all DNS queries are routed through the proxy; when disabled, DNS queries use direct connection. - +- **DNS Traffic Handling**: with `--dns-via-proxy true` (default) DNS (port 53) follows your rules like any other traffic; with `false`, DNS always uses a direct connection regardless of rules. - **Automatic Direct Routing**: Certain IP addresses and ports automatically use direct connection regardless of proxy rules: - - **Broadcast addresses** (255.255.255.255 and x.x.x.255) - Network broadcast - - **Multicast addresses** (224.0.0.0 - 239.255.255.255) - Group communication - - **APIPA addresses** (169.254.0.0/16) - Automatic Private IP Addressing (link-local) + - **Loopback** (127.0.0.0/8, ::1) and **0.0.0.0/8** + - **Multicast / broadcast / reserved** (224.0.0.0 and above, ff00::/8) and IPv6 link-local (fe80::/10) - **DHCP ports** (UDP 67, 68) - Dynamic Host Configuration Protocol These addresses and ports are used by system components, network discovery, and essential Linux services. -- **UDP Proxy Requirements**: UDP traffic only works when a SOCKS5 proxy is configured. If an HTTP proxy server is configured, ProxyBridge will ignore UDP proxy rules and route UDP traffic as direct connection instead. This limitation does not affect UDP rules with BLOCK or DIRECT actions. +- **UDP Proxy Requirements**: UDP traffic only works through a SOCKS5 proxy. A rule that sends UDP to an HTTP proxy cannot be honoured, so that UDP traffic goes DIRECT; ProxyBridge logs a warning naming the rule when it is created or the engine starts. Use a SOCKS5 proxy, or a TCP-only rule plus a separate `UDP ... BLOCK` rule, if direct UDP is not acceptable. This limitation does not affect UDP rules with BLOCK or DIRECT actions. **Important UDP Considerations**: - Configuring a SOCKS5 proxy does not guarantee UDP will work. Most SOCKS5 proxies do not support UDP traffic, including SSH SOCKS5 proxies. @@ -302,9 +332,8 @@ NOTE: - **Testing UDP/HTTP3/DTLS Support**: If you need to test UDP, HTTP/3, and DTLS support with a SOCKS5 proxy, try [Nexus Proxy](https://github.com/InterceptSuite/nexus-proxy) - a proxy application created specifically to test ProxyBridge with advanced UDP protocols. - **Root Privileges**: ProxyBridge requires root access to: - - Create NFQUEUE handles for packet interception - - Add/remove iptables rules in mangle and nat tables - - Listen on relay ports (34010 for TCP, 34011 for UDP) + - Load the eBPF programs and attach them to the root cgroup + - Listen on its local relay ports (24010 for TCP, 24011 for UDP; the next free pair is used if they are taken) Always run with `sudo` or as root user. @@ -315,138 +344,53 @@ NOTE: ## How It Works -ProxyBridge uses Linux Netfilter NFQUEUE to intercept TCP/UDP packets and applies user-defined rules to route traffic through proxies. - -**Case 1: Packet does not match any rules** +ProxyBridge hooks the **socket calls** (`connect()`, `sendmsg()`, `recvmsg()`) with eBPF programs attached to the root cgroup. It never inspects or queues packets, so the TCP data path of every application is untouched. ``` -Application → TCP/UDP Packet → NFQUEUE → ProxyBridge - ↓ - [No Match or DIRECT] - ↓ - Packet Verdict: ACCEPT - ↓ - Direct Connection → Internet + application kernel (eBPF, cgroup sock_addr hooks) ProxyBridge (userspace) + ----------- -------------------------------------- ----------------------- + connect(dst) ───────► which executable is this? ─► which rules apply? + no rule / DIRECT ─────► unchanged (zero extra cost on the data path) + BLOCK ─────► connect() fails with EPERM + PROXY(id) ─────► destination rewritten to 127.0.0.1:relay, + original destination saved ──────────────► relay reads it, + talks SOCKS5 / HTTP CONNECT + cannot decide ─────► sent to the relay as "decide" ───────────► full rule engine decides + (and teaches the kernel) ``` -**Case 2: Packet matches proxy rule** - -``` -Application → TCP/UDP Packet → NFQUEUE → ProxyBridge - ↓ - [PROXY Rule Match] - ↓ - Packet Verdict: ACCEPT + Mark - ↓ - iptables NAT REDIRECT - ↓ - Relay Server (34010/34011) ← Packet - ↓ - [Store Original Destination] - ↓ - SOCKS5/HTTP Protocol Conversion - ↓ - Proxy Server (Burp Suite/InterceptSuite) - ↓ - Forward to Original Destination - ↓ - Internet - ↓ - Response Returns - ↓ - Relay Server - ↓ - [Restore Original Source IP/Port] - ↓ - Application Receives Response -``` - -**Detailed Traffic Flow:** - -1. **Applications Generate Traffic** - User-mode applications (curl, wget, firefox, games) create TCP/UDP packets +**Kernel side** ([src/bpf/proxybridge.bpf.c](src/bpf/proxybridge.bpf.c)) +- Identifies the calling process by its executable's `(device, inode)` and looks up a precompiled **class** (the ordered list of rules whose process pattern matches that binary). Rules are compiled to bounded map lookups: port ranges, IPv4 ranges and protocol are evaluated in the kernel; anything it cannot express (octet patterns, IPv6 patterns, many ranges) is handled by the relay +- TCP: rewrites the destination to the local relay and passes the original destination via a `sockops` hook keyed by the client's source port +- UDP: encodes the original destination as a loopback **flow address** (`127.x.y.z`); the relay replies from that address and a `recvmsg` hook restores the real source, so one socket can talk to many destinations +- `getpeername()` on redirected sockets returns the original destination +- Memory is bounded: fixed-size maps (about a few MB in total), LRU maps for per-flow state -2. **Kernel Interception** - iptables rules in the mangle table send packets to NFQUEUE: - ```bash - iptables -t mangle -A OUTPUT -p tcp -j NFQUEUE --queue-num 0 - iptables -t mangle -A OUTPUT -p udp -j NFQUEUE --queue-num 0 - ``` - -3. **NFQUEUE Delivery** - libnetfilter_queue delivers packets to ProxyBridge in userspace +**Userspace side** ([src/pb_relay.c](src/pb_relay.c), [src/pb_rules.c](src/pb_rules.c), [src/pb_bpf.c](src/pb_bpf.c)) +- Rule engine: validation, globs, IPv4/IPv6, CIDR, ranges, first-match-wins; the reference semantics for everything the kernel cannot decide +- epoll based relay with bounded resources: SOCKS5 (with user/pass) and HTTP CONNECT for TCP, SOCKS5 UDP ASSOCIATE for UDP, direct forwarding for the slow path, idle timeouts, connection cap +- Several proxies can be configured; every rule chooses its own +- Binaries the kernel has not seen yet take a one-time slow path through the relay; the result is cached in the kernel so later connections skip it -4. **Rule Evaluation** - ProxyBridge inspects each packet and applies configured rules: - - **BLOCK** → Packet verdict: DROP (no network access) - - **DIRECT** → Packet verdict: ACCEPT (direct connection) - - **NO MATCH** → Packet verdict: ACCEPT (direct connection) - - **PROXY** → Packet verdict: ACCEPT + set mark (1 for TCP, 2 for UDP) - -5. **NAT Redirection** - For PROXY-matched packets, iptables NAT rules redirect marked packets: - ```bash - iptables -t nat -A OUTPUT -p tcp -m mark --mark 1 -j REDIRECT --to-port 34010 - iptables -t nat -A OUTPUT -p udp -m mark --mark 2 -j REDIRECT --to-port 34011 - ``` +**Why this design:** an earlier eBPF attempt (3.1.0) evaluated rules per packet in the kernel and was abandoned for memory and latency reasons. This design avoids both problems: decisions are made once per connection (not per packet), rules are compiled into small bounded maps, and everything the kernel cannot express goes to userspace once per connection. -6. **Relay Servers** - Local relay servers (34010 for TCP, 34011 for UDP): - - Intercept redirected packets using getsockopt(SO_ORIGINAL_DST) - - Store original destination IP and port - - Convert raw TCP/UDP to SOCKS5/HTTP proxy protocol - - Perform proxy authentication if configured - - Forward to configured proxy server - -7. **Proxy Forwarding** - Proxy server (Burp Suite/InterceptSuite) forwards traffic to the original destination - -8. **Response Handling** - Return traffic flows back through relay servers, which restore original source IP/port - -**Key Technical Points:** - -- **NFQUEUE vs WinDivert**: Linux uses Netfilter NFQUEUE (kernel feature) instead of WinDivert (Windows kernel driver) - -- **Why NFQUEUE Instead of eBPF**: While eBPF is the modern approach for Linux networking tasks and offers better performance, ProxyBridge uses NFQUEUE due to fundamental limitations discovered during development: - - **Original Plan**: ProxyBridge 3.1.0 for Linux was initially developed using eBPF after weeks of implementation - - **eBPF Memory Limitations**: eBPF provides limited memory space, which proved insufficient for ProxyBridge's feature set: - - ProxyBridge supports multiple complex proxy rules with wildcard matching, IP ranges, and process patterns - - Storing and evaluating these rules within eBPF's memory constraints was not feasible - - Alternative workarounds added excessive latency (200-500ms+ per packet) - - **Performance Requirements**: ProxyBridge's core design goals couldn't be met with eBPF: - - Work efficiently with minimal memory usage under high load - - Handle high traffic volumes (10,000+ concurrent connections) - - **Network speed impact must be ≤2-5% for proxied traffic only** - - **Zero performance impact on direct (non-proxied) traffic** - - eBPF implementation with all required features caused 15-30% slowdown on all traffic - - **NFQUEUE Advantages**: - - Userspace processing allows unlimited memory for complex rule evaluation - - Selective packet inspection - only examines packets, doesn't slow down uninspected traffic - - Mature, stable kernel interface available on all Linux distributions - - Lower latency than eBPF workarounds when handling complex rule sets - - **Verdict**: NFQUEUE provides the right balance of flexibility, performance, and compatibility for ProxyBridge's requirements. While eBPF excels for simple packet filtering, ProxyBridge's advanced rule engine and protocol conversion needs are better served by NFQUEUE's userspace processing model. - -- **Packet Marking**: ProxyBridge marks packets (mark=1 for TCP, mark=2 for UDP) instead of modifying destination -- **iptables Integration**: Uses mangle table (pre-routing processing) + nat table (port redirection) -- **Transparent Redirection**: Applications remain completely unaware of proxying -- **SO_ORIGINAL_DST**: Socket option retrieves original destination after NAT redirect -- **Multi-threaded**: Separate threads for packet processing, TCP relay, UDP relay, and connection cleanup - -**Architecture Advantages:** - -- No kernel modules required (uses built-in Netfilter) -- Works on any Linux distribution with iptables and NFQUEUE support -- Leverages proven kernel infrastructure (Netfilter/iptables) -- Separate packet marking prevents packet modification in NFQUEUE -- Clean separation: NFQUEUE for inspection, iptables for redirection +**Cleanup:** the programs are attached through BPF links, so the kernel removes them automatically when ProxyBridge exits or crashes. There are no iptables rules or system settings to restore. ## Build from Source ### Requirements -- Linux kernel with NFQUEUE support (virtually all modern distributions) -- GCC compiler -- Make +- Linux kernel 5.11+ with BTF and cgroup v2 (to run); GCC, Make, **clang** and **bpftool** (to build the eBPF object and skeleton) - Development libraries: - - libnetfilter-queue-dev (Debian/Ubuntu) or libnetfilter_queue-devel (Fedora/RHEL) - - libnfnetlink-dev (Debian/Ubuntu) or libnfnetlink-devel (Fedora/RHEL) + - libelf-dev + zlib1g-dev (Debian/Ubuntu) or elfutils-libelf-devel + zlib-devel (Fedora/RHEL). libbpf itself is built from a pinned + release and linked statically, so it is not needed at run time + - curl (fetches the pinned libbpf source on first build) - libgtk-3-dev (optional, for GUI) or gtk3-devel (Fedora/RHEL) - pkg-config +Binaries are built against a glibc 2.34 baseline (Ubuntu 22.04, Debian 12, RHEL 9 and newer run them), with LTO, FORTIFY_SOURCE=3, +full RELRO, BIND_NOW, PIE, stack protector and CET flags; `Linux/tests/check_binaries.sh` audits this on every build (issue #146). + ### Building 1. **Install build dependencies:** @@ -454,23 +398,20 @@ Application → TCP/UDP Packet → NFQUEUE → ProxyBridge **Debian/Ubuntu/Mint:** ```bash sudo apt-get update - sudo apt-get install build-essential gcc make \ - libnetfilter-queue-dev libnfnetlink-dev \ - libgtk-3-dev pkg-config + sudo apt-get install build-essential gcc make clang bpftool \ + libelf-dev zlib1g-dev curl libgtk-3-dev pkg-config ``` **Fedora:** ```bash - sudo dnf install gcc make \ - libnetfilter_queue-devel libnfnetlink-devel \ - gtk3-devel pkg-config + sudo dnf install gcc make clang bpftool \ + elfutils-libelf-devel zlib-devel curl gtk3-devel pkg-config ``` **Arch/Manjaro:** ```bash - sudo pacman -S base-devel gcc make \ - libnetfilter_queue libnfnetlink \ - gtk3 pkg-config + sudo pacman -S base-devel gcc make clang bpf \ + libelf zlib curl gtk3 pkg-config ``` 2. **Clone or download ProxyBridge source code:** @@ -515,15 +456,8 @@ After successful build, binaries will be in the `output/` directory: **Library:** ```bash cd src -gcc -Wall -Wextra -O3 -fPIC -D_GNU_SOURCE \ - -fstack-protector-strong -D_FORTIFY_SOURCE=2 -fPIE \ - -Wformat -Wformat-security -Werror=format-security \ - -fno-strict-overflow -fno-delete-null-pointer-checks -fwrapv \ - -c ProxyBridge.c -o ProxyBridge.o - -gcc -shared -Wl,-z,relro,-z,now -Wl,-z,noexecstack -s \ - -o libproxybridge.so ProxyBridge.o \ - -lpthread -lnetfilter_queue -lnfnetlink +make # builds bpf/proxybridge.bpf.o, proxybridge.skel.h and libproxybridge.so +make test-rules # parser/matcher unit tests (ASan + UBSan) ``` **CLI:** @@ -541,6 +475,10 @@ gcc -o ProxyBridge main.o \ -lproxybridge -lpthread ``` +**Testing against Nexus Proxy.** [Nexus Proxy](https://github.com/InterceptSuite/nexus-proxy) is a Node.js SOCKS5 server that really forwards TCP and UDP. `tests/e2e_nexus.sh` runs ProxyBridge in front of two Nexus instances (one with a username/password) with real origin servers and a real DNS server, and checks rules by IP, port, port range, process, CIDR and domain, TCP and UDP, 200 MB / 100 MB checksummed transfers, 100 parallel and 1,500 sequential connections, 300 concurrent UDP sockets, 59 KB datagrams, a sustained UDP stream, and recovery after the proxy restarts. Stock Nexus cannot do UDP ASSOCIATE on Linux (its IPv6 relay socket binds `0.0.0.0`, IPv4 destinations are not sent as `::ffff:` addresses, and mapped replies are not matched); `tests/nexus-linux-udp.patch` fixes that, and without it the UDP checks are skipped. Nexus limits each client IP to 500 connections, so it cannot be used for the 10,000-connection scale test. + +**Config location:** the GUI stores profiles, proxies and rules in `/etc/proxybridge/config.ini` (mode 0600). Set `PROXYBRIDGE_CONFIG_DIR` to use another directory (used by the tests). + ## Uninstallation To remove ProxyBridge from your system: @@ -564,20 +502,7 @@ sudo rm -f /etc/ld.so.conf.d/proxybridge.conf sudo ldconfig ``` -**Cleanup after crash:** - -If ProxyBridge crashed and left iptables rules: -```bash -sudo ProxyBridge --cleanup -``` - -Or manually remove iptables rules: -```bash -sudo iptables -t mangle -D OUTPUT -p tcp -j NFQUEUE --queue-num 0 -sudo iptables -t mangle -D OUTPUT -p udp -j NFQUEUE --queue-num 0 -sudo iptables -t nat -D OUTPUT -p tcp -m mark --mark 1 -j REDIRECT --to-port 34010 -sudo iptables -t nat -D OUTPUT -p udp -m mark --mark 2 -j REDIRECT --to-port 34011 -``` +**Cleanup after crash:** nothing to do. ProxyBridge attaches its eBPF programs through BPF links, which the kernel removes when the process exits or crashes. To check: `sudo bpftool cgroup tree /sys/fs/cgroup` should list no `pb_*` programs. ## License @@ -588,3 +513,22 @@ MIT License - See LICENSE file for details **Author:** Sourav Kalal / InterceptSuite **GitHub:** https://github.com/InterceptSuite/ProxyBridge **Documentation:** https://github.com/InterceptSuite/ProxyBridge/tree/master/Linux + +## Testing + +```bash +cd Linux && ./build.sh # builds in a temp dir, final files land in Linux/output/ +cd src && make test-rules BUILD_DIR=/tmp/pbt # rule engine + DNS parser tests (no root needed) +cd tests && ./run_relay_test.sh both # relay tests, plain and ASan/UBSan (no root needed) +cd ../../tests && sudo ./e2e.sh # end-to-end with real kernel hooks (root); uses Linux/output, override with PB_DIR= +sudo ./e2e_domains.sh # domain rules with a real resolver client (root, needs dig) +sudo ./e2e_rules.sh # every rule form + every proxy type, incl. HTTP auth and UDP rules (root) +sudo ./api_live.sh # library API: edit/move/disable/delete rules and proxies on the live datapath (root) +sudo BPFTOOL=$(command -v bpftool) ./e2e_security.sh # relay hand-off cannot be hijacked from another loopback address (root) +sudo ./churn.sh 3000 # rule/proxy churn with live traffic: memory, fds and threads must stay flat (root) +sudo ./e2e_openssh.sh # interoperability with a real third-party SOCKS5 server (OpenSSH ssh -D), 40 MB each way (root) +sudo NODE=$(command -v node) NEXUS_JS=/path/to/nexus-proxy/script.js ./e2e_nexus.sh # against Nexus Proxy, a real forwarding SOCKS5 server with UDP (root) +sudo ./scale.sh 10000 # 10,000 concurrent proxied connections (root) +sudo ./gui_headless.sh # the real GTK GUI under the Broadway backend (root, needs broadwayd) +cd ../gui/tests && gcc -I../../src -I.. -D_GNU_SOURCE $(pkg-config --cflags gtk+-3.0) config_test.c ../gui_config.c -o /tmp/config_test $(pkg-config --libs gtk+-3.0) && /tmp/config_test # GUI config/profile tests +``` diff --git a/Linux/build.sh b/Linux/build.sh index 6d5f49bd..e779a987 100755 --- a/Linux/build.sh +++ b/Linux/build.sh @@ -1,101 +1,106 @@ #!/bin/bash - -set -e # Exit on error +# Builds libproxybridge.so, the CLI and (if GTK3 is present) the GUI. +# Everything is compiled in a temporary directory; only the final binaries are copied to output/, after an audit of their +# portability (glibc floor) and hardening. +# +# ./build.sh -> Linux/output/ +# OUTPUT_DIR=/some/dir ./build.sh +# WERROR=1 ./build.sh warnings are errors (CI) LTO=0 ./build.sh no link-time optimisation +# AUDIT=0 ./build.sh skip the binary audit STATIC_LIBBPF=0 ./build.sh use the system libbpf +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -OUTPUT_DIR="$SCRIPT_DIR/output" +OUTPUT_DIR="${OUTPUT_DIR:-$SCRIPT_DIR/output}" +BUILD_DIR="$(mktemp -d "${TMPDIR:-/tmp}/proxybridge-build.XXXXXX")" +trap 'rm -rf "$BUILD_DIR"' EXIT +JOBS="$(nproc 2>/dev/null || echo 2)" +export WERROR LTO OPT -# Remove and recreate output directory -if [ -d "$OUTPUT_DIR" ]; then - echo "Removing existing output directory..." - rm -rf "$OUTPUT_DIR" +need() { # command, install hint + command -v "$1" >/dev/null 2>&1 || { echo "ERROR: '$1' not found. $2"; exit 1; } +} +need gcc "Install: sudo apt install build-essential | sudo dnf install gcc" +need curl "Needed to fetch the pinned libbpf source. Install: sudo apt install curl" +need make "Install: sudo apt install make | sudo dnf install make" +need clang "Needed to compile the eBPF program. Install: sudo apt install clang | sudo dnf install clang" +BPFTOOL="${BPFTOOL:-bpftool}" # set BPFTOOL=/path/to/bpftool if it is not on PATH (e.g. /usr/lib/linux-tools/*/bpftool on Ubuntu) +export BPFTOOL +need "$BPFTOOL" "Needed to generate the eBPF skeleton. Install: sudo apt install linux-tools-generic (Ubuntu) | sudo apt install bpftool (Debian) | sudo dnf install bpftool" +if [ "${STATIC_LIBBPF:-1}" != 1 ] && [ ! -f /usr/include/bpf/libbpf.h ] && ! pkg-config --exists libbpf 2>/dev/null; then + echo "ERROR: libbpf development files not found. Install: sudo apt install libbpf-dev | sudo dnf install libbpf-devel" + exit 1 fi +for h in libelf.h zlib.h; do + [ -f "/usr/include/$h" ] || find /usr/include -name "$h" -print -quit 2>/dev/null | grep -q . || { + echo "ERROR: $h not found (needed to build the static libbpf). Install: sudo apt install libelf-dev zlib1g-dev | sudo dnf install elfutils-libelf-devel zlib-devel" + exit 1 + } +done -echo "Creating output directory..." +if [ -e "$OUTPUT_DIR" ]; then + echo "Removing existing output directory..." + rm -rf "$OUTPUT_DIR" 2>/dev/null || { + echo "ERROR: cannot remove $OUTPUT_DIR (files owned by another user, e.g. root)." + echo " Remove it manually (sudo rm -rf '$OUTPUT_DIR') or choose another one with OUTPUT_DIR=..." + exit 1 + } +fi mkdir -p "$OUTPUT_DIR" echo "" -# Build library echo "=== Building Library ===" -cd "$SCRIPT_DIR/src" -make clean 2>/dev/null || true -make - -if [ -f "libproxybridge.so" ]; then - echo "Library build successful" +LIBBPF_ARGS=() +if [ "${STATIC_LIBBPF:-1}" = 1 ]; then + if make -s -C "$SCRIPT_DIR/src" libbpf-static; then + LIBBPF_ARGS=(LIBBPF_DIR="$(make -s -C "$SCRIPT_DIR/src" libbpf-path)") + echo "libbpf: linking the pinned static build" + else + echo "WARNING: could not build the pinned libbpf (needs network, curl, libelf-dev and zlib1g-dev);" + echo " falling back to the system libbpf. The result will not run on systems without libbpf >= 1.0." + export ALLOW_DYNAMIC_LIBBPF=1 + fi else - echo "Library build failed!" - exit 1 + export ALLOW_DYNAMIC_LIBBPF=1 fi +make -j"$JOBS" -C "$SCRIPT_DIR/src" BUILD_DIR="$BUILD_DIR/lib" "${LIBBPF_ARGS[@]}" +[ -f "$BUILD_DIR/lib/libproxybridge.so" ] || { echo "Library build failed!"; exit 1; } +echo "Library build successful" echo "" -# Build CLI (library must stay for linking) echo "=== Building CLI ===" -cd "$SCRIPT_DIR/cli" -make clean 2>/dev/null || true -make - -if [ -f "ProxyBridge" ]; then - echo "CLI build successful" -else - echo "CLI build failed!" - exit 1 -fi +make -j"$JOBS" -C "$SCRIPT_DIR/cli" BUILD_DIR="$BUILD_DIR/cli" LIBDIR="$BUILD_DIR/lib" +[ -f "$BUILD_DIR/cli/ProxyBridge" ] || { echo "CLI build failed!"; exit 1; } +echo "CLI build successful" echo "" -# Move binaries to output echo "=== Building GUI ===" -cd "$SCRIPT_DIR" -rm -f ProxyBridgeGUI +GUI_BUILT=0 if pkg-config --exists gtk+-3.0; then - GUI_CFLAGS="-Wall -Wno-unused-parameter -O3 -Isrc -D_GNU_SOURCE -fstack-protector-strong -D_FORTIFY_SOURCE=2 -fPIE -Wformat -Wformat-security -Werror=format-security -fno-strict-overflow -fno-delete-null-pointer-checks -fwrapv $(pkg-config --cflags gtk+-3.0)" - GUI_LDFLAGS="-Lsrc -pie -Wl,-z,relro,-z,now -Wl,-z,noexecstack -s -Wl,-rpath,'$ORIGIN/.' -lproxybridge -lpthread $(pkg-config --libs gtk+-3.0) -export-dynamic" - - # Compile all GUI source files - GUI_OBJS="" - for src in gui/*.c; do - obj="${src%.c}.o" - gcc $GUI_CFLAGS -c "$src" -o "$obj" - GUI_OBJS="$GUI_OBJS $obj" - done - - gcc -o ProxyBridgeGUI $GUI_OBJS $GUI_LDFLAGS - - rm -f gui/*.o + make -j"$JOBS" -C "$SCRIPT_DIR/gui" BUILD_DIR="$BUILD_DIR/gui" LIBDIR="$BUILD_DIR/lib" + GUI_BUILT=1 echo "GUI build successful" else echo "GTK3 not found. Skipping GUI build." echo " Install with: sudo apt install libgtk-3-dev (Debian/Ubuntu/Mint)" echo " sudo dnf install gtk3-devel (Fedora)" fi - -echo "" -echo "Moving binaries to output directory..." -mv "$SCRIPT_DIR/src/libproxybridge.so" "$OUTPUT_DIR/" -mv "$SCRIPT_DIR/cli/ProxyBridge" "$OUTPUT_DIR/" -if [ -f ProxyBridgeGUI ]; then - mv ProxyBridgeGUI "$OUTPUT_DIR/" -fi -echo "Binaries moved to output" echo "" -# Cleanup build files -echo "Cleaning up build artifacts..." -cd "$SCRIPT_DIR/src" -rm -f *.o -make clean 2>/dev/null || true -cd "$SCRIPT_DIR/cli" -rm -f *.o -make clean 2>/dev/null || true -echo "Cleanup complete" +echo "Copying binaries to $OUTPUT_DIR ..." +cp "$BUILD_DIR/lib/libproxybridge.so" "$OUTPUT_DIR/" +cp "$BUILD_DIR/cli/ProxyBridge" "$OUTPUT_DIR/" +[ "$GUI_BUILT" = 1 ] && cp "$BUILD_DIR/gui/ProxyBridgeGUI" "$OUTPUT_DIR/" echo "" -# Show results +if [ "${AUDIT:-1}" = 1 ]; then + echo "=== Auditing binaries ===" + "$SCRIPT_DIR/tests/check_binaries.sh" "$OUTPUT_DIR" || { echo "Audit failed: the binaries are not portable/hardened enough to ship."; exit 1; } + echo "" +fi + echo "===================================" echo "Build Complete!" echo "===================================" -cd "$OUTPUT_DIR" -ls -lh +ls -lh "$OUTPUT_DIR" echo "" echo "Output location: $OUTPUT_DIR" - diff --git a/Linux/cli/Makefile b/Linux/cli/Makefile index 6aff0ac9..ed4e85d7 100644 --- a/Linux/cli/Makefile +++ b/Linux/cli/Makefile @@ -1,29 +1,25 @@ -CC = gcc -CFLAGS = -Wall -Wextra -O3 -I../src \ - -fstack-protector-strong \ - -D_FORTIFY_SOURCE=2 \ - -fPIE \ - -Wformat -Wformat-security \ - -Werror=format-security \ - -fno-strict-overflow \ - -fno-delete-null-pointer-checks \ - -fwrapv -LDFLAGS = -L../src -pie -Wl,-z,relro,-z,now -Wl,-z,noexecstack -s \ - -Wl,-rpath,/usr/local/lib +include ../common.mk + +# BUILD_DIR / LIBDIR: where generated files go and where libproxybridge.so was built (build.sh sets both). +BUILD_DIR ?= . +LIBDIR ?= ../src + +CFLAGS = $(CFLAGS_COMMON) -fPIE -I../src +LDFLAGS = -pie $(HARDEN_LDFLAGS) -L$(LIBDIR) -Wl,-rpath,/usr/local/lib $(STRIP_FLAG) LIBS = -lproxybridge -lpthread -TARGET = ProxyBridge -SOURCES = main.c -OBJECTS = $(SOURCES:.c=.o) +TARGET = $(BUILD_DIR)/ProxyBridge +OBJECTS = $(BUILD_DIR)/main.o all: $(TARGET) +$(BUILD_DIR): + mkdir -p $@ + $(TARGET): $(OBJECTS) - $(CC) -o $@ $^ $(LDFLAGS) $(LIBS) - @echo "Stripping debug symbols..." - strip --strip-all $@ + $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) $(LIBS) -%.o: %.c +$(BUILD_DIR)/%.o: %.c | $(BUILD_DIR) $(CC) $(CFLAGS) -c $< -o $@ clean: diff --git a/Linux/cli/main.c b/Linux/cli/main.c index 45a540ad..dc436403 100644 --- a/Linux/cli/main.c +++ b/Linux/cli/main.c @@ -8,7 +8,8 @@ #include "../src/ProxyBridge.h" #define MAX_RULES 100 -#define MAX_RULE_STR 512 +#define MAX_PROXIES 32 +#define MAX_RULE_STR 1024 typedef struct { char process_name[256]; @@ -16,8 +17,19 @@ typedef struct { char target_ports[256]; RuleProtocol protocol; RuleAction action; + char proxy_ref[64]; /* proxy name or 1-based index; empty = default */ } ProxyRule; +typedef struct { + char name[64]; + ProxyType type; + char host[256]; + uint16_t port; + char username[256]; + char password[256]; + uint32_t id; +} ProxyEntry; + static volatile bool keep_running = false; static int verbose_level = 0; @@ -40,22 +52,8 @@ static void connection_callback(const char* process_name, uint32_t pid, const ch static void signal_handler(int sig) { - if (sig == SIGSEGV || sig == SIGABRT || sig == SIGBUS) - { - printf("\n\n=== CLI CRASH DETECTED ===\n"); - printf("Signal: %d (%s)\n", sig, - sig == SIGSEGV ? "SEGFAULT" : - sig == SIGABRT ? "ABORT" : "BUS ERROR"); - printf("Calling emergency cleanup...\n"); - ProxyBridge_Stop(); - _exit(1); - } - - if (keep_running) - { - printf("\n\nStopping ProxyBridge...\n"); - keep_running = false; - } + (void)sig; + keep_running = false; } static void show_banner(void) @@ -66,7 +64,7 @@ static void show_banner(void) printf(" | |_) | '__/ _ \\ \\/ / | | | | _ \\| '__| |/ _` |/ _` |/ _ \\\n"); printf(" | __/| | | (_) > <| |_| | | |_) | | | | (_| | (_| | __/\n"); printf(" |_| |_| \\___/_/\\_\\\\__, | |____/|_| |_|\\__,_|\\__, |\\___|\n"); - printf(" |___/ |___/ V4.0-Beta\n"); + printf(" |___/ |___/ V%s\n", PROXYBRIDGE_VERSION); printf("\n"); printf(" Universal proxy client for Linux applications\n"); printf("\n"); @@ -82,215 +80,242 @@ static void show_help(const char* prog) printf(" %s [OPTIONS]\n\n", prog); printf("OPTIONS:\n"); - printf(" --proxy Proxy server URL with optional authentication\n"); - printf(" Format: type://ip:port or type://ip:port:username:password\n"); + printf(" --proxy Add a proxy (repeatable). The first one is the default.\n"); + printf(" Format: [name=]type://[user:pass@]host:port\n"); printf(" Examples: socks5://127.0.0.1:1080\n"); - printf(" http://proxy.com:8080:myuser:mypass\n"); + printf(" work=http://alice:secret@proxy.corp:8080\n"); + printf(" tor=socks5://[::1]:9050\n"); + printf(" Legacy form type://host:port:user:pass is still accepted.\n"); printf(" Default: socks5://127.0.0.1:4444\n\n"); - printf(" --rule Traffic routing rule (can be specified multiple times)\n"); - printf(" Format: process:hosts:ports:protocol:action\n"); - printf(" process - Process name(s): curl, cur*, *, or multiple separated by ;\n"); - printf(" hosts - IP/host(s): *, google.com, 192.168.*.*, or multiple separated by ; or ,\n"); - printf(" ports - Port(s): *, 443, 80;8080, 80-100, or multiple separated by ; or ,\n"); + printf(" --rule Traffic routing rule (repeatable, first match wins)\n"); + printf(" Format: process:hosts:ports:protocol:action[=proxy]\n"); + printf(" process - name or path glob(s): curl, chrom*, *, or several separated by ; or ,\n"); + printf(" hosts - IPv4/IPv6, CIDR (10.0.0.0/8), ranges (1.2.3.4-1.2.3.9),\n"); + printf(" octet patterns (192.168.*.*), domain names (github.com, *.github.com)\n"); + printf(" or *; several separated by ; or ,\n"); + printf(" (domains are matched via the DNS answers the application received)\n"); + printf(" ports - *, 443, 80;8080, 8000-9000\n"); printf(" protocol - TCP, UDP, or BOTH\n"); - printf(" action - PROXY, DIRECT, or BLOCK\n"); + printf(" action - PROXY, DIRECT, or BLOCK; PROXY=name (or PROXY=2) picks a specific proxy\n"); printf(" Examples:\n"); printf(" curl:*:*:TCP:PROXY\n"); - printf(" curl;wget:*:*:TCP:PROXY\n"); - printf(" *:*:53:UDP:PROXY\n"); - printf(" firefox:*:80;443:TCP:DIRECT\n\n"); + printf(" firefox:*:*:BOTH:PROXY=work\n"); + printf(" curl;wget:10.0.0.0/8:*:TCP:DIRECT\n"); + printf(" *:*:53:UDP:PROXY=tor\n"); + printf(" firefox:*.github.com;github.com:443:TCP:PROXY=work\n\n"); - printf(" --dns-via-proxy Route DNS queries through proxy\n"); - printf(" Values: true, false, 1, 0\n"); - printf(" Default: true\n\n"); + printf(" --dns-via-proxy Route DNS queries through the proxy (true/false/1/0, default true)\n\n"); - printf(" --verbose Logging verbosity level\n"); - printf(" 0 - No logs (default)\n"); - printf(" 1 - Show log messages only\n"); - printf(" 2 - Show connection events only\n"); - printf(" 3 - Show both logs and connections\n\n"); + printf(" --verbose 0 none (default), 1 logs, 2 connections, 3 both\n\n"); - printf(" --cleanup Cleanup resources (iptables, etc.) from crashed instance\n"); - printf(" Use if ProxyBridge crashed without proper cleanup\n\n"); + printf(" --cleanup No-op, kept for compatibility: the eBPF programs detach automatically\n\n"); printf(" --help, -h Show this help message\n\n"); printf("EXAMPLES:\n"); - printf(" # Basic usage with default proxy\n"); printf(" sudo %s --rule curl:*:*:TCP:PROXY\n\n", prog); - - printf(" # Multiple rules with custom proxy\n"); - printf(" sudo %s --proxy socks5://192.168.1.10:1080 \\\n", prog); - printf(" --rule curl:*:*:TCP:PROXY \\\n"); - printf(" --rule wget:*:*:TCP:PROXY \\\n"); - printf(" --verbose 2\n\n"); - - printf(" # Route DNS through proxy with multiple apps\n"); - printf(" sudo %s --proxy socks5://127.0.0.1:1080 \\\n", prog); - printf(" --rule \"curl;wget;firefox:*:*:BOTH:PROXY\" \\\n"); - printf(" --dns-via-proxy true --verbose 3\n\n"); + printf(" sudo %s --proxy socks5://192.168.1.10:1080 --proxy work=http://proxy.corp:8080 \\\n", prog); + printf(" --rule firefox:*:*:BOTH:PROXY --rule curl:*:*:TCP:PROXY=work --verbose 2\n\n"); printf("NOTE:\n"); - printf(" ProxyBridge requires root privileges to use nfqueue.\n"); - printf(" Run with 'sudo' or as root user.\n\n"); + printf(" ProxyBridge needs root and a Linux kernel >= 5.11 with BTF and cgroup v2 (eBPF datapath).\n"); + printf(" Credentials given on the command line are visible to other users via ps.\n\n"); +} + +static void upper_copy(char* dst, const char* src, size_t n) +{ + size_t i = 0; + for (; src[i] && i + 1 < n; i++) + dst[i] = (char)toupper((unsigned char)src[i]); + dst[i] = '\0'; } static RuleProtocol parse_protocol(const char* str) { char upper[16]; - for (size_t i = 0; str[i] && i < 15; i++) - upper[i] = toupper(str[i]); - upper[strlen(str) < 15 ? strlen(str) : 15] = '\0'; - - if (strcmp(upper, "TCP") == 0) - return RULE_PROTOCOL_TCP; - else if (strcmp(upper, "UDP") == 0) - return RULE_PROTOCOL_UDP; - else if (strcmp(upper, "BOTH") == 0) - return RULE_PROTOCOL_BOTH; - else - { - fprintf(stderr, "ERROR: Invalid protocol '%s'. Use TCP, UDP, or BOTH\n", str); - exit(1); - } + upper_copy(upper, str, sizeof(upper)); + if (strcmp(upper, "TCP") == 0) return RULE_PROTOCOL_TCP; + if (strcmp(upper, "UDP") == 0) return RULE_PROTOCOL_UDP; + if (strcmp(upper, "BOTH") == 0) return RULE_PROTOCOL_BOTH; + fprintf(stderr, "ERROR: Invalid protocol '%s'. Use TCP, UDP, or BOTH\n", str); + exit(1); } -static RuleAction parse_action(const char* str) +/* action[=proxy] */ +static RuleAction parse_action(const char* str, char* proxy_ref, size_t ref_size) { - char upper[16]; - for (size_t i = 0; str[i] && i < 15; i++) - upper[i] = toupper(str[i]); - upper[strlen(str) < 15 ? strlen(str) : 15] = '\0'; - - if (strcmp(upper, "PROXY") == 0) - return RULE_ACTION_PROXY; - else if (strcmp(upper, "DIRECT") == 0) - return RULE_ACTION_DIRECT; - else if (strcmp(upper, "BLOCK") == 0) - return RULE_ACTION_BLOCK; - else + char buf[128]; + snprintf(buf, sizeof(buf), "%s", str); + proxy_ref[0] = '\0'; + char* eq = strchr(buf, '='); + if (eq) { - fprintf(stderr, "ERROR: Invalid action '%s'. Use PROXY, DIRECT, or BLOCK\n", str); - exit(1); + *eq = '\0'; + snprintf(proxy_ref, ref_size, "%s", eq + 1); } + char upper[16]; + upper_copy(upper, buf, sizeof(upper)); + if (strcmp(upper, "PROXY") == 0) return RULE_ACTION_PROXY; + if (eq && proxy_ref[0]) { fprintf(stderr, "ERROR: '=proxy' is only valid with PROXY\n"); exit(1); } + if (strcmp(upper, "DIRECT") == 0) return RULE_ACTION_DIRECT; + if (strcmp(upper, "BLOCK") == 0) return RULE_ACTION_BLOCK; + fprintf(stderr, "ERROR: Invalid action '%s'. Use PROXY, DIRECT, or BLOCK\n", buf); + exit(1); } static void default_if_empty(char* dest, const char* src, const char* default_val, size_t dest_size) { if (src == NULL || src[0] == '\0' || strcmp(src, " ") == 0) - strncpy(dest, default_val, dest_size - 1); + snprintf(dest, dest_size, "%s", default_val); else - strncpy(dest, src, dest_size - 1); - dest[dest_size - 1] = '\0'; + snprintf(dest, dest_size, "%.*s", (int)dest_size - 1, src); } +/* process:hosts:ports:protocol:action[=proxy] -- hosts may itself contain ':' (IPv6), so parse from both ends */ static bool parse_rule(const char* rule_str, ProxyRule* rule) { char buffer[MAX_RULE_STR]; - strncpy(buffer, rule_str, sizeof(buffer) - 1); - buffer[sizeof(buffer) - 1] = '\0'; - - char* parts[5] = {NULL, NULL, NULL, NULL, NULL}; - int part_idx = 0; - char* token = strtok(buffer, ":"); + snprintf(buffer, sizeof(buffer), "%s", rule_str); - while (token != NULL && part_idx < 5) + char* first = strchr(buffer, ':'); + char* last = strrchr(buffer, ':'); + char* second_last = NULL; + char* third_last = NULL; + if (last && last != first) { - parts[part_idx++] = token; - token = strtok(NULL, ":"); + *last = '\0'; + second_last = strrchr(buffer, ':'); + if (second_last && second_last != first) + { + *second_last = '\0'; + third_last = strrchr(buffer, ':'); + } } - - if (part_idx != 5) + if (!first || !last || !second_last || !third_last || third_last == first) { fprintf(stderr, "ERROR: Invalid rule format '%s'\n", rule_str); - fprintf(stderr, "Expected format: process:hosts:ports:protocol:action\n"); + fprintf(stderr, "Expected format: process:hosts:ports:protocol:action[=proxy]\n"); return false; } + *third_last = '\0'; + const char* ports = third_last + 1; + const char* protocol = second_last + 1; + const char* action = last + 1; - default_if_empty(rule->process_name, parts[0], "*", sizeof(rule->process_name)); - default_if_empty(rule->target_hosts, parts[1], "*", sizeof(rule->target_hosts)); - default_if_empty(rule->target_ports, parts[2], "*", sizeof(rule->target_ports)); - - rule->protocol = parse_protocol(parts[3]); - rule->action = parse_action(parts[4]); - + char* sep = strchr(buffer, ':'); + if (!sep) + { + fprintf(stderr, "ERROR: Invalid rule format '%s'\n", rule_str); + return false; + } + *sep = '\0'; + const char* hosts = sep + 1; + + default_if_empty(rule->process_name, buffer, "*", sizeof(rule->process_name)); + default_if_empty(rule->target_hosts, hosts, "*", sizeof(rule->target_hosts)); + default_if_empty(rule->target_ports, ports, "*", sizeof(rule->target_ports)); + rule->protocol = parse_protocol(protocol); + rule->action = parse_action(action, rule->proxy_ref, sizeof(rule->proxy_ref)); return true; } -static bool parse_proxy_url(const char* url, ProxyType* type, char* host, uint16_t* port, char* username, char* password) +/* [name=]type://[user:pass@]host:port or legacy type://host:port[:user:pass] ; host may be [v6] */ +static bool parse_proxy_spec(const char* spec, ProxyEntry* p) { - char buffer[512]; - strncpy(buffer, url, sizeof(buffer) - 1); - buffer[sizeof(buffer) - 1] = '\0'; + char buffer[768]; + snprintf(buffer, sizeof(buffer), "%s", spec); + memset(p, 0, sizeof(*p)); - username[0] = '\0'; - password[0] = '\0'; - - // parse type:// char* scheme_end = strstr(buffer, "://"); if (scheme_end == NULL) { - fprintf(stderr, "ERROR: Invalid proxy URL format. Expected type://host:port\n"); + fprintf(stderr, "ERROR: Invalid proxy '%s'. Expected [name=]type://host:port\n", spec); return false; } - *scheme_end = '\0'; char* scheme = buffer; char* rest = scheme_end + 3; + char* eq = strchr(scheme, '='); + if (eq) + { + *eq = '\0'; + snprintf(p->name, sizeof(p->name), "%.*s", (int)sizeof(p->name) - 1, scheme); + scheme = eq + 1; + } char upper_scheme[16]; - for (size_t i = 0; scheme[i] && i < 15; i++) - upper_scheme[i] = toupper(scheme[i]); - upper_scheme[strlen(scheme) < 15 ? strlen(scheme) : 15] = '\0'; - - if (strcmp(upper_scheme, "SOCKS5") == 0) - *type = PROXY_TYPE_SOCKS5; - else if (strcmp(upper_scheme, "HTTP") == 0) - *type = PROXY_TYPE_HTTP; + upper_copy(upper_scheme, scheme, sizeof(upper_scheme)); + if (strcmp(upper_scheme, "SOCKS5") == 0) p->type = PROXY_TYPE_SOCKS5; + else if (strcmp(upper_scheme, "HTTP") == 0) p->type = PROXY_TYPE_HTTP; else { fprintf(stderr, "ERROR: Invalid proxy type '%s'. Use 'socks5' or 'http'\n", scheme); return false; } - // parse host:port[:user:pass] - char* parts[4]; - int num_parts = 0; - char* token = strtok(rest, ":"); - while (token != NULL && num_parts < 4) + char* at = strrchr(rest, '@'); + if (at) { - parts[num_parts++] = token; - token = strtok(NULL, ":"); + *at = '\0'; + char* colon = strchr(rest, ':'); + if (colon) { *colon = '\0'; snprintf(p->password, sizeof(p->password), "%s", colon + 1); } + snprintf(p->username, sizeof(p->username), "%s", rest); + rest = at + 1; } - if (num_parts < 2) + char* port_str = NULL; + char* legacy = NULL; + if (rest[0] == '[') { - fprintf(stderr, "ERROR: Invalid proxy URL. Missing host or port\n"); - return false; + char* close = strchr(rest, ']'); + if (!close || close[1] != ':') { fprintf(stderr, "ERROR: Invalid IPv6 proxy host in '%s'\n", spec); return false; } + *close = '\0'; + snprintf(p->host, sizeof(p->host), "%s", rest + 1); + port_str = close + 2; } - - strncpy(host, parts[0], 255); - host[255] = '\0'; - - *port = atoi(parts[1]); - if (*port == 0) + else { - fprintf(stderr, "ERROR: Invalid proxy port '%s'\n", parts[1]); - return false; + char* colon = strchr(rest, ':'); + if (!colon) { fprintf(stderr, "ERROR: Invalid proxy '%s': missing port\n", spec); return false; } + *colon = '\0'; + snprintf(p->host, sizeof(p->host), "%s", rest); + port_str = colon + 1; } - - if (num_parts >= 4) + legacy = strchr(port_str, ':'); /* type://host:port:user:pass */ + if (legacy && !at) { - strncpy(username, parts[2], 255); - username[255] = '\0'; - strncpy(password, parts[3], 255); - password[255] = '\0'; + *legacy++ = '\0'; + char* colon = strchr(legacy, ':'); + if (colon) { *colon = '\0'; snprintf(p->password, sizeof(p->password), "%s", colon + 1); } + snprintf(p->username, sizeof(p->username), "%s", legacy); } - + char* endp = NULL; + long port = strtol(port_str, &endp, 10); + if (!*port_str || *endp || port < 1 || port > 65535) + { + fprintf(stderr, "ERROR: Invalid proxy port '%s'\n", port_str); + return false; + } + p->port = (uint16_t)port; + if (!p->host[0]) { fprintf(stderr, "ERROR: Proxy host is empty in '%s'\n", spec); return false; } return true; } +static uint32_t find_proxy(const ProxyEntry* proxies, int n, const char* ref) +{ + if (!ref[0]) + return 0; + char* endp = NULL; + long idx = strtol(ref, &endp, 10); + if (!*endp && idx >= 1 && idx <= n) + return proxies[idx - 1].id; + for (int i = 0; i < n; i++) + if (strcmp(proxies[i].name, ref) == 0) + return proxies[i].id; + return UINT32_MAX; +} + static bool is_root(void) { return getuid() == 0; @@ -298,24 +323,21 @@ static bool is_root(void) int main(int argc, char *argv[]) { - // check cleanup flag + ProxyEntry proxies[MAX_PROXIES]; + int num_proxies = 0; + ProxyRule rules[MAX_RULES]; + int num_rules = 0; + bool dns_via_proxy = true; + for (int i = 1; i < argc; i++) { if (strcmp(argv[i], "--cleanup") == 0) { - printf("Running cleanup...\n"); - ProxyBridge_Stop(); - printf("Cleanup complete.\n"); + printf("Nothing to clean up: the eBPF programs detach automatically when ProxyBridge exits.\n"); return 0; } } - char proxy_url[512] = "socks5://127.0.0.1:4444"; - ProxyRule rules[MAX_RULES]; - int num_rules = 0; - bool dns_via_proxy = true; - - // parse args for (int i = 1; i < argc; i++) { if (strcmp(argv[i], "--help") == 0 || strcmp(argv[i], "-h") == 0) @@ -325,8 +347,15 @@ int main(int argc, char *argv[]) } else if (strcmp(argv[i], "--proxy") == 0 && i + 1 < argc) { - strncpy(proxy_url, argv[++i], sizeof(proxy_url) - 1); - proxy_url[sizeof(proxy_url) - 1] = '\0'; + if (num_proxies >= MAX_PROXIES) + { + fprintf(stderr, "ERROR: Maximum %d proxies supported\n", MAX_PROXIES); + return 1; + } + if (!parse_proxy_spec(argv[++i], &proxies[num_proxies])) + return 1; + memset(argv[i], 0, strlen(argv[i])); /* keep credentials out of `ps` */ + num_proxies++; } else if (strcmp(argv[i], "--rule") == 0 && i + 1 < argc) { @@ -369,9 +398,15 @@ int main(int argc, char *argv[]) } } + if (num_proxies == 0) + { + if (!parse_proxy_spec("socks5://127.0.0.1:4444", &proxies[0])) + return 1; + num_proxies = 1; + } + show_banner(); - // need root if (!is_root()) { printf("\033[31m\nERROR: ProxyBridge requires root privileges!\033[0m\n"); @@ -379,54 +414,35 @@ int main(int argc, char *argv[]) return 1; } - // parse proxy config - ProxyType proxy_type; - char proxy_host[256]; - uint16_t proxy_port; - char proxy_username[256]; - char proxy_password[256]; - - if (!parse_proxy_url(proxy_url, &proxy_type, proxy_host, &proxy_port, proxy_username, proxy_password)) - return 1; - - // setup callbacks based on verbose - // 0=nothing 1=logs 2=connections 3=both - if (verbose_level == 1 || verbose_level == 3) ProxyBridge_SetLogCallback(log_callback); else - ProxyBridge_SetLogCallback(NULL); // Explicitly disable + ProxyBridge_SetLogCallback(NULL); if (verbose_level == 2 || verbose_level == 3) ProxyBridge_SetConnectionCallback(connection_callback); else - ProxyBridge_SetConnectionCallback(NULL); // Explicitly disable + ProxyBridge_SetConnectionCallback(NULL); - // turn on traffic logging when needed ProxyBridge_SetTrafficLoggingEnabled(verbose_level > 0); + ProxyBridge_SetDnsViaProxy(dns_via_proxy); - // show config - printf("Proxy: %s://%s:%u\n", - proxy_type == PROXY_TYPE_HTTP ? "http" : "socks5", - proxy_host, proxy_port); - - if (proxy_username[0] != '\0') - printf("Proxy Auth: %s:***\n", proxy_username); - - printf("DNS via Proxy: %s\n", dns_via_proxy ? "Enabled" : "Disabled"); - - // setup proxy - if (!ProxyBridge_SetProxyConfig(proxy_type, proxy_host, proxy_port, - proxy_username[0] ? proxy_username : "", - proxy_password[0] ? proxy_password : "")) + char err[256]; + printf("Proxies: %d\n", num_proxies); + for (int i = 0; i < num_proxies; i++) { - fprintf(stderr, "ERROR: Failed to set proxy configuration\n"); - return 1; + ProxyEntry* p = &proxies[i]; + p->id = ProxyBridge_AddProxy(p->name, p->type, p->host, p->port, p->username, p->password, err, sizeof(err)); + if (!p->id) + { + fprintf(stderr, "ERROR: Proxy %d (%s:%u): %s\n", i + 1, p->host, p->port, err); + return 1; + } + printf(" [%d]%s%s%s %s://%s:%u%s\n", i + 1, p->name[0] ? " " : "", p->name, i == 0 ? " (default)" : "", + p->type == PROXY_TYPE_HTTP ? "http" : "socks5", p->host, p->port, p->username[0] ? " (auth)" : ""); } + printf("DNS via Proxy: %s\n", dns_via_proxy ? "Enabled" : "Disabled"); - ProxyBridge_SetDnsViaProxy(dns_via_proxy); - - // add rules if (num_rules > 0) { printf("Rules: %d\n", num_rules); @@ -436,28 +452,28 @@ int main(int argc, char *argv[]) rules[i].protocol == RULE_PROTOCOL_UDP ? "UDP" : "BOTH"; const char* action_str = rules[i].action == RULE_ACTION_PROXY ? "PROXY" : rules[i].action == RULE_ACTION_DIRECT ? "DIRECT" : "BLOCK"; - - uint32_t rule_id = ProxyBridge_AddRule( - rules[i].process_name, - rules[i].target_hosts, - rules[i].target_ports, - rules[i].protocol, - rules[i].action); - - if (rule_id > 0) + uint32_t proxy_id = 0; + if (rules[i].action == RULE_ACTION_PROXY) { - printf(" [%u] %s:%s:%s:%s -> %s\n", - rule_id, - rules[i].process_name, - rules[i].target_hosts, - rules[i].target_ports, - protocol_str, - action_str); + proxy_id = find_proxy(proxies, num_proxies, rules[i].proxy_ref); + if (proxy_id == UINT32_MAX) + { + fprintf(stderr, "ERROR: Rule %d refers to unknown proxy '%s'\n", i + 1, rules[i].proxy_ref); + return 1; + } } - else + + uint32_t rule_id = ProxyBridge_AddRule(rules[i].process_name, rules[i].target_hosts, rules[i].target_ports, + rules[i].protocol, rules[i].action, proxy_id, err, sizeof(err)); + if (rule_id == 0) { - fprintf(stderr, " ERROR: Failed to add rule for %s\n", rules[i].process_name); + fprintf(stderr, " ERROR: Rule %d (%s:%s:%s): %s\n", i + 1, rules[i].process_name, + rules[i].target_hosts, rules[i].target_ports, err); + return 1; } + printf(" [%u] %s:%s:%s:%s -> %s%s%s\n", rule_id, rules[i].process_name, rules[i].target_hosts, + rules[i].target_ports, protocol_str, action_str, + rules[i].proxy_ref[0] ? " via " : "", rules[i].proxy_ref); } } else @@ -466,10 +482,9 @@ int main(int argc, char *argv[]) printf("Use --rule to add proxy rules. See --help for examples.\n"); } - // start proxybridge if (!ProxyBridge_Start()) { - fprintf(stderr, "ERROR: Failed to start ProxyBridge\n"); + fprintf(stderr, "ERROR: Failed to start ProxyBridge (run with --verbose 1 for details)\n"); return 1; } @@ -478,19 +493,11 @@ int main(int argc, char *argv[]) signal(SIGINT, signal_handler); signal(SIGTERM, signal_handler); - signal(SIGSEGV, signal_handler); // Catch segfault - signal(SIGABRT, signal_handler); // Catch abort - signal(SIGBUS, signal_handler); // Catch bus error - // main loop while (keep_running) - { sleep(1); - } - // cleanup ProxyBridge_Stop(); printf("ProxyBridge stopped.\n"); - return 0; } diff --git a/Linux/common.mk b/Linux/common.mk new file mode 100644 index 00000000..546df91e --- /dev/null +++ b/Linux/common.mk @@ -0,0 +1,23 @@ +# Compiler and linker flags shared by the library, the CLI and the GUI. +# OPT=-O2 optimisation level LTO=0 disable link-time optimisation +# WERROR=1 warnings are errors (CI) EXTRA_CFLAGS / EXTRA_LDFLAGS e.g. sanitizers +CC ?= gcc +OPT ?= -O2 +LTO ?= 1 + +# Keeps a flag only if the compiler accepts it (other GCC versions and architectures differ). +cc-option = $(shell $(CC) $(1) -Werror -x c -c /dev/null -o /dev/null 2>/dev/null && echo $(1)) + +HARDEN_CFLAGS = -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3 -fstack-protector-strong \ + $(call cc-option,-fstack-clash-protection) $(call cc-option,-fcf-protection=full) \ + $(call cc-option,-mbranch-protection=standard) $(call cc-option,-ftrivial-auto-var-init=zero) \ + $(call cc-option,-fno-plt) -fno-strict-overflow -fno-delete-null-pointer-checks -fwrapv \ + -Wformat -Wformat-security -Werror=format-security + +CFLAGS_COMMON = $(OPT) -Wall -Wextra $(if $(WERROR),-Werror) $(HARDEN_CFLAGS) \ + -ffunction-sections -fdata-sections $(if $(filter 1,$(LTO)),-flto=auto) $(EXTRA_CFLAGS) + +HARDEN_LDFLAGS = -Wl,-z,relro,-z,now -Wl,-z,noexecstack -Wl,--as-needed -Wl,--gc-sections \ + $(if $(filter 1,$(LTO)),-flto=auto) $(EXTRA_LDFLAGS) + +STRIP_FLAG ?= -s diff --git a/Linux/deploy.sh b/Linux/deploy.sh index d9dd9299..fad6731b 100644 --- a/Linux/deploy.sh +++ b/Linux/deploy.sh @@ -150,35 +150,35 @@ install_dependencies() { ubuntu|debian|linuxmint|pop|elementary|zorin|kali|raspbian|mx|antix|deepin|lmde) echo "Using apt package manager..." apt-get update -qq - apt-get install -y libnetfilter-queue1 libnfnetlink0 iptables libgtk-3-0 + apt-get install -y libelf1 zlib1g libgtk-3-0 ;; fedora) echo "Using dnf package manager..." - dnf install -y libnetfilter_queue libnfnetlink iptables gtk3 + dnf install -y elfutils-libelf zlib gtk3 ;; rhel|centos|rocky|almalinux) echo "Using yum package manager..." - yum install -y libnetfilter_queue libnfnetlink iptables gtk3 + yum install -y elfutils-libelf zlib gtk3 ;; arch|manjaro|endeavouros|garuda) echo "Using pacman package manager..." - pacman -Sy --noconfirm libnetfilter_queue libnfnetlink iptables gtk3 + pacman -Sy --noconfirm libelf zlib gtk3 ;; opensuse*|sles) echo "Using zypper package manager..." - zypper install -y libnetfilter_queue1 libnfnetlink0 iptables gtk3 + zypper install -y libelf1 libz1 gtk3 ;; void) echo "Using xbps package manager..." - xbps-install -Sy libnetfilter_queue libnfnetlink iptables gtk3 + xbps-install -Sy libelf zlib gtk3 ;; *) echo "WARNING: Unknown distribution '$DISTRO' (family: '$DISTRO_LIKE')" echo "" echo "Please manually install the following packages:" - echo " Debian/Ubuntu: sudo apt install libnetfilter-queue1 libnfnetlink0 iptables libgtk-3-0" - echo " Fedora: sudo dnf install libnetfilter_queue libnfnetlink iptables gtk3" - echo " Arch: sudo pacman -S libnetfilter_queue libnfnetlink iptables gtk3" + echo " Debian/Ubuntu: sudo apt install libelf1 zlib1g libgtk-3-0" + echo " Fedora: sudo dnf install elfutils-libelf zlib gtk3" + echo " Arch: sudo pacman -S libelf zlib gtk3" echo "" read -p "Continue anyway? (y/n) " -n 1 -r echo @@ -335,8 +335,7 @@ main() { fi echo " sudo ProxyBridge --proxy socks5://IP:PORT --rule \"app:*:*:TCP:PROXY\"" echo "" - echo "For cleanup after crash:" - echo " sudo ProxyBridge --cleanup" + echo "No cleanup is needed after a crash: the kernel detaches the eBPF programs automatically." echo "" } diff --git a/Linux/gui/Makefile b/Linux/gui/Makefile new file mode 100644 index 00000000..b7ca1a3a --- /dev/null +++ b/Linux/gui/Makefile @@ -0,0 +1,33 @@ +include ../common.mk + +BUILD_DIR ?= . +LIBDIR ?= ../src + +# Compile-time API limits keep the GUI runnable on Ubuntu 22.04 (GTK 3.24.33, GLib 2.72). +GTK_CFLAGS = $(shell pkg-config --cflags gtk+-3.0) -DGDK_VERSION_MIN_REQUIRED=GDK_VERSION_3_22 -DGDK_VERSION_MAX_ALLOWED=GDK_VERSION_3_22 \ + -DGLIB_VERSION_MIN_REQUIRED=GLIB_VERSION_2_72 -DGLIB_VERSION_MAX_ALLOWED=GLIB_VERSION_2_72 +GTK_LIBS = $(shell pkg-config --libs gtk+-3.0) + +CFLAGS = $(CFLAGS_COMMON) -Wno-unused-parameter -fPIE -D_GNU_SOURCE -I../src $(GTK_CFLAGS) +LDFLAGS = -pie $(HARDEN_LDFLAGS) -L$(LIBDIR) -Wl,-rpath,'$$ORIGIN' -export-dynamic $(STRIP_FLAG) +LIBS = -lproxybridge -lpthread $(GTK_LIBS) + +SOURCES = $(filter-out tests/%,$(wildcard *.c)) +OBJECTS = $(addprefix $(BUILD_DIR)/,$(SOURCES:.c=.o)) +TARGET = $(BUILD_DIR)/ProxyBridgeGUI + +all: $(TARGET) + +$(BUILD_DIR): + mkdir -p $@ + +$(TARGET): $(OBJECTS) + $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) $(LIBS) + +$(BUILD_DIR)/%.o: %.c gui.h | $(BUILD_DIR) + $(CC) $(CFLAGS) -c $< -o $@ + +clean: + rm -f $(OBJECTS) $(TARGET) + +.PHONY: all clean diff --git a/Linux/gui/gui.h b/Linux/gui/gui.h index 035c1ee2..edb7cd9b 100644 --- a/Linux/gui/gui.h +++ b/Linux/gui/gui.h @@ -6,38 +6,20 @@ #include #include #include -#include -#include -#include +#include #include #include -#include #include "ProxyBridge.h" -typedef struct { - char *process_name; - uint32_t pid; - char *dest_ip; - uint16_t dest_port; - char *proxy_info; - char *timestamp; -} ConnectionData; - -typedef struct { - char *message; -} LogData; typedef struct { - GtkWidget *dialog; - GtkWidget *ip_entry; - GtkWidget *port_entry; - GtkWidget *type_combo; - GtkWidget *user_entry; - GtkWidget *pass_entry; - GtkWidget *test_host; - GtkWidget *test_port; - GtkTextBuffer *output_buffer; - GtkWidget *test_btn; -} ConfigInfo; + uint32_t id; // library id, 0 = not registered + char *name; // unique key, rules and config refer to it + ProxyType type; + char *host; + uint16_t port; + char *username; + char *password; +} ProxyData; typedef struct { uint32_t id; @@ -46,21 +28,20 @@ typedef struct { char *target_ports; RuleProtocol protocol; RuleAction action; + char *proxy_name; // NULL or "" = default proxy bool enabled; bool selected; } RuleData; -struct TestRunnerData { - char *host; - uint16_t port; - ConfigInfo *ui_info; -}; - +// lists are only current while the profile is inactive, see profile_sync_active typedef struct { - char *result_text; - GtkTextBuffer *buffer; - GtkWidget *btn; -} TestResultData; + char *name; + GList *proxies; + char *default_proxy; + GList *rules; +} Profile; + +typedef void (*RuleImportCb)(const RuleData *rule, int index, void *ud); extern GtkWidget *window; extern GtkTextBuffer *conn_buffer; @@ -68,37 +49,58 @@ extern GtkTextBuffer *log_buffer; extern GtkWidget *status_bar; extern guint status_context_id; -extern char g_proxy_ip[256]; -extern uint16_t g_proxy_port; -extern ProxyType g_proxy_type; -extern char g_proxy_user[256]; -extern char g_proxy_pass[256]; +// avoids libc strto*/atoi, glibc 2.38 redirects them to symbols old systems lack +static inline long parse_long(const char *s) { + return s ? (long)g_ascii_strtoll(s, NULL, 10) : 0; +} +extern GList *g_profiles; +extern char *g_active_profile; +extern GList *g_proxies_list; // working copy of the active profile +extern char *g_default_proxy; extern GList *g_rules_list; extern bool g_chk_logging; extern bool g_chk_dns; -long safe_strtol(const char *nptr); void show_message(GtkWindow *parent, GtkMessageType type, const char *format, ...); -void trim_buffer_lines(GtkTextBuffer *buffer, int max_lines); -char* get_current_time_str(); -char *escape_json_string(const char *src); +GtkWidget *combo_new(const char *first, ...) G_GNUC_NULL_TERMINATED; +GtkWidget *hint_label(const char *text); +void set_error_label(GtkWidget *label, const char *msg); +void grid_row(GtkWidget *grid, int row, const char *label, GtkWidget *widget); + char *extract_sub_json_str(const char *json, const char *key); -bool extract_sub_json_bool(const char *json, const char *key); +int rules_from_json(const char *json, RuleImportCb cb, void *ud); +char *rules_to_json(GList *rules, bool only_selected); -// config storage void save_config(); void load_config(); +void config_free_all(); +Profile *profile_find(const char *name); +Profile *profile_create(const char *name, bool copy_active); +bool profile_rename(Profile *p, const char *new_name); +bool profile_activate(const char *name); +bool profile_delete(Profile *p); -// settings void on_proxy_configure(GtkWidget *widget, gpointer data); +void on_profiles_clicked(GtkWidget *widget, gpointer data); +ProxyData *find_proxy_by_name(const char *name); +uint32_t resolve_proxy_id(const char *name); +void free_proxy_data(ProxyData *p); +void apply_proxies_to_lib(); -// rules section void on_proxy_rules_clicked(GtkWidget *widget, gpointer data); +void free_rule_data(RuleData *rule); +void apply_rules_to_lib(); +void refresh_rules_window(); -// Logs +void gui_log(const char *format, ...); void lib_log_callback(const char *message); void lib_connection_callback(const char *process_name, uint32_t pid, const char *dest_ip, uint16_t dest_port, const char *proxy_info); +void gui_logs_init(); +void gui_logs_stop_timers(); +void gui_logs_free(); +uint64_t gui_conn_event_count(); +void on_pause_conn_toggled(GtkToggleButton *button, gpointer user_data); void on_search_conn_changed(GtkSearchEntry *entry, gpointer user_data); void on_search_log_changed(GtkSearchEntry *entry, gpointer user_data); void on_clear_conn_clicked(GtkButton *button, gpointer user_data); diff --git a/Linux/gui/gui_config.c b/Linux/gui/gui_config.c index 46c95717..e70d63e7 100644 --- a/Linux/gui/gui_config.c +++ b/Linux/gui/gui_config.c @@ -1,123 +1,384 @@ #include "gui.h" #include -#define CONFIG_DIR "/etc/proxybridge" -#define CONFIG_PATH "/etc/proxybridge/config.ini" +#define CONFIG_DIR_DEFAULT "/etc/proxybridge" + +GList *g_profiles = NULL; +char *g_active_profile = NULL; + +// PROXYBRIDGE_CONFIG_DIR overrides the default dir (used for testing) +static char *config_dir() { + const char *env = getenv("PROXYBRIDGE_CONFIG_DIR"); + return g_strdup((env && *env) ? env : CONFIG_DIR_DEFAULT); +} + +static char *esc(const char *s) { + return g_uri_escape_string(s ? s : "", NULL, FALSE); +} + +static char *unesc(const char *s) { + char *out = g_uri_unescape_string(s ? s : "", NULL); + return out ? out : g_strdup(""); +} + +static void put_esc(FILE *f, const char *s, char sep) { + char *e = esc(s); + fputs(e, f); + fputc(sep, f); + g_free(e); +} + +static void free_profile(Profile *p) { + for (GList *l = p->proxies; l != NULL; l = l->next) free_proxy_data((ProxyData *)l->data); + for (GList *l = p->rules; l != NULL; l = l->next) free_rule_data((RuleData *)l->data); + g_list_free(p->proxies); + g_list_free(p->rules); + g_free(p->default_proxy); + g_free(p->name); + g_free(p); +} + +Profile *profile_find(const char *name) { + if (!name) return NULL; + for (GList *l = g_profiles; l != NULL; l = l->next) + if (strcmp(((Profile *)l->data)->name, name) == 0) return (Profile *)l->data; + return NULL; +} + +// the working lists belong to the active profile, copy the (possibly changed) heads back +static void profile_sync_active() { + Profile *p = profile_find(g_active_profile); + if (!p) return; + p->proxies = g_proxies_list; + p->rules = g_rules_list; + p->default_proxy = g_default_proxy; +} + +static void load_globals_from(Profile *p) { + g_proxies_list = p->proxies; + g_rules_list = p->rules; + g_default_proxy = p->default_proxy; +} + +void config_free_all() { + profile_sync_active(); + g_list_free_full(g_profiles, (GDestroyNotify)free_profile); + g_profiles = NULL; + g_free(g_active_profile); + g_active_profile = NULL; + g_proxies_list = g_rules_list = NULL; + g_default_proxy = NULL; +} + +static Profile *profile_new_empty(const char *name) { + Profile *p = g_malloc0(sizeof(Profile)); + p->name = g_strdup(name); + g_profiles = g_list_append(g_profiles, p); + return p; +} + +static ProxyData *copy_proxy(const ProxyData *src) { + ProxyData *d = g_malloc0(sizeof(ProxyData)); + d->name = g_strdup(src->name); + d->type = src->type; + d->host = g_strdup(src->host); + d->port = src->port; + d->username = g_strdup(src->username ? src->username : ""); + d->password = g_strdup(src->password ? src->password : ""); + return d; +} + +static RuleData *copy_rule(const RuleData *src) { + RuleData *d = g_malloc0(sizeof(RuleData)); + d->process_name = g_strdup(src->process_name ? src->process_name : ""); + d->target_hosts = g_strdup(src->target_hosts ? src->target_hosts : ""); + d->target_ports = g_strdup(src->target_ports ? src->target_ports : ""); + d->protocol = src->protocol; + d->action = src->action; + d->proxy_name = g_strdup(src->proxy_name); + d->enabled = src->enabled; + return d; +} + +Profile *profile_create(const char *name, bool copy_active) { + if (!name || !*name || profile_find(name)) return NULL; + profile_sync_active(); + Profile *src = copy_active ? profile_find(g_active_profile) : NULL; + Profile *p = profile_new_empty(name); + if (src) { + for (GList *l = src->proxies; l != NULL; l = l->next) p->proxies = g_list_append(p->proxies, copy_proxy((ProxyData *)l->data)); + for (GList *l = src->rules; l != NULL; l = l->next) p->rules = g_list_append(p->rules, copy_rule((RuleData *)l->data)); + p->default_proxy = g_strdup(src->default_proxy); + } + return p; +} + +bool profile_rename(Profile *p, const char *new_name) { + if (!p || !new_name || !*new_name) return false; + if (strcmp(p->name, new_name) == 0) return true; + if (profile_find(new_name)) return false; + bool active = g_active_profile && strcmp(g_active_profile, p->name) == 0; + g_free(p->name); + p->name = g_strdup(new_name); + if (active) { + g_free(g_active_profile); + g_active_profile = g_strdup(new_name); + } + return true; +} + +bool profile_activate(const char *name) { + Profile *p = profile_find(name); + if (!p) return false; + profile_sync_active(); + if (name != g_active_profile) { + char *copy = g_strdup(name); + g_free(g_active_profile); + g_active_profile = copy; + } + load_globals_from(p); + return true; +} + +// the last profile stays; deleting the active one activates another first +bool profile_delete(Profile *p) { + if (!p || !g_profiles || !g_profiles->next) return false; + if (g_active_profile && strcmp(g_active_profile, p->name) == 0) { + Profile *other = (Profile *)g_profiles->data; + if (other == p) other = (Profile *)g_profiles->next->data; + if (!profile_activate(other->name)) return false; + } + g_profiles = g_list_remove(g_profiles, p); + free_profile(p); + return true; +} + +static void write_profile(FILE *f, Profile *prof) { + fputs("[PROFILE:", f); + char *pn = esc(prof->name); + fprintf(f, "%s]\ndefaultProxy=", pn); + g_free(pn); + put_esc(f, prof->default_proxy, '\n'); + + // fields are percent-encoded so '|' and newlines are safe: name|type|host|port|user|pass + fputs("[PROXIES]\n", f); + for (GList *l = prof->proxies; l != NULL; l = l->next) { + ProxyData *p = (ProxyData *)l->data; + put_esc(f, p->name, '|'); + fprintf(f, "%d|", p->type); + put_esc(f, p->host, '|'); + fprintf(f, "%u|", p->port); + put_esc(f, p->username, '|'); + put_esc(f, p->password, '\n'); + } + + // id|protocol|action|enabled|procname|hosts|ports|proxyname + fputs("[RULES]\n", f); + for (GList *l = prof->rules; l != NULL; l = l->next) { + RuleData *r = (RuleData *)l->data; + fprintf(f, "%u|%d|%d|%d|%s|%s|%s|", r->id, r->protocol, r->action, r->enabled, + r->process_name ? r->process_name : "", r->target_hosts ? r->target_hosts : "", + r->target_ports ? r->target_ports : ""); + put_esc(f, r->proxy_name, '\n'); + } +} -// save config void save_config() { - struct stat st = {0}; - if (stat(CONFIG_DIR, &st) == -1) { - if (mkdir(CONFIG_DIR, 0700) != 0) { + profile_sync_active(); + + char *dir = config_dir(); + char *path = g_strdup_printf("%s/config.ini", dir); + struct stat st; + if (stat(dir, &st) == -1) { + if (g_mkdir_with_parents(dir, 0700) != 0) { perror("failed to create config dir"); - return; + goto out; } - } else if (chmod(CONFIG_DIR, 0700) != 0) { + } else if (chmod(dir, 0700) != 0) { perror("chmod config dir failed"); } - int cfg_fd = open(CONFIG_PATH, O_WRONLY | O_CREAT | O_TRUNC, 0600); + int cfg_fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600); if (cfg_fd < 0) { - printf("failed to save config to %s\n", CONFIG_PATH); - return; + fprintf(stderr, "failed to save config to %s\n", path); + goto out; } // mode on open only hits create; tighten existing 0644 installs too - if (fchmod(cfg_fd, 0600) != 0) { - perror("chmod config file failed"); - } + if (fchmod(cfg_fd, 0600) != 0) perror("chmod config file failed"); FILE *f = fdopen(cfg_fd, "w"); if (!f) { close(cfg_fd); - printf("failed to save config to %s\n", CONFIG_PATH); - return; - } - - // settings section - fprintf(f, "[SETTINGS]\n"); - fprintf(f, "ip=%s\n", g_proxy_ip); - fprintf(f, "port=%d\n", g_proxy_port); - fprintf(f, "type=%d\n", g_proxy_type); - fprintf(f, "user=%s\n", g_proxy_user); - fprintf(f, "pass=%s\n", g_proxy_pass); - fprintf(f, "logging=%d\n", g_chk_logging); - fprintf(f, "dns=%d\n", g_chk_dns); - - // rules section - fprintf(f, "[RULES]\n"); - for (GList *l = g_rules_list; l != NULL; l = l->next) { - RuleData *rule = (RuleData *)l->data; - // format: id|protocol|action|enabled|procname|hosts|ports - fprintf(f, "%u|%d|%d|%d|%s|%s|%s\n", - rule->id, - rule->protocol, - rule->action, - rule->enabled, - rule->process_name ? rule->process_name : "", - rule->target_hosts ? rule->target_hosts : "", - rule->target_ports ? rule->target_ports : "" - ); + fprintf(stderr, "failed to save config to %s\n", path); + goto out; } + fputs("[SETTINGS]\nactiveProfile=", f); + put_esc(f, g_active_profile, '\n'); + fprintf(f, "logging=%d\ndns=%d\n", g_chk_logging, g_chk_dns); + for (GList *l = g_profiles; l != NULL; l = l->next) write_profile(f, (Profile *)l->data); fclose(f); +out: + g_free(dir); + g_free(path); +} + +static ProxyType proxy_type_from_int(int v) { + return v == PROXY_TYPE_HTTP ? PROXY_TYPE_HTTP : PROXY_TYPE_SOCKS5; +} + +static ProxyData *find_in_list(GList *list, const char *name) { + for (GList *l = list; l != NULL; l = l->next) + if (strcmp(((ProxyData *)l->data)->name, name) == 0) return (ProxyData *)l->data; + return NULL; +} + +static ProxyData *parse_proxy_line(char *line) { + char *fields[6] = {NULL}; + int n = 0; + char *tok; + while (n < 6 && (tok = strsep(&line, "|")) != NULL) fields[n++] = tok; + if (n < 4) return NULL; + + int port = parse_long(fields[3]); + ProxyData *px = g_malloc0(sizeof(ProxyData)); + px->name = unesc(fields[0]); + px->type = proxy_type_from_int(parse_long(fields[1])); + px->host = unesc(fields[2]); + px->port = (port >= 1 && port <= 65535) ? (uint16_t)port : 0; + px->username = unesc(n > 4 ? fields[4] : ""); + px->password = unesc(n > 5 ? fields[5] : ""); + return px; +} + +static RuleData *parse_rule_line(char *line) { + RuleData *rule = g_malloc0(sizeof(RuleData)); + char *tok; + int idx = 0; + while ((tok = strsep(&line, "|")) != NULL) { + switch (idx++) { + case 1: rule->protocol = parse_long(tok); break; + case 2: rule->action = parse_long(tok); break; + case 3: rule->enabled = parse_long(tok); break; + case 4: rule->process_name = g_strdup(tok); break; + case 5: rule->target_hosts = g_strdup(tok); break; + case 6: rule->target_ports = g_strdup(tok); break; + case 7: rule->proxy_name = unesc(tok); break; + } + } + // old files can hold garbage, ids belong to the library and are assigned when the rule is added + if (idx < 4 || rule->protocol > RULE_PROTOCOL_BOTH || rule->action > RULE_ACTION_BLOCK) { + free_rule_data(rule); + return NULL; + } + if (!rule->process_name) rule->process_name = g_strdup(""); + if (!rule->target_hosts) rule->target_hosts = g_strdup(""); + if (!rule->target_ports) rule->target_ports = g_strdup(""); + return rule; } -// load settings from file +// files without profile blocks (old format) end up in a profile called "Default" void load_config() { - chmod(CONFIG_PATH, 0600); // old installs might still be world readable - FILE *f = fopen(CONFIG_PATH, "r"); - if (!f) return; + char *dir = config_dir(); + char *path = g_strdup_printf("%s/config.ini", dir); + chmod(path, 0600); + FILE *f = fopen(path, "r"); + g_free(dir); + g_free(path); - char line[2048]; - int section = 0; // 0=none, 1=settings, 2=rules + char line[8192]; + int section = 0; // 1=settings, 2=rules, 3=proxies, 4=profile header + Profile *cur = NULL; + char *saved_active = NULL; - while (fgets(line, sizeof(line), f)) { - // trim newline - line[strcspn(line, "\r\n")] = 0; + // pre multi-proxy config, migrated to a proxy called "default" + char old_ip[256] = "", old_user[256] = "", old_pass[256] = ""; + int old_port = 0, old_type = PROXY_TYPE_SOCKS5; - if (strlen(line) == 0 || line[0] == '#') continue; + while (f && fgets(line, sizeof(line), f)) { + line[strcspn(line, "\r\n")] = 0; + if (!line[0] || line[0] == '#') continue; if (strcmp(line, "[SETTINGS]") == 0) { section = 1; continue; } - if (strcmp(line, "[RULES]") == 0) { section = 2; continue; } + if (strcmp(line, "[RULES]") == 0 || strcmp(line, "[PROXIES]") == 0) { + section = line[1] == 'R' ? 2 : 3; + if (!cur) cur = profile_new_empty("Default"); + continue; + } + size_t len = strlen(line); + if (strncmp(line, "[PROFILE:", 9) == 0 && line[len - 1] == ']') { + line[len - 1] = 0; + char *pn = unesc(line + 9); + cur = pn[0] ? profile_find(pn) : NULL; + if (!cur && pn[0]) cur = profile_new_empty(pn); + g_free(pn); + section = cur ? 4 : 0; + continue; + } - if (section == 1) { + if (section == 1 || section == 4) { char *val = strchr(line, '='); if (!val) continue; - *val = 0; val++; - - if (strcmp(line, "ip") == 0) strncpy(g_proxy_ip, val, sizeof(g_proxy_ip) - 1); - else if (strcmp(line, "port") == 0) g_proxy_port = atoi(val); - else if (strcmp(line, "type") == 0) g_proxy_type = atoi(val); - else if (strcmp(line, "user") == 0) strncpy(g_proxy_user, val, sizeof(g_proxy_user) - 1); - else if (strcmp(line, "pass") == 0) strncpy(g_proxy_pass, val, sizeof(g_proxy_pass) - 1); - else if (strcmp(line, "logging") == 0) g_chk_logging = atoi(val); - else if (strcmp(line, "dns") == 0) g_chk_dns = atoi(val); - } - else if (section == 2) { - // parse rule line - RuleData *rule = g_malloc0(sizeof(RuleData)); - char *p = line; - char *token; - int idx = 0; - - while ((token = strsep(&p, "|")) != NULL) { - switch(idx) { - case 0: rule->id = atoi(token); break; - case 1: rule->protocol = atoi(token); break; - case 2: rule->action = atoi(token); break; - case 3: rule->enabled = atoi(token); break; - case 4: rule->process_name = g_strdup(token); break; - case 5: rule->target_hosts = g_strdup(token); break; - case 6: rule->target_ports = g_strdup(token); break; - } - idx++; - } + *val++ = 0; - if (idx >= 4) { - g_rules_list = g_list_append(g_rules_list, rule); - } else { - g_free(rule); + if (strcmp(line, "defaultProxy") == 0) { + if (!cur) cur = profile_new_empty("Default"); + g_free(cur->default_proxy); + cur->default_proxy = unesc(val); + if (!cur->default_proxy[0]) { g_free(cur->default_proxy); cur->default_proxy = NULL; } } + else if (section != 1) continue; + else if (strcmp(line, "ip") == 0) g_strlcpy(old_ip, val, sizeof(old_ip)); + else if (strcmp(line, "port") == 0) old_port = parse_long(val); + else if (strcmp(line, "type") == 0) old_type = parse_long(val); + else if (strcmp(line, "user") == 0) g_strlcpy(old_user, val, sizeof(old_user)); + else if (strcmp(line, "pass") == 0) g_strlcpy(old_pass, val, sizeof(old_pass)); + else if (strcmp(line, "activeProfile") == 0) { g_free(saved_active); saved_active = unesc(val); } + else if (strcmp(line, "logging") == 0) g_chk_logging = parse_long(val); + else if (strcmp(line, "dns") == 0) g_chk_dns = parse_long(val); + } + else if (section == 3 && cur) { + ProxyData *px = parse_proxy_line(line); + if (px && (!px->name[0] || find_in_list(cur->proxies, px->name))) { free_proxy_data(px); px = NULL; } + if (px) cur->proxies = g_list_append(cur->proxies, px); + } + else if (section == 2 && cur) { + RuleData *rule = parse_rule_line(line); + if (rule) cur->rules = g_list_append(cur->rules, rule); } } - fclose(f); -} \ No newline at end of file + if (f) fclose(f); + + if (old_ip[0] && old_port >= 1 && old_port <= 65535) { + Profile *d = profile_find("Default"); + if (!d) d = profile_new_empty("Default"); + if (!d->proxies) { + ProxyData *px = g_malloc0(sizeof(ProxyData)); + px->name = g_strdup("default"); + px->type = proxy_type_from_int(old_type); + px->host = g_strdup(old_ip); + px->port = (uint16_t)old_port; + px->username = g_strdup(old_user); + px->password = g_strdup(old_pass); + d->proxies = g_list_append(d->proxies, px); + g_free(d->default_proxy); + d->default_proxy = g_strdup("default"); + } + } + + if (!g_profiles) profile_new_empty("Default"); + + for (GList *l = g_profiles; l != NULL; l = l->next) { + Profile *p = (Profile *)l->data; + if (p->proxies && (!p->default_proxy || !find_in_list(p->proxies, p->default_proxy))) { + g_free(p->default_proxy); + p->default_proxy = g_strdup(((ProxyData *)p->proxies->data)->name); + } + } + + Profile *act = profile_find(saved_active); + if (!act) act = (Profile *)g_profiles->data; + g_free(saved_active); + g_free(g_active_profile); + g_active_profile = g_strdup(act->name); + load_globals_from(act); +} diff --git a/Linux/gui/gui_events.c b/Linux/gui/gui_events.c new file mode 100644 index 00000000..80500e26 --- /dev/null +++ b/Linux/gui/gui_events.c @@ -0,0 +1,84 @@ +#include "gui_events.h" +#include +#include +#include + +struct EventRing { + pthread_mutex_t lock; + char *slots; + uint16_t *lens; + size_t cap, slot_size, head, count; + uint64_t pushed, delivered, dropped, dropped_pending; +}; + +EventRing *event_ring_new(size_t capacity, size_t slot_size) { + if (capacity == 0 || slot_size < 2 || slot_size > UINT16_MAX) return NULL; + EventRing *r = calloc(1, sizeof(*r)); + if (!r) return NULL; + r->slots = malloc(capacity * slot_size); + r->lens = calloc(capacity, sizeof(uint16_t)); + if (!r->slots || !r->lens) { + free(r->slots); + free(r->lens); + free(r); + return NULL; + } + r->cap = capacity; + r->slot_size = slot_size; + pthread_mutex_init(&r->lock, NULL); + return r; +} + +void event_ring_free(EventRing *r) { + if (!r) return; + pthread_mutex_destroy(&r->lock); + free(r->slots); + free(r->lens); + free(r); +} + +void event_ring_push(EventRing *r, const char *text) { + size_t len = text ? strnlen(text, r->slot_size - 1) : 0; + pthread_mutex_lock(&r->lock); + if (r->count == r->cap) { + r->head = (r->head + 1) % r->cap; + r->count--; + r->dropped++; + r->dropped_pending++; + } + size_t idx = (r->head + r->count) % r->cap; + memcpy(r->slots + idx * r->slot_size, text, len); + r->lens[idx] = (uint16_t)len; + r->count++; + r->pushed++; + pthread_mutex_unlock(&r->lock); +} + +size_t event_ring_drain(EventRing *r, char *out, size_t out_size, size_t max_events, uint64_t *dropped) { + size_t n = 0, used = 0; + pthread_mutex_lock(&r->lock); + while (n < max_events && r->count > 0) { + size_t len = r->lens[r->head]; + if (used + len + 2 > out_size) break; + memcpy(out + used, r->slots + r->head * r->slot_size, len); + used += len; + out[used++] = '\n'; + r->head = (r->head + 1) % r->cap; + r->count--; + r->delivered++; + n++; + } + if (dropped) *dropped = r->dropped_pending; + r->dropped_pending = 0; + pthread_mutex_unlock(&r->lock); + if (out_size) out[used] = '\0'; + return n; +} + +void event_ring_stats(EventRing *r, uint64_t *pushed, uint64_t *delivered, uint64_t *dropped) { + pthread_mutex_lock(&r->lock); + if (pushed) *pushed = r->pushed; + if (delivered) *delivered = r->delivered; + if (dropped) *dropped = r->dropped; + pthread_mutex_unlock(&r->lock); +} diff --git a/Linux/gui/gui_events.h b/Linux/gui/gui_events.h new file mode 100644 index 00000000..65a1e74d --- /dev/null +++ b/Linux/gui/gui_events.h @@ -0,0 +1,21 @@ +#ifndef PROXYBRIDGE_GUI_EVENTS_H +#define PROXYBRIDGE_GUI_EVENTS_H + +#include +#include + +typedef struct EventRing EventRing; + +EventRing *event_ring_new(size_t capacity, size_t slot_size); +void event_ring_free(EventRing *r); + +// any thread; text is truncated to slot_size - 1, the oldest event is dropped when full +void event_ring_push(EventRing *r, const char *text); + +// moves events into out as "text\n" lines (NUL terminated), stops at max_events or when out is full. +// *dropped receives the number of events dropped since the previous drain +size_t event_ring_drain(EventRing *r, char *out, size_t out_size, size_t max_events, uint64_t *dropped); + +void event_ring_stats(EventRing *r, uint64_t *pushed, uint64_t *delivered, uint64_t *dropped); + +#endif diff --git a/Linux/gui/gui_json.c b/Linux/gui/gui_json.c new file mode 100644 index 00000000..7742949e --- /dev/null +++ b/Linux/gui/gui_json.c @@ -0,0 +1,140 @@ +#include "gui.h" + +static char *escape_json_string(const char *src) { + GString *str = g_string_new(""); + for (const char *p = src ? src : ""; *p; p++) { + switch (*p) { + case '\\': g_string_append(str, "\\\\"); break; + case '"': g_string_append(str, "\\\""); break; + case '\n': g_string_append(str, "\\n"); break; + case '\r': g_string_append(str, "\\r"); break; + case '\t': g_string_append(str, "\\t"); break; + default: + if ((unsigned char)*p < 0x20) g_string_append_printf(str, "\\u%04x", (unsigned char)*p); + else g_string_append_c(str, *p); + } + } + return g_string_free(str, FALSE); +} + +// returns NULL if the key is missing, its value is not a string, or the string is unterminated +char *extract_sub_json_str(const char *json, const char *key) { + char *needle = g_strdup_printf("\"%s\"", key); + const char *k = strstr(json, needle); + const char *v = k ? strchr(k + strlen(needle), ':') : NULL; + g_free(needle); + if (!v) return NULL; + v++; + while (*v == ' ' || *v == '\t' || *v == '\n' || *v == '\r') v++; + if (*v != '"') return NULL; + v++; + + GString *out = g_string_new(""); + while (*v && *v != '"') { + if (*v == '\\' && v[1]) { + v++; + g_string_append_c(out, *v == 'n' ? '\n' : *v == 'r' ? '\r' : *v == 't' ? '\t' : *v); + } else { + g_string_append_c(out, *v); + } + v++; + } + if (*v != '"') { g_string_free(out, TRUE); return NULL; } + return g_string_free(out, FALSE); +} + +static bool json_has_key(const char *json, const char *key) { + char *needle = g_strdup_printf("\"%s\"", key); + bool found = strstr(json, needle) != NULL; + g_free(needle); + return found; +} + +static bool json_bool(const char *json, const char *key, bool def) { + if (!json_has_key(json, key)) return def; + char *needle = g_strdup_printf("\"%s\"", key); + const char *colon = strchr(strstr(json, needle) + strlen(needle), ':'); + g_free(needle); + if (!colon) return def; + colon++; + while (*colon == ' ' || *colon == '\t' || *colon == '\n' || *colon == '\r') colon++; + return strncmp(colon, "true", 4) == 0; +} + +// '}' closing the object that starts at p, skipping braces inside strings +static const char *object_end(const char *p) { + bool in_str = false; + for (p++; *p; p++) { + if (in_str) { + if (*p == '\\' && p[1]) p++; + else if (*p == '"') in_str = false; + } else if (*p == '"') { + in_str = true; + } else if (*p == '}') { + return p; + } + } + return NULL; +} + +static char *first_str(const char *obj, const char *key, const char *alt) { + char *s = extract_sub_json_str(obj, key); + return s ? s : extract_sub_json_str(obj, alt); +} + +static bool name_is(const char *s, const char *want) { + return s && g_ascii_strcasecmp(s, want) == 0; +} + +// accepts our keys and the macOS ones. unknown action strings (macOS stores a proxy id) mean PROXY +int rules_from_json(const char *json, RuleImportCb cb, void *ud) { + int count = 0; + for (const char *cur = json; (cur = strchr(cur, '{')) != NULL; ) { + const char *end = object_end(cur); + if (!end) break; + char *obj = g_strndup(cur, end - cur + 1); + cur = end + 1; + + RuleData r = {0}; + r.process_name = extract_sub_json_str(obj, "processNames"); + r.target_hosts = extract_sub_json_str(obj, "targetHosts"); + r.target_ports = extract_sub_json_str(obj, "targetPorts"); + char *proto = first_str(obj, "protocol", "ruleProtocol"); + char *act = first_str(obj, "action", "ruleAction"); + r.proxy_name = extract_sub_json_str(obj, "proxy"); + r.enabled = json_bool(obj, "enabled", true); + if (!r.target_hosts) r.target_hosts = g_strdup("*"); + if (!r.target_ports) r.target_ports = g_strdup("*"); + r.protocol = name_is(proto, "TCP") ? RULE_PROTOCOL_TCP : name_is(proto, "UDP") ? RULE_PROTOCOL_UDP : RULE_PROTOCOL_BOTH; + r.action = name_is(act, "DIRECT") ? RULE_ACTION_DIRECT : name_is(act, "BLOCK") ? RULE_ACTION_BLOCK : RULE_ACTION_PROXY; + + cb(&r, ++count, ud); + + g_free(r.process_name); g_free(r.target_hosts); g_free(r.target_ports); g_free(r.proxy_name); + g_free(proto); g_free(act); g_free(obj); + } + return count; +} + +char *rules_to_json(GList *rules, bool only_selected) { + GString *out = g_string_new("[\n"); + bool first = true; + for (GList *l = rules; l != NULL; l = l->next) { + RuleData *r = (RuleData *)l->data; + if (only_selected && !r->selected) continue; + char *proc = escape_json_string(r->process_name); + char *host = escape_json_string(r->target_hosts); + char *port = escape_json_string(r->target_ports); + char *pxy = escape_json_string(r->action == RULE_ACTION_PROXY ? r->proxy_name : NULL); + const char *proto = r->protocol == RULE_PROTOCOL_TCP ? "TCP" : r->protocol == RULE_PROTOCOL_UDP ? "UDP" : "BOTH"; + const char *act = r->action == RULE_ACTION_PROXY ? "PROXY" : r->action == RULE_ACTION_DIRECT ? "DIRECT" : "BLOCK"; + g_string_append_printf(out, "%s {\n \"processNames\": \"%s\",\n \"targetHosts\": \"%s\",\n \"targetPorts\": \"%s\",\n" + " \"protocol\": \"%s\",\n \"ruleProtocol\": \"%s\",\n \"action\": \"%s\",\n \"ruleAction\": \"%s\",\n" + " \"proxy\": \"%s\",\n \"enabled\": %s\n }", + first ? "" : ",\n", proc, host, port, proto, proto, act, act, pxy, r->enabled ? "true" : "false"); + g_free(proc); g_free(host); g_free(port); g_free(pxy); + first = false; + } + g_string_append(out, "\n]\n"); + return g_string_free(out, FALSE); +} diff --git a/Linux/gui/gui_logs.c b/Linux/gui/gui_logs.c index 8fc8d2f2..4771e26c 100644 --- a/Linux/gui/gui_logs.c +++ b/Linux/gui/gui_logs.c @@ -1,49 +1,65 @@ #include "gui.h" +#include "gui_events.h" +#include + +#define RING_CAPACITY 4096 +#define SLOT_SIZE 512 +#define DRAIN_MAX 2000 +#define DRAIN_BUF_SIZE (256 * 1024) +#define DRAIN_INTERVAL_MS 100 +#define CONN_HISTORY 2000 +#define LOG_HISTORY 1000 + +typedef struct { + GtkTextBuffer **buffer; + EventRing *ring; + int max_lines; + char *filter; // lowercase, NULL = show all + bool paused; +} View; + +static View conn_view = {&conn_buffer, NULL, CONN_HISTORY, NULL, false}; +static View log_view = {&log_buffer, NULL, LOG_HISTORY, NULL, false}; +static char *drain_buf = NULL; +static guint drain_source = 0; + +static void filter_from_line(GtkTextBuffer *buffer, int first_line, const char *lower_search) { + GtkTextIter line_start, buf_end; + gtk_text_buffer_get_iter_at_line(buffer, &line_start, first_line); + gtk_text_buffer_get_end_iter(buffer, &buf_end); + gtk_text_buffer_remove_tag_by_name(buffer, "hidden", &line_start, &buf_end); + if (!lower_search) return; -// filter logs based on search -static void filter_text_view(GtkTextBuffer *buffer, const char *text) { - if (!buffer) return; - - GtkTextIter start, end; - gtk_text_buffer_get_bounds(buffer, &start, &end); - gtk_text_buffer_remove_tag_by_name(buffer, "hidden", &start, &end); - - if (!text || strlen(text) == 0) return; - - GtkTextIter line_start = start; while (!gtk_text_iter_is_end(&line_start)) { GtkTextIter line_end = line_start; if (!gtk_text_iter_ends_line(&line_end)) gtk_text_iter_forward_to_line_end(&line_end); char *line_text = gtk_text_buffer_get_text(buffer, &line_start, &line_end, FALSE); - - // search case insensitive char *lower_line = g_utf8_strdown(line_text, -1); - char *lower_search = g_utf8_strdown(text, -1); - if (!strstr(lower_line, lower_search)) { - GtkTextIter next_line = line_end; - gtk_text_iter_forward_char(&next_line); // include newline - gtk_text_buffer_apply_tag_by_name(buffer, "hidden", &line_start, &next_line); + GtkTextIter next_line = line_end; + gtk_text_iter_forward_char(&next_line); + gtk_text_buffer_apply_tag_by_name(buffer, "hidden", &line_start, &next_line); } - g_free(lower_line); - g_free(lower_search); g_free(line_text); - gtk_text_iter_forward_line(&line_start); } } +static void set_filter(View *v, const char *text) { + g_free(v->filter); + v->filter = (text && *text) ? g_utf8_strdown(text, -1) : NULL; + if (*v->buffer) filter_from_line(*v->buffer, 0, v->filter); +} + void on_search_conn_changed(GtkSearchEntry *entry, gpointer user_data) { - const char *text = gtk_entry_get_text(GTK_ENTRY(entry)); - filter_text_view(conn_buffer, text); + set_filter(&conn_view, gtk_entry_get_text(GTK_ENTRY(entry))); } void on_search_log_changed(GtkSearchEntry *entry, gpointer user_data) { - const char *text = gtk_entry_get_text(GTK_ENTRY(entry)); - filter_text_view(log_buffer, text); + set_filter(&log_view, gtk_entry_get_text(GTK_ENTRY(entry))); } void on_clear_conn_clicked(GtkButton *button, gpointer user_data) { @@ -54,73 +70,114 @@ void on_clear_log_clicked(GtkButton *button, gpointer user_data) { if (log_buffer) gtk_text_buffer_set_text(log_buffer, "", 0); } -static void free_connection_data(ConnectionData *data) { - if (data) { - free(data->process_name); - free(data->dest_ip); - free(data->proxy_info); - free(data->timestamp); - free(data); - } +void on_pause_conn_toggled(GtkToggleButton *button, gpointer user_data) { + conn_view.paused = gtk_toggle_button_get_active(button); } -// update logs from main thread -static gboolean update_log_gui(gpointer user_data) { - LogData *data = (LogData *)user_data; - if (!data) return FALSE; - - GtkTextIter end; - gtk_text_buffer_get_end_iter(log_buffer, &end); - - char *time_str = get_current_time_str(); - char full_msg[1200]; - snprintf(full_msg, sizeof(full_msg), "%s %s\n", time_str, data->message); - free(time_str); +// one insert, one filter pass over the new lines and one trim per batch +static void append_batch(View *v, const char *text, uint64_t dropped) { + GtkTextBuffer *buffer = *v->buffer; + if (!buffer) return; - gtk_text_buffer_insert(log_buffer, &end, full_msg, -1); + GString *batch = g_string_new(text); + if (dropped) g_string_append_printf(batch, "[%" G_GUINT64_FORMAT " events skipped]\n", dropped); + if (!g_utf8_validate(batch->str, -1, NULL)) { + char *fixed = g_utf8_make_valid(batch->str, -1); + g_string_assign(batch, fixed); + g_free(fixed); + } - trim_buffer_lines(log_buffer, 100); + GtkTextIter end; + gtk_text_buffer_get_end_iter(buffer, &end); + int first_line = gtk_text_iter_get_line(&end); + gtk_text_buffer_insert(buffer, &end, batch->str, (gint)batch->len); + g_string_free(batch, TRUE); + + if (v->filter) filter_from_line(buffer, first_line, v->filter); + + int excess = gtk_text_buffer_get_line_count(buffer) - 1 - v->max_lines; + if (excess > 0) { + GtkTextIter start, cut; + gtk_text_buffer_get_start_iter(buffer, &start); + gtk_text_buffer_get_iter_at_line(buffer, &cut, excess); + gtk_text_buffer_delete(buffer, &start, &cut); + } +} - free(data->message); - free(data); - return FALSE; // done +static void drain_view(View *v) { + if (v->paused || !v->ring) return; + uint64_t dropped = 0; + size_t n = event_ring_drain(v->ring, drain_buf, DRAIN_BUF_SIZE, DRAIN_MAX, &dropped); + if (n || dropped) append_batch(v, drain_buf, dropped); } -// update conn info -static gboolean update_connection_gui_append(gpointer user_data) { - ConnectionData *data = (ConnectionData *)user_data; - if (!data) return FALSE; +static gboolean drain_tick(gpointer user_data) { + drain_view(&conn_view); + drain_view(&log_view); + return G_SOURCE_CONTINUE; +} - if (conn_buffer) { - GtkTextIter end; - gtk_text_buffer_get_end_iter(conn_buffer, &end); +void gui_logs_init() { + drain_buf = g_malloc(DRAIN_BUF_SIZE); + conn_view.ring = event_ring_new(RING_CAPACITY, SLOT_SIZE); + log_view.ring = event_ring_new(RING_CAPACITY, SLOT_SIZE); + drain_source = g_timeout_add(DRAIN_INTERVAL_MS, drain_tick, NULL); +} - char line_buffer[1024]; - snprintf(line_buffer, sizeof(line_buffer), "%s %s (PID:%u) -> %s:%u via %s\n", - data->timestamp, data->process_name, data->pid, data->dest_ip, data->dest_port, data->proxy_info); +void gui_logs_stop_timers() { + if (drain_source) g_source_remove(drain_source); + drain_source = 0; +} - gtk_text_buffer_insert(conn_buffer, &end, line_buffer, -1); +// call after the engine stopped, library threads push into the rings until then +void gui_logs_free() { + gui_logs_stop_timers(); + event_ring_free(conn_view.ring); + event_ring_free(log_view.ring); + conn_view.ring = log_view.ring = NULL; + g_free(conn_view.filter); + g_free(log_view.filter); + conn_view.filter = log_view.filter = NULL; + g_free(drain_buf); + drain_buf = NULL; +} - trim_buffer_lines(conn_buffer, 100); - } +uint64_t gui_conn_event_count() { + uint64_t pushed = 0; + if (conn_view.ring) event_ring_stats(conn_view.ring, &pushed, NULL, NULL); + return pushed; +} - free_connection_data(data); - return FALSE; +static void push_line(View *v, const char *fmt, ...) G_GNUC_PRINTF(2, 3); + +static void push_line(View *v, const char *fmt, ...) { + if (!v->ring) return; + char line[SLOT_SIZE]; + time_t now = time(NULL); + struct tm tm_now; + size_t off = 0; + if (localtime_r(&now, &tm_now)) off = strftime(line, sizeof(line), "[%H:%M:%S] ", &tm_now); + va_list args; + va_start(args, fmt); + g_vsnprintf(line + off, sizeof(line) - off, fmt, args); + va_end(args); + event_ring_push(v->ring, line); } void lib_log_callback(const char *message) { - LogData *data = malloc(sizeof(LogData)); - data->message = strdup(message); - g_idle_add(update_log_gui, data); + push_line(&log_view, "%s", message ? message : ""); +} + +void gui_log(const char *format, ...) { + char msg[SLOT_SIZE]; + va_list args; + va_start(args, format); + g_vsnprintf(msg, sizeof(msg), format, args); + va_end(args); + lib_log_callback(msg); } void lib_connection_callback(const char *process_name, uint32_t pid, const char *dest_ip, uint16_t dest_port, const char *proxy_info) { - ConnectionData *data = malloc(sizeof(ConnectionData)); - data->process_name = strdup(process_name); - data->pid = pid; - data->dest_ip = strdup(dest_ip); - data->dest_port = dest_port; - data->proxy_info = strdup(proxy_info); - data->timestamp = get_current_time_str(); - g_idle_add(update_connection_gui_append, data); + push_line(&conn_view, "%s (PID:%u) -> %s:%u via %s", process_name ? process_name : "", pid, + dest_ip ? dest_ip : "", dest_port, proxy_info ? proxy_info : ""); } diff --git a/Linux/gui/gui_rules.c b/Linux/gui/gui_rules.c index df625edd..435e9f47 100644 --- a/Linux/gui/gui_rules.c +++ b/Linux/gui/gui_rules.c @@ -1,23 +1,51 @@ #include "gui.h" +static GtkWidget *rules_window = NULL; static GtkWidget *rules_list_box = NULL; static GtkWidget *btn_select_all_header = NULL; -// forward static void refresh_rules_ui(); -static void free_rule_data(RuleData *rule) { - if (rule) { - if (rule->process_name) free(rule->process_name); - if (rule->target_hosts) free(rule->target_hosts); - if (rule->target_ports) free(rule->target_ports); - free(rule); +void free_rule_data(RuleData *rule) { + if (!rule) return; + g_free(rule->process_name); + g_free(rule->target_hosts); + g_free(rule->target_ports); + g_free(rule->proxy_name); + g_free(rule); +} + +static GtkWindow *parent_window() { + return GTK_WINDOW(rules_window ? rules_window : window); +} + +// position in the library list, rules it rejected (id 0) are not in it +static int lib_index_of(RuleData *rule) { + int idx = 0; + for (GList *l = g_rules_list; l != NULL && l->data != rule; l = l->next) + if (((RuleData *)l->data)->id != 0) idx++; + return idx; +} + +static uint32_t rule_proxy_id(RuleAction action, const char *proxy_name) { + return action == RULE_ACTION_PROXY ? resolve_proxy_id(proxy_name) : 0; +} + +void apply_rules_to_lib() { + int n = 1; + for (GList *l = g_rules_list; l != NULL; l = l->next, n++) { + RuleData *r = (RuleData *)l->data; + char err[256] = ""; + r->id = ProxyBridge_AddRule(r->process_name, r->target_hosts, r->target_ports, r->protocol, r->action, + rule_proxy_id(r->action, r->proxy_name), err, sizeof(err)); + if (r->id == 0) gui_log("Rule #%d (%s) not loaded: %s", n, r->process_name, err[0] ? err : "rejected"); + else if (!r->enabled) ProxyBridge_DisableRule(r->id); } } static void on_rule_delete(GtkWidget *widget, gpointer data) { RuleData *rule = (RuleData *)data; - ProxyBridge_DeleteRule(rule->id); + if (rule->id != 0) ProxyBridge_DeleteRule(rule->id); g_rules_list = g_list_remove(g_rules_list, rule); free_rule_data(rule); save_config(); @@ -27,85 +55,95 @@ static void on_rule_delete(GtkWidget *widget, gpointer data) { static void on_rule_toggle(GtkToggleButton *btn, gpointer data) { RuleData *rule = (RuleData *)data; rule->enabled = gtk_toggle_button_get_active(btn); - if (rule->enabled) ProxyBridge_EnableRule(rule->id); - else ProxyBridge_DisableRule(rule->id); + if (rule->id != 0) { + if (rule->enabled) ProxyBridge_EnableRule(rule->id); + else ProxyBridge_DisableRule(rule->id); + } save_config(); } -static void on_save_rule(GtkWidget *widget, gpointer data) { - GtkWidget **widgets = (GtkWidget **)data; - GtkWidget *dialog = widgets[0]; - RuleData *edit_rule = (RuleData *)widgets[6]; // existing rule if present +static void on_rule_move(RuleData *rule, int dir) { + GList *link = g_list_find(g_rules_list, rule); + GList *other = link ? (dir < 0 ? link->prev : link->next) : NULL; + if (!other) return; - const char *proc = gtk_entry_get_text(GTK_ENTRY(widgets[1])); - const char *hosts = gtk_entry_get_text(GTK_ENTRY(widgets[2])); - const char *ports = gtk_entry_get_text(GTK_ENTRY(widgets[3])); - RuleProtocol proto = gtk_combo_box_get_active(GTK_COMBO_BOX(widgets[4])); - RuleAction action = gtk_combo_box_get_active(GTK_COMBO_BOX(widgets[5])); + // swap payloads so the list head stays valid + link->data = other->data; + other->data = rule; - if (strlen(proc) == 0) return; // need process name - - if (edit_rule) { - // update backend and local copy - ProxyBridge_EditRule(edit_rule->id, proc, hosts, ports, proto, action); - - free(edit_rule->process_name); edit_rule->process_name = strdup(proc); - free(edit_rule->target_hosts); edit_rule->target_hosts = strdup(hosts); - free(edit_rule->target_ports); edit_rule->target_ports = strdup(ports); - edit_rule->protocol = proto; - edit_rule->action = action; - } else { - // add new rule - uint32_t new_id = ProxyBridge_AddRule(proc, hosts, ports, proto, action); - RuleData *new_rule = malloc(sizeof(RuleData)); - new_rule->id = new_id; - new_rule->process_name = strdup(proc); - new_rule->target_hosts = strdup(hosts); - new_rule->target_ports = strdup(ports); - new_rule->protocol = proto; - new_rule->action = action; - new_rule->enabled = true; - new_rule->selected = false; - g_rules_list = g_list_append(g_rules_list, new_rule); - } + if (rule->id != 0 && !ProxyBridge_MoveRule(rule->id, lib_index_of(rule))) + gui_log("Could not reorder rule %u in the engine", rule->id); save_config(); refresh_rules_ui(); - gtk_widget_destroy(dialog); - free(widgets); } +static void on_rule_move_up(GtkWidget *widget, gpointer data) { on_rule_move((RuleData *)data, -1); } +static void on_rule_move_down(GtkWidget *widget, gpointer data) { on_rule_move((RuleData *)data, 1); } + static void on_browse_clicked(GtkWidget *widget, gpointer data) { - GtkWidget *entry = (GtkWidget *)data; - GtkWidget *dialog = gtk_file_chooser_dialog_new("Select Application", - NULL, - GTK_FILE_CHOOSER_ACTION_OPEN, - "_Cancel", GTK_RESPONSE_CANCEL, - "_Select", GTK_RESPONSE_ACCEPT, - NULL); + GtkWidget *dialog = gtk_file_chooser_dialog_new("Select Application", NULL, GTK_FILE_CHOOSER_ACTION_OPEN, + "_Cancel", GTK_RESPONSE_CANCEL, "_Select", GTK_RESPONSE_ACCEPT, NULL); if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { char *filename = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dialog)); - char *base = g_path_get_basename(filename); - gtk_entry_set_text(GTK_ENTRY(entry), base); - g_free(base); - g_free(filename); + if (filename) { + char *base = g_path_get_basename(filename); + gtk_entry_set_text(GTK_ENTRY(data), base); + g_free(base); + g_free(filename); + } } gtk_widget_destroy(dialog); } +static void on_action_changed(GtkComboBox *combo, gpointer data) { + gtk_widget_set_sensitive(GTK_WIDGET(data), gtk_combo_box_get_active(combo) == RULE_ACTION_PROXY); +} + +static void set_str(char **dst, const char *src) { + g_free(*dst); + *dst = g_strdup(src); +} + +// returns the saved rule, NULL while the engine rejects it (err filled) +static RuleData *save_rule(RuleData *rule, const char *proc, const char *hosts, const char *ports, + RuleProtocol proto, RuleAction action, const char *proxy_name, char *err, size_t err_size) { + if (!ProxyBridge_ValidateRule(proc, hosts, ports, err, err_size)) return NULL; + uint32_t pid = rule_proxy_id(action, proxy_name); + + if (rule && rule->id != 0) { + if (!ProxyBridge_EditRule(rule->id, proc, hosts, ports, proto, action, pid, err, err_size)) return NULL; + } else { + // new rule, or one the engine rejected at load time + uint32_t id = ProxyBridge_AddRule(proc, hosts, ports, proto, action, pid, err, err_size); + if (id == 0) return NULL; + if (rule && !rule->enabled) ProxyBridge_DisableRule(id); + if (!rule) { + rule = g_malloc0(sizeof(RuleData)); + rule->enabled = true; + g_rules_list = g_list_append(g_rules_list, rule); + } + rule->id = id; + ProxyBridge_MoveRule(id, lib_index_of(rule)); + } + set_str(&rule->process_name, proc); + set_str(&rule->target_hosts, hosts); + set_str(&rule->target_ports, ports); + set_str(&rule->proxy_name, proxy_name); + rule->protocol = proto; + rule->action = action; + return rule; +} + static void open_rule_dialog(RuleData *rule) { - GtkWidget *dialog = gtk_dialog_new(); - gtk_window_set_title(GTK_WINDOW(dialog), rule ? "Edit Rule" : "Add Rule"); - gtk_window_set_transient_for(GTK_WINDOW(dialog), GTK_WINDOW(window)); - gtk_window_set_modal(GTK_WINDOW(dialog), TRUE); + GtkWidget *dialog = gtk_dialog_new_with_buttons(rule ? "Edit Rule" : "Add Rule", parent_window(), + GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, + "Cancel", GTK_RESPONSE_CANCEL, "Save", GTK_RESPONSE_ACCEPT, NULL); gtk_window_set_default_size(GTK_WINDOW(dialog), 500, 400); - - GtkWidget *content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); GtkWidget *grid = gtk_grid_new(); gtk_grid_set_row_spacing(GTK_GRID(grid), 8); gtk_grid_set_column_spacing(GTK_GRID(grid), 10); gtk_container_set_border_width(GTK_CONTAINER(grid), 15); - // proc input GtkWidget *proc_entry = gtk_entry_new(); GtkWidget *browse_btn = gtk_button_new_with_label("Browse..."); g_signal_connect(browse_btn, "clicked", G_CALLBACK(on_browse_clicked), proc_entry); @@ -115,87 +153,63 @@ static void open_rule_dialog(RuleData *rule) { GtkWidget *host_entry = gtk_entry_new(); GtkWidget *port_entry = gtk_entry_new(); - - // protocol list - GtkWidget *proto_combo = gtk_combo_box_text_new(); - gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(proto_combo), "TCP"); - gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(proto_combo), "UDP"); - gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(proto_combo), "BOTH"); - - // action list - GtkWidget *action_combo = gtk_combo_box_text_new(); - gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(action_combo), "PROXY"); - gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(action_combo), "DIRECT"); - gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(action_combo), "BLOCK"); - - if (rule) { - gtk_entry_set_text(GTK_ENTRY(proc_entry), rule->process_name); - gtk_entry_set_text(GTK_ENTRY(host_entry), rule->target_hosts); - gtk_entry_set_text(GTK_ENTRY(port_entry), rule->target_ports); - gtk_combo_box_set_active(GTK_COMBO_BOX(proto_combo), rule->protocol); - gtk_combo_box_set_active(GTK_COMBO_BOX(action_combo), rule->action); - } else { - gtk_entry_set_text(GTK_ENTRY(port_entry), "*"); - gtk_entry_set_text(GTK_ENTRY(host_entry), "*"); - gtk_combo_box_set_active(GTK_COMBO_BOX(proto_combo), 2); // BOTH default - gtk_combo_box_set_active(GTK_COMBO_BOX(action_combo), 0); // PROXY default + GtkWidget *proto_combo = combo_new("TCP", "UDP", "BOTH", NULL); + GtkWidget *action_combo = combo_new("PROXY", "DIRECT", "BLOCK", NULL); + + // entry 0 = default proxy, then one per proxy in g_proxies_list order + GtkWidget *proxy_combo = combo_new("Default proxy", NULL); + int proxy_sel = 0, pi = 1; + for (GList *l = g_proxies_list; l != NULL; l = l->next, pi++) { + ProxyData *p = (ProxyData *)l->data; + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(proxy_combo), p->name); + if (rule && rule->proxy_name && strcmp(rule->proxy_name, p->name) == 0) proxy_sel = pi; } - - int row = 0; - gtk_grid_attach(GTK_GRID(grid), gtk_label_new("Process Name:"), 0, row, 1, 1); - gtk_grid_attach(GTK_GRID(grid), proc_box, 1, row, 1, 1); row++; - - GtkWidget *proc_hint = gtk_label_new("Example: firefox; chrome; /usr/bin/wget"); - gtk_style_context_add_class(gtk_widget_get_style_context(proc_hint), "dim-label"); - gtk_widget_set_halign(proc_hint, GTK_ALIGN_START); - gtk_grid_attach(GTK_GRID(grid), proc_hint, 1, row, 1, 1); row++; - - gtk_grid_attach(GTK_GRID(grid), gtk_label_new("Target Host:"), 0, row, 1, 1); - gtk_grid_attach(GTK_GRID(grid), host_entry, 1, row, 1, 1); row++; - - GtkWidget *host_hint = gtk_label_new("Example: 192.168.1.*; 10.0.0.1-50; *"); - gtk_style_context_add_class(gtk_widget_get_style_context(host_hint), "dim-label"); - gtk_widget_set_halign(host_hint, GTK_ALIGN_START); - gtk_grid_attach(GTK_GRID(grid), host_hint, 1, row, 1, 1); row++; - - gtk_grid_attach(GTK_GRID(grid), gtk_label_new("Target Port:"), 0, row, 1, 1); - gtk_grid_attach(GTK_GRID(grid), port_entry, 1, row, 1, 1); row++; - - GtkWidget *port_hint = gtk_label_new("Example: 80; 443; 8000-8080; *"); - gtk_style_context_add_class(gtk_widget_get_style_context(port_hint), "dim-label"); - gtk_widget_set_halign(port_hint, GTK_ALIGN_START); - gtk_grid_attach(GTK_GRID(grid), port_hint, 1, row, 1, 1); row++; - - gtk_grid_attach(GTK_GRID(grid), gtk_label_new("Protocol:"), 0, row, 1, 1); - gtk_grid_attach(GTK_GRID(grid), proto_combo, 1, row, 1, 1); row++; - - gtk_grid_attach(GTK_GRID(grid), gtk_label_new("Action:"), 0, row, 1, 1); - gtk_grid_attach(GTK_GRID(grid), action_combo, 1, row, 1, 1); row++; - - gtk_container_add(GTK_CONTAINER(content), grid); - - GtkWidget *save_btn = gtk_button_new_with_label("Save"); - GtkWidget *cancel_btn = gtk_button_new_with_label("Cancel"); - gtk_dialog_add_action_widget(GTK_DIALOG(dialog), cancel_btn, GTK_RESPONSE_CANCEL); - gtk_dialog_add_action_widget(GTK_DIALOG(dialog), save_btn, GTK_RESPONSE_ACCEPT); - - GtkWidget **data = malloc(7 * sizeof(GtkWidget*)); - data[0] = dialog; - data[1] = proc_entry; - data[2] = host_entry; - data[3] = port_entry; - data[4] = proto_combo; - data[5] = action_combo; - data[6] = (GtkWidget*)rule; - - g_signal_connect(save_btn, "clicked", G_CALLBACK(on_save_rule), data); - g_signal_connect(cancel_btn, "clicked", G_CALLBACK(gtk_widget_destroy), NULL); + gtk_combo_box_set_active(GTK_COMBO_BOX(proxy_combo), proxy_sel); + g_signal_connect(action_combo, "changed", G_CALLBACK(on_action_changed), proxy_combo); + + gtk_entry_set_text(GTK_ENTRY(proc_entry), rule ? rule->process_name : ""); + gtk_entry_set_text(GTK_ENTRY(host_entry), rule ? rule->target_hosts : "*"); + gtk_entry_set_text(GTK_ENTRY(port_entry), rule ? rule->target_ports : "*"); + gtk_combo_box_set_active(GTK_COMBO_BOX(proto_combo), rule ? (int)rule->protocol : RULE_PROTOCOL_BOTH); + gtk_combo_box_set_active(GTK_COMBO_BOX(action_combo), rule ? (int)rule->action : RULE_ACTION_PROXY); + + GtkWidget *err_label = hint_label(""); + grid_row(grid, 0, "Process Name:", proc_box); + gtk_grid_attach(GTK_GRID(grid), hint_label("Example: firefox; chrome; /usr/bin/wget"), 1, 1, 1, 1); + grid_row(grid, 2, "Target Host:", host_entry); + gtk_grid_attach(GTK_GRID(grid), hint_label("Example: 192.168.1.*; 10.0.0.0/8; 10.0.0.1-10.0.0.50; github.com; *.github.com; * (domains are matched via DNS answers)"), 1, 3, 1, 1); + grid_row(grid, 4, "Target Port:", port_entry); + gtk_grid_attach(GTK_GRID(grid), hint_label("Example: 80; 443; 8000-8080; *"), 1, 5, 1, 1); + grid_row(grid, 6, "Protocol:", proto_combo); + grid_row(grid, 7, "Action:", action_combo); + grid_row(grid, 8, "Proxy:", proxy_combo); + gtk_grid_attach(GTK_GRID(grid), err_label, 0, 9, 2, 1); + gtk_container_add(GTK_CONTAINER(gtk_dialog_get_content_area(GTK_DIALOG(dialog))), grid); gtk_widget_show_all(dialog); + on_action_changed(GTK_COMBO_BOX(action_combo), proxy_combo); + + bool saved = false; + while (!saved && gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { + int proto = gtk_combo_box_get_active(GTK_COMBO_BOX(proto_combo)); + int action = gtk_combo_box_get_active(GTK_COMBO_BOX(action_combo)); + int psel = gtk_combo_box_get_active(GTK_COMBO_BOX(proxy_combo)); + ProxyData *pd = (action == RULE_ACTION_PROXY && psel > 0) ? g_list_nth_data(g_proxies_list, psel - 1) : NULL; + char err[256] = ""; + + saved = save_rule(rule, gtk_entry_get_text(GTK_ENTRY(proc_entry)), gtk_entry_get_text(GTK_ENTRY(host_entry)), + gtk_entry_get_text(GTK_ENTRY(port_entry)), proto < 0 ? RULE_PROTOCOL_BOTH : (RuleProtocol)proto, + action < 0 ? RULE_ACTION_PROXY : (RuleAction)action, pd ? pd->name : NULL, err, sizeof(err)) != NULL; + if (saved) { + save_config(); + refresh_rules_ui(); + } else { + set_error_label(err_label, err[0] ? err : "Invalid rule."); + } + } + gtk_widget_destroy(dialog); } static void on_rule_edit(GtkWidget *widget, gpointer data) { - on_proxy_rules_clicked(NULL, NULL); // Re-open if closed? Usually modal. - // Wait, on_rule_edit clicked from rules list already open_rule_dialog((RuleData *)data); } @@ -203,106 +217,104 @@ static void on_rule_add_clicked(GtkWidget *widget, gpointer data) { open_rule_dialog(NULL); } +static bool all_rules_selected() { + if (!g_rules_list) return false; + for (GList *l = g_rules_list; l != NULL; l = l->next) + if (!((RuleData *)l->data)->selected) return false; + return true; +} + +static void update_select_all_label() { + if (btn_select_all_header) + gtk_button_set_label(GTK_BUTTON(btn_select_all_header), all_rules_selected() ? "Deselect All" : "Select All"); +} + static void on_rule_select_toggle(GtkToggleButton *btn, gpointer data) { - RuleData *rule = (RuleData *)data; - rule->selected = gtk_toggle_button_get_active(btn); - if (btn_select_all_header) { - bool all_selected = (g_rules_list != NULL); - if (g_rules_list == NULL) all_selected = false; - for (GList *l = g_rules_list; l != NULL; l = l->next) { - RuleData *r = (RuleData *)l->data; - if (!r->selected) { - all_selected = false; - break; - } - } - gtk_button_set_label(GTK_BUTTON(btn_select_all_header), all_selected ? "Deselect All" : "Select All"); - } + ((RuleData *)data)->selected = gtk_toggle_button_get_active(btn); + update_select_all_label(); } static void on_rule_export_clicked(GtkWidget *widget, gpointer data) { - if (!g_rules_list) return; bool any_selected = false; - for (GList *l = g_rules_list; l != NULL; l = l->next) { - if (((RuleData *)l->data)->selected) { any_selected = true; break; } - } + for (GList *l = g_rules_list; l != NULL; l = l->next) any_selected |= ((RuleData *)l->data)->selected; if (!any_selected) { - show_message(NULL, GTK_MESSAGE_WARNING, "Please select at least one rule to export."); + show_message(parent_window(), GTK_MESSAGE_WARNING, "Please select at least one rule to export."); return; } - GtkWidget *dialog = gtk_file_chooser_dialog_new("Export Rules", GTK_WINDOW(window), GTK_FILE_CHOOSER_ACTION_SAVE, "_Cancel", GTK_RESPONSE_CANCEL, "_Save", GTK_RESPONSE_ACCEPT, NULL); + GtkWidget *dialog = gtk_file_chooser_dialog_new("Export Rules", parent_window(), GTK_FILE_CHOOSER_ACTION_SAVE, + "_Cancel", GTK_RESPONSE_CANCEL, "_Save", GTK_RESPONSE_ACCEPT, NULL); gtk_file_chooser_set_do_overwrite_confirmation(GTK_FILE_CHOOSER(dialog), TRUE); gtk_file_chooser_set_current_name(GTK_FILE_CHOOSER(dialog), "proxy_rules.json"); if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { char *filename = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dialog)); - FILE *f = fopen(filename, "w"); - if (f) { - fprintf(f, "[\n"); - bool first = true; - for (GList *l = g_rules_list; l != NULL; l = l->next) { - RuleData *r = (RuleData *)l->data; - if (!r->selected) continue; - if (!first) fprintf(f, ",\n"); - char *proc = escape_json_string(r->process_name); - char *host = escape_json_string(r->target_hosts); - char *port = escape_json_string(r->target_ports); - const char *proto = (r->protocol == RULE_PROTOCOL_TCP) ? "TCP" : (r->protocol == RULE_PROTOCOL_UDP ? "UDP" : "BOTH"); - const char *act = (r->action == RULE_ACTION_PROXY) ? "PROXY" : (r->action == RULE_ACTION_DIRECT ? "DIRECT" : "BLOCK"); - fprintf(f, " {\n \"processNames\": \"%s\",\n \"targetHosts\": \"%s\",\n \"targetPorts\": \"%s\",\n \"protocol\": \"%s\",\n \"action\": \"%s\",\n \"enabled\": %s\n }", proc, host, port, proto, act, r->enabled ? "true" : "false"); - g_free(proc); g_free(host); g_free(port); - first = false; - } - fprintf(f, "\n]\n"); - fclose(f); - show_message(NULL, GTK_MESSAGE_INFO, "Rules exported successfully."); - } + char *json = rules_to_json(g_rules_list, true); + GError *err = NULL; + if (filename && g_file_set_contents(filename, json, -1, &err)) + show_message(parent_window(), GTK_MESSAGE_INFO, "Rules exported successfully."); + else + show_message(parent_window(), GTK_MESSAGE_ERROR, "Could not write %s: %s", filename ? filename : "file", err ? err->message : "unknown error"); + if (err) g_error_free(err); + g_free(json); g_free(filename); } gtk_widget_destroy(dialog); } +typedef struct { + int imported, failed; + GString *fail_msg; +} ImportCtx; + +static void import_rule_cb(const RuleData *r, int index, void *ud) { + ImportCtx *ctx = (ImportCtx *)ud; + char err[256] = ""; + // unknown proxy name -> default proxy + ProxyData *px = r->action == RULE_ACTION_PROXY ? find_proxy_by_name(r->proxy_name) : NULL; + uint32_t id = r->process_name ? ProxyBridge_AddRule(r->process_name, r->target_hosts, r->target_ports, r->protocol, + r->action, px ? px->id : 0, err, sizeof(err)) : 0; + if (id == 0) { + ctx->failed++; + if (ctx->fail_msg->len < 3000) + g_string_append_printf(ctx->fail_msg, "Rule #%d (%s): %s\n", index, r->process_name ? r->process_name : "?", + !r->process_name ? "missing processNames" : err[0] ? err : "rejected"); + return; + } + if (!r->enabled) ProxyBridge_DisableRule(id); + RuleData *nd = g_malloc0(sizeof(RuleData)); + nd->id = id; + nd->process_name = g_strdup(r->process_name); + nd->target_hosts = g_strdup(r->target_hosts); + nd->target_ports = g_strdup(r->target_ports); + nd->protocol = r->protocol; + nd->action = r->action; + nd->enabled = r->enabled; + nd->proxy_name = px ? g_strdup(px->name) : NULL; + g_rules_list = g_list_append(g_rules_list, nd); + ctx->imported++; +} + static void on_rule_import_clicked(GtkWidget *widget, gpointer data) { - GtkWidget *dialog = gtk_file_chooser_dialog_new("Import Rules", GTK_WINDOW(window), GTK_FILE_CHOOSER_ACTION_OPEN, "_Cancel", GTK_RESPONSE_CANCEL, "_Open", GTK_RESPONSE_ACCEPT, NULL); + GtkWidget *dialog = gtk_file_chooser_dialog_new("Import Rules", parent_window(), GTK_FILE_CHOOSER_ACTION_OPEN, + "_Cancel", GTK_RESPONSE_CANCEL, "_Open", GTK_RESPONSE_ACCEPT, NULL); if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { char *filename = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dialog)); char *content = NULL; - gsize len; - if (g_file_get_contents(filename, &content, &len, NULL)) { - char *curr = content; - int imported = 0; - while ((curr = strchr(curr, '{')) != NULL) { - char *end = strchr(curr, '}'); - if (!end) break; - char saved = *end; *end = '\0'; - - char *proc = extract_sub_json_str(curr, "processNames"); - char *host = extract_sub_json_str(curr, "targetHosts"); - char *port = extract_sub_json_str(curr, "targetPorts"); - char *proto_s = extract_sub_json_str(curr, "protocol"); - char *act_s = extract_sub_json_str(curr, "action"); - bool en = extract_sub_json_bool(curr, "enabled"); - - if (proc && host && port && proto_s && act_s) { - RuleProtocol p = RULE_PROTOCOL_BOTH; - if (strcmp(proto_s, "TCP") == 0) p = RULE_PROTOCOL_TCP; - else if (strcmp(proto_s, "UDP") == 0) p = RULE_PROTOCOL_UDP; - RuleAction a = RULE_ACTION_PROXY; - if (strcmp(act_s, "DIRECT") == 0) a = RULE_ACTION_DIRECT; - else if (strcmp(act_s, "BLOCK") == 0) a = RULE_ACTION_BLOCK; - - uint32_t nid = ProxyBridge_AddRule(proc, host, port, p, a); - if (!en) ProxyBridge_DisableRule(nid); - RuleData *nd = malloc(sizeof(RuleData)); - nd->id = nid; nd->process_name = strdup(proc); nd->target_hosts = strdup(host); - nd->target_ports = strdup(port); nd->protocol = p; nd->action = a; nd->enabled = en; nd->selected = false; - g_rules_list = g_list_append(g_rules_list, nd); - imported++; - } - g_free(proc); g_free(host); g_free(port); g_free(proto_s); g_free(act_s); - *end = saved; curr = end + 1; - } + if (filename && g_file_get_contents(filename, &content, NULL, NULL)) { + ImportCtx ctx = {0, 0, g_string_new("")}; + rules_from_json(content, import_rule_cb, &ctx); g_free(content); - if (imported > 0) { refresh_rules_ui(); show_message(NULL, GTK_MESSAGE_INFO, "Imported %d rules.", imported); } + + if (ctx.imported > 0) { save_config(); refresh_rules_ui(); } + if (ctx.failed > 0) + show_message(parent_window(), GTK_MESSAGE_WARNING, "Imported %d rules, %d failed validation and were skipped:\n\n%s", + ctx.imported, ctx.failed, ctx.fail_msg->str); + else if (ctx.imported > 0) + show_message(parent_window(), GTK_MESSAGE_INFO, "Imported %d rules.", ctx.imported); + else + show_message(parent_window(), GTK_MESSAGE_WARNING, "No rules found in the file."); + g_string_free(ctx.fail_msg, TRUE); + } else { + show_message(parent_window(), GTK_MESSAGE_ERROR, "Could not read the selected file."); } g_free(filename); } @@ -310,160 +322,162 @@ static void on_rule_import_clicked(GtkWidget *widget, gpointer data) { } static void on_bulk_delete_clicked(GtkWidget *widget, gpointer data) { - if (!g_rules_list) return; - GList *iter = g_rules_list; - GList *to_delete = NULL; - while (iter != NULL) { - RuleData *rule = (RuleData *)iter->data; - if (rule->selected) to_delete = g_list_append(to_delete, rule); - iter = iter->next; - } - if (!to_delete) return; - for (GList *d = to_delete; d != NULL; d = d->next) { - RuleData *rule = (RuleData *)d->data; - ProxyBridge_DeleteRule(rule->id); - g_rules_list = g_list_remove(g_rules_list, rule); - free_rule_data(rule); + GList *l = g_rules_list; + while (l != NULL) { + GList *next = l->next; + RuleData *rule = (RuleData *)l->data; + if (rule->selected) { + if (rule->id != 0) ProxyBridge_DeleteRule(rule->id); + g_rules_list = g_list_delete_link(g_rules_list, l); + free_rule_data(rule); + } + l = next; } - g_list_free(to_delete); + save_config(); refresh_rules_ui(); } static void on_select_all_clicked(GtkWidget *widget, gpointer data) { - if (!g_rules_list) return; - bool all_selected = true; - for (GList *l = g_rules_list; l != NULL; l = l->next) { - if (!((RuleData *)l->data)->selected) { all_selected = false; break; } - } - bool new_state = !all_selected; + bool new_state = !all_rules_selected(); for (GList *l = g_rules_list; l != NULL; l = l->next) ((RuleData *)l->data)->selected = new_state; refresh_rules_ui(); } +static GtkWidget *header_label(const char *text, bool expand) { + GtkWidget *l = gtk_label_new(text); + gtk_widget_set_halign(l, GTK_ALIGN_START); + gtk_widget_set_hexpand(l, expand); + return l; +} + +static GtkWidget *cell_label(const char *text) { + GtkWidget *l = header_label(text, false); + gtk_label_set_ellipsize(GTK_LABEL(l), PANGO_ELLIPSIZE_END); + return l; +} + +static GtkWidget *small_button(GtkWidget *box, const char *label, const char *tip, bool sensitive, GCallback cb, gpointer data) { + GtkWidget *b = gtk_button_new_with_label(label); + if (tip) gtk_widget_set_tooltip_text(b, tip); + gtk_widget_set_sensitive(b, sensitive); + g_signal_connect(b, "clicked", cb, data); + gtk_box_pack_start(GTK_BOX(box), b, FALSE, FALSE, 0); + return b; +} + +static GtkWidget *check_cell(GtkWidget *grid, int row, int col, bool active, GCallback cb, gpointer data) { + GtkWidget *c = gtk_check_button_new(); + gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(c), active); + g_signal_connect(c, "toggled", cb, data); + gtk_grid_attach(GTK_GRID(grid), c, col, row, 1, 1); + return c; +} + static void refresh_rules_ui() { if (!rules_list_box) return; GList *children = gtk_container_get_children(GTK_CONTAINER(rules_list_box)); - for(GList *iter = children; iter != NULL; iter = g_list_next(iter)) gtk_widget_destroy(GTK_WIDGET(iter->data)); + for (GList *c = children; c != NULL; c = c->next) gtk_widget_destroy(GTK_WIDGET(c->data)); g_list_free(children); - - if (btn_select_all_header) { - bool all_selected = (g_rules_list != NULL); - if (g_rules_list == NULL) all_selected = false; - for (GList *l = g_rules_list; l != NULL; l = l->next) { - if (!((RuleData *)l->data)->selected) { all_selected = false; break; } - } - gtk_button_set_label(GTK_BUTTON(btn_select_all_header), all_selected ? "Deselect All" : "Select All"); - } + update_select_all_label(); GtkWidget *grid = gtk_grid_new(); gtk_grid_set_row_spacing(GTK_GRID(grid), 10); gtk_grid_set_column_spacing(GTK_GRID(grid), 15); gtk_container_add(GTK_CONTAINER(rules_list_box), grid); - int row = 0; - gtk_grid_attach(GTK_GRID(grid), gtk_label_new(" "), 0, row, 1, 1); - gtk_grid_attach(GTK_GRID(grid), gtk_label_new("Enable"), 1, row, 1, 1); - gtk_grid_attach(GTK_GRID(grid), gtk_label_new("Actions"), 2, row, 1, 1); - gtk_grid_attach(GTK_GRID(grid), gtk_label_new("SR"), 3, row, 1, 1); - GtkWidget *h_proc = gtk_label_new("Process"); gtk_widget_set_halign(h_proc, GTK_ALIGN_START); - gtk_widget_set_hexpand(h_proc, TRUE); gtk_grid_attach(GTK_GRID(grid), h_proc, 4, row, 1, 1); - GtkWidget *h_host = gtk_label_new("Target Hosts"); gtk_widget_set_halign(h_host, GTK_ALIGN_START); - gtk_widget_set_hexpand(h_host, TRUE); gtk_grid_attach(GTK_GRID(grid), h_host, 5, row, 1, 1); - gtk_grid_attach(GTK_GRID(grid), gtk_label_new("Protocol"), 6, row, 1, 1); - gtk_grid_attach(GTK_GRID(grid), gtk_label_new("Action"), 7, row, 1, 1); - row++; - gtk_grid_attach(GTK_GRID(grid), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL), 0, row, 8, 1); row++; - - int sr_counter = 1; - for (GList *l = g_rules_list; l != NULL; l = l->next) { - RuleData *r = (RuleData *)l->data; - GtkWidget *chk_sel = gtk_check_button_new(); - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(chk_sel), r->selected); - g_signal_connect(chk_sel, "toggled", G_CALLBACK(on_rule_select_toggle), r); - gtk_grid_attach(GTK_GRID(grid), chk_sel, 0, row, 1, 1); + const char *heads[] = {" ", "Enable", "Actions", "SR", "Process", "Target Hosts", "Protocol", "Action", "Proxy"}; + for (int c = 0; c < 9; c++) + gtk_grid_attach(GTK_GRID(grid), header_label(heads[c], c == 4 || c == 5), c, 0, 1, 1); + gtk_grid_attach(GTK_GRID(grid), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL), 0, 1, 9, 1); - GtkWidget *chk = gtk_check_button_new(); - gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(chk), r->enabled); - g_signal_connect(chk, "toggled", G_CALLBACK(on_rule_toggle), r); - gtk_grid_attach(GTK_GRID(grid), chk, 1, row, 1, 1); + const char *proto_strs[] = {"TCP", "UDP", "BOTH"}; + const char *action_strs[] = {"PROXY", "DIRECT", "BLOCK"}; + const char *action_class[] = {"success", "info", "warning"}; + int row = 2, sr = 1; + for (GList *l = g_rules_list; l != NULL; l = l->next, row += 2) { + RuleData *r = (RuleData *)l->data; + check_cell(grid, row, 0, r->selected, G_CALLBACK(on_rule_select_toggle), r); + check_cell(grid, row, 1, r->enabled, G_CALLBACK(on_rule_toggle), r); + // list position is the priority, first match wins GtkWidget *act_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 2); - GtkWidget *btn_edit = gtk_button_new_with_label("Edit"); - g_signal_connect(btn_edit, "clicked", G_CALLBACK(on_rule_edit), r); - GtkWidget *btn_del = gtk_button_new_with_label("Delete"); - g_signal_connect(btn_del, "clicked", G_CALLBACK(on_rule_delete), r); - gtk_box_pack_start(GTK_BOX(act_box), btn_edit, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(act_box), btn_del, FALSE, FALSE, 0); + small_button(act_box, "Edit", NULL, true, G_CALLBACK(on_rule_edit), r); + small_button(act_box, "Delete", NULL, true, G_CALLBACK(on_rule_delete), r); + small_button(act_box, "▲", "Move up (higher priority)", l->prev != NULL, G_CALLBACK(on_rule_move_up), r); + small_button(act_box, "▼", "Move down (lower priority)", l->next != NULL, G_CALLBACK(on_rule_move_down), r); gtk_grid_attach(GTK_GRID(grid), act_box, 2, row, 1, 1); - char sr_str[16]; snprintf(sr_str, sizeof(sr_str), "%d", sr_counter++); + char sr_str[16]; + snprintf(sr_str, sizeof(sr_str), "%d", sr++); gtk_grid_attach(GTK_GRID(grid), gtk_label_new(sr_str), 3, row, 1, 1); + gtk_grid_attach(GTK_GRID(grid), cell_label(r->process_name), 4, row, 1, 1); + gtk_grid_attach(GTK_GRID(grid), cell_label(r->target_hosts), 5, row, 1, 1); + gtk_grid_attach(GTK_GRID(grid), gtk_label_new(proto_strs[r->protocol <= RULE_PROTOCOL_BOTH ? r->protocol : RULE_PROTOCOL_BOTH]), 6, row, 1, 1); - GtkWidget *l_proc = gtk_label_new(r->process_name); gtk_widget_set_halign(l_proc, GTK_ALIGN_START); - gtk_label_set_ellipsize(GTK_LABEL(l_proc), PANGO_ELLIPSIZE_END); - gtk_grid_attach(GTK_GRID(grid), l_proc, 4, row, 1, 1); - - GtkWidget *l_host = gtk_label_new(r->target_hosts); gtk_widget_set_halign(l_host, GTK_ALIGN_START); - gtk_label_set_ellipsize(GTK_LABEL(l_host), PANGO_ELLIPSIZE_END); - gtk_grid_attach(GTK_GRID(grid), l_host, 5, row, 1, 1); - - const char* proto_strs[] = {"TCP", "UDP", "BOTH"}; - gtk_grid_attach(GTK_GRID(grid), gtk_label_new(proto_strs[r->protocol]), 6, row, 1, 1); - - const char* action_strs[] = {"PROXY", "DIRECT", "BLOCK"}; - GtkWidget *l_act = gtk_label_new(action_strs[r->action]); - GtkStyleContext *context = gtk_widget_get_style_context(l_act); - if (r->action == RULE_ACTION_PROXY) gtk_style_context_add_class(context, "success"); - else if (r->action == RULE_ACTION_DIRECT) gtk_style_context_add_class(context, "info"); - else gtk_style_context_add_class(context, "warning"); + int a = r->action <= RULE_ACTION_BLOCK ? r->action : RULE_ACTION_PROXY; + GtkWidget *l_act = gtk_label_new(action_strs[a]); + gtk_style_context_add_class(gtk_widget_get_style_context(l_act), action_class[a]); gtk_grid_attach(GTK_GRID(grid), l_act, 7, row, 1, 1); - row++; - gtk_grid_attach(GTK_GRID(grid), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL), 0, row, 8, 1); row++; + const char *proxy_txt = "-"; + if (r->action == RULE_ACTION_PROXY) + proxy_txt = (r->proxy_name && find_proxy_by_name(r->proxy_name)) ? r->proxy_name : "default"; + gtk_grid_attach(GTK_GRID(grid), cell_label(proxy_txt), 8, row, 1, 1); + gtk_grid_attach(GTK_GRID(grid), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL), 0, row + 1, 9, 1); } gtk_widget_show_all(rules_list_box); } +void refresh_rules_window() { + refresh_rules_ui(); +} + +static void on_rules_window_destroy(GtkWidget *widget, gpointer data) { + rules_window = NULL; + rules_list_box = NULL; + btn_select_all_header = NULL; +} + void on_proxy_rules_clicked(GtkWidget *widget, gpointer data) { - GtkWidget *dialog = gtk_window_new(GTK_WINDOW_TOPLEVEL); - gtk_window_set_title(GTK_WINDOW(dialog), "Process Rules"); - gtk_window_set_default_size(GTK_WINDOW(dialog), 800, 500); - gtk_window_set_transient_for(GTK_WINDOW(dialog), GTK_WINDOW(window)); + if (rules_window) { + gtk_window_present(GTK_WINDOW(rules_window)); + return; + } + rules_window = gtk_window_new(GTK_WINDOW_TOPLEVEL); + g_signal_connect(rules_window, "destroy", G_CALLBACK(on_rules_window_destroy), NULL); + gtk_window_set_title(GTK_WINDOW(rules_window), "Process Rules"); + gtk_window_set_default_size(GTK_WINDOW(rules_window), 1000, 500); + gtk_window_set_transient_for(GTK_WINDOW(rules_window), GTK_WINDOW(window)); GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10); gtk_container_set_border_width(GTK_CONTAINER(vbox), 20); - gtk_container_add(GTK_CONTAINER(dialog), vbox); + gtk_container_add(GTK_CONTAINER(rules_window), vbox); GtkWidget *header_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); GtkWidget *title = gtk_label_new(NULL); gtk_label_set_markup(GTK_LABEL(title), "Process Rules"); + gtk_box_pack_start(GTK_BOX(header_box), title, FALSE, FALSE, 0); + GtkWidget *spacer = gtk_label_new(""); + gtk_box_pack_start(GTK_BOX(header_box), spacer, TRUE, TRUE, 0); + + btn_select_all_header = gtk_button_new_with_label("Select All"); GtkWidget *add_btn = gtk_button_new_with_label("+ Add Rule"); + GtkWidget *del_btn = gtk_button_new_with_label("Delete Selected"); + GtkWidget *import_btn = gtk_button_new_with_label("Import"); + GtkWidget *export_btn = gtk_button_new_with_label("Export"); g_signal_connect(add_btn, "clicked", G_CALLBACK(on_rule_add_clicked), NULL); - btn_select_all_header = gtk_button_new_with_label("Select All"); g_signal_connect(btn_select_all_header, "clicked", G_CALLBACK(on_select_all_clicked), NULL); - GtkWidget *del_all_btn = gtk_button_new_with_label("Delete Selected"); - g_signal_connect(del_all_btn, "clicked", G_CALLBACK(on_bulk_delete_clicked), NULL); - GtkWidget *import_btn = gtk_button_new_with_label("Import"); + g_signal_connect(del_btn, "clicked", G_CALLBACK(on_bulk_delete_clicked), NULL); g_signal_connect(import_btn, "clicked", G_CALLBACK(on_rule_import_clicked), NULL); - GtkWidget *export_btn = gtk_button_new_with_label("Export"); g_signal_connect(export_btn, "clicked", G_CALLBACK(on_rule_export_clicked), NULL); - - gtk_box_pack_start(GTK_BOX(header_box), title, FALSE, FALSE, 0); - GtkWidget *spacer = gtk_label_new(""); - gtk_widget_set_hexpand(spacer, TRUE); - gtk_box_pack_start(GTK_BOX(header_box), spacer, TRUE, TRUE, 0); - gtk_box_pack_end(GTK_BOX(header_box), add_btn, FALSE, FALSE, 5); - gtk_box_pack_end(GTK_BOX(header_box), btn_select_all_header, FALSE, FALSE, 5); - gtk_box_pack_end(GTK_BOX(header_box), del_all_btn, FALSE, FALSE, 5); - gtk_box_pack_end(GTK_BOX(header_box), export_btn, FALSE, FALSE, 5); - gtk_box_pack_end(GTK_BOX(header_box), import_btn, FALSE, FALSE, 5); + GtkWidget *right[] = {add_btn, btn_select_all_header, del_btn, export_btn, import_btn}; + for (int i = 0; i < 5; i++) gtk_box_pack_end(GTK_BOX(header_box), right[i], FALSE, FALSE, 5); gtk_box_pack_start(GTK_BOX(vbox), header_box, FALSE, FALSE, 0); GtkWidget *scrolled = gtk_scrolled_window_new(NULL, NULL); - gtk_widget_set_vexpand(scrolled, TRUE); rules_list_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 5); gtk_container_add(GTK_CONTAINER(scrolled), rules_list_box); gtk_box_pack_start(GTK_BOX(vbox), scrolled, TRUE, TRUE, 0); refresh_rules_ui(); - gtk_widget_show_all(dialog); + gtk_widget_show_all(rules_window); } diff --git a/Linux/gui/gui_settings.c b/Linux/gui/gui_settings.c index 3268f9ca..a97d4f4a 100644 --- a/Linux/gui/gui_settings.c +++ b/Linux/gui/gui_settings.c @@ -1,221 +1,620 @@ #include "gui.h" -// settings stuff - -static gboolean on_test_done(gpointer user_data) { - TestResultData *data = (TestResultData *)user_data; - gtk_text_buffer_set_text(data->buffer, data->result_text, -1); - gtk_widget_set_sensitive(data->btn, TRUE); +GList *g_proxies_list = NULL; +char *g_default_proxy = NULL; + +enum { COL_NAME, COL_TYPE, COL_ADDR, COL_AUTH, COL_DEFAULT, COL_PTR, N_COLS }; + +typedef struct { + GtkWidget *dialog; + GtkWidget *tree; + GtkListStore *store; + GtkWidget *test_host; + GtkWidget *test_port; + GtkTextBuffer *output_buffer; + GtkWidget *test_btn; +} ConfigInfo; + +// refs on buffer/btn keep them valid if the dialog closes mid-test +typedef struct { + uint32_t proxy_id; + char *host; + uint16_t port; + GtkTextBuffer *buffer; + GtkWidget *btn; + char result[4096]; +} TestJob; + +void free_proxy_data(ProxyData *p) { + if (!p) return; + g_free(p->name); + g_free(p->host); + g_free(p->username); + if (p->password) memset(p->password, 0, strlen(p->password)); + g_free(p->password); + g_free(p); +} - free(data->result_text); - free(data); - return FALSE; +ProxyData *find_proxy_by_name(const char *name) { + if (!name || !*name) return NULL; + for (GList *l = g_proxies_list; l != NULL; l = l->next) { + ProxyData *p = (ProxyData *)l->data; + if (p->name && strcmp(p->name, name) == 0) return p; + } + return NULL; } -static gpointer run_test_thread(gpointer user_data) { - struct TestRunnerData *req = (struct TestRunnerData *)user_data; +// 0 = default proxy (empty or unknown name) +uint32_t resolve_proxy_id(const char *name) { + ProxyData *p = find_proxy_by_name(name); + return p ? p->id : 0; +} - char *buffer = malloc(4096); - memset(buffer, 0, 4096); +void apply_proxies_to_lib() { + ProxyBridge_ClearProxies(); + for (GList *l = g_proxies_list; l != NULL; l = l->next) { + ProxyData *p = (ProxyData *)l->data; + char err[256] = ""; + p->id = ProxyBridge_AddProxy(p->name, p->type, p->host, p->port, p->username, p->password, err, sizeof(err)); + if (p->id == 0) gui_log("Proxy '%s' rejected: %s", p->name, err[0] ? err : "unknown error"); + } + ProxyBridge_SetDefaultProxy(resolve_proxy_id(g_default_proxy)); +} - // run test - ProxyBridge_TestConnection(req->host, req->port, buffer, 4096); +static bool confirm(GtkWindow *parent, const char *format, ...) { + va_list args; + va_start(args, format); + char *msg = g_strdup_vprintf(format, args); + va_end(args); + GtkWidget *q = gtk_message_dialog_new(parent, GTK_DIALOG_MODAL, GTK_MESSAGE_QUESTION, GTK_BUTTONS_YES_NO, "%s", msg); + int resp = gtk_dialog_run(GTK_DIALOG(q)); + gtk_widget_destroy(q); + g_free(msg); + return resp == GTK_RESPONSE_YES; +} - TestResultData *res = malloc(sizeof(TestResultData)); - res->result_text = buffer; - res->buffer = req->ui_info->output_buffer; - res->btn = req->ui_info->test_btn; +static bool port_text_valid(const char *text, int *port_out) { + size_t n = strlen(text); + if (n == 0 || n > 5 || strspn(text, "0123456789") != n) return false; + int p = (int)parse_long(text); + if (p < 1 || p > 65535) return false; + *port_out = p; + return true; +} - g_idle_add(on_test_done, res); +static gboolean on_test_done(gpointer user_data) { + TestJob *job = (TestJob *)user_data; + char *text = g_utf8_make_valid(job->result, -1); + gtk_text_buffer_set_text(job->buffer, text, -1); + gtk_widget_set_sensitive(job->btn, TRUE); + g_free(text); + g_object_unref(job->buffer); + g_object_unref(job->btn); + g_free(job->host); + g_free(job); + return FALSE; +} - free(req->host); - free(req); +static gpointer run_test_thread(gpointer user_data) { + TestJob *job = (TestJob *)user_data; + ProxyBridge_TestProxy(job->proxy_id, job->host, job->port, job->result, sizeof(job->result)); + job->result[sizeof(job->result) - 1] = '\0'; + g_idle_add(on_test_done, job); return NULL; } +static ProxyData *get_selected_proxy(ConfigInfo *info) { + GtkTreeModel *model; + GtkTreeIter iter; + gpointer ptr = NULL; + if (gtk_tree_selection_get_selected(gtk_tree_view_get_selection(GTK_TREE_VIEW(info->tree)), &model, &iter)) + gtk_tree_model_get(model, &iter, COL_PTR, &ptr, -1); + return (ProxyData *)ptr; +} + static void on_start_test_clicked(GtkWidget *widget, gpointer data) { ConfigInfo *info = (ConfigInfo *)data; - - // check inputs - const char *ip_text = gtk_entry_get_text(GTK_ENTRY(info->ip_entry)); - const char *port_text = gtk_entry_get_text(GTK_ENTRY(info->port_entry)); - - if (!ip_text || strlen(ip_text) == 0 || strspn(port_text, "0123456789") != strlen(port_text) || strlen(port_text) == 0) { - gtk_text_buffer_set_text(info->output_buffer, "Error: Invalid Proxy IP or Port.", -1); + ProxyData *px = get_selected_proxy(info); + const char *err = !px ? "Error: Select a proxy to test." + : px->id == 0 ? "Error: This proxy was rejected by the engine, edit and save it again." : NULL; + if (err) { + gtk_text_buffer_set_text(info->output_buffer, err, -1); return; } - // save config - ProxyType type = (gtk_combo_box_get_active(GTK_COMBO_BOX(info->type_combo)) == 0) ? PROXY_TYPE_HTTP : PROXY_TYPE_SOCKS5; - int port = (int)safe_strtol(port_text); - const char *user = gtk_entry_get_text(GTK_ENTRY(info->user_entry)); - const char *pass = gtk_entry_get_text(GTK_ENTRY(info->pass_entry)); - - ProxyBridge_SetProxyConfig(type, ip_text, port, user, pass); - - // get target const char *t_host = gtk_entry_get_text(GTK_ENTRY(info->test_host)); - const char *t_port_s = gtk_entry_get_text(GTK_ENTRY(info->test_port)); - - if (!t_host || strlen(t_host) == 0) t_host = "google.com"; - int t_port = (int)safe_strtol(t_port_s); - if (t_port <= 0) t_port = 80; + int t_port = (int)parse_long(gtk_entry_get_text(GTK_ENTRY(info->test_port))); + if (t_port <= 0 || t_port > 65535) t_port = 80; - // update gui gtk_text_buffer_set_text(info->output_buffer, "Testing connection... Please wait...", -1); gtk_widget_set_sensitive(info->test_btn, FALSE); - // start thread - struct TestRunnerData *req = malloc(sizeof(struct TestRunnerData)); - req->host = strdup(t_host); - req->port = t_port; - req->ui_info = info; + TestJob *job = g_malloc0(sizeof(TestJob)); + job->proxy_id = px->id; + job->host = g_strdup(*t_host ? t_host : "google.com"); + job->port = (uint16_t)t_port; + job->buffer = g_object_ref(info->output_buffer); + job->btn = g_object_ref(info->test_btn); + g_thread_unref(g_thread_new("test_conn", run_test_thread, job)); +} - GThread *thread = g_thread_new("test_conn", run_test_thread, req); - g_thread_unref(thread); +static void refresh_proxy_store(ConfigInfo *info) { + gtk_list_store_clear(info->store); + for (GList *l = g_proxies_list; l != NULL; l = l->next) { + ProxyData *p = (ProxyData *)l->data; + char *addr = g_strdup_printf("%s:%u", p->host, p->port); + bool is_def = g_default_proxy && strcmp(g_default_proxy, p->name) == 0; + GtkTreeIter iter; + gtk_list_store_append(info->store, &iter); + gtk_list_store_set(info->store, &iter, + COL_NAME, p->name, + COL_TYPE, p->type == PROXY_TYPE_HTTP ? "HTTP" : "SOCKS5", + COL_ADDR, addr, + COL_AUTH, (p->username && *p->username) ? "Yes" : "No", + COL_DEFAULT, is_def ? "Yes" : "", + COL_PTR, p, + -1); + g_free(addr); + } } -void on_proxy_configure(GtkWidget *widget, gpointer data) { - ConfigInfo info; +static void select_proxy_row(ConfigInfo *info, ProxyData *target) { + GtkTreeModel *model = GTK_TREE_MODEL(info->store); + GtkTreeIter iter; + if (!target || !gtk_tree_model_get_iter_first(model, &iter)) return; + do { + gpointer ptr = NULL; + gtk_tree_model_get(model, &iter, COL_PTR, &ptr, -1); + if (ptr == target) { + gtk_tree_selection_select_iter(gtk_tree_view_get_selection(GTK_TREE_VIEW(info->tree)), &iter); + return; + } + } while (gtk_tree_model_iter_next(model, &iter)); +} - GtkWidget *content_area; - GtkWidget *grid; +static void set_str(char **dst, const char *src) { + g_free(*dst); + *dst = g_strdup(src); +} + +// renaming keeps the default proxy and the rules pointing at it +static void rename_proxy_refs(const char *old_name, const char *new_name) { + if (g_default_proxy && strcmp(g_default_proxy, old_name) == 0) set_str(&g_default_proxy, new_name); + for (GList *l = g_rules_list; l != NULL; l = l->next) { + RuleData *r = (RuleData *)l->data; + if (r->proxy_name && strcmp(r->proxy_name, old_name) == 0) set_str(&r->proxy_name, new_name); + } +} - info.dialog = gtk_dialog_new_with_buttons("Proxy Settings", - GTK_WINDOW(window), +// existing == NULL adds. returns the saved proxy or NULL +static ProxyData *proxy_edit_dialog(GtkWindow *parent, ProxyData *existing) { + GtkWidget *dialog = gtk_dialog_new_with_buttons(existing ? "Edit Proxy" : "Add Proxy", parent, GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, - "Cancel", GTK_RESPONSE_CANCEL, - "Save", GTK_RESPONSE_ACCEPT, - NULL); - // wider window - gtk_window_set_default_size(GTK_WINDOW(info.dialog), 600, 500); - - content_area = gtk_dialog_get_content_area(GTK_DIALOG(info.dialog)); - grid = gtk_grid_new(); + "Cancel", GTK_RESPONSE_CANCEL, "Save", GTK_RESPONSE_ACCEPT, NULL); + gtk_window_set_default_size(GTK_WINDOW(dialog), 420, -1); + GtkWidget *grid = gtk_grid_new(); gtk_grid_set_row_spacing(GTK_GRID(grid), 5); gtk_grid_set_column_spacing(GTK_GRID(grid), 10); gtk_container_set_border_width(GTK_CONTAINER(grid), 10); - gtk_box_pack_start(GTK_BOX(content_area), grid, TRUE, TRUE, 0); - - // proxy type dropdown - gtk_grid_attach(GTK_GRID(grid), gtk_label_new("Type:"), 0, 0, 1, 1); - info.type_combo = gtk_combo_box_text_new(); - gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(info.type_combo), "HTTP"); - gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(info.type_combo), "SOCKS5"); - gtk_combo_box_set_active(GTK_COMBO_BOX(info.type_combo), g_proxy_type == PROXY_TYPE_HTTP ? 0 : 1); - gtk_grid_attach(GTK_GRID(grid), info.type_combo, 1, 0, 3, 1); - - // ip field - gtk_grid_attach(GTK_GRID(grid), gtk_label_new("Host:"), 0, 1, 1, 1); - info.ip_entry = gtk_entry_new(); - gtk_entry_set_text(GTK_ENTRY(info.ip_entry), g_proxy_ip); - gtk_widget_set_hexpand(info.ip_entry, TRUE); - gtk_grid_attach(GTK_GRID(grid), info.ip_entry, 1, 1, 3, 1); - - // port field - gtk_grid_attach(GTK_GRID(grid), gtk_label_new("Port:"), 0, 2, 1, 1); - info.port_entry = gtk_entry_new(); - if (g_proxy_port != 0) { + gtk_box_pack_start(GTK_BOX(gtk_dialog_get_content_area(GTK_DIALOG(dialog))), grid, TRUE, TRUE, 0); + + GtkWidget *name_entry = gtk_entry_new(); + GtkWidget *type_combo = combo_new("HTTP", "SOCKS5", NULL); + GtkWidget *ip_entry = gtk_entry_new(); + GtkWidget *port_entry = gtk_entry_new(); + GtkWidget *user_entry = gtk_entry_new(); + GtkWidget *pass_entry = gtk_entry_new(); + gtk_entry_set_visibility(GTK_ENTRY(pass_entry), FALSE); + grid_row(grid, 0, "Name:", name_entry); + grid_row(grid, 1, "Type:", type_combo); + grid_row(grid, 2, "Host:", ip_entry); + grid_row(grid, 3, "Port:", port_entry); + grid_row(grid, 4, "Username:", user_entry); + grid_row(grid, 5, "Password:", pass_entry); + GtkWidget *err_label = hint_label(""); + gtk_grid_attach(GTK_GRID(grid), err_label, 0, 6, 2, 1); + + gtk_combo_box_set_active(GTK_COMBO_BOX(type_combo), 1); + if (existing) { char port_str[16]; - snprintf(port_str, sizeof(port_str), "%d", g_proxy_port); - gtk_entry_set_text(GTK_ENTRY(info.port_entry), port_str); + snprintf(port_str, sizeof(port_str), "%u", existing->port); + gtk_entry_set_text(GTK_ENTRY(name_entry), existing->name); + gtk_combo_box_set_active(GTK_COMBO_BOX(type_combo), existing->type == PROXY_TYPE_HTTP ? 0 : 1); + gtk_entry_set_text(GTK_ENTRY(ip_entry), existing->host); + if (existing->port) gtk_entry_set_text(GTK_ENTRY(port_entry), port_str); + gtk_entry_set_text(GTK_ENTRY(user_entry), existing->username ? existing->username : ""); + gtk_entry_set_text(GTK_ENTRY(pass_entry), existing->password ? existing->password : ""); + } else if (!g_proxies_list) { + gtk_entry_set_text(GTK_ENTRY(name_entry), "default"); } - gtk_grid_attach(GTK_GRID(grid), info.port_entry, 1, 2, 3, 1); + gtk_widget_show_all(dialog); + + ProxyData *result = NULL; + while (!result && gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { + const char *port_text = gtk_entry_get_text(GTK_ENTRY(port_entry)); + const char *user = gtk_entry_get_text(GTK_ENTRY(user_entry)); + const char *pass = gtk_entry_get_text(GTK_ENTRY(pass_entry)); + ProxyType type = gtk_combo_box_get_active(GTK_COMBO_BOX(type_combo)) == 0 ? PROXY_TYPE_HTTP : PROXY_TYPE_SOCKS5; + char *name = g_strstrip(g_strdup(gtk_entry_get_text(GTK_ENTRY(name_entry)))); + char *host = g_strstrip(g_strdup(gtk_entry_get_text(GTK_ENTRY(ip_entry)))); + ProxyData *dup = find_proxy_by_name(name); + int port = 0; + char err[256] = ""; + + const char *msg = !*name ? "Name cannot be empty." + : (dup && dup != existing) ? "A proxy with this name already exists." + : !*host ? "Host (IP/Domain) cannot be empty." + : !port_text_valid(port_text, &port) ? "Port must be a number between 1 and 65535." : NULL; + + uint32_t id = existing ? existing->id : 0; + if (!msg) { + bool ok; + if (id) { + ok = ProxyBridge_EditProxy(id, name, type, host, (uint16_t)port, user, pass, err, sizeof(err)); + } else { + id = ProxyBridge_AddProxy(name, type, host, (uint16_t)port, user, pass, err, sizeof(err)); + ok = id != 0; + } + if (!ok) msg = err[0] ? err : "Engine rejected the proxy settings."; + } + if (msg) { + set_error_label(err_label, msg); + g_free(name); + g_free(host); + continue; + } - // user field - gtk_grid_attach(GTK_GRID(grid), gtk_label_new("Username:"), 0, 3, 1, 1); - info.user_entry = gtk_entry_new(); - gtk_entry_set_text(GTK_ENTRY(info.user_entry), g_proxy_user); - gtk_grid_attach(GTK_GRID(grid), info.user_entry, 1, 3, 3, 1); + ProxyData *p = existing; + if (!p) { + p = g_malloc0(sizeof(ProxyData)); + g_proxies_list = g_list_append(g_proxies_list, p); + } else if (strcmp(p->name, name) != 0) { + rename_proxy_refs(p->name, name); + } + p->id = id; + g_free(p->name); p->name = name; + g_free(p->host); p->host = host; + set_str(&p->username, user); + if (p->password) memset(p->password, 0, strlen(p->password)); + set_str(&p->password, pass); + p->type = type; + p->port = (uint16_t)port; + + if (!find_proxy_by_name(g_default_proxy)) { + set_str(&g_default_proxy, p->name); + ProxyBridge_SetDefaultProxy(p->id); + } + result = p; + } + gtk_widget_destroy(dialog); + return result; +} - // password - gtk_grid_attach(GTK_GRID(grid), gtk_label_new("Password:"), 0, 4, 1, 1); - info.pass_entry = gtk_entry_new(); - gtk_entry_set_visibility(GTK_ENTRY(info.pass_entry), FALSE); - gtk_entry_set_text(GTK_ENTRY(info.pass_entry), g_proxy_pass); - gtk_grid_attach(GTK_GRID(grid), info.pass_entry, 1, 4, 3, 1); +static void commit_proxy_change(ConfigInfo *info, ProxyData *p) { + if (!p) return; + save_config(); + refresh_proxy_store(info); + select_proxy_row(info, p); +} - // test section - gtk_grid_attach(GTK_GRID(grid), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL), 0, 5, 4, 1); +static void on_proxy_add(GtkWidget *widget, gpointer data) { + ConfigInfo *info = (ConfigInfo *)data; + commit_proxy_change(info, proxy_edit_dialog(GTK_WINDOW(info->dialog), NULL)); +} + +static void on_proxy_edit(GtkWidget *widget, gpointer data) { + ConfigInfo *info = (ConfigInfo *)data; + ProxyData *sel = get_selected_proxy(info); + if (!sel) { + show_message(GTK_WINDOW(info->dialog), GTK_MESSAGE_WARNING, "Select a proxy first."); + return; + } + commit_proxy_change(info, proxy_edit_dialog(GTK_WINDOW(info->dialog), sel)); +} + +static void on_proxy_row_activated(GtkTreeView *tree, GtkTreePath *path, GtkTreeViewColumn *col, gpointer data) { + on_proxy_edit(NULL, data); +} + +static void on_proxy_delete(GtkWidget *widget, gpointer data) { + ConfigInfo *info = (ConfigInfo *)data; + ProxyData *p = get_selected_proxy(info); + if (!p) { + show_message(GTK_WINDOW(info->dialog), GTK_MESSAGE_WARNING, "Select a proxy first."); + return; + } + if (!confirm(GTK_WINDOW(info->dialog), "Delete proxy '%s'?\nRules using it will fall back to the default proxy.", p->name)) return; + + if (p->id) ProxyBridge_DeleteProxy(p->id); + for (GList *l = g_rules_list; l != NULL; l = l->next) { + RuleData *r = (RuleData *)l->data; + if (r->proxy_name && strcmp(r->proxy_name, p->name) == 0) set_str(&r->proxy_name, NULL); + } - GtkWidget *test_label = gtk_label_new("Test Connection"); + bool was_default = g_default_proxy && strcmp(g_default_proxy, p->name) == 0; + g_proxies_list = g_list_remove(g_proxies_list, p); + free_proxy_data(p); + if (was_default) { + set_str(&g_default_proxy, g_proxies_list ? ((ProxyData *)g_proxies_list->data)->name : NULL); + ProxyBridge_SetDefaultProxy(resolve_proxy_id(g_default_proxy)); + } + save_config(); + refresh_proxy_store(info); +} + +static void on_proxy_set_default(GtkWidget *widget, gpointer data) { + ConfigInfo *info = (ConfigInfo *)data; + ProxyData *p = get_selected_proxy(info); + if (!p) { + show_message(GTK_WINDOW(info->dialog), GTK_MESSAGE_WARNING, "Select a proxy first."); + return; + } + set_str(&g_default_proxy, p->name); + ProxyBridge_SetDefaultProxy(p->id); + commit_proxy_change(info, p); + + char *status_msg = g_strdup_printf("Default proxy: %s (%s:%u)", p->name, p->host, p->port); + gtk_statusbar_push(GTK_STATUSBAR(status_bar), status_context_id, status_msg); + g_free(status_msg); +} + +void on_proxy_configure(GtkWidget *widget, gpointer data) { + ConfigInfo info = {0}; + info.dialog = gtk_dialog_new_with_buttons("Proxy Settings", GTK_WINDOW(window), + GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, + "Close", GTK_RESPONSE_CLOSE, NULL); + gtk_window_set_default_size(GTK_WINDOW(info.dialog), 650, 560); + + GtkWidget *grid = gtk_grid_new(); + gtk_grid_set_row_spacing(GTK_GRID(grid), 5); + gtk_grid_set_column_spacing(GTK_GRID(grid), 10); + gtk_container_set_border_width(GTK_CONTAINER(grid), 10); + gtk_box_pack_start(GTK_BOX(gtk_dialog_get_content_area(GTK_DIALOG(info.dialog))), grid, TRUE, TRUE, 0); + + info.store = gtk_list_store_new(N_COLS, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_STRING, G_TYPE_POINTER); + info.tree = gtk_tree_view_new_with_model(GTK_TREE_MODEL(info.store)); + g_object_unref(info.store); + const char *titles[] = {"Name", "Type", "Host:Port", "Auth", "Default"}; + for (int i = 0; i < 5; i++) { + GtkTreeViewColumn *c = gtk_tree_view_column_new_with_attributes(titles[i], gtk_cell_renderer_text_new(), "text", i, NULL); + gtk_tree_view_column_set_resizable(c, TRUE); + gtk_tree_view_column_set_expand(c, i == 2); + gtk_tree_view_append_column(GTK_TREE_VIEW(info.tree), c); + } + g_signal_connect(info.tree, "row-activated", G_CALLBACK(on_proxy_row_activated), &info); + GtkWidget *tree_scroll = gtk_scrolled_window_new(NULL, NULL); + gtk_widget_set_size_request(tree_scroll, -1, 180); + gtk_widget_set_hexpand(tree_scroll, TRUE); + gtk_widget_set_vexpand(tree_scroll, TRUE); + gtk_container_add(GTK_CONTAINER(tree_scroll), info.tree); + gtk_grid_attach(GTK_GRID(grid), tree_scroll, 0, 0, 4, 1); + + GtkWidget *btn_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 5); + const char *labels[] = {"Add", "Edit", "Delete", "Set Default"}; + void (*cbs[])(GtkWidget *, gpointer) = {on_proxy_add, on_proxy_edit, on_proxy_delete, on_proxy_set_default}; + for (int i = 0; i < 4; i++) { + GtkWidget *b = gtk_button_new_with_label(labels[i]); + g_signal_connect(b, "clicked", G_CALLBACK(cbs[i]), &info); + gtk_box_pack_start(GTK_BOX(btn_box), b, FALSE, FALSE, 0); + } + gtk_grid_attach(GTK_GRID(grid), btn_box, 0, 1, 4, 1); + gtk_grid_attach(GTK_GRID(grid), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL), 0, 2, 4, 1); + + GtkWidget *test_label = gtk_label_new("Test Selected Proxy"); gtk_label_set_use_markup(GTK_LABEL(test_label), TRUE); gtk_widget_set_halign(test_label, GTK_ALIGN_START); - gtk_grid_attach(GTK_GRID(grid), test_label, 0, 6, 4, 1); + gtk_grid_attach(GTK_GRID(grid), test_label, 0, 3, 4, 1); - // target - gtk_grid_attach(GTK_GRID(grid), gtk_label_new("Target:"), 0, 7, 1, 1); + gtk_grid_attach(GTK_GRID(grid), gtk_label_new("Target:"), 0, 4, 1, 1); info.test_host = gtk_entry_new(); gtk_entry_set_text(GTK_ENTRY(info.test_host), "google.com"); - gtk_grid_attach(GTK_GRID(grid), info.test_host, 1, 7, 1, 1); - - // target port - gtk_grid_attach(GTK_GRID(grid), gtk_label_new("Port:"), 2, 7, 1, 1); + gtk_widget_set_hexpand(info.test_host, TRUE); + gtk_grid_attach(GTK_GRID(grid), info.test_host, 1, 4, 1, 1); + gtk_grid_attach(GTK_GRID(grid), gtk_label_new("Port:"), 2, 4, 1, 1); info.test_port = gtk_entry_new(); gtk_entry_set_text(GTK_ENTRY(info.test_port), "80"); gtk_widget_set_size_request(info.test_port, 80, -1); - gtk_grid_attach(GTK_GRID(grid), info.test_port, 3, 7, 1, 1); + gtk_grid_attach(GTK_GRID(grid), info.test_port, 3, 4, 1, 1); - // test button info.test_btn = gtk_button_new_with_label("Start Test"); g_signal_connect(info.test_btn, "clicked", G_CALLBACK(on_start_test_clicked), &info); - gtk_grid_attach(GTK_GRID(grid), info.test_btn, 0, 8, 4, 1); + gtk_grid_attach(GTK_GRID(grid), info.test_btn, 0, 5, 4, 1); - // output log GtkWidget *out_scroll = gtk_scrolled_window_new(NULL, NULL); gtk_widget_set_size_request(out_scroll, -1, 150); - gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(out_scroll), GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC); - GtkWidget *out_view = gtk_text_view_new(); gtk_text_view_set_editable(GTK_TEXT_VIEW(out_view), FALSE); gtk_text_view_set_monospace(GTK_TEXT_VIEW(out_view), TRUE); info.output_buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(out_view)); - gtk_container_add(GTK_CONTAINER(out_scroll), out_view); - gtk_grid_attach(GTK_GRID(grid), out_scroll, 0, 9, 4, 1); + gtk_grid_attach(GTK_GRID(grid), out_scroll, 0, 6, 4, 1); + refresh_proxy_store(&info); + ProxyData *def = find_proxy_by_name(g_default_proxy); + if (!def && g_proxies_list) def = (ProxyData *)g_proxies_list->data; + select_proxy_row(&info, def); gtk_widget_show_all(info.dialog); + gtk_dialog_run(GTK_DIALOG(info.dialog)); + gtk_widget_destroy(info.dialog); +} - while (TRUE) { - if (gtk_dialog_run(GTK_DIALOG(info.dialog)) != GTK_RESPONSE_ACCEPT) break; +typedef struct { + GtkWidget *dialog; + GtkWidget *tree; + GtkListStore *store; +} ProfilesInfo; + +static void update_profile_ui() { + char *title = g_strdup_printf("ProxyBridge - %s", g_active_profile); + gtk_window_set_title(GTK_WINDOW(window), title); + g_free(title); + title = g_strdup_printf("Active profile: %s", g_active_profile); + gtk_statusbar_push(GTK_STATUSBAR(status_bar), status_context_id, title); + g_free(title); +} - // check - const char *ip_text = gtk_entry_get_text(GTK_ENTRY(info.ip_entry)); - const char *port_text = gtk_entry_get_text(GTK_ENTRY(info.port_entry)); +// engine keeps running, only its proxies and rules are replaced +static void reapply_active_profile() { + ProxyBridge_ClearRules(); + apply_proxies_to_lib(); + apply_rules_to_lib(); + save_config(); + refresh_rules_window(); + update_profile_ui(); +} - if (!ip_text || strlen(ip_text) == 0) { - show_message(GTK_WINDOW(info.dialog), GTK_MESSAGE_ERROR, "Host (IP/Domain) cannot be empty."); - continue; - } +static void refresh_profile_store(ProfilesInfo *pi) { + gtk_list_store_clear(pi->store); + for (GList *l = g_profiles; l != NULL; l = l->next) { + Profile *p = (Profile *)l->data; + GtkTreeIter iter; + gtk_list_store_append(pi->store, &iter); + gtk_list_store_set(pi->store, &iter, 0, p->name, 1, strcmp(p->name, g_active_profile) == 0 ? "Active" : "", -1); + } +} - if (strspn(port_text, "0123456789") != strlen(port_text) || strlen(port_text) == 0) { - show_message(GTK_WINDOW(info.dialog), GTK_MESSAGE_ERROR, "Port must be a valid number."); - continue; - } +static char *selected_profile_name(ProfilesInfo *pi) { + GtkTreeModel *model; + GtkTreeIter iter; + char *name = NULL; + if (gtk_tree_selection_get_selected(gtk_tree_view_get_selection(GTK_TREE_VIEW(pi->tree)), &model, &iter)) + gtk_tree_model_get(model, &iter, 0, &name, -1); + return name; +} - int p = (int)safe_strtol(port_text); - if (p < 1 || p > 65535) { - show_message(GTK_WINDOW(info.dialog), GTK_MESSAGE_ERROR, "Port must be between 1 and 65535."); - continue; - } +// NULL on cancel +static char *ask_profile_name(GtkWindow *parent, const char *title, const char *initial) { + GtkWidget *d = gtk_dialog_new_with_buttons(title, parent, GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, + "Cancel", GTK_RESPONSE_CANCEL, "OK", GTK_RESPONSE_ACCEPT, NULL); + GtkWidget *box = gtk_dialog_get_content_area(GTK_DIALOG(d)); + gtk_container_set_border_width(GTK_CONTAINER(box), 10); + GtkWidget *entry = gtk_entry_new(); + gtk_entry_set_activates_default(GTK_ENTRY(entry), TRUE); + gtk_entry_set_text(GTK_ENTRY(entry), initial); + gtk_box_pack_start(GTK_BOX(box), gtk_label_new("Profile name:"), FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(box), entry, FALSE, FALSE, 5); + gtk_dialog_set_default_response(GTK_DIALOG(d), GTK_RESPONSE_ACCEPT); + gtk_widget_show_all(d); + + char *result = NULL; + while (!result && gtk_dialog_run(GTK_DIALOG(d)) == GTK_RESPONSE_ACCEPT) { + char *name = g_strstrip(g_strdup(gtk_entry_get_text(GTK_ENTRY(entry)))); + if (!*name) show_message(GTK_WINDOW(d), GTK_MESSAGE_ERROR, "Profile name cannot be empty."); + else if (profile_find(name) && strcmp(initial, name) != 0) show_message(GTK_WINDOW(d), GTK_MESSAGE_ERROR, "A profile with this name already exists."); + else { result = name; continue; } + g_free(name); + } + gtk_widget_destroy(d); + return result; +} - // save - g_proxy_type = (gtk_combo_box_get_active(GTK_COMBO_BOX(info.type_combo)) == 0) ? PROXY_TYPE_HTTP : PROXY_TYPE_SOCKS5; - g_strlcpy(g_proxy_ip, ip_text, sizeof(g_proxy_ip)); - g_proxy_port = p; - g_strlcpy(g_proxy_user, gtk_entry_get_text(GTK_ENTRY(info.user_entry)), sizeof(g_proxy_user)); - g_strlcpy(g_proxy_pass, gtk_entry_get_text(GTK_ENTRY(info.pass_entry)), sizeof(g_proxy_pass)); +static void on_profile_switch(GtkWidget *widget, gpointer data) { + ProfilesInfo *pi = (ProfilesInfo *)data; + char *name = selected_profile_name(pi); + if (name && strcmp(name, g_active_profile) != 0 && profile_activate(name)) { + reapply_active_profile(); + refresh_profile_store(pi); + } + g_free(name); +} - ProxyBridge_SetProxyConfig(g_proxy_type, g_proxy_ip, g_proxy_port, g_proxy_user, g_proxy_pass); +static void on_profile_new(GtkWidget *widget, gpointer data) { + ProfilesInfo *pi = (ProfilesInfo *)data; + char *name = ask_profile_name(GTK_WINDOW(pi->dialog), "New Profile", ""); + if (name && profile_create(name, false)) { + save_config(); + refresh_profile_store(pi); + } + g_free(name); +} +static void on_profile_duplicate(GtkWidget *widget, gpointer data) { + ProfilesInfo *pi = (ProfilesInfo *)data; + char *src = selected_profile_name(pi); + if (!src) return; + char *initial = g_strdup_printf("%s copy", src); + char *name = ask_profile_name(GTK_WINDOW(pi->dialog), "Duplicate Profile", initial); + char *prev = g_strdup(g_active_profile); + // copies the active profile, so make the source active for a moment (library state is untouched) + if (name && profile_activate(src)) { + profile_create(name, true); + profile_activate(prev); save_config(); + refresh_profile_store(pi); + } + g_free(prev); g_free(initial); g_free(name); g_free(src); +} - char status_msg[512]; - snprintf(status_msg, sizeof(status_msg), "Configuration updated: %s:%d", g_proxy_ip, g_proxy_port); - gtk_statusbar_push(GTK_STATUSBAR(status_bar), status_context_id, status_msg); - break; +static void on_profile_rename(GtkWidget *widget, gpointer data) { + ProfilesInfo *pi = (ProfilesInfo *)data; + char *old = selected_profile_name(pi); + if (!old) return; + char *name = ask_profile_name(GTK_WINDOW(pi->dialog), "Rename Profile", old); + if (name && profile_rename(profile_find(old), name)) { + save_config(); + refresh_profile_store(pi); + update_profile_ui(); } + g_free(name); + g_free(old); +} - gtk_widget_destroy(info.dialog); +static void on_profile_delete(GtkWidget *widget, gpointer data) { + ProfilesInfo *pi = (ProfilesInfo *)data; + char *name = selected_profile_name(pi); + if (!name) return; + if (!g_profiles->next) { + show_message(GTK_WINDOW(pi->dialog), GTK_MESSAGE_WARNING, "The last profile cannot be deleted."); + } else if (confirm(GTK_WINDOW(pi->dialog), "Delete profile '%s' with all its proxies and rules?", name)) { + bool was_active = strcmp(g_active_profile, name) == 0; + if (profile_delete(profile_find(name))) { + if (was_active) reapply_active_profile(); + else save_config(); + refresh_profile_store(pi); + } + } + g_free(name); +} + +static void on_profile_row_activated(GtkTreeView *tree, GtkTreePath *path, GtkTreeViewColumn *col, gpointer data) { + on_profile_switch(NULL, data); +} + +void on_profiles_clicked(GtkWidget *widget, gpointer data) { + ProfilesInfo pi = {0}; + pi.dialog = gtk_dialog_new_with_buttons("Profiles", GTK_WINDOW(window), GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT, + "Close", GTK_RESPONSE_CLOSE, NULL); + gtk_window_set_default_size(GTK_WINDOW(pi.dialog), 420, 360); + GtkWidget *box = gtk_dialog_get_content_area(GTK_DIALOG(pi.dialog)); + gtk_container_set_border_width(GTK_CONTAINER(box), 10); + + pi.store = gtk_list_store_new(2, G_TYPE_STRING, G_TYPE_STRING); + pi.tree = gtk_tree_view_new_with_model(GTK_TREE_MODEL(pi.store)); + g_object_unref(pi.store); + const char *titles[] = {"Profile", "Status"}; + for (int i = 0; i < 2; i++) { + GtkTreeViewColumn *c = gtk_tree_view_column_new_with_attributes(titles[i], gtk_cell_renderer_text_new(), "text", i, NULL); + gtk_tree_view_column_set_expand(c, i == 0); + gtk_tree_view_append_column(GTK_TREE_VIEW(pi.tree), c); + } + g_signal_connect(pi.tree, "row-activated", G_CALLBACK(on_profile_row_activated), &pi); + GtkWidget *scroll = gtk_scrolled_window_new(NULL, NULL); + gtk_container_add(GTK_CONTAINER(scroll), pi.tree); + gtk_box_pack_start(GTK_BOX(box), scroll, TRUE, TRUE, 0); + + GtkWidget *btns = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 5); + const char *labels[] = {"Switch", "New", "Duplicate", "Rename", "Delete"}; + void (*cbs[])(GtkWidget *, gpointer) = {on_profile_switch, on_profile_new, on_profile_duplicate, on_profile_rename, on_profile_delete}; + for (int i = 0; i < 5; i++) { + GtkWidget *b = gtk_button_new_with_label(labels[i]); + g_signal_connect(b, "clicked", G_CALLBACK(cbs[i]), &pi); + gtk_box_pack_start(GTK_BOX(btns), b, FALSE, FALSE, 0); + } + gtk_box_pack_start(GTK_BOX(box), btns, FALSE, FALSE, 5); + + refresh_profile_store(&pi); + gtk_widget_show_all(pi.dialog); + gtk_dialog_run(GTK_DIALOG(pi.dialog)); + gtk_widget_destroy(pi.dialog); } diff --git a/Linux/gui/gui_utils.c b/Linux/gui/gui_utils.c index ae69d292..13314962 100644 --- a/Linux/gui/gui_utils.c +++ b/Linux/gui/gui_utils.c @@ -1,91 +1,48 @@ #include "gui.h" -// safer way to turn string to int -long safe_strtol(const char *nptr) { - if (!nptr) return 0; // null check - char *endptr; - long val = strtol(nptr, &endptr, 10); - if (endptr == nptr) return 0; // bad input - return val; -} - -// show popup msg void show_message(GtkWindow *parent, GtkMessageType type, const char *format, ...) { va_list args; va_start(args, format); char *msg = g_strdup_vprintf(format, args); va_end(args); - GtkWidget *dialog = gtk_message_dialog_new(parent, - GTK_DIALOG_DESTROY_WITH_PARENT | GTK_DIALOG_MODAL, - type, - GTK_BUTTONS_OK, - "%s", msg); + GtkWidget *dialog = gtk_message_dialog_new(parent, GTK_DIALOG_DESTROY_WITH_PARENT | GTK_DIALOG_MODAL, + type, GTK_BUTTONS_OK, "%s", msg); gtk_dialog_run(GTK_DIALOG(dialog)); gtk_widget_destroy(dialog); g_free(msg); } -// keep log size under control so we dont use too much ram -void trim_buffer_lines(GtkTextBuffer *buffer, int max_lines) { - if (gtk_text_buffer_get_line_count(buffer) > max_lines) { - GtkTextIter start, next; - gtk_text_buffer_get_start_iter(buffer, &start); - next = start; - gtk_text_iter_forward_line(&next); - gtk_text_buffer_delete(buffer, &start, &next); - } -} - -char* get_current_time_str() { - time_t rawtime; - struct tm *timeinfo; - char *buffer = malloc(32); - time(&rawtime); - timeinfo = localtime(&rawtime); - strftime(buffer, 32, "[%H:%M:%S]", timeinfo); - return buffer; +GtkWidget *combo_new(const char *first, ...) { + GtkWidget *combo = gtk_combo_box_text_new(); + va_list args; + va_start(args, first); + for (const char *s = first; s; s = va_arg(args, const char *)) + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(combo), s); + va_end(args); + return combo; } -// json stuff -char *escape_json_string(const char *src) { - if (!src) return strdup(""); - GString *str = g_string_new(""); - for (const char *p = src; *p; p++) { - if (*p == '\\') g_string_append(str, "\\\\"); - else if (*p == '"') g_string_append(str, "\\\""); - else if (*p == '\n') g_string_append(str, "\\n"); - else g_string_append_c(str, *p); - } - return g_string_free(str, FALSE); +GtkWidget *hint_label(const char *text) { + GtkWidget *l = gtk_label_new(text); + gtk_style_context_add_class(gtk_widget_get_style_context(l), "dim-label"); + gtk_widget_set_halign(l, GTK_ALIGN_START); + gtk_label_set_xalign(GTK_LABEL(l), 0); + gtk_label_set_line_wrap(GTK_LABEL(l), TRUE); + gtk_label_set_max_width_chars(GTK_LABEL(l), 60); + return l; } -// basic parser, good enough for valid inputs -char *extract_sub_json_str(const char *json, const char *key) { - char search_key[256]; - snprintf(search_key, sizeof(search_key), "\"%s\"", key); - char *k = strstr(json, search_key); - if (!k) return NULL; - char *colon = strchr(k, ':'); - if (!colon) return NULL; - char *val_start = strchr(colon, '"'); - if (!val_start) return NULL; - val_start++; - char *val_end = strchr(val_start, '"'); - if (!val_end) return NULL; - return g_strndup(val_start, val_end - val_start); +void set_error_label(GtkWidget *label, const char *msg) { + char *m = g_markup_printf_escaped("%s", msg); + gtk_label_set_markup(GTK_LABEL(label), m); + g_free(m); } -bool extract_sub_json_bool(const char *json, const char *key) { - char search_key[256]; - snprintf(search_key, sizeof(search_key), "\"%s\"", key); - char *k = strstr(json, search_key); - if (!k) return false; - char *colon = strchr(k, ':'); - if (!colon) return false; - // skip whitespace - char *v = colon + 1; - while(*v == ' ' || *v == '\t') v++; - if (strncmp(v, "true", 4) == 0) return true; - return false; +void grid_row(GtkWidget *grid, int row, const char *label, GtkWidget *widget) { + GtkWidget *l = gtk_label_new(label); + gtk_widget_set_halign(l, GTK_ALIGN_START); + gtk_grid_attach(GTK_GRID(grid), l, 0, row, 1, 1); + gtk_widget_set_hexpand(widget, TRUE); + gtk_grid_attach(GTK_GRID(grid), widget, 1, row, 1, 1); } diff --git a/Linux/gui/main.c b/Linux/gui/main.c index 388622e4..0cfc8679 100644 --- a/Linux/gui/main.c +++ b/Linux/gui/main.c @@ -1,21 +1,13 @@ #include "gui.h" +#include +#include -// widgets GtkWidget *window; -GtkTextView *conn_view; GtkTextBuffer *conn_buffer; -GtkTextView *log_view; GtkTextBuffer *log_buffer; GtkWidget *status_bar; guint status_context_id; -// default config -char g_proxy_ip[256] = ""; -uint16_t g_proxy_port = 0; -ProxyType g_proxy_type = PROXY_TYPE_SOCKS5; -char g_proxy_user[256] = ""; -char g_proxy_pass[256] = ""; - GList *g_rules_list = NULL; bool g_chk_logging = true; bool g_chk_dns = true; @@ -118,7 +110,6 @@ static void on_check_update(GtkWidget *widget, gpointer data) { GError *spawn_err = NULL; int exit_code = 0; - // feed only, no deploy.sh. open the linux download/notes url in the browser if (!g_spawn_sync(NULL, argv, NULL, G_SPAWN_SEARCH_PATH, NULL, NULL, &json, &curl_err, &exit_code, &spawn_err)) { show_message(GTK_WINDOW(window), GTK_MESSAGE_ERROR, "Update check failed: %s", @@ -202,7 +193,8 @@ static void on_about(GtkWidget *widget, gpointer data) { char *markup = g_strdup_printf( "ProxyBridge\n" "Version %s\n\n" - "Universal proxy client for Linux applications\n\n" + "Universal proxy client for Linux applications\n" + "eBPF backend (needs root, kernel 5.11+ with BTF and cgroup v2)\n\n" "Author: Sourav Kalal / InterceptSuite\n\n" "Website: interceptsuite.com\n" "GitHub: InterceptSuite/ProxyBridge\n\n" @@ -217,162 +209,237 @@ static void on_about(GtkWidget *widget, gpointer data) { gtk_widget_destroy(dialog); } -static void signal_handler(int sig) { - fprintf(stderr, "\nSignal %d received. Stopping ProxyBridge...\n", sig); - ProxyBridge_Stop(); - exit(sig); +typedef enum { ENGINE_STOPPED, ENGINE_STARTING, ENGINE_RUNNING, ENGINE_STOPPING } EngineState; + +static EngineState engine_state = ENGINE_STOPPED; +static GThread *engine_thread = NULL; +static struct { bool start, ok; } engine_job; +static GtkWidget *status_info; +static GtkWidget *start_item, *stop_item; +static guint status_source = 0; +static uint64_t last_conn_count = 0; +static unsigned conn_rate = 0; + +static void update_status_info() { + static const char *names[] = {"Stopped", "Starting...", "Running", "Stopping..."}; + char *text = g_strdup_printf("Engine: %s | Profile: %s | Proxies: %u | Rules: %u | %u conn/s", + names[engine_state], g_active_profile, g_list_length(g_proxies_list), + g_list_length(g_rules_list), conn_rate); + gtk_label_set_text(GTK_LABEL(status_info), text); + g_free(text); + gtk_widget_set_sensitive(start_item, engine_state == ENGINE_STOPPED); + gtk_widget_set_sensitive(stop_item, engine_state == ENGINE_RUNNING); +} + +static void set_engine_state(EngineState state) { + engine_state = state; + update_status_info(); +} + +static gboolean status_tick(gpointer data) { + uint64_t now = gui_conn_event_count(); + conn_rate = (unsigned)(now - last_conn_count); + last_conn_count = now; + update_status_info(); + return G_SOURCE_CONTINUE; +} + +// runs in the main loop (g_unix_signal_add), so no library calls from a real signal context +static gboolean on_unix_signal(gpointer data) { + fprintf(stderr, "\nSignal received. Stopping ProxyBridge...\n"); + gtk_main_quit(); + return G_SOURCE_REMOVE; } static void on_window_destroy(GtkWidget *widget, gpointer data) { - ProxyBridge_Stop(); + if (status_source) g_source_remove(status_source); + status_source = 0; + gui_logs_stop_timers(); gtk_main_quit(); } -int main(int argc, char *argv[]) { - signal(SIGINT, signal_handler); - signal(SIGTERM, signal_handler); - signal(SIGSEGV, signal_handler); +static gboolean engine_job_done(gpointer data) { + g_thread_join(engine_thread); + engine_thread = NULL; + if (!engine_job.start) { + set_engine_state(ENGINE_STOPPED); + gtk_statusbar_push(GTK_STATUSBAR(status_bar), status_context_id, "ProxyBridge Service Stopped."); + } else if (engine_job.ok) { + set_engine_state(ENGINE_RUNNING); + gtk_statusbar_push(GTK_STATUSBAR(status_bar), status_context_id, "ProxyBridge Service Started (eBPF)."); + } else { + set_engine_state(ENGINE_STOPPED); + gtk_statusbar_push(GTK_STATUSBAR(status_bar), status_context_id, "Failed to start ProxyBridge engine. See the Activity Logs tab."); + show_message(GTK_WINDOW(window), GTK_MESSAGE_ERROR, + "Failed to start the ProxyBridge engine.\n\n" + "Open the \"Activity Logs\" tab to see the reason.\n" + "The eBPF backend needs root, a kernel >= 5.11 with BTF and cgroup v2."); + } + return G_SOURCE_REMOVE; +} - if (getuid() != 0) { gtk_init(&argc, &argv); show_message(NULL, GTK_MESSAGE_ERROR, "ProxyBridge must be run as root (sudo)."); return 1; } - setenv("GSETTINGS_BACKEND", "memory", 1); +static gpointer engine_job_thread(gpointer data) { + engine_job.ok = engine_job.start ? ProxyBridge_Start() : ProxyBridge_Stop(); + g_idle_add(engine_job_done, NULL); + return NULL; +} - // load config from file - load_config(); +// Start/Stop can take a second (BPF load, /proc scan), keep them off the UI thread +static void engine_request(bool start) { + if (engine_thread || engine_state != (start ? ENGINE_STOPPED : ENGINE_RUNNING)) return; + engine_job.start = start; + set_engine_state(start ? ENGINE_STARTING : ENGINE_STOPPING); + engine_thread = g_thread_new("engine", engine_job_thread, NULL); +} + +static void on_start_engine(GtkWidget *widget, gpointer data) { + engine_request(true); +} + +static void on_stop_engine(GtkWidget *widget, gpointer data) { + engine_request(false); +} + +// exit path: wait for a pending job, then stop synchronously +static void shutdown_engine() { + if (engine_thread) { + g_thread_join(engine_thread); + engine_thread = NULL; + engine_state = engine_job.start && engine_job.ok ? ENGINE_RUNNING : ENGINE_STOPPED; + } + if (engine_state == ENGINE_RUNNING) ProxyBridge_Stop(); + engine_state = ENGINE_STOPPED; +} + +static GtkWidget *add_menu_item(GtkWidget *menu, const char *label, GCallback cb) { + GtkWidget *item = gtk_menu_item_new_with_label(label); + g_signal_connect(item, "activate", cb, NULL); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item); + return item; +} + +static void add_check_item(GtkWidget *menu, const char *label, bool active, GCallback cb) { + GtkWidget *item = gtk_check_menu_item_new_with_label(label); + gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(item), active); + g_signal_connect(item, "toggled", cb, NULL); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item); +} + +static void add_separator(GtkWidget *menu) { + gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); +} + +static GtkWidget *add_submenu(GtkWidget *menubar, const char *label) { + GtkWidget *item = gtk_menu_item_new_with_label(label); + GtkWidget *menu = gtk_menu_new(); + gtk_menu_item_set_submenu(GTK_MENU_ITEM(item), menu); + gtk_menu_shell_append(GTK_MENU_SHELL(menubar), item); + return menu; +} + +static GtkTextBuffer *add_text_tab(GtkWidget *notebook, const char *title, const char *placeholder, GCallback on_search, GCallback on_clear, GCallback on_pause) { + GtkWidget *tab = gtk_box_new(GTK_ORIENTATION_VERTICAL, 5); + gtk_container_set_border_width(GTK_CONTAINER(tab), 5); + GtkWidget *toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 5); + GtkWidget *search = gtk_search_entry_new(); + gtk_entry_set_placeholder_text(GTK_ENTRY(search), placeholder); + g_signal_connect(search, "search-changed", on_search, NULL); + GtkWidget *clear_btn = gtk_button_new_with_label("Clear Logs"); + g_signal_connect(clear_btn, "clicked", on_clear, NULL); + gtk_box_pack_start(GTK_BOX(toolbar), search, TRUE, TRUE, 0); + gtk_box_pack_start(GTK_BOX(toolbar), clear_btn, FALSE, FALSE, 0); + if (on_pause) { + GtkWidget *pause_btn = gtk_toggle_button_new_with_label("Pause"); + g_signal_connect(pause_btn, "toggled", on_pause, NULL); + gtk_box_pack_start(GTK_BOX(toolbar), pause_btn, FALSE, FALSE, 0); + } + gtk_box_pack_start(GTK_BOX(tab), toolbar, FALSE, FALSE, 0); + + GtkWidget *view = gtk_text_view_new(); + gtk_text_view_set_editable(GTK_TEXT_VIEW(view), FALSE); + gtk_text_view_set_cursor_visible(GTK_TEXT_VIEW(view), FALSE); + GtkTextBuffer *buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(view)); + gtk_text_buffer_create_tag(buffer, "hidden", "invisible", TRUE, NULL); + GtkWidget *scrolled = gtk_scrolled_window_new(NULL, NULL); + gtk_container_add(GTK_CONTAINER(scrolled), view); + gtk_box_pack_start(GTK_BOX(tab), scrolled, TRUE, TRUE, 0); + gtk_notebook_append_page(GTK_NOTEBOOK(notebook), tab, gtk_label_new(title)); + return buffer; +} +int main(int argc, char *argv[]) { + if (getuid() != 0) { + gtk_init(&argc, &argv); + show_message(NULL, GTK_MESSAGE_ERROR, "ProxyBridge must be run as root (sudo)."); + return 1; + } + setenv("GSETTINGS_BACKEND", "memory", 1); + load_config(); gtk_init(&argc, &argv); GtkSettings *settings = gtk_settings_get_default(); if (settings) g_object_set(settings, "gtk-application-prefer-dark-theme", TRUE, NULL); window = gtk_window_new(GTK_WINDOW_TOPLEVEL); - gtk_window_set_title(GTK_WINDOW(window), "ProxyBridge"); + char *win_title = g_strdup_printf("ProxyBridge - %s", g_active_profile); + gtk_window_set_title(GTK_WINDOW(window), win_title); + g_free(win_title); gtk_window_set_default_size(GTK_WINDOW(window), 900, 600); g_signal_connect(window, "destroy", G_CALLBACK(on_window_destroy), NULL); GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); gtk_container_add(GTK_CONTAINER(window), vbox); - // setup menu GtkWidget *menubar = gtk_menu_bar_new(); - GtkWidget *proxy_menu_item = gtk_menu_item_new_with_label("Proxy"); - GtkWidget *proxy_menu = gtk_menu_new(); - GtkWidget *config_item = gtk_menu_item_new_with_label("Proxy Settings"); - GtkWidget *rules_item = gtk_menu_item_new_with_label("Proxy Rules"); - - GtkWidget *log_check_item = gtk_check_menu_item_new_with_label("Enable Traffic Logging"); - gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(log_check_item), g_chk_logging); - g_signal_connect(log_check_item, "toggled", G_CALLBACK(on_log_traffic_toggled), NULL); - - GtkWidget *dns_check_item = gtk_check_menu_item_new_with_label("DNS via Proxy"); - gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(dns_check_item), g_chk_dns); - g_signal_connect(dns_check_item, "toggled", G_CALLBACK(on_dns_proxy_toggled), NULL); - - GtkWidget *exit_item = gtk_menu_item_new_with_label("Exit"); - - g_signal_connect(config_item, "activate", G_CALLBACK(on_proxy_configure), NULL); - g_signal_connect(rules_item, "activate", G_CALLBACK(on_proxy_rules_clicked), NULL); - g_signal_connect(exit_item, "activate", G_CALLBACK(on_window_destroy), NULL); - - gtk_menu_shell_append(GTK_MENU_SHELL(proxy_menu), config_item); - gtk_menu_shell_append(GTK_MENU_SHELL(proxy_menu), rules_item); - gtk_menu_shell_append(GTK_MENU_SHELL(proxy_menu), gtk_separator_menu_item_new()); - gtk_menu_shell_append(GTK_MENU_SHELL(proxy_menu), log_check_item); - gtk_menu_shell_append(GTK_MENU_SHELL(proxy_menu), dns_check_item); - gtk_menu_shell_append(GTK_MENU_SHELL(proxy_menu), gtk_separator_menu_item_new()); - gtk_menu_shell_append(GTK_MENU_SHELL(proxy_menu), exit_item); - gtk_menu_item_set_submenu(GTK_MENU_ITEM(proxy_menu_item), proxy_menu); - - GtkWidget *about_menu_item = gtk_menu_item_new_with_label("About"); - GtkWidget *about_menu = gtk_menu_new(); - GtkWidget *about_child_item = gtk_menu_item_new_with_label("About"); - g_signal_connect(about_child_item, "activate", G_CALLBACK(on_about), NULL); - GtkWidget *update_item = gtk_menu_item_new_with_label("Check for Updates"); - g_signal_connect(update_item, "activate", G_CALLBACK(on_check_update), NULL); - gtk_menu_shell_append(GTK_MENU_SHELL(about_menu), about_child_item); - gtk_menu_shell_append(GTK_MENU_SHELL(about_menu), update_item); - gtk_menu_item_set_submenu(GTK_MENU_ITEM(about_menu_item), about_menu); - - gtk_menu_shell_append(GTK_MENU_SHELL(menubar), proxy_menu_item); - gtk_menu_shell_append(GTK_MENU_SHELL(menubar), about_menu_item); + GtkWidget *proxy_menu = add_submenu(menubar, "Proxy"); + add_menu_item(proxy_menu, "Proxy Settings...", G_CALLBACK(on_proxy_configure)); + add_menu_item(proxy_menu, "Proxy Rules...", G_CALLBACK(on_proxy_rules_clicked)); + add_menu_item(proxy_menu, "Profiles...", G_CALLBACK(on_profiles_clicked)); + add_separator(proxy_menu); + add_check_item(proxy_menu, "Enable Traffic Logging", g_chk_logging, G_CALLBACK(on_log_traffic_toggled)); + add_check_item(proxy_menu, "DNS via Proxy", g_chk_dns, G_CALLBACK(on_dns_proxy_toggled)); + add_separator(proxy_menu); + start_item = add_menu_item(proxy_menu, "Start Engine", G_CALLBACK(on_start_engine)); + stop_item = add_menu_item(proxy_menu, "Stop Engine", G_CALLBACK(on_stop_engine)); + add_menu_item(proxy_menu, "Exit", G_CALLBACK(on_window_destroy)); + GtkWidget *about_menu = add_submenu(menubar, "About"); + add_menu_item(about_menu, "About", G_CALLBACK(on_about)); + add_menu_item(about_menu, "Check for Updates", G_CALLBACK(on_check_update)); gtk_box_pack_start(GTK_BOX(vbox), menubar, FALSE, FALSE, 0); - // tabs GtkWidget *notebook = gtk_notebook_new(); gtk_box_pack_start(GTK_BOX(vbox), notebook, TRUE, TRUE, 0); + conn_buffer = add_text_tab(notebook, "Connections", "Search connections...", G_CALLBACK(on_search_conn_changed), G_CALLBACK(on_clear_conn_clicked), G_CALLBACK(on_pause_conn_toggled)); + log_buffer = add_text_tab(notebook, "Activity Logs", "Search logs...", G_CALLBACK(on_search_log_changed), G_CALLBACK(on_clear_log_clicked), NULL); - // connections tab - GtkWidget *conn_vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 5); - gtk_container_set_border_width(GTK_CONTAINER(conn_vbox), 5); - GtkWidget *conn_toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 5); - GtkWidget *conn_search = gtk_search_entry_new(); - gtk_entry_set_placeholder_text(GTK_ENTRY(conn_search), "Search connections..."); - g_signal_connect(conn_search, "search-changed", G_CALLBACK(on_search_conn_changed), NULL); - GtkWidget *conn_clear_btn = gtk_button_new_with_label("Clear Logs"); - g_signal_connect(conn_clear_btn, "clicked", G_CALLBACK(on_clear_conn_clicked), NULL); - gtk_box_pack_start(GTK_BOX(conn_toolbar), conn_search, TRUE, TRUE, 0); - gtk_box_pack_start(GTK_BOX(conn_toolbar), conn_clear_btn, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(conn_vbox), conn_toolbar, FALSE, FALSE, 0); - conn_view = GTK_TEXT_VIEW(gtk_text_view_new()); - gtk_text_view_set_editable(conn_view, FALSE); - gtk_text_view_set_cursor_visible(conn_view, FALSE); - conn_buffer = gtk_text_view_get_buffer(conn_view); - gtk_text_buffer_create_tag(conn_buffer, "hidden", "invisible", TRUE, NULL); - GtkWidget *scrolled_window = gtk_scrolled_window_new(NULL, NULL); - gtk_container_add(GTK_CONTAINER(scrolled_window), GTK_WIDGET(conn_view)); - gtk_box_pack_start(GTK_BOX(conn_vbox), scrolled_window, TRUE, TRUE, 0); - gtk_notebook_append_page(GTK_NOTEBOOK(notebook), conn_vbox, gtk_label_new("Connections")); - - // logs tab - GtkWidget *log_vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 5); - gtk_container_set_border_width(GTK_CONTAINER(log_vbox), 5); - GtkWidget *log_toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 5); - GtkWidget *log_search = gtk_search_entry_new(); - gtk_entry_set_placeholder_text(GTK_ENTRY(log_search), "Search logs..."); - g_signal_connect(log_search, "search-changed", G_CALLBACK(on_search_log_changed), NULL); - GtkWidget *log_clear_btn = gtk_button_new_with_label("Clear Logs"); - g_signal_connect(log_clear_btn, "clicked", G_CALLBACK(on_clear_log_clicked), NULL); - gtk_box_pack_start(GTK_BOX(log_toolbar), log_search, TRUE, TRUE, 0); - gtk_box_pack_start(GTK_BOX(log_toolbar), log_clear_btn, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(log_vbox), log_toolbar, FALSE, FALSE, 0); - log_view = GTK_TEXT_VIEW(gtk_text_view_new()); - gtk_text_view_set_editable(log_view, FALSE); - gtk_text_view_set_cursor_visible(log_view, FALSE); - log_buffer = gtk_text_view_get_buffer(log_view); - gtk_text_buffer_create_tag(log_buffer, "hidden", "invisible", TRUE, NULL); - GtkWidget *log_scroll = gtk_scrolled_window_new(NULL, NULL); - gtk_container_add(GTK_CONTAINER(log_scroll), GTK_WIDGET(log_view)); - gtk_box_pack_start(GTK_BOX(log_vbox), log_scroll, TRUE, TRUE, 0); - gtk_notebook_append_page(GTK_NOTEBOOK(notebook), log_vbox, gtk_label_new("Activity Logs")); - - // status bar + GtkWidget *status_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); status_bar = gtk_statusbar_new(); status_context_id = gtk_statusbar_get_context_id(GTK_STATUSBAR(status_bar), "Status"); - gtk_box_pack_start(GTK_BOX(vbox), status_bar, FALSE, FALSE, 0); + status_info = gtk_label_new(""); + gtk_widget_set_margin_end(status_info, 10); + gtk_box_pack_start(GTK_BOX(status_box), status_bar, TRUE, TRUE, 0); + gtk_box_pack_end(GTK_BOX(status_box), status_info, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(vbox), status_box, FALSE, FALSE, 0); + gui_logs_init(); - // start ProxyBridge_SetLogCallback(lib_log_callback); ProxyBridge_SetConnectionCallback(lib_connection_callback); ProxyBridge_SetTrafficLoggingEnabled(g_chk_logging); ProxyBridge_SetDnsViaProxy(g_chk_dns); + apply_proxies_to_lib(); + apply_rules_to_lib(); - if (ProxyBridge_Start()) { - // apply config - ProxyBridge_SetProxyConfig(g_proxy_type, g_proxy_ip, g_proxy_port, g_proxy_user, g_proxy_pass); - - // restore rules - for (GList *l = g_rules_list; l != NULL; l = l->next) { - RuleData *r = (RuleData *)l->data; - r->id = ProxyBridge_AddRule(r->process_name, r->target_hosts, r->target_ports, r->protocol, r->action); - if (!r->enabled) ProxyBridge_DisableRule(r->id); - } - - gtk_statusbar_push(GTK_STATUSBAR(status_bar), status_context_id, "ProxyBridge Service Started."); - } else { - gtk_statusbar_push(GTK_STATUSBAR(status_bar), status_context_id, "Failed to start ProxyBridge engine."); - } + g_unix_signal_add(SIGINT, on_unix_signal, NULL); + g_unix_signal_add(SIGTERM, on_unix_signal, NULL); gtk_widget_show_all(window); + update_status_info(); + status_source = g_timeout_add_seconds(1, status_tick, NULL); + engine_request(true); gtk_main(); + shutdown_engine(); + gui_logs_free(); + config_free_all(); return 0; } diff --git a/Linux/gui/tests/config_test.c b/Linux/gui/tests/config_test.c new file mode 100644 index 00000000..ffd66599 --- /dev/null +++ b/Linux/gui/tests/config_test.c @@ -0,0 +1,133 @@ +// root-free test of gui_config.c: profiles, migration, round trip, leak/heap stress +// build: see run_tests.sh +#include "gui.h" +#include + +GList *g_proxies_list = NULL; +char *g_default_proxy = NULL; +GList *g_rules_list = NULL; +bool g_chk_logging = true; +bool g_chk_dns = true; + +void free_proxy_data(ProxyData *p) { + g_free(p->name); g_free(p->host); g_free(p->username); g_free(p->password); g_free(p); +} + +void free_rule_data(RuleData *r) { + g_free(r->process_name); g_free(r->target_hosts); g_free(r->target_ports); g_free(r->proxy_name); g_free(r); +} + +static int failures = 0; +#define CHECK(c) do { if (!(c)) { printf("FAIL line %d: %s\n", __LINE__, #c); failures++; } } while (0) + +static void write_file(const char *dir, const char *content) { + char *path = g_strdup_printf("%s/config.ini", dir); + g_file_set_contents(path, content, -1, NULL); + g_free(path); +} + +// 0 when the allocator is intercepted (ASan), LeakSanitizer covers that case +static size_t heap_used() { + return mallinfo2().uordblks; +} + +static void add_content(const char *proxy_name) { + ProxyData *px = g_malloc0(sizeof(ProxyData)); + px->name = g_strdup(proxy_name); px->host = g_strdup("github.com"); px->port = 8080; + px->username = g_strdup(""); px->password = g_strdup("s3cr|et"); + g_proxies_list = g_list_append(g_proxies_list, px); + g_default_proxy = g_strdup(proxy_name); + RuleData *r = g_malloc0(sizeof(RuleData)); + r->process_name = g_strdup("curl"); r->target_hosts = g_strdup("*.github.com"); r->target_ports = g_strdup("*"); + r->action = RULE_ACTION_PROXY; r->proxy_name = g_strdup(proxy_name); r->enabled = true; + g_rules_list = g_list_append(g_rules_list, r); +} + +int main() { + char *dir = g_dir_make_tmp("pbcfg-XXXXXX", NULL); + CHECK(dir != NULL); + setenv("PROXYBRIDGE_CONFIG_DIR", dir, 1); + + load_config(); + CHECK(g_list_length(g_profiles) == 1 && strcmp(g_active_profile, "Default") == 0); + config_free_all(); + + // old single-proxy format + write_file(dir, "[SETTINGS]\nip=1.2.3.4\nport=1080\ntype=1\nuser=u\npass=p|w\nlogging=0\ndns=1\n[RULES]\n3|2|0|1|firefox|*|443;80|\n"); + load_config(); + CHECK(strcmp(g_active_profile, "Default") == 0); + CHECK(g_list_length(g_proxies_list) == 1); + CHECK(strcmp(((ProxyData *)g_proxies_list->data)->host, "1.2.3.4") == 0); + CHECK(strcmp(g_default_proxy, "default") == 0); + CHECK(g_list_length(g_rules_list) == 1 && strcmp(((RuleData *)g_rules_list->data)->target_ports, "443;80") == 0); + CHECK(!g_chk_logging); + + CHECK(profile_create("Work", false) != NULL); + CHECK(profile_create("Work", false) == NULL); + CHECK(profile_create("", false) == NULL); + Profile *dup = profile_create("Copy", true); + CHECK(dup && g_list_length(dup->proxies) == 1 && g_list_length(dup->rules) == 1); + CHECK(profile_rename(profile_find("Copy"), "Home")); + CHECK(!profile_rename(profile_find("Home"), "Work")); + CHECK(profile_activate("Work")); + CHECK(g_proxies_list == NULL && g_rules_list == NULL); + add_content("p 1|x"); + g_chk_dns = false; + save_config(); + + char *path = g_strdup_printf("%s/config.ini", dir); + struct stat st; + CHECK(stat(path, &st) == 0 && (st.st_mode & 0777) == 0600); + g_free(path); + + config_free_all(); + load_config(); + CHECK(g_list_length(g_profiles) == 3 && strcmp(g_active_profile, "Work") == 0); + CHECK(strcmp(((ProxyData *)g_proxies_list->data)->name, "p 1|x") == 0); + CHECK(strcmp(((ProxyData *)g_proxies_list->data)->password, "s3cr|et") == 0); + CHECK(strcmp(((RuleData *)g_rules_list->data)->target_hosts, "*.github.com") == 0); + CHECK(!g_chk_dns && !g_chk_logging); + CHECK(profile_find("Home") && g_list_length(profile_find("Home")->rules) == 1); + + CHECK(profile_delete(profile_find("Work"))); + CHECK(strcmp(g_active_profile, "Default") == 0 && g_list_length(g_rules_list) == 1); + CHECK(profile_delete(profile_find("Home"))); + CHECK(!profile_delete(profile_find("Default"))); + CHECK(g_list_length(g_profiles) == 1); + save_config(); + config_free_all(); + + // stress: heap must return to baseline + load_config(); + for (int i = 0; i < 200; i++) { // warm up allocator caches + Profile *p = profile_create("w", true); + if (p) profile_delete(p); + } + size_t base = heap_used(); + for (int i = 0; i < 20000; i++) { + char *a = g_strdup_printf("p%d", i), *b = g_strdup_printf("q%d", i), *c = g_strdup_printf("c%d", i); + CHECK(profile_create(a, false) != NULL); + CHECK(profile_activate(a)); + add_content("px"); + CHECK(profile_rename(profile_find(a), b)); + CHECK(profile_create(c, true) != NULL); + save_config(); + if (i % 1000 == 0) { + config_free_all(); + load_config(); + } + CHECK(profile_activate("Default")); + CHECK(profile_delete(profile_find(b))); + CHECK(profile_delete(profile_find(c))); + g_free(a); g_free(b); g_free(c); + } + size_t after = heap_used(); + printf("heap before=%zu after=%zu\n", base, after); + CHECK(base == 0 || after < base + 64 * 1024); + CHECK(g_list_length(g_profiles) == 1); + + config_free_all(); + g_free(dir); + if (!failures) printf("config_test: all passed\n"); + return failures ? 1 : 0; +} diff --git a/Linux/gui/tests/events_test.c b/Linux/gui/tests/events_test.c new file mode 100644 index 00000000..f3736dc1 --- /dev/null +++ b/Linux/gui/tests/events_test.c @@ -0,0 +1,106 @@ +// event ring: ordering, drop accounting, multi-producer stress (run under ASan and TSan too) +#include "gui_events.h" +#include +#include +#include +#include +#include + +#define PRODUCERS 8 +#define PER_PRODUCER 200000 + +static int failures = 0; +#define CHECK(c) do { if (!(c)) { printf("FAIL line %d: %s\n", __LINE__, #c); failures++; } } while (0) + +static EventRing *ring; + +static void *producer(void *arg) { + int id = (int)(intptr_t)arg; + char buf[64]; + for (int i = 0; i < PER_PRODUCER; i++) { + snprintf(buf, sizeof(buf), "%d:%d", id, i); + event_ring_push(ring, buf); + } + return NULL; +} + +// "id:seq" without strto* +static int parse_event(const char *s, int *id, long *seq) { + int i = 0; + *id = 0; + for (; s[i] >= '0' && s[i] <= '9'; i++) *id = *id * 10 + (s[i] - '0'); + if (s[i++] != ':') return 0; + *seq = 0; + for (; s[i] >= '0' && s[i] <= '9'; i++) *seq = *seq * 10 + (s[i] - '0'); + return s[i] == '\n' || s[i] == '\0'; +} + +int main() { + // fifo + drop accounting on a tiny ring + EventRing *small = event_ring_new(4, 16); + char out[256]; + uint64_t dropped = 0; + char buf[16]; + for (int i = 0; i < 10; i++) { snprintf(buf, sizeof(buf), "e%d", i); event_ring_push(small, buf); } + CHECK(event_ring_drain(small, out, sizeof(out), 100, &dropped) == 4); + CHECK(dropped == 6 && strcmp(out, "e6\ne7\ne8\ne9\n") == 0); + CHECK(event_ring_drain(small, out, sizeof(out), 100, &dropped) == 0 && dropped == 0 && out[0] == '\0'); + event_ring_push(small, "0123456789abcdefXYZ"); + CHECK(event_ring_drain(small, out, sizeof(out), 100, &dropped) == 1 && strcmp(out, "0123456789abcde\n") == 0); + event_ring_push(small, "a"); event_ring_push(small, "b"); event_ring_push(small, "c"); + CHECK(event_ring_drain(small, out, 6, 100, &dropped) == 2 && strcmp(out, "a\nb\n") == 0); + CHECK(event_ring_drain(small, out, sizeof(out), 1, &dropped) == 1 && strcmp(out, "c\n") == 0); + event_ring_free(small); + CHECK(event_ring_new(0, 8) == NULL); + + // multi producer stress with a slow consumer + ring = event_ring_new(4096, 512); + pthread_t th[PRODUCERS]; + for (long i = 0; i < PRODUCERS; i++) pthread_create(&th[i], NULL, producer, (void *)(intptr_t)i); + + static char big[256 * 1024]; + long last[PRODUCERS]; + for (int i = 0; i < PRODUCERS; i++) last[i] = -1; + uint64_t delivered = 0, reported_dropped = 0; + int done = 0; + for (int round = 0; ; round++) { + uint64_t d = 0; + size_t n = event_ring_drain(ring, big, sizeof(big), 2000, &d); + reported_dropped += d; + delivered += n; + size_t seen = 0; + for (char *p = big; *p; ) { + char *nl = strchr(p, '\n'); + int id = 0; long seq = 0; + CHECK(nl && parse_event(p, &id, &seq) && id >= 0 && id < PRODUCERS); + if (!nl || id < 0 || id >= PRODUCERS) break; + CHECK(seq > last[id]); + last[id] = seq; + seen++; + p = nl + 1; + } + CHECK(seen == n); + if (round % 4 == 0) usleep(1000); + if (!done) { + done = 1; + uint64_t pushed; + event_ring_stats(ring, &pushed, NULL, NULL); + done = pushed == (uint64_t)PRODUCERS * PER_PRODUCER; + } else if (n == 0) { + break; + } + } + for (int i = 0; i < PRODUCERS; i++) pthread_join(th[i], NULL); + + uint64_t pushed, del, drop; + event_ring_stats(ring, &pushed, &del, &drop); + printf("pushed=%llu delivered=%llu dropped=%llu\n", (unsigned long long)pushed, (unsigned long long)del, (unsigned long long)drop); + CHECK(pushed == (uint64_t)PRODUCERS * PER_PRODUCER); + CHECK(pushed == del + drop); + CHECK(del == delivered); + CHECK(drop == reported_dropped); + event_ring_free(ring); + + if (!failures) printf("events_test: all passed\n"); + return failures ? 1 : 0; +} diff --git a/Linux/gui/tests/json_test.c b/Linux/gui/tests/json_test.c new file mode 100644 index 00000000..e1d0ef00 --- /dev/null +++ b/Linux/gui/tests/json_test.c @@ -0,0 +1,83 @@ +// hostile-input test of the rules json parser/writer (gui_json.c) +#include "gui.h" + +void free_rule_data(RuleData *r) { + g_free(r->process_name); g_free(r->target_hosts); g_free(r->target_ports); g_free(r->proxy_name); g_free(r); +} + +static int failures = 0; +#define CHECK(c) do { if (!(c)) { printf("FAIL line %d: %s\n", __LINE__, #c); failures++; } } while (0) + +typedef struct { int n, named, disabled; RuleAction last_action; RuleProtocol last_proto; size_t max_len; } Stats; + +static void cb(const RuleData *r, int index, void *ud) { + Stats *s = (Stats *)ud; + s->n++; + if (r->process_name) { + s->named++; + if (strlen(r->process_name) > s->max_len) s->max_len = strlen(r->process_name); + } + if (!r->enabled) s->disabled++; + s->last_action = r->action; + s->last_proto = r->protocol; +} + +static Stats parse(const char *json) { + Stats s = {0}; + rules_from_json(json, cb, &s); + return s; +} + +int main() { + Stats s = parse("[{\"processNames\":\"a\",\"action\":\"DIRECT\",\"protocol\":\"udp\",\"enabled\":false}]"); + CHECK(s.n == 1 && s.named == 1 && s.disabled == 1 && s.last_action == RULE_ACTION_DIRECT && s.last_proto == RULE_PROTOCOL_UDP); + + // macOS style: proxy uuid as action, ruleProtocol/ruleAction keys + s = parse("{\"processNames\":\"a\",\"ruleAction\":\"6F1C-uuid\",\"ruleProtocol\":\"TCP\"}"); + CHECK(s.n == 1 && s.last_action == RULE_ACTION_PROXY && s.last_proto == RULE_PROTOCOL_TCP && s.disabled == 0); + + // hostile small inputs must not crash + const char *bad[] = {"", "{", "}", "{{{{", "{\"processNames\":", "{\"processNames\":\"unterminated", "{\"processNames\":5}", + "{\"processNames\":null,\"enabled\":\"yes\"}", "{\"a\":\"}\"}", "[{\"processNames\":\"x\\", "{\"processNames\":\"x\\\"}", NULL}; + for (int i = 0; bad[i]; i++) parse(bad[i]); + s = parse("{\"processNames\":\"a}b\"}"); + CHECK(s.n == 1 && s.max_len == 3); + s = parse("{\"processNames\":5}"); + CHECK(s.n == 1 && s.named == 0); + + // huge string + size_t big = 20 * 1024 * 1024; + char *json = g_malloc(big + 64); + strcpy(json, "[{\"processNames\":\""); + memset(json + strlen(json), 'A', big); + strcpy(json + 18 + big, "\"}]"); + s = parse(json); + CHECK(s.n == 1 && s.max_len == big); + g_free(json); + + GString *deep = g_string_new(""); + for (int i = 0; i < 1000000; i++) g_string_append_c(deep, '{'); + parse(deep->str); + g_string_free(deep, TRUE); + + // 100k rules round trip through the writer + GList *rules = NULL; + for (int i = 0; i < 100000; i++) { + RuleData *r = g_malloc0(sizeof(RuleData)); + r->process_name = g_strdup_printf("proc\"%d\\", i); + r->target_hosts = g_strdup("*.example.com;10.0.0.0/8"); + r->target_ports = g_strdup("80;443"); + r->protocol = i % 3; r->action = i % 3; r->enabled = i % 2; + r->proxy_name = i % 3 == 0 ? g_strdup("px") : NULL; + r->selected = true; + rules = g_list_append(rules, r); + } + char *out = rules_to_json(rules, true); + s = parse(out); + CHECK(s.n == 100000 && s.named == 100000 && s.disabled == 50000); + g_free(out); + g_list_free_full(rules, (GDestroyNotify)free_rule_data); + + if (!failures) printf("json_test: all passed\n"); + return failures ? 1 : 0; +} diff --git a/Linux/gui/tests/run_tests.sh b/Linux/gui/tests/run_tests.sh new file mode 100755 index 00000000..c09b9cf7 --- /dev/null +++ b/Linux/gui/tests/run_tests.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# usage: tests/run_tests.sh [outdir] (root-free) +set -e +cd "$(dirname "$0")/.." +OUT="${1:-$(mktemp -d)}" +CF="-Wall -Wextra -Wno-unused-parameter -g -fsanitize=address,undefined -I../src -I. -D_GNU_SOURCE $(pkg-config --cflags gtk+-3.0)" +LF="$(pkg-config --libs gtk+-3.0)" +gcc $CF tests/config_test.c gui_config.c -o "$OUT/config_test" $LF +gcc $CF tests/json_test.c gui_json.c -o "$OUT/json_test" $LF +gcc -O1 -I../src -I. -D_GNU_SOURCE $(pkg-config --cflags gtk+-3.0) tests/config_test.c gui_config.c -o "$OUT/config_plain" $LF +"$OUT/config_plain" +ASAN_OPTIONS=detect_leaks=1 "$OUT/config_test" +ASAN_OPTIONS=detect_leaks=1 "$OUT/json_test" +gcc -O2 -Wall -Wextra -I. tests/events_test.c gui_events.c -o "$OUT/events_plain" -pthread +time "$OUT/events_plain" +gcc -g -Wall -Wextra -fsanitize=address,undefined -I. tests/events_test.c gui_events.c -o "$OUT/events_asan" -pthread +ASAN_OPTIONS=detect_leaks=1 "$OUT/events_asan" +gcc -g -O1 -Wall -Wextra -fsanitize=thread -I. tests/events_test.c gui_events.c -o "$OUT/events_tsan" -pthread +setarch "$(uname -m)" -R "$OUT/events_tsan" diff --git a/Linux/setup.sh b/Linux/setup.sh index a365a16d..6e5af115 100644 --- a/Linux/setup.sh +++ b/Linux/setup.sh @@ -53,36 +53,36 @@ install_dependencies() { ubuntu|debian|linuxmint|pop|elementary|zorin|kali|raspbian|mx|antix|deepin|lmde) echo "Using apt package manager..." apt-get update -qq - # libgtk-3-0 is for runtime, libnetfilter-queue1 for functionality - apt-get install -y libnetfilter-queue1 libnfnetlink0 iptables libgtk-3-0 + # libgtk-3-0 is for the GUI, libelf+zlib are what the statically linked libbpf needs + apt-get install -y libelf1 zlib1g libgtk-3-0 ;; fedora) echo "Using dnf package manager..." - dnf install -y libnetfilter_queue libnfnetlink iptables gtk3 + dnf install -y elfutils-libelf zlib gtk3 ;; rhel|centos|rocky|almalinux) echo "Using yum package manager..." - yum install -y libnetfilter_queue libnfnetlink iptables gtk3 + yum install -y elfutils-libelf zlib gtk3 ;; arch|manjaro|endeavouros|garuda) echo "Using pacman package manager..." - pacman -Sy --noconfirm libnetfilter_queue libnfnetlink iptables gtk3 + pacman -Sy --noconfirm libelf zlib gtk3 ;; opensuse*|sles) echo "Using zypper package manager..." - zypper install -y libnetfilter_queue1 libnfnetlink0 iptables gtk3 + zypper install -y libelf1 libz1 gtk3 ;; void) echo "Using xbps package manager..." - xbps-install -Sy libnetfilter_queue libnfnetlink iptables gtk3 + xbps-install -Sy libelf zlib gtk3 ;; *) echo "WARNING: Unknown distribution '$DISTRO' (family: '$DISTRO_LIKE')" echo "" echo "Please manually install the following packages:" - echo " Debian/Ubuntu: sudo apt install libnetfilter-queue1 libnfnetlink0 iptables libgtk-3-0" - echo " Fedora: sudo dnf install libnetfilter_queue libnfnetlink iptables gtk3" - echo " Arch: sudo pacman -S libnetfilter_queue libnfnetlink iptables gtk3" + echo " Debian/Ubuntu: sudo apt install libelf1 zlib1g libgtk-3-0" + echo " Fedora: sudo dnf install elfutils-libelf zlib gtk3" + echo " Arch: sudo pacman -S libelf zlib gtk3" echo "" read -p "Continue anyway? (y/n) " -n 1 -r echo @@ -229,8 +229,7 @@ main() { fi echo " sudo ProxyBridge --proxy socks5://IP:PORT --rule \"app:*:*:TCP:PROXY\"" echo "" - echo "For cleanup after crash:" - echo " sudo ProxyBridge --cleanup" + echo "No cleanup is needed after a crash: the kernel detaches the eBPF programs automatically." echo "" } diff --git a/Linux/src/Makefile b/Linux/src/Makefile index 29effb09..ab841116 100644 --- a/Linux/src/Makefile +++ b/Linux/src/Makefile @@ -1,35 +1,82 @@ -CC = gcc -CFLAGS = -Wall -Wextra -O3 -fPIC -D_GNU_SOURCE \ - -fstack-protector-strong \ - -D_FORTIFY_SOURCE=2 \ - -fPIE \ - -Wformat -Wformat-security \ - -Werror=format-security \ - -fno-strict-overflow \ - -fno-delete-null-pointer-checks \ - -fwrapv -LDFLAGS = -shared -Wl,-z,relro,-z,now -Wl,-z,noexecstack -s -LIBS = -lpthread -lnetfilter_queue -lnfnetlink - -TARGET = libproxybridge.so -SOURCES = ProxyBridge.c -OBJECTS = $(SOURCES:.c=.o) +include ../common.mk + +CLANG ?= clang +BPFTOOL ?= bpftool +MULTIARCH := $(shell $(CC) -print-multiarch) + +# BUILD_DIR keeps every generated file out of the source tree; build.sh points it at a temporary directory. +BUILD_DIR ?= . + +# libbpf is linked statically from a pinned release built as position-independent code, so the library does not depend on the +# distribution's libbpf (Ubuntu 22.04 ships 0.5, and distribution libbpf.a is not PIC). With LIBBPF_DIR unset the system +# libbpf is used instead. +LIBBPF_VERSION ?= 1.6.3 +LIBBPF_SHA256 ?= 989ed3c1a3db8ff0f7c08dd43953c6b9d0c3ac252653a48d566aaedf98bc80ca +LIBBPF_RECIPE ?= 2 +LIBBPF_CACHE ?= $(or $(XDG_CACHE_HOME),$(HOME)/.cache)/proxybridge/libbpf-$(LIBBPF_VERSION)-r$(LIBBPF_RECIPE) +LIBBPF_DIR ?= + +ifneq ($(LIBBPF_DIR),) +LIBBPF_INCLUDE = -I$(LIBBPF_DIR)/include +LIBBPF_LIBS = $(LIBBPF_DIR)/lib/libbpf.a -lelf -lz +else +LIBBPF_LIBS = -lbpf +endif + +CFLAGS = $(CFLAGS_COMMON) -fPIC -fvisibility=hidden -D_GNU_SOURCE -I$(BUILD_DIR) $(LIBBPF_INCLUDE) +LDFLAGS = -shared $(HARDEN_LDFLAGS) -Wl,-z,defs -Wl,--version-script=$(CURDIR)/exports.map $(STRIP_FLAG) +LIBS = -lpthread $(LIBBPF_LIBS) + +SOURCES = ProxyBridge.c pb_rules.c pb_dns.c pb_proc.c pb_bpf.c pb_relay.c +OBJECTS = $(addprefix $(BUILD_DIR)/,$(SOURCES:.c=.o)) +TARGET = $(BUILD_DIR)/libproxybridge.so +BPF_OBJ = $(BUILD_DIR)/proxybridge.bpf.o +SKEL = $(BUILD_DIR)/proxybridge.skel.h all: $(TARGET) -$(TARGET): $(OBJECTS) - $(CC) $(LDFLAGS) -o $@ $^ $(LIBS) - @echo "Stripping debug symbols..." - strip --strip-unneeded $@ +$(BUILD_DIR): + mkdir -p $@ + +$(BPF_OBJ): bpf/proxybridge.bpf.c bpf/pb_bpf_shared.h | $(BUILD_DIR) + $(CLANG) -O2 -g -target bpf -D__BPF__ $(LIBBPF_INCLUDE) -I/usr/include/$(MULTIARCH) -c $< -o $@ -%.o: %.c +$(SKEL): $(BPF_OBJ) + $(BPFTOOL) gen skeleton $< > $@.tmp && mv $@.tmp $@ + +$(BUILD_DIR)/pb_bpf.o: $(SKEL) + +$(TARGET): $(OBJECTS) exports.map + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(OBJECTS) $(LIBS) + +$(BUILD_DIR)/%.o: %.c pb_internal.h ProxyBridge.h | $(BUILD_DIR) $(CC) $(CFLAGS) -c $< -o $@ +test-rules: tests/rules_test.c tests/dns_test.c pb_rules.c pb_rules.h pb_dns.c pb_dns.h | $(BUILD_DIR) + $(CC) -Wall -Wextra -g -fsanitize=address,undefined -D_GNU_SOURCE tests/rules_test.c pb_rules.c -o $(BUILD_DIR)/rules_test && $(BUILD_DIR)/rules_test + $(CC) -Wall -Wextra -g -fsanitize=address,undefined -D_GNU_SOURCE tests/dns_test.c pb_dns.c -o $(BUILD_DIR)/dns_test && $(BUILD_DIR)/dns_test + +# Downloads, verifies and builds libbpf (cached); prints nothing. `make -s libbpf-path` prints the directory to use as LIBBPF_DIR. +libbpf-static: $(LIBBPF_CACHE)/lib/libbpf.a + +$(LIBBPF_CACHE)/lib/libbpf.a: + @set -e; work=$$(mktemp -d); cd "$$work"; \ + curl -fsSL -o libbpf.tar.gz https://github.com/libbpf/libbpf/archive/refs/tags/v$(LIBBPF_VERSION).tar.gz; \ + echo "$(LIBBPF_SHA256) libbpf.tar.gz" | sha256sum -c --quiet -; \ + tar xzf libbpf.tar.gz; \ + $(MAKE) -s -C libbpf-$(LIBBPF_VERSION)/src BUILD_STATIC_ONLY=y OBJDIR="$$work/obj" CFLAGS="-O2 -fPIC -include $(CURDIR)/glibc_compat.h" \ + DESTDIR="$$work/root" PREFIX=/ LIBDIR=/lib install >/dev/null; \ + mkdir -p "$(LIBBPF_CACHE)"; cp -r "$$work/root/include" "$$work/root/lib" "$(LIBBPF_CACHE)/"; \ + cd /; rm -rf "$$work" + +libbpf-path: + @echo $(LIBBPF_CACHE) + clean: - rm -f $(OBJECTS) $(TARGET) + rm -f $(OBJECTS) $(TARGET) $(BPF_OBJ) $(SKEL) $(BUILD_DIR)/rules_test $(BUILD_DIR)/dns_test install: $(TARGET) install -m 0755 $(TARGET) /usr/local/lib/ ldconfig -.PHONY: all clean install +.PHONY: all clean install test-rules libbpf-static libbpf-path diff --git a/Linux/src/ProxyBridge.c b/Linux/src/ProxyBridge.c index 6a008c23..2d19db13 100644 --- a/Linux/src/ProxyBridge.c +++ b/Linux/src/ProxyBridge.c @@ -1,3166 +1,720 @@ +#include +#include +#include +#include +#include +#include #include #include #include -#include -#include -#include -#include -#include -#include -#include -#include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include +#include #include "ProxyBridge.h" +#include "pb_bpf.h" +#include "pb_dns.h" +#include "pb_internal.h" +#include "pb_proc.h" +#include "pb_rules.h" + +#define MAX_PROXIES 255 +#define MAINT_PERIOD_S 2 +#define HOSTS_PATH "/etc/hosts" + +static struct { + pthread_rwlock_t lock; + struct pb_rule *rules; size_t nrules, caprules; + struct pb_rule_view *views; size_t nviews; + struct pb_proxy proxies[MAX_PROXIES]; size_t nproxies; + uint32_t next_rule_id, next_proxy_id, default_proxy; + bool running; + atomic_bool dns_via_proxy, traffic_logging, has_domain_rules; + pthread_t maint; + bool maint_started, maint_stop, maint_kick; + pthread_mutex_t maint_mu; + pthread_cond_t maint_cv; + uint64_t warned[16]; size_t nwarned; + LogCallback log_cb; + ConnectionCallback conn_cb; +} G = { .lock = PTHREAD_RWLOCK_INITIALIZER, .maint_mu = PTHREAD_MUTEX_INITIALIZER, .maint_cv = PTHREAD_COND_INITIALIZER, + .next_rule_id = 1, .next_proxy_id = 1, .dns_via_proxy = true }; + +static pthread_mutex_t cb_lock = PTHREAD_MUTEX_INITIALIZER; + +static void set_err(char *err, size_t n, const char *fmt, ...) __attribute__((format(printf, 3, 4))); +static void set_err(char *err, size_t n, const char *fmt, ...) +{ + if (!err || !n) return; + va_list ap; + va_start(ap, fmt); + vsnprintf(err, n, fmt, ap); + va_end(ap); +} + +void pb_core_log(const char *fmt, ...) +{ + char buf[1024]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof buf, fmt, ap); + va_end(ap); + pthread_mutex_lock(&cb_lock); + if (G.log_cb) G.log_cb(buf); + pthread_mutex_unlock(&cb_lock); +} + +static void strip_deleted(char *p) +{ + static const char suffix[] = " (deleted)"; + size_t n = strlen(p), s = sizeof suffix - 1; + if (n > s && !strcmp(p + n - s, suffix)) p[n - s] = 0; +} + +static bool exe_path_of_pid(uint32_t pid, char *out, size_t n) +{ + char link[64]; + snprintf(link, sizeof link, "/proc/%u/exe", pid); + ssize_t r = readlink(link, out, n - 1); + if (r <= 0) { out[0] = 0; return false; } + out[r] = 0; + strip_deleted(out); + return true; +} -#define LOCAL_PROXY_PORT 34010 -#define LOCAL_UDP_RELAY_PORT 34011 -#define MAX_PROCESS_NAME 256 -#define PID_CACHE_SIZE 1024 -#define PID_CACHE_TTL_MS 1000 -#define NUM_PACKET_THREADS 4 -#define CONNECTION_HASH_SIZE 256 -#define SOCKS5_BUFFER_SIZE 1024 -#define HTTP_BUFFER_SIZE 1024 -#define LOG_BUFFER_SIZE 1024 - -// safe way to run commands without shell injection issues -static int run_command_v(const char *cmd_path, char *const argv[]) { - pid_t pid = fork(); - if (pid == -1) { - return -1; - } else if (pid == 0) { - // child process - // send output to /dev/null so it doesnt clutter - int fd = open("/dev/null", O_WRONLY); - if (fd >= 0) { - dup2(fd, STDOUT_FILENO); - dup2(fd, STDERR_FILENO); - close(fd); +static void emit_connection(uint32_t pid, const char *comm, int family, const uint8_t addr[16], uint16_t port, int action, uint32_t proxy_id) +{ + if (!atomic_load(&G.traffic_logging)) return; + char path[4096], dest[INET6_ADDRSTRLEN + 280], info[400]; + const char *name = comm && *comm ? comm : "unknown"; + if (exe_path_of_pid(pid, path, sizeof path)) { + const char *base = strrchr(path, '/'); + name = base ? base + 1 : path; + } + if (family == AF_INET) { + inet_ntop(AF_INET, addr + 12, dest, sizeof dest); + uint32_t ip = ((uint32_t)addr[12] << 24) | ((uint32_t)addr[13] << 16) | ((uint32_t)addr[14] << 8) | addr[15]; + char domain[1][256]; + if (pb_dns_lookup(ip, domain, 1) == 1) { + size_t l = strlen(dest); + snprintf(dest + l, sizeof dest - l, " (%.*s)", (int)(sizeof dest - l - 4), domain[0]); } - execvp(cmd_path, argv); - _exit(127); // command not found or no perms } else { - // parent waits for child - int status; - waitpid(pid, &status, 0); - if (WIFEXITED(status)) { - return WEXITSTATUS(status); - } - return -1; + inet_ntop(AF_INET6, addr, dest, sizeof dest); } + struct pb_proxy p; + if (action == PB_ACT_PROXY && pb_core_get_proxy(proxy_id, &p) == 0) + snprintf(info, sizeof info, "%s %s:%u", p.type == PROXY_TYPE_SOCKS5 ? "SOCKS5" : "HTTP", p.host, p.port); + else + snprintf(info, sizeof info, "%s", action == PB_ACT_PROXY ? "PROXY" : action == PB_ACT_BLOCK ? "BLOCK" : "DIRECT"); + pthread_mutex_lock(&cb_lock); + if (G.conn_cb) G.conn_cb(name, pid, dest, port, info); + pthread_mutex_unlock(&cb_lock); } -// run iptables commands easier -static int run_iptables_cmd(const char *arg1, const char *arg2, const char *arg3, const char *arg4, const char *arg5, const char *arg6, const char *arg7, const char *arg8, const char *arg9, const char *arg10, const char *arg11, const char *arg12, const char *arg13, const char *arg14) { - // build argv array skipping null args - const char *argv[17]; - int i = 0; - argv[i++] = "iptables"; - if (arg1) argv[i++] = arg1; - if (arg2) argv[i++] = arg2; - if (arg3) argv[i++] = arg3; - if (arg4) argv[i++] = arg4; - if (arg5) argv[i++] = arg5; - if (arg6) argv[i++] = arg6; - if (arg7) argv[i++] = arg7; - if (arg8) argv[i++] = arg8; - if (arg9) argv[i++] = arg9; - if (arg10) argv[i++] = arg10; - if (arg11) argv[i++] = arg11; - if (arg12) argv[i++] = arg12; - if (arg13) argv[i++] = arg13; - if (arg14) argv[i++] = arg14; - argv[i] = NULL; - - return run_command_v("iptables", (char **)argv); +static void on_bpf_event(const struct pb_event *e) +{ + char comm[17]; + memcpy(comm, e->comm, 16); + comm[16] = 0; + emit_connection(e->pid, comm, e->family, e->addr, e->port, e->action, e->proxy_id); } -// convert string to int safely -static int safe_atoi(const char *str) { - if (!str) return 0; - char *endptr; - long val = strtol(str, &endptr, 10); - if (endptr == str) return 0; - return (int)val; +void pb_core_flow_event(const struct pb_orig *o, int action) +{ + emit_connection(o->pid, NULL, o->family, o->addr, o->port, action, o->proxy_id); } - -typedef struct PROCESS_RULE { - uint32_t rule_id; - char process_name[MAX_PROCESS_NAME]; - char *target_hosts; - char *target_ports; - RuleProtocol protocol; - RuleAction action; - bool enabled; - struct PROCESS_RULE *next; -} PROCESS_RULE; - -#define SOCKS5_VERSION 0x05 -#define SOCKS5_CMD_CONNECT 0x01 -#define SOCKS5_CMD_UDP_ASSOCIATE 0x03 -#define SOCKS5_ATYP_IPV4 0x01 -#define SOCKS5_AUTH_NONE 0x00 - -typedef struct CONNECTION_INFO { - uint16_t src_port; - bool is_udp; // protocol - a TCP and a UDP flow may share a local port - uint32_t src_ip; - uint32_t orig_dest_ip; - uint16_t orig_dest_port; - bool is_tracked; - uint64_t last_activity; - struct CONNECTION_INFO *next; -} CONNECTION_INFO; - -typedef struct LOGGED_CONNECTION { - uint32_t pid; - uint32_t dest_ip; - uint16_t dest_port; - RuleAction action; - struct LOGGED_CONNECTION *next; -} LOGGED_CONNECTION; - -typedef struct PID_CACHE_ENTRY { - uint32_t src_ip; - uint16_t src_port; - uint32_t pid; - uint64_t timestamp; - bool is_udp; - struct PID_CACHE_ENTRY *next; -} PID_CACHE_ENTRY; - -static CONNECTION_INFO *connection_hash_table[CONNECTION_HASH_SIZE] = {NULL}; -static LOGGED_CONNECTION *logged_connections = NULL; -static PROCESS_RULE *rules_list = NULL; -static uint32_t g_next_rule_id = 1; -static pthread_rwlock_t conn_lock = PTHREAD_RWLOCK_INITIALIZER; // read-heavy connection hash -static pthread_rwlock_t rules_lock = PTHREAD_RWLOCK_INITIALIZER; // read-heavy rules list -static pthread_mutex_t pid_cache_lock = PTHREAD_MUTEX_INITIALIZER; // PID cache only -static pthread_mutex_t log_lock = PTHREAD_MUTEX_INITIALIZER; // logged connections only - -typedef struct { - int client_socket; - uint32_t orig_dest_ip; - uint16_t orig_dest_port; -} connection_config_t; - -typedef struct { - int from_socket; - int to_socket; -} transfer_config_t; - -static struct nfq_handle *nfq_h = NULL; -static struct nfq_q_handle *nfq_qh = NULL; -static pthread_t packet_thread[NUM_PACKET_THREADS] = {0}; -static pthread_t proxy_thread = 0; -static pthread_t udp_relay_thread = 0; -static pthread_t cleanup_thread = 0; -static PID_CACHE_ENTRY *pid_cache[PID_CACHE_SIZE] = {NULL}; -static bool g_has_active_rules = false; -static bool running = false; -static uint32_t g_current_process_id = 0; - -// udp relay stuff -static int udp_relay_socket = -1; -static int socks5_udp_control_socket = -1; -static int socks5_udp_send_socket = -1; -static struct sockaddr_in socks5_udp_relay_addr; -static bool udp_associate_connected = false; -static uint64_t last_udp_connect_attempt = 0; - -static bool g_traffic_logging_enabled = true; - -static char g_proxy_host[256] = ""; -static uint16_t g_proxy_port = 0; -static uint16_t g_local_relay_port = LOCAL_PROXY_PORT; -static ProxyType g_proxy_type = PROXY_TYPE_SOCKS5; -static char g_proxy_username[256] = ""; -static char g_proxy_password[256] = ""; -static bool g_dns_via_proxy = true; -static uint32_t g_proxy_ip_cached = 0; // Cached resolved proxy IP -static LogCallback g_log_callback = NULL; -static ConnectionCallback g_connection_callback = NULL; - -static void log_message(const char *msg, ...) +static struct pb_proxy *find_proxy_locked(uint32_t id) { - if (g_log_callback == NULL) return; - char buffer[LOG_BUFFER_SIZE]; - va_list args; - va_start(args, msg); - vsnprintf(buffer, sizeof(buffer), msg, args); - va_end(args); - g_log_callback(buffer); + for (size_t i = 0; i < G.nproxies; i++) + if (G.proxies[i].id == id) return &G.proxies[i]; + return NULL; } -static const char* extract_filename(const char* path) +static struct pb_proxy *default_proxy_locked(void) { - if (!path) return ""; - const char* last_slash = strrchr(path, '/'); - return last_slash ? (last_slash + 1) : path; + struct pb_proxy *p = G.default_proxy ? find_proxy_locked(G.default_proxy) : NULL; + return p ? p : (G.nproxies ? &G.proxies[0] : NULL); } -static inline char* skip_whitespace(char *str) +static struct pb_proxy *proxy_for_rule_locked(const struct pb_rule *r) { - while (*str == ' ' || *str == '\t') - str++; - return str; + struct pb_proxy *p = r->proxy_id ? find_proxy_locked(r->proxy_id) : NULL; + return p ? p : default_proxy_locked(); } -static void format_ip_address(uint32_t ip, char *buffer, size_t size) +int pb_core_get_proxy(uint32_t id, struct pb_proxy *out) { - snprintf(buffer, size, "%d.%d.%d.%d", - (ip >> 0) & 0xFF, (ip >> 8) & 0xFF, - (ip >> 16) & 0xFF, (ip >> 24) & 0xFF); + pthread_rwlock_rdlock(&G.lock); + struct pb_proxy *p = id ? find_proxy_locked(id) : NULL; + if (!p) p = default_proxy_locked(); + if (p) *out = *p; + pthread_rwlock_unlock(&G.lock); + return p ? 0 : -1; } -typedef bool (*token_match_func)(const char *token, const void *data); - -static bool parse_token_list(const char *list, const char *delimiters, token_match_func match_func, const void *match_data) +static void maint_kick(void) { - if (list == NULL || list[0] == '\0' || strcmp(list, "*") == 0) - return true; - - size_t len = strlen(list) + 1; - char *list_copy = malloc(len); - if (list_copy == NULL) - return false; - - memcpy(list_copy, list, len); // copy including null terminator - bool matched = false; - char *saveptr = NULL; - char *token = strtok_r(list_copy, delimiters, &saveptr); - while (token != NULL) - { - token = skip_whitespace(token); - - // remove spaces at end - size_t tlen = strlen(token); - while (tlen > 0 && (token[tlen - 1] == ' ' || token[tlen - 1] == '\t' || token[tlen-1] == '\r' || token[tlen-1] == '\n')) - { - token[tlen - 1] = '\0'; - tlen--; - } + pthread_mutex_lock(&G.maint_mu); + G.maint_kick = true; + pthread_cond_signal(&G.maint_cv); + pthread_mutex_unlock(&G.maint_mu); +} - if (tlen > 0 && match_func(token, match_data)) - { - matched = true; - break; - } - token = strtok_r(NULL, delimiters, &saveptr); +// Domain rules: arm the kernel for addresses whose DNS name matches a rule. Caller holds G.lock. +static void arm_ip_locked(uint32_t ip, const char *const *names, int nnames) +{ + for (size_t i = 0; i < G.nrules; i++) { + const struct pb_rule *r = &G.rules[i]; + if (!r->enabled) continue; + bool hit = false; + for (int d = 0; d < r->ndomains && !hit; d++) + for (int k = 0; k < nnames && !hit; k++) hit = pb_domain_match(r->domains[d], names[k]); + if (hit) pb_bpf_dom_set((uint32_t)i, ip); } - free(list_copy); - return matched; } -static void configure_tcp_socket(int sock, int bufsize, int timeout_ms) +static void arm_cb(uint32_t ip, const char *const *names, int n, void *ctx) { - int nodelay = 1; - setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &nodelay, sizeof(nodelay)); - setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize)); - setsockopt(sock, SOL_SOCKET, SO_SNDBUF, &bufsize, sizeof(bufsize)); - struct timeval timeout = {timeout_ms / 1000, (timeout_ms % 1000) * 1000}; - setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); - setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); + (void)ctx; + arm_ip_locked(ip, names, n); } -static void configure_udp_socket(int sock, int bufsize, int timeout_ms) +static void rearm_all_locked(void) { - setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize)); - setsockopt(sock, SOL_SOCKET, SO_SNDBUF, &bufsize, sizeof(bufsize)); - struct timeval timeout = {timeout_ms / 1000, (timeout_ms % 1000) * 1000}; - setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); - setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); + pb_bpf_dom_clear(); + if (atomic_load(&G.has_domain_rules)) pb_dns_for_each(arm_cb, NULL); } -static ssize_t send_all(int sock, const char *buf, size_t len) +void pb_core_dns_observe(const uint8_t *msg, size_t len) { - size_t sent = 0; - while (sent < len) { - ssize_t n = send(sock, buf + sent, len - sent, MSG_NOSIGNAL); - if (n < 0) return -1; - sent += n; + if (!atomic_load(&G.has_domain_rules)) return; + struct pb_dns_result res; + if (pb_dns_observe(msg, len, &res) <= 0) return; + pthread_rwlock_rdlock(&G.lock); + for (int i = 0; i < res.nips; i++) { + char names[PB_DNS_MAX_NAMES][256]; + const char *ptrs[PB_DNS_MAX_NAMES]; + int n = pb_dns_lookup(res.ips[i], names, PB_DNS_MAX_NAMES); + for (int k = 0; k < n; k++) ptrs[k] = names[k]; + arm_ip_locked(res.ips[i], ptrs, n); } - return sent; + pthread_rwlock_unlock(&G.lock); } -static uint64_t get_monotonic_ms(void) +static void warn_udp_over_http_locked(void) { - struct timespec ts; - clock_gettime(CLOCK_MONOTONIC, &ts); - return (uint64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; + for (size_t i = 0; i < G.nrules; i++) { + const struct pb_rule *r = &G.rules[i]; + if (!r->enabled || r->action != RULE_ACTION_PROXY || r->protocol == RULE_PROTOCOL_TCP) continue; + const struct pb_proxy *p = proxy_for_rule_locked(r); + if (!p || p->type != PROXY_TYPE_HTTP) continue; + uint64_t key = ((uint64_t)r->id << 32) | p->id; + bool seen = false; + for (size_t k = 0; k < G.nwarned && !seen; k++) seen = G.warned[k] == key; + if (seen) continue; + if (G.nwarned < sizeof G.warned / sizeof *G.warned) G.warned[G.nwarned++] = key; + pb_core_log("rule %u sends UDP to HTTP proxy '%s', which cannot carry UDP: that UDP traffic goes DIRECT", r->id, p->name); + } } -static uint32_t parse_ipv4(const char *ip); -static uint32_t resolve_hostname(const char *hostname); -static int socks5_connect(int s, uint32_t dest_ip, uint16_t dest_port); -static bool match_ip_pattern(const char *pattern, uint32_t ip); -static bool match_port_pattern(const char *pattern, uint16_t port); -static bool match_ip_list(const char *ip_list, uint32_t ip); -static bool match_port_list(const char *port_list, uint16_t port); -static bool match_process_pattern(const char *pattern, const char *process_name); -static bool match_process_list(const char *process_list, const char *process_name); -static int http_connect(int s, uint32_t dest_ip, uint16_t dest_port); -static void* local_proxy_server(void *arg); -static void* connection_handler(void *arg); -static void* transfer_handler(void *arg); -static void* packet_processor(void *arg); -static uint32_t get_process_id_from_connection(uint32_t src_ip, uint16_t src_port, bool is_udp); -static bool get_process_name_from_pid(uint32_t pid, char *name, size_t name_size); -static RuleAction match_rule(const char *process_name, uint32_t dest_ip, uint16_t dest_port, bool is_udp); -static RuleAction check_process_rule(uint32_t src_ip, uint16_t src_port, uint32_t dest_ip, uint16_t dest_port, bool is_udp, uint32_t *out_pid); -static void add_connection(uint16_t src_port, bool is_udp, uint32_t src_ip, uint32_t dest_ip, uint16_t dest_port); -static bool get_connection(uint16_t src_port, bool is_udp, uint32_t *dest_ip, uint16_t *dest_port); -static bool is_connection_tracked(uint16_t src_port, bool is_udp); -static void cleanup_stale_connections(void); -static bool is_connection_already_logged(uint32_t pid, uint32_t dest_ip, uint16_t dest_port, RuleAction action); -static void add_logged_connection(uint32_t pid, uint32_t dest_ip, uint16_t dest_port, RuleAction action); -static void clear_logged_connections(void); -static bool is_broadcast_or_multicast(uint32_t ip); -static uint32_t get_cached_pid(uint32_t src_ip, uint16_t src_port, bool is_udp); -static void cache_pid(uint32_t src_ip, uint16_t src_port, uint32_t pid, bool is_udp); -static void clear_pid_cache(void); -static void update_has_active_rules(void); - -// find which process owns a socket by checking /proc -// uses uid hint to skip processes we dont need to check -static uint32_t find_pid_from_inode(unsigned long target_inode, uint32_t uid_hint) +static void rebuild_views_locked(void) { - // build the socket string we're looking for - char expected[64]; - int expected_len = snprintf(expected, sizeof(expected), "socket:[%lu]", target_inode); - - DIR *proc_dir = opendir("/proc"); - if (!proc_dir) - return 0; - - uint32_t pid = 0; - struct dirent *proc_entry; - - while ((proc_entry = readdir(proc_dir)) != NULL) { - // skip stuff that aint a pid folder - if (proc_entry->d_type != DT_DIR || !isdigit(proc_entry->d_name[0])) - continue; - - // if we know the user id, skip other users processes - // makes this way faster cuz less folders to check - if (uid_hint != (uint32_t)-1) { - struct stat proc_stat; - char proc_path[280]; - snprintf(proc_path, sizeof(proc_path), "/proc/%s", proc_entry->d_name); - if (stat(proc_path, &proc_stat) == 0 && proc_stat.st_uid != uid_hint) - continue; - } - - char fd_path[280]; - snprintf(fd_path, sizeof(fd_path), "/proc/%s/fd", proc_entry->d_name); - DIR *fd_dir = opendir(fd_path); - if (!fd_dir) - continue; - - struct dirent *fd_entry; - while ((fd_entry = readdir(fd_dir)) != NULL) { - if (fd_entry->d_name[0] == '.') - continue; - - char link_path[560]; - snprintf(link_path, sizeof(link_path), "/proc/%s/fd/%s", - proc_entry->d_name, fd_entry->d_name); - - char link_target[64]; - ssize_t link_len = readlink(link_path, link_target, sizeof(link_target) - 1); - if (link_len == expected_len) { - link_target[link_len] = '\0'; - if (memcmp(link_target, expected, expected_len) == 0) { - pid = (uint32_t)safe_atoi(proc_entry->d_name); - closedir(fd_dir); - closedir(proc_dir); - return pid; - } - } - } - closedir(fd_dir); + free(G.views); + G.views = calloc(G.nrules ? G.nrules : 1, sizeof *G.views); + G.nviews = G.views ? G.nrules : 0; + bool domains = false; + for (size_t i = 0; i < G.nviews; i++) { + const struct pb_rule *r = &G.rules[i]; + struct pb_rule_view *v = &G.views[i]; + v->rule = r; + v->action = r->action; + domains |= r->enabled && r->ndomains > 0; + if (r->action != RULE_ACTION_PROXY) continue; + const struct pb_proxy *p = proxy_for_rule_locked(r); + if (!p) v->action = RULE_ACTION_BLOCK; // no usable proxy: fail closed + else { v->proxy_id = (uint16_t)p->id; v->udp_direct = p->type == PROXY_TYPE_HTTP; } } - closedir(proc_dir); - return pid; + atomic_store(&G.has_domain_rules, domains); } -// fast pid lookup using netlink -// tcp uses exact query, udp needs dump -static uint32_t get_process_id_from_connection(uint32_t src_ip, uint16_t src_port, bool is_udp) +static void sync_locked(void) { - uint32_t cached_pid = get_cached_pid(src_ip, src_port, is_udp); - if (cached_pid != 0) - return cached_pid; - - int fd = socket(AF_NETLINK, SOCK_DGRAM | SOCK_CLOEXEC, NETLINK_SOCK_DIAG); - if (fd < 0) - return 0; - - // short timeout so we dont block packet flow - struct timeval tv = {0, 100000}; // 100ms - setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); - - struct { - struct nlmsghdr nlh; - struct inet_diag_req_v2 r; - } req; - - memset(&req, 0, sizeof(req)); - req.nlh.nlmsg_len = sizeof(req); - req.nlh.nlmsg_type = SOCK_DIAG_BY_FAMILY; - req.r.sdiag_family = AF_INET; - req.r.sdiag_protocol = is_udp ? IPPROTO_UDP : IPPROTO_TCP; - - // udp needs dump cuz no connection state to match on - // tcp can filter to only syn_sent and established states - req.nlh.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP; - if (!is_udp) - req.r.idiag_states = (1 << 2) | (1 << 3); // SYN_SENT(2) + ESTABLISHED(3) only - else - req.r.idiag_states = (uint32_t)-1; // All states for UDP - req.r.idiag_ext = 0; - - struct sockaddr_nl sa; - memset(&sa, 0, sizeof(sa)); - sa.nl_family = AF_NETLINK; + rebuild_views_locked(); + if (!G.running) return; + if (pb_bpf_sync(G.views, G.nviews) != 0) pb_core_log("failed to apply rules to the kernel datapath"); + pb_bpf_set_flags(atomic_load(&G.traffic_logging), atomic_load(&G.dns_via_proxy)); + rearm_all_locked(); + warn_udp_over_http_locked(); +} - if (sendto(fd, &req, sizeof(req), 0, (struct sockaddr *)&sa, sizeof(sa)) < 0) { - close(fd); - return 0; - } +void pb_core_decide(const struct pb_orig *o, struct pb_decision *out) +{ + char path[4096]; + exe_path_of_pid(o->pid, path, sizeof path); + out->action = PB_ACT_DIRECT; + out->proxy_id = 0; - uint32_t pid = 0; - unsigned long target_inode = 0; - uint32_t target_uid = (uint32_t)-1; - bool found = false; - char buf[16384]; - struct iovec iov = {buf, sizeof(buf)}; - struct msghdr msg = {&sa, sizeof(sa), &iov, 1, NULL, 0, 0}; - - while (1) { - ssize_t len = recvmsg(fd, &msg, 0); - if (len <= 0) - break; - - struct nlmsghdr *h = (struct nlmsghdr *)buf; - while (NLMSG_OK(h, (size_t)len)) { - if (h->nlmsg_type == NLMSG_DONE || h->nlmsg_type == NLMSG_ERROR) - goto nl_done; - - if (h->nlmsg_type == SOCK_DIAG_BY_FAMILY) { - struct inet_diag_msg *r = NLMSG_DATA(h); - - // check if this is our socket - if (r->id.idiag_src[0] == src_ip && ntohs(r->id.idiag_sport) == src_port) { - target_inode = r->idiag_inode; - target_uid = r->idiag_uid; // UID to narrow /proc scan - found = true; - goto nl_done; - } - } - h = NLMSG_NEXT(h, len); + pthread_rwlock_rdlock(&G.lock); + if (!pb_implicit_direct(o->family, o->addr, o->port, o->proto, atomic_load(&G.dns_via_proxy))) { + char names[PB_DNS_MAX_NAMES][256]; + const char *ptrs[PB_DNS_MAX_NAMES] = { 0 }; + int nn = 0; + if (o->family == AF_INET && atomic_load(&G.has_domain_rules)) { + uint32_t ip = ((uint32_t)o->addr[12] << 24) | ((uint32_t)o->addr[13] << 16) | ((uint32_t)o->addr[14] << 8) | o->addr[15]; + nn = pb_dns_lookup(ip, names, PB_DNS_MAX_NAMES); + for (int k = 0; k < nn; k++) ptrs[k] = names[k]; } - } - -nl_done: - close(fd); - - // if netlink found it, now find pid from inode - // no need to scan /proc/net/tcp since we got inode already - if (found && target_inode != 0) { - pid = find_pid_from_inode(target_inode, target_uid); - } - - // fallback for udp if netlink didnt find it - // happens when app uses sendto without connecting socket first - if (!found && is_udp) { - const char* udp_files[] = {"/proc/net/udp", "/proc/net/udp6"}; - for (int file_idx = 0; file_idx < 2 && pid == 0; file_idx++) { - FILE *fp = fopen(udp_files[file_idx], "r"); - if (!fp) - continue; - - char line[512]; - if (!fgets(line, sizeof(line), fp)) { // skip header - fclose(fp); - continue; + int idx = pb_rules_eval(G.rules, G.nrules, path, o->family, o->addr, o->port, o->proto, ptrs, nn); + if (idx >= 0) { + const struct pb_rule *r = &G.rules[idx]; + if (r->action != RULE_ACTION_PROXY) { + out->action = r->action == RULE_ACTION_BLOCK ? PB_ACT_BLOCK : PB_ACT_DIRECT; + } else { + const struct pb_proxy *p = proxy_for_rule_locked(r); + if (!p) out->action = PB_ACT_BLOCK; + else if (o->proto == IPPROTO_UDP && p->type == PROXY_TYPE_HTTP) out->action = PB_ACT_DIRECT; + else { out->action = PB_ACT_PROXY; out->proxy_id = p->id; } } - while (fgets(line, sizeof(line), fp)) { - unsigned int local_addr, local_port; - unsigned long inode; - int uid_val; - - if (sscanf(line, "%*d: %X:%X %*X:%*X %*X %*X:%*X %*X:%*X %*X %d %*d %lu", - &local_addr, &local_port, &uid_val, &inode) == 4) { - if (local_port == src_port && inode != 0) { - pid = find_pid_from_inode(inode, (uint32_t)uid_val); - break; - } - } - } - fclose(fp); } } - - if (pid != 0) - cache_pid(src_ip, src_port, pid, is_udp); - return pid; + if (o->exe_ino && path[0]) pb_bpf_learn(o, path, G.views, G.nviews); + pthread_rwlock_unlock(&G.lock); } -static bool get_process_name_from_pid(uint32_t pid, char *name, size_t name_size) +int pb_core_take_tcp_orig(uint32_t client_ip_be, uint16_t client_port, struct pb_orig *out) { - if (pid == 0) - return false; - - // pid 1 is always init/systemd - // kinda like windows pid 4 for system stuff - if (pid == 1) - { - snprintf(name, name_size, "systemd"); - return true; - } + return pb_bpf_take_tcp_orig(client_ip_be, client_port, out); +} - char path[64]; - snprintf(path, sizeof(path), "/proc/%u/exe", pid); +int pb_core_get_udp_flow(uint32_t flow_ip_be, struct pb_orig *out) { return pb_bpf_get_udp_flow(flow_ip_be, out); } - ssize_t len = readlink(path, name, name_size - 1); - if (len < 0) - return false; +// ---- local proxy exemption and /etc/hosts ------------------------------------------------------------------------------- +// A rule such as "*:...:PROXY" would also match the proxy's own outbound connections and send them back through itself, so +// the processes that listen on a configured local proxy address are never redirected. - name[len] = '\0'; - return true; -} +struct host_cache { char host[256]; uint32_t ip_be; time_t at; }; -static bool match_ip_pattern(const char *pattern, uint32_t ip) +static uint32_t resolve_proxy_ip(const char *host, struct host_cache *cache, size_t ncache, time_t now) { - if (pattern == NULL || strcmp(pattern, "*") == 0) - return true; - - unsigned char ip_octets[4]; - ip_octets[0] = (ip >> 0) & 0xFF; - ip_octets[1] = (ip >> 8) & 0xFF; - ip_octets[2] = (ip >> 16) & 0xFF; - ip_octets[3] = (ip >> 24) & 0xFF; - - char pattern_copy[256]; - strncpy(pattern_copy, pattern, sizeof(pattern_copy) - 1); - pattern_copy[sizeof(pattern_copy) - 1] = '\0'; - - char pattern_octets[4][16]; - int octet_count = 0; - int char_idx = 0; - - for (size_t i = 0; i <= strlen(pattern_copy) && octet_count < 4; i++) - { - if (pattern_copy[i] == '.' || pattern_copy[i] == '\0') - { - pattern_octets[octet_count][char_idx] = '\0'; - octet_count++; - char_idx = 0; - if (pattern_copy[i] == '\0') - break; - } - else - { - if (char_idx < 15) - pattern_octets[octet_count][char_idx++] = pattern_copy[i]; - } + struct in_addr a; + if (inet_pton(AF_INET, host, &a) == 1) return a.s_addr; + if (!strcmp(host, "localhost")) return htonl(INADDR_LOOPBACK); + size_t slot = 0; + for (size_t i = 0; i < ncache; i++) { + if (!strcmp(cache[i].host, host)) { slot = i; break; } + if (cache[i].at < cache[slot].at) slot = i; } - - if (octet_count != 4) - return false; - - for (int i = 0; i < 4; i++) - { - if (strcmp(pattern_octets[i], "*") == 0) - continue; - - char *dash = strchr(pattern_octets[i], '-'); - if (dash != NULL) - { - int start = safe_atoi(pattern_octets[i]); - int end = safe_atoi(dash + 1); - if (ip_octets[i] < start || ip_octets[i] > end) - return false; - } - else - { - int pattern_val = safe_atoi(pattern_octets[i]); - if (pattern_val != ip_octets[i]) - return false; + if (strcmp(cache[slot].host, host) || now - cache[slot].at > 60) { + struct addrinfo hints = { .ai_family = AF_INET, .ai_socktype = SOCK_STREAM }, *res = NULL; + snprintf(cache[slot].host, sizeof cache[slot].host, "%.255s", host); + cache[slot].at = now; + cache[slot].ip_be = 0; + if (getaddrinfo(host, NULL, &hints, &res) == 0 && res) { + cache[slot].ip_be = ((struct sockaddr_in *)res->ai_addr)->sin_addr.s_addr; + freeaddrinfo(res); } } - return true; + return cache[slot].ip_be; } -static bool match_port_pattern(const char *pattern, uint16_t port) +static void refresh_exempt(struct host_cache *cache, size_t ncache, uint32_t *last, size_t *nlast) { - if (pattern == NULL || strcmp(pattern, "*") == 0) - return true; - - char *dash = strchr(pattern, '-'); - if (dash != NULL) - { - int start_port = safe_atoi(pattern); - int end_port = safe_atoi(dash + 1); - return (port >= start_port && port <= end_port); + struct { char host[256]; uint16_t port; } targets[MAX_PROXIES]; + size_t nt = 0; + pthread_rwlock_rdlock(&G.lock); + for (size_t i = 0; i < G.nproxies; i++) { + snprintf(targets[nt].host, sizeof targets[nt].host, "%.255s", G.proxies[i].host); + targets[nt++].port = G.proxies[i].port; } + pthread_rwlock_unlock(&G.lock); - return (port == safe_atoi(pattern)); + uint32_t tgids[64]; + size_t n = 0; + time_t now = time(NULL); + for (size_t i = 0; i < nt && n < 64; i++) { + uint32_t ip = resolve_proxy_ip(targets[i].host, cache, ncache, now); + if (!ip || !pb_proc_is_local_ip(ip)) continue; + uint32_t found[8]; + size_t nf = pb_proc_listeners(ip, targets[i].port, found, 8); + for (size_t k = 0; k < nf && n < 64; k++) { + bool dup = false; + for (size_t j = 0; j < n && !dup; j++) dup = tgids[j] == found[k]; + if (!dup && found[k] != (uint32_t)getpid()) tgids[n++] = found[k]; + } + } + if (n != *nlast || memcmp(tgids, last, n * sizeof *tgids)) { + pb_bpf_set_exempt(tgids, n); + memcpy(last, tgids, n * sizeof *tgids); + *nlast = n; + } } -static bool ip_match_wrapper(const char *token, const void *data) +static void reload_hosts_if_changed(time_t *mtime, off_t *size) { - return match_ip_pattern(token, *(const uint32_t*)data); + struct stat st; + if (stat(HOSTS_PATH, &st) != 0 || (st.st_mtime == *mtime && st.st_size == *size)) return; + *mtime = st.st_mtime; + *size = st.st_size; + pb_dns_load_hosts(HOSTS_PATH, NULL); + pthread_rwlock_rdlock(&G.lock); + rearm_all_locked(); + pthread_rwlock_unlock(&G.lock); } -static bool match_ip_list(const char *ip_list, uint32_t ip) +static void *maint_loop(void *arg) { - return parse_token_list(ip_list, ";", ip_match_wrapper, &ip); + (void)arg; + struct host_cache cache[8] = { 0 }; + uint32_t last[64]; + size_t nlast = 0; + time_t hosts_mtime = 0; + off_t hosts_size = 0; + pthread_mutex_lock(&G.maint_mu); + while (!G.maint_stop) { + G.maint_kick = false; + pthread_mutex_unlock(&G.maint_mu); + refresh_exempt(cache, 8, last, &nlast); + reload_hosts_if_changed(&hosts_mtime, &hosts_size); + pthread_mutex_lock(&G.maint_mu); + struct timespec until; + clock_gettime(CLOCK_REALTIME, &until); + until.tv_sec += MAINT_PERIOD_S; + while (!G.maint_stop && !G.maint_kick && pthread_cond_timedwait(&G.maint_cv, &G.maint_mu, &until) == 0) {} + } + pthread_mutex_unlock(&G.maint_mu); + return NULL; } -static bool port_match_wrapper(const char *token, const void *data) +// ---- proxies ------------------------------------------------------------------------------------------------------------- +static bool valid_proxy(const char *host, uint16_t port, char *err, size_t en) { - return match_port_pattern(token, *(const uint16_t*)data); + if (!host || !*host) { set_err(err, en, "proxy host is empty"); return false; } + if (strlen(host) > 253) { set_err(err, en, "proxy host too long"); return false; } + for (const char *c = host; *c; c++) + if (isspace((unsigned char)*c) || *c == '/' || *c == '@') { set_err(err, en, "proxy host contains invalid characters"); return false; } + if (!port) { set_err(err, en, "proxy port must be 1-65535"); return false; } + return true; } -static bool match_port_list(const char *port_list, uint16_t port) +static void fill_proxy(struct pb_proxy *p, const char *name, ProxyType type, const char *host, uint16_t port, const char *user, const char *pass) { - return parse_token_list(port_list, ",;", port_match_wrapper, &port); + snprintf(p->name, sizeof p->name, "%s", name && *name ? name : host); + p->type = type == PROXY_TYPE_HTTP ? PROXY_TYPE_HTTP : PROXY_TYPE_SOCKS5; + snprintf(p->host, sizeof p->host, "%s", host); + p->port = port; + snprintf(p->user, sizeof p->user, "%s", user ? user : ""); + snprintf(p->pass, sizeof p->pass, "%s", pass ? pass : ""); } -static bool match_process_pattern(const char *pattern, const char *process_full_path) +uint32_t ProxyBridge_AddProxy(const char *name, ProxyType type, const char *host, uint16_t port, const char *user, const char *pass, char *err, size_t en) { - if (pattern == NULL || strcmp(pattern, "*") == 0) - return true; - - const char *filename = strrchr(process_full_path, '/'); - if (filename != NULL) - filename++; - else - filename = process_full_path; - - size_t pattern_len = strlen(pattern); - size_t name_len = strlen(filename); - size_t full_path_len = strlen(process_full_path); - - bool is_full_path_pattern = (strchr(pattern, '/') != NULL); - const char *match_target = is_full_path_pattern ? process_full_path : filename; - size_t target_len = is_full_path_pattern ? full_path_len : name_len; - - if (pattern_len > 0 && pattern[pattern_len - 1] == '*') - { - return strncasecmp(pattern, match_target, pattern_len - 1) == 0; - } - - if (pattern_len > 1 && pattern[0] == '*') - { - const char *pattern_suffix = pattern + 1; - size_t suffix_len = pattern_len - 1; - if (target_len >= suffix_len) - { - return strcasecmp(match_target + target_len - suffix_len, pattern_suffix) == 0; - } - return false; + if (!valid_proxy(host, port, err, en)) return 0; + uint32_t id = 0; + pthread_rwlock_wrlock(&G.lock); + if (G.nproxies >= MAX_PROXIES) { + set_err(err, en, "too many proxies (max %d)", MAX_PROXIES); + } else { + struct pb_proxy *p = &G.proxies[G.nproxies++]; + memset(p, 0, sizeof *p); + p->id = id = G.next_proxy_id++; + fill_proxy(p, name, type, host, port, user, pass); + sync_locked(); } - - return strcasecmp(pattern, match_target) == 0; + pthread_rwlock_unlock(&G.lock); + if (id) maint_kick(); + return id; } -typedef struct { - const char *process_name; -} process_match_data; - -static bool process_match_wrapper(const char *token, const void *data) +bool ProxyBridge_EditProxy(uint32_t id, const char *name, ProxyType type, const char *host, uint16_t port, const char *user, const char *pass, char *err, size_t en) { - const process_match_data *pdata = data; - return match_process_pattern(token, pdata->process_name); + if (!valid_proxy(host, port, err, en)) return false; + pthread_rwlock_wrlock(&G.lock); + struct pb_proxy *p = find_proxy_locked(id); + if (p) { fill_proxy(p, name, type, host, port, user, pass); sync_locked(); } + else set_err(err, en, "unknown proxy id %u", id); + pthread_rwlock_unlock(&G.lock); + if (p) maint_kick(); + return p != NULL; } -static bool match_process_list(const char *process_list, const char *process_name) +bool ProxyBridge_DeleteProxy(uint32_t id) { - process_match_data data = {process_name}; - return parse_token_list(process_list, ";", process_match_wrapper, &data); + bool ok = false; + pthread_rwlock_wrlock(&G.lock); + for (size_t i = 0; i < G.nproxies && !ok; i++) { + if (G.proxies[i].id != id) continue; + memmove(&G.proxies[i], &G.proxies[i + 1], (G.nproxies - i - 1) * sizeof G.proxies[0]); + G.nproxies--; + if (G.default_proxy == id) G.default_proxy = 0; + ok = true; + } + if (ok) sync_locked(); + pthread_rwlock_unlock(&G.lock); + if (ok) maint_kick(); + return ok; } -static uint32_t parse_ipv4(const char *ip) +void ProxyBridge_ClearProxies(void) { - unsigned int a, b, c, d; - if (sscanf(ip, "%u.%u.%u.%u", &a, &b, &c, &d) != 4) - return 0; - if (a > 255 || b > 255 || c > 255 || d > 255) - return 0; - return (a << 0) | (b << 8) | (c << 16) | (d << 24); + pthread_rwlock_wrlock(&G.lock); + G.nproxies = 0; + G.default_proxy = 0; + sync_locked(); + pthread_rwlock_unlock(&G.lock); + maint_kick(); } -static uint32_t resolve_hostname(const char *hostname) +void ProxyBridge_SetDefaultProxy(uint32_t id) { - if (hostname == NULL || hostname[0] == '\0') - return 0; - - uint32_t ip = parse_ipv4(hostname); - if (ip != 0) - return ip; - - struct addrinfo hints, *result = NULL; - memset(&hints, 0, sizeof(hints)); - hints.ai_family = AF_INET; - hints.ai_socktype = SOCK_STREAM; - - if (getaddrinfo(hostname, NULL, &hints, &result) != 0) - { - log_message("failed to resolve hostname: %s", hostname); - return 0; - } - - if (result == NULL || result->ai_family != AF_INET) - { - if (result != NULL) - freeaddrinfo(result); - log_message("no ipv4 address found for hostname: %s", hostname); - return 0; - } - - struct sockaddr_in *addr = (struct sockaddr_in *)result->ai_addr; - uint32_t resolved_ip = addr->sin_addr.s_addr; - freeaddrinfo(result); - - return resolved_ip; + pthread_rwlock_wrlock(&G.lock); + G.default_proxy = id; + sync_locked(); + pthread_rwlock_unlock(&G.lock); } -static bool is_broadcast_or_multicast(uint32_t ip) +bool ProxyBridge_SetProxyConfig(ProxyType type, const char *host, uint16_t port, const char *user, const char *pass) { - // localhost - uint8_t first_octet = (ip >> 0) & 0xFF; - if (first_octet == 127) - return true; - - // link-local apipa stuff - uint8_t second_octet = (ip >> 8) & 0xFF; - if (first_octet == 169 && second_octet == 254) - return true; - - // broadcast - if (ip == 0xFFFFFFFF) - return true; - - // subnet broadcast - if ((ip & 0xFF000000) == 0xFF000000) - return true; - - // multicast range - if (first_octet >= 224 && first_octet <= 239) - return true; - - return false; + char err[256]; + pthread_rwlock_rdlock(&G.lock); + const struct pb_proxy *d = default_proxy_locked(); + uint32_t id = d ? d->id : 0; + pthread_rwlock_unlock(&G.lock); + if (id) return ProxyBridge_EditProxy(id, NULL, type, host, port, user, pass, err, sizeof err); + return ProxyBridge_AddProxy("default", type, host, port, user, pass, err, sizeof err) != 0; } -static RuleAction match_rule(const char *process_name, uint32_t dest_ip, uint16_t dest_port, bool is_udp) +// ---- rules --------------------------------------------------------------------------------------------------------------- +bool ProxyBridge_ValidateRule(const char *process, const char *hosts, const char *ports, char *err, size_t en) { - PROCESS_RULE *rule = rules_list; - PROCESS_RULE *wildcard_rule = NULL; - - while (rule != NULL) - { - if (!rule->enabled) - { - rule = rule->next; - continue; - } - - if (rule->protocol != RULE_PROTOCOL_BOTH) - { - if (rule->protocol == RULE_PROTOCOL_TCP && is_udp) - { - rule = rule->next; - continue; - } - if (rule->protocol == RULE_PROTOCOL_UDP && !is_udp) - { - rule = rule->next; - continue; - } - } - - bool is_wildcard_process = (strcmp(rule->process_name, "*") == 0 || strcasecmp(rule->process_name, "ANY") == 0); - - if (is_wildcard_process) - { - bool has_ip_filter = (strcmp(rule->target_hosts, "*") != 0); - bool has_port_filter = (strcmp(rule->target_ports, "*") != 0); - - if (has_ip_filter || has_port_filter) - { - if (match_ip_list(rule->target_hosts, dest_ip) && - match_port_list(rule->target_ports, dest_port)) - { - return rule->action; - } - rule = rule->next; - continue; - } - - if (wildcard_rule == NULL) - { - wildcard_rule = rule; - } - rule = rule->next; - continue; - } - - if (match_process_list(rule->process_name, process_name)) - { - if (match_ip_list(rule->target_hosts, dest_ip) && - match_port_list(rule->target_ports, dest_port)) - { - return rule->action; - } - } - - rule = rule->next; - } - - if (wildcard_rule != NULL) - { - return wildcard_rule->action; - } - - return RULE_ACTION_DIRECT; + struct pb_rule r; + if (!pb_rule_parse(&r, process, hosts, ports, err, en)) return false; + pb_rule_free(&r); + return true; } -static RuleAction check_process_rule(uint32_t src_ip, uint16_t src_port, uint32_t dest_ip, uint16_t dest_port, bool is_udp, uint32_t *out_pid) +static struct pb_rule *find_rule_locked(uint32_t id, size_t *idx) { - uint32_t pid; - char process_name[MAX_PROCESS_NAME]; - - pid = get_process_id_from_connection(src_ip, src_port, is_udp); - - if (out_pid != NULL) - *out_pid = pid; - - if (pid == 0) - return RULE_ACTION_DIRECT; - - if (pid == g_current_process_id) - return RULE_ACTION_DIRECT; - - if (!get_process_name_from_pid(pid, process_name, sizeof(process_name))) - return RULE_ACTION_DIRECT; - - pthread_rwlock_rdlock(&rules_lock); - RuleAction action = match_rule(process_name, dest_ip, dest_port, is_udp); - pthread_rwlock_unlock(&rules_lock); - - if (action == RULE_ACTION_PROXY && is_udp && g_proxy_type == PROXY_TYPE_HTTP) - { - return RULE_ACTION_DIRECT; - } - if (action == RULE_ACTION_PROXY && (g_proxy_host[0] == '\0' || g_proxy_port == 0)) - { - return RULE_ACTION_DIRECT; - } - - return action; + for (size_t i = 0; i < G.nrules; i++) + if (G.rules[i].id == id) { if (idx) *idx = i; return &G.rules[i]; } + return NULL; } -// Read exactly n bytes, looping over short reads. SOCKS5/HTTP replies can split -// across TCP segments (common on high-latency remote proxies), so fixed-length -// handshake reads must accumulate. Returns n on success, -1 on error/peer close. -static int recv_n(int s, void *buf, size_t n) -{ - size_t got = 0; - while (got < n) - { - ssize_t r = recv(s, (char *)buf + got, n - got, 0); - if (r <= 0) return -1; - got += (size_t)r; +uint32_t ProxyBridge_AddRule(const char *proc, const char *hosts, const char *ports, RuleProtocol protocol, RuleAction action, uint32_t proxy_id, char *err, size_t en) +{ + struct pb_rule r; + if (!pb_rule_parse(&r, proc, hosts, ports, err, en)) return 0; + r.enabled = true; + r.protocol = protocol; + r.action = action; + r.proxy_id = proxy_id; + + uint32_t id = 0; + pthread_rwlock_wrlock(&G.lock); + if (G.nrules >= PB_MAX_RULES) { + set_err(err, en, "too many rules (max %d)", PB_MAX_RULES); + } else if (G.nrules == G.caprules) { + size_t cap = G.caprules ? G.caprules * 2 : 32; + struct pb_rule *grown = realloc(G.rules, cap * sizeof *grown); + if (grown) { G.rules = grown; G.caprules = cap; } + else set_err(err, en, "out of memory"); + } + if (G.nrules < G.caprules && G.nrules < PB_MAX_RULES) { + r.id = id = G.next_rule_id++; + G.rules[G.nrules++] = r; + sync_locked(); + } else { + pb_rule_free(&r); } - return (int)n; + pthread_rwlock_unlock(&G.lock); + return id; } -// Read and validate a SOCKS5 CONNECT reply (RFC 1928). The proxy chooses the -// BND.ADDR type in its reply independently of the request's ATYP - many proxies -// answer with a 4-byte IPv4 0.0.0.0 even for other requests - so parse the 4-byte -// header (VER REP RSV ATYP) then drain the variable BND.ADDR + BND.PORT by ATYP, -// instead of assuming a fixed 10-byte reply. *reply gets REP (or -1) for logging. -static int socks5_read_connect_reply(int s, int *reply) +bool ProxyBridge_EditRule(uint32_t id, const char *proc, const char *hosts, const char *ports, RuleProtocol protocol, RuleAction action, uint32_t proxy_id, char *err, size_t en) { - unsigned char hdr[4]; - if (reply) *reply = -1; - if (recv_n(s, hdr, 4) != 4) return -1; - if (reply) *reply = hdr[1]; - if (hdr[0] != SOCKS5_VERSION || hdr[1] != 0x00) return -1; - - int drain; - if (hdr[3] == SOCKS5_ATYP_IPV4) drain = 4 + 2; - else if (hdr[3] == 0x04 /* IPv6 */) drain = 16 + 2; - else if (hdr[3] == 0x03 /* domain */) - { - unsigned char dlen; - if (recv_n(s, &dlen, 1) != 1) return -1; - drain = (int)dlen + 2; + struct pb_rule r; + if (!pb_rule_parse(&r, proc, hosts, ports, err, en)) return false; + pthread_rwlock_wrlock(&G.lock); + struct pb_rule *old = find_rule_locked(id, NULL); + if (!old) { + set_err(err, en, "unknown rule id %u", id); + pb_rule_free(&r); + } else { + r.id = id; + r.enabled = old->enabled; + r.protocol = protocol; + r.action = action; + r.proxy_id = proxy_id; + pb_rule_free(old); + *old = r; + sync_locked(); } - else return -1; // unknown ATYP - - unsigned char scratch[270]; // max drain = 255 + 2 (domain) < 270 - if (drain > 0 && recv_n(s, scratch, (size_t)drain) != drain) return -1; - return 0; + pthread_rwlock_unlock(&G.lock); + return old != NULL; } -static int socks5_connect(int s, uint32_t dest_ip, uint16_t dest_port) +static bool set_enabled(uint32_t id, bool on) { - unsigned char buf[SOCKS5_BUFFER_SIZE]; - bool use_auth = (g_proxy_username[0] != '\0'); - - buf[0] = SOCKS5_VERSION; - if (use_auth) - { - buf[1] = 0x02; - buf[2] = SOCKS5_AUTH_NONE; - buf[3] = 0x02; - if (send(s, buf, 4, MSG_NOSIGNAL) != 4) - { - log_message("socks5 failed to send auth methods"); - return -1; - } - } - else - { - buf[1] = 0x01; - buf[2] = SOCKS5_AUTH_NONE; - if (send(s, buf, 3, MSG_NOSIGNAL) != 3) - { - log_message("socks5 failed to send auth methods"); - return -1; - } - } - - if (recv_n(s, buf, 2) != 2 || buf[0] != SOCKS5_VERSION) - { - log_message("socks5 invalid auth response"); - return -1; - } - - if (buf[1] == 0x02 && use_auth) - { - size_t ulen = strlen(g_proxy_username); - size_t plen = strlen(g_proxy_password); - if (ulen > 255 || plen > 255) // SOCKS5 username/password fields are length-prefixed by one byte - { - log_message("socks5 credentials too long"); - return -1; - } - buf[0] = 0x01; - buf[1] = (unsigned char)ulen; - memcpy(buf + 2, g_proxy_username, ulen); - buf[2 + ulen] = (unsigned char)plen; - memcpy(buf + 3 + ulen, g_proxy_password, plen); - - if (send(s, buf, 3 + ulen + plen, MSG_NOSIGNAL) != (ssize_t)(3 + ulen + plen)) - { - log_message("socks5 failed to send credentials"); - return -1; - } - - if (recv_n(s, buf, 2) != 2 || buf[0] != 0x01 || buf[1] != 0x00) - { - log_message("socks5 authentication failed"); - return -1; - } - } - else if (buf[1] != SOCKS5_AUTH_NONE) - { - log_message("socks5 unsupported auth method"); - return -1; - } + pthread_rwlock_wrlock(&G.lock); + struct pb_rule *r = find_rule_locked(id, NULL); + if (r) { r->enabled = on; sync_locked(); } + pthread_rwlock_unlock(&G.lock); + return r != NULL; +} - buf[0] = SOCKS5_VERSION; - buf[1] = SOCKS5_CMD_CONNECT; - buf[2] = 0x00; - buf[3] = SOCKS5_ATYP_IPV4; - memcpy(buf + 4, &dest_ip, 4); - uint16_t port_net = htons(dest_port); - memcpy(buf + 8, &port_net, 2); - - if (send(s, buf, 10, MSG_NOSIGNAL) != 10) - { - log_message("socks5 failed to send connect request"); - return -1; - } +bool ProxyBridge_EnableRule(uint32_t id) { return set_enabled(id, true); } +bool ProxyBridge_DisableRule(uint32_t id) { return set_enabled(id, false); } - int reply = -1; - if (socks5_read_connect_reply(s, &reply) != 0) - { - log_message("socks5 connect failed status %d", reply); - return -1; +bool ProxyBridge_DeleteRule(uint32_t id) +{ + size_t i; + pthread_rwlock_wrlock(&G.lock); + struct pb_rule *r = find_rule_locked(id, &i); + if (r) { + pb_rule_free(r); + memmove(&G.rules[i], &G.rules[i + 1], (G.nrules - i - 1) * sizeof *r); + G.nrules--; + sync_locked(); } - - return 0; + pthread_rwlock_unlock(&G.lock); + return r != NULL; } -static int http_connect(int s, uint32_t dest_ip, uint16_t dest_port) +bool ProxyBridge_MoveRule(uint32_t id, int new_index) { - char buf[HTTP_BUFFER_SIZE]; - char dest_ip_str[32]; - format_ip_address(dest_ip, dest_ip_str, sizeof(dest_ip_str)); - - int len = snprintf(buf, sizeof(buf), - "CONNECT %s:%d HTTP/1.1\r\n" - "Host: %s:%d\r\n" - "Proxy-Connection: Keep-Alive\r\n", - dest_ip_str, dest_port, dest_ip_str, dest_port); - - if (g_proxy_username[0] != '\0') - { - // encode user:pass in base64 - static const char b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - char auth_raw[512]; - int auth_raw_len = snprintf(auth_raw, sizeof(auth_raw), "%s:%s", g_proxy_username, g_proxy_password); - char auth_b64[700]; - int j = 0; - for (int i = 0; i < auth_raw_len; i += 3) { - unsigned int n = ((unsigned char)auth_raw[i]) << 16; - if (i + 1 < auth_raw_len) n |= ((unsigned char)auth_raw[i + 1]) << 8; - if (i + 2 < auth_raw_len) n |= ((unsigned char)auth_raw[i + 2]); - auth_b64[j++] = b64[(n >> 18) & 0x3F]; - auth_b64[j++] = b64[(n >> 12) & 0x3F]; - auth_b64[j++] = (i + 1 < auth_raw_len) ? b64[(n >> 6) & 0x3F] : '='; - auth_b64[j++] = (i + 2 < auth_raw_len) ? b64[n & 0x3F] : '='; - } - auth_b64[j] = '\0'; - len += snprintf(buf + len, sizeof(buf) - len, - "Proxy-Authorization: Basic %s\r\n", auth_b64); - } - - len += snprintf(buf + len, sizeof(buf) - len, "\r\n"); - - if (send_all(s, buf, len) < 0) - { - log_message("http failed to send connect"); - return -1; - } - - ssize_t recv_len = recv(s, buf, sizeof(buf) - 1, 0); - if (recv_len < 12) - { - log_message("http invalid response"); - return -1; - } - - buf[recv_len] = '\0'; - if (strncmp(buf, "HTTP/1.", 7) != 0) - { - log_message("http invalid response"); - return -1; + size_t i; + pthread_rwlock_wrlock(&G.lock); + struct pb_rule *r = find_rule_locked(id, &i); + if (r) { + size_t to = new_index < 0 ? 0 : (size_t)new_index >= G.nrules ? G.nrules - 1 : (size_t)new_index; + struct pb_rule moved = *r; + if (to < i) memmove(&G.rules[to + 1], &G.rules[to], (i - to) * sizeof moved); + else if (to > i) memmove(&G.rules[i], &G.rules[i + 1], (to - i) * sizeof moved); + G.rules[to] = moved; + sync_locked(); } - - int status_code = 0; - if (sscanf(buf + 9, "%d", &status_code) != 1 || status_code != 200) - { - log_message("http connect failed status %d", status_code); - return -1; - } - - return 0; + pthread_rwlock_unlock(&G.lock); + return r != NULL; } -// relay functions for production use - -// connection handler like windows - blocks on connect then transfers data -static void* connection_handler(void *arg) +void ProxyBridge_ClearRules(void) { - connection_config_t *config = (connection_config_t *)arg; - int client_sock = config->client_socket; - uint32_t dest_ip = config->orig_dest_ip; - uint16_t dest_port = config->orig_dest_port; - int proxy_sock; - struct sockaddr_in proxy_addr; - uint32_t proxy_ip; - - free(config); - - // use cached proxy ip we resolved earlier - proxy_ip = g_proxy_ip_cached; - if (proxy_ip == 0) - { - // try resolving again if cache failed - proxy_ip = resolve_hostname(g_proxy_host); - if (proxy_ip == 0) - { - close(client_sock); - return NULL; - } - g_proxy_ip_cached = proxy_ip; - } - - proxy_sock = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); - if (proxy_sock < 0) - { - close(client_sock); - return NULL; - } - - configure_tcp_socket(proxy_sock, 1048576, 60000); - configure_tcp_socket(client_sock, 1048576, 60000); - - memset(&proxy_addr, 0, sizeof(proxy_addr)); - proxy_addr.sin_family = AF_INET; - proxy_addr.sin_addr.s_addr = proxy_ip; - proxy_addr.sin_port = htons(g_proxy_port); - - if (connect(proxy_sock, (struct sockaddr *)&proxy_addr, sizeof(proxy_addr)) < 0) - { - close(client_sock); - close(proxy_sock); - return NULL; - } - - // do handshake blocking - if (g_proxy_type == PROXY_TYPE_SOCKS5) - { - if (socks5_connect(proxy_sock, dest_ip, dest_port) != 0) - { - close(client_sock); - close(proxy_sock); - return NULL; - } - } - else if (g_proxy_type == PROXY_TYPE_HTTP) - { - if (http_connect(proxy_sock, dest_ip, dest_port) != 0) - { - close(client_sock); - close(proxy_sock); - return NULL; - } - } - - // Data transfer phase (runs for both SOCKS5 and HTTP once the handshake succeeds). - // Disable timeout for data transfer phase - struct timeval zero_timeout = {0, 0}; - setsockopt(proxy_sock, SOL_SOCKET, SO_RCVTIMEO, &zero_timeout, sizeof(zero_timeout)); - setsockopt(proxy_sock, SOL_SOCKET, SO_SNDTIMEO, &zero_timeout, sizeof(zero_timeout)); - setsockopt(client_sock, SOL_SOCKET, SO_RCVTIMEO, &zero_timeout, sizeof(zero_timeout)); - setsockopt(client_sock, SOL_SOCKET, SO_SNDTIMEO, &zero_timeout, sizeof(zero_timeout)); - - // Enable and configure customized TCP keep-alives - int keepalive = 1; - int keepidle = 300; // 5 minutes in seconds - int keepintvl = 1; // 1 second interval - int keepcnt = 5; // 5 probes before dropping - setsockopt(proxy_sock, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive)); - setsockopt(proxy_sock, SOL_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle)); - setsockopt(proxy_sock, SOL_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl)); - setsockopt(proxy_sock, SOL_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt)); - setsockopt(client_sock, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive)); - setsockopt(client_sock, SOL_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle)); - setsockopt(client_sock, SOL_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl)); - setsockopt(client_sock, SOL_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt)); - - // setup transfer config - transfer_config_t *transfer_config = (transfer_config_t *)malloc(sizeof(transfer_config_t)); - if (transfer_config == NULL) - { - close(client_sock); - close(proxy_sock); - return NULL; - } - - transfer_config->from_socket = client_sock; - transfer_config->to_socket = proxy_sock; - - // transfer data both ways in this thread - transfer_handler((void*)transfer_config); - - return NULL; + pthread_rwlock_wrlock(&G.lock); + for (size_t i = 0; i < G.nrules; i++) pb_rule_free(&G.rules[i]); + G.nrules = 0; + sync_locked(); + pthread_rwlock_unlock(&G.lock); } -// relay data both ways using splice for zero-copy -// data goes kernel to kernel thru pipe, never hits userspace -// way faster than copying thru userspace buffers -static void* transfer_handler(void *arg) +// ---- runtime ------------------------------------------------------------------------------------------------------------- +void ProxyBridge_SetDnsViaProxy(bool enable) { - transfer_config_t *config = (transfer_config_t *)arg; - int sock1 = config->from_socket; // client socket - int sock2 = config->to_socket; // proxy socket - free(config); - - // make 2 pipes for splice - // pipe_a: proxy to client (download) - // pipe_b: client to proxy (upload) - int pipe_a[2] = {-1, -1}; - int pipe_b[2] = {-1, -1}; - - if (pipe2(pipe_a, O_CLOEXEC | O_NONBLOCK) < 0 || - pipe2(pipe_b, O_CLOEXEC | O_NONBLOCK) < 0) { - // pipe failed, cleanup and fallback - if (pipe_a[0] >= 0) { close(pipe_a[0]); close(pipe_a[1]); } - if (pipe_b[0] >= 0) { close(pipe_b[0]); close(pipe_b[1]); } - goto fallback; - } - - // make pipes bigger for better speed (64kb to 1mb) - fcntl(pipe_a[0], F_SETPIPE_SZ, 1048576); - fcntl(pipe_b[0], F_SETPIPE_SZ, 1048576); - - // set sockets to nonblocking for splice - fcntl(sock1, F_SETFL, fcntl(sock1, F_GETFL, 0) | O_NONBLOCK); - fcntl(sock2, F_SETFL, fcntl(sock2, F_GETFL, 0) | O_NONBLOCK); - - { - struct pollfd fds[2]; - ssize_t pipe_a_bytes = 0; // bytes in download pipe (proxy→client) - ssize_t pipe_b_bytes = 0; // bytes in upload pipe (client→proxy) - bool sock1_done = false; // client EOF or error - bool sock2_done = false; // proxy EOF or error - bool shut_wr_sock1 = false; // already called shutdown(sock1, SHUT_WR) - bool shut_wr_sock2 = false; // already called shutdown(sock2, SHUT_WR) - - while (1) - { - // build poll set, use fd=-1 to skip closed sockets - // important: poll reports POLLHUP even with events=0 - // so use fd=-1 to actually skip it or we get busy loop - fds[0].fd = (!sock1_done || pipe_a_bytes > 0 || pipe_b_bytes > 0) ? sock1 : -1; - fds[1].fd = (!sock2_done || pipe_b_bytes > 0 || pipe_a_bytes > 0) ? sock2 : -1; - fds[0].events = 0; - fds[1].events = 0; - fds[0].revents = 0; - fds[1].revents = 0; - - // download: proxy to client - if (!sock2_done && pipe_a_bytes == 0) - fds[1].events |= POLLIN; // read from proxy - if (pipe_a_bytes > 0) - fds[0].events |= POLLOUT; // write to client - - // upload: client to proxy - if (!sock1_done && pipe_b_bytes == 0) - fds[0].events |= POLLIN; // read from client - if (pipe_b_bytes > 0) - fds[1].events |= POLLOUT; // write to proxy - - // all done - if (fds[0].fd == -1 && fds[1].fd == -1) - break; - if (fds[0].events == 0 && fds[1].events == 0) - break; - - int ready = poll(fds, 2, -1); - if (ready < 0) { - if (errno == EINTR) continue; - break; - } - - // check for errors and hangups - if (fds[0].revents & POLLERR) break; - if (fds[1].revents & POLLERR) break; - - // pollhup means other side closed - if ((fds[1].revents & POLLHUP) && !(fds[1].revents & POLLIN)) - sock2_done = true; - if ((fds[0].revents & POLLHUP) && !(fds[0].revents & POLLIN)) - sock1_done = true; - - // download path - - if (!sock2_done && pipe_a_bytes == 0 && (fds[1].revents & POLLIN)) { - ssize_t n = splice(sock2, NULL, pipe_a[1], NULL, 1048576, - SPLICE_F_MOVE | SPLICE_F_NONBLOCK); - if (n > 0) { - pipe_a_bytes = n; - } else if (n == 0) { - sock2_done = true; - } else if (errno != EAGAIN && errno != EWOULDBLOCK) { - sock2_done = true; - } - } - - if (pipe_a_bytes > 0 && (fds[0].revents & POLLOUT)) { - ssize_t n = splice(pipe_a[0], NULL, sock1, NULL, pipe_a_bytes, - SPLICE_F_MOVE | SPLICE_F_NONBLOCK); - if (n > 0) { - pipe_a_bytes -= n; - } else if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK) { - break; - } - } - - // upload path - - if (!sock1_done && pipe_b_bytes == 0 && (fds[0].revents & POLLIN)) { - ssize_t n = splice(sock1, NULL, pipe_b[1], NULL, 1048576, - SPLICE_F_MOVE | SPLICE_F_NONBLOCK); - if (n > 0) { - pipe_b_bytes = n; - } else if (n == 0) { - sock1_done = true; - } else if (errno != EAGAIN && errno != EWOULDBLOCK) { - sock1_done = true; - } - } - - if (pipe_b_bytes > 0 && (fds[1].revents & POLLOUT)) { - ssize_t n = splice(pipe_b[0], NULL, sock2, NULL, pipe_b_bytes, - SPLICE_F_MOVE | SPLICE_F_NONBLOCK); - if (n > 0) { - pipe_b_bytes -= n; - } else if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK) { - break; - } - } - - // half-close when one side done and pipe empty - // tell other side with shutdown - if (sock2_done && pipe_a_bytes == 0 && !shut_wr_sock1) { - shutdown(sock1, SHUT_WR); - shut_wr_sock1 = true; - } - if (sock1_done && pipe_b_bytes == 0 && !shut_wr_sock2) { - shutdown(sock2, SHUT_WR); - shut_wr_sock2 = true; - } - - // both sides finished and pipes empty - if (sock1_done && sock2_done && pipe_a_bytes == 0 && pipe_b_bytes == 0) - break; - } - } - - close(pipe_a[0]); close(pipe_a[1]); - close(pipe_b[0]); close(pipe_b[1]); - goto cleanup; - -fallback: - // fallback to normal recv/send if pipes didnt work - { - char buf[131072]; - struct pollfd fds[2]; - fds[0].fd = sock1; - fds[0].events = POLLIN; - fds[1].fd = sock2; - fds[1].events = POLLIN; - - while (1) { - int ready = poll(fds, 2, -1); - if (ready < 0) { - if (errno == EINTR) continue; - break; - } - - if (fds[0].revents & POLLERR || fds[1].revents & POLLERR) break; - - bool did_work = false; - if (fds[0].revents & POLLIN) { - ssize_t n = recv(sock1, buf, sizeof(buf), MSG_NOSIGNAL); - if (n <= 0) break; - if (send_all(sock2, buf, n) < 0) break; - did_work = true; - } - if (fds[1].revents & POLLIN) { - ssize_t n = recv(sock2, buf, sizeof(buf), MSG_NOSIGNAL); - if (n <= 0) break; - if (send_all(sock1, buf, n) < 0) break; - did_work = true; - } - - // pollhup with no data means peer closed - if (!did_work) break; - } - } - -cleanup: - shutdown(sock1, SHUT_RDWR); - shutdown(sock2, SHUT_RDWR); - close(sock1); - close(sock2); - return NULL; + atomic_store(&G.dns_via_proxy, enable); + pb_bpf_set_flags(atomic_load(&G.traffic_logging), enable); } -// proxy server accepts connections and spawns threads -static void* local_proxy_server(void *arg) +void ProxyBridge_SetLogCallback(LogCallback cb) { - (void)arg; - struct sockaddr_in addr; - int listen_sock; - int on = 1; - - listen_sock = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); - if (listen_sock < 0) - { - log_message("Socket creation failed"); - return NULL; - } - - setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)); - setsockopt(listen_sock, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on)); - - memset(&addr, 0, sizeof(addr)); - addr.sin_family = AF_INET; - // loopback only, INADDR_ANY was reachable from the lan - addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - addr.sin_port = htons(g_local_relay_port); - - if (bind(listen_sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) - { - log_message("Bind failed"); - close(listen_sock); - return NULL; - } - - if (listen(listen_sock, SOMAXCONN) < 0) - { - log_message("Listen failed"); - close(listen_sock); - return NULL; - } - - // create thread attrs with small stack (256kb not 8mb) - // relay threads dont need big buffers anymore cuz splice - pthread_attr_t thread_attr; - pthread_attr_init(&thread_attr); - pthread_attr_setdetachstate(&thread_attr, PTHREAD_CREATE_DETACHED); - pthread_attr_setstacksize(&thread_attr, 262144); // 256KB stack - - struct pollfd pfd; - pfd.fd = listen_sock; - pfd.events = POLLIN; - - while (running) - { - int ready = poll(&pfd, 1, 1000); // 1s timeout - if (ready <= 0) - continue; - - struct sockaddr_in client_addr; - socklen_t addr_len = sizeof(client_addr); - int client_sock = accept4(listen_sock, (struct sockaddr *)&client_addr, &addr_len, SOCK_CLOEXEC); - - if (client_sock < 0) - continue; - - connection_config_t *conn_config = (connection_config_t *)malloc(sizeof(connection_config_t)); - if (conn_config == NULL) - { - close(client_sock); - continue; - } - - conn_config->client_socket = client_sock; - - uint16_t client_port = ntohs(client_addr.sin_port); - if (!get_connection(client_port, false, &conn_config->orig_dest_ip, &conn_config->orig_dest_port)) - { - close(client_sock); - free(conn_config); - continue; - } - - pthread_t conn_thread; - if (pthread_create(&conn_thread, &thread_attr, connection_handler, (void*)conn_config) != 0) - { - close(client_sock); - free(conn_config); - continue; - } - } - - pthread_attr_destroy(&thread_attr); - close(listen_sock); - return NULL; + pthread_mutex_lock(&cb_lock); + G.log_cb = cb; + pthread_mutex_unlock(&cb_lock); } -// socks5 udp associate -static int socks5_udp_associate(int s, struct sockaddr_in *relay_addr) +void ProxyBridge_SetConnectionCallback(ConnectionCallback cb) { - unsigned char buf[SOCKS5_BUFFER_SIZE]; // must hold 3 + 255 + 255 auth bytes - - // auth handshake - bool use_auth = (g_proxy_username[0] != '\0'); - buf[0] = SOCKS5_VERSION; - buf[1] = use_auth ? 0x02 : 0x01; - buf[2] = SOCKS5_AUTH_NONE; - if (use_auth) - buf[3] = 0x02; // username/password auth - - if (send(s, buf, use_auth ? 4 : 3, MSG_NOSIGNAL) != (use_auth ? 4 : 3)) - return -1; - - if (recv_n(s, buf, 2) != 2 || buf[0] != SOCKS5_VERSION) - return -1; - - if (buf[1] == 0x02 && use_auth) - { - size_t ulen = strlen(g_proxy_username); - size_t plen = strlen(g_proxy_password); - if (ulen > 255 || plen > 255) - return -1; - buf[0] = 0x01; - buf[1] = (unsigned char)ulen; - memcpy(buf + 2, g_proxy_username, ulen); - buf[2 + ulen] = (unsigned char)plen; - memcpy(buf + 3 + ulen, g_proxy_password, plen); - - if (send(s, buf, 3 + ulen + plen, MSG_NOSIGNAL) != (ssize_t)(3 + ulen + plen)) - return -1; - - if (recv_n(s, buf, 2) != 2 || buf[0] != 0x01 || buf[1] != 0x00) - return -1; - } - else if (buf[1] != SOCKS5_AUTH_NONE) - return -1; - - // udp associate request - buf[0] = SOCKS5_VERSION; - buf[1] = SOCKS5_CMD_UDP_ASSOCIATE; - buf[2] = 0x00; - buf[3] = SOCKS5_ATYP_IPV4; - memset(buf + 4, 0, 4); // 0.0.0.0 - memset(buf + 8, 0, 2); // port 0 - - if (send(s, buf, 10, MSG_NOSIGNAL) != 10) - return -1; - - // Reply: VER REP RSV ATYP BND.ADDR BND.PORT. Parse the header then the bound - // relay endpoint; we only use IPv4 relay addresses. - unsigned char rep[4]; - if (recv_n(s, rep, 4) != 4 || rep[0] != SOCKS5_VERSION || rep[1] != 0x00) - return -1; - if (rep[3] != SOCKS5_ATYP_IPV4) - return -1; - unsigned char ap[6]; - if (recv_n(s, ap, 6) != 6) - return -1; - - memset(relay_addr, 0, sizeof(*relay_addr)); - relay_addr->sin_family = AF_INET; - memcpy(&relay_addr->sin_addr.s_addr, ap, 4); - memcpy(&relay_addr->sin_port, ap + 4, 2); - return 0; + pthread_mutex_lock(&cb_lock); + G.conn_cb = cb; + pthread_mutex_unlock(&cb_lock); } -static bool establish_udp_associate(void) +void ProxyBridge_SetTrafficLoggingEnabled(bool enable) { - uint64_t now = get_monotonic_ms(); - if (now - last_udp_connect_attempt < 5000) - return false; - - last_udp_connect_attempt = now; - - if (socks5_udp_control_socket >= 0) - { - close(socks5_udp_control_socket); - socks5_udp_control_socket = -1; - } - if (socks5_udp_send_socket >= 0) - { - close(socks5_udp_send_socket); - socks5_udp_send_socket = -1; - } - - int tcp_sock = socket(AF_INET, SOCK_STREAM, 0); - if (tcp_sock < 0) - return false; - - configure_tcp_socket(tcp_sock, 262144, 3000); - - uint32_t socks5_ip = resolve_hostname(g_proxy_host); - if (socks5_ip == 0) - { - close(tcp_sock); - return false; - } + atomic_store(&G.traffic_logging, enable); + pb_bpf_set_flags(enable, atomic_load(&G.dns_via_proxy)); +} - struct sockaddr_in socks_addr; - memset(&socks_addr, 0, sizeof(socks_addr)); - socks_addr.sin_family = AF_INET; - socks_addr.sin_addr.s_addr = socks5_ip; - socks_addr.sin_port = htons(g_proxy_port); +void ProxyBridge_ClearConnectionLogs(void) {} - if (connect(tcp_sock, (struct sockaddr *)&socks_addr, sizeof(socks_addr)) < 0) - { - close(tcp_sock); - return false; +static bool proxies_missing_locked(void) +{ + for (size_t i = 0; i < G.nrules; i++) { + const struct pb_rule *r = &G.rules[i]; + if (r->enabled && r->action == RULE_ACTION_PROXY && !proxy_for_rule_locked(r)) return true; } + return false; +} - if (socks5_udp_associate(tcp_sock, &socks5_udp_relay_addr) != 0) - { - close(tcp_sock); +bool ProxyBridge_Start(void) +{ + pthread_rwlock_wrlock(&G.lock); + if (G.running) { pthread_rwlock_unlock(&G.lock); return true; } + if (proxies_missing_locked()) { + pthread_rwlock_unlock(&G.lock); + pb_core_log("cannot start: a rule uses a proxy but no proxy is configured"); return false; } - - // rfc says if server gives 0.0.0.0 use proxy servers ip instead - if (socks5_udp_relay_addr.sin_addr.s_addr == INADDR_ANY) - socks5_udp_relay_addr.sin_addr.s_addr = socks5_ip; - - // Handshake done - remove the 3s timeout so the control socket stays open indefinitely. - struct timeval zero_tv = {0, 0}; - setsockopt(tcp_sock, SOL_SOCKET, SO_RCVTIMEO, &zero_tv, sizeof(zero_tv)); - setsockopt(tcp_sock, SOL_SOCKET, SO_SNDTIMEO, &zero_tv, sizeof(zero_tv)); - - // Enable TCP keepalives so the SOCKS5 proxy doesn't idle-close the control - // connection (many proxies terminate it after ~60s of silence, killing UDP ASSOCIATE). - int ka_on = 1; - int ka_idle = 10; // start keepalives after 10s idle - int ka_intvl = 2; // send keepalive every 2s - int ka_cnt = 5; // drop after 5 missed keepalives - setsockopt(tcp_sock, SOL_SOCKET, SO_KEEPALIVE, &ka_on, sizeof(ka_on)); - setsockopt(tcp_sock, SOL_TCP, TCP_KEEPIDLE, &ka_idle, sizeof(ka_idle)); - setsockopt(tcp_sock, SOL_TCP, TCP_KEEPINTVL, &ka_intvl, sizeof(ka_intvl)); - setsockopt(tcp_sock, SOL_TCP, TCP_KEEPCNT, &ka_cnt, sizeof(ka_cnt)); - - socks5_udp_control_socket = tcp_sock; - - socks5_udp_send_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); - if (socks5_udp_send_socket < 0) - { - close(socks5_udp_control_socket); - socks5_udp_control_socket = -1; + G.running = true; // relay callbacks may fire as soon as the relay is up + pthread_rwlock_unlock(&G.lock); + + bool relay_up = false; + for (uint16_t base = PB_TCP_RELAY_PORT_DEFAULT; base < PB_TCP_RELAY_PORT_DEFAULT + 20 && !relay_up; base += 2) { + struct pb_relay_cfg rc = { .tcp_port = base, .udp_port = (uint16_t)(base + 1) }; + relay_up = pb_relay_start(&rc) == 0; + } + struct pb_bpf_cfg bc = { .tcp_port = pb_relay_tcp_port(), .udp_port = pb_relay_udp_port(), .self_tgid = (uint32_t)getpid(), + .log = atomic_load(&G.traffic_logging), .dns_via_proxy = atomic_load(&G.dns_via_proxy) }; + if (!relay_up || pb_bpf_start(&bc, on_bpf_event) != 0) { + if (relay_up) pb_relay_stop(); + pthread_rwlock_wrlock(&G.lock); + G.running = false; + pthread_rwlock_unlock(&G.lock); return false; } - configure_udp_socket(socks5_udp_send_socket, 262144, 30000); + pthread_mutex_lock(&G.maint_mu); + G.maint_stop = false; + pthread_mutex_unlock(&G.maint_mu); + G.maint_started = pthread_create(&G.maint, NULL, maint_loop, NULL) == 0; - udp_associate_connected = true; - log_message("UDP ASSOCIATE established with SOCKS5 proxy"); + pthread_rwlock_wrlock(&G.lock); + sync_locked(); + pthread_rwlock_unlock(&G.lock); + pb_core_log("ProxyBridge started (eBPF datapath, relay tcp:%u udp:%u)", pb_relay_tcp_port(), pb_relay_udp_port()); return true; } -// teardown udp associate so next packet reconnects -static void teardown_udp_associate(void) +bool ProxyBridge_Stop(void) { - udp_associate_connected = false; - if (socks5_udp_control_socket >= 0) - { - close(socks5_udp_control_socket); - socks5_udp_control_socket = -1; - } - if (socks5_udp_send_socket >= 0) - { - close(socks5_udp_send_socket); - socks5_udp_send_socket = -1; - } + pthread_rwlock_wrlock(&G.lock); + bool was = G.running; + G.running = false; + G.nwarned = 0; + pthread_rwlock_unlock(&G.lock); + if (!was) return true; + + if (G.maint_started) { + pthread_mutex_lock(&G.maint_mu); + G.maint_stop = true; + pthread_cond_signal(&G.maint_cv); + pthread_mutex_unlock(&G.maint_mu); + pthread_join(G.maint, NULL); + G.maint_started = false; + } + pb_bpf_detach(); // no new flows get redirected + pb_relay_stop(); // relay threads may use the maps until they are gone + pb_bpf_stop(); + pb_core_log("ProxyBridge stopped"); + return true; } -static void* udp_relay_server(void *arg) +int ProxyBridge_TestProxy(uint32_t proxy_id, const char *host, uint16_t port, char *buf, size_t n) { - (void)arg; - struct sockaddr_in local_addr, from_addr; - unsigned char recv_buf[65536]; - unsigned char send_buf[65536]; - socklen_t from_len; - - udp_relay_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); - if (udp_relay_socket < 0) - return NULL; - - int on = 1; - setsockopt(udp_relay_socket, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)); - configure_udp_socket(udp_relay_socket, 262144, 30000); - - memset(&local_addr, 0, sizeof(local_addr)); - local_addr.sin_family = AF_INET; - local_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - local_addr.sin_port = htons(LOCAL_UDP_RELAY_PORT); - - if (bind(udp_relay_socket, (struct sockaddr *)&local_addr, sizeof(local_addr)) < 0) - { - close(udp_relay_socket); - udp_relay_socket = -1; - return NULL; - } - - // try initial connect, not fatal if proxy not up yet - udp_associate_connected = establish_udp_associate(); - - while (running) - { - struct pollfd fds[3]; - int nfds = 1; - - // watch local relay socket for client packets - fds[0].fd = udp_relay_socket; - fds[0].events = POLLIN; - fds[0].revents = 0; - - // watch socks5 udp socket for proxy responses - fds[1].fd = (udp_associate_connected && socks5_udp_send_socket >= 0) ? socks5_udp_send_socket : -1; - fds[1].events = POLLIN; - fds[1].revents = 0; - - // watch socks5 tcp socket to detect if connection dies - fds[2].fd = (udp_associate_connected && socks5_udp_control_socket >= 0) ? socks5_udp_control_socket : -1; - fds[2].events = POLLIN; - fds[2].revents = 0; - nfds = 3; - - int ready = poll(fds, nfds, 1000); // 1s timeout - if (ready <= 0) - { - // Proactively reconnect so the next client packet is not dropped. - if (!udp_associate_connected) - udp_associate_connected = establish_udp_associate(); - continue; - } - - // check if tcp control still alive - // if it dies the udp associate is dead too - if (fds[2].fd >= 0 && (fds[2].revents & (POLLIN | POLLHUP | POLLERR))) - { - char peek_buf[1]; - ssize_t peek_len = recv(socks5_udp_control_socket, peek_buf, 1, MSG_PEEK | MSG_DONTWAIT); - if (peek_len == 0 || (peek_len < 0 && errno != EAGAIN && errno != EWOULDBLOCK)) - { - // tcp died - tear down and reconnect immediately so real-time streams - // lose as few packets as possible. - teardown_udp_associate(); - udp_associate_connected = establish_udp_associate(); - continue; - } - } + struct pb_proxy p; + if (pb_core_get_proxy(proxy_id, &p) != 0) { snprintf(buf, n, "no proxy configured"); return -1; } + return pb_relay_test_proxy(&p, host, port, buf, n); +} - // client sending to proxy - if (fds[0].revents & POLLIN) - { - from_len = sizeof(from_addr); - ssize_t recv_len = recvfrom(udp_relay_socket, recv_buf, sizeof(recv_buf), 0, - (struct sockaddr *)&from_addr, &from_len); - if (recv_len <= 0) - continue; - - // try to connect if not connected yet - if (!udp_associate_connected) - { - if (!establish_udp_associate()) - continue; - } - - uint16_t client_port = ntohs(from_addr.sin_port); - uint32_t dest_ip; - uint16_t dest_port; - - if (!get_connection(client_port, true, &dest_ip, &dest_port)) - continue; - - // make sure data fits with socks5 header - if (recv_len > (ssize_t)(sizeof(send_buf) - 10)) - continue; - - // build socks5 udp packet header - send_buf[0] = 0x00; // RSV - send_buf[1] = 0x00; // RSV - send_buf[2] = 0x00; // FRAG - send_buf[3] = SOCKS5_ATYP_IPV4; - memcpy(send_buf + 4, &dest_ip, 4); - uint16_t port_net = htons(dest_port); - memcpy(send_buf + 8, &port_net, 2); - memcpy(send_buf + 10, recv_buf, recv_len); - - ssize_t sent = sendto(socks5_udp_send_socket, send_buf, 10 + recv_len, 0, - (struct sockaddr *)&socks5_udp_relay_addr, sizeof(socks5_udp_relay_addr)); - - if (sent < 0) - { - // sendto failed - proxy likely died. Tear down, reconnect immediately - // and retry the current packet so real-time streams lose at most one packet. - teardown_udp_associate(); - if (establish_udp_associate()) - { - udp_associate_connected = true; - sendto(socks5_udp_send_socket, send_buf, (size_t)(10 + recv_len), 0, - (struct sockaddr *)&socks5_udp_relay_addr, sizeof(socks5_udp_relay_addr)); - } - } - else if (socks5_udp_send_socket >= 0) - { - // After sending (especially on a freshly established association), the proxy - // may respond within the same poll() cycle before the socket was in fds[]. - // Do a non-blocking check now so we don't add an extra poll() round-trip. - struct pollfd quick = { socks5_udp_send_socket, POLLIN, 0 }; - if (poll(&quick, 1, 0) > 0) - fds[1].revents |= POLLIN; - } - } - - // proxy sending back to client - if (fds[1].fd >= 0 && (fds[1].revents & POLLIN)) - { - from_len = sizeof(from_addr); - ssize_t recv_len = recvfrom(socks5_udp_send_socket, recv_buf, sizeof(recv_buf), 0, - (struct sockaddr *)&from_addr, &from_len); - - // socket error, proxy might be dead - if (recv_len < 0) - { - if (errno != EAGAIN && errno != EWOULDBLOCK) - teardown_udp_associate(); - continue; - } - if (recv_len < 10) - continue; - - // we dont support fragmented packets - if (recv_buf[2] != 0x00) - continue; - - // parse socks5 udp packet - if (recv_buf[3] != SOCKS5_ATYP_IPV4) - continue; - - uint32_t src_ip; - uint16_t src_port; - memcpy(&src_ip, recv_buf + 4, 4); - memcpy(&src_port, recv_buf + 8, 2); - src_port = ntohs(src_port); - - // find which client sent packet to this destination - // loop thru hash table looking for dest match - pthread_rwlock_rdlock(&conn_lock); - - struct sockaddr_in client_addr; - bool found_client = false; - - for (int hash = 0; hash < CONNECTION_HASH_SIZE; hash++) - { - CONNECTION_INFO *conn = connection_hash_table[hash]; - while (conn != NULL) - { - if (conn->is_udp && - conn->orig_dest_ip == src_ip && - conn->orig_dest_port == src_port) - { - // found it, send response back to original client port - memset(&client_addr, 0, sizeof(client_addr)); - client_addr.sin_family = AF_INET; - client_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - client_addr.sin_port = htons(conn->src_port); - // Keep session alive while proxy delivers data (benign relaxed write under read lock) - conn->last_activity = get_monotonic_ms(); - found_client = true; - break; - } - conn = conn->next; - } - if (found_client) - break; - } - - pthread_rwlock_unlock(&conn_lock); - - if (found_client) - { - // send unwrapped data back to client - ssize_t data_len = recv_len - 10; - sendto(udp_relay_socket, recv_buf + 10, data_len, 0, - (struct sockaddr *)&client_addr, sizeof(client_addr)); - } - } - - // handle errors on udp send socket - if (fds[1].fd >= 0 && (fds[1].revents & (POLLHUP | POLLERR))) - { - teardown_udp_associate(); - } - } - - teardown_udp_associate(); - if (udp_relay_socket >= 0) - close(udp_relay_socket); - - return NULL; -} - -// nfqueue callback for packets -static int packet_callback(struct nfq_q_handle *qh, struct nfgenmsg *nfmsg, struct nfq_data *nfad, void *data) -{ - (void)nfmsg; - (void)data; - - struct nfqnl_msg_packet_hdr *ph = nfq_get_msg_packet_hdr(nfad); - if (!ph) return nfq_set_verdict(qh, 0, NF_ACCEPT, 0, NULL); - - uint32_t id = ntohl(ph->packet_id); - - unsigned char *payload; - int payload_len = nfq_get_payload(nfad, &payload); - if (payload_len < (int)sizeof(struct iphdr)) - return nfq_set_verdict(qh, id, NF_ACCEPT, 0, NULL); - - struct iphdr *iph = (struct iphdr *)payload; - - // fast path when no rules - if (!g_has_active_rules && g_connection_callback == NULL) - return nfq_set_verdict(qh, id, NF_ACCEPT, 0, NULL); - - uint32_t src_ip = iph->saddr; - uint32_t dest_ip = iph->daddr; - uint16_t src_port = 0; - uint16_t dest_port = 0; - RuleAction action = RULE_ACTION_DIRECT; - uint32_t pid = 0; - - if (iph->protocol == IPPROTO_TCP) - { - if (payload_len < (int)(iph->ihl * 4 + sizeof(struct tcphdr))) - return nfq_set_verdict(qh, id, NF_ACCEPT, 0, NULL); - - struct tcphdr *tcph = (struct tcphdr *)(payload + iph->ihl * 4); - src_port = ntohs(tcph->source); - dest_port = ntohs(tcph->dest); - - // skip our own packets from local relay - if (src_port == g_local_relay_port) - return nfq_set_verdict(qh, id, NF_ACCEPT, 0, NULL); - - if (is_connection_tracked(src_port, false)) - return nfq_set_verdict(qh, id, NF_ACCEPT, 0, NULL); - - // only look at syn packets for new connections - if (!(tcph->syn && !tcph->ack)) - return nfq_set_verdict(qh, id, NF_ACCEPT, 0, NULL); - - if (dest_port == 53 && !g_dns_via_proxy) - action = RULE_ACTION_DIRECT; - else - action = check_process_rule(src_ip, src_port, dest_ip, dest_port, false, &pid); - - if (action == RULE_ACTION_PROXY && is_broadcast_or_multicast(dest_ip)) - action = RULE_ACTION_DIRECT; - - // log it if not from our own process - if (g_traffic_logging_enabled && g_connection_callback != NULL && (tcph->syn && !tcph->ack) && pid > 0 && pid != g_current_process_id) - { - char process_name[MAX_PROCESS_NAME]; - if (get_process_name_from_pid(pid, process_name, sizeof(process_name))) - { - if (!is_connection_already_logged(pid, dest_ip, dest_port, action)) - { - char dest_ip_str[32]; - format_ip_address(dest_ip, dest_ip_str, sizeof(dest_ip_str)); - - char proxy_info[300]; - if (action == RULE_ACTION_PROXY) - { - snprintf(proxy_info, sizeof(proxy_info), "proxy %s://%s:%d tcp", - g_proxy_type == PROXY_TYPE_HTTP ? "http" : "socks5", - g_proxy_host, g_proxy_port); - } - else if (action == RULE_ACTION_DIRECT) - { - snprintf(proxy_info, sizeof(proxy_info), "direct tcp"); - } - else if (action == RULE_ACTION_BLOCK) - { - snprintf(proxy_info, sizeof(proxy_info), "blocked tcp"); - } - - const char* display_name = extract_filename(process_name); - g_connection_callback(display_name, pid, dest_ip_str, dest_port, proxy_info); - - add_logged_connection(pid, dest_ip, dest_port, action); - } - } - } - - if (action == RULE_ACTION_DIRECT) - return nfq_set_verdict(qh, id, NF_ACCEPT, 0, NULL); - else if (action == RULE_ACTION_BLOCK) - return nfq_set_verdict(qh, id, NF_DROP, 0, NULL); - else if (action == RULE_ACTION_PROXY) - { - // store connection info - add_connection(src_port, false, src_ip, dest_ip, dest_port); - - // mark packet so nat table REDIRECT rule will catch it - uint32_t mark = 1; - return nfq_set_verdict2(qh, id, NF_ACCEPT, mark, 0, NULL); - } - } - else if (iph->protocol == IPPROTO_UDP) - { - if (payload_len < (int)(iph->ihl * 4 + sizeof(struct udphdr))) - return nfq_set_verdict(qh, id, NF_ACCEPT, 0, NULL); - - struct udphdr *udph = (struct udphdr *)(payload + iph->ihl * 4); - src_port = ntohs(udph->source); - dest_port = ntohs(udph->dest); - - if (src_port == LOCAL_UDP_RELAY_PORT) - return nfq_set_verdict(qh, id, NF_ACCEPT, 0, NULL); - - if (is_connection_tracked(src_port, true)) - return nfq_set_verdict(qh, id, NF_ACCEPT, 0, NULL); - - if (dest_port == 53 && !g_dns_via_proxy) - action = RULE_ACTION_DIRECT; - else - action = check_process_rule(src_ip, src_port, dest_ip, dest_port, true, &pid); - - if (action == RULE_ACTION_PROXY && is_broadcast_or_multicast(dest_ip)) - action = RULE_ACTION_DIRECT; - - if (action == RULE_ACTION_PROXY && (dest_port == 67 || dest_port == 68)) - action = RULE_ACTION_DIRECT; - - // UDP proxy only works with SOCKS5, not HTTP - if (action == RULE_ACTION_PROXY && g_proxy_type != PROXY_TYPE_SOCKS5) - action = RULE_ACTION_DIRECT; - - // log (skip our own process, log even without PID for ephemeral UDP sockets) - if (g_traffic_logging_enabled && g_connection_callback != NULL && pid != g_current_process_id) - { - char process_name[MAX_PROCESS_NAME]; - uint32_t log_pid = (pid == 0) ? 1 : pid; // Use PID 1 for unknown processes - - if (pid > 0 && get_process_name_from_pid(pid, process_name, sizeof(process_name))) - { - // Got process name from PID - } - else - { - // UDP socket not found - ephemeral or timing issue - snprintf(process_name, sizeof(process_name), "unknown"); - } - - if (!is_connection_already_logged(log_pid, dest_ip, dest_port, action)) - { - char dest_ip_str[32]; - format_ip_address(dest_ip, dest_ip_str, sizeof(dest_ip_str)); - - char proxy_info[300]; - if (action == RULE_ACTION_PROXY) - { - snprintf(proxy_info, sizeof(proxy_info), "proxy socks5://%s:%d udp", - g_proxy_host, g_proxy_port); - } - else if (action == RULE_ACTION_DIRECT) - { - snprintf(proxy_info, sizeof(proxy_info), "direct udp"); - } - else if (action == RULE_ACTION_BLOCK) - { - snprintf(proxy_info, sizeof(proxy_info), "blocked udp"); - } - - const char* display_name = extract_filename(process_name); - g_connection_callback(display_name, log_pid, dest_ip_str, dest_port, proxy_info); - - add_logged_connection(log_pid, dest_ip, dest_port, action); - } - } - - if (action == RULE_ACTION_DIRECT) - return nfq_set_verdict(qh, id, NF_ACCEPT, 0, NULL); - else if (action == RULE_ACTION_BLOCK) - return nfq_set_verdict(qh, id, NF_DROP, 0, NULL); - else if (action == RULE_ACTION_PROXY) - { - // UDP proxy via SOCKS5 UDP ASSOCIATE - add_connection(src_port, true, src_ip, dest_ip, dest_port); - - // Mark UDP packet for redirect to local UDP relay (port 34011) - uint32_t mark = 2; // Use mark=2 for UDP (mark=1 is for TCP) - return nfq_set_verdict2(qh, id, NF_ACCEPT, mark, 0, NULL); - } - } - - return nfq_set_verdict(qh, id, NF_ACCEPT, 0, NULL); -} - -static void* packet_processor(void *arg) -{ - (void)arg; - char buf[4096] __attribute__((aligned)); - int fd = nfq_fd(nfq_h); - ssize_t rv; - - while (running) - { - rv = recv(fd, buf, sizeof(buf), 0); - if (rv >= 0) - nfq_handle_packet(nfq_h, buf, rv); - // On error: ENOBUFS = kernel queue full (normal under load), - // EINTR = signal, other = transient. Always continue - - // stopping the thread would break all network traffic. - } - - return NULL; -} - -static inline uint32_t connection_hash(uint16_t port) -{ - return port % CONNECTION_HASH_SIZE; -} - -static void add_connection(uint16_t src_port, bool is_udp, uint32_t src_ip, uint32_t dest_ip, uint16_t dest_port) -{ - uint32_t hash = connection_hash(src_port); - pthread_rwlock_wrlock(&conn_lock); - - CONNECTION_INFO *conn = connection_hash_table[hash]; - while (conn != NULL) - { - if (conn->src_port == src_port && conn->is_udp == is_udp) - { - conn->orig_dest_ip = dest_ip; - conn->orig_dest_port = dest_port; - conn->src_ip = src_ip; - conn->is_tracked = true; - conn->last_activity = get_monotonic_ms(); - pthread_rwlock_unlock(&conn_lock); - return; - } - conn = conn->next; - } - - CONNECTION_INFO *new_conn = malloc(sizeof(CONNECTION_INFO)); - if (new_conn != NULL) - { - new_conn->src_port = src_port; - new_conn->is_udp = is_udp; - new_conn->src_ip = src_ip; - new_conn->orig_dest_ip = dest_ip; - new_conn->orig_dest_port = dest_port; - new_conn->is_tracked = true; - new_conn->last_activity = get_monotonic_ms(); - new_conn->next = connection_hash_table[hash]; - connection_hash_table[hash] = new_conn; - } - - pthread_rwlock_unlock(&conn_lock); -} - -static bool get_connection(uint16_t src_port, bool is_udp, uint32_t *dest_ip, uint16_t *dest_port) -{ - uint32_t hash = connection_hash(src_port); - pthread_rwlock_rdlock(&conn_lock); - - CONNECTION_INFO *conn = connection_hash_table[hash]; - while (conn != NULL) - { - if (conn->src_port == src_port && conn->is_udp == is_udp && conn->is_tracked) - { - *dest_ip = conn->orig_dest_ip; - *dest_port = conn->orig_dest_port; - conn->last_activity = get_monotonic_ms(); // benign race on timestamp - pthread_rwlock_unlock(&conn_lock); - return true; - } - conn = conn->next; - } - - pthread_rwlock_unlock(&conn_lock); - return false; -} - -static bool is_connection_tracked(uint16_t src_port, bool is_udp) -{ - uint32_t hash = connection_hash(src_port); - pthread_rwlock_rdlock(&conn_lock); - - CONNECTION_INFO *conn = connection_hash_table[hash]; - while (conn != NULL) - { - if (conn->src_port == src_port && conn->is_udp == is_udp && conn->is_tracked) - { - pthread_rwlock_unlock(&conn_lock); - return true; - } - conn = conn->next; - } - - pthread_rwlock_unlock(&conn_lock); - return false; -} - -static void __attribute__((unused)) remove_connection(uint16_t src_port) -{ - uint32_t hash = connection_hash(src_port); - pthread_rwlock_wrlock(&conn_lock); - - CONNECTION_INFO **conn_ptr = &connection_hash_table[hash]; - while (*conn_ptr != NULL) - { - if ((*conn_ptr)->src_port == src_port) - { - CONNECTION_INFO *to_free = *conn_ptr; - *conn_ptr = (*conn_ptr)->next; - free(to_free); - pthread_rwlock_unlock(&conn_lock); - return; - } - conn_ptr = &(*conn_ptr)->next; - } - - pthread_rwlock_unlock(&conn_lock); -} - -static void cleanup_stale_connections(void) -{ - uint64_t now = get_monotonic_ms(); - - // Cleanup connection hash table - for (int i = 0; i < CONNECTION_HASH_SIZE; i++) - { - pthread_rwlock_wrlock(&conn_lock); - CONNECTION_INFO **conn_ptr = &connection_hash_table[i]; - - while (*conn_ptr != NULL) - { - // 30 min idle timeout. Clean closes are removed on FIN/RST already; a short - // timeout would reap open-but-idle proxied connections (mail IDLE, voice, - // websocket) mid-session and silently break them until restart. - if (now - (*conn_ptr)->last_activity > 1800000) - { - CONNECTION_INFO *to_free = *conn_ptr; - *conn_ptr = (*conn_ptr)->next; - free(to_free); - } - else - { - conn_ptr = &(*conn_ptr)->next; - } - } - pthread_rwlock_unlock(&conn_lock); - } - - // Cleanup PID cache (separate lock - no contention with connection lookups) - uint64_t now_cache = get_monotonic_ms(); - for (int i = 0; i < PID_CACHE_SIZE; i++) - { - pthread_mutex_lock(&pid_cache_lock); - PID_CACHE_ENTRY **entry_ptr = &pid_cache[i]; - while (*entry_ptr != NULL) - { - if (now_cache - (*entry_ptr)->timestamp > 10000) // 10 sec cache TTL - { - PID_CACHE_ENTRY *to_free = *entry_ptr; - *entry_ptr = (*entry_ptr)->next; - free(to_free); - } - else - { - entry_ptr = &(*entry_ptr)->next; - } - } - pthread_mutex_unlock(&pid_cache_lock); - } - - // Keep only last 100 logged connections - pthread_mutex_lock(&log_lock); - int logged_count = 0; - LOGGED_CONNECTION *temp = logged_connections; - while (temp != NULL) - { - logged_count++; - temp = temp->next; - } - - if (logged_count > 100) - { - temp = logged_connections; - for (int i = 0; i < 99 && temp != NULL; i++) - { - temp = temp->next; - } - if (temp != NULL && temp->next != NULL) - { - LOGGED_CONNECTION *to_free = temp->next; - temp->next = NULL; - while (to_free != NULL) - { - LOGGED_CONNECTION *next = to_free->next; - free(to_free); - to_free = next; - } - } - } - pthread_mutex_unlock(&log_lock); -} - -static bool is_connection_already_logged(uint32_t pid, uint32_t dest_ip, uint16_t dest_port, RuleAction action) -{ - pthread_mutex_lock(&log_lock); - - LOGGED_CONNECTION *logged = logged_connections; - while (logged != NULL) - { - if (logged->pid == pid && logged->dest_ip == dest_ip && - logged->dest_port == dest_port && logged->action == action) - { - pthread_mutex_unlock(&log_lock); - return true; - } - logged = logged->next; - } - - pthread_mutex_unlock(&log_lock); - return false; -} - -static void add_logged_connection(uint32_t pid, uint32_t dest_ip, uint16_t dest_port, RuleAction action) -{ - pthread_mutex_lock(&log_lock); - - // keep only last 100 entries to avoid memory growth - int count = 0; - LOGGED_CONNECTION *temp = logged_connections; - while (temp != NULL && count < 100) - { - count++; - temp = temp->next; - } - - if (count >= 100) - { - temp = logged_connections; - for (int i = 0; i < 98 && temp != NULL; i++) - { - temp = temp->next; - } - - if (temp != NULL && temp->next != NULL) - { - LOGGED_CONNECTION *to_free_list = temp->next; - temp->next = NULL; - - // Free excess entries (still under log_lock, but this is rare) - while (to_free_list != NULL) - { - LOGGED_CONNECTION *next = to_free_list->next; - free(to_free_list); - to_free_list = next; - } - } - } - - LOGGED_CONNECTION *logged = malloc(sizeof(LOGGED_CONNECTION)); - if (logged != NULL) - { - logged->pid = pid; - logged->dest_ip = dest_ip; - logged->dest_port = dest_port; - logged->action = action; - logged->next = logged_connections; - logged_connections = logged; - } - - pthread_mutex_unlock(&log_lock); -} - -static void clear_logged_connections(void) -{ - pthread_mutex_lock(&log_lock); - - while (logged_connections != NULL) - { - LOGGED_CONNECTION *to_free = logged_connections; - logged_connections = logged_connections->next; - free(to_free); - } - - pthread_mutex_unlock(&log_lock); -} - -static uint32_t pid_cache_hash(uint32_t src_ip, uint16_t src_port, bool is_udp) -{ - uint32_t hash = src_ip ^ ((uint32_t)src_port << 16) ^ (is_udp ? 0x80000000 : 0); - return hash % PID_CACHE_SIZE; -} - -static uint32_t get_cached_pid(uint32_t src_ip, uint16_t src_port, bool is_udp) -{ - uint32_t hash = pid_cache_hash(src_ip, src_port, is_udp); - uint64_t current_time = get_monotonic_ms(); - uint32_t pid = 0; - - pthread_mutex_lock(&pid_cache_lock); - - PID_CACHE_ENTRY *entry = pid_cache[hash]; - while (entry != NULL) - { - if (entry->src_ip == src_ip && - entry->src_port == src_port && - entry->is_udp == is_udp) - { - if (current_time - entry->timestamp < PID_CACHE_TTL_MS) - { - pid = entry->pid; - break; - } - else - { - break; - } - } - entry = entry->next; - } - - pthread_mutex_unlock(&pid_cache_lock); - return pid; -} - -static void cache_pid(uint32_t src_ip, uint16_t src_port, uint32_t pid, bool is_udp) -{ - uint32_t hash = pid_cache_hash(src_ip, src_port, is_udp); - uint64_t current_time = get_monotonic_ms(); - - pthread_mutex_lock(&pid_cache_lock); - - PID_CACHE_ENTRY *entry = pid_cache[hash]; - while (entry != NULL) - { - if (entry->src_ip == src_ip && - entry->src_port == src_port && - entry->is_udp == is_udp) - { - entry->pid = pid; - entry->timestamp = current_time; - pthread_mutex_unlock(&pid_cache_lock); - return; - } - entry = entry->next; - } - - PID_CACHE_ENTRY *new_entry = malloc(sizeof(PID_CACHE_ENTRY)); - if (new_entry != NULL) - { - new_entry->src_ip = src_ip; - new_entry->src_port = src_port; - new_entry->pid = pid; - new_entry->timestamp = current_time; - new_entry->is_udp = is_udp; - new_entry->next = pid_cache[hash]; - pid_cache[hash] = new_entry; - } - - pthread_mutex_unlock(&pid_cache_lock); -} - -static void clear_pid_cache(void) -{ - pthread_mutex_lock(&pid_cache_lock); - - for (int i = 0; i < PID_CACHE_SIZE; i++) - { - while (pid_cache[i] != NULL) - { - PID_CACHE_ENTRY *to_free = pid_cache[i]; - pid_cache[i] = pid_cache[i]->next; - free(to_free); - } - } - - pthread_mutex_unlock(&pid_cache_lock); -} - -static void* cleanup_worker(void *arg) -{ - (void)arg; - while (running) - { - sleep(30); // 30 seconds - if (running) - { - cleanup_stale_connections(); - } - } - return NULL; -} - -static void update_has_active_rules(void) -{ - g_has_active_rules = false; - PROCESS_RULE *rule = rules_list; - while (rule != NULL) - { - if (rule->enabled) - { - g_has_active_rules = true; - break; - } - rule = rule->next; - } -} - -uint32_t ProxyBridge_AddRule(const char* process_name, const char* target_hosts, const char* target_ports, RuleProtocol protocol, RuleAction action) -{ - if (process_name == NULL || process_name[0] == '\0') - return 0; - - PROCESS_RULE *rule = malloc(sizeof(PROCESS_RULE)); - if (rule == NULL) - return 0; - - rule->rule_id = g_next_rule_id++; - strncpy(rule->process_name, process_name, MAX_PROCESS_NAME - 1); - rule->process_name[MAX_PROCESS_NAME - 1] = '\0'; - rule->protocol = protocol; - - if (target_hosts != NULL && target_hosts[0] != '\0') - { - rule->target_hosts = strdup(target_hosts); - if (rule->target_hosts == NULL) - { - free(rule); - return 0; - } - } - else - { - rule->target_hosts = strdup("*"); - if (rule->target_hosts == NULL) - { - free(rule); - return 0; - } - } - - if (target_ports != NULL && target_ports[0] != '\0') - { - rule->target_ports = strdup(target_ports); - if (rule->target_ports == NULL) - { - free(rule->target_hosts); - free(rule); - return 0; - } - } - else - { - rule->target_ports = strdup("*"); - if (rule->target_ports == NULL) - { - free(rule->target_hosts); - free(rule); - return 0; - } - } - - rule->action = action; - rule->enabled = true; - - pthread_rwlock_wrlock(&rules_lock); - rule->next = rules_list; - rules_list = rule; - update_has_active_rules(); - pthread_rwlock_unlock(&rules_lock); - - log_message("added rule id %u for process %s protocol %d action %d", rule->rule_id, process_name, protocol, action); - - return rule->rule_id; -} - -bool ProxyBridge_EnableRule(uint32_t rule_id) +int ProxyBridge_TestConnection(const char *host, uint16_t port, char *buf, size_t n) { - if (rule_id == 0) - return false; - - pthread_rwlock_wrlock(&rules_lock); - PROCESS_RULE *rule = rules_list; - while (rule != NULL) - { - if (rule->rule_id == rule_id) - { - rule->enabled = true; - update_has_active_rules(); - pthread_rwlock_unlock(&rules_lock); - log_message("enabled rule id %u", rule_id); - return true; - } - rule = rule->next; - } - pthread_rwlock_unlock(&rules_lock); - return false; -} - -bool ProxyBridge_DisableRule(uint32_t rule_id) -{ - if (rule_id == 0) - return false; - - pthread_rwlock_wrlock(&rules_lock); - PROCESS_RULE *rule = rules_list; - while (rule != NULL) - { - if (rule->rule_id == rule_id) - { - rule->enabled = false; - update_has_active_rules(); - pthread_rwlock_unlock(&rules_lock); - log_message("disabled rule id %u", rule_id); - return true; - } - rule = rule->next; - } - pthread_rwlock_unlock(&rules_lock); - return false; -} - -bool ProxyBridge_DeleteRule(uint32_t rule_id) -{ - if (rule_id == 0) - return false; - - pthread_rwlock_wrlock(&rules_lock); - PROCESS_RULE *rule = rules_list; - PROCESS_RULE *prev = NULL; - - while (rule != NULL) - { - if (rule->rule_id == rule_id) - { - if (prev == NULL) - rules_list = rule->next; - else - prev->next = rule->next; - - update_has_active_rules(); - pthread_rwlock_unlock(&rules_lock); - - if (rule->target_hosts != NULL) - free(rule->target_hosts); - if (rule->target_ports != NULL) - free(rule->target_ports); - free(rule); - - log_message("deleted rule id %u", rule_id); - return true; - } - prev = rule; - rule = rule->next; - } - pthread_rwlock_unlock(&rules_lock); - return false; -} - -bool ProxyBridge_EditRule(uint32_t rule_id, const char* process_name, const char* target_hosts, const char* target_ports, RuleProtocol protocol, RuleAction action) -{ - if (rule_id == 0 || process_name == NULL || target_hosts == NULL || target_ports == NULL) - return false; - - // Pre-allocate new strings before taking lock to minimize hold time - char *new_hosts = strdup(target_hosts); - char *new_ports = strdup(target_ports); - if (new_hosts == NULL || new_ports == NULL) - { - free(new_hosts); - free(new_ports); - return false; - } - - pthread_rwlock_wrlock(&rules_lock); - PROCESS_RULE *rule = rules_list; - while (rule != NULL) - { - if (rule->rule_id == rule_id) - { - strncpy(rule->process_name, process_name, MAX_PROCESS_NAME - 1); - rule->process_name[MAX_PROCESS_NAME - 1] = '\0'; - - free(rule->target_hosts); - rule->target_hosts = new_hosts; - - free(rule->target_ports); - rule->target_ports = new_ports; - - rule->protocol = protocol; - rule->action = action; - - update_has_active_rules(); - pthread_rwlock_unlock(&rules_lock); - log_message("updated rule id %u", rule_id); - return true; - } - rule = rule->next; - } - pthread_rwlock_unlock(&rules_lock); - - // Rule not found - free pre-allocated strings - free(new_hosts); - free(new_ports); - return false; -} - -bool ProxyBridge_SetProxyConfig(ProxyType type, const char* proxy_ip, uint16_t proxy_port, const char* username, const char* password) -{ - if (proxy_ip == NULL || proxy_ip[0] == '\0' || proxy_port == 0) - return false; - - g_proxy_ip_cached = resolve_hostname(proxy_ip); - if (g_proxy_ip_cached == 0) - return false; - - strncpy(g_proxy_host, proxy_ip, sizeof(g_proxy_host) - 1); - g_proxy_host[sizeof(g_proxy_host) - 1] = '\0'; - g_proxy_port = proxy_port; - g_proxy_type = (type == PROXY_TYPE_HTTP) ? PROXY_TYPE_HTTP : PROXY_TYPE_SOCKS5; - - if (username != NULL) - { - strncpy(g_proxy_username, username, sizeof(g_proxy_username) - 1); - g_proxy_username[sizeof(g_proxy_username) - 1] = '\0'; - } - else - { - g_proxy_username[0] = '\0'; - } - - if (password != NULL) - { - strncpy(g_proxy_password, password, sizeof(g_proxy_password) - 1); - g_proxy_password[sizeof(g_proxy_password) - 1] = '\0'; - } - else - { - g_proxy_password[0] = '\0'; - } - - log_message("proxy configured %s %s:%d", type == PROXY_TYPE_HTTP ? "http" : "socks5", proxy_ip, proxy_port); - return true; -} - -void ProxyBridge_SetDnsViaProxy(bool enable) -{ - g_dns_via_proxy = enable; - log_message("dns via proxy %s", enable ? "enabled" : "disabled"); -} - -void ProxyBridge_SetLogCallback(LogCallback callback) -{ - g_log_callback = callback; -} - -void ProxyBridge_SetConnectionCallback(ConnectionCallback callback) -{ - g_connection_callback = callback; -} - -void ProxyBridge_SetTrafficLoggingEnabled(bool enable) -{ - g_traffic_logging_enabled = enable; -} - -void ProxyBridge_ClearConnectionLogs(void) -{ - clear_logged_connections(); -} - -bool ProxyBridge_Start(void) -{ - if (running) - return false; - - running = true; - g_current_process_id = getpid(); - - // Ignore SIGPIPE - send() on a closed socket must return EPIPE, not kill the process - signal(SIGPIPE, SIG_IGN); - - // Raise system socket buffer limits for high throughput (requires root) - // Default rmem_max/wmem_max is usually 208KB, far too small for >100Mbps - FILE *fp; - fp = fopen("/proc/sys/net/core/rmem_max", "w"); - if (fp) { fprintf(fp, "4194304"); fclose(fp); } // 4MB - fp = fopen("/proc/sys/net/core/wmem_max", "w"); - if (fp) { fprintf(fp, "4194304"); fclose(fp); } // 4MB - - if (pthread_create(&proxy_thread, NULL, local_proxy_server, NULL) != 0) - { - running = false; - return false; - } - - if (pthread_create(&cleanup_thread, NULL, cleanup_worker, NULL) != 0) - { - running = false; - pthread_cancel(proxy_thread); - pthread_join(proxy_thread, NULL); - proxy_thread = 0; - return false; - } - - // Start UDP relay server if SOCKS5 proxy - if (g_proxy_type == PROXY_TYPE_SOCKS5) - { - if (pthread_create(&udp_relay_thread, NULL, udp_relay_server, NULL) != 0) - { - log_message("failed to create UDP relay thread"); - } - } - - nfq_h = nfq_open(); - if (!nfq_h) - { - log_message("nfq_open failed"); - goto start_fail; - } - - if (nfq_unbind_pf(nfq_h, AF_INET) < 0) - { - log_message("nfq_unbind_pf failed"); - } - - if (nfq_bind_pf(nfq_h, AF_INET) < 0) - { - log_message("nfq_bind_pf failed"); - nfq_close(nfq_h); - nfq_h = NULL; - goto start_fail; - } - - nfq_qh = nfq_create_queue(nfq_h, 0, &packet_callback, NULL); - if (!nfq_qh) - { - log_message("nfq_create_queue failed"); - nfq_close(nfq_h); - nfq_h = NULL; - goto start_fail; - } - - if (nfq_set_mode(nfq_qh, NFQNL_COPY_PACKET, 0xffff) < 0) - { - log_message("nfq_set_mode failed"); - nfq_destroy_queue(nfq_qh); - nfq_qh = NULL; - nfq_close(nfq_h); - nfq_h = NULL; - goto start_fail; - } - - // Set larger queue length for better performance (16384 like Windows) - nfq_set_queue_maxlen(nfq_qh, 16384); - - // setup iptables rules for packet interception - USE MANGLE table so it runs BEFORE nat - log_message("setting up iptables rules"); - // mangle table runs before nat, so we can mark packets there - int ret1 = run_iptables_cmd("-t", "mangle", "-A", "OUTPUT", "-p", "tcp", "-j", "NFQUEUE", "--queue-num", "0", NULL, NULL, NULL, NULL); - int ret2 = run_iptables_cmd("-t", "mangle", "-A", "OUTPUT", "-p", "udp", "-j", "NFQUEUE", "--queue-num", "0", NULL, NULL, NULL, NULL); - - if (ret1 != 0 || ret2 != 0) { - log_message("failed to add iptables rules ret1=%d ret2=%d", ret1, ret2); - } else { - log_message("iptables nfqueue rules added successfully"); - } - - // setup nat redirect for marked packets - int ret3 = run_iptables_cmd("-t", "nat", "-A", "OUTPUT", "-p", "tcp", "-m", "mark", "--mark", "1", "-j", "REDIRECT", "--to-port", "34010"); - int ret4 = run_iptables_cmd("-t", "nat", "-A", "OUTPUT", "-p", "udp", "-m", "mark", "--mark", "2", "-j", "REDIRECT", "--to-port", "34011"); - if (ret3 != 0 || ret4 != 0) { - log_message("failed to add nat redirect rules"); - } - - (void)ret3; - (void)ret4; - - for (int i = 0; i < NUM_PACKET_THREADS; i++) - { - if (pthread_create(&packet_thread[i], NULL, packet_processor, NULL) != 0) - { - log_message("failed to create packet thread %d", i); - } - } - - log_message("proxybridge started"); - return true; - -start_fail: - running = false; - if (proxy_thread != 0) { pthread_cancel(proxy_thread); pthread_join(proxy_thread, NULL); proxy_thread = 0; } - if (cleanup_thread != 0) { pthread_cancel(cleanup_thread); pthread_join(cleanup_thread, NULL); cleanup_thread = 0; } - if (udp_relay_thread != 0) { pthread_cancel(udp_relay_thread); pthread_join(udp_relay_thread, NULL); udp_relay_thread = 0; } - return false; -} - -bool ProxyBridge_Stop(void) -{ - if (!running) - return false; - - running = false; - - // cleanup iptables - int ret1 = run_iptables_cmd("-t", "mangle", "-D", "OUTPUT", "-p", "tcp", "-j", "NFQUEUE", "--queue-num", "0", NULL, NULL, NULL, NULL); - int ret2 = run_iptables_cmd("-t", "mangle", "-D", "OUTPUT", "-p", "udp", "-j", "NFQUEUE", "--queue-num", "0", NULL, NULL, NULL, NULL); - int ret3 = run_iptables_cmd("-t", "nat", "-D", "OUTPUT", "-p", "tcp", "-m", "mark", "--mark", "1", "-j", "REDIRECT", "--to-port", "34010"); - int ret4 = run_iptables_cmd("-t", "nat", "-D", "OUTPUT", "-p", "udp", "-m", "mark", "--mark", "2", "-j", "REDIRECT", "--to-port", "34011"); - (void)ret1; - (void)ret2; - (void)ret3; - (void)ret4; - - for (int i = 0; i < NUM_PACKET_THREADS; i++) - { - if (packet_thread[i] != 0) - { - pthread_cancel(packet_thread[i]); - pthread_join(packet_thread[i], NULL); - packet_thread[i] = 0; - } - } - - if (nfq_qh) - { - nfq_destroy_queue(nfq_qh); - nfq_qh = NULL; - } - - if (nfq_h) - { - nfq_close(nfq_h); - nfq_h = NULL; - } - - if (proxy_thread != 0) - { - pthread_cancel(proxy_thread); - pthread_join(proxy_thread, NULL); - proxy_thread = 0; - } - - if (udp_relay_thread != 0) - { - pthread_cancel(udp_relay_thread); - pthread_join(udp_relay_thread, NULL); - udp_relay_thread = 0; - } - - if (cleanup_thread != 0) - { - pthread_cancel(cleanup_thread); - pthread_join(cleanup_thread, NULL); - cleanup_thread = 0; - } - - // Free all connections in hash table - pthread_rwlock_wrlock(&conn_lock); - for (int i = 0; i < CONNECTION_HASH_SIZE; i++) - { - while (connection_hash_table[i] != NULL) - { - CONNECTION_INFO *to_free = connection_hash_table[i]; - connection_hash_table[i] = connection_hash_table[i]->next; - free(to_free); - } - } - pthread_rwlock_unlock(&conn_lock); - - // Free all rules - pthread_rwlock_wrlock(&rules_lock); - while (rules_list != NULL) - { - PROCESS_RULE *to_free = rules_list; - rules_list = rules_list->next; - free(to_free->target_hosts); - free(to_free->target_ports); - free(to_free); - } - g_has_active_rules = false; - g_next_rule_id = 1; - pthread_rwlock_unlock(&rules_lock); - - clear_logged_connections(); - clear_pid_cache(); - - log_message("proxybridge stopped"); - return true; -} - -int ProxyBridge_TestConnection(const char* target_host, uint16_t target_port, char* result_buffer, size_t buffer_size) -{ - int test_sock = -1; - struct sockaddr_in proxy_addr; - uint32_t target_ip; - int ret = -1; - char temp_buffer[512]; - - if (g_proxy_host[0] == '\0' || g_proxy_port == 0) - { - snprintf(result_buffer, buffer_size, "error no proxy configured"); - return -1; - } - - if (target_host == NULL || target_host[0] == '\0') - { - snprintf(result_buffer, buffer_size, "error invalid target host"); - return -1; - } - - snprintf(temp_buffer, sizeof(temp_buffer), "testing connection to %s:%d via %s proxy %s:%d\n", - target_host, target_port, - g_proxy_type == PROXY_TYPE_HTTP ? "http" : "socks5", - g_proxy_host, g_proxy_port); - strncpy(result_buffer, temp_buffer, buffer_size - 1); - result_buffer[buffer_size - 1] = '\0'; - - struct addrinfo hints, *res; - memset(&hints, 0, sizeof(hints)); - hints.ai_family = AF_INET; - hints.ai_socktype = SOCK_STREAM; - int gai_ret = getaddrinfo(target_host, NULL, &hints, &res); - if (gai_ret != 0) - { - snprintf(temp_buffer, sizeof(temp_buffer), "error failed to resolve hostname %s: %s\n", - target_host, gai_strerror(gai_ret)); - strncat(result_buffer, temp_buffer, buffer_size - strlen(result_buffer) - 1); - return -1; - } - target_ip = ((struct sockaddr_in *)res->ai_addr)->sin_addr.s_addr; - freeaddrinfo(res); - - snprintf(temp_buffer, sizeof(temp_buffer), "resolved %s to %d.%d.%d.%d\n", - target_host, - (target_ip >> 0) & 0xFF, (target_ip >> 8) & 0xFF, - (target_ip >> 16) & 0xFF, (target_ip >> 24) & 0xFF); - strncat(result_buffer, temp_buffer, buffer_size - strlen(result_buffer) - 1); - - test_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); - if (test_sock < 0) - { - snprintf(temp_buffer, sizeof(temp_buffer), "error socket creation failed\n"); - strncat(result_buffer, temp_buffer, buffer_size - strlen(result_buffer) - 1); - return -1; - } - - configure_tcp_socket(test_sock, 65536, 10000); - - memset(&proxy_addr, 0, sizeof(proxy_addr)); - proxy_addr.sin_family = AF_INET; - proxy_addr.sin_addr.s_addr = resolve_hostname(g_proxy_host); - proxy_addr.sin_port = htons(g_proxy_port); - - snprintf(temp_buffer, sizeof(temp_buffer), "connecting to proxy %s:%d\n", g_proxy_host, g_proxy_port); - strncat(result_buffer, temp_buffer, buffer_size - strlen(result_buffer) - 1); - - if (connect(test_sock, (struct sockaddr*)&proxy_addr, sizeof(proxy_addr)) < 0) - { - snprintf(temp_buffer, sizeof(temp_buffer), "error failed to connect to proxy\n"); - strncat(result_buffer, temp_buffer, buffer_size - strlen(result_buffer) - 1); - close(test_sock); - return -1; - } - - strncat(result_buffer, "connected to proxy server\n", buffer_size - strlen(result_buffer) - 1); - - if (g_proxy_type == PROXY_TYPE_SOCKS5) - { - if (socks5_connect(test_sock, target_ip, target_port) != 0) - { - snprintf(temp_buffer, sizeof(temp_buffer), "error socks5 handshake failed\n"); - strncat(result_buffer, temp_buffer, buffer_size - strlen(result_buffer) - 1); - close(test_sock); - return -1; - } - strncat(result_buffer, "socks5 handshake successful\n", buffer_size - strlen(result_buffer) - 1); - } - else - { - if (http_connect(test_sock, target_ip, target_port) != 0) - { - snprintf(temp_buffer, sizeof(temp_buffer), "error http connect failed\n"); - strncat(result_buffer, temp_buffer, buffer_size - strlen(result_buffer) - 1); - close(test_sock); - return -1; - } - strncat(result_buffer, "http connect successful\n", buffer_size - strlen(result_buffer) - 1); - } - - char http_request[512]; - snprintf(http_request, sizeof(http_request), - "GET / HTTP/1.1\r\n" - "Host: %s\r\n" - "Connection: close\r\n" - "User-Agent: ProxyBridge/1.0\r\n" - "\r\n", target_host); - - if (send_all(test_sock, http_request, strlen(http_request)) < 0) - { - snprintf(temp_buffer, sizeof(temp_buffer), "error failed to send test request\n"); - strncat(result_buffer, temp_buffer, buffer_size - strlen(result_buffer) - 1); - close(test_sock); - return -1; - } - - strncat(result_buffer, "sent http get request\n", buffer_size - strlen(result_buffer) - 1); - char response[1024]; - ssize_t bytes_received = recv(test_sock, response, sizeof(response) - 1, 0); - if (bytes_received > 0) - { - response[bytes_received] = '\0'; - - if (strstr(response, "HTTP/") != NULL) - { - char* status_line = strstr(response, "HTTP/"); - int status_code = 0; - if (status_line != NULL) - { - sscanf(status_line, "HTTP/%*s %d", &status_code); - } - - snprintf(temp_buffer, sizeof(temp_buffer), "success received http %d response %ld bytes\n", status_code, (long)bytes_received); - strncat(result_buffer, temp_buffer, buffer_size - strlen(result_buffer) - 1); - ret = 0; - } - else - { - snprintf(temp_buffer, sizeof(temp_buffer), "error received data but not valid http response\n"); - strncat(result_buffer, temp_buffer, buffer_size - strlen(result_buffer) - 1); - ret = -1; - } - } - else - { - snprintf(temp_buffer, sizeof(temp_buffer), "error failed to receive response\n"); - strncat(result_buffer, temp_buffer, buffer_size - strlen(result_buffer) - 1); - ret = -1; - } - - close(test_sock); - - if (ret == 0) - { - strncat(result_buffer, "\nproxy connection test passed\n", buffer_size - strlen(result_buffer) - 1); - } - else - { - strncat(result_buffer, "\nproxy connection test failed\n", buffer_size - strlen(result_buffer) - 1); - } - - return ret; -} - -// Library destructor - automatically cleanup when library is unloaded -__attribute__((destructor)) -static void library_cleanup(void) -{ - if (running) - { - log_message("library unloading - cleaning up automatically"); - ProxyBridge_Stop(); - } - else - { - // Even if not running, ensure iptables rules are removed - // This handles cases where the app crashed before calling Stop - run_iptables_cmd("-t", "mangle", "-D", "OUTPUT", "-p", "tcp", "-j", "NFQUEUE", "--queue-num", "0", NULL, NULL, NULL, NULL); - run_iptables_cmd("-t", "mangle", "-D", "OUTPUT", "-p", "udp", "-j", "NFQUEUE", "--queue-num", "0", NULL, NULL, NULL, NULL); - run_iptables_cmd("-t", "nat", "-D", "OUTPUT", "-p", "tcp", "-m", "mark", "--mark", "1", "-j", "REDIRECT", "--to-port", "34010"); - run_iptables_cmd("-t", "nat", "-D", "OUTPUT", "-p", "udp", "-m", "mark", "--mark", "2", "-j", "REDIRECT", "--to-port", "34011"); - } + return ProxyBridge_TestProxy(0, host, port, buf, n); } diff --git a/Linux/src/ProxyBridge.h b/Linux/src/ProxyBridge.h index bd80583b..267bb8f4 100644 --- a/Linux/src/ProxyBridge.h +++ b/Linux/src/ProxyBridge.h @@ -1,8 +1,9 @@ #ifndef PROXYBRIDGE_H #define PROXYBRIDGE_H -#define PROXYBRIDGE_VERSION "3.2.0" +#define PROXYBRIDGE_VERSION "4.0.0-Beta" +#include #include #include @@ -10,6 +11,8 @@ extern "C" { #endif +#define PB_API __attribute__((visibility("default"))) + typedef void (*LogCallback)(const char* message); typedef void (*ConnectionCallback)(const char* process_name, uint32_t pid, const char* dest_ip, uint16_t dest_port, const char* proxy_info); @@ -30,20 +33,52 @@ typedef enum { RULE_PROTOCOL_BOTH = 2 } RuleProtocol; -uint32_t ProxyBridge_AddRule(const char* process_name, const char* target_hosts, const char* target_ports, RuleProtocol protocol, RuleAction action); -bool ProxyBridge_EnableRule(uint32_t rule_id); -bool ProxyBridge_DisableRule(uint32_t rule_id); -bool ProxyBridge_DeleteRule(uint32_t rule_id); -bool ProxyBridge_EditRule(uint32_t rule_id, const char* process_name, const char* target_hosts, const char* target_ports, RuleProtocol protocol, RuleAction action); -bool ProxyBridge_SetProxyConfig(ProxyType type, const char* proxy_ip, uint16_t proxy_port, const char* username, const char* password); -void ProxyBridge_SetDnsViaProxy(bool enable); -void ProxyBridge_SetLogCallback(LogCallback callback); -void ProxyBridge_SetConnectionCallback(ConnectionCallback callback); -void ProxyBridge_SetTrafficLoggingEnabled(bool enable); -void ProxyBridge_ClearConnectionLogs(void); -bool ProxyBridge_Start(void); -bool ProxyBridge_Stop(void); -int ProxyBridge_TestConnection(const char* target_host, uint16_t target_port, char* result_buffer, size_t buffer_size); +/* ---- Proxy configurations ------------------------------------------------------------------------------------------- + * Any number of proxies can be registered. Ids are non-zero and stable until the proxy is deleted. + * Returns 0 on failure (the reason is written to err, if given). */ +PB_API uint32_t ProxyBridge_AddProxy(const char* name, ProxyType type, const char* host, uint16_t port, + const char* username, const char* password, char* err, size_t err_size); +PB_API bool ProxyBridge_EditProxy(uint32_t proxy_id, const char* name, ProxyType type, const char* host, uint16_t port, + const char* username, const char* password, char* err, size_t err_size); +PB_API bool ProxyBridge_DeleteProxy(uint32_t proxy_id); /* rules that used it fall back to the default proxy */ +PB_API void ProxyBridge_ClearProxies(void); +/* Which proxy a rule with proxy_id == 0 uses. 0 = the first proxy added. */ +PB_API void ProxyBridge_SetDefaultProxy(uint32_t proxy_id); + +/* ---- Rules ---------------------------------------------------------------------------------------------------------- + * Rules are evaluated in list order, first match wins. process_name: ';' or ',' separated globs matched against the + * executable's full path and file name ("*" or empty = any). target_hosts: ';' or ',' separated IPv4/IPv6 addresses, + * CIDR (10.0.0.0/8), a-b ranges, octet wildcards (192.168.*.*) or "*" ("" = any). target_ports: list and ranges + * ("80;443;8000-9000", "" or "*" = any). + * proxy_id applies when action == RULE_ACTION_PROXY; 0 = default proxy. + * AddRule returns the new rule id, or 0 on validation failure (reason in err). */ +PB_API uint32_t ProxyBridge_AddRule(const char* process_name, const char* target_hosts, const char* target_ports, + RuleProtocol protocol, RuleAction action, uint32_t proxy_id, char* err, size_t err_size); +PB_API bool ProxyBridge_EditRule(uint32_t rule_id, const char* process_name, const char* target_hosts, const char* target_ports, + RuleProtocol protocol, RuleAction action, uint32_t proxy_id, char* err, size_t err_size); +PB_API bool ProxyBridge_EnableRule(uint32_t rule_id); +PB_API bool ProxyBridge_DisableRule(uint32_t rule_id); +PB_API bool ProxyBridge_DeleteRule(uint32_t rule_id); +PB_API bool ProxyBridge_MoveRule(uint32_t rule_id, int new_index); /* reorder: priority = position */ +PB_API void ProxyBridge_ClearRules(void); +/* Validates a rule without adding it; returns true if valid, otherwise writes the reason to err. */ +PB_API bool ProxyBridge_ValidateRule(const char* process_name, const char* target_hosts, const char* target_ports, + char* err, size_t err_size); + +/* ---- Legacy single-proxy helper ------------------------------------------------------------------------------------- + * Sets (creating on first use) the default proxy. Equivalent to Add/EditProxy on the "default" entry. */ +PB_API bool ProxyBridge_SetProxyConfig(ProxyType type, const char* proxy_ip, uint16_t proxy_port, const char* username, const char* password); + +/* ---- Runtime --------------------------------------------------------------------------------------------------------- */ +PB_API void ProxyBridge_SetDnsViaProxy(bool enable); +PB_API void ProxyBridge_SetLogCallback(LogCallback callback); +PB_API void ProxyBridge_SetConnectionCallback(ConnectionCallback callback); +PB_API void ProxyBridge_SetTrafficLoggingEnabled(bool enable); +PB_API void ProxyBridge_ClearConnectionLogs(void); +PB_API bool ProxyBridge_Start(void); /* needs root (CAP_BPF/CAP_SYS_ADMIN) and a BTF kernel >= 5.11; returns false and logs why on failure */ +PB_API bool ProxyBridge_Stop(void); /* keeps rules and proxies; Start() may be called again */ +PB_API int ProxyBridge_TestConnection(const char* target_host, uint16_t target_port, char* result_buffer, size_t buffer_size); +PB_API int ProxyBridge_TestProxy(uint32_t proxy_id, const char* target_host, uint16_t target_port, char* result_buffer, size_t buffer_size); #ifdef __cplusplus } diff --git a/Linux/src/bpf/pb_bpf_shared.h b/Linux/src/bpf/pb_bpf_shared.h new file mode 100644 index 00000000..ef987093 --- /dev/null +++ b/Linux/src/bpf/pb_bpf_shared.h @@ -0,0 +1,66 @@ +// Types shared by the BPF object and userspace. +#ifndef PB_BPF_SHARED_H +#define PB_BPF_SHARED_H + +#ifdef __BPF__ +typedef __u8 pb_u8; typedef __u16 pb_u16; typedef __u32 pb_u32; typedef __u64 pb_u64; +#else +#include +typedef uint8_t pb_u8; typedef uint16_t pb_u16; typedef uint32_t pb_u32; typedef uint64_t pb_u64; +#endif + +#define PB_MAX_RULES 512 +#define PB_MAX_CLASSES 2048 +#define PB_RULES_PER_CLASS 16 +#define PB_BPF_MAX_V4 4 +#define PB_BPF_MAX_PORT 4 + +#define PB_RF_ANY_HOST 0x01 +#define PB_RF_ANY_PORT 0x02 +#define PB_RF_HOST_COMPLEX 0x04 // host pattern the kernel cannot express: "maybe" for every destination +#define PB_RF_PORT_COMPLEX 0x08 // more port ranges than fit: "maybe" when none of the stored ones match +#define PB_RF_HAS_V6 0x10 // IPv6 patterns present: "maybe" for IPv6 destinations +#define PB_RF_UDP_DIRECT 0x20 // PROXY rule whose proxy cannot carry UDP: UDP goes direct +#define PB_RF_DOMAINS 0x40 // answered through the dom_hit map filled by the DNS tap + +#define PB_CLASS_DECIDE 0xffffffffu // pb_bclass.n: always take the slow path for this binary + +struct pb_cfg { + pb_u32 enabled; // at least one rule is loaded + pb_u32 self_tgid; // the process hosting the relay is never redirected + pb_u32 log; + pb_u32 dns_via_proxy; + pb_u32 dns_tap; // a domain rule exists + pb_u32 n_exempt; // entries in the exempt map + pb_u16 tcp_port, udp_port; + pb_u64 netns; // network namespace of the relay; other namespaces are ignored (0 = unknown) +}; + +struct pb_exe_key { pb_u32 dev; pb_u32 pad; pb_u64 ino; }; +struct pb_port_key { pb_u32 ip; pb_u32 port; }; // client source address (network order) and port +struct pb_dom_key { pb_u32 rule; pb_u32 ip; }; // ip in host order + +struct pb_brule { + pb_u16 proxy_id; + pb_u8 action; // 0 proxy, 1 direct, 2 block + pb_u8 proto_mask; // 1 tcp, 2 udp + pb_u8 flags, n_v4, n_port, pad; + struct { pb_u32 lo, hi; } v4[PB_BPF_MAX_V4]; // host order + struct { pb_u16 lo, hi; } port[PB_BPF_MAX_PORT]; // host order +}; + +struct pb_bclass { + pb_u32 n; + pb_u32 tap; // covered by a domain rule: its DNS must be observed + pb_u32 rule[PB_RULES_PER_CLASS]; +}; + +struct pb_event { + pb_u32 pid; + pb_u8 comm[16]; + pb_u8 addr[16]; + pb_u16 port, proxy_id; + pb_u8 family, proto, action, pad; +}; + +#endif diff --git a/Linux/src/bpf/proxybridge.bpf.c b/Linux/src/bpf/proxybridge.bpf.c new file mode 100644 index 00000000..db8a0330 --- /dev/null +++ b/Linux/src/bpf/proxybridge.bpf.c @@ -0,0 +1,385 @@ +// cgroup socket-address hooks: redirect selected processes' connect()/sendmsg() to the userspace relay. +#include +#include +#include +#include +#include "pb_bpf_shared.h" + +// Minimal kernel types, relocated against the running kernel's BTF (CO-RE). +#pragma clang attribute push(__attribute__((preserve_access_index)), apply_to = record) +struct super_block { __u32 s_dev; }; +struct inode { struct super_block *i_sb; unsigned long i_ino; }; +struct file { struct inode *f_inode; }; +struct mm_struct { struct file *exe_file; }; +struct task_struct { struct mm_struct *mm; }; +#pragma clang attribute pop + +#define IPPROTO_TCP 6 +#define IPPROTO_UDP 17 +#define AF_INET 2 +#define AF_INET6 10 + +#define ACT_PROXY 0 +#define ACT_DIRECT 1 +#define ACT_BLOCK 2 +#define ACT_DECIDE 3 + +#define M_NO 0 +#define M_YES 1 +#define M_MAYBE 2 + +struct pb_orig { + __u8 addr[16]; + __u16 port; + __u16 proxy_id; + __u32 pid; + __u8 family, proto, action, pad; + __u32 exe_dev, exe_pad; + __u64 exe_ino; +}; + +volatile struct pb_cfg cfg; + +#define MAP(name, kind, entries, key_t, val_t) \ + struct { __uint(type, kind); __uint(max_entries, entries); __type(key, key_t); __type(value, val_t); } name SEC(".maps") + +MAP(exe2class, BPF_MAP_TYPE_HASH, 16384, struct pb_exe_key, __u32); +MAP(classes, BPF_MAP_TYPE_ARRAY, PB_MAX_CLASSES, __u32, struct pb_bclass); +MAP(brules, BPF_MAP_TYPE_ARRAY, PB_MAX_RULES, __u32, struct pb_brule); +MAP(exempt, BPF_MAP_TYPE_HASH, 64, __u32, __u8); +MAP(by_cookie, BPF_MAP_TYPE_LRU_HASH, 32768, __u64, struct pb_orig); +MAP(by_port, BPF_MAP_TYPE_LRU_HASH, 32768, struct pb_port_key, struct pb_orig); +MAP(flows, BPF_MAP_TYPE_LRU_HASH, 32768, __u32, struct pb_orig); +MAP(dom_hit, BPF_MAP_TYPE_LRU_HASH, 65536, struct pb_dom_key, __u8); +struct { __uint(type, BPF_MAP_TYPE_RINGBUF); __uint(max_entries, 256 * 1024); } events SEC(".maps"); + +static __always_inline int foreign_netns(void *ctx) +{ + __u64 want = cfg.netns; + return want && bpf_get_netns_cookie(ctx) != want; +} + +static __always_inline int rule_match(const struct pb_brule *r, __u32 ri, __u8 fam, __u32 ip4, __u16 port) +{ + int port_res = M_YES; + if (!(r->flags & PB_RF_ANY_PORT)) { + port_res = M_NO; + #pragma unroll + for (int i = 0; i < PB_BPF_MAX_PORT; i++) { + if (i >= r->n_port) break; + if (port >= r->port[i].lo && port <= r->port[i].hi) { port_res = M_YES; break; } + } + if (port_res == M_NO && (r->flags & PB_RF_PORT_COMPLEX)) port_res = M_MAYBE; + if (port_res == M_NO) return M_NO; + } + int host_res; + if (r->flags & PB_RF_ANY_HOST) host_res = M_YES; + else if (r->flags & PB_RF_HOST_COMPLEX) host_res = M_MAYBE; + else if (fam == AF_INET6) host_res = (r->flags & PB_RF_HAS_V6) ? M_MAYBE : M_NO; + else { + host_res = M_NO; + #pragma unroll + for (int i = 0; i < PB_BPF_MAX_V4; i++) { + if (i >= r->n_v4) break; + if (ip4 >= r->v4[i].lo && ip4 <= r->v4[i].hi) { host_res = M_YES; break; } + } + if (host_res == M_NO && (r->flags & PB_RF_DOMAINS)) { + struct pb_dom_key dk = { .rule = ri, .ip = ip4 }; + if (bpf_map_lookup_elem(&dom_hit, &dk)) host_res = M_YES; + } + } + if (host_res == M_NO) return M_NO; + return (host_res == M_MAYBE || port_res == M_MAYBE) ? M_MAYBE : M_YES; +} + +static __always_inline int implicit_direct(const struct pb_orig *o) +{ + const __u8 *a = o->addr; + if (o->family == AF_INET) { + if (a[12] == 127 || a[12] == 0 || a[12] >= 224) return 1; + } else { + if (a[0] == 0xff || (a[0] == 0xfe && (a[1] & 0xc0) == 0x80)) return 1; + int loopback = a[15] == 1; + #pragma unroll + for (int i = 0; i < 15; i++) if (a[i]) loopback = 0; + if (loopback) return 1; + } + if (o->proto == IPPROTO_UDP && (o->port == 67 || o->port == 68)) return 1; + return o->port == 53 && !cfg.dns_via_proxy; +} + +// Sets o->proxy_id for ACT_PROXY. +static __always_inline int decide(struct pb_orig *o) +{ + if (!cfg.enabled || o->pid == cfg.self_tgid) return ACT_DIRECT; + if (cfg.n_exempt && bpf_map_lookup_elem(&exempt, &o->pid)) return ACT_DIRECT; + int tap = o->port == 53 && cfg.dns_tap; + if (!tap && implicit_direct(o)) return ACT_DIRECT; + + struct mm_struct *mm = bpf_get_current_task_btf()->mm; + if (!mm) return ACT_DIRECT; + struct pb_exe_key key = {}; + key.dev = mm->exe_file->f_inode->i_sb->s_dev; + key.ino = mm->exe_file->f_inode->i_ino; + o->exe_dev = key.dev; + o->exe_ino = key.ino; + if (!key.ino) return ACT_DECIDE; + + __u32 *cid = bpf_map_lookup_elem(&exe2class, &key); + if (!cid) return ACT_DECIDE; + struct pb_bclass *c = bpf_map_lookup_elem(&classes, cid); + if (!c || c->n > PB_RULES_PER_CLASS) return ACT_DECIDE; + if (tap) { + if (c->tap) return ACT_DECIDE; + if (implicit_direct(o)) return ACT_DIRECT; + } + + __u32 ip4 = o->family == AF_INET ? bpf_ntohl(*(const __u32 *)(o->addr + 12)) : 0; + __u8 pbit = o->proto == IPPROTO_TCP ? 1 : 2; + #pragma unroll + for (int i = 0; i < PB_RULES_PER_CLASS; i++) { + if (i >= c->n) break; + __u32 ri = c->rule[i]; + struct pb_brule *r = bpf_map_lookup_elem(&brules, &ri); + if (!r) return ACT_DECIDE; + if (!(r->proto_mask & pbit)) continue; + int m = rule_match(r, ri, o->family, ip4, o->port); + if (m == M_NO) continue; + if (m == M_MAYBE) return ACT_DECIDE; + if (r->action != ACT_PROXY) return r->action; + if (o->proto == IPPROTO_UDP && (r->flags & PB_RF_UDP_DIRECT)) return ACT_DIRECT; + o->proxy_id = r->proxy_id; + return ACT_PROXY; + } + return ACT_DIRECT; +} + +static __always_inline void emit(const struct pb_orig *o, int action) +{ + if (!cfg.log) return; + struct pb_event *e = bpf_ringbuf_reserve(&events, sizeof *e, 0); + if (!e) return; + e->pid = o->pid; + bpf_get_current_comm(e->comm, sizeof e->comm); + __builtin_memcpy(e->addr, o->addr, 16); + e->port = o->port; + e->proxy_id = o->proxy_id; + e->family = o->family; + e->proto = o->proto; + e->action = action; + e->pad = 0; + bpf_ringbuf_submit(e, 0); +} + +static __always_inline void set_mapped_v4(__u32 *ip6, __u32 ip4_be) +{ + ip6[0] = 0; ip6[1] = 0; ip6[2] = bpf_htonl(0x0000ffff); ip6[3] = ip4_be; +} + +static __always_inline void orig_from_v4(struct pb_orig *o, __u32 ip_be, __u16 port_be, __u8 proto) +{ + __builtin_memset(o, 0, sizeof *o); + o->addr[10] = 0xff; o->addr[11] = 0xff; + __builtin_memcpy(o->addr + 12, &ip_be, 4); + o->port = bpf_ntohs(port_be); + o->family = AF_INET; + o->proto = proto; + o->pid = bpf_get_current_pid_tgid() >> 32; +} + +// A v4-mapped address on an IPv6 socket is an IPv4 destination. +static __always_inline void orig_from_v6(struct pb_orig *o, const __u32 ip6[4], __u16 port_be, __u8 proto) +{ + __builtin_memset(o, 0, sizeof *o); + __builtin_memcpy(o->addr, ip6, 16); + o->port = bpf_ntohs(port_be); + o->proto = proto; + o->pid = bpf_get_current_pid_tgid() >> 32; + o->family = (ip6[0] == 0 && ip6[1] == 0 && ip6[2] == bpf_htonl(0x0000ffff)) ? AF_INET : AF_INET6; +} + +// Loopback flow address 127.1-255.x.y standing for one UDP destination; the relay recovers the destination from it. +static __always_inline __u32 udp_flow_addr(struct pb_orig *o) +{ + const __u32 *w = (const __u32 *)o->addr; + __u32 h = w[0] * 2654435761u ^ w[1] * 40503u ^ w[2] * 2246822519u ^ w[3] * 3266489917u; + h ^= ((__u32)o->port << 16) | o->proxy_id; + h ^= (__u32)o->action * 668265263u; + if (o->action == ACT_DECIDE) h ^= o->pid * 374761393u; + h ^= h >> 15; h *= 2246822519u; h ^= h >> 13; + __u32 addr = 0; + for (int i = 0; i < 8; i++) { + addr = bpf_htonl(0x7f000000 | (((h + i) & 0x00ffffff) | 0x00010000)); + struct pb_orig *ex = bpf_map_lookup_elem(&flows, &addr); + if (!ex) { bpf_map_update_elem(&flows, &addr, o, BPF_NOEXIST); break; } + if (!__builtin_memcmp(ex->addr, o->addr, 16) && ex->port == o->port && ex->proxy_id == o->proxy_id && + ex->action == o->action && (o->action != ACT_DECIDE || ex->pid == o->pid)) break; + } + return addr; +} + +// 0: leave the call untouched, 1: redirected to the relay, -1: block. +static __always_inline int route(struct pb_orig *o) +{ + int act = decide(o); + if (act == ACT_DIRECT || act == ACT_BLOCK) { + emit(o, act); + return act == ACT_BLOCK ? -1 : 0; + } + o->action = act; + if (act == ACT_PROXY) emit(o, ACT_PROXY); + else o->proxy_id = 0; + return 1; +} + +SEC("cgroup/connect4") +int pb_connect4(struct bpf_sock_addr *ctx) +{ + if (foreign_netns(ctx) || (ctx->protocol != IPPROTO_TCP && ctx->protocol != IPPROTO_UDP)) return 1; + struct pb_orig o; + orig_from_v4(&o, ctx->user_ip4, ctx->user_port, ctx->protocol); + int r = route(&o); + if (r <= 0) return r == 0; + if (ctx->protocol == IPPROTO_TCP) { + __u64 ck = bpf_get_socket_cookie(ctx); + bpf_map_update_elem(&by_cookie, &ck, &o, BPF_ANY); + ctx->user_ip4 = bpf_htonl(0x7f000001); + ctx->user_port = bpf_htons(cfg.tcp_port); + } else { + ctx->user_ip4 = udp_flow_addr(&o); + ctx->user_port = bpf_htons(cfg.udp_port); + } + return 1; +} + +SEC("cgroup/connect6") +int pb_connect6(struct bpf_sock_addr *ctx) +{ + if (foreign_netns(ctx) || (ctx->protocol != IPPROTO_TCP && ctx->protocol != IPPROTO_UDP)) return 1; + __u32 ip6[4] = { ctx->user_ip6[0], ctx->user_ip6[1], ctx->user_ip6[2], ctx->user_ip6[3] }; + struct pb_orig o; + orig_from_v6(&o, ip6, ctx->user_port, ctx->protocol); + int r = route(&o); + if (r <= 0) return r == 0; + if (ctx->protocol == IPPROTO_TCP) { + __u64 ck = bpf_get_socket_cookie(ctx); + bpf_map_update_elem(&by_cookie, &ck, &o, BPF_ANY); + set_mapped_v4(ctx->user_ip6, bpf_htonl(0x7f000001)); + ctx->user_port = bpf_htons(cfg.tcp_port); + } else { + set_mapped_v4(ctx->user_ip6, udp_flow_addr(&o)); + ctx->user_port = bpf_htons(cfg.udp_port); + } + return 1; +} + +SEC("cgroup/sendmsg4") +int pb_sendmsg4(struct bpf_sock_addr *ctx) +{ + if (foreign_netns(ctx) || ctx->protocol != IPPROTO_UDP) return 1; + struct pb_orig o; + orig_from_v4(&o, ctx->user_ip4, ctx->user_port, IPPROTO_UDP); + int r = route(&o); + if (r <= 0) return r == 0; + ctx->user_ip4 = udp_flow_addr(&o); + ctx->user_port = bpf_htons(cfg.udp_port); + return 1; +} + +// udpv6_sendmsg refuses a hook that rewrites a native IPv6 destination to a v4-mapped address and loopback has only ::1, so +// a native IPv6 flow that must be proxied fails closed (EPERM); the application falls back to IPv4 or TCP. +SEC("cgroup/sendmsg6") +int pb_sendmsg6(struct bpf_sock_addr *ctx) +{ + if (foreign_netns(ctx) || ctx->protocol != IPPROTO_UDP) return 1; + __u32 ip6[4] = { ctx->user_ip6[0], ctx->user_ip6[1], ctx->user_ip6[2], ctx->user_ip6[3] }; + struct pb_orig o; + orig_from_v6(&o, ip6, ctx->user_port, IPPROTO_UDP); + int r = route(&o); + if (r <= 0) return r == 0; + return o.action != ACT_PROXY; +} + +// Replies come from the flow address; show the application the real peer. +SEC("cgroup/recvmsg4") +int pb_recvmsg4(struct bpf_sock_addr *ctx) +{ + if (ctx->protocol != IPPROTO_UDP || ctx->user_port != bpf_htons(cfg.udp_port)) return 1; + __u32 key = ctx->user_ip4; + struct pb_orig *o = bpf_map_lookup_elem(&flows, &key); + if (o && o->family == AF_INET) { + ctx->user_ip4 = *(__u32 *)(o->addr + 12); + ctx->user_port = bpf_htons(o->port); + } + return 1; +} + +SEC("cgroup/recvmsg6") +int pb_recvmsg6(struct bpf_sock_addr *ctx) +{ + if (ctx->protocol != IPPROTO_UDP || ctx->user_port != bpf_htons(cfg.udp_port)) return 1; + if (ctx->user_ip6[0] || ctx->user_ip6[1] || ctx->user_ip6[2] != bpf_htonl(0x0000ffff)) return 1; + __u32 key = ctx->user_ip6[3]; + struct pb_orig *o = bpf_map_lookup_elem(&flows, &key); + if (!o) return 1; + const __u32 *w = (const __u32 *)o->addr; + ctx->user_ip6[0] = w[0]; ctx->user_ip6[1] = w[1]; ctx->user_ip6[2] = w[2]; ctx->user_ip6[3] = w[3]; + ctx->user_port = bpf_htons(o->port); + return 1; +} + +// getpeername() on a redirected socket reports the original destination. +SEC("cgroup/getpeername4") +int pb_getpeername4(struct bpf_sock_addr *ctx) +{ + struct pb_orig *o; + if (ctx->protocol == IPPROTO_UDP) { + __u32 key = ctx->user_ip4; + o = bpf_map_lookup_elem(&flows, &key); + } else if (ctx->protocol == IPPROTO_TCP) { + __u64 ck = bpf_get_socket_cookie(ctx); + o = bpf_map_lookup_elem(&by_cookie, &ck); + } else return 1; + if (o && o->family == AF_INET) { + ctx->user_ip4 = *(__u32 *)(o->addr + 12); + ctx->user_port = bpf_htons(o->port); + } + return 1; +} + +SEC("cgroup/getpeername6") +int pb_getpeername6(struct bpf_sock_addr *ctx) +{ + struct pb_orig *o; + if (ctx->protocol == IPPROTO_UDP) { + if (ctx->user_ip6[0] || ctx->user_ip6[1] || ctx->user_ip6[2] != bpf_htonl(0x0000ffff)) return 1; + __u32 key = ctx->user_ip6[3]; + o = bpf_map_lookup_elem(&flows, &key); + } else if (ctx->protocol == IPPROTO_TCP) { + __u64 ck = bpf_get_socket_cookie(ctx); + o = bpf_map_lookup_elem(&by_cookie, &ck); + } else return 1; + if (o) { + const __u32 *w = (const __u32 *)o->addr; + ctx->user_ip6[0] = w[0]; ctx->user_ip6[1] = w[1]; ctx->user_ip6[2] = w[2]; ctx->user_ip6[3] = w[3]; + ctx->user_port = bpf_htons(o->port); + } + return 1; +} + +// Hands the original destination to the relay, keyed by the client's source address and port. +SEC("sockops") +int pb_sockops(struct bpf_sock_ops *s) +{ + if (s->op != BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB) return 1; + __u64 ck = bpf_get_socket_cookie(s); + struct pb_orig *o = bpf_map_lookup_elem(&by_cookie, &ck); + if (!o) return 1; + struct pb_port_key k = { .ip = s->local_ip4, .port = s->local_port }; + if (s->family == AF_INET6 && !s->local_ip6[0] && !s->local_ip6[1] && s->local_ip6[2] == bpf_htonl(0x0000ffff)) + k.ip = s->local_ip6[3]; + bpf_map_update_elem(&by_port, &k, o, BPF_ANY); + return 1; +} + +char LICENSE[] SEC("license") = "GPL"; diff --git a/Linux/src/exports.map b/Linux/src/exports.map new file mode 100644 index 00000000..0084447a --- /dev/null +++ b/Linux/src/exports.map @@ -0,0 +1,6 @@ +{ + global: + ProxyBridge_*; + local: + *; +}; diff --git a/Linux/src/glibc_compat.h b/Linux/src/glibc_compat.h new file mode 100644 index 00000000..873c1a80 --- /dev/null +++ b/Linux/src/glibc_compat.h @@ -0,0 +1,23 @@ +// glibc 2.38 headers redirect strto*/scanf to C23 variants (GLIBC_2.38 symbols), which stops the binary from loading on older +// systems (GitHub issue #146). This header binds the names back to the classic symbol versions; it is force-included when +// building third-party C code (libbpf) that uses them. Our own sources avoid these functions. +#ifndef PB_GLIBC_COMPAT_H +#define PB_GLIBC_COMPAT_H + +#include + +#if defined(__GLIBC__) && (__GLIBC__ > 2 || (__GLIBC__ == 2 && __GLIBC_MINOR__ >= 38)) +# if defined(__x86_64__) +# define PB_BASE_GLIBC "GLIBC_2.2.5" +# elif defined(__aarch64__) +# define PB_BASE_GLIBC "GLIBC_2.17" +# endif +# ifdef PB_BASE_GLIBC +# define PB_SYMVER(name) __asm__(".symver __isoc23_" #name "," #name "@" PB_BASE_GLIBC) +PB_SYMVER(strtol); PB_SYMVER(strtoul); PB_SYMVER(strtoll); PB_SYMVER(strtoull); +PB_SYMVER(scanf); PB_SYMVER(fscanf); PB_SYMVER(sscanf); +PB_SYMVER(vscanf); PB_SYMVER(vfscanf); PB_SYMVER(vsscanf); +# endif +#endif + +#endif diff --git a/Linux/src/pb_bpf.c b/Linux/src/pb_bpf.c new file mode 100644 index 00000000..df73bb3d --- /dev/null +++ b/Linux/src/pb_bpf.c @@ -0,0 +1,495 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "pb_bpf.h" +#include "proxybridge.skel.h" + +#ifndef CGROUP2_SUPER_MAGIC +#define CGROUP2_SUPER_MAGIC 0x63677270 +#endif +#ifndef SO_NETNS_COOKIE +#define SO_NETNS_COOKIE 71 +#endif + +#define MAX_KNOWN_EXES 16384 +#define MAX_EXEMPT 64 + +struct exe_ent { uint32_t dev; uint64_t ino; char *path; }; + +static struct { + pthread_mutex_t lock; + struct proxybridge_bpf *skel; + int linkfds[16], nlinks; + struct ring_buffer *rb; + pthread_t evt_thread; + bool evt_started, running; + int stop_fd; + pb_event_cb cb; + struct exe_ent *exes; size_t nexes; + struct pb_bclass *cls; size_t ncls; + struct pb_brule *br; size_t nbr; +} G = { .lock = PTHREAD_MUTEX_INITIALIZER, .stop_fd = -1 }; + +#define MAPFD(name) bpf_map__fd(G.skel->maps.name) + +static int libbpf_print(enum libbpf_print_level lvl, const char *fmt, va_list ap) +{ + if (lvl == LIBBPF_DEBUG) return 0; + char buf[1024]; + vsnprintf(buf, sizeof buf, fmt, ap); + size_t n = strlen(buf); + while (n && buf[n - 1] == '\n') buf[--n] = 0; + if (n) pb_core_log("libbpf: %s", buf); + return 0; +} + +static int open_cgroup_root(void) +{ + static const char *const cands[] = { "/sys/fs/cgroup", "/sys/fs/cgroup/unified" }; + for (size_t i = 0; i < sizeof cands / sizeof *cands; i++) { + struct statfs sf; + if (statfs(cands[i], &sf) == 0 && (unsigned long)sf.f_type == CGROUP2_SUPER_MAGIC) + return open(cands[i], O_RDONLY | O_DIRECTORY | O_CLOEXEC); + } + return -1; +} + +// ALLOW_MULTI lets us coexist with other sock_addr programs on the root cgroup. +static bool attach(struct bpf_program *prog, enum bpf_attach_type type, int cgfd, bool optional) +{ + if (!bpf_program__autoload(prog)) return true; + LIBBPF_OPTS(bpf_link_create_opts, opts, .flags = BPF_F_ALLOW_MULTI); + int lfd = bpf_link_create(bpf_program__fd(prog), cgfd, type, &opts); + if (lfd < 0 && errno == EINVAL) lfd = bpf_link_create(bpf_program__fd(prog), cgfd, type, NULL); + if (lfd < 0) { + if (optional) return true; + pb_core_log("attach %s failed: %s", bpf_program__name(prog), strerror(errno)); + return false; + } + G.linkfds[G.nlinks++] = lfd; + return true; +} + +static void detach_all(void) +{ + while (G.nlinks) close(G.linkfds[--G.nlinks]); +} + +static int on_event(void *ctx, void *data, size_t len) +{ + (void)ctx; + if (len >= sizeof(struct pb_event) && G.cb) G.cb(data); + return 0; +} + +static void *evt_loop(void *arg) +{ + (void)arg; + struct pollfd p[2] = { { ring_buffer__epoll_fd(G.rb), POLLIN, 0 }, { G.stop_fd, POLLIN, 0 } }; + while (poll(p, 2, -1) >= 0 || errno == EINTR) { + if (p[1].revents) break; + if (p[0].revents) ring_buffer__consume(G.rb); + } + return NULL; +} + +static uint64_t netns_cookie(void) +{ + uint64_t cookie = 0; + socklen_t len = sizeof cookie; + int fd = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); + if (fd >= 0) { + if (getsockopt(fd, SOL_SOCKET, SO_NETNS_COOKIE, &cookie, &len) != 0) cookie = 0; + close(fd); + } + return cookie; +} + +static bool attach_all(int cgfd) +{ +#define ATTACH(prog, type, optional) attach(G.skel->progs.prog, type, cgfd, optional) + return ATTACH(pb_connect4, BPF_CGROUP_INET4_CONNECT, false) && + ATTACH(pb_connect6, BPF_CGROUP_INET6_CONNECT, true) && + ATTACH(pb_sendmsg4, BPF_CGROUP_UDP4_SENDMSG, false) && + ATTACH(pb_sendmsg6, BPF_CGROUP_UDP6_SENDMSG, true) && + ATTACH(pb_recvmsg4, BPF_CGROUP_UDP4_RECVMSG, false) && + ATTACH(pb_recvmsg6, BPF_CGROUP_UDP6_RECVMSG, true) && + ATTACH(pb_getpeername4, BPF_CGROUP_INET4_GETPEERNAME, true) && + ATTACH(pb_getpeername6, BPF_CGROUP_INET6_GETPEERNAME, true) && + ATTACH(pb_sockops, BPF_CGROUP_SOCK_OPS, false); +#undef ATTACH +} + +// getpeername hooks need a newer kernel than the rest; load without them if the first attempt fails. +static int load_skeleton(void) +{ + G.skel = proxybridge_bpf__open(); + if (!G.skel) return -errno; + int err = proxybridge_bpf__load(G.skel); + if (!err) return 0; + proxybridge_bpf__destroy(G.skel); + G.skel = proxybridge_bpf__open(); + if (!G.skel) return -errno; + bpf_program__set_autoload(G.skel->progs.pb_getpeername4, false); + bpf_program__set_autoload(G.skel->progs.pb_getpeername6, false); + return proxybridge_bpf__load(G.skel); +} + +static void free_tables(void) +{ + for (size_t i = 0; i < G.nexes; i++) free(G.exes[i].path); + free(G.exes); G.exes = NULL; G.nexes = 0; + free(G.cls); G.cls = NULL; G.ncls = 0; + free(G.br); G.br = NULL; G.nbr = 0; +} + +int pb_bpf_start(const struct pb_bpf_cfg *cfg, pb_event_cb cb) +{ + pthread_mutex_lock(&G.lock); + if (G.running) { pthread_mutex_unlock(&G.lock); return 0; } + int rc = -1, cgfd = -1; + + if (geteuid() != 0) { pb_core_log("ProxyBridge needs root (CAP_BPF/CAP_SYS_ADMIN) to load its eBPF programs"); goto out; } + libbpf_set_print(libbpf_print); + struct rlimit rl = { RLIM_INFINITY, RLIM_INFINITY }; + setrlimit(RLIMIT_MEMLOCK, &rl); + + cgfd = open_cgroup_root(); + if (cgfd < 0) { pb_core_log("cgroup v2 is not mounted (need a unified hierarchy at /sys/fs/cgroup)"); goto out; } + + int err = load_skeleton(); + if (err) { + pb_core_log("loading the BPF programs failed (needs a kernel >= 5.11 with BTF and cgroup sock_addr support): %s", strerror(-err)); + goto out; + } + + volatile struct pb_cfg *c = &G.skel->bss->cfg; + c->tcp_port = cfg->tcp_port; + c->udp_port = cfg->udp_port; + c->self_tgid = cfg->self_tgid; + c->log = cfg->log; + c->dns_via_proxy = cfg->dns_via_proxy; + c->netns = netns_cookie(); + if (!c->netns) pb_core_log("kernel has no SO_NETNS_COOKIE: connections from other network namespaces are not isolated"); + + if (!attach_all(cgfd)) goto out; + + G.cb = cb; + G.stop_fd = eventfd(0, EFD_CLOEXEC); + G.rb = ring_buffer__new(MAPFD(events), on_event, NULL, NULL); + if (G.stop_fd < 0 || !G.rb || pthread_create(&G.evt_thread, NULL, evt_loop, NULL) != 0) { + pb_core_log("cannot start the event reader"); + goto out; + } + G.evt_started = G.running = true; + rc = 0; +out: + if (cgfd >= 0) close(cgfd); + if (rc != 0) { + detach_all(); + if (G.rb) { ring_buffer__free(G.rb); G.rb = NULL; } + if (G.stop_fd >= 0) { close(G.stop_fd); G.stop_fd = -1; } + if (G.skel) { proxybridge_bpf__destroy(G.skel); G.skel = NULL; } + } + pthread_mutex_unlock(&G.lock); + return rc; +} + +void pb_bpf_detach(void) +{ + pthread_mutex_lock(&G.lock); + if (G.evt_started) { + eventfd_write(G.stop_fd, 1); + pthread_mutex_unlock(&G.lock); + pthread_join(G.evt_thread, NULL); + pthread_mutex_lock(&G.lock); + G.evt_started = false; + } + detach_all(); + pthread_mutex_unlock(&G.lock); +} + +void pb_bpf_stop(void) +{ + pb_bpf_detach(); + pthread_mutex_lock(&G.lock); + if (G.rb) { ring_buffer__free(G.rb); G.rb = NULL; } + if (G.stop_fd >= 0) { close(G.stop_fd); G.stop_fd = -1; } + if (G.skel) { proxybridge_bpf__destroy(G.skel); G.skel = NULL; } + free_tables(); + G.running = false; + pthread_mutex_unlock(&G.lock); +} + +void pb_bpf_set_flags(bool log, bool dns_via_proxy) +{ + pthread_mutex_lock(&G.lock); + if (G.skel) { + G.skel->bss->cfg.log = log; + G.skel->bss->cfg.dns_via_proxy = dns_via_proxy; + } + pthread_mutex_unlock(&G.lock); +} + +static uint32_t kernel_dev(dev_t d) { return (major(d) << 20) | minor(d); } + +static struct exe_ent *exe_find(uint32_t dev, uint64_t ino) +{ + for (size_t i = 0; i < G.nexes; i++) + if (G.exes[i].ino == ino && G.exes[i].dev == dev) return &G.exes[i]; + return NULL; +} + +static struct exe_ent *exe_add(uint32_t dev, uint64_t ino, const char *path) +{ + struct exe_ent *e = exe_find(dev, ino); + if (e) return e; + if (G.nexes >= MAX_KNOWN_EXES) return NULL; + if (!G.exes && !(G.exes = calloc(MAX_KNOWN_EXES, sizeof *G.exes))) return NULL; + char *copy = strdup(path ? path : ""); + if (!copy) return NULL; + e = &G.exes[G.nexes++]; + e->dev = dev; e->ino = ino; e->path = copy; + return e; +} + +static void strip_deleted(char *p) +{ + static const char suffix[] = " (deleted)"; + size_t n = strlen(p), s = sizeof suffix - 1; + if (n > s && !strcmp(p + n - s, suffix)) p[n - s] = 0; +} + +static void scan_proc(void) +{ + DIR *d = opendir("/proc"); + if (!d) return; + struct dirent *de; + while ((de = readdir(d))) { + if (de->d_name[0] < '1' || de->d_name[0] > '9') continue; + char link[300], path[4096]; + struct stat st; + snprintf(link, sizeof link, "/proc/%.200s/exe", de->d_name); + if (stat(link, &st) != 0) continue; + ssize_t n = readlink(link, path, sizeof path - 1); + if (n <= 0) continue; + path[n] = 0; + strip_deleted(path); + exe_add(kernel_dev(st.st_dev), st.st_ino, path); + } + closedir(d); +} + +static void compile_rule(const struct pb_rule_view *v, struct pb_brule *b) +{ + const struct pb_rule *r = v->rule; + memset(b, 0, sizeof *b); + b->action = (uint8_t)v->action; + b->proxy_id = v->action == RULE_ACTION_PROXY ? v->proxy_id : 0; + b->proto_mask = r->protocol == RULE_PROTOCOL_TCP ? 1 : r->protocol == RULE_PROTOCOL_UDP ? 2 : 3; + if (v->action == RULE_ACTION_PROXY && v->udp_direct) b->flags |= PB_RF_UDP_DIRECT; + + if (r->any_port) b->flags |= PB_RF_ANY_PORT; + for (int i = 0; i < r->nports; i++) { + if (b->n_port < PB_BPF_MAX_PORT) { b->port[b->n_port].lo = r->ports[i].lo; b->port[b->n_port].hi = r->ports[i].hi; b->n_port++; } + else b->flags |= PB_RF_PORT_COMPLEX; + } + if (r->any_host) { b->flags |= PB_RF_ANY_HOST; return; } + if (r->ndomains) b->flags |= PB_RF_DOMAINS; + for (int i = 0; i < r->nhosts; i++) { + const struct pb_host_pat *h = &r->hosts[i]; + if (h->kind == PB_HOST_V4_RANGE && b->n_v4 < PB_BPF_MAX_V4) { b->v4[b->n_v4].lo = h->lo4; b->v4[b->n_v4].hi = h->hi4; b->n_v4++; } + else if (h->kind == PB_HOST_V6_RANGE) b->flags |= PB_RF_HAS_V6; + else b->flags |= PB_RF_HOST_COMPLEX; + } +} + +// A catch-all rule shadows every later one. +static bool is_catchall(const struct pb_brule *b) +{ + return (b->flags & PB_RF_ANY_HOST) && (b->flags & PB_RF_ANY_PORT) && b->proto_mask == 3; +} + +static void class_for(const struct pb_rule_view *v, const struct pb_brule *br, size_t n, const char *path, struct pb_bclass *out) +{ + memset(out, 0, sizeof *out); + for (size_t i = 0; i < n; i++) { + if (!v[i].rule->enabled || !pb_rule_match_process(v[i].rule, path)) continue; + if (pb_rule_has_domains(v[i].rule)) out->tap = 1; + if (out->n >= PB_RULES_PER_CLASS) { + memset(out->rule, 0, sizeof out->rule); + out->n = PB_CLASS_DECIDE; + out->tap = 1; + return; + } + out->rule[out->n++] = (uint32_t)i; + if (is_catchall(&br[i])) return; + } +} + +static int class_intern(const struct pb_bclass *c) +{ + for (size_t i = 0; i < G.ncls; i++) + if (!memcmp(&G.cls[i], c, sizeof *c)) return (int)i; + if (G.ncls >= PB_MAX_CLASSES) return -1; + G.cls[G.ncls] = *c; + return (int)G.ncls++; +} + +static void write_class(uint32_t id) +{ + bpf_map_update_elem(MAPFD(classes), &id, &G.cls[id], BPF_ANY); +} + +static void install_exe(const struct exe_ent *e, const struct pb_rule_view *v, size_t n) +{ + struct pb_bclass c; + class_for(v, G.br, n, e->path, &c); + struct pb_exe_key key = { .dev = e->dev, .ino = e->ino }; + int id = class_intern(&c); + if (id < 0) { bpf_map_delete_elem(MAPFD(exe2class), &key); return; } + uint32_t cid = (uint32_t)id; + write_class((uint32_t)id); + bpf_map_update_elem(MAPFD(exe2class), &key, &cid, BPF_ANY); +} + +static size_t installed_keys(struct pb_exe_key **out) +{ + size_t n = 0, cap = 0; + struct pb_exe_key *keys = NULL, cur, next; + int fd = MAPFD(exe2class), rc = bpf_map_get_next_key(fd, NULL, &next); + while (rc == 0) { + if (n == cap) { + struct pb_exe_key *grown = realloc(keys, (cap ? cap * 2 : 256) * sizeof *keys); + if (!grown) break; + keys = grown; cap = cap ? cap * 2 : 256; + } + keys[n++] = cur = next; + rc = bpf_map_get_next_key(fd, &cur, &next); + } + *out = keys; + return n; +} + +int pb_bpf_sync(const struct pb_rule_view *v, size_t n) +{ + if (n > PB_MAX_RULES) { pb_core_log("too many rules (max %d)", PB_MAX_RULES); return -1; } + pthread_mutex_lock(&G.lock); + if (!G.skel) { pthread_mutex_unlock(&G.lock); return -1; } + + struct pb_brule *br = calloc(n ? n : 1, sizeof *br); + if (!G.cls) G.cls = calloc(PB_MAX_CLASSES, sizeof *G.cls); + if (!br || !G.cls) { free(br); pthread_mutex_unlock(&G.lock); return -1; } + free(G.br); + G.br = br; G.nbr = n; + + bool active = false, domains = false; + for (size_t i = 0; i < n; i++) { + compile_rule(&v[i], &br[i]); + uint32_t k = (uint32_t)i; + bpf_map_update_elem(MAPFD(brules), &k, &br[i], BPF_ANY); + if (v[i].rule->enabled) { + active = true; + domains |= pb_rule_has_domains(v[i].rule) != 0; + } + } + + scan_proc(); + memset(G.cls, 0, PB_MAX_CLASSES * sizeof *G.cls); + G.ncls = 1; // class 0: no rule applies + struct pb_exe_key *stale; + size_t nstale = installed_keys(&stale); + for (size_t i = 0; i < G.nexes; i++) install_exe(&G.exes[i], v, n); + write_class(0); + for (size_t i = 0; i < nstale; i++) + if (!exe_find(stale[i].dev, stale[i].ino)) bpf_map_delete_elem(MAPFD(exe2class), &stale[i]); + free(stale); + + G.skel->bss->cfg.dns_tap = domains; + G.skel->bss->cfg.enabled = active; + pthread_mutex_unlock(&G.lock); + return 0; +} + +void pb_bpf_learn(const struct pb_orig *o, const char *exe_path, const struct pb_rule_view *v, size_t n) +{ + if (!o->exe_ino) return; + pthread_mutex_lock(&G.lock); + if (G.skel && G.cls && n == G.nbr && !exe_find(o->exe_dev, o->exe_ino)) { + struct exe_ent *e = exe_add(o->exe_dev, o->exe_ino, exe_path); + if (e) install_exe(e, v, n); + } + pthread_mutex_unlock(&G.lock); +} + +void pb_bpf_set_exempt(const uint32_t *tgids, size_t n) +{ + pthread_mutex_lock(&G.lock); + if (!G.skel) { pthread_mutex_unlock(&G.lock); return; } + int fd = MAPFD(exempt); + uint32_t cur, next, stale[MAX_EXEMPT]; + size_t nstale = 0; + for (int rc = bpf_map_get_next_key(fd, NULL, &next); rc == 0 && nstale < MAX_EXEMPT; rc = bpf_map_get_next_key(fd, &cur, &next)) { + cur = next; + bool keep = false; + for (size_t i = 0; i < n && !keep; i++) keep = tgids[i] == cur; + if (!keep) stale[nstale++] = cur; + } + for (size_t i = 0; i < nstale; i++) bpf_map_delete_elem(fd, &stale[i]); + uint8_t one = 1; + size_t count = n < MAX_EXEMPT ? n : MAX_EXEMPT; + for (size_t i = 0; i < count; i++) bpf_map_update_elem(fd, &tgids[i], &one, BPF_ANY); + G.skel->bss->cfg.n_exempt = (uint32_t)count; + pthread_mutex_unlock(&G.lock); +} + +void pb_bpf_dom_set(uint32_t rule_idx, uint32_t ip_host) +{ + if (!G.skel) return; + struct pb_dom_key k = { .rule = rule_idx, .ip = ip_host }; + uint8_t one = 1; + bpf_map_update_elem(MAPFD(dom_hit), &k, &one, BPF_ANY); +} + +void pb_bpf_dom_clear(void) +{ + if (!G.skel) return; + int fd = MAPFD(dom_hit); + struct pb_dom_key k, next; + for (int rc = bpf_map_get_next_key(fd, NULL, &next); rc == 0;) { + k = next; + rc = bpf_map_get_next_key(fd, &k, &next); + bpf_map_delete_elem(fd, &k); + } +} + +int pb_bpf_take_tcp_orig(uint32_t client_ip_be, uint16_t client_port, struct pb_orig *out) +{ + if (!G.skel) return -1; + struct pb_port_key key = { .ip = client_ip_be, .port = client_port }; + int fd = MAPFD(by_port); + if (bpf_map_lookup_elem(fd, &key, out) != 0) return -1; + bpf_map_delete_elem(fd, &key); + return 0; +} + +int pb_bpf_get_udp_flow(uint32_t flow_ip_be, struct pb_orig *out) +{ + return G.skel && bpf_map_lookup_elem(MAPFD(flows), &flow_ip_be, out) == 0 ? 0 : -1; +} diff --git a/Linux/src/pb_bpf.h b/Linux/src/pb_bpf.h new file mode 100644 index 00000000..56abe6bb --- /dev/null +++ b/Linux/src/pb_bpf.h @@ -0,0 +1,47 @@ +// eBPF backend: loads and attaches the datapath, compiles rules into BPF maps, delivers flow events. +#ifndef PB_BPF_H +#define PB_BPF_H + +#include "pb_internal.h" +#include "pb_rules.h" +#include "bpf/pb_bpf_shared.h" + +struct pb_bpf_cfg { + uint16_t tcp_port, udp_port; + uint32_t self_tgid; + bool log; + bool dns_via_proxy; +}; + +// A rule as seen by the compiler: the parsed rule plus its effective action and proxy. +struct pb_rule_view { + const struct pb_rule *rule; + int action; // PROXY with no usable proxy has already become BLOCK + uint16_t proxy_id; + bool udp_direct; // the proxy cannot carry UDP +}; + +typedef void (*pb_event_cb)(const struct pb_event *e); + +// Returns 0, or -1 after logging why (not root, kernel too old, no cgroup2, verifier...). +int pb_bpf_start(const struct pb_bpf_cfg *cfg, pb_event_cb cb); +// Detaches the hooks and stops the event reader; the maps stay valid until pb_bpf_stop() (relay threads may still look up). +void pb_bpf_detach(void); +void pb_bpf_stop(void); + +void pb_bpf_set_flags(bool log, bool dns_via_proxy); +// Recompiles the rule set and rescans /proc for running executables. -1 if it does not fit. +int pb_bpf_sync(const struct pb_rule_view *v, size_t n); +// Installs the class of the executable the kernel reported in `o` (slow-path learning); no-op for known binaries. +void pb_bpf_learn(const struct pb_orig *o, const char *exe_path, const struct pb_rule_view *v, size_t n); +// Processes (tgids) whose traffic is never redirected, e.g. the local proxy itself. +void pb_bpf_set_exempt(const uint32_t *tgids, size_t n); + +// (rule, address) pairs whose DNS name matches a domain rule. +void pb_bpf_dom_set(uint32_t rule_idx, uint32_t ip_host); +void pb_bpf_dom_clear(void); + +int pb_bpf_take_tcp_orig(uint32_t client_ip_be, uint16_t client_port, struct pb_orig *out); +int pb_bpf_get_udp_flow(uint32_t flow_ip_be, struct pb_orig *out); + +#endif diff --git a/Linux/src/pb_dns.c b/Linux/src/pb_dns.c new file mode 100644 index 00000000..c78050aa --- /dev/null +++ b/Linux/src/pb_dns.c @@ -0,0 +1,258 @@ +#include +#include +#include +#include +#include +#include +#include +#include "pb_dns.h" + +#define BUCKETS 16384 + +struct ent { + uint32_t ip; + char *names[PB_DNS_MAX_NAMES]; int n; + struct ent *hnext; // hash chain + struct ent *prev, *next; // age order: head = oldest +}; + +static struct { + pthread_mutex_t lock; + struct ent *bucket[BUCKETS]; + struct ent *head, *tail; + size_t count; +} T = { .lock = PTHREAD_MUTEX_INITIALIZER }; + +static unsigned hash_ip(uint32_t ip) { ip *= 2654435761u; return (ip >> 12) & (BUCKETS - 1); } + +static void ent_unlink_age(struct ent *e) +{ + if (e->prev) e->prev->next = e->next; else T.head = e->next; + if (e->next) e->next->prev = e->prev; else T.tail = e->prev; + e->prev = e->next = NULL; +} + +static void ent_push_tail(struct ent *e) +{ + e->prev = T.tail; e->next = NULL; + if (T.tail) T.tail->next = e; else T.head = e; + T.tail = e; +} + +static void ent_free(struct ent *e) +{ + for (int i = 0; i < e->n; i++) free(e->names[i]); + free(e); +} + +static void evict_oldest(struct pb_dns_result *res) +{ + struct ent *e = T.head; + if (!e) return; + ent_unlink_age(e); + for (struct ent **pp = &T.bucket[hash_ip(e->ip)]; *pp; pp = &(*pp)->hnext) + if (*pp == e) { *pp = e->hnext; break; } + if (res && res->nevicted < 32) res->evicted[res->nevicted++] = e->ip; + ent_free(e); + T.count--; +} + +static struct ent *find(uint32_t ip) +{ + for (struct ent *e = T.bucket[hash_ip(ip)]; e; e = e->hnext) if (e->ip == ip) return e; + return NULL; +} + +// Adds names to ip's entry (creating it), refreshing its age. Caller holds the lock. +static void add_mapping(uint32_t ip, char names[][256], int nn, struct pb_dns_result *res) +{ + struct ent *e = find(ip); + if (!e) { + while (T.count >= PB_DNS_MAX_ENTRIES) evict_oldest(res); + e = calloc(1, sizeof *e); + if (!e) return; + e->ip = ip; + unsigned h = hash_ip(ip); + e->hnext = T.bucket[h]; T.bucket[h] = e; + T.count++; + } else { + ent_unlink_age(e); + } + ent_push_tail(e); + for (int i = 0; i < nn; i++) { + bool have = false; + for (int j = 0; j < e->n; j++) if (!strcmp(e->names[j], names[i])) { have = true; break; } + if (have) continue; + char *c = strdup(names[i]); + if (!c) continue; + if (e->n == PB_DNS_MAX_NAMES) { free(e->names[0]); memmove(&e->names[0], &e->names[1], (PB_DNS_MAX_NAMES - 1) * sizeof(char *)); e->n--; } + e->names[e->n++] = c; + } + if (res) { + bool dup = false; + for (int i = 0; i < res->nips; i++) if (res->ips[i] == ip) dup = true; + if (!dup && res->nips < 32) res->ips[res->nips++] = ip; + } +} + + +// Reads a (possibly compressed) name at *off into out (lower-case, dotted). Advances *off past the name as stored at that +// position. Returns false on malformed data. +static bool read_name(const uint8_t *m, size_t len, size_t *off, char *out, size_t outsz) +{ + size_t pos = *off, o = 0; + int hops = 0; + bool jumped = false; + for (;;) { + if (pos >= len) return false; + uint8_t l = m[pos]; + if (l == 0) { pos++; break; } + if ((l & 0xC0) == 0xC0) { + if (pos + 1 >= len) return false; + size_t ptr = ((size_t)(l & 0x3F) << 8) | m[pos + 1]; + if (!jumped) *off = pos + 2; + jumped = true; + if (++hops > 16 || ptr >= len) return false; + pos = ptr; + continue; + } + if (l & 0xC0) return false; + if (pos + 1 + l > len) return false; + if (o + l + 2 > outsz) return false; + if (o) out[o++] = '.'; + for (uint8_t i = 0; i < l; i++) out[o++] = (char)tolower(m[pos + 1 + i]); + pos += 1u + l; + } + out[o] = 0; + if (!jumped) *off = pos; + return true; +} + +static uint16_t rd16(const uint8_t *p) { return (uint16_t)((p[0] << 8) | p[1]); } + +int pb_dns_observe(const uint8_t *m, size_t len, struct pb_dns_result *res) +{ + if (res) memset(res, 0, sizeof *res); + if (len < 12) return 0; + uint16_t flags = rd16(m + 2), qd = rd16(m + 4), an = rd16(m + 6); + if (!(flags & 0x8000) || (flags & 0x7800) || (flags & 0x000F)) return 0; // response, QUERY opcode, NOERROR only + if (qd > 4 || an == 0 || an > 128) return 0; + + char names[PB_DNS_MAX_NAMES][256]; + int nn = 0; + size_t off = 12; + char buf[256]; + for (int i = 0; i < qd; i++) { + if (!read_name(m, len, &off, buf, sizeof buf) || off + 4 > len) return 0; + off += 4; + if (nn < PB_DNS_MAX_NAMES) { bool dup = false; for (int k = 0; k < nn; k++) if (!strcmp(names[k], buf)) dup = true; if (!dup) strcpy(names[nn++], buf); } + } + + uint32_t ips[64]; int nips = 0; + struct { size_t rdata; uint16_t type, rdlen; char owner[256]; } rr[128]; + int nrr = 0; + for (int i = 0; i < an; i++) { + char owner[256]; + if (!read_name(m, len, &off, owner, sizeof owner) || off + 10 > len) break; + uint16_t type = rd16(m + off), rdlen = rd16(m + off + 8); + size_t rd = off + 10; + if (rd + rdlen > len) break; + strcpy(rr[nrr].owner, owner); rr[nrr].type = type; rr[nrr].rdlen = rdlen; rr[nrr].rdata = rd; nrr++; + off = rd + rdlen; + } + for (int i = 0; i < nrr; i++) { + char target[256]; + if (rr[i].type == 5) { // CNAME: owner and target are both names of the final address + size_t o2 = rr[i].rdata; + if (read_name(m, len, &o2, target, sizeof target)) { + for (int pass = 0; pass < 2; pass++) { + const char *nm = pass ? target : rr[i].owner; + bool dup = false; + for (int k = 0; k < nn; k++) if (!strcmp(names[k], nm)) dup = true; + if (!dup && nn < PB_DNS_MAX_NAMES) strcpy(names[nn++], nm); + } + } + } else if (rr[i].type == 1 && rr[i].rdlen == 4) { + if (nips < 64) { + ips[nips++] = ((uint32_t)m[rr[i].rdata] << 24) | ((uint32_t)m[rr[i].rdata + 1] << 16) | ((uint32_t)m[rr[i].rdata + 2] << 8) | m[rr[i].rdata + 3]; + bool dup = false; + for (int k = 0; k < nn; k++) if (!strcmp(names[k], rr[i].owner)) dup = true; + if (!dup && nn < PB_DNS_MAX_NAMES) strcpy(names[nn++], rr[i].owner); + } + } + } + if (!nips || !nn) return 0; + + pthread_mutex_lock(&T.lock); + for (int i = 0; i < nips; i++) { + uint32_t ip = ips[i]; + if ((ip >> 24) == 127 || ip == 0) continue; // answers pointing at loopback/unspecified are never redirected + add_mapping(ip, names, nn, res); + } + pthread_mutex_unlock(&T.lock); + return nips; +} + +int pb_dns_lookup(uint32_t ip, char out[][256], int max) +{ + int n = 0; + pthread_mutex_lock(&T.lock); + struct ent *e = find(ip); + if (e) for (; n < e->n && n < max; n++) snprintf(out[n], 256, "%s", e->names[n]); + pthread_mutex_unlock(&T.lock); + return n; +} + +int pb_dns_load_hosts(const char *path, struct pb_dns_result *res) +{ + if (res) memset(res, 0, sizeof *res); + FILE *f = fopen(path, "r"); + if (!f) return 0; + char line[1024]; + int added = 0; + while (fgets(line, sizeof line, f)) { + char *hash = strchr(line, '#'); + if (hash) *hash = 0; + char *save = NULL, *ipstr = strtok_r(line, " \t\r\n", &save); + struct in_addr a; + if (!ipstr || inet_pton(AF_INET, ipstr, &a) != 1) continue; + uint32_t ip = ntohl(a.s_addr); + if ((ip >> 24) == 127 || ip == 0) continue; + char names[PB_DNS_MAX_NAMES][256]; + int nn = 0; + for (char *nm; nn < PB_DNS_MAX_NAMES && (nm = strtok_r(NULL, " \t\r\n", &save)); ) { + snprintf(names[nn], 256, "%s", nm); + for (char *c = names[nn]; *c; c++) *c = (char)tolower((unsigned char)*c); + nn++; + } + if (!nn) continue; + pthread_mutex_lock(&T.lock); + add_mapping(ip, names, nn, res); + pthread_mutex_unlock(&T.lock); + added++; + } + fclose(f); + return added; +} + +void pb_dns_for_each(void (*cb)(uint32_t, const char *const *, int, void *), void *ctx) +{ + pthread_mutex_lock(&T.lock); + for (struct ent *e = T.head; e; e = e->next) cb(e->ip, (const char *const *)e->names, e->n, ctx); + pthread_mutex_unlock(&T.lock); +} + +void pb_dns_clear(void) +{ + pthread_mutex_lock(&T.lock); + while (T.head) evict_oldest(NULL); + pthread_mutex_unlock(&T.lock); +} + +size_t pb_dns_count(void) +{ + pthread_mutex_lock(&T.lock); + size_t n = T.count; + pthread_mutex_unlock(&T.lock); + return n; +} diff --git a/Linux/src/pb_dns.h b/Linux/src/pb_dns.h new file mode 100644 index 00000000..550e491c --- /dev/null +++ b/Linux/src/pb_dns.h @@ -0,0 +1,33 @@ +// DNS awareness: learns IPv4 -> domain-name mappings from DNS answers that pass through the relay (the "DNS tap") so that +// domain rules can be applied to connections the kernel only sees as IP addresses. +#ifndef PB_DNS_H +#define PB_DNS_H + +#include +#include + +#define PB_DNS_MAX_NAMES 8 +#define PB_DNS_MAX_ENTRIES 32768 // bounded: oldest mappings are evicted first + +struct pb_dns_result { + uint32_t ips[32]; int nips; // addresses (host order) whose mapping was added or refreshed + uint32_t evicted[32]; int nevicted; // addresses dropped to stay within PB_DNS_MAX_ENTRIES +}; + +// Parses a DNS response message (no TCP length prefix). Returns the number of A records learned (0 if the message is not a +// successful response or has no A records). Safe on arbitrary/malicious input. +int pb_dns_observe(const uint8_t *msg, size_t len, struct pb_dns_result *res); + +// Copies up to max names known for ip (host order) into out; returns the count. +int pb_dns_lookup(uint32_t ip, char out[][256], int max); + +// Adds static mappings from /etc/hosts. Returns the number of addresses added. +int pb_dns_load_hosts(const char *path, struct pb_dns_result *res); + +// Visits every mapping (used to re-arm kernel state after a rule change). cb must not call back into pb_dns_*. +void pb_dns_for_each(void (*cb)(uint32_t ip, const char *const *names, int n, void *ctx), void *ctx); + +void pb_dns_clear(void); +size_t pb_dns_count(void); + +#endif diff --git a/Linux/src/pb_internal.h b/Linux/src/pb_internal.h new file mode 100644 index 00000000..7a15989f --- /dev/null +++ b/Linux/src/pb_internal.h @@ -0,0 +1,94 @@ +// Interfaces between the ProxyBridge modules: core (ProxyBridge.c, pb_bpf.c), kernel program, relay (pb_relay.c). +#ifndef PB_INTERNAL_H +#define PB_INTERNAL_H + +#include +#include +#include +#include +#include "ProxyBridge.h" + +#define PB_TCP_RELAY_PORT_DEFAULT 24010 // outside the default ephemeral port range +#define PB_UDP_RELAY_PORT_DEFAULT 24011 + +#define PB_ACT_PROXY 0 // same values as RuleAction +#define PB_ACT_DIRECT 1 +#define PB_ACT_BLOCK 2 +#define PB_ACT_DECIDE 3 // the kernel could not decide: the relay calls pb_core_decide() + +// Original destination of a redirected flow; layout shared with proxybridge.bpf.c. +struct pb_orig { + uint8_t addr[16]; // network order; IPv4 as ::ffff:a.b.c.d + uint16_t port; // host order + uint16_t proxy_id; + uint32_t pid; // tgid of the process that opened the flow + uint8_t family; // AF_INET or AF_INET6 of the original destination + uint8_t proto; // IPPROTO_TCP or IPPROTO_UDP + uint8_t action; // PB_ACT_PROXY or PB_ACT_DECIDE + uint8_t pad; + uint32_t exe_dev; // kernel-side identity of the executable (PB_ACT_DECIDE flows) + uint32_t exe_pad; + uint64_t exe_ino; +}; + +struct pb_decision { + int action; // PB_ACT_PROXY, PB_ACT_DIRECT or PB_ACT_BLOCK + uint32_t proxy_id; +}; + +struct pb_proxy { + uint32_t id; + ProxyType type; + char name[64]; + char host[256]; // IP literal or host name + uint16_t port; + char user[256]; // empty = no authentication + char pass[256]; +}; + +// ---- provided by the core ---- + +// Original destination of a relayed TCP connection, keyed by the client's source address and port as accept() reports them. +// Consumes the entry; 0 on success. +int pb_core_take_tcp_orig(uint32_t client_ip_be, uint16_t client_port, struct pb_orig *out); + +// Original destination behind a UDP flow address (the IP_PKTINFO destination of a datagram). Does not consume. +int pb_core_get_udp_flow(uint32_t flow_ip_be, struct pb_orig *out); + +// Full rule evaluation for a PB_ACT_DECIDE flow. +void pb_core_decide(const struct pb_orig *o, struct pb_decision *out); + +// Copy of the proxy with this id, or of the default proxy for id 0. 0 on success. +int pb_core_get_proxy(uint32_t id, struct pb_proxy *out); + +void pb_core_log(const char *fmt, ...) __attribute__((format(printf, 1, 2))); + +// Reports a relayed flow once its final action is known. +void pb_core_flow_event(const struct pb_orig *o, int action); + +// DNS tap: called for every DNS message travelling server-to-client on a flow whose original port is 53 (UDP payload, or one +// complete TCP message without its length prefix), before the client receives it. +void pb_core_dns_observe(const uint8_t *msg, size_t len); + +// ---- provided by the relay ---- + +struct pb_relay_cfg { + uint16_t tcp_port; // 0 = default + uint16_t udp_port; // 0 = default + int max_tcp_conns; // 0 = 32768 + int tcp_idle_timeout_s; // 0 = 300 + int udp_idle_timeout_s; // 0 = 60 + int handshake_timeout_s; // 0 = 10 +}; + +// Returns -1 (after logging) and leaves nothing running on failure. +int pb_relay_start(const struct pb_relay_cfg *cfg); +// Stops every thread and closes every fd; safe to call twice and to start again afterwards. +void pb_relay_stop(void); +uint16_t pb_relay_tcp_port(void); +uint16_t pb_relay_udp_port(void); + +// Blocking test of a proxy: tunnels to target_host:target_port and writes a one-line result (or the error) to buf. 0 on success. +int pb_relay_test_proxy(const struct pb_proxy *proxy, const char *target_host, uint16_t target_port, char *buf, size_t buflen); + +#endif diff --git a/Linux/src/pb_proc.c b/Linux/src/pb_proc.c new file mode 100644 index 00000000..61b756e2 --- /dev/null +++ b/Linux/src/pb_proc.c @@ -0,0 +1,115 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "pb_proc.h" + +#define MAX_INODES 8 + +static bool parse_number(const char *s, unsigned base, unsigned long long *out, const char **end) +{ + unsigned long long v = 0; + const char *p = s; + for (;; p++) { + unsigned d = (unsigned)(*p - '0'); + if (*p >= 'a' && *p <= 'f') d = (unsigned)(*p - 'a') + 10; + else if (*p >= 'A' && *p <= 'F') d = (unsigned)(*p - 'A') + 10; + if (d >= base) break; + v = v * base + d; + } + if (p == s) return false; + *out = v; + if (end) *end = p; + return true; +} + +// Fields of a /proc/net/tcp row: sl, local, remote, state, queues, timer, retrans, uid, timeout, inode. +static bool parse_tcp_row(char *line, uint32_t *addr, uint32_t *port, unsigned *state, unsigned long long *inode) +{ + char *fields[10], *save = NULL; + size_t n = 0; + for (char *t = strtok_r(line, " \t\n", &save); t && n < 10; t = strtok_r(NULL, " \t\n", &save)) fields[n++] = t; + if (n < 10) return false; + unsigned long long a, p, st; + const char *colon; + if (!parse_number(fields[1], 16, &a, &colon) || *colon != ':' || !parse_number(colon + 1, 16, &p, NULL)) return false; + if (!parse_number(fields[3], 16, &st, NULL) || !parse_number(fields[9], 10, inode, NULL)) return false; + *addr = (uint32_t)a; + *port = (uint32_t)p; + *state = (unsigned)st; + return true; +} + +static size_t listen_inodes(uint32_t ip_be, uint16_t port, uint64_t *inodes) +{ + FILE *f = fopen("/proc/net/tcp", "r"); + if (!f) return 0; + char line[512]; + size_t n = 0; + if (!fgets(line, sizeof line, f)) { fclose(f); return 0; } + while (n < MAX_INODES && fgets(line, sizeof line, f)) { + uint32_t addr, lport; + unsigned state; + unsigned long long inode; + if (!parse_tcp_row(line, &addr, &lport, &state, &inode)) continue; + if (state == 0x0A && lport == port && inode && (addr == ip_be || addr == 0)) inodes[n++] = inode; + } + fclose(f); + return n; +} + +static bool pid_has_socket(const char *pid, uint64_t inode) +{ + char dir[300], link[400], want[40], got[64]; + snprintf(dir, sizeof dir, "/proc/%.200s/fd", pid); + snprintf(want, sizeof want, "socket:[%llu]", (unsigned long long)inode); + DIR *d = opendir(dir); + if (!d) return false; + struct dirent *de; + bool found = false; + while (!found && (de = readdir(d))) { + if (de->d_name[0] == '.') continue; + snprintf(link, sizeof link, "%s/%.40s", dir, de->d_name); + ssize_t r = readlink(link, got, sizeof got - 1); + if (r > 0) { got[r] = 0; found = !strcmp(got, want); } + } + closedir(d); + return found; +} + +size_t pb_proc_listeners(uint32_t ip_be, uint16_t port, uint32_t *tgids, size_t max) +{ + uint64_t inodes[MAX_INODES]; + size_t ni = listen_inodes(ip_be, port, inodes), n = 0; + if (!ni) return 0; + DIR *d = opendir("/proc"); + if (!d) return 0; + struct dirent *de; + while (n < max && (de = readdir(d))) { + if (de->d_name[0] < '1' || de->d_name[0] > '9') continue; + for (size_t i = 0; i < ni; i++) + if (pid_has_socket(de->d_name, inodes[i])) { + unsigned long long pid; + if (parse_number(de->d_name, 10, &pid, NULL)) tgids[n++] = (uint32_t)pid; + break; + } + } + closedir(d); + return n; +} + +bool pb_proc_is_local_ip(uint32_t ip_be) +{ + if ((ntohl(ip_be) >> 24) == 127) return true; + struct ifaddrs *list, *a; + if (getifaddrs(&list) != 0) return false; + bool local = false; + for (a = list; a && !local; a = a->ifa_next) + local = a->ifa_addr && a->ifa_addr->sa_family == AF_INET && ((struct sockaddr_in *)a->ifa_addr)->sin_addr.s_addr == ip_be; + freeifaddrs(list); + return local; +} diff --git a/Linux/src/pb_proc.h b/Linux/src/pb_proc.h new file mode 100644 index 00000000..c5e49865 --- /dev/null +++ b/Linux/src/pb_proc.h @@ -0,0 +1,15 @@ +// /proc helpers: which processes listen on a local TCP port, and which addresses belong to this host. +#ifndef PB_PROC_H +#define PB_PROC_H + +#include +#include +#include + +// tgids of processes with a TCP socket in LISTEN state on `port` bound to `ip_be` or to 0.0.0.0. Returns the count (<= max). +size_t pb_proc_listeners(uint32_t ip_be, uint16_t port, uint32_t *tgids, size_t max); + +// True for 127.0.0.0/8 and for any IPv4 address configured on a local interface. +bool pb_proc_is_local_ip(uint32_t ip_be); + +#endif diff --git a/Linux/src/pb_relay.c b/Linux/src/pb_relay.c new file mode 100644 index 00000000..2fc833e7 --- /dev/null +++ b/Linux/src/pb_relay.c @@ -0,0 +1,1764 @@ +// Userspace relay for the eBPF backend: TCP (SOCKS5 / HTTP CONNECT / DIRECT) and UDP (SOCKS5 UDP ASSOCIATE / DIRECT). +// Every conn/session is owned by exactly one epoll thread; handshake workers hand finished objects over through queues. +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pb_internal.h" + +#define SCRATCH_SZ 65536 +#define HDR_MAX 8192 +#define NWORKERS 16 +#define MAX_DATA 8 +#define PIPE_SZ (1 << 20) +#define JOBQ_MAX 4096 // handshake queue; excess accepts are shed with a log +#define MAX_UDP_SESS 1024 +#define MAX_FLOWS 64 +#define UDP_QMAX 32 +#define UDP_QBYTES (256 * 1024) +#define UDP_MAXDG 65536 +#define UDP_BATCH 16 +#define FLOW_TTL_MS 1000 +#define DC_SIZE 1024 +#define SESS_BUCKETS 256 +#define DIRECT_ID 0xFFFFFFFFu + +static char TAG_STOP, TAG_WAKE, TAG_UDP, TAG_TIMER; + +static int64_t now_ms(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; +} + +static void close_fd(int *fd) +{ + if (*fd >= 0) { close(*fd); *fd = -1; } +} + +static void fmt_ip(int family, const uint8_t *addr16, char *out, size_t n) +{ + if (family == AF_INET) inet_ntop(AF_INET, addr16 + 12, out, (socklen_t)n); + else inet_ntop(AF_INET6, addr16, out, (socklen_t)n); +} + +static void orig_str(const struct pb_orig *o, char *out, size_t n) +{ + char ip[INET6_ADDRSTRLEN]; + fmt_ip(o->family, o->addr, ip, sizeof ip); + snprintf(out, n, "%s:%u", ip, o->port); +} + +static socklen_t orig_to_sa(const struct pb_orig *o, struct sockaddr_storage *ss) +{ + memset(ss, 0, sizeof *ss); + if (o->family == AF_INET) { + struct sockaddr_in *a = (struct sockaddr_in *)ss; + a->sin_family = AF_INET; + a->sin_port = htons(o->port); + memcpy(&a->sin_addr, o->addr + 12, 4); + return sizeof *a; + } + struct sockaddr_in6 *a = (struct sockaddr_in6 *)ss; + a->sin6_family = AF_INET6; + a->sin6_port = htons(o->port); + memcpy(&a->sin6_addr, o->addr, 16); + return sizeof *a; +} + +// Waits for fd readiness. 0 = ready (or error/hup: the next I/O call reports it), -1 = timeout, -2 = stop requested. +static int wait_fd(int fd, short ev, int64_t deadline, int stopfd) +{ + for (;;) { + int64_t rem = deadline - now_ms(); + if (rem <= 0) return -1; + struct pollfd p[2] = { { .fd = fd, .events = ev }, { .fd = stopfd, .events = POLLIN } }; + int r = poll(p, stopfd >= 0 ? 2 : 1, rem > 60000 ? 60000 : (int)rem); + if (r < 0) { if (errno == EINTR) continue; return -1; } + if (r == 0) continue; + if (stopfd >= 0 && p[1].revents) return -2; + if (p[0].revents) return 0; + } +} + +struct io { + int fd; + int64_t dl; + int stopfd; + char err[200]; +}; + +static void seterr(struct io *x, const char *fmt, ...) __attribute__((format(printf, 2, 3))); +static void seterr(struct io *x, const char *fmt, ...) +{ + va_list ap; + va_start(ap, fmt); + vsnprintf(x->err, sizeof x->err, fmt, ap); + va_end(ap); +} + +static void io_waitfail(struct io *x, int w) +{ + seterr(x, w == -1 ? "timed out" : "relay stopping"); +} + +static int io_send(struct io *x, const void *buf, size_t n) +{ + const uint8_t *p = buf; + while (n) { + ssize_t r = send(x->fd, p, n, MSG_NOSIGNAL); + if (r > 0) { p += r; n -= (size_t)r; continue; } + if (r < 0 && errno == EINTR) continue; + if (r < 0 && errno == EAGAIN) { + int w = wait_fd(x->fd, POLLOUT, x->dl, x->stopfd); + if (w) { io_waitfail(x, w); return -1; } + continue; + } + seterr(x, "send failed: %s", strerror(errno)); + return -1; + } + return 0; +} + +static ssize_t io_recv(struct io *x, void *buf, size_t n) +{ + for (;;) { + ssize_t r = recv(x->fd, buf, n, 0); + if (r > 0) return r; + if (r == 0) { seterr(x, "connection closed by peer during handshake"); return -1; } + if (errno == EINTR) continue; + if (errno == EAGAIN) { + int w = wait_fd(x->fd, POLLIN, x->dl, x->stopfd); + if (w) { io_waitfail(x, w); return -1; } + continue; + } + seterr(x, "recv failed: %s", strerror(errno)); + return -1; + } +} + +static int io_recv_exact(struct io *x, void *buf, size_t n) +{ + uint8_t *p = buf; + while (n) { + ssize_t r = io_recv(x, p, n); + if (r < 0) return -1; + p += r; + n -= (size_t)r; + } + return 0; +} + +static void tune_sock(int fd) +{ + int one = 1, idle = 60, intvl = 15, cnt = 4; + setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one); + setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &one, sizeof one); + setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof idle); + setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &intvl, sizeof intvl); + setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof cnt); +} + +static int connect_addr(const struct sockaddr *sa, socklen_t len, int64_t dl, int stopfd, int *eno) +{ + int fd = socket(sa->sa_family, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0); + if (fd < 0) { *eno = errno; return -1; } + tune_sock(fd); + if (connect(fd, sa, len) < 0) { + if (errno != EINPROGRESS) { *eno = errno; close(fd); return -1; } + int w = wait_fd(fd, POLLOUT, dl, stopfd); + if (w) { *eno = w == -1 ? ETIMEDOUT : ECANCELED; close(fd); return -1; } + int e = 0; + socklen_t el = sizeof e; + getsockopt(fd, SOL_SOCKET, SO_ERROR, &e, &el); + if (e) { *eno = e; close(fd); return -1; } + } + return fd; +} + +// Hostname resolution (IPv4): at most RES_MAX_THREADS short-lived detached lookups, deadline-bounded for the waiter, +// results cached (fresh for 60 s; last good answer reused for up to 10 min when re-resolution fails). +#define RES_MAX_THREADS 4 +#define RES_DEADLINE_MS 5000 +#define RES_TTL_MS 60000 +#define RES_STALE_MS 600000 +#define RES_CACHE_N 64 + +struct resreq { + struct resreq *next; + char name[256]; + int refs; + bool done; + int gai; + struct in_addr addr; + pthread_cond_t cv; +}; +struct rescache { char name[256]; struct in_addr addr; int64_t when; bool used; }; + +static pthread_mutex_t res_mu = PTHREAD_MUTEX_INITIALIZER; +static struct resreq *res_inflight; +static int res_threads; +static struct rescache res_cache[RES_CACHE_N]; + +static struct rescache *res_cache_find(const char *name) // res_mu held +{ + for (int i = 0; i < RES_CACHE_N; i++) if (res_cache[i].used && !strcmp(res_cache[i].name, name)) return &res_cache[i]; + return NULL; +} + +static void res_unref(struct resreq *r) // res_mu held +{ + if (--r->refs == 0) { pthread_cond_destroy(&r->cv); free(r); } +} + +static void *res_thread(void *arg) +{ + struct resreq *r = arg; + struct addrinfo hints, *res = NULL; + memset(&hints, 0, sizeof hints); + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + int gr = getaddrinfo(r->name, NULL, &hints, &res); + pthread_mutex_lock(&res_mu); + if (gr == 0 && res && res->ai_family == AF_INET) { + r->addr = ((struct sockaddr_in *)res->ai_addr)->sin_addr; + struct rescache *c = res_cache_find(r->name); + if (!c) { + c = &res_cache[0]; + for (int i = 0; i < RES_CACHE_N; i++) { + if (!res_cache[i].used) { c = &res_cache[i]; break; } + if (res_cache[i].when < c->when) c = &res_cache[i]; + } + } + snprintf(c->name, sizeof c->name, "%s", r->name); + c->addr = r->addr; c->when = now_ms(); c->used = true; + } else { + r->gai = gr ? gr : EAI_NONAME; + } + r->done = true; + for (struct resreq **pp = &res_inflight; *pp; pp = &(*pp)->next) + if (*pp == r) { *pp = r->next; break; } + res_threads--; + pthread_cond_broadcast(&r->cv); + res_unref(r); + pthread_mutex_unlock(&res_mu); + if (res) freeaddrinfo(res); + return NULL; +} + +static int resolve_v4(const char *name, int64_t dl, int stopfd, struct in_addr *out, char *err, size_t errn) +{ + int64_t limit = now_ms() + RES_DEADLINE_MS; + if (dl > limit) dl = limit; + pthread_mutex_lock(&res_mu); + struct rescache *c = res_cache_find(name); + if (c && now_ms() - c->when < RES_TTL_MS) { *out = c->addr; pthread_mutex_unlock(&res_mu); return 0; } + struct resreq *r; + for (r = res_inflight; r; r = r->next) if (!strcmp(r->name, name)) break; + const char *why = NULL; + if (!r) { + if (res_threads >= RES_MAX_THREADS) { + why = "resolver busy"; + } else { + r = calloc(1, sizeof *r); + pthread_condattr_t ca; + pthread_condattr_init(&ca); + pthread_condattr_setclock(&ca, CLOCK_MONOTONIC); + if (r) pthread_cond_init(&r->cv, &ca); + pthread_condattr_destroy(&ca); + pthread_t th; + pthread_attr_t at; + pthread_attr_init(&at); + pthread_attr_setdetachstate(&at, PTHREAD_CREATE_DETACHED); + pthread_attr_setstacksize(&at, 256 * 1024); + if (r) { + snprintf(r->name, sizeof r->name, "%s", name); + r->refs = 2; + r->next = res_inflight; res_inflight = r; + res_threads++; + if (pthread_create(&th, &at, res_thread, r) != 0) { + res_inflight = r->next; res_threads--; r->refs = 1; + res_unref(r); r = NULL; why = "cannot start resolver thread"; + } + } else why = "out of memory"; + pthread_attr_destroy(&at); + } + } else { + r->refs++; + } + int rc = -1; + if (r) { + while (!r->done) { + int64_t rem = dl - now_ms(); + if (rem <= 0) { why = "timed out"; break; } + struct pollfd sp = { .fd = stopfd, .events = POLLIN }; + if (stopfd >= 0 && poll(&sp, 1, 0) > 0) { why = "relay stopping"; break; } + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + int64_t ms = rem < 50 ? rem : 50; + ts.tv_nsec += ms * 1000000; + ts.tv_sec += ts.tv_nsec / 1000000000; ts.tv_nsec %= 1000000000; + pthread_cond_timedwait(&r->cv, &res_mu, &ts); + } + if (r->done && !r->gai) { *out = r->addr; rc = 0; } + else if (r->done) { snprintf(err, errn, "cannot resolve '%s': %s", name, gai_strerror(r->gai)); why = NULL; } + res_unref(r); + } + if (rc != 0) { + c = res_cache_find(name); + if (c && now_ms() - c->when < RES_STALE_MS) { *out = c->addr; rc = 0; } + } + pthread_mutex_unlock(&res_mu); + if (rc != 0 && why) snprintf(err, errn, "cannot resolve '%s': %s", name, why); + return rc; +} + +// Resolves and connects to the proxy; on failure writes a readable reason to err. +static int connect_host(const char *host, uint16_t port, int64_t dl, int stopfd, char *err, size_t errn) +{ + struct sockaddr_storage ss[1]; + socklen_t sl[1]; + int n = 0; + struct in_addr a4; + struct in6_addr a6; + memset(ss, 0, sizeof ss); + if (inet_pton(AF_INET, host, &a4) == 1 || (inet_pton(AF_INET6, host, &a6) != 1 && resolve_v4(host, dl, stopfd, &a4, err, errn) == 0)) { + struct sockaddr_in *s = (struct sockaddr_in *)&ss[0]; + s->sin_family = AF_INET; s->sin_port = htons(port); s->sin_addr = a4; + sl[0] = sizeof *s; n = 1; + } else if (inet_pton(AF_INET6, host, &a6) == 1) { + struct sockaddr_in6 *s = (struct sockaddr_in6 *)&ss[0]; + s->sin6_family = AF_INET6; s->sin6_port = htons(port); s->sin6_addr = a6; + sl[0] = sizeof *s; n = 1; + } else { + return -1; + } + int eno = ETIMEDOUT; + for (int i = 0; i < n; i++) { + int64_t rem = dl - now_ms(); + int64_t d = (i + 1 < n && rem > 2) ? now_ms() + rem / 2 : dl; + int fd = connect_addr((struct sockaddr *)&ss[i], sl[i], d, stopfd, &eno); + if (fd >= 0) return fd; + if (eno == ECANCELED) break; + } + snprintf(err, errn, "connect to %s:%u failed: %s", host, port, strerror(eno)); + return -1; +} + +struct s5dst { uint8_t atyp; uint8_t addr[16]; char host[256]; uint16_t port; }; +struct s5bnd { uint8_t atyp; uint8_t addr[16]; uint16_t port; }; + +static const char *socks5_rep_str(int rep) +{ + switch (rep) { + case 1: return "general SOCKS server failure"; + case 2: return "connection not allowed by ruleset"; + case 3: return "network unreachable"; + case 4: return "host unreachable"; + case 5: return "connection refused by target"; + case 6: return "TTL expired"; + case 7: return "command not supported"; + case 8: return "address type not supported"; + default: return "unknown error"; + } +} + +static void s5dst_from_orig(struct s5dst *d, const struct pb_orig *o) +{ + memset(d, 0, sizeof *d); + d->port = o->port; + if (o->family == AF_INET) { d->atyp = 1; memcpy(d->addr, o->addr + 12, 4); } + else { d->atyp = 4; memcpy(d->addr, o->addr, 16); } +} + +static int socks5_request(struct io *x, const struct pb_proxy *p, uint8_t cmd, const struct s5dst *d, struct s5bnd *b) +{ + bool auth = p->user[0] != 0; + uint8_t g[4] = { 5, auth ? 2 : 1, 0, 2 }; + if (io_send(x, g, auth ? 4 : 3)) return -1; + uint8_t r[2]; + if (io_recv_exact(x, r, 2)) return -1; + if (r[0] != 5) { seterr(x, "not a SOCKS5 server (bad greeting reply 0x%02x)", r[0]); return -1; } + if (r[1] == 0xFF) { seterr(x, "SOCKS5 proxy rejected all auth methods (credentials required or not accepted)"); return -1; } + if (r[1] == 2) { + size_t ul = strlen(p->user), pl = strlen(p->pass); + if (!auth) { seterr(x, "SOCKS5 proxy requires username/password but none configured"); return -1; } + if (ul > 255 || pl > 255) { seterr(x, "SOCKS5 credentials too long"); return -1; } + uint8_t ab[3 + 255 + 255]; + size_t o = 0; + ab[o++] = 1; ab[o++] = (uint8_t)ul; memcpy(ab + o, p->user, ul); o += ul; + ab[o++] = (uint8_t)pl; memcpy(ab + o, p->pass, pl); o += pl; + if (io_send(x, ab, o)) return -1; + if (io_recv_exact(x, r, 2)) return -1; + if (r[1] != 0) { seterr(x, "SOCKS5 authentication failed (wrong username or password)"); return -1; } + } else if (r[1] != 0) { + seterr(x, "SOCKS5 proxy selected unsupported auth method 0x%02x", r[1]); + return -1; + } + uint8_t rq[4 + 1 + 255 + 2]; + size_t o = 0; + rq[o++] = 5; rq[o++] = cmd; rq[o++] = 0; rq[o++] = d->atyp; + if (d->atyp == 1) { memcpy(rq + o, d->addr, 4); o += 4; } + else if (d->atyp == 4) { memcpy(rq + o, d->addr, 16); o += 16; } + else { + size_t hl = strlen(d->host); + if (hl == 0 || hl > 255) { seterr(x, "invalid target host name"); return -1; } + rq[o++] = (uint8_t)hl; memcpy(rq + o, d->host, hl); o += hl; + } + rq[o++] = (uint8_t)(d->port >> 8); rq[o++] = (uint8_t)d->port; + if (io_send(x, rq, o)) return -1; + uint8_t h[4]; + if (io_recv_exact(x, h, 4)) return -1; + if (h[0] != 5) { seterr(x, "bad SOCKS5 reply version 0x%02x", h[0]); return -1; } + if (h[1] != 0) { seterr(x, "SOCKS5 request failed: %s (code %u)", socks5_rep_str(h[1]), h[1]); return -1; } + struct s5bnd tmp; + if (!b) b = &tmp; + memset(b, 0, sizeof *b); + b->atyp = h[3]; + size_t al; + if (h[3] == 1) al = 4; + else if (h[3] == 4) al = 16; + else if (h[3] == 3) { + uint8_t l; + if (io_recv_exact(x, &l, 1)) return -1; + uint8_t skip[256]; + if (io_recv_exact(x, skip, l)) return -1; + al = 0; + } else { seterr(x, "SOCKS5 reply has unknown address type %u", h[3]); return -1; } + if (al && io_recv_exact(x, b->addr, al)) return -1; + uint8_t pt[2]; + if (io_recv_exact(x, pt, 2)) return -1; + b->port = (uint16_t)(pt[0] << 8 | pt[1]); + return 0; +} + +static void b64(const char *in, size_t n, char *out) +{ + static const char t[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + size_t i, o = 0; + for (i = 0; i + 2 < n; i += 3) { + uint32_t v = (uint8_t)in[i] << 16 | (uint8_t)in[i + 1] << 8 | (uint8_t)in[i + 2]; + out[o++] = t[v >> 18]; out[o++] = t[(v >> 12) & 63]; out[o++] = t[(v >> 6) & 63]; out[o++] = t[v & 63]; + } + if (i < n) { + uint32_t v = (uint8_t)in[i] << 16 | (i + 1 < n ? (uint8_t)in[i + 1] << 8 : 0); + out[o++] = t[v >> 18]; out[o++] = t[(v >> 12) & 63]; + out[o++] = i + 1 < n ? t[(v >> 6) & 63] : '='; + out[o++] = '='; + } + out[o] = 0; +} + +static int http_connect(struct io *x, const struct pb_proxy *p, const char *host, uint16_t port, uint8_t *left, size_t *leftn) +{ + char hp[300]; + snprintf(hp, sizeof hp, strchr(host, ':') ? "[%s]:%u" : "%s:%u", host, port); + char req[1024 + 700]; + int n = snprintf(req, sizeof req, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n", hp, hp); + if (p->user[0]) { + char cred[520], enc[720]; + int cl = snprintf(cred, sizeof cred, "%s:%s", p->user, p->pass); + if (cl < 0 || cl >= (int)sizeof cred) { seterr(x, "HTTP proxy credentials too long"); return -1; } + b64(cred, (size_t)cl, enc); + n += snprintf(req + n, sizeof req - (size_t)n, "Proxy-Authorization: Basic %s\r\n", enc); + } + n += snprintf(req + n, sizeof req - (size_t)n, "\r\n"); + if (io_send(x, req, (size_t)n)) return -1; + + uint8_t buf[HDR_MAX]; + size_t tot = 0, hend = 0; + while (!hend) { + if (tot >= sizeof buf) { seterr(x, "HTTP proxy response headers too large"); return -1; } + ssize_t r = io_recv(x, buf + tot, sizeof buf - tot); + if (r < 0) return -1; + size_t from = tot >= 3 ? tot - 3 : 0; + tot += (size_t)r; + for (size_t i = from; i + 4 <= tot; i++) + if (!memcmp(buf + i, "\r\n\r\n", 4)) { hend = i + 4; break; } + } + int code = 0; + if (hend >= 12 && !memcmp(buf, "HTTP/", 5)) { + const uint8_t *sp = memchr(buf, ' ', hend); + if (sp && sp + 4 <= buf + hend) code = (sp[1] - '0') * 100 + (sp[2] - '0') * 10 + (sp[3] - '0'); + } + if (code < 200 || code > 299) { + if (code == 0) seterr(x, "invalid HTTP proxy response"); + else if (code == 407) seterr(x, "HTTP proxy authentication required (407): check username/password"); + else if (code == 403) seterr(x, "HTTP proxy forbids this connection (403)"); + else if (code >= 500) seterr(x, "HTTP proxy or upstream error (%d)", code); + else seterr(x, "HTTP proxy refused CONNECT (%d)", code); + return -1; + } + *leftn = tot - hend; + if (*leftn) memcpy(left, buf + hend, *leftn); + return 0; +} + +struct conn { + struct conn *prev, *next; + int fd[2]; + size_t off[2], len[2]; // pend[d]: unsent tail of bytes read from fd[d] (heap, freed once drained) + uint8_t *pend[2]; + bool dns; // original destination port 53: upstream->client is parsed as TCP-DNS + bool doff; // stopped observing (malformed stream) + uint8_t *dbuf; + size_t dhave, dcap; + uint32_t mask[2]; + bool eof[2], shut[2], dead; + int64_t last; +}; + +static void conn_bufs_free(struct conn *c) +{ + free(c->pend[0]); free(c->pend[1]); free(c->dbuf); + c->pend[0] = c->pend[1] = c->dbuf = NULL; +} + +struct dthread { + pthread_t th; + bool started; + int ep, wake; + pthread_mutex_t mu; + struct conn *inq_head, *inq_tail; + struct conn *conns; + struct conn *grave; + uint8_t *scratch; + int pfd[2]; // reusable splice pipe; always empty between connections + bool nosplice; + atomic_int load; +}; + +enum { J_TCP = 1, J_UDP }; +struct sess; +struct job { + struct job *next; + int kind; + int fd; + struct pb_orig o; + struct sess *s; +}; + +struct flowent { uint8_t addr[16]; uint16_t port; uint32_t ip_be; int64_t last; }; +struct pkt { size_t len; uint8_t d[]; }; + +enum { SK_DIRECT = 1, SK_SOCKS5, SK_DROP }; +enum { ST_SETUP = 1, ST_READY, ST_FAILED }; +struct ref { struct sess *s; int which; }; + +struct sess { + struct sess *hnext, *prev, *next, *dnext; + struct sockaddr_in cli; + uint32_t id, flow_be; // id = proxy id or DIRECT_ID; flow_be only for DIRECT sessions + uint64_t sid; + int kind, state; + int up_fd, ctl_fd; + bool connected; // DIRECT: v6 sockets are connected; v4 are not (replies from any port of the host are accepted) + struct sockaddr_storage dst; + socklen_t dstlen; + uint16_t dport; + bool dead, ok, warned; + int64_t last, fail_until; + struct pb_proxy proxy; + char err[700]; + struct ref ref[2]; + int nflows; + struct flowent flows[MAX_FLOWS]; + struct pkt *q[UDP_QMAX]; + int nq; + size_t qbytes; +}; + +struct dce { + bool used; + struct sockaddr_in cli; + uint32_t flow_be, pid; + struct pb_orig o; + int action, fidx; + int64_t looked, last; + uint64_t sid; +}; + +static struct { + pthread_mutex_t life; + bool running; + struct pb_relay_cfg cfg; + int lfd, ufd, stopfd, tmfd, uep, uwake; + atomic_bool stopping; + atomic_int nconn; + atomic_uint rr; + uint16_t tcp_port, udp_port; + + pthread_t acceptor; bool acc_started; + pthread_t workers[NWORKERS]; int nworkers; + struct dthread data[MAX_DATA]; + int ndata; + pthread_t udpth; bool udp_started; + + pthread_mutex_t jmu; + pthread_cond_t jcv; + struct job *jhead, *jtail; + int jcount; + + pthread_mutex_t dmu; + struct sess *done; + + struct sess *sess_head; + struct sess *buckets[SESS_BUCKETS]; + int nsess; + struct sess *sgrave; + struct dce *dc; + uint8_t *rxbuf; + int64_t drop_count, drop_logged; + uint64_t next_sid; + struct mmsghdr *mm; + struct iovec *mmiov; + struct sockaddr_in *mmname; + char (*mmctl)[CMSG_SPACE(sizeof(struct in_pktinfo))]; +} R = { .life = PTHREAD_MUTEX_INITIALIZER, .jmu = PTHREAD_MUTEX_INITIALIZER, .jcv = PTHREAD_COND_INITIALIZER, + .dmu = PTHREAD_MUTEX_INITIALIZER, .lfd = -1, .ufd = -1, .stopfd = -1, .tmfd = -1, .uep = -1, .uwake = -1 }; + +static void proxy_desc(const struct pb_proxy *p, char *out, size_t n) +{ + snprintf(out, n, "%s %s:%u", p->type == PROXY_TYPE_SOCKS5 ? "SOCKS5" : "HTTP", p->host, p->port); +} + +static int job_push(int kind, int fd, const struct pb_orig *o, struct sess *s) +{ + struct job *j = calloc(1, sizeof *j); + if (!j) return -1; + j->kind = kind; j->fd = fd; j->s = s; + if (o) j->o = *o; + pthread_mutex_lock(&R.jmu); + if (R.jcount >= JOBQ_MAX || atomic_load(&R.stopping)) { + pthread_mutex_unlock(&R.jmu); + free(j); + return -1; + } + if (R.jtail) R.jtail->next = j; else R.jhead = j; + R.jtail = j; + R.jcount++; + pthread_cond_signal(&R.jcv); + pthread_mutex_unlock(&R.jmu); + return 0; +} + +static void conn_handoff(int cfd, int ufd, const uint8_t *left, size_t leftn, bool dns) +{ + struct conn *c = calloc(1, sizeof *c); + if (!c) { close(cfd); close(ufd); atomic_fetch_sub(&R.nconn, 1); return; } + c->fd[0] = cfd; c->fd[1] = ufd; + c->dns = dns; + if (leftn) { + uint8_t *m = malloc(leftn); + if (!m) { close(cfd); close(ufd); free(c); atomic_fetch_sub(&R.nconn, 1); return; } + memcpy(m, left, leftn); + if (dns) { c->dbuf = m; c->dhave = c->dcap = leftn; } // must pass through the DNS parser + else { c->pend[1] = m; c->len[1] = leftn; } + } + unsigned start = atomic_fetch_add(&R.rr, 1); + struct dthread *t = &R.data[start % R.ndata]; + for (int i = 1; i < R.ndata; i++) { + struct dthread *u = &R.data[(start + i) % R.ndata]; + if (atomic_load(&u->load) < atomic_load(&t->load)) t = u; + } + atomic_fetch_add(&t->load, 1); + pthread_mutex_lock(&t->mu); + if (atomic_load(&R.stopping)) { + pthread_mutex_unlock(&t->mu); + atomic_fetch_sub(&t->load, 1); + close(cfd); close(ufd); conn_bufs_free(c); free(c); + atomic_fetch_sub(&R.nconn, 1); + return; + } + if (t->inq_tail) t->inq_tail->next = c; else t->inq_head = c; + t->inq_tail = c; + pthread_mutex_unlock(&t->mu); + eventfd_write(t->wake, 1); +} + +static void tcp_job_run(struct job *j) +{ + struct pb_orig *o = &j->o; + int cfd = j->fd, up = -1, action = o->action; + uint32_t pid = o->proxy_id; + int64_t dl = now_ms() + (int64_t)R.cfg.handshake_timeout_s * 1000; + char dst[80], err[1024] = "", desc[300]; + uint8_t left[HDR_MAX]; + size_t leftn = 0; + orig_str(o, dst, sizeof dst); + + if (action == PB_ACT_DECIDE) { + struct pb_decision d; + pb_core_decide(o, &d); + action = d.action; pid = d.proxy_id; + } + if (action == PB_ACT_DIRECT) { + pb_core_flow_event(o, PB_ACT_DIRECT); + struct sockaddr_storage ss; + socklen_t sl = orig_to_sa(o, &ss); + int eno = 0; + up = connect_addr((struct sockaddr *)&ss, sl, dl, R.stopfd, &eno); + if (up < 0) snprintf(err, sizeof err, "direct connect to %s failed: %s", dst, strerror(eno)); + } else if (action == PB_ACT_PROXY) { + struct pb_proxy px; + if (pb_core_get_proxy(pid, &px) != 0) { + pb_core_flow_event(o, PB_ACT_PROXY); + snprintf(err, sizeof err, "no proxy configured (id %u) for %s", pid, dst); + } else { + proxy_desc(&px, desc, sizeof desc); + pb_core_flow_event(o, PB_ACT_PROXY); + char cerr[200]; + up = connect_host(px.host, px.port, dl, R.stopfd, cerr, sizeof cerr); + if (up < 0) { + snprintf(err, sizeof err, "proxy %s: %s", desc, cerr); + } else { + struct io x = { .fd = up, .dl = dl, .stopfd = R.stopfd }; + int rc; + if (px.type == PROXY_TYPE_SOCKS5) { + struct s5dst d; + s5dst_from_orig(&d, o); + rc = socks5_request(&x, &px, 1, &d, NULL); + } else { + char ip[INET6_ADDRSTRLEN]; + fmt_ip(o->family, o->addr, ip, sizeof ip); + rc = http_connect(&x, &px, ip, o->port, left, &leftn); + } + if (rc) { + snprintf(err, sizeof err, "proxy %s -> %s: %s", desc, dst, x.err); + close(up); up = -1; + } + } + } + } else { + pb_core_flow_event(o, PB_ACT_BLOCK); + setsockopt(cfd, SOL_SOCKET, SO_LINGER, &(struct linger){ 1, 0 }, sizeof(struct linger)); + close(cfd); + atomic_fetch_sub(&R.nconn, 1); + return; + } + if (up < 0) { + pb_core_log("relay: %s", err); + close(cfd); + atomic_fetch_sub(&R.nconn, 1); + return; + } + conn_handoff(cfd, up, left, leftn, o->port == 53); +} + +static void udp_job_run(struct sess *s) +{ + int64_t dl = now_ms() + (int64_t)R.cfg.handshake_timeout_s * 1000; + char cerr[200], desc[300]; + int ctl = -1, up = -1; + proxy_desc(&s->proxy, desc, sizeof desc); + s->ok = false; + ctl = connect_host(s->proxy.host, s->proxy.port, dl, R.stopfd, cerr, sizeof cerr); + if (ctl < 0) { snprintf(s->err, sizeof s->err, "%s: %s", desc, cerr); goto out; } + struct io x = { .fd = ctl, .dl = dl, .stopfd = R.stopfd }; + struct s5dst d; + struct s5bnd b; + memset(&d, 0, sizeof d); + d.atyp = 1; + if (socks5_request(&x, &s->proxy, 3, &d, &b)) { snprintf(s->err, sizeof s->err, "%s UDP ASSOCIATE: %s", desc, x.err); goto out; } + if (b.atyp == 3) { snprintf(s->err, sizeof s->err, "%s: UDP relay address is a domain name (unsupported)", desc); goto out; } + struct sockaddr_storage ra; + socklen_t rl; + memset(&ra, 0, sizeof ra); + static const uint8_t zero[16]; + bool unspec = !memcmp(b.addr, zero, b.atyp == 1 ? 4 : 16); + if (unspec) { // use the address we reached the proxy at + rl = sizeof ra; + if (getpeername(ctl, (struct sockaddr *)&ra, &rl) < 0) { snprintf(s->err, sizeof s->err, "%s: getpeername: %s", desc, strerror(errno)); goto out; } + if (ra.ss_family == AF_INET) ((struct sockaddr_in *)&ra)->sin_port = htons(b.port); + else ((struct sockaddr_in6 *)&ra)->sin6_port = htons(b.port); + } else if (b.atyp == 1) { + struct sockaddr_in *a = (struct sockaddr_in *)&ra; + a->sin_family = AF_INET; a->sin_port = htons(b.port); memcpy(&a->sin_addr, b.addr, 4); + rl = sizeof *a; + } else { + struct sockaddr_in6 *a = (struct sockaddr_in6 *)&ra; + a->sin6_family = AF_INET6; a->sin6_port = htons(b.port); memcpy(&a->sin6_addr, b.addr, 16); + rl = sizeof *a; + } + up = socket(ra.ss_family, SOCK_DGRAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0); + if (up < 0 || connect(up, (struct sockaddr *)&ra, rl) < 0) { + snprintf(s->err, sizeof s->err, "%s: UDP relay socket: %s", desc, strerror(errno)); + goto out; + } + s->ctl_fd = ctl; s->up_fd = up; s->ok = true; + ctl = up = -1; +out: + close_fd(&ctl); + close_fd(&up); + pthread_mutex_lock(&R.dmu); + s->dnext = R.done; + R.done = s; + pthread_mutex_unlock(&R.dmu); + eventfd_write(R.uwake, 1); +} + +static void *worker_main(void *arg) +{ + (void)arg; + for (;;) { + pthread_mutex_lock(&R.jmu); + while (!R.jhead && !atomic_load(&R.stopping)) pthread_cond_wait(&R.jcv, &R.jmu); + if (atomic_load(&R.stopping)) { pthread_mutex_unlock(&R.jmu); return NULL; } + struct job *j = R.jhead; + R.jhead = j->next; + if (!R.jhead) R.jtail = NULL; + R.jcount--; + pthread_mutex_unlock(&R.jmu); + if (j->kind == J_TCP) tcp_job_run(j); else udp_job_run(j->s); + free(j); + } +} + +static void *acceptor_main(void *arg) +{ + (void)arg; + int64_t last_cap_log = 0; + while (!atomic_load(&R.stopping)) { + struct pollfd p[2] = { { .fd = R.lfd, .events = POLLIN }, { .fd = R.stopfd, .events = POLLIN } }; + if (poll(p, 2, -1) < 0) { if (errno == EINTR) continue; break; } + if (p[1].revents) break; + for (int i = 0; i < 256; i++) { + struct sockaddr_in pa; + socklen_t pl = sizeof pa; + int fd = accept4(R.lfd, (struct sockaddr *)&pa, &pl, SOCK_NONBLOCK | SOCK_CLOEXEC); + if (fd < 0) { + if (errno == EMFILE || errno == ENFILE || errno == ENOBUFS || errno == ENOMEM) { + pb_core_log("relay: accept: %s, backing off", strerror(errno)); + struct pollfd s = { .fd = R.stopfd, .events = POLLIN }; + poll(&s, 1, 200); + } + break; + } + struct pb_orig o; + if (pb_core_take_tcp_orig(pa.sin_addr.s_addr, ntohs(pa.sin_port), &o) != 0) { + pb_core_log("relay: no original destination for client %s:%u, closing", inet_ntoa(pa.sin_addr), ntohs(pa.sin_port)); + close(fd); + continue; + } + if (atomic_fetch_add(&R.nconn, 1) >= R.cfg.max_tcp_conns) { + atomic_fetch_sub(&R.nconn, 1); + int64_t t = now_ms(); + if (t - last_cap_log > 5000) { last_cap_log = t; pb_core_log("relay: connection limit (%d) reached, dropping new connections", R.cfg.max_tcp_conns); } + close(fd); + continue; + } + if (job_push(J_TCP, fd, &o, NULL) != 0) { + atomic_fetch_sub(&R.nconn, 1); + close(fd); + int64_t t = now_ms(); + if (t - last_cap_log > 5000) { last_cap_log = t; pb_core_log("relay: handshake queue full (%d), shedding new connections", JOBQ_MAX); } + } + } + } + return NULL; +} + +static void pipe_discard(int fd) +{ + char b[4096]; + while (read(fd, b, sizeof b) > 0) {} +} + +static int spill_from_pipe(struct conn *c, int d, int pipe_rd, size_t n) +{ + c->pend[d] = malloc(n); + if (!c->pend[d]) { pipe_discard(pipe_rd); return -1; } + size_t got = 0; + while (got < n) { + ssize_t r = read(pipe_rd, c->pend[d] + got, n - got); + if (r <= 0) { if (r < 0 && errno == EINTR) continue; break; } + got += (size_t)r; + } + pipe_discard(pipe_rd); + c->off[d] = 0; + c->len[d] = got; + if (!got) { free(c->pend[d]); c->pend[d] = NULL; return -1; } + return 0; +} + +// Zero-copy forward of one chunk in direction d via the thread's pipe. Returns 1 progress, 0 nothing to do (EAGAIN), +// -1 fatal, -2 splice unsupported (use the copy path), 2 EOF. +static int pump_splice(struct dthread *t, struct conn *c, int d) +{ + int o = 1 - d; + ssize_t n = splice(c->fd[d], NULL, t->pfd[1], NULL, PIPE_SZ, SPLICE_F_NONBLOCK | SPLICE_F_MOVE); + if (n == 0) return 2; + if (n < 0) { + if (errno == EAGAIN || errno == EINTR) return 0; + if (errno == EINVAL || errno == ENOSYS) return -2; + return -1; + } + ssize_t w = splice(t->pfd[0], NULL, c->fd[o], NULL, (size_t)n, SPLICE_F_NONBLOCK | SPLICE_F_MOVE); + if (w < 0) { + if (errno != EAGAIN && errno != EINTR) { pipe_discard(t->pfd[0]); return -1; } + w = 0; + } + if (w < n && spill_from_pipe(c, d, t->pfd[0], (size_t)(n - w)) != 0) return -1; + return 1; +} + +// Upstream->client for TCP-DNS: only whole messages (after pb_core_dns_observe returned) are forwarded. +static int dns_pump_up(struct conn *c, uint8_t *scratch) +{ + int progress = 0; + ssize_t n = recv(c->fd[1], scratch, SCRATCH_SZ, 0); + if (n < 0 && errno != EAGAIN && errno != EINTR) return -1; + if (n > 0) { + if (c->dhave + (size_t)n > c->dcap) { + size_t nc = c->dhave + (size_t)n; + uint8_t *nb = realloc(c->dbuf, nc); + if (!nb) return -1; + c->dbuf = nb; c->dcap = nc; + } + memcpy(c->dbuf + c->dhave, scratch, (size_t)n); + c->dhave += (size_t)n; + progress = 1; + } else if (n == 0) { + c->eof[1] = true; + progress = 1; + } + size_t fwd = 0; + if (c->doff) { + fwd = c->dhave; + } else { + while (c->dhave - fwd >= 2) { + size_t L = (size_t)(c->dbuf[fwd] << 8 | c->dbuf[fwd + 1]); + if (L == 0) { c->doff = true; fwd = c->dhave; break; } // malformed: relay raw from here on + if (c->dhave - fwd < 2 + L) break; + pb_core_dns_observe(c->dbuf + fwd + 2, L); + fwd += 2 + L; + } + if (c->eof[1]) fwd = c->dhave; // truncated trailing message: pass through unobserved + } + if (fwd) { + ssize_t w = send(c->fd[0], c->dbuf, fwd, MSG_NOSIGNAL); + if (w < 0) { + if (errno != EAGAIN && errno != EINTR) return -1; + w = 0; + } + if ((size_t)w < fwd) { + c->pend[1] = malloc(fwd - (size_t)w); + if (!c->pend[1]) return -1; + memcpy(c->pend[1], c->dbuf + w, fwd - (size_t)w); + c->off[1] = 0; c->len[1] = fwd - (size_t)w; + } + memmove(c->dbuf, c->dbuf + fwd, c->dhave - fwd); + c->dhave -= fwd; + progress = 1; + } + return progress; +} + +static int conn_pump(struct dthread *t, struct conn *c) +{ + uint8_t *scratch = t->scratch; + for (int iter = 0; iter < 16; iter++) { + bool progress = false; + for (int d = 0; d < 2; d++) { + int o = 1 - d; + if (c->dns && d == 1 && c->len[1] == 0 && (!c->eof[1] || c->dhave)) { + int r = dns_pump_up(c, scratch); + if (r < 0) return -1; + if (r) progress = true; + } else if (c->len[d] == 0 && !c->eof[d]) { + int sp = -2; + if (!t->nosplice && !c->dns) { + sp = pump_splice(t, c, d); + if (sp == -1) return -1; + if (sp == -2) t->nosplice = true; + else if (sp == 2) { c->eof[d] = true; progress = true; } + else if (sp == 1) progress = true; + } + if (sp == -2) { + ssize_t n = recv(c->fd[d], scratch, SCRATCH_SZ, 0); + if (n > 0) { + progress = true; + ssize_t w = send(c->fd[o], scratch, (size_t)n, MSG_NOSIGNAL); + if (w < 0) { + if (errno != EAGAIN && errno != EINTR) return -1; + w = 0; + } + if (w < n) { // spill the unsent tail; no more reads in this direction until drained + c->pend[d] = malloc((size_t)(n - w)); + if (!c->pend[d]) return -1; + memcpy(c->pend[d], scratch + w, (size_t)(n - w)); + c->off[d] = 0; + c->len[d] = (size_t)(n - w); + } + } + else if (n == 0) { c->eof[d] = true; progress = true; } + else if (errno != EAGAIN && errno != EINTR) return -1; + } + } else if (c->len[d] > 0) { + ssize_t n = send(c->fd[o], c->pend[d] + c->off[d], c->len[d], MSG_NOSIGNAL); + if (n > 0) { + c->off[d] += (size_t)n; c->len[d] -= (size_t)n; progress = true; + if (!c->len[d]) { free(c->pend[d]); c->pend[d] = NULL; } + } + else if (errno != EAGAIN && errno != EINTR) return -1; + } + if (c->len[d] == 0 && c->eof[d] && !c->shut[d]) { + shutdown(c->fd[o], SHUT_WR); + c->shut[d] = true; + progress = true; + } + } + if (progress) c->last = now_ms(); + if (c->shut[0] && c->shut[1]) return 1; + if (!progress) break; + } + return 0; +} + +static int conn_update(struct dthread *t, struct conn *c) +{ + for (int i = 0; i < 2; i++) { + uint32_t want = 0; + if (!c->eof[i] && c->len[i] == 0) want |= EPOLLIN; + if (c->len[1 - i] > 0) want |= EPOLLOUT; + if (want == c->mask[i]) continue; + struct epoll_event ev = { .events = want, .data.ptr = c }; + int rc; + if (!want) rc = epoll_ctl(t->ep, EPOLL_CTL_DEL, c->fd[i], NULL); + else rc = epoll_ctl(t->ep, c->mask[i] ? EPOLL_CTL_MOD : EPOLL_CTL_ADD, c->fd[i], &ev); + if (rc < 0) return -1; + c->mask[i] = want; + } + return 0; +} + +static void conn_close(struct dthread *t, struct conn *c) +{ + close(c->fd[0]); + close(c->fd[1]); + c->dead = true; + conn_bufs_free(c); + if (c->prev) c->prev->next = c->next; else t->conns = c->next; + if (c->next) c->next->prev = c->prev; + c->next = t->grave; + c->prev = NULL; + t->grave = c; + atomic_fetch_sub(&R.nconn, 1); + atomic_fetch_sub(&t->load, 1); +} + +static void conn_service(struct dthread *t, struct conn *c) +{ + int r = conn_pump(t, c); + if (r == 0 && conn_update(t, c) != 0) r = -1; + if (r != 0) conn_close(t, c); +} + +static void dthread_adopt(struct dthread *t) +{ + pthread_mutex_lock(&t->mu); + struct conn *c = t->inq_head; + t->inq_head = t->inq_tail = NULL; + pthread_mutex_unlock(&t->mu); + while (c) { + struct conn *n = c->next; + c->next = t->conns; c->prev = NULL; + if (t->conns) t->conns->prev = c; + t->conns = c; + c->last = now_ms(); + if (c->len[1] || c->dhave) conn_service(t, c); + else if (conn_update(t, c) != 0) conn_close(t, c); + c = n; + } +} + +static void free_grave(struct dthread *t) +{ + while (t->grave) { struct conn *n = t->grave->next; free(t->grave); t->grave = n; } +} + +static void *data_main(void *arg) +{ + struct dthread *t = arg; + struct epoll_event evs[64]; + int64_t last_sweep = now_ms(); + int64_t sweep_every = (int64_t)R.cfg.tcp_idle_timeout_s * 250; // idle/4, clamped to 1s..5s + if (sweep_every < 1000) sweep_every = 1000; + if (sweep_every > 5000) sweep_every = 5000; + while (!atomic_load(&R.stopping)) { + int n = epoll_wait(t->ep, evs, 64, 1000); + if (n < 0 && errno != EINTR) break; + for (int i = 0; i < n; i++) { + void *p = evs[i].data.ptr; + if (p == &TAG_STOP) goto done; + if (p == &TAG_WAKE) { + eventfd_t v; + eventfd_read(t->wake, &v); + dthread_adopt(t); + } else if (!((struct conn *)p)->dead) { + conn_service(t, p); + } + } + free_grave(t); + int64_t now = now_ms(); + if (now - last_sweep >= sweep_every) { + last_sweep = now; + int64_t lim = (int64_t)R.cfg.tcp_idle_timeout_s * 1000; + for (struct conn *c = t->conns, *nx; c; c = nx) { + nx = c->next; + if (now - c->last > lim) conn_close(t, c); + } + free_grave(t); + } + } +done: + return NULL; +} + +static void udp_droplog(const char *why) +{ + R.drop_count++; + int64_t t = now_ms(); + if (t - R.drop_logged > 5000) { + pb_core_log("relay: UDP datagram dropped (%s); %lld dropped so far", why, (long long)R.drop_count); + R.drop_logged = t; + } +} + +static uint32_t sess_hash(const struct sockaddr_in *c, uint32_t id, uint32_t flow) +{ + uint32_t h = 2166136261u; + uint32_t v[4] = { c->sin_addr.s_addr, c->sin_port, id, flow }; + for (int i = 0; i < 4; i++) { h ^= v[i]; h *= 16777619u; h ^= h >> 15; } + return h % SESS_BUCKETS; +} + +static struct sess *sess_find(const struct sockaddr_in *c, uint32_t id, uint32_t flow) +{ + for (struct sess *s = R.buckets[sess_hash(c, id, flow)]; s; s = s->hnext) + if (s->cli.sin_addr.s_addr == c->sin_addr.s_addr && s->cli.sin_port == c->sin_port && s->id == id && s->flow_be == flow) + return s; + return NULL; +} + +static void sess_free_queue(struct sess *s) +{ + for (int i = 0; i < s->nq; i++) free(s->q[i]); + s->nq = 0; s->qbytes = 0; +} + +static void sess_destroy(struct sess *s) +{ + close_fd(&s->up_fd); // closing removes from epoll + close_fd(&s->ctl_fd); + sess_free_queue(s); + uint32_t h = sess_hash(&s->cli, s->id, s->flow_be); + for (struct sess **pp = &R.buckets[h]; *pp; pp = &(*pp)->hnext) + if (*pp == s) { *pp = s->hnext; break; } + if (s->prev) s->prev->next = s->next; else R.sess_head = s->next; + if (s->next) s->next->prev = s->prev; + s->dead = true; + s->next = R.sgrave; + R.sgrave = s; + R.nsess--; +} + +static void sess_free_grave(void) +{ + while (R.sgrave) { struct sess *n = R.sgrave->next; free(R.sgrave); R.sgrave = n; } +} + +static struct sess *sess_new(const struct sockaddr_in *cli, uint32_t id, uint32_t flow, int kind) +{ + if (R.nsess >= MAX_UDP_SESS) return NULL; + struct sess *s = calloc(1, sizeof *s); + if (!s) return NULL; + s->cli = *cli; s->id = id; s->flow_be = flow; s->kind = kind; + s->up_fd = s->ctl_fd = -1; + s->ref[0].s = s->ref[1].s = s; + s->ref[0].which = 0; s->ref[1].which = 1; + s->last = now_ms(); + s->sid = ++R.next_sid; + uint32_t h = sess_hash(cli, id, flow); + s->hnext = R.buckets[h]; R.buckets[h] = s; + s->next = R.sess_head; + if (R.sess_head) R.sess_head->prev = s; + R.sess_head = s; + R.nsess++; + return s; +} + +static void udp_reply(const struct sockaddr_in *cli, uint32_t flow_be, const uint8_t *data, size_t n) +{ + struct iovec iov = { (void *)data, n }; + union { char b[CMSG_SPACE(sizeof(struct in_pktinfo))]; struct cmsghdr a; } cb; + memset(&cb, 0, sizeof cb); + struct msghdr mh = { .msg_name = (void *)cli, .msg_namelen = sizeof *cli, .msg_iov = &iov, .msg_iovlen = 1, + .msg_control = cb.b, .msg_controllen = sizeof cb.b }; + struct cmsghdr *cm = CMSG_FIRSTHDR(&mh); + cm->cmsg_level = IPPROTO_IP; cm->cmsg_type = IP_PKTINFO; cm->cmsg_len = CMSG_LEN(sizeof(struct in_pktinfo)); + struct in_pktinfo pi; + memset(&pi, 0, sizeof pi); + pi.ipi_spec_dst.s_addr = flow_be; + memcpy(CMSG_DATA(cm), &pi, sizeof pi); + sendmsg(R.ufd, &mh, MSG_DONTWAIT | MSG_NOSIGNAL); +} + +static int sess_arm(struct sess *s, int which, uint32_t events) +{ + int fd = which ? s->ctl_fd : s->up_fd; + struct epoll_event ev = { .events = events, .data.ptr = &s->ref[which] }; + return epoll_ctl(R.uep, EPOLL_CTL_ADD, fd, &ev); +} + +static int flow_upsert(struct sess *s, const struct pb_orig *o, uint32_t flow_be, int64_t now) +{ + int victim = 0; + for (int i = 0; i < s->nflows; i++) { + if (s->flows[i].port == o->port && !memcmp(s->flows[i].addr, o->addr, 16)) { + s->flows[i].ip_be = flow_be; + s->flows[i].last = now; + return i; + } + if (s->flows[i].last < s->flows[victim].last) victim = i; + } + if (s->nflows < MAX_FLOWS) victim = s->nflows++; + memcpy(s->flows[victim].addr, o->addr, 16); + s->flows[victim].port = o->port; + s->flows[victim].ip_be = flow_be; + s->flows[victim].last = now; + return victim; +} + +static size_t socks5_header(uint8_t *h, const struct pb_orig *o) +{ + h[0] = h[1] = h[2] = 0; + size_t n; + if (o->family == AF_INET) { h[3] = 1; memcpy(h + 4, o->addr + 12, 4); n = 10; } + else { h[3] = 4; memcpy(h + 4, o->addr, 16); n = 22; } + h[n - 2] = (uint8_t)(o->port >> 8); + h[n - 1] = (uint8_t)o->port; + return n; +} + +static void udp_dgram(const struct sockaddr_in *cli, uint32_t flow_be, const uint8_t *pl, size_t n, int64_t now) +{ + uint32_t hv = (cli->sin_addr.s_addr * 2654435761u) ^ (cli->sin_port * 40503u) ^ (flow_be * 2246822519u); + struct dce *e = &R.dc[hv % DC_SIZE]; + bool same = e->used && e->cli.sin_addr.s_addr == cli->sin_addr.s_addr && e->cli.sin_port == cli->sin_port && e->flow_be == flow_be; + if (!same || now - e->looked >= FLOW_TTL_MS) { + struct pb_orig o; + if (pb_core_get_udp_flow(flow_be, &o) != 0) { e->used = false; udp_droplog("unknown flow address"); return; } + bool keep = same && e->o.port == o.port && !memcmp(e->o.addr, o.addr, 16) && now - e->last <= (int64_t)R.cfg.udp_idle_timeout_s * 1000; + if (!keep) { + int act = o.action; + uint32_t pid = o.proxy_id; + if (act == PB_ACT_DECIDE) { + struct pb_decision d; + pb_core_decide(&o, &d); + act = d.action; + pid = d.proxy_id; + } + if (act == PB_ACT_PROXY) { + struct pb_proxy px; + if (pb_core_get_proxy(pid, &px) != 0) { + pb_core_log("relay: UDP: no proxy configured (id %u), dropping", pid); + act = PB_ACT_BLOCK; + } else { + pid = px.id; + } + } else if (act != PB_ACT_DIRECT) { + act = PB_ACT_BLOCK; + } + pb_core_flow_event(&o, act); + e->action = act; + e->pid = pid; + e->sid = 0; + } + e->used = true; + e->cli = *cli; + e->flow_be = flow_be; + e->o = o; + e->looked = now; + } + e->last = now; + if (e->action == PB_ACT_BLOCK) return; + + const struct pb_orig *o = &e->o; + struct sess *s; + if (e->action == PB_ACT_DIRECT) { + s = sess_find(cli, DIRECT_ID, flow_be); + if (!s) { + s = sess_new(cli, DIRECT_ID, flow_be, SK_DIRECT); + if (!s) { udp_droplog("UDP session limit reached"); return; } + s->dstlen = orig_to_sa(o, &s->dst); + s->dport = o->port; + s->connected = o->family != AF_INET; + s->up_fd = socket(s->dst.ss_family, SOCK_DGRAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0); + if (s->up_fd < 0 || (s->connected && connect(s->up_fd, (struct sockaddr *)&s->dst, s->dstlen) < 0) || sess_arm(s, 0, EPOLLIN) < 0) { + pb_core_log("relay: UDP direct socket failed: %s", strerror(errno)); + sess_destroy(s); + return; + } + s->state = ST_READY; + } + s->last = now; + if (s->connected) send(s->up_fd, pl, n, MSG_DONTWAIT | MSG_NOSIGNAL); + else sendto(s->up_fd, pl, n, MSG_DONTWAIT | MSG_NOSIGNAL, (struct sockaddr *)&s->dst, s->dstlen); + return; + } + + s = sess_find(cli, e->pid, 0); + if (!s) { + struct pb_proxy px; + if (pb_core_get_proxy(e->pid, &px) != 0) { udp_droplog("proxy vanished"); return; } + s = sess_new(cli, px.id, 0, px.type == PROXY_TYPE_SOCKS5 ? SK_SOCKS5 : SK_DROP); + if (!s) { udp_droplog("UDP session limit reached"); return; } + s->proxy = px; + if (s->kind == SK_DROP) { + s->state = ST_READY; + } else { + s->state = ST_SETUP; + if (job_push(J_UDP, -1, NULL, s) != 0) { + s->state = ST_FAILED; + s->fail_until = now + 5000; + udp_droplog("setup queue full"); + } + } + } + s->last = now; + if (s->kind == SK_DROP) { + if (!s->warned) { + s->warned = true; + pb_core_log("relay: UDP cannot be proxied through HTTP proxy '%s'; dropping UDP from %s:%u", s->proxy.name, + inet_ntoa(cli->sin_addr), ntohs(cli->sin_port)); + } + return; + } + if (s->state == ST_FAILED) { udp_droplog("proxy UDP setup failed"); return; } + + if (e->sid == s->sid && e->fidx < s->nflows && s->flows[e->fidx].port == o->port && !memcmp(s->flows[e->fidx].addr, o->addr, 16)) { + s->flows[e->fidx].last = now; + } else { + e->fidx = flow_upsert(s, o, flow_be, now); + e->sid = s->sid; + } + + uint8_t hdr[22]; + size_t hl = socks5_header(hdr, o); + if (hl + n > UDP_MAXDG) { udp_droplog("datagram too large"); return; } + if (s->state == ST_SETUP) { + if (s->nq >= UDP_QMAX || s->qbytes + hl + n > UDP_QBYTES) { udp_droplog("setup queue full"); return; } + struct pkt *p = malloc(sizeof *p + hl + n); + if (!p) return; + p->len = hl + n; + memcpy(p->d, hdr, hl); + memcpy(p->d + hl, pl, n); + s->q[s->nq++] = p; + s->qbytes += p->len; + return; + } + struct iovec iov[2] = { { hdr, hl }, { (void *)pl, n } }; + struct msghdr mh = { .msg_iov = iov, .msg_iovlen = 2 }; + sendmsg(s->up_fd, &mh, MSG_DONTWAIT | MSG_NOSIGNAL); +} + +static void udp_recv_client(void) +{ + for (int round = 0; round < 8; round++) { + for (int i = 0; i < UDP_BATCH; i++) { + R.mmiov[i] = (struct iovec){ R.rxbuf + (size_t)i * UDP_MAXDG, UDP_MAXDG }; + R.mm[i].msg_hdr = (struct msghdr){ .msg_name = &R.mmname[i], .msg_namelen = sizeof(struct sockaddr_in), .msg_iov = &R.mmiov[i], + .msg_iovlen = 1, .msg_control = R.mmctl[i], .msg_controllen = sizeof R.mmctl[i] }; + } + int cnt = recvmmsg(R.ufd, R.mm, UDP_BATCH, MSG_DONTWAIT, NULL); + if (cnt <= 0) return; + int64_t now = now_ms(); + for (int i = 0; i < cnt; i++) { + struct msghdr *mh = &R.mm[i].msg_hdr; + if (mh->msg_flags & MSG_TRUNC) continue; + struct in_pktinfo pi; + bool have = false; + for (struct cmsghdr *c = CMSG_FIRSTHDR(mh); c; c = CMSG_NXTHDR(mh, c)) + if (c->cmsg_level == IPPROTO_IP && c->cmsg_type == IP_PKTINFO) { memcpy(&pi, CMSG_DATA(c), sizeof pi); have = true; } + if (!have || (ntohl(pi.ipi_addr.s_addr) >> 24) != 127) { udp_droplog("destination not in 127.0.0.0/8"); continue; } + udp_dgram(&R.mmname[i], pi.ipi_addr.s_addr, R.rxbuf + (size_t)i * UDP_MAXDG, R.mm[i].msg_len, now); + } + if (cnt < UDP_BATCH) return; + } +} + +static void udp_recv_upstream(struct sess *s) +{ + for (int i = 0; i < 64 && !s->dead; i++) { + struct sockaddr_in from; + socklen_t fl = sizeof from; + ssize_t n = recvfrom(s->up_fd, R.rxbuf, UDP_MAXDG, MSG_DONTWAIT, (struct sockaddr *)&from, &fl); + if (n < 0) return; // includes ECONNREFUSED from ICMP: ignore + s->last = now_ms(); + if (s->kind == SK_DIRECT) { + // unconnected v4 socket: accept replies from the destination host on any port (some servers answer from + // another port); anything from another host is dropped. + if (!s->connected && (fl < sizeof from || from.sin_addr.s_addr != ((struct sockaddr_in *)&s->dst)->sin_addr.s_addr)) { + udp_droplog("reply from unexpected host"); + continue; + } + if (s->dport == 53) pb_core_dns_observe(R.rxbuf, (size_t)n); + udp_reply(&s->cli, s->flow_be, R.rxbuf, (size_t)n); + continue; + } + const uint8_t *b = R.rxbuf; + if (n < 10 || b[2] != 0) continue; // fragments unsupported + size_t h; + uint8_t addr[16] = { 0 }; + bool known = true; + uint16_t port; + if (b[3] == 1) { h = 10; memset(addr + 10, 0xff, 2); memcpy(addr + 12, b + 4, 4); } + else if (b[3] == 4) { if (n < 22) continue; h = 22; memcpy(addr, b + 4, 16); } + else if (b[3] == 3) { h = 7 + (size_t)b[4]; if ((size_t)n < h) continue; known = false; } + else continue; + port = (uint16_t)(b[h - 2] << 8 | b[h - 1]); + // Source mapping: exact (ip,port) the client sent to; otherwise the same ip on another port is delivered from the + // flow address of the most recently used destination with that ip; any other source is dropped (and counted). + uint32_t flow = 0; + uint16_t fport = 0; + bool found = false; + if (known) { + int best = -1; + for (int k = 0; k < s->nflows; k++) { + if (memcmp(s->flows[k].addr, addr, 16)) continue; + if (s->flows[k].port == port) { best = k; break; } + if (best < 0 || s->flows[k].last > s->flows[best].last) best = k; + } + if (best >= 0) { flow = s->flows[best].ip_be; fport = s->flows[best].port; found = true; } + } else if (s->nflows == 1) { + flow = s->flows[0].ip_be; fport = s->flows[0].port; found = true; + } + if (!found) { udp_droplog("reply from unknown source"); continue; } + if (fport == 53) pb_core_dns_observe(b + h, (size_t)n - h); + udp_reply(&s->cli, flow, b + h, (size_t)n - h); + } +} + +static void udp_setup_done(void) +{ + pthread_mutex_lock(&R.dmu); + struct sess *l = R.done; + R.done = NULL; + pthread_mutex_unlock(&R.dmu); + while (l) { + struct sess *s = l; + l = s->dnext; + if (s->ok && sess_arm(s, 0, EPOLLIN) == 0 && sess_arm(s, 1, EPOLLIN | EPOLLRDHUP) == 0) { + s->state = ST_READY; + s->last = now_ms(); + for (int i = 0; i < s->nq; i++) send(s->up_fd, s->q[i]->d, s->q[i]->len, MSG_DONTWAIT | MSG_NOSIGNAL); + sess_free_queue(s); + } else { + if (s->ok) snprintf(s->err, sizeof s->err, "epoll_ctl: %s", strerror(errno)); + pb_core_log("relay: UDP associate failed: %s", s->err); + close_fd(&s->up_fd); + close_fd(&s->ctl_fd); + sess_free_queue(s); + s->state = ST_FAILED; + s->fail_until = now_ms() + 5000; + } + } +} + +static void udp_ctl_event(struct sess *s) +{ + char tmp[256]; + for (;;) { + ssize_t n = recv(s->ctl_fd, tmp, sizeof tmp, MSG_DONTWAIT); + if (n > 0) continue; + if (n < 0 && errno == EAGAIN) return; + if (n < 0 && errno == EINTR) continue; + break; + } + pb_core_log("relay: SOCKS5 UDP control connection closed, dropping session for %s:%u", inet_ntoa(s->cli.sin_addr), ntohs(s->cli.sin_port)); + sess_destroy(s); +} + +static void udp_sweep(void) +{ + int64_t now = now_ms(), lim = (int64_t)R.cfg.udp_idle_timeout_s * 1000; + for (struct sess *s = R.sess_head, *n; s; s = n) { + n = s->next; + if (s->state == ST_SETUP) continue; + if (s->state == ST_FAILED ? now >= s->fail_until : now - s->last > lim) sess_destroy(s); + } +} + +static void *udp_main(void *arg) +{ + (void)arg; + struct epoll_event evs[64]; + while (!atomic_load(&R.stopping)) { + int n = epoll_wait(R.uep, evs, 64, 1000); + if (n < 0 && errno != EINTR) break; + for (int i = 0; i < n; i++) { + void *p = evs[i].data.ptr; + if (p == &TAG_STOP) return NULL; + if (p == &TAG_UDP) udp_recv_client(); + else if (p == &TAG_WAKE) { eventfd_t v; eventfd_read(R.uwake, &v); udp_setup_done(); } + else if (p == &TAG_TIMER) { uint64_t v; if (read(R.tmfd, &v, sizeof v) > 0) udp_sweep(); } + else { + struct ref *r = p; + struct sess *s = r->s; + if (s->dead) continue; + if (r->which == 1) udp_ctl_event(s); else udp_recv_upstream(s); + } + } + sess_free_grave(); + } + return NULL; +} + +static void teardown(void) +{ + atomic_store(&R.stopping, true); + if (R.stopfd >= 0) eventfd_write(R.stopfd, 1); + pthread_mutex_lock(&R.jmu); + pthread_cond_broadcast(&R.jcv); + pthread_mutex_unlock(&R.jmu); + if (R.acc_started) pthread_join(R.acceptor, NULL); + for (int i = 0; i < R.nworkers; i++) pthread_join(R.workers[i], NULL); + for (int i = 0; i < R.ndata; i++) if (R.data[i].started) pthread_join(R.data[i].th, NULL); + if (R.udp_started) pthread_join(R.udpth, NULL); + R.acc_started = R.udp_started = false; + R.nworkers = 0; + + while (R.jhead) { + struct job *j = R.jhead; + R.jhead = j->next; + if (j->kind == J_TCP) { close(j->fd); atomic_fetch_sub(&R.nconn, 1); } + free(j); + } + R.jtail = NULL; R.jcount = 0; + for (int i = 0; i < R.ndata; i++) { + struct dthread *t = &R.data[i]; + for (struct conn *c = t->inq_head, *n; c; c = n) { n = c->next; close(c->fd[0]); close(c->fd[1]); conn_bufs_free(c); free(c); atomic_fetch_sub(&R.nconn, 1); } + t->inq_head = t->inq_tail = NULL; + for (struct conn *c = t->conns, *n; c; c = n) { n = c->next; close(c->fd[0]); close(c->fd[1]); conn_bufs_free(c); free(c); atomic_fetch_sub(&R.nconn, 1); } + t->conns = NULL; + free_grave(t); + free(t->scratch); t->scratch = NULL; + close_fd(&t->pfd[0]); close_fd(&t->pfd[1]); + t->started = false; + close_fd(&t->ep); + close_fd(&t->wake); + } + while (R.sess_head) sess_destroy(R.sess_head); + sess_free_grave(); + memset(R.buckets, 0, sizeof R.buckets); + R.done = NULL; + R.nsess = 0; + free(R.dc); R.dc = NULL; + free(R.rxbuf); R.rxbuf = NULL; + free(R.mm); free(R.mmiov); free(R.mmname); free(R.mmctl); + R.mm = NULL; R.mmiov = NULL; R.mmname = NULL; R.mmctl = NULL; + close_fd(&R.lfd); + close_fd(&R.ufd); + close_fd(&R.tmfd); + close_fd(&R.uep); + close_fd(&R.uwake); + close_fd(&R.stopfd); +} + +static int add_ep(int ep, int fd, void *tag, uint32_t ev) +{ + struct epoll_event e = { .events = ev, .data.ptr = tag }; + return epoll_ctl(ep, EPOLL_CTL_ADD, fd, &e); +} + +int pb_relay_start(const struct pb_relay_cfg *cfg) +{ + pthread_mutex_lock(&R.life); + if (R.running) { pthread_mutex_unlock(&R.life); return 0; } + struct pb_relay_cfg c = { 0 }; + if (cfg) c = *cfg; + if (!c.tcp_port) c.tcp_port = PB_TCP_RELAY_PORT_DEFAULT; + if (!c.udp_port) c.udp_port = PB_UDP_RELAY_PORT_DEFAULT; + if (c.max_tcp_conns <= 0) c.max_tcp_conns = 32768; + if (c.tcp_idle_timeout_s <= 0) c.tcp_idle_timeout_s = 300; + if (c.udp_idle_timeout_s <= 0) c.udp_idle_timeout_s = 60; + if (c.handshake_timeout_s <= 0) c.handshake_timeout_s = 10; + R.cfg = c; + atomic_store(&R.stopping, false); + atomic_store(&R.nconn, 0); + R.drop_count = 0; R.drop_logged = 0; + const char *why = ""; + int one = 1; + long ncpu = sysconf(_SC_NPROCESSORS_ONLN); + R.ndata = ncpu < 2 ? 2 : (ncpu > MAX_DATA ? MAX_DATA : (int)ncpu); + for (int i = 0; i < R.ndata; i++) { + memset(&R.data[i], 0, sizeof R.data[i]); + R.data[i].ep = R.data[i].wake = -1; + R.data[i].pfd[0] = R.data[i].pfd[1] = -1; + pthread_mutex_init(&R.data[i].mu, NULL); + } + + R.stopfd = eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK); + R.uwake = eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK); + if (R.stopfd < 0 || R.uwake < 0) { why = "eventfd"; goto fail; } + + R.lfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0); + if (R.lfd < 0) { why = "tcp socket"; goto fail; } + setsockopt(R.lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + tune_sock(R.lfd); + struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(c.tcp_port), .sin_addr.s_addr = htonl(INADDR_LOOPBACK) }; + if (bind(R.lfd, (struct sockaddr *)&a, sizeof a) < 0 || listen(R.lfd, 4096) < 0) { + pb_core_log("relay: cannot listen on 127.0.0.1:%u: %s", c.tcp_port, strerror(errno)); + goto fail_quiet; + } + + R.ufd = socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0); + if (R.ufd < 0) { why = "udp socket"; goto fail; } + if (setsockopt(R.ufd, SOL_SOCKET, SO_BINDTODEVICE, "lo", 3) < 0) + pb_core_log("relay: SO_BINDTODEVICE lo failed (%s); relying on destination address filtering", strerror(errno)); + int rb = 4 << 20; + if (setsockopt(R.ufd, SOL_SOCKET, SO_RCVBUFFORCE, &rb, sizeof rb) < 0) setsockopt(R.ufd, SOL_SOCKET, SO_RCVBUF, &rb, sizeof rb); + if (setsockopt(R.ufd, IPPROTO_IP, IP_PKTINFO, &one, sizeof one) < 0) { why = "IP_PKTINFO"; goto fail; } + a.sin_port = htons(c.udp_port); + a.sin_addr.s_addr = htonl(INADDR_ANY); + if (bind(R.ufd, (struct sockaddr *)&a, sizeof a) < 0) { + pb_core_log("relay: cannot bind UDP port %u: %s", c.udp_port, strerror(errno)); + goto fail_quiet; + } + R.tcp_port = c.tcp_port; R.udp_port = c.udp_port; + + R.uep = epoll_create1(EPOLL_CLOEXEC); + R.tmfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC); + R.dc = calloc(DC_SIZE, sizeof *R.dc); + R.rxbuf = malloc((size_t)UDP_MAXDG * UDP_BATCH); + R.mm = calloc(UDP_BATCH, sizeof *R.mm); + R.mmiov = calloc(UDP_BATCH, sizeof *R.mmiov); + R.mmname = calloc(UDP_BATCH, sizeof *R.mmname); + R.mmctl = calloc(UDP_BATCH, sizeof *R.mmctl); + if (R.uep < 0 || R.tmfd < 0 || !R.dc || !R.rxbuf || !R.mm || !R.mmiov || !R.mmname || !R.mmctl) { why = "udp resources"; goto fail; } + struct itimerspec its = { .it_interval = { 1, 0 }, .it_value = { 1, 0 } }; + if (timerfd_settime(R.tmfd, 0, &its, NULL) < 0 || + add_ep(R.uep, R.ufd, &TAG_UDP, EPOLLIN) || add_ep(R.uep, R.stopfd, &TAG_STOP, EPOLLIN) || + add_ep(R.uep, R.uwake, &TAG_WAKE, EPOLLIN) || add_ep(R.uep, R.tmfd, &TAG_TIMER, EPOLLIN)) { why = "udp epoll"; goto fail; } + + for (int i = 0; i < R.ndata; i++) { + struct dthread *t = &R.data[i]; + t->scratch = malloc(SCRATCH_SZ); + if (pipe2(t->pfd, O_CLOEXEC | O_NONBLOCK) == 0) fcntl(t->pfd[0], F_SETPIPE_SZ, PIPE_SZ); + else t->pfd[0] = t->pfd[1] = -1, t->nosplice = true; + t->ep = epoll_create1(EPOLL_CLOEXEC); + t->wake = eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK); + if (!t->scratch || t->ep < 0 || t->wake < 0 || add_ep(t->ep, t->wake, &TAG_WAKE, EPOLLIN) || add_ep(t->ep, R.stopfd, &TAG_STOP, EPOLLIN)) { + why = "tcp epoll"; goto fail; + } + } + pthread_attr_t at; + pthread_attr_init(&at); + pthread_attr_setstacksize(&at, 512 * 1024); + bool ok = true; + for (int i = 0; i < R.ndata && ok; i++) { + ok = pthread_create(&R.data[i].th, &at, data_main, &R.data[i]) == 0; + R.data[i].started = ok; + } + for (int i = 0; i < NWORKERS && ok; i++) { + ok = pthread_create(&R.workers[i], &at, worker_main, NULL) == 0; + if (ok) R.nworkers++; + } + if (ok) { ok = pthread_create(&R.udpth, &at, udp_main, NULL) == 0; R.udp_started = ok; } + if (ok) { ok = pthread_create(&R.acceptor, &at, acceptor_main, NULL) == 0; R.acc_started = ok; } + pthread_attr_destroy(&at); + if (!ok) { why = "pthread_create"; goto fail; } + R.running = true; + pthread_mutex_unlock(&R.life); + return 0; + +fail: + pb_core_log("relay: start failed (%s): %s", why, strerror(errno)); +fail_quiet: + teardown(); + pthread_mutex_unlock(&R.life); + return -1; +} + +void pb_relay_stop(void) +{ + pthread_mutex_lock(&R.life); + if (R.running) { + teardown(); + R.running = false; + } + pthread_mutex_unlock(&R.life); +} + +uint16_t pb_relay_tcp_port(void) { return R.tcp_port; } +uint16_t pb_relay_udp_port(void) { return R.udp_port; } + +int pb_relay_test_proxy(const struct pb_proxy *proxy, const char *host, uint16_t port, char *buf, size_t buflen) +{ + if (!buf || !buflen) return -1; + int64_t dl = now_ms() + 10000; + int fd = -1, rc = -1; + char err[256] = ""; + uint8_t left[HDR_MAX]; + size_t leftn = 0; + fd = connect_host(proxy->host, proxy->port, dl, -1, err, sizeof err); + if (fd < 0) { snprintf(buf, buflen, "Cannot reach proxy: %s", err); return -1; } + struct io x = { .fd = fd, .dl = dl, .stopfd = -1 }; + if (proxy->type == PROXY_TYPE_SOCKS5) { + struct s5dst d; + memset(&d, 0, sizeof d); + d.port = port; + struct in_addr a4; struct in6_addr a6; + if (inet_pton(AF_INET, host, &a4) == 1) { d.atyp = 1; memcpy(d.addr, &a4, 4); } + else if (inet_pton(AF_INET6, host, &a6) == 1) { d.atyp = 4; memcpy(d.addr, &a6, 16); } + else { d.atyp = 3; snprintf(d.host, sizeof d.host, "%s", host); } + rc = socks5_request(&x, proxy, 1, &d, NULL); + } else { + rc = http_connect(&x, proxy, host, port, left, &leftn); + } + if (rc) { snprintf(buf, buflen, "%s", x.err); close(fd); return -1; } + if (port != 80) { + snprintf(buf, buflen, "Tunnel established"); + close(fd); + return 0; + } + char req[600]; + int n = snprintf(req, sizeof req, "HEAD / HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", host); + char resp[1024]; + size_t got = 0; + if (io_send(&x, req, (size_t)n)) { snprintf(buf, buflen, "%s", x.err); close(fd); return -1; } + if (leftn) { got = leftn < sizeof resp ? leftn : sizeof resp - 1; memcpy(resp, left, got); } + while (!memchr(resp, '\n', got) && got < sizeof resp - 1) { + ssize_t r = io_recv(&x, resp + got, sizeof resp - 1 - got); + if (r < 0) break; + got += (size_t)r; + } + close(fd); + resp[got] = 0; + char *nl = strpbrk(resp, "\r\n"); + if (nl) *nl = 0; + if (!resp[0]) { snprintf(buf, buflen, "No response through proxy: %s", x.err); return -1; } + snprintf(buf, buflen, "%s", resp); + return 0; +} diff --git a/Linux/src/pb_rules.c b/Linux/src/pb_rules.c new file mode 100644 index 00000000..de231af5 --- /dev/null +++ b/Linux/src/pb_rules.c @@ -0,0 +1,412 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "pb_rules.h" + +static void set_err(char *err, size_t n, const char *fmt, ...) __attribute__((format(printf, 3, 4))); +static void set_err(char *err, size_t n, const char *fmt, ...) +{ + if (!err || !n) return; + va_list ap; + va_start(ap, fmt); + vsnprintf(err, n, fmt, ap); + va_end(ap); +} + +// Splits on ';' or ',' and trims whitespace. Returns the number of tokens (<= max) or -1 if there are more than max. +static int split_list(const char *s, char tokens[][256], int max) +{ + int n = 0; + while (*s) { + while (*s == ';' || *s == ',' || isspace((unsigned char)*s)) s++; + if (!*s) break; + const char *e = s; + while (*e && *e != ';' && *e != ',') e++; + const char *t = e; + while (t > s && isspace((unsigned char)t[-1])) t--; + if (n >= max) return -1; + size_t len = (size_t)(t - s); + if (len >= 256) len = 255; + memcpy(tokens[n], s, len); + tokens[n][len] = 0; + n++; + s = e; + } + return n; +} + +bool pb_glob_match(const char *p, const char *s) +{ + const char *star = NULL, *ss = s; + while (*s) { + if (*p == '*') { star = p++; ss = s; } + else if (*p == '?' || *p == *s) { p++; s++; } + else if (star) { p = star + 1; s = ++ss; } + else return false; + } + while (*p == '*') p++; + return *p == 0; +} + +static bool parse_uint(const char *s, size_t len, unsigned max, unsigned *out) +{ + if (!len || len > 5) return false; + unsigned v = 0; + for (size_t i = 0; i < len; i++) { + if (!isdigit((unsigned char)s[i])) return false; + v = v * 10 + (unsigned)(s[i] - '0'); + } + if (v > max) return false; + *out = v; + return true; +} + +static bool parse_ipv4(const char *s, uint32_t *out) +{ + struct in_addr a; + if (inet_pton(AF_INET, s, &a) != 1) return false; + *out = ntohl(a.s_addr); + return true; +} + +// "10", "*", "1-5" -> inclusive octet range +static bool parse_octet(const char *s, size_t len, uint8_t *lo, uint8_t *hi) +{ + unsigned a, b; + if (len == 1 && s[0] == '*') { *lo = 0; *hi = 255; return true; } + const char *dash = memchr(s, '-', len); + if (dash) { + if (!parse_uint(s, (size_t)(dash - s), 255, &a) || !parse_uint(dash + 1, len - (size_t)(dash - s) - 1, 255, &b) || a > b) return false; + *lo = (uint8_t)a; *hi = (uint8_t)b; + return true; + } + if (!parse_uint(s, len, 255, &a)) return false; + *lo = *hi = (uint8_t)a; + return true; +} + +static bool v6_prefix_range(const uint8_t *base, unsigned prefix, uint8_t *lo, uint8_t *hi) +{ + if (prefix > 128) return false; + for (int i = 0; i < 16; i++) { + int bits = (int)prefix - i * 8; + uint8_t mask = bits >= 8 ? 0xff : bits <= 0 ? 0 : (uint8_t)(0xff << (8 - bits)); + lo[i] = base[i] & mask; + hi[i] = base[i] | (uint8_t)~mask; + } + return true; +} + +static bool looks_like_domain(const char *tok) +{ + for (const char *c = tok; *c; c++) + if (isalpha((unsigned char)*c) || *c == '_' || *c == '?') return true; + return false; +} + +static bool parse_domain_token(const char *tok, char *out, char *err, size_t errlen) +{ + size_t n = strlen(tok); + if (n == 0 || n > 253) { set_err(err, errlen, "invalid domain pattern '%s'", tok); return false; } + for (size_t i = 0; i < n; i++) { + unsigned char c = (unsigned char)tok[i]; + if (!(isalnum(c) || c == '-' || c == '.' || c == '_' || c == '*' || c == '?')) { + set_err(err, errlen, "invalid character '%c' in domain pattern '%s'", c, tok); + return false; + } + out[i] = (char)tolower(c); + } + out[n] = 0; + if (out[0] == '.' || out[n - 1] == '.' || strstr(out, "..")) { set_err(err, errlen, "invalid domain pattern '%s'", tok); return false; } + return true; +} + +bool pb_domain_match(const char *pattern, const char *name) +{ + char lower[256]; + size_t n = strlen(name); + if (n >= sizeof lower) return false; + for (size_t i = 0; i <= n; i++) lower[i] = (char)tolower((unsigned char)name[i]); + while (n && lower[n - 1] == '.') lower[--n] = 0; // tolerate a trailing root dot + return pb_glob_match(pattern, lower); +} + +static bool parse_host_token(const char *tok, struct pb_host_pat *p, char *err, size_t errlen) +{ + memset(p, 0, sizeof *p); + if (strchr(tok, ':')) { + char buf[256]; + snprintf(buf, sizeof buf, "%s", tok); + char *slash = strchr(buf, '/'), *dash = strchr(buf, '-'); + struct in6_addr a, b; + p->kind = PB_HOST_V6_RANGE; + if (slash) { + *slash = 0; + unsigned pre; + if (inet_pton(AF_INET6, buf, &a) != 1 || !parse_uint(slash + 1, strlen(slash + 1), 128, &pre)) goto bad6; + v6_prefix_range(a.s6_addr, pre, p->lo6, p->hi6); + } else if (dash) { + *dash = 0; + if (inet_pton(AF_INET6, buf, &a) != 1 || inet_pton(AF_INET6, dash + 1, &b) != 1) goto bad6; + if (memcmp(a.s6_addr, b.s6_addr, 16) > 0) { set_err(err, errlen, "IPv6 range '%s' has start > end", tok); return false; } + memcpy(p->lo6, a.s6_addr, 16); memcpy(p->hi6, b.s6_addr, 16); + } else { + if (inet_pton(AF_INET6, buf, &a) != 1) goto bad6; + memcpy(p->lo6, a.s6_addr, 16); memcpy(p->hi6, a.s6_addr, 16); + } + return true; +bad6: + set_err(err, errlen, "invalid IPv6 address or range '%s'", tok); + return false; + } + + const char *slash = strchr(tok, '/'), *dash = strchr(tok, '-'); + uint32_t a, b; + char buf[256]; + if (slash) { + snprintf(buf, sizeof buf, "%.*s", (int)(slash - tok), tok); + unsigned pre; + if (!parse_ipv4(buf, &a) || !parse_uint(slash + 1, strlen(slash + 1), 32, &pre)) goto bad4; + uint32_t mask = pre ? 0xffffffffu << (32 - pre) : 0; + p->kind = PB_HOST_V4_RANGE; p->lo4 = a & mask; p->hi4 = a | ~mask; + return true; + } + int dots_before = 0; + for (const char *c = tok; dash && c < dash; c++) dots_before += *c == '.'; + if (dash && dots_before == 3) { + snprintf(buf, sizeof buf, "%.*s", (int)(dash - tok), tok); + if (!parse_ipv4(buf, &a) || !parse_ipv4(dash + 1, &b)) goto bad4; + if (a > b) { set_err(err, errlen, "IPv4 range '%s' has start > end", tok); return false; } + p->kind = PB_HOST_V4_RANGE; p->lo4 = a; p->hi4 = b; + return true; + } + const char *s = tok; + for (int i = 0; i < 4; i++) { + const char *e = strchr(s, '.'); + size_t len = e ? (size_t)(e - s) : strlen(s); + if ((i < 3) != (e != NULL)) goto bad4; + if (!parse_octet(s, len, &p->box_lo[i], &p->box_hi[i])) goto bad4; + s = e ? e + 1 : s + len; + } + // a box whose wildcard octets form a suffix collapses to a numeric range + { + int i = 0; + while (i < 4 && p->box_lo[i] == p->box_hi[i]) i++; + bool contiguous = true; + if (i < 4) { + for (int j = i + 1; j < 4; j++) + if (p->box_lo[j] != 0 || p->box_hi[j] != 255) contiguous = false; + } + if (contiguous) { + p->kind = PB_HOST_V4_RANGE; + p->lo4 = p->hi4 = 0; + for (int j = 0; j < 4; j++) { + p->lo4 = (p->lo4 << 8) | p->box_lo[j]; + p->hi4 = (p->hi4 << 8) | p->box_hi[j]; + } + } else { + p->kind = PB_HOST_V4_BOX; + } + } + return true; +bad4: + set_err(err, errlen, "invalid IPv4 address, range or pattern '%s'", tok); + return false; +} + +static bool parse_port_token(const char *tok, struct pb_port_range *r, char *err, size_t errlen) +{ + unsigned a, b; + const char *dash = strchr(tok, '-'); + if (dash) { + if (!parse_uint(tok, (size_t)(dash - tok), 65535, &a) || !parse_uint(dash + 1, strlen(dash + 1), 65535, &b) || a > b || a == 0) { + set_err(err, errlen, "invalid port range '%s'", tok); + return false; + } + } else { + if (!parse_uint(tok, strlen(tok), 65535, &a) || a == 0) { + set_err(err, errlen, "invalid port '%s' (1-65535)", tok); + return false; + } + b = a; + } + r->lo = (uint16_t)a; r->hi = (uint16_t)b; + return true; +} + +void pb_rule_free(struct pb_rule *r) +{ + free(r->procs); free(r->hosts); free(r->ports); free(r->domains); + r->procs = NULL; r->hosts = NULL; r->ports = NULL; r->domains = NULL; + r->nprocs = r->nhosts = r->nports = r->ndomains = 0; +} + +static bool is_any(const char *s) +{ + while (isspace((unsigned char)*s)) s++; + if (!*s) return true; + return s[0] == '*' && (s[1] == 0 || isspace((unsigned char)s[1])); +} + +bool pb_rule_parse(struct pb_rule *out, const char *process, const char *hosts, const char *ports, char *err, size_t errlen) +{ + memset(out, 0, sizeof *out); + if (!process) process = ""; + if (!hosts) hosts = ""; + if (!ports) ports = ""; + if (strlen(process) >= sizeof out->process_src || strlen(hosts) >= sizeof out->hosts_src || strlen(ports) >= sizeof out->ports_src) { + set_err(err, errlen, "pattern too long (max 511 characters)"); + return false; + } + snprintf(out->process_src, sizeof out->process_src, "%s", process); + snprintf(out->hosts_src, sizeof out->hosts_src, "%s", hosts); + snprintf(out->ports_src, sizeof out->ports_src, "%s", ports); + + char (*tok)[256] = malloc(sizeof(char[256]) * PB_MAX_PATTERNS); + if (!tok) { set_err(err, errlen, "out of memory"); return false; } + + if (is_any(process)) out->any_proc = true; + else { + int n = split_list(process, tok, PB_MAX_PATTERNS); + if (n < 0) { set_err(err, errlen, "too many process patterns (max %d)", PB_MAX_PATTERNS); goto fail; } + out->procs = malloc(sizeof(char[256]) * (size_t)(n ? n : 1)); + if (!out->procs) goto oom; + for (int i = 0; i < n; i++) { + if (!strcmp(tok[i], "*")) { out->any_proc = true; continue; } + memcpy(out->procs[out->nprocs++], tok[i], 256); + } + if (out->any_proc) { free(out->procs); out->procs = NULL; out->nprocs = 0; } + } + + if (is_any(hosts)) out->any_host = true; + else { + int n = split_list(hosts, tok, PB_MAX_PATTERNS); + if (n < 0) { set_err(err, errlen, "too many host patterns (max %d)", PB_MAX_PATTERNS); goto fail; } + out->hosts = calloc((size_t)(n ? n : 1), sizeof *out->hosts); + out->domains = malloc(sizeof(char[256]) * (size_t)(n ? n : 1)); + if (!out->hosts || !out->domains) goto oom; + for (int i = 0; i < n; i++) { + if (!strcmp(tok[i], "*")) { out->any_host = true; break; } + if (looks_like_domain(tok[i]) && !strchr(tok[i], ':')) { + if (!parse_domain_token(tok[i], out->domains[out->ndomains], err, errlen)) goto fail; + out->ndomains++; + continue; + } + if (!parse_host_token(tok[i], &out->hosts[out->nhosts], err, errlen)) goto fail; + out->nhosts++; + } + if (out->any_host) { free(out->hosts); free(out->domains); out->hosts = NULL; out->domains = NULL; out->nhosts = out->ndomains = 0; } + } + + if (is_any(ports)) out->any_port = true; + else { + int n = split_list(ports, tok, PB_MAX_PATTERNS); + if (n < 0) { set_err(err, errlen, "too many port patterns (max %d)", PB_MAX_PATTERNS); goto fail; } + out->ports = calloc((size_t)(n ? n : 1), sizeof *out->ports); + if (!out->ports) goto oom; + for (int i = 0; i < n; i++) { + if (!strcmp(tok[i], "*")) { out->any_port = true; break; } + if (!parse_port_token(tok[i], &out->ports[out->nports], err, errlen)) goto fail; + out->nports++; + } + if (out->any_port) { free(out->ports); out->ports = NULL; out->nports = 0; } + } + free(tok); + return true; +oom: + set_err(err, errlen, "out of memory"); +fail: + free(tok); + pb_rule_free(out); + return false; +} + +bool pb_rule_match_process(const struct pb_rule *r, const char *exe_path) +{ + if (r->any_proc) return true; + if (!exe_path || !*exe_path) return false; + const char *base = strrchr(exe_path, '/'); + base = base ? base + 1 : exe_path; + for (int i = 0; i < r->nprocs; i++) + if (pb_glob_match(r->procs[i], base) || pb_glob_match(r->procs[i], exe_path)) return true; + return false; +} + +bool pb_rule_match_proto(const struct pb_rule *r, int ipproto) +{ + switch (r->protocol) { + case RULE_PROTOCOL_TCP: return ipproto == IPPROTO_TCP; + case RULE_PROTOCOL_UDP: return ipproto == IPPROTO_UDP; + default: return true; + } +} + +bool pb_rule_has_domains(const struct pb_rule *r) { return r->ndomains > 0; } + +bool pb_rule_match_dest(const struct pb_rule *r, int family, const uint8_t addr16[16], uint16_t port, + const char *const *names, int nnames) +{ + if (!r->any_port) { + bool ok = false; + for (int i = 0; i < r->nports && !ok; i++) ok = port >= r->ports[i].lo && port <= r->ports[i].hi; + if (!ok) return false; + } + if (r->any_host) return true; + for (int i = 0; i < r->nhosts; i++) { + const struct pb_host_pat *h = &r->hosts[i]; + if (family == AF_INET) { + uint32_t a = ((uint32_t)addr16[12] << 24) | ((uint32_t)addr16[13] << 16) | ((uint32_t)addr16[14] << 8) | addr16[15]; + if (h->kind == PB_HOST_V4_RANGE && a >= h->lo4 && a <= h->hi4) return true; + if (h->kind == PB_HOST_V4_BOX) { + bool ok = true; + for (int j = 0; j < 4 && ok; j++) { + uint8_t o = addr16[12 + j]; + ok = o >= h->box_lo[j] && o <= h->box_hi[j]; + } + if (ok) return true; + } + } else if (h->kind == PB_HOST_V6_RANGE) { + if (memcmp(addr16, h->lo6, 16) >= 0 && memcmp(addr16, h->hi6, 16) <= 0) return true; + } + } + for (int d = 0; d < r->ndomains; d++) + for (int k = 0; k < nnames; k++) + if (pb_domain_match(r->domains[d], names[k])) return true; + return false; +} + +bool pb_implicit_direct(int family, const uint8_t a[16], uint16_t port, int ipproto, bool dns_via_proxy) +{ + if (family == AF_INET) { + if (a[12] == 127) return true; // loopback + if (a[12] >= 224) return true; // multicast + reserved + broadcast + if (a[12] == 0) return true; // 0.0.0.0/8 + } else { + static const uint8_t lo[16] = { [15] = 1 }; + if (!memcmp(a, lo, 16)) return true; // ::1 + if (a[0] == 0xff) return true; // multicast + if (a[0] == 0xfe && (a[1] & 0xc0) == 0x80) return true; // link-local (needs scope id anyway) + } + if (ipproto == IPPROTO_UDP && (port == 67 || port == 68)) return true; // DHCP + if (port == 53 && !dns_via_proxy) return true; + return false; +} + +int pb_rules_eval(const struct pb_rule *rules, size_t n, const char *exe_path, int family, const uint8_t addr16[16], + uint16_t port, int ipproto, const char *const *names, int nnames) +{ + for (size_t i = 0; i < n; i++) { + const struct pb_rule *r = &rules[i]; + if (!r->enabled) continue; + if (!pb_rule_match_proto(r, ipproto)) continue; + if (!pb_rule_match_process(r, exe_path)) continue; + if (!pb_rule_match_dest(r, family, addr16, port, names, nnames)) continue; + return (int)i; + } + return -1; +} diff --git a/Linux/src/pb_rules.h b/Linux/src/pb_rules.h new file mode 100644 index 00000000..02cec44e --- /dev/null +++ b/Linux/src/pb_rules.h @@ -0,0 +1,59 @@ +// Rule model: parsing/validation and the full userspace matcher (the reference semantics; the BPF side is a compiled subset). +#ifndef PB_RULES_H +#define PB_RULES_H + +#include "pb_internal.h" + +#define PB_MAX_PATTERNS 64 + +enum { PB_HOST_V4_RANGE = 1, PB_HOST_V4_BOX, PB_HOST_V6_RANGE }; + +struct pb_host_pat { + int kind; + uint32_t lo4, hi4; // PB_HOST_V4_RANGE, host order + uint8_t box_lo[4], box_hi[4]; // PB_HOST_V4_BOX: per-octet inclusive range (e.g. 192.168.*.* or 10.1-5.*.*) + uint8_t lo6[16], hi6[16]; // PB_HOST_V6_RANGE, network order +}; + +struct pb_port_range { uint16_t lo, hi; }; + +struct pb_rule { + uint32_t id; + bool enabled; + RuleProtocol protocol; + RuleAction action; + uint32_t proxy_id; // action == PROXY: 0 = default proxy + + char process_src[512], hosts_src[512], ports_src[512]; // original text, kept for the UI / export + + bool any_proc; + char (*procs)[256]; int nprocs; + bool any_host; + struct pb_host_pat *hosts; int nhosts; + char (*domains)[256]; int ndomains; // lower-case domain globs ("*.github.com", "example.org"), matched against DNS-learned names + bool any_port; + struct pb_port_range *ports; int nports; +}; + +// Parses and validates the three pattern strings into `out` (id/enabled/action/protocol/proxy_id are left to the caller). +// On failure returns false and writes a human readable reason to err. On success the caller must pb_rule_free(). +bool pb_rule_parse(struct pb_rule *out, const char *process, const char *hosts, const char *ports, char *err, size_t errlen); +void pb_rule_free(struct pb_rule *r); + +bool pb_glob_match(const char *pattern, const char *str); +bool pb_rule_match_process(const struct pb_rule *r, const char *exe_path); +bool pb_rule_match_proto(const struct pb_rule *r, int ipproto); +// names/nnames: domain names the DNS tap has learned for this destination address (may be NULL/0). +bool pb_rule_match_dest(const struct pb_rule *r, int family, const uint8_t addr16[16], uint16_t port, + const char *const *names, int nnames); +bool pb_rule_has_domains(const struct pb_rule *r); +bool pb_domain_match(const char *pattern, const char *name); // case-insensitive; glob if it contains * or ?, else exact + +// Destinations that are never redirected (loopback, multicast, broadcast, DHCP, DNS when !dns_via_proxy). +bool pb_implicit_direct(int family, const uint8_t addr16[16], uint16_t port, int ipproto, bool dns_via_proxy); + +// First enabled matching rule wins. Returns the index of the matching rule or -1 (no rule: DIRECT). +int pb_rules_eval(const struct pb_rule *rules, size_t n, const char *exe_path, int family, const uint8_t addr16[16], + uint16_t port, int ipproto, const char *const *names, int nnames); + +#endif diff --git a/Linux/src/tests/dns_test.c b/Linux/src/tests/dns_test.c new file mode 100644 index 00000000..928b9cf6 --- /dev/null +++ b/Linux/src/tests/dns_test.c @@ -0,0 +1,92 @@ +#include +#include +#include +#include +#include +#include "../pb_dns.h" + +static int fails; +#define CHECK(c) do { if (!(c)) { printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #c); fails++; } } while (0) + +// tiny DNS message builder ------------------------------------------------------------------------------------------- +struct msg { uint8_t b[2048]; size_t n; }; +static void w16(struct msg *m, unsigned v) { m->b[m->n++] = (uint8_t)(v >> 8); m->b[m->n++] = (uint8_t)v; } +static void w32(struct msg *m, unsigned v) { w16(m, v >> 16); w16(m, v & 0xffff); } +static size_t wname(struct msg *m, const char *name) +{ + size_t start = m->n; + while (*name) { + const char *dot = strchr(name, '.'); size_t l = dot ? (size_t)(dot - name) : strlen(name); + m->b[m->n++] = (uint8_t)l; memcpy(m->b + m->n, name, l); m->n += l; name += l + (dot ? 1 : 0); + } + m->b[m->n++] = 0; + return start; +} +static void ptr(struct msg *m, size_t at) { w16(m, 0xC000 | (unsigned)at); } +static void header(struct msg *m, unsigned flags, unsigned qd, unsigned an) { m->n = 0; w16(m, 0x1234); w16(m, flags); w16(m, qd); w16(m, an); w16(m, 0); w16(m, 0); } +static void a_rr(struct msg *m, size_t owner_off, const char *ip) { ptr(m, owner_off); w16(m, 1); w16(m, 1); w32(m, 60); w16(m, 4); struct in_addr a; inet_pton(AF_INET, ip, &a); memcpy(m->b + m->n, &a, 4); m->n += 4; } +static uint32_t H(const char *ip) { struct in_addr a; inet_pton(AF_INET, ip, &a); return ntohl(a.s_addr); } +static bool has(uint32_t ip, const char *name) { char n[8][256]; int c = pb_dns_lookup(ip, n, 8); for (int i = 0; i < c; i++) if (!strcmp(n[i], name)) return true; return false; } + +int main(void) +{ + struct msg m; struct pb_dns_result r; + + // 1. plain A answer with a compressed owner name + header(&m, 0x8180, 1, 2); size_t q = wname(&m, "www.Example.COM"); w16(&m, 1); w16(&m, 1); + a_rr(&m, q, "93.184.216.34"); a_rr(&m, q, "93.184.216.35"); + CHECK(pb_dns_observe(m.b, m.n, &r) == 2 && r.nips == 2); + CHECK(has(H("93.184.216.34"), "www.example.com") && has(H("93.184.216.35"), "www.example.com")); // lower-cased + + // 2. CNAME chain: www.shop.test -> cdn.edge.net -> A + header(&m, 0x8180, 1, 3); q = wname(&m, "www.shop.test"); w16(&m, 1); w16(&m, 1); + ptr(&m, q); w16(&m, 5); w16(&m, 1); w32(&m, 300); size_t lenpos = m.n; w16(&m, 0); size_t t = wname(&m, "cdn.edge.net"); w16(&m, 0); /* patch below */ + m.b[lenpos] = 0; m.b[lenpos + 1] = (uint8_t)(m.n - lenpos - 2 - 2); m.n -= 2; + a_rr(&m, t, "198.51.100.7"); + // fix an count (we wrote 3 but only 2 RRs) + m.b[7] = 2; + CHECK(pb_dns_observe(m.b, m.n, &r) == 1); + CHECK(has(H("198.51.100.7"), "www.shop.test") && has(H("198.51.100.7"), "cdn.edge.net")); + + // 3. rejects: query (QR=0), NXDOMAIN, no answers, loopback answers + header(&m, 0x0100, 1, 0); q = wname(&m, "x.test"); w16(&m, 1); w16(&m, 1); + CHECK(pb_dns_observe(m.b, m.n, &r) == 0); + header(&m, 0x8183, 1, 0); q = wname(&m, "x.test"); w16(&m, 1); w16(&m, 1); + CHECK(pb_dns_observe(m.b, m.n, &r) == 0); + header(&m, 0x8180, 1, 1); q = wname(&m, "local.test"); w16(&m, 1); w16(&m, 1); a_rr(&m, q, "127.0.0.9"); + pb_dns_observe(m.b, m.n, &r); CHECK(!has(H("127.0.0.9"), "local.test")); + + // 4. hostile input must not crash or loop: pointer loops, truncated records, random garbage + header(&m, 0x8180, 1, 1); m.b[m.n++] = 0xC0; m.b[m.n++] = 12; // name pointing at itself + w16(&m, 1); w16(&m, 1); CHECK(pb_dns_observe(m.b, m.n, &r) == 0); + header(&m, 0x8180, 1, 1); q = wname(&m, "t.test"); w16(&m, 1); w16(&m, 1); ptr(&m, q); w16(&m, 1); w16(&m, 1); w32(&m, 1); w16(&m, 4); m.b[m.n++] = 1; // rdata truncated + CHECK(pb_dns_observe(m.b, m.n, &r) == 0); + srand(7); + for (int i = 0; i < 200000; i++) { + size_t n = (size_t)(rand() % 200); uint8_t g[200]; + for (size_t k = 0; k < n; k++) g[k] = (uint8_t)rand(); + if (n >= 12 && (i & 1)) { g[2] = 0x81; g[3] = 0x80; g[5] = 1; g[7] = (uint8_t)(1 + rand() % 3); } + pb_dns_observe(g, n, &r); + } + + // 5. bounded table: oldest mappings are evicted and reported + pb_dns_clear(); + int evicted = 0; + for (uint32_t i = 0; i < PB_DNS_MAX_ENTRIES + 500; i++) { + header(&m, 0x8180, 1, 1); q = wname(&m, "bulk.test"); w16(&m, 1); w16(&m, 1); + ptr(&m, q); w16(&m, 1); w16(&m, 1); w32(&m, 60); w16(&m, 4); uint32_t ip = 0x0A000000u + 0x010000u + i; m.b[m.n++] = (uint8_t)(ip >> 24); m.b[m.n++] = (uint8_t)(ip >> 16); m.b[m.n++] = (uint8_t)(ip >> 8); m.b[m.n++] = (uint8_t)ip; + pb_dns_observe(m.b, m.n, &r); evicted += r.nevicted; + } + CHECK(pb_dns_count() == PB_DNS_MAX_ENTRIES); CHECK(evicted == 500); + CHECK(!has(0x0A010000u, "bulk.test") && has(0x0A010000u + 600, "bulk.test")); + + // 6. /etc/hosts style file + pb_dns_clear(); + FILE *f = fopen("/tmp/pb_dns_test_hosts", "w"); fprintf(f, "# c\n127.0.0.1 localhost\n192.0.2.44 intranet.test wiki.Intranet.test # x\n::1 ip6\nbogus line\n"); fclose(f); + CHECK(pb_dns_load_hosts("/tmp/pb_dns_test_hosts", &r) == 1); + CHECK(has(H("192.0.2.44"), "intranet.test") && has(H("192.0.2.44"), "wiki.intranet.test")); + remove("/tmp/pb_dns_test_hosts"); + + printf(fails ? "DNS TESTS FAILED (%d)\n" : "dns tests passed\n", fails); + return fails != 0; +} diff --git a/Linux/src/tests/relay_test.c b/Linux/src/tests/relay_test.c new file mode 100644 index 00000000..452d52fb --- /dev/null +++ b/Linux/src/tests/relay_test.c @@ -0,0 +1,1800 @@ +// Root-free loopback test harness for pb_relay.c: fake pb_core_* services, in-process SOCKS5 / HTTP proxies and echo servers. +// The kernel redirect is simulated by registering original destinations keyed by the client's (ip,port) or by flow address. +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../pb_internal.h" + +static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER; +#define TCP_SLOTS 65536 +static struct tcp_slot { uint64_t key; struct pb_orig o; uint8_t state; } g_tcp[TCP_SLOTS]; // 0 free, 1 used, 2 tombstone +static inline uint64_t tcp_key(uint32_t ip_be, uint16_t port) { return (uint64_t)ip_be << 16 | port; } +static inline uint32_t tcp_hash(uint64_t k) { return (uint32_t)((k * 0x9E3779B97F4A7C15ull) >> 48); } +static struct { uint32_t ip_be; struct pb_orig o; bool used; } g_flows[64]; +static struct pb_proxy g_px[16]; +static bool g_px_used[16]; +static uint32_t g_default_px = 1; +static struct pb_decision g_decision = { PB_ACT_DIRECT, 0 }; +static atomic_int g_ev, g_ev_proxy, g_ev_direct, g_ev_block, g_decide_calls; +static char g_log[64][512]; +static int g_log_n; + +int pb_core_take_tcp_orig(uint32_t ip_be, uint16_t port, struct pb_orig *out) +{ + uint64_t k = tcp_key(ip_be, port); + int ok = 0; + pthread_mutex_lock(&g_mu); + for (uint32_t i = tcp_hash(k), n = 0; n < TCP_SLOTS && g_tcp[i].state; i = (i + 1) % TCP_SLOTS, n++) { + if (g_tcp[i].state == 1 && g_tcp[i].key == k) { + *out = g_tcp[i].o; + g_tcp[i].state = g_tcp[(i + 1) % TCP_SLOTS].state ? 2 : 0; + ok = 1; + break; + } + } + pthread_mutex_unlock(&g_mu); + return ok ? 0 : -1; +} +int pb_core_get_udp_flow(uint32_t ip, struct pb_orig *out) +{ + int ok = 0; + pthread_mutex_lock(&g_mu); + for (int i = 0; i < 64; i++) + if (g_flows[i].used && g_flows[i].ip_be == ip) { *out = g_flows[i].o; ok = 1; break; } + pthread_mutex_unlock(&g_mu); + return ok ? 0 : -1; +} +void pb_core_decide(const struct pb_orig *o, struct pb_decision *out) +{ + (void)o; + atomic_fetch_add(&g_decide_calls, 1); + pthread_mutex_lock(&g_mu); + *out = g_decision; + pthread_mutex_unlock(&g_mu); +} +int pb_core_get_proxy(uint32_t id, struct pb_proxy *out) +{ + if (id == 0) id = g_default_px; + if (id >= 16 || !g_px_used[id]) return -1; + *out = g_px[id]; + return 0; +} +void pb_core_log(const char *fmt, ...) +{ + char b[512]; + va_list ap; + va_start(ap, fmt); + vsnprintf(b, sizeof b, fmt, ap); + va_end(ap); + pthread_mutex_lock(&g_mu); + snprintf(g_log[g_log_n++ % 64], 512, "%s", b); + pthread_mutex_unlock(&g_mu); + if (getenv("VERBOSE")) fprintf(stderr, " [log] %s\n", b); +} +void pb_core_flow_event(const struct pb_orig *o, int action) +{ + (void)o; + atomic_fetch_add(&g_ev, 1); + if (action == PB_ACT_PROXY) atomic_fetch_add(&g_ev_proxy, 1); + else if (action == PB_ACT_DIRECT) atomic_fetch_add(&g_ev_direct, 1); + else atomic_fetch_add(&g_ev_block, 1); +} + +struct obs { int64_t t0, t1; size_t len; uint32_t h; }; +static struct obs g_obs[64]; +static atomic_int g_obs_n, g_obs_sleep; +static int64_t mono_ms(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; +} +static uint32_t fnv(const uint8_t *p, size_t n) +{ + uint32_t h = 2166136261u; + while (n--) { h ^= *p++; h *= 16777619u; } + return h; +} +void pb_core_dns_observe(const uint8_t *m, size_t len) +{ + int64_t t0 = mono_ms(); + int sl = atomic_load(&g_obs_sleep); + if (sl) usleep((useconds_t)sl * 1000); + int i = atomic_fetch_add(&g_obs_n, 1); + if (i < 64) g_obs[i] = (struct obs){ t0, mono_ms(), len, fnv(m, len) }; +} + +static bool log_has(const char *s) +{ + bool r = false; + pthread_mutex_lock(&g_mu); + for (int i = 0; i < 64 && i < g_log_n; i++) if (strstr(g_log[i], s)) r = true; + pthread_mutex_unlock(&g_mu); + return r; +} +static void set_decision(struct pb_decision d) +{ + pthread_mutex_lock(&g_mu); + g_decision = d; + pthread_mutex_unlock(&g_mu); +} +static void log_clear(void) { pthread_mutex_lock(&g_mu); g_log_n = 0; memset(g_log, 0, sizeof g_log); pthread_mutex_unlock(&g_mu); } +static void ev_clear(void) { g_ev = g_ev_proxy = g_ev_direct = g_ev_block = 0; } + +static int g_pass, g_fail; +static const char *volatile g_cur = "startup"; +static void watchdog(int sig) +{ + (void)sig; + static const char m1[] = "\nFATAL: watchdog expired while running: "; + if (write(2, m1, sizeof m1 - 1) < 0) {} + const char *c = g_cur; + if (write(2, c, strlen(c)) < 0) {} + if (write(2, "\n", 1) < 0) {} + _exit(3); +} +static void arm_watchdog(void) +{ + const char *e = getenv("PB_TEST_WATCHDOG"); + int secs = e ? atoi(e) : 300; + signal(SIGALRM, watchdog); + alarm((unsigned)(secs > 0 ? secs : 300)); +} +#define RUN(fn) do { const char *only_ = getenv("PB_TEST_ONLY"); if (only_ && !strstr(only_, #fn)) break; g_cur = #fn; printf("---- %s\n", #fn); fn(); } while (0) +#define CHECK(cond, ...) do { if (cond) { g_pass++; printf("PASS "); } else { g_fail++; printf("FAIL "); } \ + printf(__VA_ARGS__); printf("\n"); fflush(stdout); } while (0) + +static const uint16_t TPORT = 39011, UPORT = 39012; +static atomic_int g_next_port = 20000; + +static void fill_orig(struct pb_orig *o, const char *ip, uint16_t port, int proto, int action, uint32_t px) +{ + memset(o, 0, sizeof *o); + struct in_addr a; + inet_pton(AF_INET, ip, &a); + memset(o->addr + 10, 0xff, 2); + memcpy(o->addr + 12, &a, 4); + o->port = port; o->family = AF_INET; o->proto = (uint8_t)proto; o->action = (uint8_t)action; o->proxy_id = (uint16_t)px; +} +static void set_tcp_from(uint32_t src_ip_be, uint16_t src, const char *ip, uint16_t port, int action, uint32_t px) +{ + uint64_t k = tcp_key(src_ip_be, src); + pthread_mutex_lock(&g_mu); + uint32_t i = tcp_hash(k); + while (g_tcp[i].state == 1 && g_tcp[i].key != k) i = (i + 1) % TCP_SLOTS; + g_tcp[i].key = k; + g_tcp[i].state = 1; + fill_orig(&g_tcp[i].o, ip, port, IPPROTO_TCP, action, px); + pthread_mutex_unlock(&g_mu); +} +static void set_tcp(uint16_t src, const char *ip, uint16_t port, int action, uint32_t px) +{ + set_tcp_from(htonl(INADDR_LOOPBACK), src, ip, port, action, px); +} +static int g_nflows; +static void set_flow(const char *flow_ip, const char *ip, uint16_t port, int action, uint32_t px) +{ + struct in_addr f; + inet_pton(AF_INET, flow_ip, &f); + pthread_mutex_lock(&g_mu); + int i; + for (i = 0; i < g_nflows; i++) if (g_flows[i].ip_be == f.s_addr) break; + if (i == g_nflows) g_nflows++; + g_flows[i].used = true; g_flows[i].ip_be = f.s_addr; + fill_orig(&g_flows[i].o, ip, port, IPPROTO_UDP, action, px); + pthread_mutex_unlock(&g_mu); +} +static void set_proxy_host(uint32_t id, const char *host) +{ + snprintf(g_px[id].host, sizeof g_px[id].host, "%s", host); +} +static void set_proxy(uint32_t id, ProxyType t, uint16_t port, const char *u, const char *p) +{ + memset(&g_px[id], 0, sizeof g_px[id]); + g_px[id].id = id; g_px[id].type = t; snprintf(g_px[id].name, 64, "px%u", id); + snprintf(g_px[id].host, sizeof g_px[id].host, "127.0.0.1"); + g_px[id].port = port; + if (u) snprintf(g_px[id].user, sizeof g_px[id].user, "%s", u); + if (p) snprintf(g_px[id].pass, sizeof g_px[id].pass, "%s", p); + g_px_used[id] = true; +} + +static int count_fds(void) +{ + int n = 0; + DIR *d = opendir("/proc/self/fd"); + while (readdir(d)) n++; + closedir(d); + return n - 3; +} +static int settle_fds(int target, int ms); +static int64_t now_ms(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; +} +static void msleep(int ms) { usleep((useconds_t)ms * 1000); } +static int settle_fds(int target, int ms) +{ + int64_t t0 = now_ms(); + int n = count_fds(); + while (n > target && now_ms() - t0 < ms) { msleep(50); n = count_fds(); } + return n; +} + +struct srvcfg { bool auth; char user[64], pass[64]; bool leftover; bool silent; }; +struct srv { int lfd; uint16_t port; void (*h)(int, const struct srvcfg *); struct srvcfg cfg; }; +struct hctx { int fd; struct srv *s; }; + +static atomic_int g_assoc_count, g_s5_udp_dgrams; + +static int rd_full(int fd, void *b, size_t n) +{ + uint8_t *p = b; + while (n) { ssize_t r = recv(fd, p, n, 0); if (r <= 0) return -1; p += r; n -= (size_t)r; } + return 0; +} +static int wr_full(int fd, const void *b, size_t n) +{ + const uint8_t *p = b; + while (n) { ssize_t r = send(fd, p, n, MSG_NOSIGNAL); if (r <= 0) return -1; p += r; n -= (size_t)r; } + return 0; +} + +struct pipe_arg { int a, b; }; +static void *pipe_dir(void *p) +{ + struct pipe_arg *x = p; + char *buf = malloc(262144); + ssize_t r; + while ((r = recv(x->a, buf, 262144, 0)) > 0) + if (wr_full(x->b, buf, (size_t)r)) break; + free(buf); + shutdown(x->b, SHUT_WR); + if (r < 0) shutdown(x->a, SHUT_RD); + return NULL; +} +static void pipe_both(int a, int b) +{ + struct pipe_arg x = { a, b }, y = { b, a }; + pthread_t t; + pthread_create(&t, NULL, pipe_dir, &x); + pipe_dir(&y); + pthread_join(t, NULL); +} + +static void echo_handler(int fd, const struct srvcfg *c) +{ + (void)c; + char buf[32768]; + ssize_t r; + while ((r = recv(fd, buf, sizeof buf, 0)) > 0) + if (wr_full(fd, buf, (size_t)r)) break; + shutdown(fd, SHUT_WR); +} + +static int connect_target(int atyp, const uint8_t *addr, const char *host, uint16_t port) +{ + struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(port) }; + if (atyp == 1) memcpy(&a.sin_addr, addr, 4); + else if (atyp == 3) { if (!strcmp(host, "localhost") || !strcmp(host, "echo.test")) a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); else return -1; } + else return -1; + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (connect(fd, (struct sockaddr *)&a, sizeof a) < 0) { close(fd); return -1; } + return fd; +} + +static void socks_udp_assoc(int tfd) +{ + atomic_fetch_add(&g_assoc_count, 1); + int u = socket(AF_INET, SOCK_DGRAM, 0); + struct sockaddr_in a = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK) }; + bind(u, (struct sockaddr *)&a, sizeof a); + socklen_t al = sizeof a; + getsockname(u, (struct sockaddr *)&a, &al); + uint8_t rep[10] = { 5, 0, 0, 1, 127, 0, 0, 1, (uint8_t)(ntohs(a.sin_port) >> 8), (uint8_t)ntohs(a.sin_port) }; + wr_full(tfd, rep, 10); + struct sockaddr_in cli = { 0 }; + bool have = false; + for (;;) { + struct pollfd p[2] = { { tfd, POLLIN, 0 }, { u, POLLIN, 0 } }; + if (poll(p, 2, 10000) < 0) break; + if (p[0].revents) { char b[64]; if (recv(tfd, b, sizeof b, 0) <= 0) break; } + if (p[1].revents & POLLIN) { + uint8_t b[70000]; + struct sockaddr_in from; + socklen_t fl = sizeof from; + ssize_t n = recvfrom(u, b, sizeof b, 0, (struct sockaddr *)&from, &fl); + if (n < 1) continue; + if (!have) { cli = from; have = true; } + if (from.sin_addr.s_addr == cli.sin_addr.s_addr && from.sin_port == cli.sin_port) { + if (n < 10 || b[3] != 1) continue; + struct sockaddr_in d = { .sin_family = AF_INET }; + memcpy(&d.sin_addr, b + 4, 4); memcpy(&d.sin_port, b + 8, 2); + sendto(u, b + 10, (size_t)n - 10, 0, (struct sockaddr *)&d, sizeof d); + atomic_fetch_add(&g_s5_udp_dgrams, 1); + } else { + uint8_t o[70020] = { 0, 0, 0, 1 }; + memcpy(o + 4, &from.sin_addr, 4); memcpy(o + 8, &from.sin_port, 2); + memcpy(o + 10, b, (size_t)n); + sendto(u, o, (size_t)n + 10, 0, (struct sockaddr *)&cli, sizeof cli); + } + } + } + close(u); +} + +static void socks_handler(int fd, const struct srvcfg *c) +{ + if (c->silent) { char b[16]; while (recv(fd, b, sizeof b, 0) > 0) {} return; } + uint8_t h[2], m[256]; + if (rd_full(fd, h, 2) || h[0] != 5 || rd_full(fd, m, h[1])) return; + bool has0 = false, has2 = false; + for (int i = 0; i < h[1]; i++) { if (m[i] == 0) has0 = true; if (m[i] == 2) has2 = true; } + uint8_t sel[2] = { 5, 0xFF }; + if (c->auth && has2) sel[1] = 2; else if (!c->auth && has0) sel[1] = 0; + wr_full(fd, sel, 2); + if (sel[1] == 0xFF) return; + if (sel[1] == 2) { + uint8_t v, l; char u[256], p[256]; + if (rd_full(fd, &v, 1) || rd_full(fd, &l, 1) || rd_full(fd, u, l)) return; + u[l] = 0; + if (rd_full(fd, &l, 1) || rd_full(fd, p, l)) return; + p[l] = 0; + bool ok = !strcmp(u, c->user) && !strcmp(p, c->pass); + uint8_t r[2] = { 1, ok ? 0 : 1 }; + wr_full(fd, r, 2); + if (!ok) return; + } + uint8_t rq[4]; + if (rd_full(fd, rq, 4)) return; + uint8_t addr[16] = { 0 }; + char host[256] = ""; + if (rq[3] == 1) { if (rd_full(fd, addr, 4)) return; } + else if (rq[3] == 4) { if (rd_full(fd, addr, 16)) return; } + else if (rq[3] == 3) { uint8_t l; if (rd_full(fd, &l, 1) || rd_full(fd, host, l)) return; host[l] = 0; } + uint8_t pt[2]; + if (rd_full(fd, pt, 2)) return; + uint16_t port = (uint16_t)(pt[0] << 8 | pt[1]); + if (rq[1] == 3) { socks_udp_assoc(fd); return; } + int t = connect_target(rq[3], addr, host, port); + uint8_t rep[10] = { 5, t < 0 ? 5 : 0, 0, 1, 0, 0, 0, 0, 0, 0 }; + wr_full(fd, rep, 10); + if (t < 0) return; + pipe_both(fd, t); + close(t); +} + +static void http_handler(int fd, const struct srvcfg *c) +{ + char b[4096]; + size_t n = 0; + while (n < sizeof b - 1) { + ssize_t r = recv(fd, b + n, 1, 0); + if (r <= 0) return; + n++; + if (n >= 4 && !memcmp(b + n - 4, "\r\n\r\n", 4)) break; + } + b[n] = 0; + if (c->auth) { + char want[200]; + snprintf(want, sizeof want, "Proxy-Authorization: Basic %s", c->pass); + if (!strstr(b, want)) { const char *r = "HTTP/1.1 407 Proxy Authentication Required\r\n\r\n"; wr_full(fd, r, strlen(r)); return; } + } + char host[200]; unsigned port; + if (sscanf(b, "CONNECT %199[^:]:%u", host, &port) != 2) return; + struct in_addr a; + int t = -1; + if (inet_pton(AF_INET, host, &a) == 1) t = connect_target(1, (uint8_t *)&a, "", (uint16_t)port); + else t = connect_target(3, NULL, host, (uint16_t)port); + if (t < 0) { const char *r = "HTTP/1.1 502 Bad Gateway\r\n\r\n"; wr_full(fd, r, strlen(r)); return; } + const char *ok = c->leftover ? "HTTP/1.1 200 Connection established\r\nX: y\r\n\r\nLEFTOVER" : "HTTP/1.1 200 Connection established\r\n\r\n"; + wr_full(fd, ok, strlen(ok)); + pipe_both(fd, t); + close(t); +} + +static void *conn_thread(void *p) +{ + struct hctx *x = p; + x->s->h(x->fd, &x->s->cfg); + close(x->fd); + free(x); + return NULL; +} +static void *accept_thread(void *p) +{ + struct srv *s = p; + for (;;) { + int fd = accept(s->lfd, NULL, NULL); + if (fd < 0) { if (errno == EINTR) continue; return NULL; } + struct hctx *x = malloc(sizeof *x); + x->fd = fd; x->s = s; + pthread_t t; + pthread_create(&t, NULL, conn_thread, x); + pthread_detach(t); + } +} +static struct srv *srv_start_p(void (*h)(int, const struct srvcfg *), const struct srvcfg *cfg, uint16_t port) +{ + struct srv *s = calloc(1, sizeof *s); + s->h = h; + if (cfg) s->cfg = *cfg; + s->lfd = socket(AF_INET, SOCK_STREAM, 0); + int one = 1; + setsockopt(s->lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + struct sockaddr_in a = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK), .sin_port = htons(port) }; + if (bind(s->lfd, (struct sockaddr *)&a, sizeof a) < 0) { close(s->lfd); free(s); return NULL; } + listen(s->lfd, 256); + socklen_t al = sizeof a; + getsockname(s->lfd, (struct sockaddr *)&a, &al); + s->port = ntohs(a.sin_port); + pthread_t t; + pthread_create(&t, NULL, accept_thread, s); + pthread_detach(t); + return s; +} +#define srv_start(h, c) srv_start_p(h, c, 0) + +struct udpecho { int fd; uint16_t port; }; +static void *udpecho_thread(void *p) +{ + struct udpecho *e = p; + char b[70000]; + for (;;) { + struct sockaddr_in f; + socklen_t fl = sizeof f; + ssize_t n = recvfrom(e->fd, b, sizeof b, 0, (struct sockaddr *)&f, &fl); + if (n < 0) { if (errno == EINTR) continue; return NULL; } + sendto(e->fd, b, (size_t)n, 0, (struct sockaddr *)&f, fl); + } +} +static uint16_t udpecho_start(void) +{ + struct udpecho *e = calloc(1, sizeof *e); + e->fd = socket(AF_INET, SOCK_DGRAM, 0); + int rb = 4 << 20; + setsockopt(e->fd, SOL_SOCKET, SO_RCVBUF, &rb, sizeof rb); + struct sockaddr_in a = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK) }; + bind(e->fd, (struct sockaddr *)&a, sizeof a); + socklen_t al = sizeof a; + getsockname(e->fd, (struct sockaddr *)&a, &al); + e->port = ntohs(a.sin_port); + pthread_t t; + pthread_create(&t, NULL, udpecho_thread, e); + pthread_detach(t); + return e->port; +} + +static int g_last_connect_errno; +static int relay_connect_from(uint32_t src_ip_be, uint16_t src) +{ + int fd = socket(AF_INET, SOCK_STREAM, 0); + int one = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + struct sockaddr_in a = { .sin_family = AF_INET, .sin_addr.s_addr = src_ip_be, .sin_port = htons(src) }; + if (bind(fd, (struct sockaddr *)&a, sizeof a) < 0) { + g_last_connect_errno = errno; + printf(" relay_connect: bind(127.0.0.1:%u) failed: %s\n", src, strerror(errno)); + close(fd); return -1; + } + a.sin_port = htons(pb_relay_tcp_port()); + a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + struct timeval tv = { 10, 0 }; + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); + if (connect(fd, (struct sockaddr *)&a, sizeof a) < 0) { + g_last_connect_errno = errno; + // an ECONNRESET here is legitimate for flows the relay aborts (BLOCK, cap): on a fast loopback the RST can arrive + // before connect() returns. Callers that expect a close accept it through closed_by_relay(). + if (errno != ECONNRESET) + printf(" relay_connect: connect(src port %u -> relay port %u) failed: %s\n", src, pb_relay_tcp_port(), strerror(errno)); + close(fd); return -1; + } + tv.tv_sec = 30; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); + return fd; +} +static int relay_connect(uint16_t src) { return relay_connect_from(htonl(INADDR_LOOPBACK), src); } +static uint16_t new_src(void) { return (uint16_t)atomic_fetch_add(&g_next_port, 1); } + +static inline uint8_t pat(uint64_t i) { return (uint8_t)((i * 0x9E3779B1u) >> 11); } + +struct xf { int fd; uint64_t total; bool half; }; +static void *xf_send(void *p) +{ + struct xf *x = p; + uint8_t buf[65536]; + uint64_t off = 0; + while (off < x->total) { + size_t n = x->total - off > sizeof buf ? sizeof buf : (size_t)(x->total - off); + for (size_t j = 0; j < n; j++) buf[j] = pat(off + j); + if (wr_full(x->fd, buf, n)) break; + off += n; + } + if (x->half) shutdown(x->fd, SHUT_WR); + return NULL; +} +static int64_t xfer(int fd, uint64_t total, bool half) +{ + struct xf x = { fd, total, half }; + pthread_t t; + pthread_create(&t, NULL, xf_send, &x); + uint8_t buf[65536]; + uint64_t got = 0; + int64_t res = 0; + for (;;) { + if (!half && got >= total) break; + ssize_t r = recv(fd, buf, sizeof buf, 0); + if (r <= 0) break; + for (ssize_t j = 0; j < r; j++) if (buf[j] != pat(got + (uint64_t)j)) { res = -1; goto out; } + got += (uint64_t)r; + } + res = (int64_t)got; +out: + if (res < 0) shutdown(fd, SHUT_RDWR); + pthread_join(t, NULL); + return res; +} +static bool expect_closed(int fd, int timeout_ms) +{ + if (timeout_ms < 10000) timeout_ms = 10000; + struct pollfd p = { fd, POLLIN, 0 }; + int pr = poll(&p, 1, timeout_ms); + if (pr <= 0) { printf(" expect_closed(fd=%d): poll=%d errno=%d revents=0x%x\n", fd, pr, errno, p.revents); return false; } + char b[64]; + ssize_t r = recv(fd, b, sizeof b, 0); + int e = errno; + bool ok = r == 0 || (r < 0 && e != EAGAIN); + if (!ok) printf(" expect_closed(fd=%d): poll=%d revents=0x%x recv=%zd errno=%d\n", fd, pr, p.revents, r, e); + return ok; +} + +// True when the relay closed the connection: either connect() itself was reset (the relay aborted the flow before connect() +// returned, which happens on a fast loopback) or the client sees EOF/RST afterwards. +static bool closed_by_relay(int fd, int timeout_ms) +{ + if (fd < 0) return g_last_connect_errno == ECONNRESET; + return expect_closed(fd, timeout_ms); +} + +static int relay_up(int tcp_idle, int udp_idle, int maxc, int hs) +{ + struct pb_relay_cfg c = { TPORT, UPORT, maxc, tcp_idle, udp_idle, hs }; + return pb_relay_start(&c); +} + +static uint16_t free_port(void) +{ + int fd = socket(AF_INET, SOCK_STREAM, 0); + struct sockaddr_in a = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK) }; + bind(fd, (struct sockaddr *)&a, sizeof a); + socklen_t al = sizeof a; + getsockname(fd, (struct sockaddr *)&a, &al); + close(fd); + return ntohs(a.sin_port); +} + +static int64_t tcp_roundtrip(const char *dst_ip, uint16_t dst_port, int action, uint32_t px, uint64_t n, bool half) +{ + uint16_t src = new_src(); + set_tcp(src, dst_ip, dst_port, action, px); + int fd = relay_connect(src); + if (fd < 0) return -2; + int64_t r = xfer(fd, n, half); + close(fd); + return r; +} + +static int udp_client(void) +{ + int fd = socket(AF_INET, SOCK_DGRAM, 0); + struct sockaddr_in a = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK) }; + bind(fd, (struct sockaddr *)&a, sizeof a); + return fd; +} +static void udp_send_flow(int fd, const char *flow_ip, const char *msg) +{ + struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(pb_relay_udp_port()) }; + inet_pton(AF_INET, flow_ip, &a.sin_addr); + sendto(fd, msg, strlen(msg), 0, (struct sockaddr *)&a, sizeof a); +} +static int udp_recv_to(int fd, char *buf, size_t n, char *src_ip, uint16_t *src_port, int ms) +{ + struct timeval tv = { ms / 1000, (ms % 1000) * 1000 }; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + struct sockaddr_in f; + socklen_t fl = sizeof f; + ssize_t r = recvfrom(fd, buf, n - 1, 0, (struct sockaddr *)&f, &fl); + if (r < 0) return -1; + buf[r] = 0; + inet_ntop(AF_INET, &f.sin_addr, src_ip, 32); + *src_port = ntohs(f.sin_port); + return (int)r; +} +static int udp_recv(int fd, char *buf, size_t n, char *ip, uint16_t *sp) { return udp_recv_to(fd, buf, n, ip, sp, 6000); } +static int udp_recv_neg(int fd, char *buf, size_t n, char *ip, uint16_t *sp) { return udp_recv_to(fd, buf, n, ip, sp, 1500); } +static bool udp_echo_ok(int fd, const char *flow_ip, const char *msg) +{ + char b[256], ip[32]; + uint16_t sp; + udp_send_flow(fd, flow_ip, msg); + int r = udp_recv(fd, b, sizeof b, ip, &sp); + return r == (int)strlen(msg) && !strcmp(b, msg) && !strcmp(ip, flow_ip) && sp == pb_relay_udp_port(); +} + +static struct srv *g_echo, *g_s5, *g_s5auth, *g_http, *g_httpauth, *g_httpleft, *g_silent; +static uint16_t g_uecho1, g_uecho2; + +static void *conc_thread(void *p) +{ + int idx = (int)(intptr_t)p; + uint64_t mb = getenv("LARGE_MB") ? strtoull(getenv("LARGE_MB"), NULL, 10) : 8; + int mode = idx % 3; + int64_t r = tcp_roundtrip("127.0.0.1", g_echo->port, mode == 2 ? PB_ACT_DIRECT : PB_ACT_PROXY, mode == 0 ? 1 : (mode == 1 ? 3 : 0), + mb << 20, false); + return (void *)(intptr_t)(r == (int64_t)(mb << 20)); +} + +static long vmrss_kb(void) +{ + FILE *f = fopen("/proc/self/status", "r"); + char line[256]; + long v = -1; + while (f && fgets(line, sizeof line, f)) + if (!strncmp(line, "VmRSS:", 6)) { v = atol(line + 6); break; } + if (f) fclose(f); + return v; +} + +static atomic_uint g_churn_ctr; +static atomic_int g_churn_bad, g_churn_udp_lost; +static uint16_t g_dead_port; + +static void churn_one(unsigned n) +{ + uint32_t ip = htonl(0x7f000002 + n % 60); + uint16_t src = (uint16_t)(20000 + (n / 60) % 12000); + int mode = (int)(n % 9); + uint32_t px = 0; + int action = PB_ACT_PROXY; + uint16_t dport = g_echo->port; + switch (mode) { + case 0: px = 1; break; + case 1: px = 3; break; + case 2: action = PB_ACT_DIRECT; break; + case 3: action = PB_ACT_BLOCK; break; + case 4: px = 6; break; + case 5: px = 7; break; + case 6: px = 1; break; + case 7: action = PB_ACT_DIRECT; dport = g_dead_port; break; + default: action = PB_ACT_DIRECT; break; + } + set_tcp_from(ip, src, "127.0.0.1", dport, action, px); + int fd = relay_connect_from(ip, src); + if (fd < 0) { + if (mode != 3 && mode != 4 && mode != 5 && mode != 7) atomic_fetch_add(&g_churn_bad, 1); + return; + } + char b[128]; + switch (mode) { + case 0: case 1: case 2: + if (send(fd, "z", 1, MSG_NOSIGNAL) != 1 || rd_full(fd, b, 1) || b[0] != 'z') atomic_fetch_add(&g_churn_bad, 1); + break; + case 6: { + struct linger l = { 1, 0 }; + setsockopt(fd, SOL_SOCKET, SO_LINGER, &l, sizeof l); + break; + } + case 8: { + char big[100] = { 0 }; + send(fd, big, sizeof big, MSG_NOSIGNAL); + struct linger l = { 1, 0 }; + setsockopt(fd, SOL_SOCKET, SO_LINGER, &l, sizeof l); + break; + } + default: + (void)expect_closed(fd, 10000); + } + close(fd); +} + +struct churn_arg { unsigned count; }; +static void *churn_tcp_thread(void *p) +{ + struct churn_arg *a = p; + for (unsigned i = 0; i < a->count; i++) churn_one(atomic_fetch_add(&g_churn_ctr, 1)); + return NULL; +} + +struct churn_udp { unsigned total; int bad; }; +static void *churn_udp_thread(void *p) +{ + struct churn_udp *a = p; + enum { B = 1000 }; + int *fds = malloc(B * sizeof *fds); + for (unsigned done = 0; done < a->total;) { + int n = a->total - done < B ? (int)(a->total - done) : B; + for (int lo = 0; lo < n; lo += 50) { // paced so a starved echo server cannot overflow its socket buffer + int hi = lo + 50 < n ? lo + 50 : n; + for (int i = lo; i < hi; i++) { + fds[i] = udp_client(); + udp_send_flow(fds[i], (done + (unsigned)i) % 10 == 0 ? "127.1.9.2" : "127.1.9.1", "churn"); + } + for (int i = lo; i < hi; i++) { + char r[32], ip[32]; + uint16_t sp; + if (udp_recv_to(fds[i], r, sizeof r, ip, &sp, 4000) != 5) a->bad++; + close(fds[i]); + } + } + done += (unsigned)n; + if (done < a->total) msleep(2200); + } + free(fds); + return NULL; +} + +static void churn_phase(unsigned tcp_total, unsigned udp_total) +{ + enum { T = 8 }; + pthread_t th[T], ut; + struct churn_arg ca = { tcp_total / T }; + struct churn_udp cu = { udp_total, 0 }; + pthread_create(&ut, NULL, churn_udp_thread, &cu); + for (int i = 0; i < T; i++) pthread_create(&th[i], NULL, churn_tcp_thread, &ca); + for (int i = 0; i < T; i++) pthread_join(th[i], NULL); + pthread_join(ut, NULL); + atomic_fetch_add(&g_churn_udp_lost, cu.bad); +} + +static void test_churn(void) +{ + unsigned tcp_total = getenv("PB_CHURN_TCP") ? (unsigned)atoi(getenv("PB_CHURN_TCP")) : 100000; + unsigned udp_total = getenv("PB_CHURN_UDP") ? (unsigned)atoi(getenv("PB_CHURN_UDP")) : 20000; + long limit_kb = (getenv("PB_CHURN_RSS_MB") ? atol(getenv("PB_CHURN_RSS_MB")) : 6) * 1024; + pb_relay_stop(); + CHECK(relay_up(300, 1, 0, 5) == 0, "restart relay for churn (udp_idle=1s)"); + g_dead_port = free_port(); + set_proxy(6, PROXY_TYPE_SOCKS5, g_dead_port, NULL, NULL); + set_proxy(7, PROXY_TYPE_SOCKS5, g_s5auth->port, "alice", "WRONG"); + set_flow("127.1.9.1", "127.0.0.1", g_uecho1, PB_ACT_DIRECT, 0); + set_flow("127.1.9.2", "127.0.0.1", g_uecho2, PB_ACT_PROXY, 1); + msleep(300); + int base_fds = count_fds(); + + unsigned chunk_tcp = tcp_total / 10 / 8 * 8, chunk_udp = udp_total / 10; + long budget_ms = (getenv("PB_CHURN_BUDGET_S") ? atol(getenv("PB_CHURN_BUDGET_S")) : 60) * 1000; + int64_t t0 = now_ms(); + churn_phase(chunk_tcp, chunk_udp); + msleep(3000); + long rss0 = vmrss_kb(); + int chunks = 1; + while (chunks < 10 && now_ms() - t0 < budget_ms) { churn_phase(chunk_tcp, chunk_udp); chunks++; } + msleep(3500); + int fds_end = settle_fds(base_fds, 10000); + long rss1 = vmrss_kb(); + unsigned tcp_done = chunk_tcp * (unsigned)chunks, udp_done = chunk_udp * (unsigned)chunks; + printf(" churn: %u TCP + %u UDP sessions in %lld ms; RSS after warm-up %ld KB, final %ld KB (+%ld KB); fds base=%d end=%d\n", + tcp_done, udp_done, (long long)(now_ms() - t0), rss0, rss1, rss1 - rss0, base_fds, fds_end); + CHECK(chunks >= 4, "churn: completed %d of 10 chunks within the time budget", chunks); + CHECK(g_churn_bad == 0, "churn: every TCP flow that must succeed did (failures=%d)", (int)g_churn_bad); + CHECK(g_churn_udp_lost * 50 <= (int)udp_done, "churn: UDP datagrams lost <= 2%% (%d of %u; session-limit drop logged: %s)", (int)g_churn_udp_lost, udp_done, + log_has("UDP session limit") ? "yes" : "no"); + CHECK(fds_end <= base_fds, "churn: fd count back to baseline (base=%d end=%d)", base_fds, fds_end); + CHECK(rss1 - rss0 < limit_kb, "churn: RSS growth after warm-up < %ld MB (+%ld KB)", limit_kb / 1024, rss1 - rss0); + pb_relay_stop(); + CHECK(relay_up(300, 60, 0, 5) == 0, "restart relay"); +} + +static void *perf_conn_thread(void *p) +{ + int n = (int)(intptr_t)p, ok = 0; + for (int i = 0; i < n; i++) { + uint16_t src = new_src(); + set_tcp(src, "127.0.0.1", g_echo->port, PB_ACT_PROXY, 1); + int fd = relay_connect(src); + char b = 'a'; + if (fd >= 0 && send(fd, &b, 1, MSG_NOSIGNAL) == 1 && recv(fd, &b, 1, 0) == 1) ok++; + if (fd >= 0) close(fd); + } + return (void *)(intptr_t)ok; +} + +static void test_perf(void) +{ + unsigned dg = getenv("PB_PERF_UDP") ? (unsigned)atoi(getenv("PB_PERF_UDP")) : 60000; + int conns = getenv("PB_PERF_CONNS") ? atoi(getenv("PB_PERF_CONNS")) : 1500; + set_flow("127.1.10.1", "127.0.0.1", g_uecho1, PB_ACT_DIRECT, 0); + set_flow("127.1.10.2", "127.0.0.1", g_uecho2, PB_ACT_PROXY, 1); + const char *fl[2] = { "127.1.10.1", "127.1.10.2" }; + const char *nm[2] = { "DIRECT", "SOCKS5" }; + for (int k = 0; k < 2; k++) { + int c = udp_client(); + struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(pb_relay_udp_port()) }; + inet_pton(AF_INET, fl[k], &a.sin_addr); + char msg[64] = "perf-datagram", rx[128], ip[32]; + uint16_t sp; + udp_echo_ok(c, fl[k], "warm"); + unsigned got = 0; + int64_t t0 = now_ms(); + for (unsigned sent = 0; sent < dg; sent += 16) { + for (int i = 0; i < 16; i++) sendto(c, msg, sizeof msg, 0, (struct sockaddr *)&a, sizeof a); + for (int i = 0; i < 16; i++) if (udp_recv_to(c, rx, sizeof rx, ip, &sp, 2000) > 0) got++; + } + int64_t ms = now_ms() - t0; + CHECK(got >= dg * 99 / 100, "perf UDP %s: %u datagrams round trip, %.0f pps (%lld ms, %u received)", nm[k], dg, + (double)got * 1000.0 / (double)(ms + 1), (long long)ms, got); + close(c); + } + enum { T = 8 }; + pthread_t th[T]; + int64_t t0 = now_ms(); + for (int i = 0; i < T; i++) pthread_create(&th[i], NULL, perf_conn_thread, (void *)(intptr_t)(conns / T)); + int ok = 0; + for (int i = 0; i < T; i++) { void *r; pthread_join(th[i], &r); ok += (int)(intptr_t)r; } + int64_t ms = now_ms() - t0; + CHECK(ok == conns / T * T, "perf TCP via SOCKS5: %d short connections, %.0f conn/s (%lld ms)", ok, (double)ok * 1000.0 / (double)(ms + 1), (long long)ms); +} + +static void test_tcp_basic(void) +{ + ev_clear(); + CHECK(tcp_roundtrip("127.0.0.1", g_echo->port, PB_ACT_PROXY, 1, 100000, false) == 100000, "TCP via SOCKS5 (no auth)"); + CHECK(g_ev == 1 && g_ev_proxy == 1, "flow_event exactly once for proxied flow (got %d)", (int)g_ev); + CHECK(tcp_roundtrip("127.0.0.1", g_echo->port, PB_ACT_PROXY, 2, 100000, false) == 100000, "TCP via SOCKS5 (user/pass auth)"); + CHECK(tcp_roundtrip("127.0.0.1", g_echo->port, PB_ACT_PROXY, 3, 100000, false) == 100000, "TCP via HTTP CONNECT"); + CHECK(tcp_roundtrip("127.0.0.1", g_echo->port, PB_ACT_PROXY, 4, 100000, false) == 100000, "TCP via HTTP CONNECT with Basic auth"); + CHECK(tcp_roundtrip("127.0.0.1", g_echo->port, PB_ACT_PROXY, 0, 5000, false) == 5000, "TCP via default proxy (id 0)"); + ev_clear(); + CHECK(tcp_roundtrip("127.0.0.1", g_echo->port, PB_ACT_DIRECT, 0, 100000, false) == 100000, "TCP DIRECT"); + CHECK(g_ev == 1 && g_ev_direct == 1, "flow_event for direct"); + + uint16_t src = new_src(); + set_tcp(src, "127.0.0.1", g_echo->port, PB_ACT_PROXY, 5); + int fd = relay_connect(src); + char b[16] = { 0 }; + bool ok = fd >= 0 && rd_full(fd, b, 8) == 0 && !memcmp(b, "LEFTOVER", 8); + CHECK(ok, "HTTP CONNECT leftover bytes forwarded to client"); + if (fd >= 0) close(fd); + + ev_clear(); + src = new_src(); + set_tcp(src, "127.0.0.1", g_echo->port, PB_ACT_BLOCK, 0); + fd = relay_connect(src); + send(fd, "x", 1, MSG_NOSIGNAL); + CHECK(closed_by_relay(fd, 3000), "TCP BLOCK closes client"); + CHECK(g_ev_block == 1, "flow_event BLOCK"); + close(fd); + + set_decision((struct pb_decision){ PB_ACT_DIRECT, 0 }); + CHECK(tcp_roundtrip("127.0.0.1", g_echo->port, PB_ACT_DECIDE, 0, 5000, false) == 5000, "TCP DECIDE -> DIRECT"); + set_decision((struct pb_decision){ PB_ACT_PROXY, 1 }); + CHECK(tcp_roundtrip("127.0.0.1", g_echo->port, PB_ACT_DECIDE, 0, 5000, false) == 5000, "TCP DECIDE -> PROXY"); + set_decision((struct pb_decision){ PB_ACT_BLOCK, 0 }); + src = new_src(); + set_tcp(src, "127.0.0.1", g_echo->port, PB_ACT_DECIDE, 0); + fd = relay_connect(src); + CHECK(closed_by_relay(fd, 3000), "TCP DECIDE -> BLOCK"); + close(fd); + + fd = relay_connect(new_src()); + CHECK(closed_by_relay(fd, 3000), "TCP without original destination is closed"); + close(fd); + +} + +static void test_tcp_identity(void) +{ + uint16_t src = new_src(); + set_tcp(src, "127.0.0.1", g_echo->port, PB_ACT_DIRECT, 0); + int fd = relay_connect_from(htonl(0x7f000002), src); + if (fd >= 0) send(fd, "x", 1, MSG_NOSIGNAL); + CHECK(closed_by_relay(fd, 3000), "connection from 127.0.0.2 with a registered port is closed"); + if (fd >= 0) close(fd); + fd = relay_connect(src); + char b = 0; + bool ok = fd >= 0 && send(fd, "y", 1, MSG_NOSIGNAL) == 1 && rd_full(fd, &b, 1) == 0 && b == 'y'; + CHECK(ok, "registered flow from the right source address still works (entry not consumed by the impostor)"); + if (fd >= 0) close(fd); + fd = relay_connect_from(htonl(0x7f000003), new_src()); + CHECK(closed_by_relay(fd, 3000), "connection from an unregistered address/port is closed"); + if (fd >= 0) close(fd); +} + +static void test_tcp_bad_proxy(void) +{ + log_clear(); + uint16_t dead = free_port(); + set_proxy(6, PROXY_TYPE_SOCKS5, dead, NULL, NULL); + uint16_t src = new_src(); + set_tcp(src, "127.0.0.1", g_echo->port, PB_ACT_PROXY, 6); + int fd = relay_connect(src); + CHECK(closed_by_relay(fd, 5000) && log_has("refused"), "bad proxy (connection refused): client closed + logged"); + close(fd); + + log_clear(); + set_proxy(7, PROXY_TYPE_SOCKS5, g_s5auth->port, "alice", "WRONG"); + src = new_src(); + set_tcp(src, "127.0.0.1", g_echo->port, PB_ACT_PROXY, 7); + fd = relay_connect(src); + CHECK(closed_by_relay(fd, 5000) && log_has("authentication failed"), "SOCKS5 wrong password: client closed + logged"); + close(fd); + + log_clear(); + set_proxy(8, PROXY_TYPE_HTTP, g_httpauth->port, "alice", "WRONG"); + src = new_src(); + set_tcp(src, "127.0.0.1", g_echo->port, PB_ACT_PROXY, 8); + fd = relay_connect(src); + CHECK(closed_by_relay(fd, 5000) && log_has("407"), "HTTP 407: client closed + logged"); + close(fd); + + log_clear(); + src = new_src(); + set_tcp(src, "127.0.0.1", g_echo->port, PB_ACT_PROXY, 12); + fd = relay_connect(src); + CHECK(closed_by_relay(fd, 5000) && log_has("no proxy configured"), "unknown proxy id: client closed + logged"); + close(fd); + + log_clear(); + src = new_src(); + set_tcp(src, "127.0.0.1", dead, PB_ACT_PROXY, 1); + fd = relay_connect(src); + CHECK(closed_by_relay(fd, 5000) && log_has("connection refused by target"), "SOCKS5 REP code mapped in log"); + close(fd); +} + +static void sink_handler(int fd, const struct srvcfg *c) +{ + (void)c; + char *buf = malloc(262144); + uint64_t total = 0; + ssize_t r; + while ((r = recv(fd, buf, 262144, 0)) > 0) total += (uint64_t)r; + free(buf); + wr_full(fd, &total, 8); +} +static struct srv *g_sink; +struct thru { int action; uint32_t px; uint64_t bytes; int ok; }; +static void *thru_thread(void *p) +{ + struct thru *t = p; + uint16_t src = new_src(); + set_tcp(src, "127.0.0.1", g_sink->port, t->action, t->px); + int fd = relay_connect(src); + static const char z[65536]; + uint64_t sent = 0, total = 0; + while (fd >= 0 && sent < t->bytes) { if (wr_full(fd, z, sizeof z)) break; sent += sizeof z; } + if (fd >= 0) { shutdown(fd, SHUT_WR); t->ok = rd_full(fd, &total, 8) == 0 && total == sent && sent == t->bytes; close(fd); } + return NULL; +} +static void test_throughput(void) +{ + uint64_t mb = getenv("THRU_MB") ? strtoull(getenv("THRU_MB"), NULL, 10) : 256; + struct { const char *name; int action; uint32_t px; } m[] = { { "DIRECT via relay", PB_ACT_DIRECT, 0 }, { "SOCKS5 via relay + fake proxy", PB_ACT_PROXY, 1 } }; + for (int k = 0; k < 2; k++) { + enum { N = 8 }; + pthread_t th[N]; + struct thru t[N]; + int64_t t0 = now_ms(); + for (int i = 0; i < N; i++) { t[i] = (struct thru){ m[k].action, m[k].px, mb << 20, 0 }; pthread_create(&th[i], NULL, thru_thread, &t[i]); } + int ok = 0; + for (int i = 0; i < N; i++) { pthread_join(th[i], NULL); ok += t[i].ok; } + int64_t ms = now_ms() - t0; + CHECK(ok == N, "throughput %s: %d x %lluMB, %.0f MB/s aggregate (%lld ms)", m[k].name, N, (unsigned long long)mb, + (double)(N * mb) * 1000.0 / (double)(ms + 1), (long long)ms); + } +} + +static void test_tcp_halfclose(void) +{ + CHECK(tcp_roundtrip("127.0.0.1", g_echo->port, PB_ACT_PROXY, 1, 300000, true) == 300000, "half-close via SOCKS5: all echoed then EOF"); + CHECK(tcp_roundtrip("127.0.0.1", g_echo->port, PB_ACT_PROXY, 3, 300000, true) == 300000, "half-close via HTTP CONNECT"); + CHECK(tcp_roundtrip("127.0.0.1", g_echo->port, PB_ACT_DIRECT, 0, 300000, true) == 300000, "half-close DIRECT"); +} + +static void test_tcp_large(void) +{ + uint64_t mb = getenv("LARGE_MB") ? strtoull(getenv("LARGE_MB"), NULL, 10) : 8; + int64_t t0 = now_ms(); + CHECK(tcp_roundtrip("127.0.0.1", g_echo->port, PB_ACT_PROXY, 1, 50ull << 20, false) == (int64_t)(50ull << 20), "50MB single transfer via SOCKS5, checksum ok (%lld ms, %.0f MB/s each way)", (long long)(now_ms() - t0), 50000.0 / (double)(now_ms() - t0 + 1)); + enum { N = 50 }; + pthread_t th[N]; + t0 = now_ms(); + for (int i = 0; i < N; i++) pthread_create(&th[i], NULL, conc_thread, (void *)(intptr_t)i); + int ok = 0; + for (int i = 0; i < N; i++) { void *r; pthread_join(th[i], &r); ok += (int)(intptr_t)r; } + CHECK(ok == N, "%d concurrent x %lluMB (socks5/http/direct mix) integrity: %d/%d ok (%lld ms)", N, (unsigned long long)mb, ok, N, (long long)(now_ms() - t0)); +} + +// Single-threaded SOCKS5 "hold" server: accepts any CONNECT and then just swallows data, so 10k connections cost +// the test process almost nothing and the RSS delta mostly reflects the relay. +static atomic_int g_hold_ready; +static void *hold_thread(void *arg) +{ + int lfd = (int)(intptr_t)arg; + enum { MAXFD = 1 << 18 }; + uint8_t *stage = calloc(MAXFD, 1); + int ep = epoll_create1(0); + struct epoll_event ev = { .events = EPOLLIN, .data.fd = lfd }; + epoll_ctl(ep, EPOLL_CTL_ADD, lfd, &ev); + for (;;) { + struct epoll_event evs[128]; + int n = epoll_wait(ep, evs, 128, -1); + for (int i = 0; i < n; i++) { + int fd = evs[i].data.fd; + if (fd == lfd) { + int c; + while ((c = accept4(lfd, NULL, NULL, SOCK_NONBLOCK)) >= 0) { + if (c >= MAXFD) { close(c); continue; } + stage[c] = 0; + struct epoll_event e = { .events = EPOLLIN, .data.fd = c }; + epoll_ctl(ep, EPOLL_CTL_ADD, c, &e); + } + continue; + } + uint8_t b[4096]; + ssize_t r; + if (stage[fd] == 0) { + r = recv(fd, b, 3, 0); + if (r == 3) { send(fd, "\5\0", 2, MSG_NOSIGNAL); stage[fd] = 1; continue; } + } else if (stage[fd] == 1) { + r = recv(fd, b, 10, 0); + if (r == 10) { + uint8_t rep[10] = { 5, 0, 0, 1 }; + send(fd, rep, 10, MSG_NOSIGNAL); + stage[fd] = 2; + atomic_fetch_add(&g_hold_ready, 1); + continue; + } + } else { + r = recv(fd, b, sizeof b, 0); + if (r > 0) continue; + } + if (r < 0 && errno == EAGAIN) continue; + close(fd); + } + } + return NULL; +} + +static long rss_kb(void) +{ + long pages = 0, rss = 0; + FILE *f = fopen("/proc/self/statm", "r"); + if (f) { if (fscanf(f, "%ld %ld", &pages, &rss) != 2) rss = 0; fclose(f); } + return rss * (sysconf(_SC_PAGESIZE) / 1024); +} + +static void test_tcp_10k(void) +{ + enum { N = 10000 }; + struct rlimit rl; + getrlimit(RLIMIT_NOFILE, &rl); + rl.rlim_cur = rl.rlim_max < 200000 ? rl.rlim_max : 200000; + setrlimit(RLIMIT_NOFILE, &rl); + int lfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); + int one = 1; + setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + struct sockaddr_in a = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK) }; + bind(lfd, (struct sockaddr *)&a, sizeof a); + listen(lfd, 4096); + socklen_t al = sizeof a; + getsockname(lfd, (struct sockaddr *)&a, &al); + pthread_t t; + pthread_create(&t, NULL, hold_thread, (void *)(intptr_t)lfd); + pthread_detach(t); + set_proxy(10, PROXY_TYPE_SOCKS5, ntohs(a.sin_port), NULL, NULL); + + pb_relay_stop(); + CHECK(relay_up(300, 60, 0, 10) == 0, "restart relay with default limits for 10k test"); + msleep(300); + atomic_store(&g_next_port, 21000); + atomic_store(&g_hold_ready, 0); + int base_fds = count_fds(); + long rss0 = rss_kb(); + int *fds = malloc(N * sizeof *fds); + for (int i = 0; i < N; i++) fds[i] = -1; + int opened = 0, consec_fail = 0; + bool stalled = false; + for (int i = 0; i < N && !stalled; i++) { + fds[i] = -1; + for (int tries = 0; tries < 5 && fds[i] < 0; tries++) { + uint16_t src = new_src(); + set_tcp(src, "127.0.0.1", 9, PB_ACT_PROXY, 10); + fds[i] = relay_connect(src); + } + if (fds[i] >= 0) { opened++; consec_fail = 0; } + else if (++consec_fail >= 20) { printf(" 10k test: 20 consecutive connect failures at i=%d (%s), giving up\n", i, strerror(errno)); stalled = true; } + if (i % 100 == 99) { // pace so the bounded handshake queue is not what we measure + int64_t t0 = now_ms(); + while (atomic_load(&g_hold_ready) < i - 200 && now_ms() - t0 < 30000) msleep(2); + if (atomic_load(&g_hold_ready) < i - 200) { printf(" 10k test: handshakes stalled at i=%d (ready=%d), giving up\n", i, (int)g_hold_ready); stalled = true; } + } + } + int64_t t0 = now_ms(); + while (!stalled && atomic_load(&g_hold_ready) < N && now_ms() - t0 < 60000) msleep(20); + msleep(500); + long rss1 = rss_kb(); + int alive = 0; + for (int i = 0; i < N; i += 100) { + struct pollfd p = { fds[i], POLLIN, 0 }; + if (fds[i] >= 0 && poll(&p, 1, 0) == 0) alive++; + } + CHECK(opened == N && g_hold_ready == N, "10000 idle connections established through SOCKS5 (opened=%d handshaken=%d)", opened, (int)g_hold_ready); + CHECK(alive == N / 100, "sampled idle connections still open (%d/%d)", alive, N / 100); + printf(" RSS before=%ld KB after=%ld KB delta=%ld MB\n", rss0, rss1, (rss1 - rss0) / 1024); + CHECK((rss1 - rss0) / 1024 < 100, "RSS delta for 10k idle connections < 100MB (%ld MB)", (rss1 - rss0) / 1024); + for (int i = 0; i < N; i++) if (fds[i] >= 0) close(fds[i]); + free(fds); + t0 = now_ms(); + while (count_fds() > base_fds + 1 && now_ms() - t0 < 20000) msleep(50); + CHECK(count_fds() <= base_fds + 1, "all fds released after closing 10k connections (base=%d now=%d)", base_fds, count_fds()); + pb_relay_stop(); + CHECK(relay_up(300, 60, 0, 5) == 0, "restart relay"); +} + +static void test_tcp_idle(void) +{ + pb_relay_stop(); + CHECK(relay_up(3, 60, 0, 5) == 0, "restart relay with tcp_idle=3s"); + uint16_t src = new_src(); + set_tcp(src, "127.0.0.1", g_echo->port, PB_ACT_PROXY, 1); + int fd = relay_connect(src); + send(fd, "hi", 2, 0); + char b[4]; + rd_full(fd, b, 2); + int64_t t0 = now_ms(); + bool closed = expect_closed(fd, 15000); + CHECK(closed && now_ms() - t0 >= 1000 && now_ms() - t0 < 14000, "idle TCP connection closed after ~2s (%lld ms)", (long long)(now_ms() - t0)); + close(fd); + + src = new_src(); + set_tcp(src, "127.0.0.1", g_echo->port, PB_ACT_DIRECT, 0); + fd = relay_connect(src); + bool alive = true; + for (int i = 0; i < 10 && alive; i++) { + send(fd, "p", 1, 0); + alive = rd_full(fd, b, 1) == 0; + msleep(500); + } + CHECK(alive, "active connection survives past idle timeout"); + close(fd); +} + +static void test_tcp_cap(void) +{ + pb_relay_stop(); + CHECK(relay_up(300, 60, 5, 5) == 0, "restart relay with max_tcp_conns=5"); + int fds[5]; + int up = 0; + for (int i = 0; i < 5; i++) { + uint16_t s = new_src(); + set_tcp(s, "127.0.0.1", g_echo->port, PB_ACT_DIRECT, 0); + fds[i] = relay_connect(s); + char b[1]; + send(fds[i], "x", 1, 0); + if (rd_full(fds[i], b, 1) == 0) up++; + } + CHECK(up == 5, "5 connections within cap work"); + uint16_t s = new_src(); + set_tcp(s, "127.0.0.1", g_echo->port, PB_ACT_DIRECT, 0); + int extra = relay_connect(s); + send(extra, "x", 1, MSG_NOSIGNAL); + CHECK(closed_by_relay(extra, 3000), "6th connection closed immediately (cap)"); + close(extra); + close(fds[0]); + bool freed = false; + for (int i = 0; i < 100 && !freed; i++) { + msleep(100); + freed = tcp_roundtrip("127.0.0.1", g_echo->port, PB_ACT_DIRECT, 0, 1000, false) == 1000; + } + CHECK(freed, "slot freed after close"); + for (int i = 1; i < 5; i++) close(fds[i]); + msleep(300); +} + +static void test_handshake_timeout(void) +{ + pb_relay_stop(); + CHECK(relay_up(300, 60, 0, 2) == 0, "restart relay with handshake_timeout=2s"); + log_clear(); + set_proxy(9, PROXY_TYPE_SOCKS5, g_silent->port, NULL, NULL); + uint16_t s = new_src(); + set_tcp(s, "127.0.0.1", g_echo->port, PB_ACT_PROXY, 9); + int fd = relay_connect(s); + int64_t t0 = now_ms(); + bool closed = expect_closed(fd, 6000); + CHECK(closed && now_ms() - t0 >= 1500 && now_ms() - t0 < 14000 && log_has("timed out"), "silent proxy: handshake times out (%lld ms)", (long long)(now_ms() - t0)); + close(fd); +} + +static void test_udp(void) +{ + char b[256], ip[32]; + uint16_t sp; + int c = udp_client(); + set_flow("127.1.2.3", "127.0.0.1", g_uecho1, PB_ACT_PROXY, 1); + set_flow("127.1.2.4", "127.0.0.1", g_uecho2, PB_ACT_PROXY, 1); + int a0 = g_assoc_count; + CHECK(udp_echo_ok(c, "127.1.2.3", "hello-one"), "UDP via SOCKS5 UDP ASSOCIATE, reply from flow address 127.1.2.3"); + CHECK(udp_echo_ok(c, "127.1.2.4", "hello-two"), "second destination from same client socket, reply from 127.1.2.4"); + CHECK(udp_echo_ok(c, "127.1.2.3", "again"), "first destination again"); + CHECK(g_assoc_count - a0 == 1, "exactly one UDP ASSOCIATE for the session (got %d)", (int)g_assoc_count - a0); + + int c2 = udp_client(); + for (int i = 0; i < 10; i++) { char m[32]; snprintf(m, sizeof m, "burst%d", i); udp_send_flow(c2, "127.1.2.3", m); } + int got = 0; + while (udp_recv_to(c2, b, sizeof b, ip, &sp, 3000) > 0) got++; + CHECK(got == 10, "10 datagrams sent during association setup delivered (got %d)", got); + close(c2); + + set_flow("127.1.2.5", "127.0.0.1", g_uecho1, PB_ACT_PROXY, 2); + c2 = udp_client(); + CHECK(udp_echo_ok(c2, "127.1.2.5", "auth-udp"), "UDP via authenticated SOCKS5"); + close(c2); + + set_flow("127.1.3.1", "127.0.0.1", g_uecho1, PB_ACT_DIRECT, 0); + set_flow("127.1.3.2", "127.0.0.1", g_uecho2, PB_ACT_DIRECT, 0); + CHECK(udp_echo_ok(c, "127.1.3.1", "direct-1") && udp_echo_ok(c, "127.1.3.2", "direct-2"), "UDP DIRECT, two destinations"); + + set_flow("127.1.4.1", "127.0.0.1", g_uecho1, PB_ACT_BLOCK, 0); + udp_send_flow(c, "127.1.4.1", "blocked"); + CHECK(udp_recv_neg(c, b, sizeof b, ip, &sp) < 0, "UDP BLOCK dropped"); + udp_send_flow(c, "127.9.9.9", "unknown"); + CHECK(udp_recv_neg(c, b, sizeof b, ip, &sp) < 0, "UDP unknown flow dropped"); + + log_clear(); + set_flow("127.1.4.2", "127.0.0.1", g_uecho1, PB_ACT_PROXY, 3); + udp_send_flow(c, "127.1.4.2", "via-http"); + udp_send_flow(c, "127.1.4.2", "via-http2"); + CHECK(udp_recv_neg(c, b, sizeof b, ip, &sp) < 0 && log_has("HTTP proxy"), "UDP via HTTP proxy dropped + logged"); + + set_decision((struct pb_decision){ PB_ACT_DIRECT, 0 }); + set_flow("127.1.4.3", "127.0.0.1", g_uecho1, PB_ACT_DECIDE, 0); + g_decide_calls = 0; + bool ok = udp_echo_ok(c, "127.1.4.3", "d1") && udp_echo_ok(c, "127.1.4.3", "d2") && udp_echo_ok(c, "127.1.4.3", "d3"); + CHECK(ok && g_decide_calls == 1, "UDP DECIDE -> DIRECT, decided once (calls=%d)", (int)g_decide_calls); + + set_flow("127.1.4.4", "127.0.0.1", g_uecho1, PB_ACT_PROXY, 6); + udp_send_flow(c, "127.1.4.4", "dead-proxy"); + CHECK(udp_recv_neg(c, b, sizeof b, ip, &sp) < 0, "UDP via dead SOCKS5 proxy: no reply, relay survives"); + CHECK(udp_echo_ok(c, "127.1.3.1", "still-alive"), "relay still serves UDP afterwards"); + close(c); +} + +static void test_udp_idle(void) +{ + pb_relay_stop(); + CHECK(relay_up(300, 2, 0, 5) == 0, "restart relay with udp_idle=2s"); + int c = udp_client(); + set_flow("127.1.5.1", "127.0.0.1", g_uecho1, PB_ACT_DIRECT, 0); + set_flow("127.1.5.2", "127.0.0.1", g_uecho2, PB_ACT_PROXY, 1); + int base = count_fds(); + bool ok = udp_echo_ok(c, "127.1.5.1", "x") && udp_echo_ok(c, "127.1.5.2", "y"); + int mid = count_fds(); + int end = settle_fds(base, 15000); + CHECK(ok && mid > base && end <= base, "UDP sessions expire after idle and free their fds (base=%d mid=%d end=%d)", base, mid, end); + CHECK(udp_echo_ok(c, "127.1.5.1", "again"), "new session after expiry works"); + close(c); +} + +static void test_start_stop(void) +{ + pb_relay_stop(); + pb_relay_stop(); + msleep(500); + int before = count_fds(); + int bad = 0; + for (int i = 0; i < 20; i++) { + if (relay_up(0, 0, 0, 0) != 0) { bad++; continue; } + if (i % 4 == 0) { + bool a = tcp_roundtrip("127.0.0.1", g_echo->port, PB_ACT_PROXY, 1, 2000, false) == 2000; + int c = udp_client(); + set_flow("127.1.6.1", "127.0.0.1", g_uecho1, PB_ACT_DIRECT, 0); + bool b = udp_echo_ok(c, "127.1.6.1", "z"); + close(c); + if (!a || !b) bad++; + } + pb_relay_stop(); + } + pb_relay_stop(); + int after = settle_fds(before, 5000); + CHECK(bad == 0 && after <= before, "start/stop x20 (with traffic): fds before=%d after=%d, failures=%d", before, after, bad); + + int l = socket(AF_INET, SOCK_STREAM, 0); + int one = 1; + setsockopt(l, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + struct sockaddr_in a = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK), .sin_port = htons(TPORT) }; + if (bind(l, (struct sockaddr *)&a, sizeof a) < 0) perror("test bind"); + listen(l, 1); + int b4 = count_fds(); + log_clear(); + int r1 = relay_up(0, 0, 0, 0); + int af = settle_fds(b4, 3000); + CHECK(r1 == -1 && af <= b4 && log_has("cannot listen"), "start fails when TCP port busy, no fd leak, logged"); + close(l); + int u = socket(AF_INET, SOCK_DGRAM, 0); + a.sin_port = htons(UPORT); + a.sin_addr.s_addr = htonl(INADDR_ANY); + if (bind(u, (struct sockaddr *)&a, sizeof a) < 0) perror("test bind"); + b4 = count_fds(); + r1 = relay_up(0, 0, 0, 0); + af = settle_fds(b4, 3000); + CHECK(r1 == -1 && af <= b4, "start fails when UDP port busy, no fd leak (TCP listener released)"); + close(u); + CHECK(relay_up(0, 0, 0, 0) == 0, "start works after failed starts"); + pb_relay_stop(); +} + +static void test_proxy_helper(void) +{ + char b[256]; + int r = pb_relay_test_proxy(&g_px[1], "echo.test", g_echo->port, b, sizeof b); + CHECK(r == 0 && !strcmp(b, "Tunnel established"), "test_proxy SOCKS5 domain ATYP, non-80 port: '%s'", b); + r = pb_relay_test_proxy(&g_px[2], "127.0.0.1", g_echo->port, b, sizeof b); + CHECK(r == 0, "test_proxy SOCKS5 with auth: '%s'", b); + r = pb_relay_test_proxy(&g_px[3], "echo.test", g_echo->port, b, sizeof b); + CHECK(r == 0 && !strcmp(b, "Tunnel established"), "test_proxy HTTP CONNECT hostname: '%s'", b); + r = pb_relay_test_proxy(&g_px[7], "127.0.0.1", g_echo->port, b, sizeof b); + CHECK(r == -1 && strstr(b, "authentication failed"), "test_proxy wrong password -> readable error: '%s'", b); + r = pb_relay_test_proxy(&g_px[6], "127.0.0.1", 80, b, sizeof b); + CHECK(r == -1 && strstr(b, "Cannot reach proxy"), "test_proxy dead proxy -> readable error: '%s'", b); + r = pb_relay_test_proxy(&g_px[1], "nonexistent.test", 443, b, sizeof b); + CHECK(r == -1 && strstr(b, "refused"), "test_proxy unreachable target -> error: '%s'", b); +} + +static bool loopback_ok(void) +{ + int l = socket(AF_INET, SOCK_STREAM, 0), c = socket(AF_INET, SOCK_STREAM, 0); + bool ok = false; + struct sockaddr_in a = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK) }; + struct timeval tv = { 3, 0 }; + setsockopt(c, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); + if (l >= 0 && c >= 0 && bind(l, (struct sockaddr *)&a, sizeof a) == 0 && listen(l, 1) == 0) { + socklen_t al = sizeof a; + getsockname(l, (struct sockaddr *)&a, &al); + if (connect(c, (struct sockaddr *)&a, sizeof a) == 0) { + struct pollfd p = { l, POLLIN, 0 }; + ok = poll(&p, 1, 3000) == 1; + } + } + if (l >= 0) close(l); + if (c >= 0) close(c); + return ok; +} +static bool can_bind53(void) +{ + int fd = socket(AF_INET, SOCK_STREAM, 0); + struct sockaddr_in a = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK), .sin_port = htons(53) }; + bool ok = bind(fd, (struct sockaddr *)&a, sizeof a) == 0; + close(fd); + return ok; +} +// Runs in the forked DNS child only: gets a private netns where port 53 is bindable. Returns false if impossible. +static bool isolate_for_port53(void) +{ + bool force_no_lo = getenv("PB_TEST_FORCE_NO_LO") != NULL; + if (!force_no_lo && can_bind53() && loopback_ok()) return true; + uid_t uid = getuid(); + gid_t gid = getgid(); + if (unshare(CLONE_NEWUSER | CLONE_NEWNET) != 0) { printf("NOTE DNS child: unshare failed: %s\n", strerror(errno)); return false; } + int fd = open("/proc/self/setgroups", O_WRONLY); + if (fd >= 0) { if (write(fd, "deny", 4) < 0) {} close(fd); } + char b[64]; + fd = open("/proc/self/uid_map", O_WRONLY); + if (fd >= 0) { int n = snprintf(b, sizeof b, "0 %u 1", uid); if (write(fd, b, (size_t)n) < 0) {} close(fd); } + fd = open("/proc/self/gid_map", O_WRONLY); + if (fd >= 0) { int n = snprintf(b, sizeof b, "0 %u 1", gid); if (write(fd, b, (size_t)n) < 0) {} close(fd); } + if (!force_no_lo) { + int s = socket(AF_INET, SOCK_DGRAM, 0); + struct ifreq ifr; + memset(&ifr, 0, sizeof ifr); + snprintf(ifr.ifr_name, IFNAMSIZ, "lo"); + ifr.ifr_flags = IFF_UP | IFF_RUNNING; + if (s >= 0) { if (ioctl(s, SIOCSIFFLAGS, &ifr) < 0) printf("NOTE DNS child: cannot bring lo up: %s\n", strerror(errno)); close(s); } + } + if (!loopback_ok()) { printf("NOTE DNS child: loopback unusable in the new namespace\n"); return false; } + if (!can_bind53()) { printf("NOTE DNS child: port 53 not bindable in the new namespace\n"); return false; } + return true; +} + +struct shared { int pass, fail, status; }; // status: 0 = not run, 1 = ran, 3 = cannot isolate +static struct shared *g_shared; + +static void mk_msg(int mode, int idx, uint8_t *m, size_t len) +{ + for (size_t i = 0; i < len; i++) m[i] = (uint8_t)(mode * 31 + idx * 17 + (int)i * 7); +} +static void dns_tcp_handler(int fd, const struct srvcfg *c) +{ + (void)c; + int one = 1; + setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one); + uint8_t l[2], q[512]; + if (rd_full(fd, l, 2)) return; + size_t n = (size_t)(l[0] << 8 | l[1]); + if (n > sizeof q || rd_full(fd, q, n)) return; + int mode = q[0]; + uint8_t out[300]; + if (mode == 'a') { + out[0] = 0; out[1] = 100; mk_msg(mode, 0, out + 2, 100); + for (int i = 0; i < 102; i++) { wr_full(fd, out + i, 1); usleep(1500); } + } else if (mode == 'b') { + out[0] = 0; out[1] = 40; mk_msg(mode, 0, out + 2, 40); + out[42] = 0; out[43] = 60; mk_msg(mode, 1, out + 44, 60); + wr_full(fd, out, 104); + } else { + out[0] = 0; out[1] = 100; mk_msg(mode, 0, out + 2, 100); + wr_full(fd, out, 102); + } +} +static void *dns_udp_thread(void *p) +{ + int fd = (int)(intptr_t)p; + uint8_t b[600]; + for (;;) { + struct sockaddr_in f; + socklen_t fl = sizeof f; + ssize_t n = recvfrom(fd, b, sizeof b, 0, (struct sockaddr *)&f, &fl); + if (n < 1) continue; + uint8_t m[100]; + mk_msg(b[0], 0, m, 100); + sendto(fd, m, 100, 0, (struct sockaddr *)&f, fl); + } + return NULL; +} + +static bool dns_tcp_case(const char *name, int action, uint32_t px, int mode, int nmsg) +{ + atomic_store(&g_obs_n, 0); + uint16_t src = new_src(); + set_tcp(src, "127.0.0.1", 53, action, px); + int fd = relay_connect(src); + uint8_t q[3] = { 0, 1, (uint8_t)mode }; + wr_full(fd, q, 3); + uint8_t got[400]; + size_t have = 0; + int64_t first = 0; + for (;;) { + ssize_t r = recv(fd, got + have, sizeof got - have, 0); + if (r <= 0) break; + if (!have) first = mono_ms(); + have += (size_t)r; + } + close(fd); + uint8_t exp[300]; + size_t el = 0; + size_t lens[2] = { mode == 'b' ? 40 : 100, 60 }; + uint32_t hashes[2]; + for (int i = 0; i < nmsg; i++) { + exp[el++] = 0; exp[el++] = (uint8_t)lens[i]; + mk_msg(mode, i, exp + el, lens[i]); + hashes[i] = fnv(exp + el, lens[i]); + el += lens[i]; + } + bool ok = have == el && !memcmp(got, exp, el) && g_obs_n == nmsg; + for (int i = 0; ok && i < nmsg; i++) ok = g_obs[i].len == lens[i] && g_obs[i].h == hashes[i]; + bool before = nmsg > 0 && g_obs[0].t1 <= first; // observe finished before the client saw any byte + CHECK(ok && before, "TCP DNS tap %s: stream intact, %d observe call(s) (got %d), observe completed %lld ms before first client byte", name, + nmsg, (int)g_obs_n, nmsg ? (long long)(first - g_obs[0].t1) : -1LL); + return ok; +} + +static void test_dns(void) +{ + CHECK(g_obs_n == 0, "no DNS observe calls for non-port-53 flows so far (%d)", (int)g_obs_n); + struct srv *d = srv_start_p(dns_tcp_handler, NULL, 53); + int ufd = socket(AF_INET, SOCK_DGRAM, 0); + struct sockaddr_in a = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK), .sin_port = htons(53) }; + int ub = bind(ufd, (struct sockaddr *)&a, sizeof a); + CHECK(d && ub == 0, "DNS fake servers on 127.0.0.1:53"); + if (!d || ub) return; + pthread_t t; + pthread_create(&t, NULL, dns_udp_thread, (void *)(intptr_t)ufd); + pthread_detach(t); + atomic_store(&g_obs_sleep, 50); + + dns_tcp_case("DIRECT single write", PB_ACT_DIRECT, 0, 'c', 1); + dns_tcp_case("DIRECT 1-byte writes", PB_ACT_DIRECT, 0, 'a', 1); + dns_tcp_case("DIRECT two messages in one write", PB_ACT_DIRECT, 0, 'b', 2); + dns_tcp_case("via SOCKS5 single write", PB_ACT_PROXY, 1, 'c', 1); + dns_tcp_case("via SOCKS5 1-byte writes", PB_ACT_PROXY, 1, 'a', 1); + dns_tcp_case("via SOCKS5 two messages", PB_ACT_PROXY, 1, 'b', 2); + dns_tcp_case("via HTTP CONNECT 1-byte writes", PB_ACT_PROXY, 3, 'a', 1); + dns_tcp_case("via HTTP CONNECT two messages", PB_ACT_PROXY, 3, 'b', 2); + + atomic_store(&g_obs_n, 0); + CHECK(tcp_roundtrip("127.0.0.1", g_echo->port, PB_ACT_DIRECT, 0, 1000, false) == 1000 && g_obs_n == 0, "TCP non-53 not observed"); + + struct { const char *name; int action; uint32_t px; const char *flow; } u[] = { + { "UDP DIRECT", PB_ACT_DIRECT, 0, "127.1.7.1" }, { "UDP via SOCKS5", PB_ACT_PROXY, 1, "127.1.7.2" } }; + for (int k = 0; k < 2; k++) { + set_flow(u[k].flow, "127.0.0.1", 53, u[k].action, u[k].px); + atomic_store(&g_obs_n, 0); + int c = udp_client(); + char b[256], ip[32]; + uint16_t sp; + udp_send_flow(c, u[k].flow, "q"); + int n = udp_recv(c, b, sizeof b, ip, &sp); + int64_t tr = mono_ms(); + uint8_t m[100]; + mk_msg('q', 0, m, 100); + bool ok = n == 100 && !memcmp(b, m, 100) && !strcmp(ip, u[k].flow) && g_obs_n == 1 && g_obs[0].h == fnv(m, 100) && g_obs[0].t1 <= tr; + CHECK(ok, "%s DNS tap: reply observed (calls=%d) before delivery, from flow address", u[k].name, (int)g_obs_n); + close(c); + } + atomic_store(&g_obs_n, 0); + int c = udp_client(); + set_flow("127.1.7.3", "127.0.0.1", g_uecho1, PB_ACT_DIRECT, 0); + CHECK(udp_echo_ok(c, "127.1.7.3", "plain") && g_obs_n == 0, "UDP non-53 not observed"); + close(c); + atomic_store(&g_obs_sleep, 0); +} + +static double pingpong_us(int fd, int n) +{ + char b = 'x'; + int64_t t0 = now_ms(); + struct timespec a, z; + clock_gettime(CLOCK_MONOTONIC, &a); + (void)t0; + for (int i = 0; i < n; i++) { + if (send(fd, &b, 1, MSG_NOSIGNAL) != 1 || recv(fd, &b, 1, 0) != 1) return -1; + } + clock_gettime(CLOCK_MONOTONIC, &z); + return ((double)(z.tv_sec - a.tv_sec) * 1e9 + (double)(z.tv_nsec - a.tv_nsec)) / 1000.0 / n; +} +static void test_latency(void) +{ + int one = 1; + int d = socket(AF_INET, SOCK_STREAM, 0); + struct sockaddr_in a = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK), .sin_port = htons(g_echo->port) }; + connect(d, (struct sockaddr *)&a, sizeof a); + setsockopt(d, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one); + double direct = pingpong_us(d, 1000); + close(d); + double res[3]; + int acts[3] = { PB_ACT_DIRECT, PB_ACT_PROXY, PB_ACT_PROXY }; + uint32_t pxs[3] = { 0, 1, 3 }; + for (int k = 0; k < 3; k++) { + uint16_t src = new_src(); + set_tcp(src, "127.0.0.1", g_echo->port, acts[k], pxs[k]); + int fd = relay_connect(src); + setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one); + res[k] = pingpong_us(fd, 1000); + close(fd); + } + printf(" avg RTT per 1-byte round trip: direct echo %.0f us, relay DIRECT %.0f us, relay+SOCKS5 %.0f us, relay+HTTP %.0f us\n", + direct, res[0], res[1], res[2]); + CHECK(direct > 0 && res[0] > 0 && res[1] > 0 && res[2] > 0 && res[0] < 20000 && res[1] < 20000 && res[2] < 20000, + "no Nagle/delayed-ACK stall through relay (all averages < 20 ms; a stall would be >= 40 ms per trip)"); +} + +static void test_hostname(void) +{ + set_proxy(11, PROXY_TYPE_SOCKS5, g_s5->port, NULL, NULL); + set_proxy_host(11, "localhost"); + CHECK(tcp_roundtrip("127.0.0.1", g_echo->port, PB_ACT_PROXY, 11, 20000, false) == 20000, "TCP via SOCKS5 proxy configured as hostname 'localhost'"); + CHECK(tcp_roundtrip("127.0.0.1", g_echo->port, PB_ACT_PROXY, 11, 20000, false) == 20000, "second connection uses resolver cache"); + set_proxy(12, PROXY_TYPE_HTTP, g_http->port, NULL, NULL); + set_proxy_host(12, "localhost"); + char b[256]; + int r = pb_relay_test_proxy(&g_px[12], "echo.test", g_echo->port, b, sizeof b); + CHECK(r == 0, "pb_relay_test_proxy with hostname proxy: '%s'", b); + set_proxy(13, PROXY_TYPE_SOCKS5, g_s5->port, NULL, NULL); + set_proxy_host(13, "no-such-host.invalid"); + int64_t t0 = now_ms(); + r = pb_relay_test_proxy(&g_px[13], "echo.test", g_echo->port, b, sizeof b); + int64_t dt = now_ms() - t0; + CHECK(r == -1 && strstr(b, "cannot resolve") && dt < 9000, "unresolvable proxy hostname -> readable error in %lld ms: '%s'", (long long)dt, b); + log_clear(); + uint16_t src = new_src(); + set_tcp(src, "127.0.0.1", g_echo->port, PB_ACT_PROXY, 13); + int fd = relay_connect(src); + CHECK(closed_by_relay(fd, 8000) && log_has("cannot resolve"), "relay TCP with unresolvable proxy: client closed + logged"); + close(fd); +} + +// A UDP server that answers from a different socket than the one it received on. +// payload starting with 'B': reply from another port on 127.0.0.1; 'C': reply from 127.0.0.2. +static void *odd_udp_thread(void *p) +{ + int fd = (int)(intptr_t)p; + int b = socket(AF_INET, SOCK_DGRAM, 0), c = socket(AF_INET, SOCK_DGRAM, 0); + struct sockaddr_in x = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK) }; + bind(b, (struct sockaddr *)&x, sizeof x); + x.sin_addr.s_addr = htonl(0x7f000002); + bind(c, (struct sockaddr *)&x, sizeof x); + char m[600]; + for (;;) { + struct sockaddr_in f; + socklen_t fl = sizeof f; + ssize_t n = recvfrom(fd, m, sizeof m, 0, (struct sockaddr *)&f, &fl); + if (n < 1) continue; + sendto(m[0] == 'C' ? c : b, m, (size_t)n, 0, (struct sockaddr *)&f, fl); + } + return NULL; +} + +static bool udp_big(int c, const char *flow, size_t n, bool expect) +{ + uint8_t *tx = malloc(n), *rx = malloc(70000); + for (size_t i = 0; i < n; i++) tx[i] = pat(i); + struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(pb_relay_udp_port()) }; + inet_pton(AF_INET, flow, &a.sin_addr); + ssize_t s = sendto(c, tx, n, 0, (struct sockaddr *)&a, sizeof a); + struct sockaddr_in f; + socklen_t fl = sizeof f; + struct timeval tv = { expect ? 6 : 1, expect ? 0 : 500000 }; + setsockopt(c, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + ssize_t r = recvfrom(c, rx, 70000, 0, (struct sockaddr *)&f, &fl); + char ip[32]; + inet_ntop(AF_INET, &f.sin_addr, ip, sizeof ip); + bool ok = s == (ssize_t)n && (expect ? (r == (ssize_t)n && !memcmp(rx, tx, n) && !strcmp(ip, flow)) : r < 0); + free(tx); free(rx); + return ok; +} + +static void test_udp_odd(void) +{ + int c = udp_client(); + int rb = 1 << 21; + setsockopt(c, SOL_SOCKET, SO_RCVBUF, &rb, sizeof rb); + set_flow("127.1.8.1", "127.0.0.1", g_uecho1, PB_ACT_DIRECT, 0); + set_flow("127.1.8.2", "127.0.0.1", g_uecho1, PB_ACT_PROXY, 1); + CHECK(udp_big(c, "127.1.8.1", 65507, true), "UDP DIRECT 65507-byte datagram round trip intact"); + CHECK(udp_big(c, "127.1.8.2", 65497, true), "UDP via SOCKS5 65497-byte datagram (max with 10-byte header) round trip intact"); + CHECK(udp_big(c, "127.1.8.2", 30000, true), "UDP via SOCKS5 30000-byte datagram intact"); + CHECK(udp_big(c, "127.1.8.2", 65507, false), "UDP via SOCKS5 65507 (header would exceed IPv4 limit): dropped, no corruption"); + CHECK(udp_echo_ok(c, "127.1.8.2", "small-after-big") && udp_echo_ok(c, "127.1.8.1", "small-direct-after-big"), "small datagrams unaffected after oversized ones"); + + uint16_t odd_port; + int ofd = socket(AF_INET, SOCK_DGRAM, 0); + struct sockaddr_in x = { .sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK) }; + bind(ofd, (struct sockaddr *)&x, sizeof x); + socklen_t xl = sizeof x; + getsockname(ofd, (struct sockaddr *)&x, &xl); + odd_port = ntohs(x.sin_port); + pthread_t t; + pthread_create(&t, NULL, odd_udp_thread, (void *)(intptr_t)ofd); + pthread_detach(t); + set_flow("127.1.8.3", "127.0.0.1", odd_port, PB_ACT_DIRECT, 0); + set_flow("127.1.8.4", "127.0.0.1", odd_port, PB_ACT_PROXY, 1); + char b[64], ip[32]; + uint16_t sp; + const char *fl[2] = { "127.1.8.3", "127.1.8.4" }; + const char *nm[2] = { "DIRECT", "SOCKS5" }; + for (int k = 0; k < 2; k++) { + udp_send_flow(c, fl[k], "Bport"); + int n = udp_recv(c, b, sizeof b, ip, &sp); + CHECK(n == 5 && !strcmp(ip, fl[k]), "%s: reply from a different port of the same host delivered from the flow address", nm[k]); + udp_send_flow(c, fl[k], "Chost"); + n = udp_recv_neg(c, b, sizeof b, ip, &sp); + CHECK(n < 0, "%s: reply from a different host dropped", nm[k]); + } + close(c); +} + +static void init_world(void) +{ + struct srvcfg c = { 0 }; + g_echo = srv_start(echo_handler, NULL); + g_s5 = srv_start(socks_handler, &c); + struct srvcfg ca = { .auth = true }; strcpy(ca.user, "alice"); strcpy(ca.pass, "secret"); + g_s5auth = srv_start(socks_handler, &ca); + g_http = srv_start(http_handler, &c); + struct srvcfg ch = { .auth = true }; strcpy(ch.pass, "YWxpY2U6c2VjcmV0"); + g_httpauth = srv_start(http_handler, &ch); + struct srvcfg cl = { .leftover = true }; + g_httpleft = srv_start(http_handler, &cl); + struct srvcfg cs = { .silent = true }; + g_silent = srv_start(socks_handler, &cs); + g_sink = srv_start(sink_handler, NULL); + g_uecho1 = udpecho_start(); + g_uecho2 = udpecho_start(); + + set_proxy(1, PROXY_TYPE_SOCKS5, g_s5->port, NULL, NULL); + set_proxy(2, PROXY_TYPE_SOCKS5, g_s5auth->port, "alice", "secret"); + set_proxy(3, PROXY_TYPE_HTTP, g_http->port, NULL, NULL); + set_proxy(4, PROXY_TYPE_HTTP, g_httpauth->port, "alice", "secret"); + set_proxy(5, PROXY_TYPE_HTTP, g_httpleft->port, NULL, NULL); + g_default_px = 1; +} + +// The port-53 tests run in a forked child (before any thread exists) so that a private network namespace never +// affects the main process. Status/counters come back through shared memory. +static void run_dns_child(void) +{ + arm_watchdog(); + if (!isolate_for_port53()) { g_shared->status = 3; _exit(0); } + init_world(); + if (relay_up(300, 60, 0, 5) != 0) { printf("FAIL DNS child: pb_relay_start\n"); g_shared->fail = 1; g_shared->status = 1; _exit(0); } + RUN(test_dns); + pb_relay_stop(); + g_shared->pass = g_pass; + g_shared->fail = g_fail; + g_shared->status = 1; + fflush(stdout); + _exit(0); +} + +int main(void) +{ + setvbuf(stdout, NULL, _IOLBF, 0); + if (!loopback_ok()) { + fprintf(stderr, "FATAL: 127.0.0.1 loopback is not usable in this environment (bind+connect failed)\n"); + return 2; + } + g_shared = mmap(NULL, sizeof *g_shared, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); + if (g_shared == MAP_FAILED) { perror("mmap"); return 2; } + memset(g_shared, 0, sizeof *g_shared); + g_cur = "dns child"; + pid_t pid = fork(); + if (pid == 0) run_dns_child(); + int st = 0; + if (pid < 0 || waitpid(pid, &st, 0) < 0) st = -1; + arm_watchdog(); + if (g_shared->status == 1 && WIFEXITED(st) && WEXITSTATUS(st) == 0) { + g_pass += g_shared->pass; + g_fail += g_shared->fail; + } else if (g_shared->status == 3) { + printf("SKIP DNS tests: cannot isolate a private network namespace with port 53 here\n"); + } else { + g_fail++; + printf("FAIL DNS child crashed or timed out (wait status 0x%x)\n", st); + } + + init_world(); + g_cur = "pb_relay_start"; + CHECK(relay_up(300, 60, 0, 5) == 0, "pb_relay_start"); + RUN(test_tcp_basic); + RUN(test_tcp_identity); + RUN(test_tcp_bad_proxy); + RUN(test_tcp_halfclose); + RUN(test_udp); + RUN(test_proxy_helper); + RUN(test_udp_odd); + RUN(test_hostname); + RUN(test_latency); + RUN(test_tcp_large); + RUN(test_throughput); + RUN(test_perf); + RUN(test_tcp_10k); + RUN(test_churn); + RUN(test_tcp_idle); + RUN(test_tcp_cap); + RUN(test_handshake_timeout); + RUN(test_udp_idle); + RUN(test_start_stop); + pb_relay_stop(); + printf("\nRESULT: %d passed, %d failed\n", g_pass, g_fail); + return g_fail ? 1 : 0; +} diff --git a/Linux/src/tests/rules_test.c b/Linux/src/tests/rules_test.c new file mode 100644 index 00000000..59346317 --- /dev/null +++ b/Linux/src/tests/rules_test.c @@ -0,0 +1,97 @@ +#include +#include +#include +#include +#include "../pb_rules.h" + +static int fails; +#define CHECK(c) do { if (!(c)) { printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #c); fails++; } } while (0) + +static void a4(uint8_t o[16], const char *s) { memset(o, 0, 16); o[10] = o[11] = 0xff; inet_pton(AF_INET, s, o + 12); } +static void a6(uint8_t o[16], const char *s) { inet_pton(AF_INET6, s, o); } + +static struct pb_rule R(const char *p, const char *h, const char *pt) +{ + struct pb_rule r; char err[128]; + bool ok = pb_rule_parse(&r, p, h, pt, err, sizeof err); + if (!ok) printf("parse error: %s\n", err); + CHECK(ok); + return r; +} +static bool bad(const char *p, const char *h, const char *pt) +{ + struct pb_rule r; char err[128]; + bool ok = pb_rule_parse(&r, p, h, pt, err, sizeof err); + if (ok) pb_rule_free(&r); else printf(" (rejected as expected: %s)\n", err); + return !ok; +} + +int main(void) +{ + uint8_t a[16]; + struct pb_rule r; + + r = R("firefox;chrome*", "", ""); CHECK(!r.any_proc && r.nprocs == 2); + CHECK(pb_rule_match_process(&r, "/usr/lib/firefox/firefox")); CHECK(pb_rule_match_process(&r, "/opt/google/chrome/chrome")); + CHECK(pb_rule_match_process(&r, "/opt/chromium/chromedriver")); CHECK(!pb_rule_match_process(&r, "/usr/bin/curl")); pb_rule_free(&r); + r = R("*chrome*", "", ""); CHECK(pb_rule_match_process(&r, "/opt/google/chrome/chrome")); CHECK(pb_rule_match_process(&r, "/usr/bin/mychromex")); pb_rule_free(&r); + r = R("/usr/bin/*", "", ""); CHECK(pb_rule_match_process(&r, "/usr/bin/curl")); CHECK(!pb_rule_match_process(&r, "/opt/x/curl")); pb_rule_free(&r); + r = R("", "", ""); CHECK(r.any_proc && r.any_host && r.any_port); pb_rule_free(&r); + r = R("*", "*", "*"); CHECK(r.any_proc && r.any_host && r.any_port); pb_rule_free(&r); + + r = R("", "192.168.*.*;10.0.0.0/8", "80;443;8000-9000"); + a4(a, "192.168.4.5"); CHECK(pb_rule_match_dest(&r, AF_INET, a, 443, NULL, 0)); + a4(a, "10.200.1.1"); CHECK(pb_rule_match_dest(&r, AF_INET, a, 8500, NULL, 0)); + a4(a, "10.200.1.1"); CHECK(!pb_rule_match_dest(&r, AF_INET, a, 22, NULL, 0)); + a4(a, "172.16.0.1"); CHECK(!pb_rule_match_dest(&r, AF_INET, a, 80, NULL, 0)); + CHECK(r.hosts[0].kind == PB_HOST_V4_RANGE); // 192.168.*.* collapses to a range + pb_rule_free(&r); + + r = R("", "10.1-5.*.7", ""); CHECK(r.hosts[0].kind == PB_HOST_V4_BOX); + a4(a, "10.3.99.7"); CHECK(pb_rule_match_dest(&r, AF_INET, a, 1, NULL, 0)); a4(a, "10.6.99.7"); CHECK(!pb_rule_match_dest(&r, AF_INET, a, 1, NULL, 0)); + a4(a, "10.3.99.8"); CHECK(!pb_rule_match_dest(&r, AF_INET, a, 1, NULL, 0)); pb_rule_free(&r); + + r = R("", "1.2.3.4-1.2.3.9", ""); a4(a, "1.2.3.9"); CHECK(pb_rule_match_dest(&r, AF_INET, a, 1, NULL, 0)); a4(a, "1.2.3.10"); CHECK(!pb_rule_match_dest(&r, AF_INET, a, 1, NULL, 0)); pb_rule_free(&r); + r = R("", "2001:db8::/32;fd00::1", ""); a6(a, "2001:db8:ffff::1"); CHECK(pb_rule_match_dest(&r, AF_INET6, a, 1, NULL, 0)); + a6(a, "2001:db9::1"); CHECK(!pb_rule_match_dest(&r, AF_INET6, a, 1, NULL, 0)); a6(a, "fd00::1"); CHECK(pb_rule_match_dest(&r, AF_INET6, a, 1, NULL, 0)); + a4(a, "1.1.1.1"); CHECK(!pb_rule_match_dest(&r, AF_INET, a, 1, NULL, 0)); pb_rule_free(&r); + + CHECK(bad("", "999.1.1.1", "")); CHECK(bad("", "1.2.3", "")); CHECK(bad("", "1.2.3.4.5", "")); CHECK(bad("", "10.0.0.0/33", "")); + CHECK(bad("", "exa$mple.com", "")); CHECK(bad("", ".example.com", "")); CHECK(bad("", "a..b.com", "")); CHECK(bad("", "5.5.5.5-1.1.1.1", "")); CHECK(bad("", "", "abc")); + CHECK(bad("", "", "0")); CHECK(bad("", "", "70000")); CHECK(bad("", "", "90-80")); CHECK(bad("", "::g", "")); CHECK(bad("", "1::/129", "")); + + struct pb_rule rules[3]; memset(rules, 0, sizeof rules); + rules[0] = R("curl", "", "443"); rules[0].enabled = true; rules[0].action = RULE_ACTION_BLOCK; + rules[1] = R("curl", "", ""); rules[1].enabled = true; rules[1].action = RULE_ACTION_PROXY; + rules[2] = R("", "", ""); rules[2].enabled = true; rules[2].action = RULE_ACTION_DIRECT; rules[2].protocol = RULE_PROTOCOL_UDP; + a4(a, "9.9.9.9"); + CHECK(pb_rules_eval(rules, 3, "/usr/bin/curl", AF_INET, a, 443, IPPROTO_TCP, NULL, 0) == 0); + CHECK(pb_rules_eval(rules, 3, "/usr/bin/curl", AF_INET, a, 80, IPPROTO_TCP, NULL, 0) == 1); + CHECK(pb_rules_eval(rules, 3, "/usr/bin/wget", AF_INET, a, 80, IPPROTO_TCP, NULL, 0) == -1); + CHECK(pb_rules_eval(rules, 3, "/usr/bin/wget", AF_INET, a, 80, IPPROTO_UDP, NULL, 0) == 2); + rules[0].enabled = false; CHECK(pb_rules_eval(rules, 3, "/usr/bin/curl", AF_INET, a, 443, IPPROTO_TCP, NULL, 0) == 1); + for (int i = 0; i < 3; i++) pb_rule_free(&rules[i]); + + CHECK(pb_implicit_direct(AF_INET, (a4(a, "127.0.0.53"), a), 53, IPPROTO_UDP, true)); + CHECK(pb_implicit_direct(AF_INET, (a4(a, "224.0.0.251"), a), 5353, IPPROTO_UDP, true)); + CHECK(!pb_implicit_direct(AF_INET, (a4(a, "8.8.8.8"), a), 53, IPPROTO_UDP, true)); + CHECK(pb_implicit_direct(AF_INET, (a4(a, "8.8.8.8"), a), 53, IPPROTO_UDP, false)); + CHECK(pb_implicit_direct(AF_INET6, (a6(a, "::1"), a), 80, IPPROTO_TCP, true)); + CHECK(!pb_implicit_direct(AF_INET6, (a6(a, "2001:db8::1"), a), 80, IPPROTO_TCP, true)); + // ---- domain rules + r = R("", "*.github.com;example.org;10.0.0.0/8", "443"); + CHECK(r.ndomains == 2 && r.nhosts == 1 && !r.any_host); + const char *gh[] = { "api.github.com" }, *ex[] = { "EXAMPLE.org." }, *no[] = { "github.com", "notgithub.com", "example.org.evil.com" }; + a4(a, "140.82.1.1"); + CHECK(pb_rule_match_dest(&r, AF_INET, a, 443, gh, 1)); + CHECK(pb_rule_match_dest(&r, AF_INET, a, 443, ex, 1)); // case-insensitive, trailing root dot tolerated + CHECK(!pb_rule_match_dest(&r, AF_INET, a, 443, no, 3)); // "*.github.com" does not match the bare apex; exact stays exact + CHECK(!pb_rule_match_dest(&r, AF_INET, a, 80, gh, 1)); // port still has to match + a4(a, "10.1.2.3"); CHECK(pb_rule_match_dest(&r, AF_INET, a, 443, NULL, 0)); // IP pattern in the same list still works + pb_rule_free(&r); + r = R("", "github.com;*.github.com", ""); CHECK(r.ndomains == 2); pb_rule_free(&r); + CHECK(pb_domain_match("a?c.com", "abc.com")); CHECK(!pb_domain_match("a?c.com", "abbc.com")); CHECK(pb_domain_match("*", "x.y")); + CHECK(bad("", "bad name.com", "")); + printf(fails ? "RULES TESTS FAILED (%d)\n" : "rules tests passed\n", fails); + return fails != 0; +} diff --git a/Linux/src/tests/run_relay_test.sh b/Linux/src/tests/run_relay_test.sh new file mode 100755 index 00000000..c9cf79d1 --- /dev/null +++ b/Linux/src/tests/run_relay_test.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Builds and runs the root-free relay test (loopback only). Usage: run_relay_test.sh [plain|asan|both] (default both) +set -e +cd "$(dirname "$0")" +mode="${1:-both}" +out="${TMPDIR:-/tmp}" +CF="-std=gnu11 -Wall -Wextra -O1 -g -D_GNU_SOURCE -fstack-protector-strong -I.. " +if [ "$mode" = plain ] || [ "$mode" = both ]; then + gcc $CF -O2 relay_test.c ../pb_relay.c -o "$out/relay_test" -lpthread + "$out/relay_test" +fi +if [ "$mode" = asan ] || [ "$mode" = both ]; then + gcc $CF -fsanitize=address,undefined -fno-omit-frame-pointer relay_test.c ../pb_relay.c -o "$out/relay_test_asan" -lpthread + PB_TEST_WATCHDOG="${PB_TEST_WATCHDOG:-900}" LARGE_MB="${LARGE_MB:-4}" THRU_MB="${THRU_MB:-32}" PB_CHURN_TCP="${PB_CHURN_TCP:-10000}" PB_CHURN_UDP="${PB_CHURN_UDP:-3000}" PB_CHURN_RSS_MB="${PB_CHURN_RSS_MB:-48}" PB_PERF_UDP=10000 PB_PERF_CONNS=400 ASAN_OPTIONS=detect_leaks=1:quarantine_size_mb=1 UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 "$out/relay_test_asan" +fi diff --git a/Linux/tests/api_live.sh b/Linux/tests/api_live.sh new file mode 100755 index 00000000..308fa24b --- /dev/null +++ b/Linux/tests/api_live.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Library API against the live datapath. Needs root, curl, python3, gcc. sudo tests/api_live.sh +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; ROOT="$(dirname "$HERE")" +PB_DIR="${PB_DIR:-$ROOT/output}" # directory holding the built ProxyBridge + libproxybridge.so (run ../build.sh first) +[ -x "$PB_DIR/ProxyBridge" ] && [ -f "$PB_DIR/libproxybridge.so" ] || { echo "Build first: (cd Linux && ./build.sh), or set PB_DIR to a directory containing ProxyBridge and libproxybridge.so"; exit 1; } +WORK=$(mktemp -d); trap 'kill $FAKE 2>/dev/null; wait 2>/dev/null; rm -rf $WORK' EXIT +gcc -Wall -Wextra -O1 -g -I"$ROOT/src" -o "$WORK/api_live_test" "$HERE/api_live_test.c" -L"$PB_DIR" -lproxybridge -lpthread || exit 1 +python3 "$HERE/fake_proxies.py" "$WORK/log" >/dev/null 2>&1 & FAKE=$! +sleep 1 +env LD_LIBRARY_PATH="$PB_DIR" ${PB_PRELOAD:+LD_PRELOAD="$PB_PRELOAD"} "$WORK/api_live_test" diff --git a/Linux/tests/api_live_test.c b/Linux/tests/api_live_test.c new file mode 100644 index 00000000..ae7d64b9 --- /dev/null +++ b/Linux/tests/api_live_test.c @@ -0,0 +1,113 @@ +// Library API test against the live eBPF datapath: every rule/proxy mutation must take effect immediately on new connections. +// Needs root and the fake proxies running (tests/api_live.sh starts them). +#define _GNU_SOURCE +#include +#include +#include +#include +#include "../src/ProxyBridge.h" + +static int pass, fail; +static int events; +static void on_conn(const char *p, uint32_t pid, const char *ip, uint16_t port, const char *info) { (void)p; (void)pid; (void)ip; (void)port; (void)info; events++; } +static void on_log(const char *m) { if (getenv("PB_VERBOSE")) fprintf(stderr, "[log] %s\n", m); } + +static void fetch(const char *ip, char *out, size_t n) +{ + char cmd[256]; snprintf(cmd, sizeof cmd, "curl -s -m 2 http://%s/ 2>&1; echo \"rc=$?\"", ip); + FILE *f = popen(cmd, "r"); size_t l = 0; + while (f && l + 1 < n && fgets(out + l, (int)(n - l), f)) l = strlen(out); + if (f) pclose(f); +} +static void expect(const char *what, const char *ip, const char *want) +{ + char out[512] = ""; fetch(ip, out, sizeof out); + int ok = want ? strstr(out, want) != NULL : (strstr(out, "hello-from") == NULL && strstr(out, "rc=0") == NULL); + if (want && !strcmp(want, "BLOCKED")) ok = strstr(out, "rc=7") != NULL; + printf(" %s %s\n", ok ? "PASS" : "FAIL", what); if (!ok) printf(" got: %.120s\n", out); + ok ? pass++ : fail++; +} +static void check(const char *what, int cond) { printf(" %s %s\n", cond ? "PASS" : "FAIL", what); cond ? pass++ : fail++; } + +int main(void) +{ + char err[256] = ""; + ProxyBridge_SetLogCallback(on_log); ProxyBridge_SetConnectionCallback(on_conn); ProxyBridge_SetTrafficLoggingEnabled(true); + + printf("== validation\n"); + check("invalid host rejected with a reason", !ProxyBridge_ValidateRule("curl", "999.1.1.1", "80", err, sizeof err) && err[0]); + check("invalid port rejected", !ProxyBridge_ValidateRule("curl", "*", "99999", err, sizeof err)); + check("domain accepted", ProxyBridge_ValidateRule("curl", "*.example.org", "443", err, sizeof err)); + check("AddRule with bad pattern returns 0 + message", ProxyBridge_AddRule("curl", "1.2.3", "*", RULE_PROTOCOL_TCP, RULE_ACTION_PROXY, 0, err, sizeof err) == 0 && err[0]); + check("AddProxy with empty host rejected", ProxyBridge_AddProxy("x", PROXY_TYPE_SOCKS5, "", 1080, "", "", err, sizeof err) == 0); + check("AddProxy with port 0 rejected", ProxyBridge_AddProxy("x", PROXY_TYPE_SOCKS5, "h", 0, "", "", err, sizeof err) == 0); + + uint32_t pa = ProxyBridge_AddProxy("a", PROXY_TYPE_SOCKS5, "127.0.0.1", 19080, "", "", err, sizeof err); + uint32_t pb = ProxyBridge_AddProxy("b", PROXY_TYPE_SOCKS5, "127.0.0.1", 19081, "bob", "pw", err, sizeof err); + uint32_t pc = ProxyBridge_AddProxy("c", PROXY_TYPE_HTTP, "127.0.0.1", 19082, "", "", err, sizeof err); + check("three proxies added with distinct ids", pa && pb && pc && pa != pb && pb != pc); + uint32_t r1 = ProxyBridge_AddRule("curl", "192.0.2.0/24", "*", RULE_PROTOCOL_TCP, RULE_ACTION_PROXY, pa, err, sizeof err); + check("rule added", r1 != 0); + check("Start", ProxyBridge_Start()); + sleep(1); + + printf("== live edits take effect on the next connection\n"); + expect("initial: rule -> proxy a", "192.0.2.5", "hello-from-A dst=192.0.2.5:80"); + check("EditRule -> proxy b", ProxyBridge_EditRule(r1, "curl", "192.0.2.0/24", "*", RULE_PROTOCOL_TCP, RULE_ACTION_PROXY, pb, err, sizeof err)); + expect("after edit: proxy b", "192.0.2.5", "hello-from-B dst=192.0.2.5:80"); + check("EditRule with a bad pattern fails and keeps the old rule", !ProxyBridge_EditRule(r1, "curl", "bad host", "*", RULE_PROTOCOL_TCP, RULE_ACTION_PROXY, pb, err, sizeof err)); + expect("old rule still active", "192.0.2.5", "hello-from-B"); + check("DisableRule", ProxyBridge_DisableRule(r1)); + expect("disabled: traffic is direct", "192.0.2.5", NULL); + check("EnableRule", ProxyBridge_EnableRule(r1)); + expect("enabled again", "192.0.2.5", "hello-from-B"); + + printf("== ordering\n"); + uint32_t r2 = ProxyBridge_AddRule("curl", "192.0.2.5", "*", RULE_PROTOCOL_TCP, RULE_ACTION_BLOCK, 0, err, sizeof err); + expect("BLOCK rule added after the PROXY rule loses (first match wins)", "192.0.2.5", "hello-from-B"); + check("MoveRule to the top", ProxyBridge_MoveRule(r2, 0)); + expect("after move: BLOCK wins", "192.0.2.5", "BLOCKED"); + expect("other addresses still proxied", "192.0.2.6", "hello-from-B dst=192.0.2.6:80"); + check("MoveRule back to the end", ProxyBridge_MoveRule(r2, 99)); + expect("PROXY wins again", "192.0.2.5", "hello-from-B"); + check("DeleteRule (the BLOCK rule)", ProxyBridge_DeleteRule(r2)); + check("DeleteRule on an unknown id fails", !ProxyBridge_DeleteRule(424242)); + + printf("== proxy lifecycle\n"); + check("DeleteProxy(b)", ProxyBridge_DeleteProxy(pb)); + expect("rule that used a deleted proxy falls back to the default proxy (a)", "192.0.2.5", "hello-from-A dst=192.0.2.5:80"); + ProxyBridge_ClearProxies(); + expect("no proxies at all: PROXY rule fails CLOSED (blocked, never direct)", "192.0.2.5", "BLOCKED"); + uint32_t pb2 = ProxyBridge_AddProxy("b2", PROXY_TYPE_SOCKS5, "127.0.0.1", 19081, "bob", "pw", err, sizeof err); + uint32_t pa2 = ProxyBridge_AddProxy("a2", PROXY_TYPE_SOCKS5, "127.0.0.1", 19080, "", "", err, sizeof err); + check("proxies re-added", pa2 && pb2); + check("EditRule to proxy_id 0 (= default)", ProxyBridge_EditRule(r1, "curl", "192.0.2.0/24", "*", RULE_PROTOCOL_TCP, RULE_ACTION_PROXY, 0, err, sizeof err)); + expect("default = first proxy added (b2)", "192.0.2.5", "hello-from-B"); + ProxyBridge_SetDefaultProxy(pa2); + expect("SetDefaultProxy(a2) retargets proxy_id 0 rules", "192.0.2.5", "hello-from-A"); + check("EditProxy changes the endpoint live (a2 -> HTTP proxy c's port)", ProxyBridge_EditProxy(pa2, "a2", PROXY_TYPE_HTTP, "127.0.0.1", 19082, "", "", err, sizeof err)); + expect("now served through the HTTP proxy", "192.0.2.5", "hello-from-C dst=192.0.2.5:80"); + + printf("== stop/start keeps configuration\n"); + check("Stop", ProxyBridge_Stop()); + expect("while stopped: nothing redirected", "192.0.2.5", NULL); + check("Start again", ProxyBridge_Start()); + sleep(1); + expect("rules and proxies survived Stop/Start", "192.0.2.5", "hello-from-C"); + + printf("== proxy test helper\n"); + char buf[512] = ""; + check("TestProxy through a SOCKS5 proxy works", ProxyBridge_TestProxy(pb2, "192.0.2.77", 80, buf, sizeof buf) == 0); + printf(" (%s)\n", buf); + uint32_t dead = ProxyBridge_AddProxy("dead", PROXY_TYPE_SOCKS5, "127.0.0.1", 19999, "", "", err, sizeof err); + buf[0] = 0; check("TestProxy against a dead proxy fails with a readable message", ProxyBridge_TestProxy(dead, "192.0.2.77", 80, buf, sizeof buf) != 0 && strlen(buf) > 5); + printf(" (%s)\n", buf); + + printf("== cleanup\n"); + ProxyBridge_ClearRules(); + expect("ClearRules: direct again", "192.0.2.5", NULL); + check("connection events were reported to the callback", events > 0); + ProxyBridge_Stop(); + printf("\npassed=%d failed=%d\n", pass, fail); + return fail != 0; +} diff --git a/Linux/tests/check_binaries.sh b/Linux/tests/check_binaries.sh new file mode 100755 index 00000000..b4dbdb13 --- /dev/null +++ b/Linux/tests/check_binaries.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Audits the built binaries: portability (glibc floor, no libbpf dependency) and hardening. +# tests/check_binaries.sh [dir] MAX_GLIBC=2.34 (default) is the highest glibc symbol version allowed +set -u +DIR="${1:-$(dirname "$(dirname "${BASH_SOURCE[0]}")")/output}" +MAX_GLIBC="${MAX_GLIBC:-2.34}" +fails=0 +ok() { printf ' ok %s\n' "$1"; } +bad() { printf ' FAIL %s\n' "$1"; fails=$((fails + 1)); } +check() { if eval "$2"; then ok "$1"; else bad "$1"; fi; } + +audit() { # file kind(lib|exe) allowed-NEEDED-regex + local f="$DIR/$1" kind="$2" needed_ok="$3" + [ -f "$f" ] || { [ "$1" = ProxyBridgeGUI ] && { echo "== $1 (not built)"; return; }; bad "$1 is missing"; return; } + echo "== $1" + local hdr prog dyn syms need + hdr=$(readelf -h "$f"); prog=$(readelf -lW "$f"); dyn=$(readelf -d "$f"); syms=$(nm -D "$f" 2>/dev/null) + if [ "$kind" = exe ]; then check "position independent executable" 'grep -q "Position-Independent" <<<"$hdr"' + else check "shared object" 'grep -q "DYN" <<<"$hdr"'; fi + check "full RELRO (GNU_RELRO + BIND_NOW)" 'grep -q GNU_RELRO <<<"$prog" && grep -q -E "BIND_NOW|FLAGS.*NOW" <<<"$dyn"' + check "non-executable stack" 'grep GNU_STACK <<<"$prog" | grep -vq "RWE"' + check "no writable+executable segments" '! grep -E "LOAD" <<<"$prog" | grep -q "RWE"' + check "no text relocations" '! grep -q TEXTREL <<<"$dyn"' + check "stack protector in use" 'grep -q __stack_chk_fail <<<"$syms"' + check "fortified libc calls in use" 'grep -q "_chk" <<<"$syms"' + if [ "$(uname -m)" = x86_64 ]; then check "CET (IBT + SHSTK) marked" 'readelf -n "$f" | grep -q IBT && readelf -n "$f" | grep -q SHSTK'; fi + need=$(grep NEEDED <<<"$dyn" | sed 's/.*\[\(.*\)\]/\1/' | tr '\n' ' ') + check "dependencies are only portable libraries ($need)" '! tr " " "\n" <<<"$need" | grep -v "^$" | grep -q -v -E "$needed_ok"' + local top + top=$(objdump -T "$f" 2>/dev/null | grep -o 'GLIBC_[0-9][0-9.]*' | sed 's/GLIBC_//' | sort -uV | tail -1) + check "needs glibc ${top:-none} (limit $MAX_GLIBC, see issue #146)" '[ -z "$top" ] || [ "$(printf "%s\n%s\n" "$top" "$MAX_GLIBC" | sort -V | tail -1)" = "$MAX_GLIBC" ]' + if [ -n "$top" ] && [ "$(printf '%s\n%s\n' "$top" "$MAX_GLIBC" | sort -V | tail -1)" != "$MAX_GLIBC" ]; then + objdump -T "$f" | grep -E "GLIBC_(2\.(3[5-9]|[4-9][0-9])|[3-9])" | awk '{print " "$NF" "$(NF-1)}' | head -5 + fi +} + +LIBBPF_DEP=''; [ "${ALLOW_DYNAMIC_LIBBPF:-0}" = 1 ] && LIBBPF_DEP='|libbpf\.so\.1' +audit libproxybridge.so lib "^(libc\.so\.6|libelf\.so\.1|libz\.so\.1|libm\.so\.6|ld-linux.*$LIBBPF_DEP)\$" +exports=$(nm -D --defined-only "$DIR/libproxybridge.so" 2>/dev/null | awk '$2=="T"{print $3}' | grep -v '^ProxyBridge_' | tr '\n' ' ') +check "library exports only the ProxyBridge_* API${exports:+ (also: $exports)}" '[ -z "$exports" ]' +audit ProxyBridge exe '^(libc\.so\.6|libproxybridge\.so|libpthread\.so\.0|libm\.so\.6|ld-linux.*)$' +audit ProxyBridgeGUI exe '^(libc\.so\.6|libproxybridge\.so|libpthread\.so\.0|libm\.so\.6|libgtk-3\.so\.0|libgdk-3\.so\.0|libglib-2\.0\.so\.0|libgobject-2\.0\.so\.0|libgio-2\.0\.so\.0|libcairo\.so\.2|libgdk_pixbuf-2\.0\.so\.0|libpango-1\.0\.so\.0|libpangocairo-1\.0\.so\.0|libatk-1\.0\.so\.0|ld-linux.*)$' + +echo +if [ "$fails" = 0 ]; then echo "binary audit: all checks passed"; else echo "binary audit: $fails check(s) failed"; fi +[ "$fails" = 0 ] diff --git a/Linux/tests/churn.sh b/Linux/tests/churn.sh new file mode 100755 index 00000000..82ba525e --- /dev/null +++ b/Linux/tests/churn.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Rule/proxy churn with live traffic. Needs root, curl, python3, gcc. sudo tests/churn.sh [iterations] +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; ROOT="$(dirname "$HERE")" +PB_DIR="${PB_DIR:-$ROOT/output}" +[ -f "$PB_DIR/libproxybridge.so" ] || { echo "Build first: (cd Linux && ./build.sh), or set PB_DIR"; exit 1; } +WORK=$(mktemp -d); trap 'kill $FAKE $TRAFFIC 2>/dev/null; wait 2>/dev/null; rm -rf $WORK' EXIT +gcc -Wall -Wextra -O1 -g -I"$ROOT/src" -o "$WORK/churn_test" "$HERE/churn_test.c" -L"$PB_DIR" -lproxybridge -lpthread || exit 1 +python3 "$HERE/fake_proxies.py" "$WORK/log" >/dev/null 2>&1 & FAKE=$! +sleep 1 +( while :; do + curl -s -m 2 http://192.0.2.5/ >/dev/null + python3 -c " +import socket +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(1) +s.sendto(b'x', ('192.0.2.9', 5300)); s.recv(100)" 2>/dev/null + done ) & TRAFFIC=$! +env LD_LIBRARY_PATH="$PB_DIR" ${PB_PRELOAD:+LD_PRELOAD="$PB_PRELOAD" PB_SANITIZED=1} "$WORK/churn_test" "${1:-3000}" diff --git a/Linux/tests/churn_test.c b/Linux/tests/churn_test.c new file mode 100644 index 00000000..2d82f50b --- /dev/null +++ b/Linux/tests/churn_test.c @@ -0,0 +1,82 @@ +// Rule/proxy churn while traffic flows: memory, descriptors and threads must stay flat. +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include "../src/ProxyBridge.h" + +static long status_kb(const char *key) +{ + FILE *f = fopen("/proc/self/status", "r"); + char line[256]; + long v = -1; + size_t n = strlen(key); + while (f && fgets(line, sizeof line, f)) + if (!strncmp(line, key, n)) { v = strtol(line + n, NULL, 10); break; } + if (f) fclose(f); + return v; +} + +static int count_dir(const char *path) +{ + DIR *d = opendir(path); + int n = 0; + struct dirent *e; + while (d && (e = readdir(d))) n += e->d_name[0] != '.'; + if (d) closedir(d); + return n; +} + +struct sample { long rss_kb, heap_kb; long threads; int fds; }; + +static struct sample sample(void) +{ + struct mallinfo2 mi = mallinfo2(); + return (struct sample){ status_kb("VmRSS:"), (long)((mi.uordblks + mi.hblkhd) / 1024), status_kb("Threads:"), count_dir("/proc/self/fd") }; +} + +int main(int argc, char **argv) +{ + int iterations = argc > 1 ? atoi(argv[1]) : 5000; + char err[256]; + uint32_t pa = ProxyBridge_AddProxy("a", PROXY_TYPE_SOCKS5, "127.0.0.1", 19080, "", "", err, sizeof err); + uint32_t pb = ProxyBridge_AddProxy("b", PROXY_TYPE_SOCKS5, "127.0.0.1", 19081, "bob", "pw", err, sizeof err); + ProxyBridge_AddRule("curl", "192.0.2.0/24", "*", RULE_PROTOCOL_TCP, RULE_ACTION_PROXY, pa, err, sizeof err); + ProxyBridge_AddRule("python3*", "192.0.2.0/24", "5300", RULE_PROTOCOL_UDP, RULE_ACTION_PROXY, pb, err, sizeof err); + ProxyBridge_AddRule("*", "*.example.test", "*", RULE_PROTOCOL_TCP, RULE_ACTION_PROXY, pa, err, sizeof err); + if (!ProxyBridge_Start()) { fprintf(stderr, "start failed\n"); return 2; } + sleep(1); + + struct sample warm = { 0 }; + int warm_at = iterations / 10; + for (int i = 0; i < iterations; i++) { + char hosts[64]; + snprintf(hosts, sizeof hosts, "10.%d.%d.0/24;*.churn%d.test", i % 200, (i / 200) % 200, i % 7); + uint32_t r1 = ProxyBridge_AddRule("wget;curl", hosts, "80;443;8000-9000", RULE_PROTOCOL_BOTH, RULE_ACTION_PROXY, i % 2 ? pa : pb, err, sizeof err); + uint32_t r2 = ProxyBridge_AddRule("*chrome*", "198.51.100.0/24", "*", RULE_PROTOCOL_TCP, RULE_ACTION_BLOCK, 0, err, sizeof err); + ProxyBridge_EditRule(r1, "wget", "203.0.113.0/24", "*", RULE_PROTOCOL_TCP, RULE_ACTION_DIRECT, 0, err, sizeof err); + ProxyBridge_MoveRule(r2, 0); + ProxyBridge_DisableRule(r1); + ProxyBridge_EnableRule(r1); + uint32_t p = ProxyBridge_AddProxy("tmp", i % 2 ? PROXY_TYPE_HTTP : PROXY_TYPE_SOCKS5, "127.0.0.1", 19082, "", "", err, sizeof err); + ProxyBridge_EditProxy(p, "tmp2", PROXY_TYPE_SOCKS5, "localhost", 19083, "u", "p", err, sizeof err); + ProxyBridge_DeleteRule(r1); + ProxyBridge_DeleteRule(r2); + ProxyBridge_DeleteProxy(p); + if (i % 500 == 499) { ProxyBridge_Stop(); ProxyBridge_Start(); } /* full restart cycles */ + if (i == warm_at) warm = sample(); + if (i % 1000 == 999) { struct sample s = sample(); printf("iteration %d: rss=%ld KB heap in use=%ld KB threads=%ld fds=%d\n", i + 1, s.rss_kb, s.heap_kb, s.threads, s.fds); fflush(stdout); } + } + struct sample end = sample(); + ProxyBridge_Stop(); + long rss_growth = end.rss_kb - warm.rss_kb, heap_growth = end.heap_kb - warm.heap_kb; + printf("warm-up: rss=%ld heap=%ld KB threads=%ld fds=%d | end: rss=%ld heap=%ld KB threads=%ld fds=%d | growth: rss %ld KB, heap in use %ld KB\n", + warm.rss_kb, warm.heap_kb, warm.threads, warm.fds, end.rss_kb, end.heap_kb, end.threads, end.fds, rss_growth, heap_growth); + int sanitized = getenv("PB_SANITIZED") != NULL; /* sanitizer quarantines distort both numbers */ + int ok = (sanitized || heap_growth < 256) && end.fds <= warm.fds + 2 && end.threads <= warm.threads + 1; + printf("%s\n", ok ? "PASS heap, descriptors and threads stay flat under churn" : "FAIL growth under churn"); + return !ok; +} diff --git a/Linux/tests/dnsbench.sh b/Linux/tests/dnsbench.sh new file mode 100755 index 00000000..c39ec013 --- /dev/null +++ b/Linux/tests/dnsbench.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Latency/throughput of DNS queries with and without the DNS tap. Needs root, python3. +# sudo tests/dnsbench.sh [queries] +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; ROOT="$(dirname "$HERE")"; N=${1:-20000} +PB_DIR="${PB_DIR:-$ROOT/output}" # directory holding the built ProxyBridge + libproxybridge.so (run ../build.sh first) +[ -x "$PB_DIR/ProxyBridge" ] && [ -f "$PB_DIR/libproxybridge.so" ] || { echo "Build first: (cd Linux && ./build.sh), or set PB_DIR to a directory containing ProxyBridge and libproxybridge.so"; exit 1; } +export LD_LIBRARY_PATH="$PB_DIR"; DNSIP=10.77.0.3 +WORK=$(mktemp -d); trap 'kill $DNS $PB 2>/dev/null; wait 2>/dev/null; ip addr del $DNSIP/32 dev lo 2>/dev/null; rm -rf $WORK' EXIT +ip addr add $DNSIP/32 dev lo 2>/dev/null +python3 "$HERE/fake_dns.py" $DNSIP $WORK/dns.log >/dev/null 2>&1 & DNS=$! +sleep 1 +bench() { python3 - "$N" "$DNSIP" <<'PY' +import socket, struct, sys, time +n, ip = int(sys.argv[1]), sys.argv[2] +q = struct.pack("!HHHHHH", 1, 0x0100, 1, 0, 0, 0) + b"\x03app\x07example\x04test\x00" + struct.pack("!HH", 1, 1) +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(2) +lat = [] +t0 = time.perf_counter() +for i in range(n): + a = time.perf_counter(); s.sendto(q, (ip, 53)); s.recvfrom(512); lat.append(time.perf_counter() - a) +dt = time.perf_counter() - t0; lat.sort() +print(" %d queries: %.0f qps avg=%.0fus p50=%.0fus p99=%.0fus" % (n, n / dt, sum(lat) / n * 1e6, lat[n // 2] * 1e6, lat[int(n * 0.99)] * 1e6)) +PY +} +echo "== no ProxyBridge"; bench; bench +stdbuf -oL "$PB_DIR/ProxyBridge" --proxy a=socks5://127.0.0.1:19080 --rule "*:*.example.test:*:TCP:PROXY=a" > $WORK/pb.out 2>&1 & PB=$! +for _ in $(seq 40); do grep -q "ProxyBridge started" $WORK/pb.out && break; sleep 0.25; done +echo "== ProxyBridge running, python3 covered by a domain rule (every query goes through the DNS tap)"; bench; bench +kill -INT $PB; wait $PB 2>/dev/null +stdbuf -oL "$PB_DIR/ProxyBridge" --proxy a=socks5://127.0.0.1:19080 --rule "someother:*.example.test:*:TCP:PROXY=a" > $WORK/pb.out 2>&1 & PB=$! +for _ in $(seq 40); do grep -q "ProxyBridge started" $WORK/pb.out && break; sleep 0.25; done +echo "== ProxyBridge running, python3 NOT covered (DNS untouched by the tap)"; bench; bench diff --git a/Linux/tests/e2e.sh b/Linux/tests/e2e.sh new file mode 100755 index 00000000..2607b05a --- /dev/null +++ b/Linux/tests/e2e.sh @@ -0,0 +1,145 @@ +#!/bin/bash +# End-to-end test of the eBPF backend. Needs root, curl, python3. Uses only loopback + TEST-NET addresses (nothing leaves the host). +# sudo Linux/tests/e2e.sh +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(dirname "$HERE")" +PB_DIR="${PB_DIR:-$ROOT/output}" # directory holding the built ProxyBridge + libproxybridge.so (run ../build.sh first) +[ -x "$PB_DIR/ProxyBridge" ] && [ -f "$PB_DIR/libproxybridge.so" ] || { echo "Build first: (cd Linux && ./build.sh), or set PB_DIR to a directory containing ProxyBridge and libproxybridge.so"; exit 1; } +WORK="$(mktemp -d)" +LOG="$WORK/proxy.log"; : > "$LOG" +PASS=0; FAIL=0 +ok() { echo " PASS $1"; PASS=$((PASS+1)); } +bad() { echo " FAIL $1"; FAIL=$((FAIL+1)); } +expect_contains() { # name haystack needle + if [[ "$2" == *"$3"* ]]; then ok "$1"; else bad "$1 (got: ${2:0:200})"; fi; } + +cleanup() { kill "$PB_PID" "$FAKE_PID" "$DIRECT_PID" 2>/dev/null; wait 2>/dev/null; rm -rf "$WORK"; } +trap cleanup EXIT + +[ "$(id -u)" = 0 ] || { echo "run as root"; exit 1; } +export LD_LIBRARY_PATH="$PB_DIR" + +python3 "$HERE/fake_proxies.py" "$LOG" > "$WORK/fake.out" 2>&1 & FAKE_PID=$! +# a direct (non-proxied) origin on the host's own LAN address, so DIRECT rules can be seen to work +MYIP=$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p' | head -1); MYIP=${MYIP:-127.0.0.1} +mkdir -p "$WORK/www"; echo "direct-origin" > "$WORK/www/index.html" +python3 -m http.server 18399 --bind "$MYIP" --directory "$WORK/www" >/dev/null 2>&1 & DIRECT_PID=$! +sleep 1 + +cp "$(command -v curl)" "$WORK/newcurl" # a binary that did not exist when ProxyBridge started + +stdbuf -oL "$PB_DIR/ProxyBridge" --verbose 3 \ + --proxy a=socks5://127.0.0.1:19080 --proxy b=socks5://bob:pw@127.0.0.1:19081 --proxy c=http://127.0.0.1:19082 \ + --rule "curl:$MYIP/32:18399:TCP:DIRECT" \ + --rule "curl:192.0.2.0/24:*:TCP:PROXY=a" \ + --rule "curl:198.51.100.0/24:*:TCP:PROXY=b" \ + --rule "curl:203.0.113.0/24:*:TCP:PROXY=c" \ + --rule "curl:203.0.114.0/24:*:TCP:BLOCK" \ + --rule "curl:2001:db8::/32:80:TCP:PROXY=a" \ + --rule "curl:10.200.0.0/24:*:TCP:PROXY=a" \ + --rule "newcurl:192.0.2.0/24:*:TCP:PROXY=b" \ + --rule "python3*:192.0.2.0/24:5300:UDP:PROXY=a" \ + --rule "python3*:198.51.100.0/24:5300:UDP:PROXY=b" \ + --rule "python3*:2001:db8::/32:*:BOTH:PROXY=a" \ + > "$WORK/pb.out" 2>&1 & PB_PID=$! +for _ in $(seq 40); do grep -q "ProxyBridge started" "$WORK/pb.out" && break; sleep 0.25; done +if ! grep -q "ProxyBridge started" "$WORK/pb.out"; then echo "ProxyBridge failed to start:"; cat "$WORK/pb.out"; exit 1; fi +sleep 1 + +echo "== TCP: per-rule proxy selection, original destination recovery" +out=$(curl -s -m 5 http://192.0.2.7/); expect_contains "proxy A via SOCKS5" "$out" "hello-from-A dst=192.0.2.7:80" +out=$(curl -s -m 5 http://198.51.100.7:8080/); expect_contains "proxy B via SOCKS5 user/pass" "$out" "hello-from-B dst=198.51.100.7:8080" +out=$(curl -s -m 5 http://203.0.113.7:81/); expect_contains "proxy C via HTTP CONNECT" "$out" "hello-from-C dst=203.0.113.7:81" +out=$(curl -s -m 5 http://203.0.114.7/ 2>&1; echo "rc=$?"); expect_contains "BLOCK rule rejects connect()" "$out" "rc=7" +out=$(curl -s -m 5 "http://$MYIP:18399/"); expect_contains "DIRECT rule bypasses the proxies" "$out" "direct-origin" +out=$(curl -s -g -m 5 'http://[2001:db8::7]/'); expect_contains "IPv6 destination through proxy A" "$out" "hello-from-A dst=2001:db8::7:80" +out=$(curl -s -m 5 http://127.0.0.1:19080/ 2>&1; echo "rc=$?"); expect_contains "loopback is never redirected" "$out" "rc=" +out=$("$WORK/newcurl" -s -m 5 http://192.0.2.8/); expect_contains "binary unknown at start (slow path)" "$out" "hello-from-B dst=192.0.2.8:80" +out=$("$WORK/newcurl" -s -m 5 http://192.0.2.9/); expect_contains "same binary, learned class (fast path)" "$out" "hello-from-B dst=192.0.2.9:80" +out=$(curl -s -m 5 http://192.0.2.10/); expect_contains "other binary still uses its own rule" "$out" "hello-from-A dst=192.0.2.10:80" +pids=(); for i in $(seq 30); do curl -s -m 5 http://192.0.2.$((20+i))/ >/dev/null & pids+=($!); done; wait "${pids[@]}" 2>/dev/null +sleep 1 +c=$(grep -c "^A TCP 192.0.2.\(2[1-9]\|3[0-9]\|4[0-9]\|50\):80" "$LOG"); [ "$c" -ge 30 ] && ok "30 concurrent connections all proxied ($c)" || bad "concurrent: only $c/30 reached proxy A" + +echo "== isolation: other network namespaces (containers) are never redirected" +if ip netns add pbtest 2>/dev/null; then + ip netns exec pbtest ip link set lo up; ip netns exec pbtest ip addr add 10.200.0.1/32 dev lo + ip netns exec pbtest python3 -m http.server 18400 --bind 10.200.0.1 --directory "$WORK/www" >/dev/null 2>&1 & NSPID=$! + sleep 1 + out=$(ip netns exec pbtest curl -s -m 4 http://10.200.0.1:18400/ 2>&1; echo "rc=$?") + expect_contains "curl inside a netns reaches its own server (rule matches the IP, but the namespace is foreign)" "$out" "direct-origin" + kill $NSPID 2>/dev/null; ip netns del pbtest 2>/dev/null +else + echo " SKIP ip netns not available" +fi + +echo "== UDP: unconnected, multi-destination, connected, IPv6" +out=$(python3 - <<'PY' +import socket +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(4) +res = [] +for dst in [("192.0.2.9", 5300), ("198.51.100.9", 5300), ("192.0.2.9", 5300)]: + s.sendto(b"ping", dst) + d, a = s.recvfrom(200) + res.append(f"{d.decode()}|from={a[0]}:{a[1]}") +print("\n".join(res)) +c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); c.settimeout(4) +c.connect(("192.0.2.9", 5300)); c.send(b"conn"); d = c.recv(200) +print("connected:" + d.decode() + "|peer=%s:%d" % c.getpeername()) +s6 = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM); s6.settimeout(4) +try: + s6.sendto(b"v6", ("2001:db8::9", 5300)); print("v6 unconnected: sent") +except OSError as e: + print("v6 unconnected: blocked errno=%d" % e.errno) +c6 = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM); c6.settimeout(4) +c6.connect(("2001:db8::9", 5300)); c6.send(b"v6c"); d = c6.recv(200) +print("v6 connected:" + d.decode() + "|peer=%s:%d" % c6.getpeername()[:2]) +d6 = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM); d6.settimeout(4) +d6.sendto(b"mapped", ("::ffff:192.0.2.9", 5300)); d, a = d6.recvfrom(200) +print("v6 socket, v4 dest:" + d.decode() + "|from=%s" % a[0]) +PY +) +expect_contains "UDP via proxy A, source restored" "$out" "UDP-A 192.0.2.9:5300 ping|from=192.0.2.9:5300" +expect_contains "same socket, second destination -> proxy B" "$out" "UDP-B 198.51.100.9:5300 ping|from=198.51.100.9:5300" +expect_contains "connected UDP works and getpeername restored" "$out" "connected:UDP-A 192.0.2.9:5300 conn|peer=192.0.2.9:5300" +expect_contains "native IPv6 unconnected UDP fails closed (EPERM)" "$out" "v6 unconnected: blocked errno=1" +expect_contains "connected IPv6 UDP through proxy A, peer restored" "$out" "v6 connected:UDP-A 2001:db8::9:5300 v6c|peer=2001:db8::9:5300" +expect_contains "dual-stack socket sending to IPv4 dest" "$out" "v6 socket, v4 dest:UDP-A 192.0.2.9:5300 mapped" + +echo "== UDP: large datagrams and many sockets" +out=$(python3 - <<'PY' +import socket, concurrent.futures as cf +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(5) +big = bytes(range(256)) * 230 # 58,880 bytes +s.sendto(big, ("192.0.2.9", 5300)); d, a = s.recvfrom(70000) +prefix = b"UDP-A 192.0.2.9:5300 " +print("big:", "ok" if d == prefix + big else "MISMATCH len=%d" % len(d)) +def one(i): + c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); c.settimeout(8) + payload = b"n%d" % i + c.sendto(payload, ("192.0.2.%d" % (100 + i % 100), 5300)) + d, a = c.recvfrom(100) + return d.endswith(payload) and a[0] == "192.0.2.%d" % (100 + i % 100) +with cf.ThreadPoolExecutor(64) as ex: + res = list(ex.map(one, range(400))) +print("many:", sum(res), "/ 400") +PY +) +expect_contains "58KB datagram through SOCKS5 UDP, intact" "$out" "big: ok" +expect_contains "400 concurrent UDP sockets, each reply from the right address" "$out" "many: 400 / 400" + +echo "== logging" +sleep 1 +grep -q "\[CONN\] curl" "$WORK/pb.out" && ok "connection events reported to the CLI" || bad "no [CONN] events" + +echo "== lifecycle" +kill -INT "$PB_PID"; wait "$PB_PID" 2>/dev/null +out=$(curl -s -m 3 http://192.0.2.7/ 2>&1; echo "rc=$?") +[[ "$out" != *hello-from* ]] && ok "after stop traffic is no longer redirected" || bad "still redirected after stop" +"${BPFTOOL:-bpftool}" prog show 2>/dev/null | grep -q pb_connect && bad "BPF programs left behind" || ok "BPF programs detached" +ss -ltn 2>/dev/null | grep -qE ":2401[0-9]" && bad "relay ports still listening" || ok "relay ports released" + +echo +echo "passed=$PASS failed=$FAIL" +[ "$FAIL" = 0 ] || { echo "---- proxy log"; cat "$LOG"; echo "---- pb output"; tail -30 "$WORK/pb.out"; exit 1; } diff --git a/Linux/tests/e2e_domains.sh b/Linux/tests/e2e_domains.sh new file mode 100755 index 00000000..8cf9b0c7 --- /dev/null +++ b/Linux/tests/e2e_domains.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# End-to-end test of domain-name rules (DNS tap). Needs root, dig, curl, python3. +# sudo Linux/tests/e2e_domains.sh +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; ROOT="$(dirname "$HERE")" +PB_DIR="${PB_DIR:-$ROOT/output}" # directory holding the built ProxyBridge + libproxybridge.so (run ../build.sh first) +[ -x "$PB_DIR/ProxyBridge" ] && [ -f "$PB_DIR/libproxybridge.so" ] || { echo "Build first: (cd Linux && ./build.sh), or set PB_DIR to a directory containing ProxyBridge and libproxybridge.so"; exit 1; } +WORK="$(mktemp -d)"; PLOG="$WORK/proxy.log"; DLOG="$WORK/dns.log"; : > "$PLOG"; : > "$DLOG" +PASS=0; FAIL=0; DNSIP=10.77.0.2 +ok() { echo " PASS $1"; PASS=$((PASS+1)); } +bad() { echo " FAIL $1"; FAIL=$((FAIL+1)); } +has() { [[ "$2" == *"$3"* ]] && ok "$1" || bad "$1 (got: ${2:0:160})"; } +cleanup() { kill "$PB_PID" "$FAKE_PID" "$DNS_PID" 2>/dev/null; wait 2>/dev/null; ip addr del $DNSIP/32 dev lo 2>/dev/null; rm -rf "$WORK"; } +trap cleanup EXIT +[ "$(id -u)" = 0 ] || { echo "run as root"; exit 1; } +export LD_LIBRARY_PATH="$PB_DIR" +ip addr add $DNSIP/32 dev lo 2>/dev/null +python3 "$HERE/fake_proxies.py" "$PLOG" > /dev/null 2>&1 & FAKE_PID=$! +python3 "$HERE/fake_dns.py" $DNSIP "$DLOG" > /dev/null 2>&1 & DNS_PID=$! +sleep 1 + +stdbuf -oL "$PB_DIR/ProxyBridge" --verbose 2 \ + --proxy a=socks5://127.0.0.1:19080 --proxy b=socks5://bob:pw@127.0.0.1:19081 --proxy h=socks5://localhost:19080 \ + --rule "*:blocked.example.test:*:TCP:BLOCK" \ + --rule "*:*.example.test:*:TCP:PROXY=a" \ + --rule "*:*.corp.test;*.cdnnet.test:*:TCP:PROXY=b" \ + --rule "*:svc.byname.test:*:TCP:PROXY=h" \ + > "$WORK/pb.out" 2>&1 & PB_PID=$! +for _ in $(seq 40); do grep -q "ProxyBridge started" "$WORK/pb.out" && break; sleep 0.25; done +grep -q "ProxyBridge started" "$WORK/pb.out" || { echo "ProxyBridge failed to start"; cat "$WORK/pb.out"; exit 1; } +sleep 1 + +resolve() { dig @$DNSIP "$@" +short +time=3 +tries=1 | head -1; } + +echo "== resolver answers are delivered unchanged" +ip1=$(resolve app.example.test); has "UDP DNS answer reaches the application" "$ip1" "192.0.2.77" +ip2=$(dig @$DNSIP +tcp tcp.example.test +short +time=3 +tries=1 | head -1); has "TCP DNS answer reaches the application" "$ip2" "192.0.2.80" +nx=$(dig @$DNSIP nope.example.test +time=3 +tries=1 | grep -c NXDOMAIN); [ "$nx" -ge 1 ] && ok "NXDOMAIN is passed through" || bad "NXDOMAIN lost" + +echo "== domain rules: resolve with one process, connect with another" +out=$(curl -s -m 5 http://192.0.2.77/); has "*.example.test -> proxy a (UDP DNS learned)" "$out" "hello-from-A dst=192.0.2.77:80" +out=$(curl -s -m 5 http://192.0.2.80/); has "*.example.test -> proxy a (TCP DNS learned)" "$out" "hello-from-A dst=192.0.2.80:80" +resolve blocked.example.test >/dev/null +out=$(curl -s -m 5 http://192.0.2.78/ 2>&1; echo "rc=$?"); has "exact-name BLOCK rule beats the wildcard rule" "$out" "rc=7" +resolve www.corp.test >/dev/null +out=$(curl -s -m 5 http://192.0.2.79/); has "CNAME chain: rule on the canonical name -> proxy b" "$out" "hello-from-B dst=192.0.2.79:80" +resolve svc.byname.test >/dev/null +out=$(curl -s -m 5 http://192.0.2.81/); has "domain rule + proxy configured by hostname (localhost)" "$out" "hello-from-A dst=192.0.2.81:80" + +echo "== things that must NOT match" +resolve plain.other.test >/dev/null +out=$(curl -s -m 2 http://192.0.2.82/ 2>&1; echo "rc=$?"); [[ "$out" != *hello-from* ]] && ok "resolved name without a rule is not proxied" || bad "unexpected proxying" +out=$(curl -s -m 2 http://192.0.2.99/ 2>&1; echo "rc=$?"); [[ "$out" != *hello-from* ]] && ok "IP never seen in DNS is not matched by a domain rule" || bad "unexpected proxying" +grep -q "192.0.2.82\|192.0.2.99" "$PLOG" && bad "proxy saw traffic it should not have" || ok "proxies saw no unmatched destinations" + +echo "== latency: first connection right after the answer (no race)" +okc=0; for i in 1 2 3 4 5; do resolve app.example.test >/dev/null; r=$(curl -s -m 5 http://192.0.2.77/); [[ "$r" == *hello-from-A* ]] && okc=$((okc+1)); done +[ "$okc" = 5 ] && ok "5/5 connections matched immediately after resolution" || bad "only $okc/5 matched immediately" + +echo "== logging" +sleep 1 +grep -q "192.0.2.77 (app.example.test)" "$WORK/pb.out" && ok "connection log shows the domain name" || bad "no domain name in connection log" + +echo "== lifecycle" +kill -INT "$PB_PID"; wait "$PB_PID" 2>/dev/null +out=$(dig @$DNSIP app.example.test +short +time=3 +tries=1 | head -1); has "DNS still works after ProxyBridge stops" "$out" "192.0.2.77" +"${BPFTOOL:-bpftool}" prog show 2>/dev/null | grep -q pb_connect && bad "BPF programs left behind" || ok "BPF programs detached" + +echo; echo "passed=$PASS failed=$FAIL" +[ "$FAIL" = 0 ] || { echo "---- dns log"; cat "$DLOG"; echo "---- proxy log"; cat "$PLOG"; echo "---- pb output"; tail -30 "$WORK/pb.out"; exit 1; } diff --git a/Linux/tests/e2e_nexus.sh b/Linux/tests/e2e_nexus.sh new file mode 100755 index 00000000..b404d5ea --- /dev/null +++ b/Linux/tests/e2e_nexus.sh @@ -0,0 +1,246 @@ +#!/bin/bash +# ProxyBridge against a REAL forwarding SOCKS5 server: Nexus Proxy (https://github.com/InterceptSuite/nexus-proxy). +# Real origin servers, real DNS, real transfers with checksums; two Nexus instances (open + username/password) so that rules +# can pick a proxy. Needs root, node, dig, curl, python3, and the Nexus script. +# +# sudo NODE=/path/to/node NEXUS_JS=/path/to/script.js tests/e2e_nexus.sh +# +# Note: stock Nexus cannot do UDP ASSOCIATE on Linux (its IPv6 relay socket binds '0.0.0.0' -> EINVAL, IPv4 destinations are not +# sent as ::ffff: addresses and mapped replies are not matched). Apply tests/nexus-linux-udp.patch to a copy of script.js for the +# UDP part; without it the UDP tests are reported as SKIP and only TCP/DNS-over-TCP is tested. +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; ROOT="$(dirname "$HERE")" +PB_DIR="${PB_DIR:-$ROOT/output}" +[ -x "$PB_DIR/ProxyBridge" ] && [ -f "$PB_DIR/libproxybridge.so" ] || { echo "Build first: (cd Linux && ./build.sh), or set PB_DIR"; exit 1; } +NODE="${NODE:-node}"; NEXUS_JS="${NEXUS_JS:-}" +command -v "$NODE" >/dev/null 2>&1 && [ -f "$NEXUS_JS" ] || { echo "SKIP: set NODE and NEXUS_JS (see header)"; exit 0; } +[ "$(id -u)" = 0 ] || { echo "run as root"; exit 1; } +export LD_LIBRARY_PATH="$PB_DIR" +WORK="$(mktemp -d)"; PASS=0; FAIL=0; SKIP=0; PIDS=() +ok() { echo " PASS $1"; PASS=$((PASS+1)); } +bad() { echo " FAIL $1"; FAIL=$((FAIL+1)); } +skip() { echo " SKIP $1"; SKIP=$((SKIP+1)); } +info() { echo " info $1"; } +has() { [[ "$2" == *"$3"* ]] && ok "$1" || bad "$1 (got: ${2:0:150})"; } +eq() { [ "$2" = "$3" ] && ok "$1" || bad "$1 (got: ${2:0:150})"; } # exact match +inlog() { grep -qF -- "$2" "$1" 2>/dev/null; } # log file, text +cleanup() { for p in "${PIDS[@]}"; do kill "$p" 2>/dev/null; done; wait 2>/dev/null + for i in 1 2 3 4 5 6 53; do ip addr del 10.77.1.$i/32 dev lo 2>/dev/null; done; rm -rf "$WORK"; } +trap cleanup EXIT +for i in 1 2 3 4 5 6 53; do ip addr add 10.77.1.$i/32 dev lo 2>/dev/null; done + +# ---- udp support probe: does this Nexus copy handle UDP ASSOCIATE on Linux? +"$NODE" "$NEXUS_JS" -p 1099 >/dev/null 2>&1 & PROBE=$!; sleep 1.5 +UDPOK=$(python3 - <<'PY' +import socket +try: + c = socket.create_connection(("127.0.0.1", 1099), timeout=3); c.sendall(b"\x05\x01\x00"); c.recv(2) + c.sendall(b"\x05\x03\x00\x01\x00\x00\x00\x00\x00\x00"); r = c.recv(64); print("yes" if r[1] == 0 else "no") +except Exception: print("no") +PY +) +kill $PROBE 2>/dev/null; wait $PROBE 2>/dev/null +[ "$UDPOK" = yes ] || info "this Nexus copy cannot do UDP ASSOCIATE here (apply tests/nexus-linux-udp.patch): UDP tests will be skipped" + +python3 "$HERE/nexus_origins.py" > "$WORK/origins.log" 2>&1 & PIDS+=($!) +PB_DNS_ZONE="app.example.test=10.77.1.5;svc.corp.test=10.77.1.6" python3 "$HERE/fake_dns.py" 10.77.1.53 "$WORK/dns.log" >/dev/null 2>&1 & PIDS+=($!) +start_nx() { # port log [extra args] + local port=$1 log=$2; shift 2 + "$NODE" "$NEXUS_JS" -v -p "$port" "$@" > "$log" 2>&1 & NXPID=$!; PIDS+=($NXPID) + for _ in $(seq 40); do ss -ltn 2>/dev/null | grep -q ":$port " && return 0; sleep 0.25; done; echo "nexus on $port did not start"; cat "$log"; exit 1; } +NX1LOG="$WORK/nx1.log"; NX2LOG="$WORK/nx2.log" +start_nx 1080 "$NX1LOG"; NX1=$NXPID +start_nx 1081 "$NX2LOG" -u nx -P secret; NX2=$NXPID +sleep 1 +mkdir -p "$WORK/bin"; cp "$(command -v curl)" "$WORK/bin/wget" +read -r -d '' EXPECT_PY <<'PY' +import hashlib, sys +B = bytes((i * 7 + 13) & 255 for i in range(1 << 20)); h = hashlib.sha256() +for _ in range(int(sys.argv[1])): h.update(B) +print(h.hexdigest()) +PY +sha_for() { python3 -c "$EXPECT_PY" "$1"; } + +stdbuf -oL "$PB_DIR/ProxyBridge" --verbose 2 \ + --proxy n1=socks5://127.0.0.1:1080 --proxy n2=socks5://nx:secret@127.0.0.1:1081 \ + --rule "curl:10.77.1.3:*:TCP:DIRECT" \ + --rule "curl:10.77.1.4:*:TCP:BLOCK" \ + --rule "curl:10.77.1.1:18701-18703:TCP:PROXY=n2" \ + --rule "curl:10.77.1.1:18700:TCP:PROXY=n1" \ + --rule "curl:10.77.1.2:*:TCP:PROXY=n2" \ + --rule "wget:10.77.1.0/24:*:TCP:PROXY=n2" \ + --rule "python3*:10.77.1.1:18700:TCP:PROXY=n1" \ + --rule "*:*.example.test:*:TCP:PROXY=n2" \ + --rule "*:*.corp.test:*:TCP:PROXY=n1" \ + --rule "python3*:10.77.1.1:18710:UDP:PROXY=n1" \ + --rule "python3*:10.77.1.2:18710:UDP:PROXY=n2" \ + --rule "python3*:10.77.1.1:18711:UDP:PROXY=n1" \ + --rule "dig;python3*:10.77.1.53:53:UDP:PROXY=n1" \ + --rule "dig;python3*:10.77.1.53:53:TCP:PROXY=n1" \ + > "$WORK/pb.out" 2>&1 & PB=$!; PIDS+=($PB) +for _ in $(seq 40); do grep -q "ProxyBridge started" "$WORK/pb.out" && break; sleep 0.25; done +grep -q "ProxyBridge started" "$WORK/pb.out" || { echo "ProxyBridge failed to start"; cat "$WORK/pb.out"; exit 1; } +sleep 1 +O() { curl -s -m 10 "$@"; } + +echo "== TCP: different rules -> different Nexus instances (real forwarding)" +out=$(O http://10.77.1.1:18700/); has "ip+port rule -> nexus n1 (open)" "$out" "origin-1" +inlog $NX1LOG "[CONNECT] 10.77.1.1:18700" && ok " n1 log shows the CONNECT to 10.77.1.1:18700" || bad " n1 never saw the connection" +out=$(O http://10.77.1.2:18700/); has "whole-IP rule -> nexus n2 (username/password)" "$out" "origin-2" +inlog $NX2LOG "[CONNECT] 10.77.1.2:18700" && ok " n2 log shows the CONNECT (auth succeeded)" || bad " n2 never saw the connection" +out=$(O http://10.77.1.1:18702/); has "port-range rule 18701-18703 -> n2" "$out" "origin-1-p18702" +inlog $NX2LOG "[CONNECT] 10.77.1.1:18702" && ok " n2 carried port 18702" || bad " n2 did not carry 18702" +out=$(O http://10.77.1.1:18704/); has "port outside every rule is direct (served)" "$out" "origin-1-p18704" +inlog $NX1LOG "10.77.1.1:18704" || inlog $NX2LOG "10.77.1.1:18704" && bad " 18704 leaked into a proxy" || ok " neither nexus saw 18704" +out=$(O http://10.77.1.3:18700/); has "DIRECT rule bypasses nexus" "$out" "origin-3" +inlog $NX1LOG "10.77.1.3" || inlog $NX2LOG "10.77.1.3" && bad " DIRECT leaked into a proxy" || ok " neither nexus saw 10.77.1.3" +out=$(O http://10.77.1.4:18700/ 2>&1; echo "rc=$?"); has "BLOCK rule rejects" "$out" "rc=7" +out=$("$WORK/bin/wget" -s -m 10 http://10.77.1.1:18704/); has "same destination, other process (wget) -> n2" "$out" "origin-1-p18704" +inlog $NX2LOG "[CONNECT] 10.77.1.1:18704" && ok " n2 saw wget's connection (curl's to the same address stayed direct)" || bad " wget was not proxied" + +echo "== Domain rules with a real resolver, DNS itself carried by Nexus" +# DNS over UDP needs UDP ASSOCIATE through nexus; with a Nexus copy that cannot do it, resolve over TCP instead +if [ "$UDPOK" = yes ]; then DIGMODE=""; DNSKIND="UDP"; else DIGMODE="+tcp"; DNSKIND="TCP"; fi +ip5=$(dig @10.77.1.53 app.example.test +short +time=3 +tries=1 $DIGMODE | head -1); eq "dig answer (${DNSKIND} DNS via nexus n1)" "$ip5" "10.77.1.5" +out=$(O http://10.77.1.5:18700/); [ -n "$out" ] || { echo " (empty answer; verbose retry:)"; curl -sv -m 10 http://10.77.1.5:18700/ 2>&1 | tail -12 | sed "s/^/ /"; } +has "*.example.test -> n2" "$out" "origin-5" +inlog $NX2LOG "[CONNECT] 10.77.1.5:18700" && ok " n2 carried the domain-matched connection" || bad " not via n2" +ip6=$(dig @10.77.1.53 +tcp svc.corp.test +short +time=3 +tries=1 | head -1); eq "dig answer (TCP DNS via nexus n1)" "$ip6" "10.77.1.6" +out=$(O http://10.77.1.6:18700/); has "*.corp.test -> n1" "$out" "origin-6" +inlog $NX1LOG "[CONNECT] 10.77.1.6:18700" && ok " n1 carried the domain-matched connection" || bad " not via n1" +inlog $NX1LOG "[CONNECT] 10.77.1.53:53" && ok " n1 carried the DNS-over-TCP query" || bad " DNS/TCP did not go through n1" +grep -q "10.77.1.5 (app.example.test)" "$WORK/pb.out" && ok " connection log names the domain" || bad " no domain in the connection log" + +echo "== the proxy's own process is exempted automatically (no DIRECT rule for node exists)" +inlog $NX2LOG "[CONNECT] 10.77.1.5:18700" && ok " nexus's own outbound connections were not sent back through itself" || bad " proxy loop" +before=$(grep -cF "[CONNECT] 10.77.1.5:18700" "$NX2LOG") +"$NODE" -e "require('net').connect(18700,'10.77.1.5').on('connect',function(){this.end()}).on('error',()=>{})"; sleep 1 +after=$(grep -cF "[CONNECT] 10.77.1.5:18700" "$NX2LOG") +[ "$after" -gt "$before" ] && ok "another node process (not the proxy) is still proxied: exemption is per process, not per binary" || bad "unrelated node process bypassed the rules" + +echo "== TCP: many connections, big transfers, integrity" +cpids=(); for i in $(seq 1 100); do (O http://10.77.1.1:18700/ > "$WORK/p$i.out") & cpids+=($!); done; wait "${cpids[@]}" 2>/dev/null +okc=$(grep -l "origin-1" "$WORK"/p*.out 2>/dev/null | wc -l); [ "$okc" = 100 ] && ok "100 parallel connections through n1" || bad "only $okc/100 parallel connections succeeded" +fail=0; for i in $(seq 1 1500); do r=$(O http://10.77.1.1:18700/); [ "$r" = origin-1 ] || fail=$((fail+1)); done; [ "$fail" = 0 ] && ok "1500 sequential connect/close cycles through n1" || bad "$fail/1500 sequential requests failed" +MB=200; WANT=$(sha_for $MB) +t0=$(date +%s.%N); GOT=$(O -m 120 "http://10.77.1.1:18700/big?mb=$MB" | sha256sum | cut -d' ' -f1); t1=$(date +%s.%N) +[ "$GOT" = "$WANT" ] && ok "200 MB download through n1: checksum identical" || bad "200 MB download corrupted" +T_PB=$(python3 -c "print(round($MB/($t1-$t0)))") +t0=$(date +%s.%N); GOT=$(O -m 120 --socks5 127.0.0.1:1080 "http://10.77.1.1:18704/big?mb=$MB" | sha256sum | cut -d' ' -f1); t1=$(date +%s.%N) +T_SOCKS=$(python3 -c "print(round($MB/($t1-$t0)))"); [ "$GOT" = "$WANT" ] || bad "reference download via --socks5 corrupted" +t0=$(date +%s.%N); GOT=$(O -m 120 "http://10.77.1.1:18704/big?mb=$MB" | sha256sum | cut -d' ' -f1); t1=$(date +%s.%N) +T_DIRECT=$(python3 -c "print(round($MB/($t1-$t0)))") +info "throughput, 200 MB: direct ${T_DIRECT} MB/s | curl speaking SOCKS5 to nexus itself ${T_SOCKS} MB/s | curl -> ProxyBridge -> nexus ${T_PB} MB/s" +python3 -c "import sys; sys.exit(0 if $T_PB >= 0.8*$T_SOCKS else 1)" && ok "ProxyBridge in front of nexus is within 20% of the application speaking SOCKS5 itself" || bad "ProxyBridge overhead above 20% (${T_PB} vs ${T_SOCKS} MB/s)" +python3 - < "$WORK/up.bin" +import sys +B = bytes((i * 7 + 13) & 255 for i in range(1 << 20)); w = sys.stdout.buffer +for _ in range(100): w.write(B) +PY +WANT=$(sha_for 100); GOT=$(O -m 120 --data-binary @"$WORK/up.bin" http://10.77.1.2:18700/upload) +[ "$GOT" = "$WANT" ] && ok "100 MB upload through n2 (auth): origin received identical bytes" || bad "upload mismatch (${GOT:0:20})" +half=$(python3 - <<'PY' +import socket, struct +def exchange(s): + s.sendall(b"GET / HTTP/1.0\r\n\r\n"); s.shutdown(socket.SHUT_WR) # half-close, then read the answer + data = b"" + while True: + d = s.recv(4096) + if not d: break + data += d + return "origin-1" in data.decode() +# (a) through ProxyBridge: the python3 rule redirects this connection to nexus n1 +print("via-proxybridge:", exchange(socket.create_connection(("10.77.1.1", 18700), timeout=10))) +# (b) reference: the application speaks SOCKS5 to nexus itself (loopback is never redirected) +c = socket.create_connection(("127.0.0.1", 1080), timeout=10); c.sendall(b"\x05\x01\x00"); c.recv(2) +c.sendall(b"\x05\x01\x00\x01" + socket.inet_aton("10.77.1.1") + struct.pack("!H", 18700)); c.recv(10) +print("direct-socks:", exchange(c)) +PY +) +echo "$half" | sed 's/^/ /' +if [[ "$half" == *"direct-socks: True"* ]]; then has "half-close through ProxyBridge (client FIN, response still delivered)" "$half" "via-proxybridge: True" +else [[ "$half" == *"via-proxybridge: False"* ]] && ok "half-close: identical to the application speaking SOCKS5 itself (nexus does not support half-open connections: Node allowHalfOpen=false)" || bad "half-close differs from the reference ($half)"; fi + +echo "== UDP through nexus" +if [ "$UDPOK" = yes ]; then +out=$(python3 - <<'PY' +import socket, time, concurrent.futures as cf +def sock(t=4): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(t); return s +s = sock() +for dst, tag in [(("10.77.1.1", 18710), "U1:"), (("10.77.1.2", 18710), "U2:"), (("10.77.1.1", 18710), "U1:")]: + s.sendto(b"hello", dst); d, a = s.recvfrom(100) + print("one-socket", dst[0], "->", d.decode(), "from", a[0] + ":" + str(a[1])) +c = sock(); c.connect(("10.77.1.1", 18711)); c.send(b"conn"); d = c.recv(100) +print("connected", d.decode(), "peer", "%s:%d" % c.getpeername()) +big = bytes(range(256)) * 234 # 59,904 bytes +b = sock(8); b.sendto(big, ("10.77.1.1", 18710)); d, a = b.recvfrom(70000) +print("big", "ok" if d == b"U1:" + big else "MISMATCH %d" % len(d)) +def one(i): + k = sock(8); k.sendto(b"n%d" % i, ("10.77.1.%d" % (1 + i % 2), 18710)); d, a = k.recvfrom(100) + return d.endswith(b"n%d" % i) and a[0] == "10.77.1.%d" % (1 + i % 2) +with cf.ThreadPoolExecutor(32) as ex: r = list(ex.map(one, range(300))) +print("concurrent", sum(r), "/ 300") +# sustained stream: 6000 datagrams of 1000 bytes at ~3000 pps, receiving while sending +import threading +q = sock(1); q.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 << 20); got = set(); sent = 6000; stop = threading.Event() +def rx(): + q.settimeout(0.2) + while not stop.is_set(): + try: + d, a = q.recvfrom(2000); got.add(int.from_bytes(d[3:7], "big")) + except socket.timeout: pass +th = threading.Thread(target=rx, daemon=True); th.start(); t0 = time.perf_counter() +for i in range(sent): + q.sendto(i.to_bytes(4, "big") + b"x" * 996, ("10.77.1.1", 18710)) + while time.perf_counter() < t0 + (i + 1) / 3000.0: time.sleep(0) +time.sleep(2); stop.set(); th.join() +print("stream", len(got), "/", sent, "replies") +# round-trip latency of small datagrams +l = sock(2); ts = [] +for i in range(1500): + t = time.perf_counter(); l.sendto(b"p", ("10.77.1.1", 18710)); l.recvfrom(100); ts.append(time.perf_counter() - t) +ts.sort(); print("rtt p50=%dus p99=%dus" % (ts[len(ts)//2]*1e6, ts[int(len(ts)*.99)]*1e6)) +PY +) +echo "$out" | sed 's/^/ /' +has "UDP one socket -> nexus n1 (destination 1)" "$out" "one-socket 10.77.1.1 -> U1:hello from 10.77.1.1:18710" +has "same socket -> nexus n2 with auth (destination 2)" "$out" "one-socket 10.77.1.2 -> U2:hello from 10.77.1.2:18710" +has "connected UDP socket, getpeername restored" "$out" "connected U3:conn peer 10.77.1.1:18711" +has "59,904-byte datagram round trip through nexus" "$out" "big ok" +has "300 concurrent UDP sockets across both proxies" "$out" "concurrent 300 / 300" +n=$(grep -o "stream [0-9]*" <<<"$out" | grep -o "[0-9]*"); [ "${n:-0}" -ge 5940 ] && ok "sustained stream: ${n}/6000 datagrams echoed (>= 99%)" || bad "stream lost too much (${n:-0}/6000)" +inlog $NX1LOG "10.77.1.1:18710" && inlog $NX2LOG "10.77.1.2:18710" && ok " both nexus logs list their UDP destinations" || bad " nexus logs lack the UDP destinations" +dq=$(dig @10.77.1.53 app.example.test +short +time=3 +tries=1 | head -1); eq "UDP DNS through nexus still answers" "$dq" "10.77.1.5" +else + skip "UDP tests (stock Nexus cannot do UDP ASSOCIATE on Linux; apply tests/nexus-linux-udp.patch)" +fi + +echo "== resilience: proxy dies and comes back" +if [ "$UDPOK" = yes ]; then + python3 - <<'PY' > "$WORK/udp_pre.out" +import socket +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(4); s.sendto(b"x", ("10.77.1.1", 18710)); print(s.recv(10).decode()) +PY +fi +kill $NX1; wait $NX1 2>/dev/null; sleep 1 +out=$(O -m 4 http://10.77.1.1:18700/ 2>&1; echo "rc=$?"); [[ "$out" != *origin-1* ]] && ok "with nexus n1 down the request fails (no leak around the proxy)" || bad "request bypassed the dead proxy" +start_nx 1080 "$NX1LOG.2"; NX1=$NXPID; sleep 1 +out=$(O http://10.77.1.1:18700/); has "TCP works again after nexus n1 restarts" "$out" "origin-1" +if [ "$UDPOK" = yes ]; then + rec=$(python3 - <<'PY' +import socket, time +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(1); t0 = time.time() +while time.time() - t0 < 12: + try: + s.sendto(b"again", ("10.77.1.1", 18710)); d, a = s.recvfrom(100); print("recovered in %.1fs: %s" % (time.time() - t0, d.decode())); break + except socket.timeout: pass +else: print("NOT RECOVERED") +PY + ); has "UDP session re-associates after nexus restarts" "$rec" "recovered in"; info "$rec" +fi +kill $NX2; wait $NX2 2>/dev/null; sleep 1 +out=$(O -m 4 http://10.77.1.2:18700/ 2>&1; echo "rc=$?"); [[ "$out" != *origin-2* ]] && ok "with n2 down its rule fails closed (nothing goes direct)" || bad "n2 rule leaked to direct" + +echo; echo "passed=$PASS failed=$FAIL skipped=$SKIP" +[ "$FAIL" = 0 ] || { echo "---- pb output"; tail -25 "$WORK/pb.out"; echo "---- nx1"; tail -8 "$NX1LOG"; echo "---- nx2"; tail -8 "$NX2LOG"; exit 1; } diff --git a/Linux/tests/e2e_openssh.sh b/Linux/tests/e2e_openssh.sh new file mode 100755 index 00000000..d371da80 --- /dev/null +++ b/Linux/tests/e2e_openssh.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# Interoperability with a REAL third-party SOCKS5 server: OpenSSH's `ssh -D`. Needs root, sshd, ssh, curl, python3. +# sudo tests/e2e_openssh.sh +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; ROOT="$(dirname "$HERE")" +PB_DIR="${PB_DIR:-$ROOT/output}" # directory holding the built ProxyBridge + libproxybridge.so (run ../build.sh first) +[ -x "$PB_DIR/ProxyBridge" ] && [ -f "$PB_DIR/libproxybridge.so" ] || { echo "Build first: (cd Linux && ./build.sh), or set PB_DIR to a directory containing ProxyBridge and libproxybridge.so"; exit 1; } +WORK="$(mktemp -d)"; PASS=0; FAIL=0; ORIGIN=10.77.0.5 +ok() { echo " PASS $1"; PASS=$((PASS+1)); } +bad() { echo " FAIL $1"; FAIL=$((FAIL+1)); } +cleanup() { kill $SSHD $SSH $WWW $PB 2>/dev/null; wait 2>/dev/null; ip addr del $ORIGIN/32 dev lo 2>/dev/null; rm -rf "$WORK"; } +trap cleanup EXIT +[ "$(id -u)" = 0 ] || { echo "run as root"; exit 1; } +export LD_LIBRARY_PATH="$PB_DIR" +command -v sshd >/dev/null && command -v ssh >/dev/null || { echo "SKIP: openssh not installed"; exit 0; } +ip addr add $ORIGIN/32 dev lo 2>/dev/null + +ssh-keygen -q -t ed25519 -N '' -f "$WORK/hostkey"; ssh-keygen -q -t ed25519 -N '' -f "$WORK/clientkey" +cp "$WORK/clientkey.pub" "$WORK/authorized_keys"; chmod 600 "$WORK/authorized_keys" +cat > "$WORK/sshd_config" < "$WORK/sshd.log" & SSHD=$! +sleep 1 +ssh -N -D 127.0.0.1:19090 -p 22222 -i "$WORK/clientkey" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ExitOnForwardFailure=yes root@127.0.0.1 2> "$WORK/ssh.log" & SSH=$! +for _ in $(seq 30); do ss -ltn 2>/dev/null | grep -q ":19090" && break; sleep 0.3; done +ss -ltn | grep -q ":19090" && ok "OpenSSH SOCKS5 server is up (ssh -D 19090)" || { bad "ssh -D did not start: $(tail -3 $WORK/ssh.log)"; exit 1; } + +# a real origin on a non-loopback local address, serving a 40MB file and a small page +head -c 41943040 /dev/urandom > "$WORK/big.bin"; echo "real-origin-page" > "$WORK/index.html" +python3 -m http.server 18500 --bind $ORIGIN --directory "$WORK" >/dev/null 2>&1 & WWW=$! +sleep 1 +SUM=$(sha256sum "$WORK/big.bin" | cut -d' ' -f1) + +stdbuf -oL "$PB_DIR/ProxyBridge" --verbose 1 --proxy ossh=socks5://127.0.0.1:19090 \ + --rule "curl:$ORIGIN:18500:TCP:PROXY=ossh" > "$WORK/pb.out" 2>&1 & PB=$! +for _ in $(seq 40); do grep -q "ProxyBridge started" "$WORK/pb.out" && break; sleep 0.25; done + +out=$(curl -s -m 8 http://$ORIGIN:18500/index.html) +[[ "$out" == *real-origin-page* ]] && ok "small request through OpenSSH's SOCKS5 server" || bad "small request (got: ${out:0:100})" +GOT=$(curl -s -m 60 http://$ORIGIN:18500/big.bin | sha256sum | cut -d' ' -f1) +[ "$GOT" = "$SUM" ] && ok "40 MB download through OpenSSH SOCKS5: checksum identical" || bad "40MB checksum mismatch" +pids=(); for i in $(seq 40); do curl -s -m 20 http://$ORIGIN:18500/index.html > "$WORK/c$i.out" & pids+=($!); done; wait "${pids[@]}" 2>/dev/null +n=$(grep -l real-origin-page "$WORK"/c*.out 2>/dev/null | wc -l); [ "$n" = 40 ] && ok "40 parallel connections through OpenSSH SOCKS5" || bad "only $n/40 parallel requests succeeded" +# upload (client -> origin) integrity +python3 - "$WORK" <<'PY' & +import http.server, socketserver, sys, hashlib, threading +class H(http.server.BaseHTTPRequestHandler): + def do_POST(self): + n = int(self.headers["Content-Length"]); h = hashlib.sha256(); left = n + while left: + b = self.rfile.read(min(65536, left)); h.update(b); left -= len(b) + body = h.hexdigest().encode(); self.send_response(200); self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body) + def log_message(self, *a): pass +socketserver.TCPServer.allow_reuse_address = True +socketserver.ThreadingTCPServer(("10.77.0.5", 18501), H).serve_forever() +PY +UP=$!; sleep 1 +kill -INT $PB; wait $PB 2>/dev/null +stdbuf -oL "$PB_DIR/ProxyBridge" --proxy ossh=socks5://127.0.0.1:19090 --rule "curl:$ORIGIN:18500-18501:TCP:PROXY=ossh" > "$WORK/pb.out" 2>&1 & PB=$! +for _ in $(seq 40); do grep -q "ProxyBridge started" "$WORK/pb.out" && break; sleep 0.25; done +GOT=$(curl -s -m 60 --data-binary @"$WORK/big.bin" http://$ORIGIN:18501/ ) +[ "$GOT" = "$SUM" ] && ok "40 MB upload through OpenSSH SOCKS5: origin received identical bytes" || bad "upload checksum mismatch (got ${GOT:0:20})" +kill $UP 2>/dev/null +# negative control: with the SSH proxy gone, the redirected request must FAIL (it would succeed if it bypassed the proxy) +kill $SSH 2>/dev/null; sleep 1 +out=$(curl -s -m 4 http://$ORIGIN:18500/index.html 2>&1; echo "rc=$?") +[[ "$out" != *real-origin-page* ]] && ok "negative control: with the proxy killed the request fails, so it really went through the proxy" || bad "request succeeded without the proxy: traffic bypassed it" +echo; echo "passed=$PASS failed=$FAIL" +[ "$FAIL" = 0 ] || { echo "--- pb"; tail -15 "$WORK/pb.out"; echo "--- sshd"; tail -10 "$WORK/sshd.log"; exit 1; } diff --git a/Linux/tests/e2e_rules.sh b/Linux/tests/e2e_rules.sh new file mode 100755 index 00000000..c13dcb44 --- /dev/null +++ b/Linux/tests/e2e_rules.sh @@ -0,0 +1,148 @@ +#!/bin/bash +# Rule-matrix end-to-end test: every rule form and every upstream proxy type through the real kernel datapath. +# sudo tests/e2e_rules.sh (needs curl, python3; only loopback + TEST-NET addresses are used) +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; ROOT="$(dirname "$HERE")" +PB_DIR="${PB_DIR:-$ROOT/output}" # directory holding the built ProxyBridge + libproxybridge.so (run ../build.sh first) +[ -x "$PB_DIR/ProxyBridge" ] && [ -f "$PB_DIR/libproxybridge.so" ] || { echo "Build first: (cd Linux && ./build.sh), or set PB_DIR to a directory containing ProxyBridge and libproxybridge.so"; exit 1; } +WORK="$(mktemp -d)"; PLOG="$WORK/proxy.log"; : > "$PLOG" +PASS=0; FAIL=0 +ok() { echo " PASS $1"; PASS=$((PASS+1)); } +bad() { echo " FAIL $1"; FAIL=$((FAIL+1)); } +via() { # name url expected-proxy-letter expected-dst + local out; out=$(curl -s -m 5 "$2" 2>&1) + [[ "$out" == *"hello-from-$3 dst=$4"* ]] && ok "$1" || bad "$1 (got: ${out:0:140})"; } +direct() { # name url -> must NOT reach any proxy (times out / fails) + local out; out=$(curl -s -m 2 "$2" 2>&1; echo "rc=$?") + [[ "$out" != *hello-from* && "$out" != *"rc=0"* ]] && ok "$1" || bad "$1 (got: ${out:0:140})"; } +blocked() { local out; out=$(curl -s -m 5 "$2" 2>&1; echo "rc=$?"); [[ "$out" == *"rc=7"* ]] && ok "$1" || bad "$1 (got: ${out:0:140})"; } +cleanup() { kill $PB_PID $FAKE_PID 2>/dev/null; wait 2>/dev/null; rm -rf "$WORK"; } +trap cleanup EXIT +[ "$(id -u)" = 0 ] || { echo "run as root"; exit 1; } +export LD_LIBRARY_PATH="$PB_DIR" + +python3 "$HERE/fake_proxies.py" "$PLOG" >/dev/null 2>&1 & FAKE_PID=$! +mkdir -p "$WORK/bin" "$WORK/pbpath" +CURL=$(command -v curl) +cp "$CURL" "$WORK/bin/wget"; cp "$CURL" "$WORK/bin/newtool"; cp "$CURL" "$WORK/bin/mycurlbin"; cp "$CURL" "$WORK/pbpath/tool" +sleep 1 + +start_pb() { # extra CLI args... + stdbuf -oL "$PB_DIR/ProxyBridge" --verbose 1 \ + --proxy a=socks5://127.0.0.1:19080 --proxy b=socks5://bob:pw@127.0.0.1:19081 --proxy c=http://127.0.0.1:19082 \ + --proxy d=http://bob:pw@127.0.0.1:19083 --proxy e=http://bob:wrong@127.0.0.1:19083 "$@" > "$WORK/pb.out" 2>&1 & PB_PID=$! + for _ in $(seq 40); do grep -q "ProxyBridge started" "$WORK/pb.out" && return 0; sleep 0.25; done + echo "ProxyBridge failed to start"; cat "$WORK/pb.out"; exit 1; } + +start_pb \ + --rule "curl:192.0.2.90:*:TCP:BLOCK" \ + --rule "curl:192.0.2.0/24:*:TCP:PROXY=a" \ + --rule "curl:198.51.100.20-198.51.100.29:*:TCP:PROXY=b" \ + --rule "curl:198.51.*.*:443:TCP:PROXY=c" \ + --rule "curl:203.0.1-3.*:*:TCP:PROXY=a" \ + --rule "curl:203.0.113.40:80;443;8000-9000:TCP:PROXY=b" \ + --rule "wget;newtool:203.0.113.50:*:TCP:PROXY=a" \ + --rule "$WORK/pbpath/*:203.0.113.60:*:TCP:PROXY=b" \ + --rule "*urlbin*:203.0.113.70:*:TCP:PROXY=a" \ + --rule "curl:203.0.113.80:1;2;3;4;5;6;7;80:TCP:PROXY=b" \ + --rule "curl:10.0.0.0/8;172.16.0.0/12;100.64.0.0/10;192.168.0.0/16;169.254.0.0/16;203.0.113.90:*:TCP:PROXY=a" \ + --rule "CURL:203.0.113.150:*:TCP:PROXY=a" \ + --rule "curl:203.0.113.100:*:TCP:PROXY=d" \ + --rule "curl:203.0.113.101:*:TCP:PROXY=e" \ + --rule "python3*:203.0.113.70:*:UDP:PROXY=a" \ + --rule "python3*:203.0.113.110:5300:UDP:PROXY=c" \ + --rule "python3*:203.0.113.120:*:UDP:BLOCK" \ + --rule "python3*:203.0.113.130:5300-5310:UDP:PROXY=b" \ + --rule "python3*:203.0.113.140:53:UDP:PROXY=a" \ + --rule "python3*:203.0.113.160:*:BOTH:PROXY=a" +sleep 1 + +echo "== diagnostics" +grep -q "sends UDP to HTTP proxy 'c', which cannot carry UDP" "$WORK/pb.out" && ok "a UDP rule pointing at an HTTP proxy is reported in the log" || bad "no warning for UDP over an HTTP proxy" + +echo "== host forms (process: curl)" +via "single IP" http://192.0.2.10/ A 192.0.2.10:80 +via "CIDR /24" http://192.0.2.200/ A 192.0.2.200:80 +via "range a-b (inside)" http://198.51.100.25/ B 198.51.100.25:80 +direct "range a-b (outside)" http://198.51.100.30:8080/ +via "octet wildcard 198.51.*.* + port 443" http://198.51.100.99:443/ C 198.51.100.99:443 +via "octet-range box 203.0.1-3.* (slow path)" http://203.0.2.77/ A 203.0.2.77:80 +direct "octet-range box (outside)" http://203.0.4.77/ +via "6 CIDRs in one rule (kernel cannot hold them, slow path)" http://203.0.113.90/ A 203.0.113.90:80 + +echo "== port forms" +via "port list: 80" http://203.0.113.40/ B 203.0.113.40:80 +via "port range 8000-9000 (8500)" http://203.0.113.40:8500/ B 203.0.113.40:8500 +direct "port not in list/range (22)" http://203.0.113.40:22/ +via "8 ports in a list (kernel table has 4 -> slow path)" http://203.0.113.80/ B 203.0.113.80:80 +direct "port 81 not in that list" http://203.0.113.80:81/ + +echo "== process forms" +out=$("$WORK/bin/wget" -s -m 5 http://203.0.113.50/); [[ "$out" == *"hello-from-A dst=203.0.113.50:80"* ]] && ok "process list a;b matches 'wget'" || bad "process list (got ${out:0:100})" +out=$("$WORK/bin/newtool" -s -m 5 http://203.0.113.50/); [[ "$out" == *"hello-from-A dst=203.0.113.50:80"* ]] && ok "process list a;b matches 'newtool'" || bad "process list 2 (got ${out:0:100})" +out=$(curl -s -m 2 http://203.0.113.50/ 2>&1; echo "rc=$?"); [[ "$out" != *hello-from* ]] && ok "process not in the list ('curl') is not matched" || bad "list leaked" +out=$("$WORK/pbpath/tool" -s -m 5 http://203.0.113.60/); [[ "$out" == *"hello-from-B dst=203.0.113.60:80"* ]] && ok "full-path glob /dir/* matches" || bad "path glob (got ${out:0:100})" +out=$("$WORK/bin/mycurlbin" -s -m 5 http://203.0.113.70/); [[ "$out" == *"hello-from-A dst=203.0.113.70:80"* ]] && ok "middle glob *urlbin* matches 'mycurlbin'" || bad "middle glob (got ${out:0:100})" +direct "process names are case-sensitive (rule 'CURL' does not match 'curl')" http://203.0.113.150/ + +echo "== ordering: first match wins" +blocked "specific BLOCK before the broader /24 PROXY" http://192.0.2.90/ +via "same /24, other address, still proxied" http://192.0.2.91/ A 192.0.2.91:80 + +echo "== HTTP proxies" +via "HTTP CONNECT with Basic auth (correct credentials)" http://203.0.113.100/ D 203.0.113.100:80 +out=$(curl -s -m 5 http://203.0.113.101/ 2>&1; echo "rc=$?"); [[ "$out" != *hello-from* ]] && ok "HTTP CONNECT with wrong credentials fails (407), nothing leaks" || bad "wrong creds accepted" +grep -q "^D AUTHFAIL" "$PLOG" && ok "the proxy really answered 407 (proxy log)" || bad "no 407 seen by the proxy" + +echo "== UDP rules" +out=$(python3 - <<'PY' +import socket +def probe(dst, payload=b"p", timeout=2): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(timeout) + try: + s.sendto(payload, dst); d, a = s.recvfrom(200); return d.decode() + "|" + a[0] + ":" + str(a[1]) + except socket.timeout: return "timeout" + except OSError as e: return "errno=%d" % e.errno +print("port-range-in:", probe(("203.0.113.130", 5305))) +print("port-range-out:", probe(("203.0.113.130", 5400), timeout=1)) +print("udp-only-rule-on-tcp-dst:", probe(("203.0.113.70", 5300), timeout=1)) +print("block:", probe(("203.0.113.120", 5300))) +print("http-proxy-udp:", probe(("203.0.113.110", 5300), timeout=1)) +print("both:", probe(("203.0.113.160", 5300))) +print("dns-port-53:", probe(("203.0.113.140", 53))) +PY +) +[[ "$out" == *"port-range-in: UDP-B 203.0.113.130:5305 p|203.0.113.130:5305"* ]] && ok "UDP port range 5300-5310 via SOCKS5+auth, source restored" || bad "udp port range ($out)" +[[ "$out" == *"port-range-out: timeout"* ]] && ok "UDP port outside the range is not proxied" || bad "udp range leak" +[[ "$out" == *"udp-only-rule-on-tcp-dst: UDP-A 203.0.113.70:5300"* ]] && ok "protocol UDP rule matches UDP" || bad "udp proto rule ($out)" +direct "protocol UDP rule does NOT match TCP to the same address" http://203.0.113.70/ +[[ "$out" == *"block: errno=1"* ]] && ok "UDP BLOCK rule: sendto fails with EPERM" || bad "udp block ($out)" +[[ "$out" == *"http-proxy-udp: timeout"* ]] && ok "UDP rule pointing at an HTTP proxy goes direct (HTTP cannot carry UDP)" || bad "http udp ($out)" +grep -q "UDP 203.0.113.110" "$PLOG" && bad "UDP leaked into the HTTP proxy" || ok "the HTTP proxy never saw that UDP" +[[ "$out" == *"both: UDP-A 203.0.113.160:5300"* ]] && ok "protocol BOTH rule matches UDP" || bad "both/udp ($out)" +tcpout=$(python3 - <<'PY' +import socket +s = socket.create_connection(("203.0.113.160", 80), timeout=5); s.sendall(b"GET / HTTP/1.0\r\n\r\n"); print(s.recv(300).decode()) +PY +) +[[ "$tcpout" == *"hello-from-A dst=203.0.113.160:80"* ]] && ok "protocol BOTH rule matches TCP too" || bad "both/tcp (got ${tcpout:0:100})" +[[ "$out" == *"dns-port-53: UDP-A 203.0.113.140:53"* ]] && ok "DNS port rule applies when --dns-via-proxy is true (default)" || bad "dns true ($out)" + +echo "== DNS switch: --dns-via-proxy false makes port 53 always direct" +kill -INT $PB_PID; wait $PB_PID 2>/dev/null +start_pb --dns-via-proxy false --rule "python3*:203.0.113.140:53:UDP:PROXY=a" --rule "python3*:203.0.113.141:5353:UDP:PROXY=a" +out=$(python3 - <<'PY' +import socket +def probe(dst, timeout=1.5): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(timeout) + try: s.sendto(b"p", dst); d, a = s.recvfrom(200); return d.decode() + except socket.timeout: return "timeout" +print("dns:", probe(("203.0.113.140", 53))) +print("other:", probe(("203.0.113.141", 5353))) +PY +) +[[ "$out" == *"dns: timeout"* ]] && ok "port 53 stays direct with dns-via-proxy=false" || bad "dns false ($out)" +[[ "$out" == *"other: UDP-A 203.0.113.141:5353"* ]] && ok "non-DNS UDP rule still proxied" || bad "other udp ($out)" + +echo; echo "passed=$PASS failed=$FAIL" +[ "$FAIL" = 0 ] || { echo "---- proxy log"; cat "$PLOG"; echo "---- pb output"; tail -20 "$WORK/pb.out"; exit 1; } diff --git a/Linux/tests/e2e_security.sh b/Linux/tests/e2e_security.sh new file mode 100755 index 00000000..f550a42d --- /dev/null +++ b/Linux/tests/e2e_security.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Security checks on the relay hand-off. Needs root, bpftool, python3. sudo tests/e2e_security.sh +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; ROOT="$(dirname "$HERE")" +PB_DIR="${PB_DIR:-$ROOT/output}" +[ -x "$PB_DIR/ProxyBridge" ] && [ -f "$PB_DIR/libproxybridge.so" ] || { echo "Build first: (cd Linux && ./build.sh), or set PB_DIR"; exit 1; } +BPFTOOL="${BPFTOOL:-bpftool}"; command -v "$BPFTOOL" >/dev/null || { echo "bpftool not found (set BPFTOOL)"; exit 1; } +[ "$(id -u)" = 0 ] || { echo "run as root"; exit 1; } +export LD_LIBRARY_PATH="$PB_DIR" +WORK="$(mktemp -d)"; PLOG="$WORK/proxy.log"; : > "$PLOG"; PASS=0; FAIL=0 +ok() { echo " PASS $1"; PASS=$((PASS+1)); } +bad() { echo " FAIL $1"; FAIL=$((FAIL+1)); } +cleanup() { kill $FAKE $PB 2>/dev/null; wait 2>/dev/null; rm -rf "$WORK"; } +trap cleanup EXIT + +python3 "$HERE/fake_proxies.py" "$PLOG" >/dev/null 2>&1 & FAKE=$! +sleep 1 +stdbuf -oL "$PB_DIR/ProxyBridge" --verbose 1 --proxy a=socks5://127.0.0.1:19080 --rule "curl:192.0.2.1:*:TCP:PROXY=a" > "$WORK/pb.out" 2>&1 & PB=$! +for _ in $(seq 40); do grep -q "ProxyBridge started" "$WORK/pb.out" && break; sleep 0.25; done +RELAY=$(sed -n 's/.*relay tcp:\([0-9]*\) udp.*/\1/p' "$WORK/pb.out" | head -1) +[ -n "$RELAY" ] || { echo "ProxyBridge did not start"; cat "$WORK/pb.out"; exit 1; } + +PORT=40123 +# plant the kind of entry the kernel creates for a redirected connection from 127.0.0.1:PORT -> 192.0.2.99:80 via proxy 1 +HEXKEY=$(python3 -c "import struct,socket;print(' '.join('%02x'%b for b in socket.inet_aton('127.0.0.1')+struct.pack('/dev/null 2>&1; } +planted() { "$BPFTOOL" map lookup name by_port key hex $HEXKEY >/dev/null 2>&1; } + +try_connect() { # source ip -> prints "closed" or "open" +python3 - "$1" $PORT $RELAY <<'PY' +import socket, sys +src, port, relay = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]) +s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1); s.bind((src, port)); s.settimeout(3) +try: + s.connect(("127.0.0.1", relay)); s.sendall(b"GET / HTTP/1.0\r\n\r\n"); d = s.recv(100) + print("closed" if not d else "open:" + d[:30].decode(errors="replace")) +except (ConnectionError, socket.timeout) as e: + print("closed") +PY +} + +plant && planted && ok "test setup: a pending flow entry for 127.0.0.1:$PORT is in the kernel map" || { bad "could not plant the map entry (bpftool map update)"; exit 1; } +r=$(try_connect 127.0.0.2); [ "$r" = closed ] && ok "a process connecting from 127.0.0.2 with the same source port is refused" || bad "foreign source address was served ($r)" +grep -q "192.0.2.99" "$PLOG" && bad "the foreign connection reached the proxy" || ok "nothing reached the proxy for the foreign connection" +planted && ok "the legitimate pending entry survived the attempt" || bad "the foreign attempt consumed the victim's entry" +r=$(try_connect 127.0.0.1); sleep 1 +grep -q "A TCP 192.0.2.99:80" "$PLOG" && ok "the legitimate source (127.0.0.1) claims the entry and is proxied" || bad "legitimate connection was not proxied ($r)" +planted && bad "the entry was not consumed" || ok "the entry is consumed after use (no replay)" +r=$(try_connect 127.0.0.1); [ "$r" = closed ] && ok "a second connection with no pending entry is refused" || bad "relay served a connection without a pending entry ($r)" + +echo; echo "passed=$PASS failed=$FAIL" +[ "$FAIL" = 0 ] || { echo "--- proxy log"; cat "$PLOG"; exit 1; } diff --git a/Linux/tests/fake_dns.py b/Linux/tests/fake_dns.py new file mode 100644 index 00000000..d0ef5b2b --- /dev/null +++ b/Linux/tests/fake_dns.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Tiny authoritative DNS server for ProxyBridge end-to-end tests (UDP + TCP, IPv4 A records and CNAME chains). + +usage: fake_dns.py +Zone (everything else answers NXDOMAIN): + app.example.test A 192.0.2.77 + blocked.example.test A 192.0.2.78 + www.corp.test CNAME edge.cdnnet.test + edge.cdnnet.test A 192.0.2.79 + tcp.example.test A 192.0.2.80 + svc.byname.test A 192.0.2.81 + plain.other.test A 192.0.2.82 +""" +import socket, socketserver, struct, sys, threading + +ZONE = { + "app.example.test": [("A", "192.0.2.77")], + "blocked.example.test": [("A", "192.0.2.78")], + "www.corp.test": [("CNAME", "edge.cdnnet.test")], + "edge.cdnnet.test": [("A", "192.0.2.79")], + "tcp.example.test": [("A", "192.0.2.80")], + "svc.byname.test": [("A", "192.0.2.81")], + "plain.other.test": [("A", "192.0.2.82")], +} +# extra records for tests: PB_DNS_ZONE="name=ip;name2=ip2" +import os +for item in filter(None, os.environ.get("PB_DNS_ZONE", "").split(";")): + n, ip = item.split("=", 1) + ZONE[n.strip().lower()] = [("A", ip.strip())] +LOG = open(sys.argv[2], "a", buffering=1) + + +def enc_name(n): + return b"".join(bytes([len(p)]) + p.encode() for p in n.split(".")) + b"\0" + + +def dec_name(m, off): + parts = [] + while m[off]: + l = m[off]; parts.append(m[off + 1:off + 1 + l].decode()); off += 1 + l + return ".".join(parts), off + 1 + + +def answer(query, proto): + qid, flags, qd = struct.unpack("!HHH", query[:6]) + name, off = dec_name(query, 12) + qtype = struct.unpack("!H", query[off:off + 2])[0] + LOG.write(f"{proto} {name} type={qtype}\n") + key = name.lower() + rrs, cur, seen = [], key, 0 + while cur in ZONE and seen < 8: + seen += 1 + for kind, val in ZONE[cur]: + if kind == "CNAME": + rd = enc_name(val) + rrs.append(enc_name(cur) + struct.pack("!HHIH", 5, 1, 300, len(rd)) + rd) + cur = val + elif qtype in (1, 255): + rrs.append(enc_name(cur) + struct.pack("!HHIH", 1, 1, 300, 4) + socket.inet_aton(val)) + cur = None + else: + cur = None + if cur is None: + break + if cur is None: + break + rcode = 0 if rrs or key in ZONE else 3 + hdr = struct.pack("!HHHHHH", qid, 0x8180 | rcode, 1, len(rrs), 0, 0) + return hdr + query[12:off + 4] + b"".join(rrs) + + +class UDP(socketserver.BaseRequestHandler): + def handle(self): + data, sock = self.request + try: + sock.sendto(answer(data, "udp"), self.client_address) + except Exception as e: + LOG.write(f"error {e!r}\n") + + +class TCP(socketserver.BaseRequestHandler): + def handle(self): + try: + while True: + hdr = self.request.recv(2) + if len(hdr) < 2: + return + n = struct.unpack("!H", hdr)[0] + data = b"" + while len(data) < n: + chunk = self.request.recv(n - len(data)) + if not chunk: + return + data += chunk + resp = answer(data, "tcp") + # deliver in two pieces to exercise stream reassembly in the relay + out = struct.pack("!H", len(resp)) + resp + self.request.sendall(out[:3]); self.request.sendall(out[3:]) + except Exception as e: + LOG.write(f"error {e!r}\n") + + +class TS(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + daemon_threads = True + + +class US(socketserver.ThreadingMixIn, socketserver.UDPServer): + allow_reuse_address = True + daemon_threads = True + + +ip = sys.argv[1] +threading.Thread(target=TS((ip, 53), TCP).serve_forever, daemon=True).start() +print("fake dns up on", ip, flush=True) +US((ip, 53), UDP).serve_forever() diff --git a/Linux/tests/fake_proxies.py b/Linux/tests/fake_proxies.py new file mode 100644 index 00000000..ffce0105 --- /dev/null +++ b/Linux/tests/fake_proxies.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Fake upstream proxies for ProxyBridge end-to-end tests. + +Starts, on 127.0.0.1: + A SOCKS5 no-auth 19080 (also UDP ASSOCIATE) + B SOCKS5 user/pass 19081 (user "bob", pass "pw") + C HTTP CONNECT 19082 + D HTTP CONNECT, Basic auth 19083 (user "bob", pass "pw"; anything else gets 407) +Whatever destination a client asks for, the "origin" is a built-in responder: + TCP -> an HTTP/1.0 200 response whose body is "hello-from- dst=" + UDP -> "UDP- " +Every request is appended to the log file given as argv[1] (" TCP :" / " UDP :"). +""" +import asyncio, ipaddress, socket, struct, sys + +LOG = open(sys.argv[1], "a", buffering=1) if len(sys.argv) > 1 else sys.stdout + + +def log(proxy, kind, dst): + LOG.write(f"{proxy} {kind} {dst}\n") + + +async def origin(reader, writer, proxy, dst): + """Minimal HTTP/echo origin on an already-tunnelled stream.""" + if dst.endswith(":8888"): # "hold" destination: keep the tunnel open until the client closes (scale tests) + try: + while await reader.read(65536): + pass + except Exception: + pass + writer.close() + return + try: + data = await asyncio.wait_for(reader.read(65536), 5) + except Exception: + data = b"" + if data.startswith((b"GET", b"HEAD", b"POST")): + body = f"hello-from-{proxy} dst={dst}\n".encode() + writer.write(b"HTTP/1.0 200 OK\r\nContent-Length: %d\r\nConnection: close\r\n\r\n" % len(body) + body) + else: + writer.write(f"echo-{proxy}:".encode() + data) + try: + await writer.drain() + except Exception: + pass + writer.close() + + +def addr_str(atyp, raw): + if atyp == 1: + return str(ipaddress.IPv4Address(raw)) + if atyp == 4: + return str(ipaddress.IPv6Address(raw)) + return raw.decode() + + +async def read_addr(reader): + atyp = (await reader.readexactly(1))[0] + if atyp == 1: + raw = await reader.readexactly(4) + elif atyp == 4: + raw = await reader.readexactly(16) + else: + n = (await reader.readexactly(1))[0] + raw = await reader.readexactly(n) + port = struct.unpack("!H", await reader.readexactly(2))[0] + return atyp, raw, port + + +def socks_handler(name, auth): + async def handle(reader, writer): + try: + ver, n = await reader.readexactly(2) + methods = await reader.readexactly(n) + if auth: + if 2 not in methods: + writer.write(b"\x05\xff"); await writer.drain(); writer.close(); return + writer.write(b"\x05\x02"); await writer.drain() + v, ul = await reader.readexactly(2) + user = await reader.readexactly(ul) + pl = (await reader.readexactly(1))[0] + pw = await reader.readexactly(pl) + ok = user == b"bob" and pw == b"pw" + writer.write(b"\x01" + (b"\x00" if ok else b"\x01")); await writer.drain() + if not ok: + log(name, "AUTHFAIL", user.decode()); writer.close(); return + else: + writer.write(b"\x05\x00"); await writer.drain() + ver, cmd, _rsv = await reader.readexactly(3) + atyp, raw, port = await read_addr(reader) + dst = f"{addr_str(atyp, raw)}:{port}" + if cmd == 1: + log(name, "TCP", dst) + writer.write(b"\x05\x00\x00\x01" + socket.inet_aton("127.0.0.1") + struct.pack("!H", 0)); await writer.drain() + await origin(reader, writer, name, dst) + elif cmd == 3: + loop = asyncio.get_running_loop() + transport, _ = await loop.create_datagram_endpoint(lambda: UdpRelay(name), local_addr=("127.0.0.1", 0)) + bport = transport.get_extra_info("sockname")[1] + writer.write(b"\x05\x00\x00\x01" + socket.inet_aton("127.0.0.1") + struct.pack("!H", bport)); await writer.drain() + try: + while await reader.read(1024): # association lives as long as the TCP control connection + pass + finally: + transport.close() + else: + writer.write(b"\x05\x07\x00\x01" + b"\0" * 6); await writer.drain() + except Exception as e: + log(name, "ERR", repr(e)) + finally: + try: + writer.close() + except Exception: + pass + return handle + + +class UdpRelay(asyncio.DatagramProtocol): + def __init__(self, name): + self.name = name + + def connection_made(self, transport): + self.t = transport + + def datagram_received(self, data, addr): + if len(data) < 10 or data[2] != 0: + return + atyp = data[3] + if atyp == 1: + raw, off = data[4:8], 8 + elif atyp == 4: + raw, off = data[4:20], 20 + else: + n = data[4]; raw, off = data[5:5 + n], 5 + n + port = struct.unpack("!H", data[off:off + 2])[0] + payload = data[off + 2:] + dst = f"{addr_str(atyp, raw)}:{port}" + log(self.name, "UDP", dst) + reply = f"UDP-{self.name} {dst} ".encode() + payload + self.t.sendto(data[:off + 2] + reply, addr) + + +import base64 + + +def http_connect_handler(name, need_auth): + async def handler(reader, writer): + await http_connect(reader, writer, name, need_auth) + return handler + + +async def http_connect(reader, writer, name="C", need_auth=False): + try: + head = await reader.readuntil(b"\r\n\r\n") + first = head.split(b"\r\n")[0].decode() + if need_auth: + want = b"Proxy-Authorization: Basic " + base64.b64encode(b"bob:pw") + if want.lower() not in head.lower(): + log(name, "AUTHFAIL", first) + writer.write(b"HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"t\"\r\nContent-Length: 0\r\n\r\n") + await writer.drain(); writer.close(); return + if not first.startswith("CONNECT "): + writer.write(b"HTTP/1.1 405 Method Not Allowed\r\n\r\n"); await writer.drain(); writer.close(); return + dst = first.split()[1] + log(name, "TCP", dst) + writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n"); await writer.drain() + await origin(reader, writer, name, dst) + except Exception as e: + log(name, "ERR", repr(e)) + finally: + writer.close() + + +async def main(): + await asyncio.start_server(socks_handler("A", False), "127.0.0.1", 19080) + await asyncio.start_server(socks_handler("B", True), "127.0.0.1", 19081) + await asyncio.start_server(http_connect_handler("C", False), "127.0.0.1", 19082) + await asyncio.start_server(http_connect_handler("D", True), "127.0.0.1", 19083) + print("fake proxies up: A=socks5:19080 B=socks5(bob:pw):19081 C=http:19082", flush=True) + await asyncio.Event().wait() + +asyncio.run(main()) diff --git a/Linux/tests/gui_headless.sh b/Linux/tests/gui_headless.sh new file mode 100755 index 00000000..d3e9d48c --- /dev/null +++ b/Linux/tests/gui_headless.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# Runs the real GTK GUI headlessly (GTK Broadway backend, no window on your desktop) against the real engine: +# config loading (profiles, proxies, rules incl. domain rules) -> engine start -> real traffic -> clean shutdown. +# Needs root, broadwayd, dig, curl, and a built GUI: pass the path of ProxyBridgeGUI and of the directory holding libproxybridge.so. +# sudo tests/gui_headless.sh +set -u +GUI=${1:?path to ProxyBridgeGUI}; LIBDIR=${2:?dir with libproxybridge.so} +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORK="$(mktemp -d)"; PLOG="$WORK/proxy.log"; DLOG="$WORK/dns.log"; : > "$PLOG"; : > "$DLOG" +PASS=0; FAIL=0; DNSIP=10.77.0.4 +ok() { echo " PASS $1"; PASS=$((PASS+1)); } +bad() { echo " FAIL $1"; FAIL=$((FAIL+1)); } +has() { [[ "$2" == *"$3"* ]] && ok "$1" || bad "$1 (got: ${2:0:160})"; } +cleanup() { kill $GUI_PID $FAKE $DNSP $BW 2>/dev/null; wait 2>/dev/null; ip addr del $DNSIP/32 dev lo 2>/dev/null; rm -rf "$WORK"; } +trap cleanup EXIT +[ "$(id -u)" = 0 ] || { echo "run as root"; exit 1; } +ip addr add $DNSIP/32 dev lo 2>/dev/null +python3 "$HERE/fake_proxies.py" "$PLOG" >/dev/null 2>&1 & FAKE=$! +python3 "$HERE/fake_dns.py" $DNSIP "$DLOG" >/dev/null 2>&1 & DNSP=$! +broadwayd :9 >/dev/null 2>&1 & BW=$! +sleep 1.5 + +# profile "Default": proxy a (default) + b; "Alt": curl goes through b instead +write_cfg() { # active-profile +mkdir -p "$WORK/cfg" +cat > "$WORK/cfg/config.ini" < "$WORK/gui.out" 2>&1 & GUI_PID=$! + for _ in $(seq 60); do "${BPFTOOL:-bpftool}" prog show 2>/dev/null | grep -q pb_connect4 && break; kill -0 $GUI_PID 2>/dev/null || break; sleep 0.25; done + sleep 1 +} +stop_gui() { + kill -TERM $GUI_PID 2>/dev/null + for _ in $(seq 40); do kill -0 $GUI_PID 2>/dev/null || break; sleep 0.25; done + if kill -0 $GUI_PID 2>/dev/null; then kill -KILL $GUI_PID; bad "GUI did not exit on SIGTERM"; else wait $GUI_PID; rc=$?; [ "$rc" = 0 ] && ok "GUI exits cleanly on SIGTERM (rc=0)" || bad "GUI exit code $rc"; fi +} + +echo "== profile Default" +run_gui Default +kill -0 $GUI_PID 2>/dev/null && ok "GUI started under Broadway" || { bad "GUI died: $(head -5 "$WORK/gui.out")"; } +"${BPFTOOL:-bpftool}" prog show 2>/dev/null | grep -q pb_connect4 && ok "engine started by the GUI (eBPF programs attached)" || bad "no eBPF programs" +out=$(curl -s -m 5 http://192.0.2.7/); has "rule 1 from the config file: curl -> proxy a" "$out" "hello-from-A dst=192.0.2.7:80" +out=$(curl -s -m 5 http://198.51.100.7/); has "rule 2: curl -> proxy b (with credentials from the config)" "$out" "hello-from-B dst=198.51.100.7:80" +out=$(curl -s -m 5 http://203.0.114.7/ 2>&1; echo "rc=$?"); has "rule 3: BLOCK" "$out" "rc=7" +out=$(curl -s -m 2 http://203.0.113.7/ 2>&1; echo "rc=$?"); [[ "$out" != *hello-from* ]] && ok "disabled rule 5 is not applied" || bad "disabled rule applied" +ip4=$(dig @$DNSIP app.example.test +short +time=3 +tries=1 | head -1) +out=$(curl -s -m 5 http://$ip4/); has "domain rule from the config (*.example.test -> b) after a real DNS lookup" "$out" "hello-from-B dst=192.0.2.77:80" +stop_gui +"${BPFTOOL:-bpftool}" prog show 2>/dev/null | grep -q pb_connect && bad "BPF programs left after GUI exit" || ok "programs detached after GUI exit" + +echo "== profile Alt (same GUI, different active profile)" +run_gui Alt +out=$(curl -s -m 5 http://192.0.2.7/); has "Alt profile: same rule now goes through proxy b" "$out" "hello-from-B dst=192.0.2.7:80" +out=$(curl -s -m 2 http://198.51.100.7/ 2>&1; echo "rc=$?"); [[ "$out" != *hello-from* ]] && ok "Alt profile does not apply rules of the Default profile" || bad "Default rules leaked into Alt" +stop_gui +grep -q "^\[PROFILE:Default\]" "$WORK/cfg/config.ini" && grep -q "^\[PROFILE:Alt\]" "$WORK/cfg/config.ini" && ok "both profiles preserved in the config after the GUI saved it" || bad "profiles lost from the config" +[ "$(stat -c %a "$WORK/cfg/config.ini")" = 600 ] && ok "config file mode is 0600" || bad "config file mode" + +echo; echo "passed=$PASS failed=$FAIL" +[ "$FAIL" = 0 ] || { echo "---- gui output"; cat "$WORK/gui.out"; echo "---- config"; cat "$WORK/cfg/config.ini"; exit 1; } diff --git a/Linux/tests/hookbench.c b/Linux/tests/hookbench.c new file mode 100644 index 00000000..3add3d0d --- /dev/null +++ b/Linux/tests/hookbench.c @@ -0,0 +1,37 @@ +// hookbench tcp|udp : measures connect() latency (tcp) or sendto() rate + loopback delivery (udp) to a local server it starts itself. +#include +#include +#include +#include +#include +#include +#include +#include +static double now_us(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return t.tv_sec * 1e6 + t.tv_nsec / 1e3; } +static const char *ip; static int port, tcp; +static void *srv(void *a) +{ + (void)a; + int s = socket(AF_INET, tcp ? SOCK_STREAM : SOCK_DGRAM, 0), one = 1; setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + struct sockaddr_in sa = { .sin_family = AF_INET, .sin_port = htons(port) }; inet_pton(AF_INET, ip, &sa.sin_addr); + if (bind(s, (void *)&sa, sizeof sa)) { perror("bind"); exit(1); } + if (tcp) { listen(s, 4096); for (;;) { int c = accept(s, 0, 0); if (c >= 0) close(c); } } + char b[2048]; for (;;) recv(s, b, sizeof b, 0); +} +int main(int argc, char **argv) +{ + tcp = !strcmp(argv[1], "tcp"); ip = argv[2]; port = atoi(argv[3]); int n = atoi(argv[4]); + pthread_t t; pthread_create(&t, 0, srv, 0); usleep(200000); + struct sockaddr_in sa = { .sin_family = AF_INET, .sin_port = htons(port) }; inet_pton(AF_INET, ip, &sa.sin_addr); + if (tcp) { + double sum = 0; int ok = 0; + for (int i = 0; i < n; i++) { int s = socket(AF_INET, SOCK_STREAM, 0); double a = now_us(); int r = connect(s, (void *)&sa, sizeof sa); sum += now_us() - a; ok += !r; close(s); } + printf("tcp connect %s:%d ok=%d/%d avg=%.2f us\n", ip, port, ok, n, sum / n); + } else { + int s = socket(AF_INET, SOCK_DGRAM, 0); char buf[64] = "x"; double a = now_us(); + for (int i = 0; i < n; i++) sendto(s, buf, 32, 0, (void *)&sa, sizeof sa); + double d = now_us() - a; + printf("udp sendto %s:%d n=%d %.3f us/call %.0f kpps\n", ip, port, n, d / n, n / d * 1e3); + } + return 0; +} diff --git a/Linux/tests/hookbench.sh b/Linux/tests/hookbench.sh new file mode 100755 index 00000000..767a8d76 --- /dev/null +++ b/Linux/tests/hookbench.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Overhead of the eBPF hooks on DIRECT traffic. Needs root. sudo tests/hookbench.sh +HB=${1:?path to hookbench}; ROOT="$(dirname "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)")" +PB_DIR="${PB_DIR:-$ROOT/output}" # directory holding the built ProxyBridge + libproxybridge.so (run ../build.sh first) +[ -x "$PB_DIR/ProxyBridge" ] && [ -f "$PB_DIR/libproxybridge.so" ] || { echo "Build first: (cd Linux && ./build.sh), or set PB_DIR to a directory containing ProxyBridge and libproxybridge.so"; exit 1; } +export LD_LIBRARY_PATH="$PB_DIR" +IP=10.77.0.1 # alias on lo: local like loopback, but not caught by the implicit-loopback bypass +ip addr add $IP/32 dev lo 2>/dev/null +trap 'ip addr del $IP/32 dev lo 2>/dev/null' EXIT +run() { for i in 1 2 3; do $HB udp $IP 19556 1000000; done; } +echo "### A: ProxyBridge not running"; run +start() { stdbuf -oL "$PB_DIR/ProxyBridge" "$@" > /tmp/pbbench.out 2>&1 & PB=$!; for _ in $(seq 40); do grep -q "ProxyBridge started" /tmp/pbbench.out && break; sleep 0.25; done; } +echo "### B: running, 20 rules for other programs (this binary: empty class -> immediate direct)" +start $(for i in $(seq 20); do echo --rule "someapp$i:*:*:BOTH:PROXY"; done); run; kill -INT $PB; wait $PB 2>/dev/null +echo "### C: running, rules that match this binary but not this destination (full rule evaluation, then direct)" +start --rule "hookbench:192.0.2.0/24:*:BOTH:PROXY" --rule "hookbench:198.51.100.0/24:80;443;8000-9000:BOTH:PROXY" --rule "hookbench:203.0.113.0/24:*:BOTH:BLOCK"; run; kill -INT $PB; wait $PB 2>/dev/null +echo "### D: running, wildcard process rule with 4 destination ranges (every process evaluated)" +start --rule "*:192.0.2.0/24;198.51.100.0/24;203.0.113.0/24;100.64.0.0/10:*:BOTH:PROXY"; run; kill -INT $PB; wait $PB 2>/dev/null diff --git a/Linux/tests/nexus-linux-udp.patch b/Linux/tests/nexus-linux-udp.patch new file mode 100644 index 00000000..3b721de1 --- /dev/null +++ b/Linux/tests/nexus-linux-udp.patch @@ -0,0 +1,25 @@ +--- a/script.js ++++ b/script.js +@@ -301,7 +301,7 @@ + this.handleUDPMessage(msg, rinfo, udpRelay, remoteMap); + }); + +- udpRelay.bind(0, '0.0.0.0', () => { ++ udpRelay.bind(0, '::', () => { + const udpPort = udpRelay.address().port; + + this.udpClients.set(clientKey, { +@@ -428,10 +428,12 @@ + console.log(` └─> ${host}:${port}${protocol}`); + } + +- udpRelay.send(data, port, host); ++ // the relay socket is IPv6 dual-stack: IPv4 destinations must be addressed as ::ffff:a.b.c.d ++ udpRelay.send(data, port, host.includes(':') ? host : '::ffff:' + host); + } + + handleRemoteToClient(msg, rinfo, udpRelay, remoteMap) { ++ rinfo = { ...rinfo, address: rinfo.address.replace(/^::ffff:/i, '') }; + const remoteKey = `${rinfo.address}:${rinfo.port}`; + const clientInfo = remoteMap.get(remoteKey); + diff --git a/Linux/tests/nexus_origins.py b/Linux/tests/nexus_origins.py new file mode 100644 index 00000000..2d154041 --- /dev/null +++ b/Linux/tests/nexus_origins.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Real origin servers for the Nexus Proxy end-to-end tests (they actually serve data, nothing is faked on the proxy side). + +usage: nexus_origins.py (binds the addresses below; add them to `lo` first) + HTTP 10.77.1.1..6:18700 "origin-1".."origin-6" + 10.77.1.1:18701..18705 "origin-1-p" + GET /big?mb=N -> N MiB of a deterministic pattern (see PATTERN) + POST /upload -> sha256 of the request body + GET /slowclose -> response, then the server half-closes and waits for the client to close + UDP 10.77.1.1:18710 "U1:" 10.77.1.2:18710 "U2:" 10.77.1.1:18711 "U3:" echo prefixed with the tag +""" +import hashlib, http.server, socket, socketserver, threading, sys, time + +BLOCK = bytes((i * 7 + 13) & 255 for i in range(1 << 20)) # 1 MiB pattern block + + +class H(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.0" + tag = "origin" + + def do_GET(self): + if self.path.startswith("/big"): + mb = int(self.path.split("mb=")[1]) + self.send_response(200); self.send_header("Content-Length", str(mb << 20)); self.end_headers() + for _ in range(mb): + self.wfile.write(BLOCK) + else: + body = (self.tag + "\n").encode() + self.send_response(200); self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body) + + def do_POST(self): + n = int(self.headers.get("Content-Length", "0")); h = hashlib.sha256(); left = n + while left: + b = self.rfile.read(min(1 << 20, left)) + if not b: + break + h.update(b); left -= len(b) + body = h.hexdigest().encode() + self.send_response(200); self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body) + + def log_message(self, *a): + pass + + +class TS(socketserver.ThreadingMixIn, http.server.HTTPServer): + allow_reuse_address = True + daemon_threads = True + request_queue_size = 1024 + + +def http(ip, port, tag): + handler = type("H_" + tag, (H,), {"tag": tag}) + srv = TS((ip, port), handler) + threading.Thread(target=srv.serve_forever, daemon=True).start() + + +def udp(ip, port, tag): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 << 20) + s.bind((ip, port)) + def loop(): + while True: + d, a = s.recvfrom(70000) + try: + s.sendto(tag.encode() + d, a) + except OSError: + pass + threading.Thread(target=loop, daemon=True).start() + + +for i in (1, 2, 3, 4, 5, 6): + http("10.77.1.%d" % i, 18700, "origin-%d" % i) +for p in range(18701, 18706): + http("10.77.1.1", p, "origin-1-p%d" % p) +udp("10.77.1.1", 18710, "U1:"); udp("10.77.1.2", 18710, "U2:"); udp("10.77.1.1", 18711, "U3:") +print("origins up", flush=True) +while True: + time.sleep(3600) diff --git a/Linux/tests/scale.sh b/Linux/tests/scale.sh new file mode 100755 index 00000000..7ff3dcac --- /dev/null +++ b/Linux/tests/scale.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Holds N concurrent proxied TCP connections open through the full stack and reports ProxyBridge memory / CPU. +# sudo tests/scale.sh [N] +set -u +N=${1:-10000} +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; ROOT="$(dirname "$HERE")" +PB_DIR="${PB_DIR:-$ROOT/output}" # directory holding the built ProxyBridge + libproxybridge.so (run ../build.sh first) +[ -x "$PB_DIR/ProxyBridge" ] && [ -f "$PB_DIR/libproxybridge.so" ] || { echo "Build first: (cd Linux && ./build.sh), or set PB_DIR to a directory containing ProxyBridge and libproxybridge.so"; exit 1; } +export LD_LIBRARY_PATH="$PB_DIR" +ulimit -n 200000 +WORK=$(mktemp -d); trap 'kill $FAKE $PB 2>/dev/null; wait 2>/dev/null; rm -rf $WORK' EXIT +python3 "$HERE/fake_proxies.py" $WORK/log >/dev/null 2>&1 & FAKE=$! +sleep 1 +stdbuf -oL "$PB_DIR/ProxyBridge" --proxy a=socks5://127.0.0.1:19080 --rule "python3*:192.0.2.0/24:*:TCP:PROXY=a" > $WORK/pb.out 2>&1 & PB=$! +for _ in $(seq 40); do grep -q "ProxyBridge started" $WORK/pb.out && break; sleep 0.25; done +rss() { awk '/VmRSS/{print $2}' /proc/$PB/status; } +maps_kb() { "${BPFTOOL:-bpftool}" map show 2>/dev/null | grep -oE 'memlock [0-9]+' | awk '{s+=$2} END{printf "%d", s/1024}'; } +echo "baseline: ProxyBridge RSS=$(( $(rss) / 1024 )) MB BPF maps=$(maps_kb) KB threads=$(ls /proc/$PB/task | wc -l)" +CPU0=$(awk '{print $14+$15}' /proc/$PB/stat) +rm -f /tmp/pb_scale_ready /tmp/pb_scale_go +python3 - $N > $WORK/client.out <<'PY' & +import asyncio, sys, time, resource, os +n = int(sys.argv[1]) +resource.setrlimit(resource.RLIMIT_NOFILE, (200000, 200000)) +async def main(): + t0 = time.time(); conns = []; fails = 0 + sem = asyncio.Semaphore(500) + async def one(i): + nonlocal fails + async with sem: + try: + r, w = await asyncio.wait_for(asyncio.open_connection("192.0.2.%d" % (1 + i % 250), 8888), 30) + w.write(b"x"); await w.drain() + conns.append((r, w)) + except Exception: + fails += 1 + await asyncio.gather(*(one(i) for i in range(n))) + dt = time.time() - t0 + print(f"client: {len(conns)} established, {fails} failed, {dt:.1f}s ({len(conns)/dt:.0f} conn/s)", flush=True) + open("/tmp/pb_scale_ready", "w").write("1") + while not os.path.exists("/tmp/pb_scale_go"): + await asyncio.sleep(0.2) + for r, w in conns: w.close() +asyncio.run(main()) +PY +CL=$! +for _ in $(seq 300); do [ -e /tmp/pb_scale_ready ] && break; sleep 1; done +sleep 2 +cat $WORK/client.out +CPU1=$(awk '{print $14+$15}' /proc/$PB/stat) +echo "under load: ProxyBridge RSS=$(( $(rss) / 1024 )) MB BPF maps=$(maps_kb) KB threads=$(ls /proc/$PB/task | wc -l) fds=$(ls /proc/$PB/fd | wc -l)" +echo "ProxyBridge CPU while opening: $(( (CPU1 - CPU0) * 1000 / $(getconf CLK_TCK) )) ms" +echo "kernel socket memory (TCP): $(grep '^TCP:' /proc/net/sockstat)" +touch /tmp/pb_scale_go; wait $CL 2>/dev/null; sleep 3 +echo "after close: ProxyBridge RSS=$(( $(rss) / 1024 )) MB fds=$(ls /proc/$PB/fd | wc -l)" +rm -f /tmp/pb_scale_ready /tmp/pb_scale_go diff --git a/Linux/tests/thrbench.c b/Linux/tests/thrbench.c new file mode 100644 index 00000000..b0334420 --- /dev/null +++ b/Linux/tests/thrbench.c @@ -0,0 +1,93 @@ +// Throughput benchmark: compares an application talking to a SOCKS5 proxy itself ("socks") against the same application being +// transparently redirected by ProxyBridge ("transparent"). The bundled proxy accepts any CONNECT and forwards to the local sink. +// thrbench proxy SOCKS5 (no auth) forwarder, splice() based +// thrbench sink discards data, replies with the byte count at EOF +// thrbench socks [conns] client doing the SOCKS5 handshake itself +// thrbench direct [conns] client connecting straight to the sink +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +static double now_s(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return t.tv_sec + t.tv_nsec / 1e9; } +static struct sockaddr_in addr_of(const char *ip, int port) { struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons(port) }; inet_pton(AF_INET, ip, &a.sin_addr); return a; } +static int listener(int port) +{ + int s = socket(AF_INET, SOCK_STREAM, 0), one = 1; setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + struct sockaddr_in a = addr_of("127.0.0.1", port); + if (bind(s, (void *)&a, sizeof a) || listen(s, 1024)) { perror("listen"); exit(1); } + return s; +} +static int read_full(int fd, void *b, size_t n) { size_t o = 0; while (o < n) { ssize_t r = read(fd, (char *)b + o, n - o); if (r <= 0) return -1; o += (size_t)r; } return 0; } + +static void *sink_conn(void *a) +{ + int c = (int)(long)a; static __thread char b[1 << 20]; unsigned long long total = 0; ssize_t n; + while ((n = read(c, b, sizeof b)) > 0) total += (unsigned long long)n; + write(c, &total, sizeof total); close(c); return NULL; +} +static void run_sink(int port) { int l = listener(port); for (;;) { int c = accept(l, 0, 0); pthread_t t; pthread_create(&t, 0, sink_conn, (void *)(long)c); pthread_detach(t); } } + +struct pair { int a, b; }; +static void *pipe_dir(void *arg) +{ + struct pair *p = arg; int pp[2]; pipe(pp); fcntl(pp[0], F_SETPIPE_SZ, 1 << 20); + for (;;) { + ssize_t n = splice(p->a, 0, pp[1], 0, 1 << 20, SPLICE_F_MOVE); + if (n <= 0) break; + while (n > 0) { ssize_t w = splice(pp[0], 0, p->b, 0, (size_t)n, SPLICE_F_MOVE); if (w <= 0) goto out; n -= w; } + } +out: shutdown(p->b, SHUT_WR); close(pp[0]); close(pp[1]); return NULL; +} +static void *proxy_conn(void *arg) +{ + int c = (int)(long)arg; unsigned char b[512]; + if (read_full(c, b, 2) || read_full(c, b + 2, b[1])) goto done; + write(c, "\x05\x00", 2); + if (read_full(c, b, 4)) goto done; + if (b[3] == 1) read_full(c, b, 6); else if (b[3] == 4) read_full(c, b, 18); else { unsigned char l; read_full(c, &l, 1); read_full(c, b, l + 2u); } + extern int g_sink; int u = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in a = addr_of("127.0.0.1", g_sink); + if (connect(u, (void *)&a, sizeof a)) goto done; + write(c, "\x05\x00\x00\x01\x7f\x00\x00\x01\x00\x00", 10); + struct pair p1 = { c, u }, p2 = { u, c }; pthread_t t1, t2; + pthread_create(&t1, 0, pipe_dir, &p1); pthread_create(&t2, 0, pipe_dir, &p2); pthread_join(t1, 0); pthread_join(t2, 0); close(u); +done: close(c); return NULL; +} +int g_sink; +static void run_proxy(int port, int sink) { g_sink = sink; int l = listener(port); for (;;) { int c = accept(l, 0, 0); pthread_t t; pthread_create(&t, 0, proxy_conn, (void *)(long)c); pthread_detach(t); } } + +struct cl { const char *ip; int port, proxy_port; long mb; double secs; int ok; }; +static void *client(void *arg) +{ + struct cl *c = arg; int s = socket(AF_INET, SOCK_STREAM, 0); + struct sockaddr_in a = c->proxy_port ? addr_of("127.0.0.1", c->proxy_port) : addr_of(c->ip, c->port); + if (connect(s, (void *)&a, sizeof a)) { perror("connect"); return NULL; } + if (c->proxy_port) { // SOCKS5 CONNECT done by the application itself + unsigned char b[32] = { 5, 1, 0 }; write(s, b, 3); read_full(s, b, 2); + unsigned char req[10] = { 5, 1, 0, 1 }; inet_pton(AF_INET, c->ip, req + 4); req[8] = (unsigned char)(c->port >> 8); req[9] = (unsigned char)c->port; + write(s, req, 10); if (read_full(s, b, 10)) return NULL; + } + static __thread char buf[1 << 20]; memset(buf, 'x', sizeof buf); + double t0 = now_s(); + for (long i = 0; i < c->mb; i++) if (write(s, buf, sizeof buf) <= 0) return NULL; + shutdown(s, SHUT_WR); + unsigned long long total = 0; read_full(s, &total, sizeof total); + c->secs = now_s() - t0; c->ok = total == (unsigned long long)c->mb << 20; close(s); return NULL; +} +int main(int argc, char **argv) +{ + if (!strcmp(argv[1], "sink")) run_sink(atoi(argv[2])); + if (!strcmp(argv[1], "proxy")) run_proxy(atoi(argv[2]), atoi(argv[3])); + int socks = !strcmp(argv[1], "socks"), ai = socks ? 3 : 2; + int pp = socks ? atoi(argv[2]) : 0; const char *ip = argv[ai]; int port = atoi(argv[ai + 1]); long mb = atol(argv[ai + 2]); int n = argc > ai + 3 ? atoi(argv[ai + 3]) : 1; + struct cl *c = calloc((size_t)n, sizeof *c); pthread_t *t = calloc((size_t)n, sizeof *t); double t0 = now_s(); + for (int i = 0; i < n; i++) { c[i] = (struct cl){ ip, port, pp, mb }; pthread_create(&t[i], 0, client, &c[i]); } + int ok = 0; for (int i = 0; i < n; i++) { pthread_join(t[i], 0); ok += c[i].ok; } + double dt = now_s() - t0; printf("%-11s conns=%d ok=%d %ld MB each total %.0f MB in %.2fs = %.0f MB/s\n", argv[1], n, ok, mb, (double)n * (double)mb, dt, (double)n * (double)mb / dt); + return 0; +} diff --git a/Linux/tests/thrbench.sh b/Linux/tests/thrbench.sh new file mode 100755 index 00000000..37fd38d9 --- /dev/null +++ b/Linux/tests/thrbench.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Throughput through ProxyBridge vs the same application speaking SOCKS5 itself. Needs root. +# sudo tests/thrbench.sh (binary must be named thrbench so process rules can match it) +TB=${1:?path to thrbench binary named "thrbench"}; ROOT="$(dirname "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)")" +PB_DIR="${PB_DIR:-$ROOT/output}" # directory holding the built ProxyBridge + libproxybridge.so (run ../build.sh first) +[ -x "$PB_DIR/ProxyBridge" ] && [ -f "$PB_DIR/libproxybridge.so" ] || { echo "Build first: (cd Linux && ./build.sh), or set PB_DIR to a directory containing ProxyBridge and libproxybridge.so"; exit 1; } +export LD_LIBRARY_PATH="$PB_DIR" +MB=${MB:-2048} +$TB sink 19990 & SINK=$!; $TB proxy 19991 19990 & PROXY=$! +trap 'kill $SINK $PROXY $PB 2>/dev/null' EXIT +sleep 0.5 +echo "== baselines (ProxyBridge not running)" +for i in 1 2 3; do $TB direct 127.0.0.1 19990 $MB 1; done +for i in 1 2 3; do $TB socks 19991 192.0.2.7 9999 $MB 1; done +for i in 1 2; do $TB socks 19991 192.0.2.7 9999 $((MB/8)) 8; done +stdbuf -oL "$PB_DIR/ProxyBridge" --proxy a=socks5://127.0.0.1:19991 --rule "thrbench:192.0.2.0/24:*:TCP:PROXY=a" > /tmp/thr_pb.out 2>&1 & PB=$! +for _ in $(seq 40); do grep -q "ProxyBridge started" /tmp/thr_pb.out && break; sleep 0.25; done +echo "== through ProxyBridge (app connects to 192.0.2.7:9999, transparently redirected)" +for i in 1 2 3; do $TB direct 192.0.2.7 9999 $MB 1; done +for i in 1 2; do $TB direct 192.0.2.7 9999 $((MB/8)) 8; done +echo "== direct traffic while ProxyBridge is running (127.0.0.1 is never redirected; non-matching process)" +for i in 1 2 3; do $TB direct 127.0.0.1 19990 $MB 1; done +kill -INT $PB; wait $PB 2>/dev/null