fix: continue parent negotiation after PROXY header

After sending an HAProxy PROXY v1 header for a chained parent, keep
negotiating the real parent protocol instead of returning early.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nirmal Khanal 2026-07-22 16:23:03 -04:00
parent 305f9932ac
commit af5e6f3a4d
6 changed files with 469 additions and 18 deletions

View File

@ -1472,16 +1472,19 @@ socks -H -p1080
<p>
<b>Sending PROXY protocol header to parent proxy:</b>
<br>Use the <code>ha</code> 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 <code>ha</code> before the parent that should receive
the header; after the header is sent, parent protocol negotiation continues:
</p><pre>
allow *
parent 1000 ha
parent 1000 ha 0.0.0.0 0
parent 1000 socks5 parent.example.com 1080
socks
proxy
</pre>
<p>
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 <code>-H</code> to accept
the header (for example <code>socks -H</code>).
</p>
<li><a name="MAXSEG"><i>How to set TCP maximum segment size (MSS)</i></a>
<p>

View File

@ -1531,16 +1531,20 @@ socks -H -p1080
<p>
<b>Отправка заголовка PROXY протокола родительскому прокси:</b>
<br>Используйте тип родительского прокси <code>ha</code> для отправки заголовка
PROXY протокола v1 родительскому прокси. Это должен быть последний родитель в цепочке:
PROXY протокола v1 родительскому прокси. Укажите <code>ha</code> перед родителем,
который должен получить заголовок; после отправки заголовка переговоры по протоколу
родителя продолжаются:
</p><pre>
allow *
parent 1000 ha
parent 1000 ha 0.0.0.0 0
parent 1000 socks5 parent.example.com 1080
socks
proxy
</pre>
<p>
Эта конфигурация отправляет информацию об IP-адресе клиента SOCKS5 родительскому
прокси через PROXY протокол.
Эта конфигурация подключается к SOCKS5 родителю, отправляет PROXY заголовок
с исходным IP клиента, затем выполняет SOCKS5-переговоры и только после этого
передаёт полезную нагрузку. Родитель должен использовать <code>-H</code>
(например <code>socks -H</code>).
</p>
<li><a name="MAXSEG"><i>Как установить максимальный размер сегмента TCP (MSS)</i></a>
<p>

View File

@ -759,10 +759,15 @@ useful). Never use this option unless you know exactly you
need it. <b><br>
admin</b> redirect request to local &acute;admin&acute;
service (with -s parameter). <b><br>
ha</b> 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 <br>
ha</b> send HAProxy PROXY protocol v1 header to the next
parent proxy (or to the destination if <b>ha</b> is used
alone). Place <b>ha</b> 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 &quot;+&quot; proxy only with <b>fakeresolve</b> option
<br>
Any parent type above can be suffixed with <b>s</b> (e.g.

View File

@ -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

View File

@ -375,7 +375,9 @@ int handleredirect(struct clientparam * param, struct ace * acentry){
ntohs(*SAPORT(&param->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 {

430
tests/ha-parent-chain/run.sh Executable file
View File

@ -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 <<EOF
nserver 8.8.8.8
log $WORKDIR/proxy2.log
logformat "%Y%m%d%H%M%S.%. %p %E %U %C:%c %R:%r %O %I %T"
auth iponly
allow *
socks -H -p18183
EOF
# Baseline SOCKS parent without -H (must not share the -H listener)
cat > proxy2-noha.cfg <<EOF
nserver 8.8.8.8
log $WORKDIR/proxy2-noha.log
logformat "%Y%m%d%H%M%S.%. %p %E %U %C:%c %R:%r %O %I %T"
auth iponly
allow *
socks -p18184
EOF
cat > proxy1.cfg <<EOF
nserver 8.8.8.8
log $WORKDIR/proxy1.log
logformat "%Y%m%d%H%M%S.%. %p %E %U %C:%c %R:%r %O %I %T"
auth iponly
allow *
parent 1000 ha 0.0.0.0 0
parent 1000 socks5 127.0.0.1 18183
proxy -p18628
EOF
# Wire-order probe: same HA chain, parent is the Python wiretap with user/pass
cat > proxy1b.cfg <<EOF
nserver 8.8.8.8
log $WORKDIR/proxy1b.log
logformat "%Y%m%d%H%M%S.%. %p %E %U %C:%c %R:%r %O %I %T"
auth iponly
allow *
parent 1000 ha 0.0.0.0 0
parent 1000 socks5 127.0.0.1 18181 upstream upstreampassword
proxy -p18630
EOF
cat > proxy1-noha.cfg <<EOF
nserver 8.8.8.8
log $WORKDIR/proxy1-noha.log
logformat "%Y%m%d%H%M%S.%. %p %E %U %C:%c %R:%r %O %I %T"
auth iponly
allow *
parent 1000 socks5 127.0.0.1 18184
proxy -p18629
EOF
"$PROXYBIN" proxy2.cfg > proxy2.out 2>&1 &
echo $! > proxy2.pid
"$PROXYBIN" proxy2-noha.cfg > proxy2-noha.out 2>&1 &
echo $! > proxy2-noha.pid
"$PROXYBIN" proxy1.cfg > proxy1.out 2>&1 &
echo $! > proxy1.pid
"$PROXYBIN" proxy1b.cfg > proxy1b.out 2>&1 &
echo $! > proxy1b.pid
"$PROXYBIN" proxy1-noha.cfg > proxy1-noha.out 2>&1 &
echo $! > proxy1-noha.pid
sleep 0.4
# shellcheck disable=SC2086
NOHA_BODY=$(curl -sk --max-time 15 $CURL_IFACE_ARGS \
--proxy "http://${PROXY1_IP}:18629" \
"https://127.0.0.1:19443/" || true)
# shellcheck disable=SC2086
BODY=$(curl -sk --max-time 15 $CURL_IFACE_ARGS \
--proxy "http://${PROXY1_IP}:18628" \
"https://127.0.0.1:19443/" || true)
# shellcheck disable=SC2086
WIRE_BODY=$(curl -sk --max-time 15 $CURL_IFACE_ARGS \
--proxy "http://${PROXY1_IP}:18630" \
"https://127.0.0.1:19443/" || true)
# Stop proxies so request logs are flushed to disk
for f in proxy1.pid proxy1b.pid proxy1-noha.pid proxy2.pid proxy2-noha.pid; do
if [ -f "$f" ]; then
kill "$(cat "$f")" 2>/dev/null || true
wait "$(cat "$f")" 2>/dev/null || true
rm -f "$f"
fi
done
sleep 0.1
echo "=== proxy1.log ==="
cat proxy1.log 2>/dev/null || true
echo "=== proxy1-noha.log ==="
cat proxy1-noha.log 2>/dev/null || true
echo "=== proxy2.log ==="
cat proxy2.log 2>/dev/null || true
echo "=== proxy2-noha.log ==="
cat proxy2-noha.log 2>/dev/null || true
echo "=== wire.log ==="
cat wire.log 2>/dev/null || true
echo "=== curl body (no-HA) ==="
printf '%s\n' "$NOHA_BODY"
echo "=== curl body (HA -> real proxy2) ==="
printf '%s\n' "$BODY"
echo "=== curl body (HA -> wiretap) ==="
printf '%s\n' "$WIRE_BODY"
fail=0
if [ "$NOHA_BODY" != "OK-DEST" ]; then
echo "FAIL: baseline HTTPS through socks5 parent (no HA) did not return OK-DEST (got: $NOHA_BODY)" >&2
fail=1
fi
if [ "$BODY" != "OK-DEST" ]; then
echo "FAIL: HTTPS through ha->socks5->socks -H did not return OK-DEST (got: $BODY)" >&2
fail=1
fi
if [ "$WIRE_BODY" != "OK-DEST" ]; then
echo "FAIL: HTTPS through ha->wiretap socks did not return OK-DEST (got: $WIRE_BODY)" >&2
fail=1
fi
if ! grep -q "ORDER=PROXY->SOCKS5->SOCKS5_AUTH->SOCKS5_CONNECT->PAYLOAD SUCCESS" wire.log; then
echo "FAIL: wire order was not PROXY -> SOCKS5 -> AUTH -> CONNECT -> PAYLOAD" >&2
fail=1
fi
if ! grep -q "client_ip=$CLIENT_IP" wire.log; then
echo "FAIL: PROXY header did not carry original client IP $CLIENT_IP" >&2
fail=1
fi
# proxy2 must complete SOCKS CONNECT (not UNKNOWN) and reach the HTTPS destination
if ! grep -E " 00000 +- +${CLIENT_IP}:[0-9]+ +127\\.0\\.0\\.1:19443 " proxy2.log >/dev/null; then
echo "FAIL: proxy2 did not complete SOCKS CONNECT from restored client $CLIENT_IP to :19443" >&2
fail=1
fi
if grep -E " 0040[0-9] +- +${CLIENT_IP}:.*UNKNOWN" proxy2.log >/dev/null; then
echo "FAIL: proxy2 still saw UNKNOWN after PROXY (parent negotiation skipped)" >&2
fail=1
fi
# Wiretap must have completed username/password SOCKS5 authentication
if ! grep -q "SOCKS5_AUTH user=b'upstream'" wire.log; then
echo "FAIL: SOCKS5 username/password authentication was not completed" >&2
fail=1
fi
# PROXY restoration: proxy2's logged client port must match proxy1's client port.
# The TCP peer port on proxy2 would be proxy1's outbound ephemeral port instead.
CLIENT_PORT=$(awk '/CONNECT 127\.0\.0\.1:19443/ && / 00000 / {
for (i = 1; i <= NF; i++) if ($i ~ /^'"$CLIENT_IP"':[0-9]+$/) {
split($i, a, ":"); print a[2]; exit
}
}' proxy1.log)
PROXY2_PORT=$(awk '/CONNECT 127\.0\.0\.1:19443/ && / 00000 / {
for (i = 1; i <= NF; i++) if ($i ~ /^'"$CLIENT_IP"':[0-9]+$/) {
split($i, a, ":"); print a[2]; exit
}
}' proxy2.log)
if [ -z "$CLIENT_PORT" ] || [ -z "$PROXY2_PORT" ] || [ "$CLIENT_PORT" != "$PROXY2_PORT" ]; then
echo "FAIL: proxy2 client port ($PROXY2_PORT) does not match original client port on proxy1 ($CLIENT_PORT)" >&2
fail=1
fi
# When a distinct client IP is available, restored address must not be proxy1's peer alone
if [ "$CLIENT_IP" != "127.0.0.1" ]; then
if grep -E " 00000 +- +127\\.0\\.0\\.1:[0-9]+ +127\\.0\\.0\\.1:19443 " proxy2.log >/dev/null && \
! grep -E " 00000 +- +${CLIENT_IP}:" proxy2.log >/dev/null; then
echo "FAIL: proxy2 logged proxy1 address instead of original client $CLIENT_IP" >&2
fail=1
fi
fi
if [ "$fail" -ne 0 ]; then
exit 1
fi
echo "PASS: ha parent chaining sends PROXY then completes SOCKS5 negotiation (client_ip=$CLIENT_IP)"
exit 0