3proxy/tests/cases/transparent.py

156 lines
6.4 KiB
Python

"""Transparent proxying: the destination comes from the redirection.
A redirected connection no longer says where it was going, so the proxy has
to ask the packet filter. That means a real redirection rule, which needs
privilege, so the case skips unless it can install one and remove it again.
The rule must not catch the proxy's own connection to the origin, or the
traffic goes round for ever. Here the proxy is given an outgoing address of
its own and the rule excludes it, which is the arrangement the documentation
recommends.
"""
import os
import platform
import shutil
import subprocess
ORIGIN_ADDR = "127.0.0.9" # where the client believes it is going
PROXY_ADDR = "127.0.0.8" # the source the proxy connects from
DECOY_ADDR = "127.0.0.7" # a second server, to show where traffic went
def _run(command):
done = subprocess.run(command, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, timeout=30)
return done.returncode, done.stdout.decode("utf-8", "replace").strip()
def _iptables_rule(action, origin_port, proxy_port):
return ["iptables", "-t", "nat", action, "OUTPUT",
"-p", "tcp", "-d", ORIGIN_ADDR, "--dport", str(origin_port),
"!", "-s", PROXY_ADDR,
"-j", "REDIRECT", "--to-ports", str(proxy_port)]
def _config(t, name, body):
"""Run a configuration that has no service, and return what it said."""
return t.run_config(name, body + "\nnot_a_command\n")
def run(t):
# --- the command and its modes ------------------------------------
# These need no redirection, so they run wherever the feature is built.
out = _config(t, "transparent_probe", "log\ntransparent")
if "'transparent'" in out:
t.skip("transparent proxying (not built in this configuration)")
return
for mode in ("auto", "socket"):
t.not_contains(_config(t, "mode_" + mode, f"log\ntransparent {mode}"),
"transparent:", f"the {mode} mode is accepted")
t.contains(_config(t, "mode_bogus", "log\ntransparent bogus"),
"unknown mode", "an unknown mode is refused")
# A mode the build has no code for is refused rather than ignored, so a
# configuration written for another platform fails where it is wrong
# instead of quietly doing something else.
for mode, built in (("netfilter", platform.system() == "Linux"), ("pf", False)):
out = _config(t, "mode_" + mode, f"log\ntransparent {mode}")
if built:
t.not_contains(out, "not available", f"the {mode} mode is accepted where it exists")
elif "not available" in out:
t.ok(f"the {mode} mode is refused where it does not exist")
else:
t.skip(f"the {mode} mode (built here, nothing to check)")
t.not_contains(_config(t, "notransparent", "log\ntransparent\nnotransparent"),
"'notransparent'", "notransparent is accepted")
# --- and the redirection itself ------------------------------------
if platform.system() != "Linux":
# The BSDs need a redirection that leaves the original destination on
# the socket - divert-to on OpenBSD, ipfw fwd on FreeBSD - and macOS
# has neither, so there is nothing to set up here.
t.skip(f"transparent proxying (no redirection to set up on {platform.system()})")
return
if os.geteuid() != 0 or not shutil.which("iptables"):
t.skip("transparent proxying (needs root and iptables to redirect)")
return
origin_port = t.free_port()
decoy_port = t.free_port()
mapper_port = t.free_port()
plain_port = t.free_port()
t.start("transparent", f"""
log
auth iponly
allow *
http * /echo* echo
httpsrv -p{origin_port} -i{ORIGIN_ADDR}
# a second server, to tell apart where a connection actually went
flush
auth iponly
allow *
http * * data size=13
httpsrv -p{decoy_port} -i{DECOY_ADDR}
# a port mapper aimed at the decoy: with the destination taken from
# the redirection instead, it goes to the origin
flush
auth iponly
allow *
transparent
tcppm -e{PROXY_ADDR} {mapper_port} {DECOY_ADDR} {decoy_port}
notransparent
# the same mapper without it, which keeps going to the decoy
flush
auth iponly
allow *
tcppm -e{PROXY_ADDR} {plain_port} {DECOY_ADDR} {decoy_port}
""", ports=[(ORIGIN_ADDR, origin_port), (DECOY_ADDR, decoy_port),
mapper_port, plain_port])
code, out = _run(_iptables_rule("-A", origin_port, mapper_port))
if code:
t.skip(f"transparent proxying (could not add a redirect rule: {out})")
return
try:
# The client asks for the address it wants; the rule sends the
# connection to the mapper instead, and the mapper has to work out
# where it was headed.
r = t.http(f"http://{ORIGIN_ADDR}:{origin_port}/echo")
t.eq(200, r.status, "a redirected connection reaches its destination")
t.contains(r, "path=/echo", "the request arrives unchanged")
t.contains(r, f"host={ORIGIN_ADDR}:{origin_port}",
"the client still believes it is talking to the origin")
t.contains(r, f"peer.addr={PROXY_ADDR}",
"the origin is reached from the proxy's own address")
# That address is what the rule excludes, which is what stops the
# proxy's own connection from being redirected back into itself.
t.not_contains(r, "size=13", "the connection did not go to the decoy")
# Without the command the mapper has no reason to look, and goes
# where it was configured to go.
_run(_iptables_rule("-D", origin_port, mapper_port))
code, out = _run(_iptables_rule("-A", origin_port, plain_port))
if code:
t.skip("the mapper without the command (could not move the rule)")
else:
r = t.http(f"http://{ORIGIN_ADDR}:{origin_port}/echo")
t.eq(13, r.length,
"without the command the connection goes to the configured target")
t.not_contains(r, "path=/echo",
"and never reaches the address the client asked for")
_run(_iptables_rule("-D", origin_port, plain_port))
finally:
# leave the machine as it was found, whatever happened above
_run(_iptables_rule("-D", origin_port, mapper_port))
_run(_iptables_rule("-D", origin_port, plain_port))