3proxy/tests/cases/ipv6.py
2026-08-26 11:54:04 +03:00

225 lines
7.9 KiB
Python

"""IPv6: listening on it, reaching it, and the rules that mention it.
A service resolves IPv4 only unless told otherwise, so the proxies that are
meant to reach IPv6 carry a family flag. Names resolving to IPv6 need
nscache6: nscache holds the IPv4 side and nothing else.
"""
def run(t):
if not t.has_ipv6():
t.skip("IPv6 (this machine has no IPv6 loopback)")
return
origin = t.free_port()
v6proxy = t.free_port()
mixed = t.free_port()
v4only = t.free_port()
socks6 = t.free_port()
t.start("ipv6", f"""
log
auth iponly
allow *
http * /echo* echo
http * /data data
httpsrv -p{origin} -i::1
# reached over IPv6, and allowed to reach IPv6
flush
auth iponly
allow *
proxy -p{v6proxy} -i::1 -6
# reached over IPv4, still able to reach IPv6
flush
auth iponly
allow *
proxy -p{mixed} -6
# asked for IPv4 only, so an IPv6 destination is not for it
flush
auth iponly
allow *
proxy -p{v4only} -4
flush
auth iponly
allow *
socks -p{socks6} -6
""", ports=[("::1", origin), ("::1", v6proxy), mixed, v4only, socks6])
url = f"http://[::1]:{origin}/echo"
# --- listening on IPv6 -------------------------------------------------
r = t.http(url)
t.eq(200, r.status, "a service bound to ::1 answers over IPv6")
t.contains(r, "peer.addr=::1", "the client is seen as an IPv6 address")
t.contains(r, "path=/echo", "and the request arrives intact")
# the Host header carries the address in brackets, and a rule matching
# any host still matches it
t.contains(r, "host=[::1]", "the host header keeps its brackets")
# --- proxying over IPv6 -------------------------------------------------
r = t.http(url, proxy=f"[::1]:{v6proxy}")
t.eq(200, r.status, "a proxy reached over IPv6 serves an IPv6 destination")
t.contains(r, "peer.addr=::1", "the proxy connects from IPv6 as well")
t.eq(20000, t.http(f"http://[::1]:{origin}/data?size=20000",
proxy=f"[::1]:{v6proxy}").length,
"a body passes over IPv6")
t.eq(200, t.http(url, proxy=f"[::1]:{v6proxy}", tunnel=True).status,
"CONNECT works over IPv6")
# --- across the two families --------------------------------------------
r = t.http(url, proxy=f"127.0.0.1:{mixed}")
t.eq(200, r.status, "a client on IPv4 can be given an IPv6 destination")
t.contains(r, "peer.addr=::1", "and the far side is still reached over IPv6")
# a service told to use one family stays in it
t.ne(200, t.http(url, proxy=f"127.0.0.1:{v4only}").status,
"a service asked for IPv4 only refuses an IPv6 destination")
# --- SOCKS with an IPv6 destination -------------------------------------
r = t.socks_http(f"127.0.0.1:{socks6}", url)
t.eq(200, r.status, "SOCKS5 carries an IPv6 destination address")
t.contains(r, "peer.addr=::1", "which is reached over IPv6")
# --- which family a service will use --------------------------------------
# -46 and -64 both reach either family; -4 and -6 are each restricted to
# one; and nothing said means -46.
v4origin = t.free_port()
flags = {"nothing said": "", "-4": "-4", "-6": "-6", "-46": "-46", "-64": "-64"}
family_ports = {name: t.free_port() for name in flags}
sections = [f"""
flush
auth iponly
allow *
proxy -p{family_ports[name]} {flag}""" for name, flag in flags.items()]
t.start("ipv6_family", f"""
log
auth iponly
allow *
http * /echo* echo
httpsrv -p{v4origin}
{"".join(sections)}
""", ports=[v4origin] + list(family_ports.values()))
expected = {
"nothing said": (200, None), # -4 is the default
"-4": (200, None),
"-6": (None, 200),
"-46": (200, 200),
"-64": (200, 200),
}
for name, port in family_ports.items():
want4, want6 = expected[name]
got4 = t.http(f"http://127.0.0.1:{v4origin}/echo", proxy=f"127.0.0.1:{port}").status
got6 = t.http(url, proxy=f"127.0.0.1:{port}").status
if want4 == 200:
t.eq(200, got4, f"{name}: an IPv4 destination is reached")
else:
t.ne(200, got4, f"{name}: an IPv4 destination is refused")
if want6 == 200:
t.eq(200, got6, f"{name}: an IPv6 destination is reached")
else:
t.ne(200, got6, f"{name}: an IPv6 destination is refused")
# --- a name that resolves to an IPv6 address ------------------------------
# The two caches are separate, and the record is only kept in the one
# that matches the address family.
# separate processes: the caches belong to the process, not the service,
# so one section configuring nscache6 would answer for the other too
with_cache6 = t.free_port()
without = t.free_port()
t.start("ipv6_names", f"""
log
flush
nserver 127.0.0.1
nscache6 1024
nsrecord v6.test ::1
auth iponly
allow *
proxy -p{with_cache6} -6
""", ports=[with_cache6])
t.start("ipv6_names_nocache", f"""
log
flush
nserver 127.0.0.1
nsrecord v6.test ::1
auth iponly
allow *
proxy -p{without} -6
""", ports=[without])
t.eq(200, t.http(f"http://v6.test:{origin}/echo",
proxy=f"127.0.0.1:{with_cache6}").status,
"a name kept in nscache6 resolves to its IPv6 address")
t.ne(200, t.http(f"http://v6.test:{origin}/echo",
proxy=f"127.0.0.1:{without}").status,
"the same record without nscache6 is not there to be found")
# --- an address has more than one spelling --------------------------------
# Denying the IPv4 form does not deny the same host asked for as an
# IPv4-mapped address, nor the IPv6 loopback, which is why the security
# notes say to deny all of them. Both halves are checked so a change in
# either direction is noticed.
partial = t.free_port()
complete = t.free_port()
t.start("ipv6_deny", f"""
log
flush
auth iponly
deny * * 127.0.0.1
allow *
proxy -p{partial} -46
flush
auth iponly
deny * * 127.0.0.1
deny * * ::1
deny * * ::ffff:127.0.0.1
allow *
proxy -p{complete} -46
""", ports=[partial, complete])
v4url = f"http://127.0.0.1:{v4origin}/echo"
mapped = f"http://[::ffff:127.0.0.1]:{v4origin}/echo"
t.ne(200, t.http(v4url, proxy=f"127.0.0.1:{partial}").status,
"denying 127.0.0.1 denies the address as written")
t.eq(200, t.http(mapped, proxy=f"127.0.0.1:{partial}").status,
"but the same host asked for as ::ffff:127.0.0.1 is still reached")
t.eq(200, t.http(url, proxy=f"127.0.0.1:{partial}").status,
"and so is ::1, which the rule never mentioned")
t.ne(200, t.http(v4url, proxy=f"127.0.0.1:{complete}").status,
"naming every spelling denies the plain address")
t.ne(200, t.http(mapped, proxy=f"127.0.0.1:{complete}").status,
"and the mapped one")
t.ne(200, t.http(url, proxy=f"127.0.0.1:{complete}").status,
"and the IPv6 loopback")
# --- rules that name addresses ------------------------------------------
allowed = t.free_port()
refused = t.free_port()
t.start("ipv6_rules", f"""
log
flush
auth iponly
allow * ::1
proxy -p{allowed} -i::1 -6
flush
auth iponly
allow * 127.0.0.1
proxy -p{refused} -i::1 -6
""", ports=[("::1", allowed), ("::1", refused)])
t.eq(200, t.http(url, proxy=f"[::1]:{allowed}").status,
"a rule naming ::1 admits an IPv6 client")
t.ne(200, t.http(url, proxy=f"[::1]:{refused}").status,
"a rule naming only an IPv4 address does not")