diff --git a/doc/html/howtoe.html b/doc/html/howtoe.html index ad60364..6bc6fa9 100644 --- a/doc/html/howtoe.html +++ b/doc/html/howtoe.html @@ -1472,16 +1472,19 @@ socks -H -p1080
Sending PROXY protocol header to parent proxy:
Use the ha parent type to send a PROXY protocol v1 header to
- the parent proxy. This must be the last parent in the chain:
+ the parent proxy. Place ha before the parent that should receive
+ the header; after the header is sent, parent protocol negotiation continues:
allow * -parent 1000 ha +parent 1000 ha 0.0.0.0 0 parent 1000 socks5 parent.example.com 1080 -socks +proxy
- This configuration sends the client IP information to the SOCKS5 parent proxy
- via the PROXY protocol.
+ This configuration connects to the SOCKS5 parent, sends the PROXY protocol
+ header with the original client IP, then performs SOCKS5 negotiation before
+ relaying the client payload. The parent should use -H to accept
+ the header (for example socks -H).
diff --git a/doc/html/howtor.html b/doc/html/howtor.html index a9d2c32..5e66368 100644 --- a/doc/html/howtor.html +++ b/doc/html/howtor.html @@ -1531,16 +1531,20 @@ socks -H -p1080
Отправка заголовка PROXY протокола родительскому прокси:
Используйте тип родительского прокси ha для отправки заголовка
- PROXY протокола v1 родительскому прокси. Это должен быть последний родитель в цепочке:
+ PROXY протокола v1 родительскому прокси. Укажите ha перед родителем,
+ который должен получить заголовок; после отправки заголовка переговоры по протоколу
+ родителя продолжаются:
allow * -parent 1000 ha +parent 1000 ha 0.0.0.0 0 parent 1000 socks5 parent.example.com 1080 -socks +proxy
- Эта конфигурация отправляет информацию об IP-адресе клиента SOCKS5 родительскому
- прокси через PROXY протокол.
+ Эта конфигурация подключается к SOCKS5 родителю, отправляет PROXY заголовок
+ с исходным IP клиента, затем выполняет SOCKS5-переговоры и только после этого
+ передаёт полезную нагрузку. Родитель должен использовать -H
+ (например socks -H).
diff --git a/doc/html/man5/3proxy.cfg.5.html b/doc/html/man5/3proxy.cfg.5.html
index 847c159..dc18074 100644
--- a/doc/html/man5/3proxy.cfg.5.html
+++ b/doc/html/man5/3proxy.cfg.5.html
@@ -759,10 +759,15 @@ useful). Never use this option unless you know exactly you
need it.
admin redirect request to local ´admin´
service (with -s parameter).
-ha send HAProxy PROXY protocol v1 header to parent
-proxy. Must be the last in the proxy chain. Useful for
-passing client IP information to the parent proxy. Example:
-parent 1000 ha
+ha send HAProxy PROXY protocol v1 header to the next
+parent proxy (or to the destination if ha is used
+alone). Place ha before the parent that should
+receive the header; after the header is sent, negotiation
+of that parent protocol continues (SOCKS, CONNECT, etc.).
+Useful for passing client IP information to the parent
+proxy. Example:
+parent 1000 ha 0.0.0.0 0
+parent 1000 socks5 parent.example.com 1080
Use "+" proxy only with fakeresolve option
Any parent type above can be suffixed with s (e.g.
diff --git a/man/3proxy.cfg.5 b/man/3proxy.cfg.5
index 5d9ec0f..672e4c6 100644
--- a/man/3proxy.cfg.5
+++ b/man/3proxy.cfg.5
@@ -828,9 +828,16 @@ unless you know exactly you need it.
.br
\fBadmin\fR redirect request to local \'admin\' service (with -s parameter).
.br
-\fBha\fR send HAProxy PROXY protocol v1 header to parent proxy. Must be the last
-in the proxy chain. Useful for passing client IP information to the parent proxy.
-Example: parent 1000 ha
+\fBha\fR send HAProxy PROXY protocol v1 header to the next parent proxy (or to
+the destination if \fBha\fR is used alone). Place \fBha\fR before the parent
+that should receive the header; after the header is sent, negotiation of that
+parent protocol continues (SOCKS, CONNECT, etc.). Useful for passing client IP
+information to the parent proxy.
+Example:
+.br
+ parent 1000 ha 0.0.0.0 0
+.br
+ parent 1000 socks5 parent.example.com 1080
.br
Use "+" proxy only with \fBfakeresolve\fR option
.br
diff --git a/src/redirect.c b/src/redirect.c
index 2fb967b..73ca9b4 100644
--- a/src/redirect.c
+++ b/src/redirect.c
@@ -375,7 +375,9 @@ int handleredirect(struct clientparam * param, struct ace * acentry){
ntohs(*SAPORT(¶m->sincl))
);
if(socksend(param, param->remsock, (unsigned char *)buf, len, conf.timeouts[CHAIN_TO])!=len) return 39;
- return 0;
+ /* ha alone: PROXY header then plain TCP relay.
+ ha before another parent: continue negotiating that parent. */
+ if(cur->type == R_HA) return 0;
}
}
else {
diff --git a/tests/ha-parent-chain/run.sh b/tests/ha-parent-chain/run.sh
new file mode 100755
index 0000000..d378bf1
--- /dev/null
+++ b/tests/ha-parent-chain/run.sh
@@ -0,0 +1,430 @@
+#!/bin/sh
+# Regression: HTTP -> ha + SOCKS5 parent -> socks -H must negotiate SOCKS
+# after the PROXY v1 header and preserve the original client address.
+#
+# Topology:
+# curl -> proxy1 HTTP :18628
+# parent ha 0.0.0.0 0
+# parent socks5 127.0.0.1:18183
+# -> proxy2 SOCKS -H :18183 -> HTTPS dest :19443
+#
+# Also runs a wiretap on :18181 to assert byte order:
+# PROXY -> SOCKS5 greeting/auth/CONNECT -> payload
+# and a no-HA baseline via SOCKS without -H on :18184.
+#
+# Usage:
+# ./tests/ha-parent-chain/run.sh [path-to-3proxy]
+# Default binary: ../../bin/3proxy relative to this script.
+
+set -eu
+
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+ROOT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
+PROXYBIN=${1:-"$ROOT_DIR/bin/3proxy"}
+WORKDIR=${TMPDIR:-/tmp}/3proxy-ha-parent-chain-$$
+PROXY1_IP=127.0.0.1
+# Prefer a distinct loopback client address so proxy2's restored IP
+# cannot be confused with proxy1's TCP peer. Fall back to 127.0.0.1.
+CLIENT_IP=127.0.0.1
+CURL_IFACE_ARGS=
+
+if [ ! -x "$PROXYBIN" ]; then
+ echo "FAIL: 3proxy binary not found or not executable: $PROXYBIN" >&2
+ echo "Build first, e.g. make -f Makefile.unix or Makefile.FreeBSD" >&2
+ exit 1
+fi
+
+if ! command -v python3 >/dev/null 2>&1; then
+ echo "FAIL: python3 is required" >&2
+ exit 1
+fi
+if ! command -v curl >/dev/null 2>&1; then
+ echo "FAIL: curl is required" >&2
+ exit 1
+fi
+if ! command -v openssl >/dev/null 2>&1; then
+ echo "FAIL: openssl is required" >&2
+ exit 1
+fi
+
+if python3 - <<'PY'
+import socket
+s = socket.socket()
+try:
+ s.bind(("127.0.0.2", 0))
+except OSError:
+ raise SystemExit(1)
+finally:
+ s.close()
+raise SystemExit(0)
+PY
+then
+ CLIENT_IP=127.0.0.2
+ CURL_IFACE_ARGS="--interface 127.0.0.2"
+fi
+
+cleanup() {
+ for f in proxy1.pid proxy1b.pid proxy1-noha.pid proxy2.pid proxy2-noha.pid wiretap.pid dest.pid; do
+ if [ -f "$WORKDIR/$f" ]; then
+ kill "$(cat "$WORKDIR/$f")" 2>/dev/null || true
+ fi
+ done
+ # Best-effort cleanup if pidfiles were not written
+ pkill -f "$WORKDIR" 2>/dev/null || true
+ rm -rf "$WORKDIR"
+}
+trap cleanup EXIT INT TERM
+
+mkdir -p "$WORKDIR"
+cd "$WORKDIR"
+
+openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 1 -nodes \
+ -subj "/CN=127.0.0.1" >/dev/null 2>&1
+
+cat > dest_server.py <<'PY'
+#!/usr/bin/env python3
+import argparse, ssl
+from http.server import BaseHTTPRequestHandler, HTTPServer
+
+class H(BaseHTTPRequestHandler):
+ def do_GET(self):
+ body = b"OK-DEST\n"
+ self.send_response(200)
+ self.send_header("Content-Length", str(len(body)))
+ self.send_header("Content-Type", "text/plain")
+ self.end_headers()
+ self.wfile.write(body)
+ def log_message(self, *a):
+ pass
+
+ap = argparse.ArgumentParser()
+ap.add_argument("--host", default="127.0.0.1")
+ap.add_argument("--port", type=int, required=True)
+ap.add_argument("--cert", required=True)
+ap.add_argument("--key", required=True)
+args = ap.parse_args()
+httpd = HTTPServer((args.host, args.port), H)
+ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ctx.load_cert_chain(args.cert, args.key)
+httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
+print("ready", flush=True)
+httpd.serve_forever()
+PY
+
+cat > wiretap_socks.py <<'PY'
+#!/usr/bin/env python3
+import argparse, socket, struct, threading, sys
+
+def recv_exact(s, n):
+ buf = b""
+ while len(buf) < n:
+ chunk = s.recv(n - len(buf))
+ if not chunk:
+ raise ConnectionError("eof")
+ buf += chunk
+ return buf
+
+def handle(conn, addr, logf):
+ order = []
+ client_ip = addr[0]
+ try:
+ line = b""
+ while not line.endswith(b"\n"):
+ b = conn.recv(1)
+ if not b:
+ break
+ line += b
+ if not line.startswith(b"PROXY "):
+ logf.write("ORDER=NO_PROXY FIRST=%r\n" % (line[:32],))
+ logf.flush()
+ return
+ order.append("PROXY")
+ parts = line.decode("latin1", "replace").strip().split()
+ if len(parts) >= 6:
+ client_ip = parts[2]
+ ver = conn.recv(1)
+ if ver != b"\x05":
+ more = ver + conn.recv(16)
+ order.append("PAYLOAD")
+ logf.write("ORDER=%s FIRST_PAYLOAD=%r client_ip=%s FAIL_NO_SOCKS\n" %
+ ("->".join(order), more, client_ip))
+ logf.flush()
+ return
+ order.append("SOCKS5")
+ nmethods = conn.recv(1)[0]
+ methods = list(conn.recv(nmethods))
+ if 2 in methods:
+ conn.sendall(b"\x05\x02")
+ auth = recv_exact(conn, 2)
+ user = recv_exact(conn, auth[1])
+ plen = recv_exact(conn, 1)[0]
+ _ = recv_exact(conn, plen)
+ order.append("SOCKS5_AUTH")
+ conn.sendall(b"\x01\x00")
+ logf.write("SOCKS5_AUTH user=%r client_ip=%s\n" % (user, client_ip))
+ elif 0 in methods:
+ conn.sendall(b"\x05\x00")
+ else:
+ conn.sendall(b"\x05\xff")
+ return
+ hdr = recv_exact(conn, 4)
+ atyp = hdr[3]
+ if atyp == 1:
+ host = socket.inet_ntoa(recv_exact(conn, 4))
+ elif atyp == 3:
+ ln = recv_exact(conn, 1)[0]
+ host = recv_exact(conn, ln).decode()
+ elif atyp == 4:
+ host = socket.inet_ntop(socket.AF_INET6, recv_exact(conn, 16))
+ else:
+ return
+ port = struct.unpack("!H", recv_exact(conn, 2))[0]
+ order.append("SOCKS5_CONNECT")
+ rem = socket.create_connection((host, port), timeout=10)
+ conn.sendall(b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00")
+ order.append("PAYLOAD")
+ logf.write("ORDER=%s SUCCESS client_ip=%s target=%s:%s\n" %
+ ("->".join(order), client_ip, host, port))
+ logf.flush()
+
+ def pump(a, b):
+ try:
+ while True:
+ d = a.recv(65536)
+ if not d:
+ break
+ b.sendall(d)
+ except Exception:
+ pass
+ try:
+ b.shutdown(socket.SHUT_WR)
+ except Exception:
+ pass
+
+ t1 = threading.Thread(target=pump, args=(conn, rem), daemon=True)
+ t2 = threading.Thread(target=pump, args=(rem, conn), daemon=True)
+ t1.start(); t2.start(); t1.join(); t2.join()
+ except Exception as e:
+ logf.write("ERROR=%r ORDER=%s\n" % (e, "->".join(order)))
+ logf.flush()
+ finally:
+ try:
+ conn.close()
+ except Exception:
+ pass
+
+ap = argparse.ArgumentParser()
+ap.add_argument("--port", type=int, required=True)
+ap.add_argument("--log", required=True)
+args = ap.parse_args()
+logf = open(args.log, "a", buffering=1)
+srv = socket.socket()
+srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+srv.bind(("127.0.0.1", args.port))
+srv.listen(50)
+print("ready", flush=True)
+while True:
+ c, a = srv.accept()
+ threading.Thread(target=handle, args=(c, a, logf), daemon=True).start()
+PY
+
+python3 dest_server.py --port 19443 --cert cert.pem --key key.pem > dest.out 2>&1 &
+echo $! > dest.pid
+python3 wiretap_socks.py --port 18181 --log wire.log > wiretap.out 2>&1 &
+echo $! > wiretap.pid
+
+# Wait until helpers are listening
+i=0
+while [ "$i" -lt 50 ]; do
+ if grep -q ready dest.out 2>/dev/null && grep -q ready wiretap.out 2>/dev/null; then
+ break
+ fi
+ i=$((i + 1))
+ sleep 0.1
+done
+
+# Real SOCKS -H parent. Use iponly here so the test is not coupled to
+# unrelated cleartext password-table comparison behavior; username/password
+# SOCKS auth is exercised against the wiretap parent below.
+cat > proxy2.cfg <