Take another local port when the one bound cannot reach the destination

extport binds a local port out of its range and connects from it. A port
which is free to bind may still be half of a pair which is closing from an
earlier connection to the same destination, and Windows fails the connect
with WSAEADDRINUSE where other systems fail the bind and fall back. The
request then failed with error 13 for a reason another port would have
avoided, which is what the range is searched for in the first place.

Retry the connect with another port from the range. Only for that error, so
a destination which is simply unreachable still costs one attempt, and out of
the same ten tries the bind already spends, so the attempts parentretries
makes are not multiplied. EADDRINUSE joins the errno values proxy.h maps to
their Winsock numbers, without which the test would never match on Windows.

parent_ports gave its three services one window between them, which is how
the case met the collision on the Windows runner. Each has its own now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Vladimir Dubrovin 2026-09-17 13:02:56 +03:00
parent ca4667c035
commit 6f02ec2747
4 changed files with 57 additions and 22 deletions

View File

@ -929,7 +929,7 @@ with probability of 0.7) for outgoing web connections. Chains are only applied t
.br .br
\fBextip\fR does not actually redirect the request; it sets the external address for this request to \fI<ip>\fR. It can be chained with another parent type. It's useful to set the external IP based on ACL or make it random. \fBextip\fR does not actually redirect the request; it sets the external address for this request to \fI<ip>\fR. It can be chained with another parent type. It's useful to set the external IP based on ACL or make it random.
.br .br
\fBextport\fR does not redirect the request; it sets the range the local port of outgoing connections is taken from, given as \fIFIRST-LAST\fR inclusive in place of the port argument, with 0.0.0.0 as the address, for example \fBparent 1000 extport 0.0.0.0 40000-40100\fR. Where the system can be asked to pick the port itself (Linux \fBIP_LOCAL_PORT_RANGE\fR) it does, otherwise a port is picked at random from the range and retried if it is already in use, up to ten times. On Linux the range has to lie within \fInet.ipv4.ip_local_port_range\fR, commonly 32768-60999: the kernel ignores a range outside it and picks an ordinary ephemeral port instead. If no port in the range can be bound, an ephemeral port is used rather than failing the connection. It can be chained with another parent type, and the access rule it belongs to decides which requests it applies to, so \fBallow * * * * UDPASSOC\fR followed by \fBparent 1000 extport 0.0.0.0 40000-40100\fR limits it to UDP associations. The range is applied when the outgoing connection is made, so a kept alive connection carrying several requests uses the rule that matched when it was opened. \fBextport\fR does not redirect the request; it sets the range the local port of outgoing connections is taken from, given as \fIFIRST-LAST\fR inclusive in place of the port argument, with 0.0.0.0 as the address, for example \fBparent 1000 extport 0.0.0.0 40000-40100\fR. Where the system can be asked to pick the port itself (Linux \fBIP_LOCAL_PORT_RANGE\fR) it does, otherwise a port is picked at random from the range and retried if it is already in use, up to ten times. A port which binds but whose pair with the destination is still closing from an earlier connection, which Windows refuses at the connect, is retried in the same way and out of the same ten tries, so a repeated connection to one destination does not fail for a port it has just used. On Linux the range has to lie within \fInet.ipv4.ip_local_port_range\fR, commonly 32768-60999: the kernel ignores a range outside it and picks an ordinary ephemeral port instead. If no port in the range can be bound, an ephemeral port is used rather than failing the connection. It can be chained with another parent type, and the access rule it belongs to decides which requests it applies to, so \fBallow * * * * UDPASSOC\fR followed by \fBparent 1000 extport 0.0.0.0 40000-40100\fR limits it to UDP associations. The range is applied when the outgoing connection is made, so a kept alive connection carrying several requests uses the rule that matched when it was opened.
.br .br
\fBintport\fR is the same for sockets bound on the side facing the client: the port a UDP association tells the client to send its datagrams to, and the FTP proxy data connection. \fBintport\fR is the same for sockets bound on the side facing the client: the port a UDP association tells the client to send its datagrams to, and the FTP proxy data connection.
.br .br

View File

@ -677,6 +677,12 @@ int connectwithpoll(struct clientparam *param, SOCKET sock, struct sockaddr *sa,
} }
/* Number of ports tried before giving up when the range has to be searched by
* hand. The kernel option picks a free port itself and needs no retries. */
#define RANGETRIES 10
static int bindrange(struct clientparam *param, SOCKET sock, PROXYSOCKADDRTYPE *sa, uint32_t range, int *tries);
int doconnect(struct clientparam * param){ int doconnect(struct clientparam * param){
SASIZETYPE size; SASIZETYPE size;
@ -694,6 +700,7 @@ int doconnect(struct clientparam * param){
} }
else { else {
struct linger lg = {1,conf.timeouts[LINGER_TO]}; struct linger lg = {1,conf.timeouts[LINGER_TO]};
int tries = RANGETRIES;
if(SAISNULL(&param->sinsr)){ if(SAISNULL(&param->sinsr)){
if(SAISNULL(&param->req)) { if(SAISNULL(&param->req)) {
@ -703,6 +710,7 @@ int doconnect(struct clientparam * param){
memcpy(SAADDR(&param->sinsr), SAADDR(&param->req), SAADDRLEN(&param->req)); memcpy(SAADDR(&param->sinsr), SAADDR(&param->req), SAADDRLEN(&param->req));
} }
if(!*SAPORT(&param->sinsr))*SAPORT(&param->sinsr) = *SAPORT(&param->req); if(!*SAPORT(&param->sinsr))*SAPORT(&param->sinsr) = *SAPORT(&param->req);
for(;;){
if ((param->remsock=param->srv->so._socket(param->sostate, SASOCK(&param->sinsr), SOCK_STREAM, if ((param->remsock=param->srv->so._socket(param->sostate, SASOCK(&param->sinsr), SOCK_STREAM,
#ifdef WITH_UN #ifdef WITH_UN
*SAFAMILY(&param->sinsr) == AF_UNIX? 0 : *SAFAMILY(&param->sinsr) == AF_UNIX? 0 :
@ -746,14 +754,24 @@ int doconnect(struct clientparam * param){
#ifdef WITH_UN #ifdef WITH_UN
if(*SAFAMILY(&param->sinsl) != AF_UNIX) if(*SAFAMILY(&param->sinsl) != AF_UNIX)
#endif #endif
if(bindwithrange(param, param->remsock, &param->sinsl, param->extport)==-1) { if(bindrange(param, param->remsock, &param->sinsl, param->extport, &tries)==-1) {
return 12; return 12;
} }
if(param->operation >= 256 || (param->operation & CONNECT) || param->redirected){ if(!(param->operation >= 256 || (param->operation & CONNECT) || param->redirected)) break;
if(connectwithpoll(param, param->remsock,(struct sockaddr *)&param->sinsr,SASIZE(&param->sinsr),conf.timeouts[CONNECT_TO])) { if(!connectwithpoll(param, param->remsock,(struct sockaddr *)&param->sinsr,SASIZE(&param->sinsr),conf.timeouts[CONNECT_TO])) break;
return 13; /* The port was free to bind, but the pair it makes with the destination
} is still closing from an earlier connection through the same range,
which Windows refuses. Another port out of the range may do. Anything
else is the destination's own answer, and is not worth a second port.
The ports come out of the allowance the bind is already spending, so
the range is not searched any harder than one bind would search it and
the attempts parentretries makes are not multiplied. Counted here as
well, since the kernel option hands out a port without spending any of
the allowance. */
if(!param->extport || errno != EADDRINUSE || --tries <= 0) return 13;
param->srv->so._closesocket(param->sostate, param->remsock);
param->remsock = INVALID_SOCKET;
} }
size = sizeof(param->sinsl); size = sizeof(param->sinsl);
if(param->srv->so._getsockname(param->sostate, param->remsock, (struct sockaddr *)&param->sinsl, &size)==-1) {return (15);} if(param->srv->so._getsockname(param->sostate, param->remsock, (struct sockaddr *)&param->sinsl, &size)==-1) {return (15);}
@ -767,10 +785,6 @@ int doconnect(struct clientparam * param){
return 0; return 0;
} }
/* Number of ports tried before giving up when the range has to be searched by
* hand. The kernel option picks a free port itself and needs no retries. */
#define RANGETRIES 10
/* Bind sock to sa, taking the local port from the range if one is set. The /* Bind sock to sa, taking the local port from the range if one is set. The
* range is packed as first | last << 16. * range is packed as first | last << 16.
* *
@ -778,11 +792,15 @@ int doconnect(struct clientparam * param){
* are free. Where the option does not exist, or the kernel refuses it, or the * are free. Where the option does not exist, or the kernel refuses it, or the
* address family is not one it covers, pick a port at random instead and retry * address family is not one it covers, pick a port at random instead and retry
* on failure, since the one picked may already be taken. * on failure, since the one picked may already be taken.
*
* *tries is how many ports may still be taken out of the range. A caller which
* has to come back for another port, because the one it was given turned out
* to be unusable for the connection it wanted, carries the same count along
* and so cannot spend more attempts than one call would.
*/ */
int bindwithrange(struct clientparam *param, SOCKET sock, PROXYSOCKADDRTYPE *sa, uint32_t range) static int bindrange(struct clientparam *param, SOCKET sock, PROXYSOCKADDRTYPE *sa, uint32_t range, int *tries)
{ {
uint16_t first, last; uint16_t first, last;
int i;
if(!range) return param->srv->so._bind(param->sostate, sock, (struct sockaddr *)sa, SASIZE(sa)); if(!range) return param->srv->so._bind(param->sostate, sock, (struct sockaddr *)sa, SASIZE(sa));
@ -798,7 +816,8 @@ int bindwithrange(struct clientparam *param, SOCKET sock, PROXYSOCKADDRTYPE *sa,
first = (uint16_t)(range & 0xffff); first = (uint16_t)(range & 0xffff);
last = (uint16_t)(range >> 16); last = (uint16_t)(range >> 16);
for(i = 0; i < RANGETRIES; i++){ while(*tries > 0){
(*tries)--;
*SAPORT(sa) = htons((uint16_t)(first + (myrand() % (unsigned)(last - first + 1)))); *SAPORT(sa) = htons((uint16_t)(first + (myrand() % (unsigned)(last - first + 1))));
if(!param->srv->so._bind(param->sostate, sock, (struct sockaddr *)sa, SASIZE(sa))) return 0; if(!param->srv->so._bind(param->sostate, sock, (struct sockaddr *)sa, SASIZE(sa))) return 0;
} }
@ -810,6 +829,13 @@ int bindwithrange(struct clientparam *param, SOCKET sock, PROXYSOCKADDRTYPE *sa,
return param->srv->so._bind(param->sostate, sock, (struct sockaddr *)sa, SASIZE(sa)); return param->srv->so._bind(param->sostate, sock, (struct sockaddr *)sa, SASIZE(sa));
} }
int bindwithrange(struct clientparam *param, SOCKET sock, PROXYSOCKADDRTYPE *sa, uint32_t range)
{
int tries = RANGETRIES;
return bindrange(param, sock, sa, range, &tries);
}
int scanaddr(const unsigned char *s, uint32_t * ip, uint32_t * mask) { int scanaddr(const unsigned char *s, uint32_t * ip, uint32_t * mask) {
unsigned d1, d2, d3, d4, m; unsigned d1, d2, d3, d4, m;
int res; int res;

View File

@ -86,6 +86,10 @@
#ifndef EINPROGRESS #ifndef EINPROGRESS
#define EINPROGRESS WSAEWOULDBLOCK #define EINPROGRESS WSAEWOULDBLOCK
#endif #endif
#ifdef EADDRINUSE
#undef EADDRINUSE
#endif
#define EADDRINUSE WSAEADDRINUSE
#define EINTR WSAEWOULDBLOCK #define EINTR WSAEWOULDBLOCK
#define SLEEPTIME 1 #define SLEEPTIME 1
#define usleep Sleep #define usleep Sleep

View File

@ -21,17 +21,22 @@ def _windows():
waits out its close - four minutes of it on Windows - so the window has 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 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. 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: try:
with open("/proc/sys/net/ipv4/ip_local_port_range") as fp: with open("/proc/sys/net/ipv4/ip_local_port_range") as fp:
low, high = (int(part) for part in fp.read().split()[:2]) low, high = (int(part) for part in fp.read().split()[:2])
except (OSError, ValueError): except (OSError, ValueError):
return (21400, 21899), (22000, 22499) return [(21400 + i * 600, 21899 + i * 600) for i in range(4)]
base = low + 1000 if low + 1150 <= high else low base = low + 1000 if low + 1400 <= high else low
return (base, base + 49), (base + 100, base + 149) return [(base + i * 100, base + i * 100 + 49) for i in range(4)]
(LOW, HIGH), (ILOW, IHIGH) = _windows() (LOW, HIGH), (SLOW, SHIGH), (MLOW, MHIGH), (ILOW, IHIGH) = _windows()
# Privileged ports: the kernel ignores such a range on Linux, since it is # 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 # outside net.ipv4.ip_local_port_range, and binding them fails outright
@ -65,7 +70,7 @@ def run(t):
flush flush
auth iponly auth iponly
allow * * * * HTTP_CONNECT allow * * * * HTTP_CONNECT
parent 1000 extport 0.0.0.0 {LOW}-{HIGH} parent 1000 extport 0.0.0.0 {MLOW}-{MHIGH}
allow * allow *
proxy -p{meth} proxy -p{meth}
@ -73,7 +78,7 @@ def run(t):
flush flush
auth iponly auth iponly
allow * allow *
parent 1000 extport 0.0.0.0 {LOW}-{HIGH} parent 1000 extport 0.0.0.0 {SLOW}-{SHIGH}
socks -p{sks} socks -p{sks}
""", ports=[srv, prx, sks, meth]) """, ports=[srv, prx, sks, meth])
@ -93,18 +98,18 @@ def run(t):
port = int_field(t.socks_http(f"127.0.0.1:{sks}", origin + "/echo"), port = int_field(t.socks_http(f"127.0.0.1:{sks}", origin + "/echo"),
"peer.port") "peer.port")
t.in_range(port, LOW, HIGH, t.in_range(port, SLOW, SHIGH,
"socks binds the outgoing connection inside the range") "socks binds the outgoing connection inside the range")
# --- per-method scoping ------------------------------------------------ # --- per-method scoping ------------------------------------------------
method_proxy = f"127.0.0.1:{meth}" method_proxy = f"127.0.0.1:{meth}"
port = int_field(t.http(origin + "/echo", proxy=method_proxy, tunnel=True), port = int_field(t.http(origin + "/echo", proxy=method_proxy, tunnel=True),
"peer.port") "peer.port")
t.in_range(port, LOW, HIGH, "CONNECT uses the range its rule sets") t.in_range(port, MLOW, MHIGH, "CONNECT uses the range its rule sets")
# a plain GET matches the later rule, which sets no range # a plain GET matches the later rule, which sets no range
port = int_field(t.http(origin + "/echo", proxy=method_proxy), "peer.port") port = int_field(t.http(origin + "/echo", proxy=method_proxy), "peer.port")
t.not_in_range(port, LOW, HIGH, t.not_in_range(port, MLOW, MHIGH,
"a method outside that rule keeps an ephemeral port") "a method outside that rule keeps an ephemeral port")
# --- a range the platform cannot honour -------------------------------- # --- a range the platform cannot honour --------------------------------