From 30451cfefacffa6df606710282fd154ffecb5723 Mon Sep 17 00:00:00 2001 From: Dream95 Date: Thu, 7 May 2026 13:25:00 +0000 Subject: [PATCH 1/3] feat: add mirror --- README.md | 7 ++ README_CN.md | 6 + cmd/cmd.go | 40 ++++++ cmd/loadBpf.go | 3 +- cmd/mirror.go | 111 +++++++++++++++++ cmd/tcpProxy.go | 74 ++++++++++-- cmd/udpProxy.go | 10 +- cmd/version.go | 2 +- scripts/test_mirror.sh | 269 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 506 insertions(+), 16 deletions(-) create mode 100644 cmd/mirror.go create mode 100755 scripts/test_mirror.sh diff --git a/README.md b/README.md index 238819b..6b86a76 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,13 @@ sudo ./gotproxy [flags] | **--socks5-pass** | SOCKS5 password (RFC1929). Must be set together with `--socks5-user`. | | **--proto** | Proxy protocol selection: `both` (default) / `tcp` / `udp`. When set to `tcp`, only TCP traffic will be redirected; when set to `udp`, only UDP traffic will be redirected. | | **--no-dns53** | Disable automatic UDP DNS rewrite from `127.0.0.53:53` to `1.1.1.1:53` (enabled by default). | +| **--mirror-enable** | Enable best-effort traffic mirroring. | +| **--mirror-target** | Mirror destination address, for example `10.0.0.2:9000`. | +| **--mirror-proto** | Mirror protocol: `auto` (default, follows `--proto`) / `both` / `tcp` / `udp`. | +| **--mirror-timeout-ms** | Mirror write timeout in milliseconds (default: `100`). | +| **--mirror-queue** | Mirror async queue size (default: `1024`). | +| **--mirror-drop-on-full** | Drop mirrored packets when queue is full (default: `true`). | + Features Under Development: IPv6 support diff --git a/README_CN.md b/README_CN.md index 620f664..fbb23ea 100644 --- a/README_CN.md +++ b/README_CN.md @@ -45,6 +45,12 @@ sudo ./gotproxy [flags] | **--socks5-pass** | socks5 密码(RFC1929)。需要同时设置 `--socks5-user`。 | | **--proto** | 代理协议选择:`both`(默认)/ `tcp` / `udp`。当设置为 `tcp` 时只重定向 TCP 流量;设置为 `udp` 时只重定向 UDP 流量。 | | **--no-dns53** | 关闭 UDP DNS 对 `127.0.0.53:53` 的自动改写。默认会自动改写为 `1.1.1.1:53`。 | +| **--mirror-enable** | 开启尽力而为的流量复制。 | +| **--mirror-target** | 复制目标地址,例如 `10.0.0.2:9000`。 | +| **--mirror-proto** | 复制协议:`auto`(默认,跟随 `--proto`)/ `both` / `tcp` / `udp`。 | +| **--mirror-timeout-ms** | 复制写超时时间(毫秒,默认 `100`)。 | +| **--mirror-queue** | 复制异步队列大小(默认 `1024`)。 | +| **--mirror-drop-on-full** | 当队列满时是否丢弃复制数据(默认 `true`)。 | 正在开发中的功能: diff --git a/cmd/cmd.go b/cmd/cmd.go index b8a3305..00304e0 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -6,6 +6,8 @@ import ( "log" "os" "strconv" + "strings" + "time" "github.com/spf13/cobra" ) @@ -22,6 +24,12 @@ var ( socks5Pass string proto string noDNS53 bool + mirrorEnable bool + mirrorTarget string + mirrorProto string + mirrorTimeoutMs int + mirrorQueue int + mirrorDropFull bool ) var rootCmd = &cobra.Command{ @@ -46,6 +54,24 @@ var rootCmd = &cobra.Command{ default: log.Fatalf("Invalid --proto value %q, expected one of: both|tcp|udp", proto) } + resolvedMirrorProto := strings.TrimSpace(strings.ToLower(mirrorProto)) + if resolvedMirrorProto == "auto" { + resolvedMirrorProto = proto + } + switch resolvedMirrorProto { + case "both", "tcp", "udp": + default: + log.Fatalf("Invalid --mirror-proto value %q, expected one of: auto|both|tcp|udp", mirrorProto) + } + if mirrorEnable && strings.TrimSpace(mirrorTarget) == "" { + log.Fatalf("Invalid mirror config: --mirror-enable requires --mirror-target") + } + if mirrorTimeoutMs <= 0 { + log.Fatalf("Invalid --mirror-timeout-ms value %d, expected > 0", mirrorTimeoutMs) + } + if mirrorQueue <= 0 { + log.Fatalf("Invalid --mirror-queue value %d, expected > 0", mirrorQueue) + } Options := &Options{ Command: command, @@ -54,6 +80,14 @@ var rootCmd = &cobra.Command{ ContainerName: containerName, EnableTCP: enableTCP, EnableUDP: enableUDP, + Mirror: MirrorOptions{ + Enabled: mirrorEnable, + Target: strings.TrimSpace(mirrorTarget), + Proto: resolvedMirrorProto, + Timeout: time.Duration(mirrorTimeoutMs) * time.Millisecond, + QueueSize: mirrorQueue, + DropOnFull: mirrorDropFull, + }, } if ok, err := common.HasPermission(); err != nil { @@ -106,4 +140,10 @@ func init() { rootCmd.PersistentFlags().StringVar(&socks5Pass, "socks5-pass", "", "The SOCKS5 password. Requires --socks5-user.") rootCmd.PersistentFlags().StringVar(&proto, "proto", "both", "Proxy protocol: both|tcp|udp") rootCmd.PersistentFlags().BoolVar(&noDNS53, "no-dns53", false, "Disable UDP DNS destination rewrite from 127.0.0.53:53 to 1.1.1.1:53") + rootCmd.PersistentFlags().BoolVar(&mirrorEnable, "mirror-enable", false, "Enable traffic mirroring") + rootCmd.PersistentFlags().StringVar(&mirrorTarget, "mirror-target", "", "Mirror destination address, e.g. 10.0.0.2:9000") + rootCmd.PersistentFlags().StringVar(&mirrorProto, "mirror-proto", "auto", "Mirror protocol: auto|both|tcp|udp") + rootCmd.PersistentFlags().IntVar(&mirrorTimeoutMs, "mirror-timeout-ms", 100, "Mirror write timeout in milliseconds") + rootCmd.PersistentFlags().IntVar(&mirrorQueue, "mirror-queue", 1024, "Mirror async queue size") + rootCmd.PersistentFlags().BoolVar(&mirrorDropFull, "mirror-drop-on-full", true, "Drop mirrored packets when mirror queue is full") } diff --git a/cmd/loadBpf.go b/cmd/loadBpf.go index 987e1c7..78be274 100644 --- a/cmd/loadBpf.go +++ b/cmd/loadBpf.go @@ -37,6 +37,7 @@ type Options struct { Ip4Mask uint8 EnableTCP bool EnableUDP bool + Mirror MirrorOptions } func LoadBpf(options *Options) { @@ -85,7 +86,7 @@ func LoadBpf(options *Options) { // Start TCP (and UDP) proxy so it can use objs.MapUdpDest for UDP original-dest lookup if options.ProxyPid == 0 { - StartProxy(objs.MapUdpDest, options.EnableTCP, options.EnableUDP, proxyListenHost) + StartProxy(objs.MapUdpDest, options.EnableTCP, options.EnableUDP, proxyListenHost, options.Mirror) } // Attach eBPF programs to the root cgroup diff --git a/cmd/mirror.go b/cmd/mirror.go new file mode 100644 index 0000000..cc6621d --- /dev/null +++ b/cmd/mirror.go @@ -0,0 +1,111 @@ +package main + +import ( + "log" + "net" + "strings" + "sync/atomic" + "time" +) + +type MirrorOptions struct { + Enabled bool + Target string + Proto string + Timeout time.Duration + QueueSize int + DropOnFull bool +} + +type mirrorMessage struct { + network string + payload []byte +} + +type MirrorDispatcher struct { + opts MirrorOptions + queue chan mirrorMessage + dropped atomic.Uint64 + sent atomic.Uint64 + sendError atomic.Uint64 +} + +func NewMirrorDispatcher(opts MirrorOptions) *MirrorDispatcher { + if !opts.Enabled || opts.Target == "" { + return nil + } + if opts.Timeout <= 0 { + opts.Timeout = 100 * time.Millisecond + } + if opts.QueueSize <= 0 { + opts.QueueSize = 1024 + } + d := &MirrorDispatcher{ + opts: opts, + queue: make(chan mirrorMessage, opts.QueueSize), + } + go d.run() + log.Printf("Mirror enabled: target=%s proto=%s direction=uplink queue=%d timeout=%s", opts.Target, opts.Proto, opts.QueueSize, opts.Timeout) + return d +} + +func (d *MirrorDispatcher) ShouldMirror(network string) bool { + if d == nil { + return false + } + return matchMirrorProto(d.opts.Proto, network) +} + +func (d *MirrorDispatcher) Enqueue(network string, payload []byte) { + if d == nil || len(payload) == 0 { + return + } + msg := mirrorMessage{ + network: network, + payload: append([]byte(nil), payload...), + } + if d.opts.DropOnFull { + select { + case d.queue <- msg: + default: + d.dropped.Add(1) + } + return + } + d.queue <- msg +} + +func (d *MirrorDispatcher) run() { + for msg := range d.queue { + if err := d.send(msg); err != nil { + d.sendError.Add(1) + log.Printf("Mirror send failed (%s -> %s): %v", msg.network, d.opts.Target, err) + continue + } + d.sent.Add(1) + } +} + +func (d *MirrorDispatcher) send(msg mirrorMessage) error { + conn, err := net.DialTimeout(msg.network, d.opts.Target, d.opts.Timeout) + if err != nil { + return err + } + defer conn.Close() + _ = conn.SetWriteDeadline(time.Now().Add(d.opts.Timeout)) + _, err = conn.Write(msg.payload) + return err +} + +func matchMirrorProto(config string, network string) bool { + c := strings.ToLower(strings.TrimSpace(config)) + n := strings.ToLower(strings.TrimSpace(network)) + switch c { + case "both": + return n == "tcp" || n == "udp" + case "tcp", "udp": + return c == n + default: + return false + } +} diff --git a/cmd/tcpProxy.go b/cmd/tcpProxy.go index 1c49ebd..098f337 100644 --- a/cmd/tcpProxy.go +++ b/cmd/tcpProxy.go @@ -6,9 +6,9 @@ import ( "io" "log" "net" - "strings" - "os" + "strings" + "sync" "syscall" "time" "unsafe" @@ -18,8 +18,12 @@ import ( ) // StartProxy starts TCP/UDP proxy on proxyPort based on enableTCP/enableUDP. -func StartProxy(udpMap *ebpf.Map, enableTCP bool, enableUDP bool, listenHost string) { +func StartProxy(udpMap *ebpf.Map, enableTCP bool, enableUDP bool, listenHost string, mirrorOpts MirrorOptions) { proxyAddr := fmt.Sprintf("%s:%d", listenHost, proxyPort) + mirror := NewMirrorDispatcher(mirrorOpts) + if mirror != nil && strings.TrimSpace(mirrorOpts.Target) == proxyAddr { + log.Fatalf("Invalid mirror config: --mirror-target must not equal proxy listen address %s", proxyAddr) + } if !enableTCP && !enableUDP { log.Printf("Proxy: enableTCP and enableUDP are both false, nothing to start") @@ -32,16 +36,16 @@ func StartProxy(udpMap *ebpf.Map, enableTCP bool, enableUDP bool, listenHost str log.Fatalf("Failed to start TCP proxy server: %v", err) } log.Printf("TCP proxy server with PID %d listening on %s", os.Getpid(), proxyAddr) - go acceptLoop(listener) + go acceptLoop(listener, mirror) } if enableUDP && udpMap != nil { - go StartUDPProxy(proxyAddr, udpMap) + go StartUDPProxy(proxyAddr, udpMap, mirror) log.Printf("UDP proxy server with PID %d listening on %s", os.Getpid(), proxyAddr) } } -func acceptLoop(listener net.Listener) { +func acceptLoop(listener net.Listener, mirror *MirrorDispatcher) { defer listener.Close() for { conn, err := listener.Accept() @@ -50,7 +54,7 @@ func acceptLoop(listener net.Listener) { continue } - go handleConnection(conn) + go handleConnection(conn, mirror) } } @@ -62,7 +66,7 @@ func getsockopt(s int, level int, optname int, optval unsafe.Pointer, optlen *ui return } -func handleConnection(conn net.Conn) { +func handleConnection(conn net.Conn, mirror *MirrorDispatcher) { defer conn.Close() targetConn, err := getTargetConnection(conn) @@ -72,9 +76,57 @@ func handleConnection(conn net.Conn) { } defer targetConn.Close() - err = proxyBidirectional(conn, targetConn) - if err != nil && !isExpectedCopyError(err) { - log.Printf("Proxy copy error: %v", err) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + _, copyErr := copyWithMirror(targetConn, conn, func(chunk []byte) { + if mirror != nil && mirror.ShouldMirror("tcp") { + mirror.Enqueue("tcp", chunk) + } + }) + if copyErr != nil && copyErr != io.EOF { + log.Printf("Failed copying data to target: %v", copyErr) + } + }() + _, err = copyWithMirror(conn, targetConn, nil) + if err != nil { + log.Printf("Failed copying data from target: %v", err) + } + wg.Wait() +} + +func copyWithMirror(dst io.Writer, src io.Reader, mirrorFn func([]byte)) (int64, error) { + buf := make([]byte, 32*1024) + var written int64 + for { + nr, readErr := src.Read(buf) + if nr > 0 { + chunk := buf[:nr] + nwTotal := 0 + for nwTotal < nr { + nw, writeErr := dst.Write(chunk[nwTotal:]) + if nw > 0 { + nwTotal += nw + } + if writeErr != nil { + return written, writeErr + } + if nw == 0 { + return written, io.ErrShortWrite + } + } + written += int64(nr) + if mirrorFn != nil { + mirrorFn(chunk) + } + } + if readErr != nil { + if readErr == io.EOF { + return written, nil + } + return written, readErr + } } } diff --git a/cmd/udpProxy.go b/cmd/udpProxy.go index e6cdf62..21ca645 100644 --- a/cmd/udpProxy.go +++ b/cmd/udpProxy.go @@ -22,7 +22,7 @@ const ( // StartUDPProxy listens on addr (UDP) and forwards packets to original destinations // looked up from the BPF map (key = client addr, value = original dst ip:port). -func StartUDPProxy(addr string, udpMap *ebpf.Map) { +func StartUDPProxy(addr string, udpMap *ebpf.Map, mirror *MirrorDispatcher) { if udpMap == nil { return } @@ -50,11 +50,11 @@ func StartUDPProxy(addr string, udpMap *ebpf.Map) { } payload := make([]byte, n) copy(payload, buf[:n]) - go handleUDPPacket(conn, clientAddr, payload, udpMap) + go handleUDPPacket(conn, clientAddr, payload, udpMap, mirror) } } -func handleUDPPacket(proxyConn *net.UDPConn, clientAddr *net.UDPAddr, payload []byte, udpMap *ebpf.Map) { +func handleUDPPacket(proxyConn *net.UDPConn, clientAddr *net.UDPAddr, payload []byte, udpMap *ebpf.Map, mirror *MirrorDispatcher) { targetAddr, err := getUDPOriginalDest(clientAddr, udpMap) if err != nil { log.Printf("UDP proxy: lookup original dest for %s: %v", clientAddr, err) @@ -80,6 +80,9 @@ func handleUDPPacket(proxyConn *net.UDPConn, clientAddr *net.UDPAddr, payload [] log.Printf("UDP proxy: write to %s: %v", targetAddr, err) return } + if mirror != nil && mirror.ShouldMirror("udp") { + mirror.Enqueue("udp", payload) + } remoteConn.SetReadDeadline(time.Now().Add(udpReadTimeout)) respBuf := make([]byte, 64*1024) @@ -97,6 +100,7 @@ func handleUDPPacket(proxyConn *net.UDPConn, clientAddr *net.UDPAddr, payload [] _, err = proxyConn.WriteToUDP(respBuf[:m], clientAddr) if err != nil { log.Printf("UDP proxy: write back to client %s: %v", clientAddr, err) + return } } diff --git a/cmd/version.go b/cmd/version.go index 3291f93..cab984f 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -1,3 +1,3 @@ package main -var Version = "0.7.0" \ No newline at end of file +var Version = "0.8.0" \ No newline at end of file diff --git a/scripts/test_mirror.sh b/scripts/test_mirror.sh new file mode 100755 index 0000000..1d255c1 --- /dev/null +++ b/scripts/test_mirror.sh @@ -0,0 +1,269 @@ +#!/usr/bin/env bash +# gotproxy traffic mirror integration test (TCP + UDP) +# Usage: sudo ./scripts/test_mirror.sh +# Requires: built gotproxy, curl, python3; root (CAP_BPF). UDP test needs dig. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +GOTPROXY_BIN="${GOTPROXY_BIN:-$REPO_ROOT/gotproxy}" +PROXY_PORT="${PROXY_PORT:-18002}" +TEST_URL="${TEST_URL:-https://1.1.1.1}" +# Plain HTTP (not HTTPS): mirrored uplink is cleartext so the script can print a readable sample. +HTTPBIN_URL="${HTTPBIN_URL:-http://httpbin.org/get}" +EXAMPLE_IP="${EXAMPLE_IP:-1.1.1.1}" +MIRROR_HOST="${MIRROR_HOST:-127.0.0.1}" +MIRROR_TCP_PORT="${MIRROR_TCP_PORT:-19081}" +MIRROR_UDP_PORT="${MIRROR_UDP_PORT:-19082}" + +PASSED=0 +FAILED=0 +LOG_FILE="" +GOTPROXY_PID="" +TCP_LISTENER_PID="" +UDP_LISTENER_PID="" +TCP_CAPTURE_FILE="" +UDP_CAPTURE_FILE="" + +info() { echo "[INFO] $*"; } +ok() { echo "[OK] $*"; ((PASSED++)) || true; } +fail() { echo "[FAIL] $*"; ((FAILED++)) || true; } +abort() { echo "[ABORT] $*"; cleanup; exit 1; } + +cleanup_listener_pid() { + local pid="${1:-}" + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fi +} + +cleanup() { + cleanup_listener_pid "$GOTPROXY_PID" + cleanup_listener_pid "$TCP_LISTENER_PID" + cleanup_listener_pid "$UDP_LISTENER_PID" + GOTPROXY_PID="" + TCP_LISTENER_PID="" + UDP_LISTENER_PID="" + [[ -n "$LOG_FILE" && -f "$LOG_FILE" ]] && rm -f "$LOG_FILE" + [[ -n "$TCP_CAPTURE_FILE" && -f "$TCP_CAPTURE_FILE" ]] && rm -f "$TCP_CAPTURE_FILE" + [[ -n "$UDP_CAPTURE_FILE" && -f "$UDP_CAPTURE_FILE" ]] && rm -f "$UDP_CAPTURE_FILE" +} + +check_env() { + if [[ "$(id -u)" -ne 0 ]]; then + abort "Please run as root: sudo $0" + fi + if [[ ! -x "$GOTPROXY_BIN" ]]; then + abort "gotproxy not found or not executable: $GOTPROXY_BIN. Run make build-bpf && make first." + fi + if ! command -v curl &>/dev/null; then + abort "curl not found. Please install curl." + fi + if ! command -v python3 &>/dev/null; then + abort "python3 not found. Please install python3." + fi + info "Using gotproxy=$GOTPROXY_BIN, proxy_port=$PROXY_PORT" +} + +start_gotproxy() { + local extra_args=("$@") + LOG_FILE=$(mktemp) + "$GOTPROXY_BIN" --p-port "$PROXY_PORT" "${extra_args[@]}" >"$LOG_FILE" 2>&1 & + GOTPROXY_PID=$! + for _ in {1..40}; do + if grep -q "listening on" "$LOG_FILE" 2>/dev/null; then + return 0 + fi + sleep 0.2 + done + abort "gotproxy did not start in time, see $LOG_FILE" +} + +wait_listener_ready() { + local file="$1" + for _ in {1..20}; do + [[ -f "$file" ]] && return 0 + sleep 0.1 + done + return 1 +} + +start_tcp_listener() { + TCP_CAPTURE_FILE=$(mktemp) + local ready_file + ready_file=$(mktemp) + python3 - <<'PY' "$MIRROR_HOST" "$MIRROR_TCP_PORT" "$TCP_CAPTURE_FILE" "$ready_file" & +import socket +import sys + +host = sys.argv[1] +port = int(sys.argv[2]) +capture = sys.argv[3] +ready = sys.argv[4] + +s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +s.bind((host, port)) +s.listen(8) +with open(ready, "w", encoding="utf-8") as f: + f.write("ready") +with open(capture, "wb") as out: + while True: + conn, _ = s.accept() + with conn: + while True: + data = conn.recv(65535) + if not data: + break + out.write(data) + out.flush() +PY + TCP_LISTENER_PID=$! + if ! wait_listener_ready "$ready_file"; then + rm -f "$ready_file" + abort "TCP mirror listener failed to start" + fi + rm -f "$ready_file" +} + +start_udp_listener() { + UDP_CAPTURE_FILE=$(mktemp) + local ready_file + ready_file=$(mktemp) + python3 - <<'PY' "$MIRROR_HOST" "$MIRROR_UDP_PORT" "$UDP_CAPTURE_FILE" "$ready_file" & +import socket +import sys + +host = sys.argv[1] +port = int(sys.argv[2]) +capture = sys.argv[3] +ready = sys.argv[4] + +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +s.bind((host, port)) +with open(ready, "w", encoding="utf-8") as f: + f.write("ready") +with open(capture, "ab") as out: + while True: + data, _ = s.recvfrom(65535) + if not data: + continue + out.write(data) + out.flush() +PY + UDP_LISTENER_PID=$! + if ! wait_listener_ready "$ready_file"; then + rm -f "$ready_file" + abort "UDP mirror listener failed to start" + fi + rm -f "$ready_file" +} + +test_tcp_mirror() { + info "Test: TCP uplink mirroring" + start_tcp_listener + start_gotproxy \ + --proto tcp \ + --mirror-enable \ + --mirror-target "${MIRROR_HOST}:${MIRROR_TCP_PORT}" \ + --mirror-proto tcp + + curl -sS -4 -o /dev/null -w "" --connect-timeout 10 "$TEST_URL" || true + sleep 1 + + local size + size=$(wc -c < "$TCP_CAPTURE_FILE" 2>/dev/null || echo 0) + cleanup_listener_pid "$GOTPROXY_PID"; GOTPROXY_PID="" + cleanup_listener_pid "$TCP_LISTENER_PID"; TCP_LISTENER_PID="" + + if [[ "${size:-0}" -gt 0 ]]; then + ok "TCP mirroring works: captured ${size} bytes at mirror target" + else + fail "TCP mirroring failed: mirror target captured 0 bytes" + [[ -n "$LOG_FILE" && -f "$LOG_FILE" ]] && { info "gotproxy log:"; cat "$LOG_FILE"; } + fi +} + +# Uses HTTP (not TLS): request line and headers appear as printable text in the mirror capture. +test_tcp_mirror_httpbin() { + info "Test: TCP uplink mirroring — plain HTTP $HTTPBIN_URL (cleartext in capture)" + start_tcp_listener + start_gotproxy \ + --proto tcp \ + --mirror-enable \ + --mirror-target "${MIRROR_HOST}:${MIRROR_TCP_PORT}" \ + --mirror-proto tcp + + curl -sS -4 -o /dev/null -w "" --connect-timeout 15 "$HTTPBIN_URL" || true + sleep 1 + + local size + size=$(wc -c < "$TCP_CAPTURE_FILE" 2>/dev/null || echo 0) + local cap="$TCP_CAPTURE_FILE" + cleanup_listener_pid "$GOTPROXY_PID"; GOTPROXY_PID="" + cleanup_listener_pid "$TCP_LISTENER_PID"; TCP_LISTENER_PID="" + + if [[ "${size:-0}" -gt 0 ]]; then + ok "HTTP httpbin mirror: captured ${size} bytes at mirror target" + info "Sample of mirrored traffic (printable lines; HTTP is not encrypted here):" + if command -v strings &>/dev/null; then + strings -n 2 "$cap" 2>/dev/null | head -n 40 || true + else + LC_ALL=C head -c 1200 "$cap" | tr '\0' '.'; echo + fi + else + fail "HTTP httpbin mirror: mirror target captured 0 bytes (is $HTTPBIN_URL reachable?)" + [[ -n "$LOG_FILE" && -f "$LOG_FILE" ]] && { info "gotproxy log:"; cat "$LOG_FILE"; } + fi + [[ -f "$cap" ]] && rm -f "$cap" +} + +test_udp_mirror() { + if ! command -v dig &>/dev/null; then + info "Test: UDP uplink mirroring — skipped (dig not installed)" + return + fi + info "Test: UDP uplink mirroring" + start_udp_listener + start_gotproxy \ + --proto udp \ + --mirror-enable \ + --mirror-target "${MIRROR_HOST}:${MIRROR_UDP_PORT}" \ + --mirror-proto udp + + dig +short +time=5 +tries=1 @"$EXAMPLE_IP" example.com &>/dev/null || true + sleep 1 + + local size + size=$(wc -c < "$UDP_CAPTURE_FILE" 2>/dev/null || echo 0) + cleanup_listener_pid "$GOTPROXY_PID"; GOTPROXY_PID="" + cleanup_listener_pid "$UDP_LISTENER_PID"; UDP_LISTENER_PID="" + + if [[ "${size:-0}" -gt 0 ]]; then + ok "UDP mirroring works: captured ${size} bytes at mirror target" + else + fail "UDP mirroring failed: mirror target captured 0 bytes" + [[ -n "$LOG_FILE" && -f "$LOG_FILE" ]] && { info "gotproxy log:"; cat "$LOG_FILE"; } + fi +} + +main() { + check_env + echo "==========================================" + echo " gotproxy mirror tests" + echo "==========================================" + test_tcp_mirror + test_tcp_mirror_httpbin + test_udp_mirror + echo "==========================================" + echo " Passed: $PASSED Failed: $FAILED" + echo "==========================================" + [[ "$FAILED" -eq 0 ]] && exit 0 || exit 1 +} + +trap 'cleanup; exit 130' INT TERM +trap 'cleanup' EXIT +main "$@" From a351c02e67f8cd1beada8ef33ef6bb9bb17dfdf7 Mon Sep 17 00:00:00 2001 From: Dream95 Date: Fri, 8 May 2026 02:38:02 +0000 Subject: [PATCH 2/3] tests: fix flaky basic proxy test in GitHub Actions (avoid https://1.1.1.1) Signed-off-by: Dream95 tests: fix flaky basic proxy test in GitHub Actions (avoid https://1.1.1.1) --- scripts/test_proxy.sh | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/scripts/test_proxy.sh b/scripts/test_proxy.sh index 2fb1d7c..a56cfc9 100755 --- a/scripts/test_proxy.sh +++ b/scripts/test_proxy.sh @@ -13,13 +13,19 @@ PROXY_PORT="${PROXY_PORT:-18001}" LOG_FILE="" GOTPROXY_PID="" -# Test URL: use IP directly to avoid DNS -TEST_URL="${TEST_URL:-https://1.1.1.1}" +# Test target: use domain + --resolve to avoid DNS and keep TLS SNI correct. +# Hitting https://1.1.1.1 directly is flaky across networks (often 4xx/5xx) and +# makes the test fail even if proxy forwarding works. +TEST_HOST="${TEST_HOST:-one.one.one.one}" # IP used to verify Original destination in proxy log EXAMPLE_IP="${EXAMPLE_IP:-1.1.1.1}" -# URL for "other IP" (not matched by --ip filter) in combined IP+cmd test -TEST_OTHER_IP_URL="${TEST_OTHER_IP_URL:-https://8.8.8.8}" -OTHER_IP="${OTHER_IP:-8.8.8.8}" +TEST_URL="${TEST_URL:-https://${TEST_HOST}/}" + +# "Other IP" (not matched by --ip filter) in combined IP+cmd test +TEST_OTHER_IP="${TEST_OTHER_IP:-8.8.8.8}" +TEST_OTHER_HOST="${TEST_OTHER_HOST:-dns.google}" +OTHER_IP="${OTHER_IP:-$TEST_OTHER_IP}" +TEST_OTHER_IP_URL="${TEST_OTHER_IP_URL:-https://${TEST_OTHER_HOST}/}" PASSED=0 FAILED=0 @@ -96,7 +102,10 @@ test_basic_proxy() { info "Test: basic proxy (global)" start_gotproxy local code - code=$(curl -sS -4 -o /dev/null -w "%{http_code}" --connect-timeout 10 "$TEST_URL" || echo "000") + code=$(curl -sS -4 -o /dev/null -w "%{http_code}" \ + --connect-timeout 10 \ + --resolve "${TEST_HOST}:443:${EXAMPLE_IP}" \ + "$TEST_URL" || echo "000") # 200 OK; 301/302 are common redirects (e.g. to HTTPS when using IP), still count as success if [[ "$code" != "200" && "$code" != "301" && "$code" != "302" ]]; then fail "Basic proxy: curl returned HTTP $code, expected 200/301/302" @@ -118,7 +127,9 @@ test_cmd_filter() { start_gotproxy --cmd "curl" local n0 n1 n2 n0=$(count_original_dest) - curl -sS -4 -o /dev/null -w "" --connect-timeout 10 "$TEST_URL" || true + curl -sS -4 -o /dev/null -w "" --connect-timeout 10 \ + --resolve "${TEST_HOST}:443:${EXAMPLE_IP}" \ + "$TEST_URL" || true n1=$(count_original_dest) if ! command -v wget &>/dev/null; then # Without wget we only check that curl was proxied @@ -154,7 +165,9 @@ test_pids_filter() { pid_file=$(mktemp) trap "rm -f $pid_file" RETURN # Subshell: write its PID, sleep so we can start gotproxy with that PID, then exec curl (same PID makes the connection) - ( echo $BASHPID > "$pid_file"; sleep 4; exec curl -sS -4 -o /dev/null -w "" --connect-timeout 15 "$TEST_URL" ) & + ( echo $BASHPID > "$pid_file"; sleep 4; exec curl -sS -4 -o /dev/null -w "" --connect-timeout 15 \ + --resolve "${TEST_HOST}:443:${EXAMPLE_IP}" \ + "$TEST_URL" ) & local helper_pid=$! # Wait for PID file to be written local i=0 @@ -173,7 +186,9 @@ test_pids_filter() { local n1 n2 n1=$(count_original_dest) # This curl runs in main script (different PID), should NOT be proxied - curl -sS -4 -o /dev/null -w "" --connect-timeout 10 "$TEST_URL" || true + curl -sS -4 -o /dev/null -w "" --connect-timeout 10 \ + --resolve "${TEST_HOST}:443:${EXAMPLE_IP}" \ + "$TEST_URL" || true n2=$(count_original_dest) local has_dest=0 log_contains_dest "$EXAMPLE_IP" && has_dest=1 @@ -216,10 +231,14 @@ test_ip_cmd_filter() { local n0 n1 n2 n3 n0=$(count_original_dest) # curl to EXAMPLE_IP: matches both --ip and --cmd -> proxied - curl -sS -4 -o /dev/null -w "" --connect-timeout 10 "$TEST_URL" || true + curl -sS -4 -o /dev/null -w "" --connect-timeout 10 \ + --resolve "${TEST_HOST}:443:${EXAMPLE_IP}" \ + "$TEST_URL" || true n1=$(count_original_dest) # curl to other IP: matches --cmd but not --ip -> not proxied - curl -sS -4 -o /dev/null -w "" --connect-timeout 10 "$TEST_OTHER_IP_URL" || true + curl -sS -4 -o /dev/null -w "" --connect-timeout 10 \ + --resolve "${TEST_OTHER_HOST}:443:${TEST_OTHER_IP}" \ + "$TEST_OTHER_IP_URL" || true n2=$(count_original_dest) if command -v wget &>/dev/null; then # wget to EXAMPLE_IP: matches --ip but not --cmd -> not proxied From 45d8fd8b8ef7948bc9fdba3fa4f1125039bae9e6 Mon Sep 17 00:00:00 2001 From: Dream95 Date: Fri, 8 May 2026 03:27:17 +0000 Subject: [PATCH 3/3] doc: add mirror examples Signed-off-by: Dream95 --- README.md | 19 +++++++++++++++++-- README_CN.md | 19 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6b86a76..223795f 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ Download binary from [release](https://github.com/Dream95/gotproxy/releases) or ```bash sudo ./gotproxy [flags] ``` +### Proxy / forwarding flags + | Flag | Description | | :--- | :--- | | **--cmd** | The command name to be proxied. If not provided, all traffic will be proxied globally. | @@ -44,6 +46,13 @@ sudo ./gotproxy [flags] | **--socks5-pass** | SOCKS5 password (RFC1929). Must be set together with `--socks5-user`. | | **--proto** | Proxy protocol selection: `both` (default) / `tcp` / `udp`. When set to `tcp`, only TCP traffic will be redirected; when set to `udp`, only UDP traffic will be redirected. | | **--no-dns53** | Disable automatic UDP DNS rewrite from `127.0.0.53:53` to `1.1.1.1:53` (enabled by default). | + +### Mirror (traffic mirroring) flags + +Mirroring is independent of proxy forwarding: it best-effort duplicates the original traffic to a target address. + +| Flag | Description | +| :--- | :--- | | **--mirror-enable** | Enable best-effort traffic mirroring. | | **--mirror-target** | Mirror destination address, for example `10.0.0.2:9000`. | | **--mirror-proto** | Mirror protocol: `auto` (default, follows `--proto`) / `both` / `tcp` / `udp`. | @@ -84,12 +93,18 @@ sudo ./gotproxy --proto tcp sudo ./gotproxy --proto udp ``` -5. Proxy by container name: +5. Proxy with traffic mirroring: + +```bash +sudo ./gotproxy --proto both --mirror-enable --mirror-target 10.0.0.2:9000 +``` + +6. Proxy by container name: ```bash sudo ./gotproxy --container-name curl-test ``` -6. Use container and pid together: +7. Use container and pid together: ```bash sudo ./gotproxy --container-name curl-test --pids 1234 ``` diff --git a/README_CN.md b/README_CN.md index fbb23ea..ede6a51 100644 --- a/README_CN.md +++ b/README_CN.md @@ -32,6 +32,8 @@ ```bash sudo ./gotproxy [flags] ``` +### 代理 / 转发 flags + | Flag | 描述 | | :--- | :--- | | **--cmd** | 需要代理的进程名称. 如果没有配置,则会进行全局流量代理. | @@ -45,6 +47,13 @@ sudo ./gotproxy [flags] | **--socks5-pass** | socks5 密码(RFC1929)。需要同时设置 `--socks5-user`。 | | **--proto** | 代理协议选择:`both`(默认)/ `tcp` / `udp`。当设置为 `tcp` 时只重定向 TCP 流量;设置为 `udp` 时只重定向 UDP 流量。 | | **--no-dns53** | 关闭 UDP DNS 对 `127.0.0.53:53` 的自动改写。默认会自动改写为 `1.1.1.1:53`。 | + +### Mirror(流量复制)flags + +Mirror 与代理/转发功能相互独立:它会尽力将原始流量复制一份发送到指定目标。 + +| Flag | 描述 | +| :--- | :--- | | **--mirror-enable** | 开启尽力而为的流量复制。 | | **--mirror-target** | 复制目标地址,例如 `10.0.0.2:9000`。 | | **--mirror-proto** | 复制协议:`auto`(默认,跟随 `--proto`)/ `both` / `tcp` / `udp`。 | @@ -88,12 +97,18 @@ sudo ./gotproxy --proto tcp sudo ./gotproxy --proto udp ``` -5. 按容器名称代理: +5. 代理并开启流量镜像(Mirror): + +```bash +sudo ./gotproxy --proto both --mirror-enable --mirror-target 10.0.0.2:9000 +``` + +6. 按容器名称代理: ```bash sudo ./gotproxy --container-name curl-test ``` -6. 容器名 + pid 同时过滤: +7. 容器名 + pid 同时过滤: ```bash sudo ./gotproxy --container-name curl-test --pids 1234 ```