mirror of
https://github.com/3proxy/3proxy.git
synced 2026-10-24 12:05:47 +08:00
On Linux the port windows lie within net.ipv4.ip_local_port_range, as the kernel honours no other, so the ephemeral port of a request the range does not apply to falls into the 50-port window now and then (CI saw 34001 in 33968-34017). The case now makes five such requests and wants one of them outside the window: bound to it, all would be in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
7.4 KiB
Python
195 lines
7.4 KiB
Python
"""extport and intport: binding the local side of a connection to a range.
|
|
|
|
Access rules accumulate until "flush": without it an earlier "allow *"
|
|
matches first and the rule carrying the range is never reached.
|
|
"""
|
|
|
|
from harness import int_field
|
|
|
|
|
|
def _windows():
|
|
"""Pick port windows this platform will actually honour.
|
|
|
|
On Linux the kernel applies IP_LOCAL_PORT_RANGE only within
|
|
net.ipv4.ip_local_port_range; a window outside it is ignored and an
|
|
ordinary ephemeral port is used, so a fixed low window would be
|
|
measuring the kernel's own choice rather than the setting.
|
|
|
|
Everywhere else the range is honoured by binding a port out of it at
|
|
random, ten times before giving up and letting the system choose. A port
|
|
which carried a connection a moment ago cannot be bound again while it
|
|
waits out its close - four minutes of it on Windows - so the window has
|
|
to be wide enough that ten tries do not all land on one. The window the
|
|
kernel picks from on Linux needs no such room, since it skips them.
|
|
|
|
Each service gets a window of its own. A port bound a second time towards
|
|
a destination it has just been used for makes a pair which is still
|
|
closing, which Windows refuses at the connect, and services sharing one
|
|
window all talk to the same origin here.
|
|
"""
|
|
try:
|
|
with open("/proc/sys/net/ipv4/ip_local_port_range") as fp:
|
|
low, high = (int(part) for part in fp.read().split()[:2])
|
|
except (OSError, ValueError):
|
|
return [(21400 + i * 600, 21899 + i * 600) for i in range(4)]
|
|
base = low + 1000 if low + 1400 <= high else low
|
|
return [(base + i * 100, base + i * 100 + 49) for i in range(4)]
|
|
|
|
|
|
(LOW, HIGH), (SLOW, SHIGH), (MLOW, MHIGH), (ILOW, IHIGH) = _windows()
|
|
|
|
# Privileged ports: the kernel ignores such a range on Linux, since it is
|
|
# outside net.ipv4.ip_local_port_range, and binding them fails outright
|
|
# without privileges. Either way nothing in the range can be taken, which
|
|
# is the case the fallback exists for.
|
|
UNHONOURED = (1, 99)
|
|
|
|
|
|
def run(t):
|
|
srv = t.free_port()
|
|
prx = t.free_port()
|
|
sks = t.free_port()
|
|
meth = t.free_port()
|
|
|
|
t.start("parent_ports", f"""
|
|
log
|
|
auth iponly
|
|
allow *
|
|
http echo * /echo**
|
|
httpsrv -p{srv}
|
|
|
|
# every outgoing connection binds inside the range
|
|
flush
|
|
auth iponly
|
|
allow *
|
|
parent 1000 extport 0.0.0.0 {LOW}-{HIGH}
|
|
proxy -p{prx}
|
|
|
|
# the range applies only to CONNECT: an HTTP proxy CONNECT is
|
|
# HTTP_CONNECT, the bare CONNECT operation being the SOCKS one
|
|
flush
|
|
auth iponly
|
|
allow * * * * HTTP_CONNECT
|
|
parent 1000 extport 0.0.0.0 {MLOW}-{MHIGH}
|
|
allow *
|
|
proxy -p{meth}
|
|
|
|
# socks, for the same setting on another service
|
|
flush
|
|
auth iponly
|
|
allow *
|
|
parent 1000 extport 0.0.0.0 {SLOW}-{SHIGH}
|
|
socks -p{sks}
|
|
""", ports=[srv, prx, sks, meth])
|
|
|
|
origin = f"http://127.0.0.1:{srv}"
|
|
proxy = f"127.0.0.1:{prx}"
|
|
|
|
# --- extport ---------------------------------------------------------
|
|
# the origin reports the source port it actually saw
|
|
port = int_field(t.http(origin + "/echo", proxy=proxy), "peer.port")
|
|
t.in_range(port, LOW, HIGH, "the outgoing connection binds inside the range")
|
|
|
|
seen = []
|
|
for _ in range(5):
|
|
seen.append(int_field(t.http(origin + "/echo", proxy=proxy), "peer.port"))
|
|
outside = [p for p in seen if p is None or not LOW <= p <= HIGH]
|
|
t.eq([], outside, "repeated connections all bind inside the range")
|
|
|
|
port = int_field(t.socks_http(f"127.0.0.1:{sks}", origin + "/echo"),
|
|
"peer.port")
|
|
t.in_range(port, SLOW, SHIGH,
|
|
"socks binds the outgoing connection inside the range")
|
|
|
|
# --- per-method scoping ------------------------------------------------
|
|
method_proxy = f"127.0.0.1:{meth}"
|
|
port = int_field(t.http(origin + "/echo", proxy=method_proxy, tunnel=True),
|
|
"peer.port")
|
|
t.in_range(port, MLOW, MHIGH, "CONNECT uses the range its rule sets")
|
|
|
|
# a plain GET matches the later rule, which sets no range. On Linux the
|
|
# window lies within the ephemeral range, so an ephemeral port falls in
|
|
# it now and then by chance: bound to the window every port would, so
|
|
# one of a few outside it tells them apart.
|
|
ports = [int_field(t.http(origin + "/echo", proxy=method_proxy), "peer.port")
|
|
for _ in range(5)]
|
|
outside = [p for p in ports if isinstance(p, int) and not MLOW <= p <= MHIGH]
|
|
t.ok(f"a method outside that rule keeps an ephemeral port ({ports})") if outside else \
|
|
t.fail("a method outside that rule keeps an ephemeral port",
|
|
f"some outside {MLOW}-{MHIGH}", ports)
|
|
|
|
# --- a range the platform cannot honour --------------------------------
|
|
# Linux ignores a range outside net.ipv4.ip_local_port_range, and any
|
|
# platform can run out of free ports in a range. Either way the
|
|
# connection falls back to an ephemeral port instead of failing.
|
|
unhonoured = t.free_port()
|
|
t.start("parent_unhonoured", f"""
|
|
log
|
|
flush
|
|
auth iponly
|
|
allow *
|
|
parent 1000 extport 0.0.0.0 {UNHONOURED[0]}-{UNHONOURED[1]}
|
|
proxy -p{unhonoured}
|
|
""", ports=[unhonoured])
|
|
r = t.http(origin + "/echo", proxy=f"127.0.0.1:{unhonoured}")
|
|
t.eq(200, r.status, "a range the platform cannot honour still connects")
|
|
t.ne(None, int_field(r, "peer.port"),
|
|
"the connection still has a source port")
|
|
|
|
# --- intport -----------------------------------------------------------
|
|
# A UDP association allocates its socket after the destination is known,
|
|
# so the range has to be applied when the rule matches rather than when
|
|
# the chain is walked.
|
|
udps = t.free_port()
|
|
t.start("parent_intport", f"""
|
|
log
|
|
flush
|
|
auth iponly
|
|
allow *
|
|
parent 1000 intport 0.0.0.0 {ILOW}-{IHIGH}
|
|
socks -p{udps}
|
|
""", ports=[udps])
|
|
t.in_range(t.socks_udp_associate(udps), ILOW, IHIGH,
|
|
"UDP ASSOCIATE binds inside the internal range")
|
|
|
|
# and the association still carries traffic while bound in the range
|
|
echo = t.udp_echo()
|
|
reply, bound = t.socks_udp(f"127.0.0.1:{udps}", "127.0.0.1", echo, b"data")
|
|
t.eq(b"echo:data", reply, "a range-bound association still relays")
|
|
t.in_range(bound, ILOW, IHIGH, "and the port it relays from is in the range")
|
|
|
|
# without a range the association still works, on an ephemeral port
|
|
udps2 = t.free_port()
|
|
t.start("parent_intport_none", f"""
|
|
log
|
|
flush
|
|
auth iponly
|
|
allow *
|
|
socks -p{udps2}
|
|
""", ports=[udps2])
|
|
t.ne(None, t.socks_udp_associate(udps2),
|
|
"UDP ASSOCIATE works without a range")
|
|
|
|
# --- configuration errors ------------------------------------------------
|
|
dead = t.free_port()
|
|
t.contains(t.run_config("badaddr", f"""
|
|
log
|
|
allow *
|
|
parent 1000 extport 127.0.0.1 {LOW}-{HIGH}
|
|
proxy -p{dead}
|
|
"""), "requires 0.0.0.0", "a non-zero address with extport is rejected")
|
|
|
|
t.contains(t.run_config("badrange", f"""
|
|
log
|
|
allow *
|
|
parent 1000 extport 0.0.0.0 notaport
|
|
proxy -p{dead}
|
|
"""), "bad port range", "a malformed range is rejected")
|
|
|
|
t.contains(t.run_config("badorder", f"""
|
|
log
|
|
allow *
|
|
parent 1000 extport 0.0.0.0 {HIGH}-{LOW}
|
|
proxy -p{dead}
|
|
"""), "bad port range", "a reversed range is rejected")
|