From cd34fd45d55ebe9a6cc69805adccc4d41b357a68 Mon Sep 17 00:00:00 2001 From: Vladimir Dubrovin <3proxy@3proxy.ru> Date: Thu, 17 Sep 2026 13:02:56 +0300 Subject: [PATCH] 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 --- man/3proxy.cfg.5 | 2 +- src/common.c | 50 ++++++++++++++++++++++++++++--------- src/proxy.h | 4 +++ tests/cases/parent_ports.py | 23 ++++++++++------- 4 files changed, 57 insertions(+), 22 deletions(-) diff --git a/man/3proxy.cfg.5 b/man/3proxy.cfg.5 index 8a9b049..ffe758b 100644 --- a/man/3proxy.cfg.5 +++ b/man/3proxy.cfg.5 @@ -935,7 +935,7 @@ with probability of 0.7) for outgoing web connections. Chains are only applied t .br \fBextip\fR does not actually redirect the request; it sets the external address for this request to \fI\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 - \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 \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 diff --git a/src/common.c b/src/common.c index fae4e62..5c6e695 100644 --- a/src/common.c +++ b/src/common.c @@ -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){ SASIZETYPE size; @@ -694,6 +700,7 @@ int doconnect(struct clientparam * param){ } else { struct linger lg = {1,conf.timeouts[LINGER_TO]}; + int tries = RANGETRIES; if(SAISNULL(¶m->sinsr)){ if(SAISNULL(¶m->req)) { @@ -703,6 +710,7 @@ int doconnect(struct clientparam * param){ memcpy(SAADDR(¶m->sinsr), SAADDR(¶m->req), SAADDRLEN(¶m->req)); } if(!*SAPORT(¶m->sinsr))*SAPORT(¶m->sinsr) = *SAPORT(¶m->req); + for(;;){ if ((param->remsock=param->srv->so._socket(param->sostate, SASOCK(¶m->sinsr), SOCK_STREAM, #ifdef WITH_UN *SAFAMILY(¶m->sinsr) == AF_UNIX? 0 : @@ -746,14 +754,24 @@ int doconnect(struct clientparam * param){ #ifdef WITH_UN if(*SAFAMILY(¶m->sinsl) != AF_UNIX) #endif - if(bindwithrange(param, param->remsock, ¶m->sinsl, param->extport)==-1) { + if(bindrange(param, param->remsock, ¶m->sinsl, param->extport, &tries)==-1) { return 12; } - if(param->operation >= 256 || (param->operation & CONNECT) || param->redirected){ - if(connectwithpoll(param, param->remsock,(struct sockaddr *)¶m->sinsr,SASIZE(¶m->sinsr),conf.timeouts[CONNECT_TO])) { - return 13; - } + if(!(param->operation >= 256 || (param->operation & CONNECT) || param->redirected)) break; + if(!connectwithpoll(param, param->remsock,(struct sockaddr *)¶m->sinsr,SASIZE(¶m->sinsr),conf.timeouts[CONNECT_TO])) break; + /* 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); if(param->srv->so._getsockname(param->sostate, param->remsock, (struct sockaddr *)¶m->sinsl, &size)==-1) {return (15);} @@ -767,10 +785,6 @@ int doconnect(struct clientparam * param){ 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 * 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 * 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. + * + * *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; - int i; 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); 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)))); 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)); } +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) { unsigned d1, d2, d3, d4, m; int res; diff --git a/src/proxy.h b/src/proxy.h index 837f9b8..4473e3b 100644 --- a/src/proxy.h +++ b/src/proxy.h @@ -86,6 +86,10 @@ #ifndef EINPROGRESS #define EINPROGRESS WSAEWOULDBLOCK #endif +#ifdef EADDRINUSE +#undef EADDRINUSE +#endif +#define EADDRINUSE WSAEADDRINUSE #define EINTR WSAEWOULDBLOCK #define SLEEPTIME 1 #define usleep Sleep diff --git a/tests/cases/parent_ports.py b/tests/cases/parent_ports.py index cdf2d73..97aae0e 100644 --- a/tests/cases/parent_ports.py +++ b/tests/cases/parent_ports.py @@ -21,17 +21,22 @@ def _windows(): 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, 21899), (22000, 22499) - base = low + 1000 if low + 1150 <= high else low - return (base, base + 49), (base + 100, base + 149) + 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), (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 # outside net.ipv4.ip_local_port_range, and binding them fails outright @@ -65,7 +70,7 @@ def run(t): flush auth iponly allow * * * * HTTP_CONNECT - parent 1000 extport 0.0.0.0 {LOW}-{HIGH} + parent 1000 extport 0.0.0.0 {MLOW}-{MHIGH} allow * proxy -p{meth} @@ -73,7 +78,7 @@ def run(t): flush auth iponly allow * - parent 1000 extport 0.0.0.0 {LOW}-{HIGH} + parent 1000 extport 0.0.0.0 {SLOW}-{SHIGH} socks -p{sks} """, 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"), "peer.port") - t.in_range(port, LOW, HIGH, + 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, 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 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 range the platform cannot honour --------------------------------