Commit Graph

1317 Commits

Author SHA1 Message Date
Vladimir Dubrovin
973fc89d58 Event loops: a fiber's stack given back while its client is relayed
A child for which mapping is the last thing it does (tcppm, tlspr) sets
maptail, what it does once mapping is over as its own end would: its
log line, prepared before for tlspr. Relayed by the loop, the client's
fiber then gives up its stack at once: left in the middle of the
child's code, never to be resumed, it is started over from the start of
its stack for the next client (fiberreset), or freed where it cannot be
(Windows). Once mapping is over the loop ends the client as the child
and its fiber would: the result, the tail, the connection released and
freed. After a reload, which sockmap() is to see to, the client is
given a fiber again for sockmap() to go on with the mapping, then its
end. Not while a reload is pending: sockmap() sees to it on the fiber.

The callbacks' state is no longer on the fiber's stack but allocated.

clientparam has maptail and maptaildata: plugins are to be built again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:25:33 +03:00
Vladimir Dubrovin
be1ae65c4f Event loops: the callbacks' relay for all sockmap() maps
What was left to sockmap() is relayed by the callbacks too:

- bandwidth limits: the pause bandlimfunc asks for after data moved is
  a timer, the sockets' events waiting for it;
- grace (-g): small reads many times this second, either way, pause
  for the grace delay before more, as sockmap() waits before polling;
- traffic limits (maxtrafin, maxtrafout): the mapping ends with 10;
- dumps to the log (logdumpsrv, logdumpcli) as the counts pass them;
- control sockets (ctrlsock, ctrlsocksrv): registered with the loop,
  anything from them or their end ends the mapping;
- data filters, before data is sent, which may take another buffer
  for it, a buffer of a filter given back once empty (bufrelease);
- the filters before the data, run once on the fiber: TLS begins
  there. TLS, or anything else between the socket functions and the
  socket, is read whatever the socket says when _prepoll() says it
  holds data already, and copied, not spliced;
- back to copying after more than splicestreak spliced reads in a
  row smaller than splicesmall, once the pipe is through, and from
  copying to splice only for data of no known length, as sockmap().

A pipe still holding data is closed on every end of the mapping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:55:03 +03:00
Vladimir Dubrovin
8fea43015c Event loops: on a reload the callbacks' relay reads no more
Given back to sockmap() once its pipes were empty, the relay of the
callbacks went on filling them meanwhile: a steady stream kept them
from emptying, and the connection from going by the newest rules.
Once a reload is seen nothing more is read, the pipes are emptied
within LINGER_TO, or the connection ends as timed out, and sockmap()
takes over as soon as they are, not with the next event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:38:35 +03:00
Vladimir Dubrovin
e0a6932704 Event loops: the callbacks' pipes emptied before sockmap() takes over
On a reload the relay of the callbacks gives the connection back to
sockmap(), which knows nothing of what its pipes hold, nor of how much
of a body of known length has already gone through: what was in a pipe
would have come out of order, and the whole length of a body been read
again, into what follows it. The pipes are emptied first, the relay
going on meanwhile, and the length left of a body is the one sockmap()
is given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:27:21 +03:00
Vladimir Dubrovin
f486242638 Event loops: splice() in the callbacks' relay
Each way of the relay, the client's data and the server's, splices as
sockmap() does where it may: from the start when told to (-s1, -s2,
-s3) or for a body of known length of splicemin or more, and with -s
(automatic) once splicestreak reads in a row have been as large. Not
back to copying once spliced, as sockmap() is: a draft. What a way has
buffered goes first, its pipe is used once that is through; a pipe
left holding data is closed, as sockmap() does. The relay is reworked
around one way, the same code both ways.

The callback's hook is given what sockmap() was given to splice, less
what its filters forbid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:19:37 +03:00
Vladimir Dubrovin
875e47a230 Event loops: bodies of known length relayed by the callbacks too
A body of known length (waitclient64, waitserver64), the HTTP proxy's
requests and answers, was left to sockmap(). The relay of the callbacks
moves it as sockmap() does: that way only, as much as it has left, the
other way not at all, and ends with 98 or 99 once it is all through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:03:58 +03:00
Vladimir Dubrovin
82387caf2f Event loops: the callbacks' relay given back to sockmap() on a reload
sockmap() has a connection go by the newest configuration's rules after
a reload (reattachfunc), which may end it. The relay of the callbacks
did not, and a connection the new rules refuse went on. Once the
configuration is newer than the connection, it is given back to
sockmap() as it is, which does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:59:44 +03:00
Vladimir Dubrovin
23e83a6b02 Event loops: sockmap() as callbacks, a draft (-L2, loopmode cb)
The data of a connection mapped by a fiber of a loop is relayed by the
loop itself, on its own stack: the sockets registered with it call back
a relay moving what they are ready for, a read and a write each way a
round, up to 16 rounds before the rest waits for the loop's next turn.
The fiber, parked meanwhile, is resumed once the mapping is over, as
sockmap() would end it, with its result: the idle timeout, shortened to
LINGER_TO once a side has ended, half-close or not as the service has
it, errors, and data left.

Only a plain relay: with filters, limits of bandwidth or traffic,
grace, dumps to the log, TLS or anything else between the socket
functions of the system, a control socket or a body of known length,
sockmap() maps it itself. No splice() yet: data is copied, so it
compares with -s0. The fiber keeps its stack.

For benchmarks: -L2 on a service (with -L for the count of loops), or
loopmode cb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:51:38 +03:00
Vladimir Dubrovin
9f27d2cbfb -B: the TLS ClientHello split at the SNI, which -s is taken for splice
tlspr splits the ClientHello it passes in the middle of the server name
(SNI) when the service has s_option, set by -s. In builds with splice()
(Linux, the default there) -s is splice for every service but UDP ones,
admin and httpsrv, so the split was never made, and the How-To's SNI
break did nothing on Linux.

-B sets it whatever the build: for TCP services but admin and httpsrv,
where s_option has a meaning of its own. -s still does it where it is
not splice. The How-To, 3proxy.cfg(5) and tlspr(8) give -B.

Fixes #1283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:52:39 +03:00
Vladimir Dubrovin
23b9619da1 bench: 75th percentile latency, loops for -L, BENCH_WORKERS
The CSV has p50, p75, p90 and p99 now, for wrk (its --latency
distribution) and for the mix load generator alike; report.py puts in
the table those the results have.

3proxy with -L among its options is given as many event loops as the
proxy has CPUs, as envoy is given workers; BENCH_WORKERS sets that
count for both whatever the CPUs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:24:50 +03:00
Vladimir Dubrovin
1fb4a313c5 Event loops: readiness answered once is asked again, bit by bit
What a socket was reported ready for is trusted once: answered again
with nothing reported since, the system is asked with poll(). Counted
per socket, the count was set back by any wait, even one woken by its
timer with nothing reported, and by any event of the socket, even for
the other direction. sockmap(), finding nothing to do after an answer,
sleeps in such a wait: the readiness found not to be there, a splice()
into a full pipe given the socket's, was trusted again after each
sleep, until it gave up after 11 turns with 93. Under load every third
session of a relay or a CONNECT ended so in the middle of its data.

What was answered and not reported since is now kept per bit: an event
makes its own bits fresh, a wait answers with what it found, and those
are asked again next time.

poll() of the BSDs and macOS tells a socket we shut down for sending
as hung up, which the loop does not (see loop.c): asking it, that is
dropped, or httpsrv lingering for the client's end found it hung up
and not readable, and turned until its time was out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:53:28 +03:00
Vladimir Dubrovin
c188f14192 Outgoing connections bound with IP_BIND_ADDRESS_NO_PORT
The socket of a connection to the destination is bound to the external
address before connect(), with port 0 unless a range is given. bind()
then takes an ephemeral port for the socket alone: not one in use
towards another destination, nor one of a connection still closing in
TIME_WAIT, which connect() would reuse. With many connections the range
runs out and bind() fails, result 12, or searches long for a port: 7 ms
a call was seen. The more connections are open at once, the sooner:
with event loops, serving more of them at once, the client of every
tenth connection or so of a saturated relay got its end at once.

Where the system has IP_BIND_ADDRESS_NO_PORT (Linux 4.2), the port is
left to connect() for a socket that is going to connect: chosen for
the pair with the destination, together with IP_LOCAL_PORT_RANGE when
a range is given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:33:39 +03:00
Vladimir Dubrovin
9cdac1cb60 splice() pipes closed with fdclose(), the loop forgetting them first
sockmap waits on its splice() pipes as on its sockets, and on an event
loop a pipe waited on is registered there like a socket. They were
closed with close(), past the socket functions, so the loop kept the
registration of a closed descriptor, which epoll and kqueue drop on the
close: a socket given the same number later was taken for it, never
registered, and its fiber waited on it forever. fdclose() tells the
loop first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:54:46 +03:00
Vladimir Dubrovin
7b873b80b1 Event loops: sockets registered once, readiness kept until not found
A fiber waiting on a socket registered it with its loop and took it
off again on every wait: two epoll_ctl() calls a wait, some 39 for an
exchange of the mix benchmark, about an eighth of the proxy's time. A
socket is now registered once, edge-triggered for reading and writing,
until it is closed, and the loop keeps what it was reported ready for
until an operation finds it not to be: read, write, accept, connect,
splice() from a pipe and TLS tell it so through fiberblockfunc, the
close through fiberclosefunc. A wait for what a socket is known to be
ready for is answered at once, with no system call and no switch; only
with none of them ready does the fiber wait in its loop. Readiness is
trusted once: answered again with nothing reported or found since, what
was there may have been taken without its end being found, and the
system is asked with poll(). epoll and kqueue only; with poll() each
wait still registers its sockets.

What takes readability for an end rather than reading, the control
connection of a UDP association, the client waiting for a SOCKS BIND,
the server between requests of a kept-alive HTTP connection, asks
first with sockreadable(), a peek below any TLS layer.

kqueue: the end of either side, the peer's or our own shutdown, is
readable or writable, not a hang up, which poll() reports only with
both: kept, it made a wait for the answer after our shutdown return
at once, and sockmap give up with 93.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:41:47 +03:00
Vladimir Dubrovin
43f40569d4 Sockets closed without SO_LINGER
Client, outgoing and FTP data sockets, and the inetd socket of a
standalone service, were given SO_LINGER, the client's for the long
string timeout (60 seconds by default), the outgoing one's for the
LINGER timeout. It kept nothing from being lost: without it the system
sends what is queued after the close all the same, and the time running
out reset the connection, losing what was still queued. All it did was
make close() wait for the peer to acknowledge the data, holding the
connection's thread and maxconn slot meanwhile, and, for clients served
on an event loop, every client of the loop: with one loop it was idle a
quarter of the time in a benchmark of many short exchanges.

The LINGER timeout keeps its use: how long a half-closed connection
waits for the rest of its data, and how long httpsrv reads what is left
of a request before closing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:00:48 +03:00
Vladimir Dubrovin
c55f736a0d Clients of TCP services on event loops, served by fibers
With -L, or loopmode on for the services which follow, the clients of a
TCP service are served on event loops: loops threads (by default one
for each CPU) each run a loop, and each client is given in turn to one
of them, which serves it on a fiber with the same code a thread runs.
Its waits in sockpoll() wait in the loop, its sockets and a timer
registered there, and the fiber goes on once one is ready or the time
is out; a sleep is a timer alone. A client costs a stack, as large as
its thread's would be, and no thread. The loops start with the first
client served on one and run as long as the process; each keeps up to
256 idle fibers for its next clients.

srvspawn() posts the client to its loop once it is counted, what is
posted made before so that posting cannot fail. A service whose clients
cannot share a thread (threadperclient), or listening for connections
back (-R), all of its clients waiting for one socket, which a loop
waits for once, keeps a thread for each client, as does every service
of a build or a processor without fibers, and the standalone services.

tests/run.py --loopmode runs every configuration with loopmode on, on
two loops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 23:00:16 +03:00
Vladimir Dubrovin
e250f98598 Event loop on Windows: the wake connection only our own
Another local process could connect to the listening socket the wake
connection is made over before ours, and be accepted instead, leaving
the loop impossible to wake. Only the connection from our own socket is
taken; another is closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:27:26 +03:00
Vladimir Dubrovin
835ce04295 Event loop on Windows: woken over a loopback TCP connection
WSAPoll() never reported the UDP socket which sent to itself to wake the
loop: a message from another thread waited for the timeout, and
loopstop() did not end looprun(). The loop is woken over a TCP
connection on the loopback instead, as its sockets are reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:17:19 +03:00
Vladimir Dubrovin
30f6043160 Event loop: what is due called when waiting fails; units on Windows
looponce() called nothing due when waiting for the sockets failed,
returning at once: a caller waiting for a timer turned forever. What is
due is called all the same, and on Windows WSAEINTR is no failure, not
errno. looptest reports any failure to wait with its error; fibertest
starts Winsock, its loop could not be made without, and recurses into
half of a fiber's stack rather than most of it, which Windows takes
more of. The units case shows what a test which never finished wrote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:03:20 +03:00
Vladimir Dubrovin
4e1455b419 Fibers: stacks a thread switches to and back
src/fiber.c, not used yet: a fiber runs a function on a stack of its
own, yielding back to the thread which resumed it, for a thread to
serve many clients with code written as if each had a thread.

The switch is our own on x86-64 (System V), aarch64, i386, arm (ARM or
Thumb code, with or without a floating point unit) and mips o32 (with
soft, 32 or 64 bit floating point registers, position independent or
not), Windows' fibers on Windows. It saves the registers a function
has to keep and swaps the stack. Elsewhere there are no fibers
(WITH_FIBERS is not defined), nor where x86-64 Linux checks returns
against a shadow stack, which fiberusable() tells: each client has a
thread there.

Stacks are mapped with a guard page below them. A fiber whose function
returned is started again with another, its stack kept. Switches are
annotated for AddressSanitizer.

tests/fibertest.c tests them: yielding and resuming, the integer and
floating point registers a function keeps across switches both ways, a
thousand fibers resumed at random each keeping its stack, recursion
deep in a stack, fibers sleeping on the event loop's timers. The units
case, which was the loop case, runs it with looptest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:51:40 +03:00
Vladimir Dubrovin
cc98c498fe An event loop: sockets and timers for one thread
src/loop.c, not used yet: one thread waiting for many sockets and
timers, calling back what waits for them, for clients to be served by
a thread each serves many of.

Sockets are waited for with epoll on Linux, kqueue on the BSDs and
macOS, poll() (WSAPoll() on Windows) elsewhere or with LOOP_POLL,
level-triggered; one with nothing to wait for is taken from the system,
or an error or a hang up would be reported all the same. One deleted
while the sockets ready are being called is not called.

Timers wait in a wheel of 1024 one-second slots. One due within a turn
waits at the head of the slot of its second, all there due in the same
second; one due later behind them, in the order of their seconds, or,
if due before the last there, at the head of the last slot of the
turn, looked at again then. Moving a timer later costs nothing while
it waits in the wheel: it is looked at when its old deadline comes and
moved then. When its slot comes a timer due within the second waits in
a heap, so it fires to the millisecond, as does one set within the
second. The clock is monotonic.

Another thread posts to the loop, waking it with an eventfd, a pipe, or
on Windows a UDP socket sending to itself.

tests/looptest.c tests it on its own, timers on a clock of its own
through several turns of the wheel: built by CMake, run by the loop
case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:30:13 +03:00
Vladimir Dubrovin
fdebdb16dc tests: SOCKS BIND
The proxy accepts the connection expected and joins it with the
client both ways, and refuses one from another address than the one
asked for. socks_bind() and socks_bind_peer() in the harness make the
request and read the second reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:54:51 +03:00
Vladimir Dubrovin
fea5bae665 Services: threadperclient when names are resolved by the system
A service resolving names with the C library, sysresolve or by default
with no name server given or found, waits for it other than in
sockpoll(): each client has a thread. sysresolves() tells it for a
resolve mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:48:42 +03:00
Vladimir Dubrovin
0d6b2950f0 Sockets read after a wait are non-blocking, and a read finding nothing waits again
A wait may end with nothing to read: a TLS record not yet whole, a
connection gone before it is accepted, and, once clients share a
thread, nothing more than a chance to try. Where a read or an accept()
followed a wait on a blocking socket, or ended the operation when it
found nothing, the socket is non-blocking and the wait is made again:
the listening sockets of SOCKS BIND and of FTP passive mode, httpsrv
reading what is left of a request body, and the RADIUS client, which
reads with sockrecvfrom().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:34:46 +03:00
Vladimir Dubrovin
7718ffa9f7 ftppr: the data socket towards the client from the socket functions
It was made with socket(): in the main network namespace rather than
the -ni one the client is reached in, and past the socket functions of
the service, which the rest of its life went through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:34:46 +03:00
Vladimir Dubrovin
126c7d00ec Connect back: a connection one client takes leaves the others waiting
The clients of a -R service all wait for the connection back on the
same listening socket, which was blocking: one connection woke them
all, one took it, and the others were left in accept() until the next,
past their time out and through a reload. The socket is non-blocking,
and an accept() which finds nothing, the connection taken by another
client or gone, is not one of the client's tries. nonblock() makes a
socket non-blocking, acceptagain() tells such an accept().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:34:46 +03:00
Vladimir Dubrovin
bfd812f0b8 A client's sleeps wait in sockpoll()
The bandwidth limiter between HTTP requests and httpsrv's delay slept
with usleep(); they are waits of sockpoll() with no fds, to be made
other than by sleeping once a client is served by an event loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:34:32 +03:00
Vladimir Dubrovin
df993b5304 Services: threadperclient with PAM, LDAP or Windows authentication
struct auth has blocks, for an authentication waiting other than in
sockpoll(): PamAuth, LdapPlugin and WindowsAuthentication set it, and a
service authenticating with one of them gives each client a thread.

Incompatible for plugins, which have to be built again with the same
version of 3proxy: struct auth has blocks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:06:39 +03:00
Vladimir Dubrovin
5d3f1e9567 doc/changelog/2/0/0: 2.0 changes so far
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:54:16 +03:00
Vladimir Dubrovin
3e7f749eb6 Services: threadperclient, a thread for each client where one is needed
A flag of the service for what cannot share a thread between clients,
once clients can be served otherwise: what waits other than in
sockpoll(), or a thread which may be unable to serve another client.
It is set for a service
- with FilePlugin, whose _prepoll writes files and sends them;
- with a -ne network namespace: a thread which failed to come back from
  it to the main namespace is not to serve another client;
- writing to an ODBC log, should the log have no thread of its own to
  write it: struct logtype has blocks, for a log waiting other than in
  sockpoll().

Nothing serves clients otherwise yet: as before, each has a thread.

Incompatible for plugins, which have to be built again with the same
version of 3proxy: struct srvparam has threadperclient, struct logtype
has blocks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:54:03 +03:00
Vladimir Dubrovin
b4bbcb9e9a Socket functions: _poll becomes _prepoll, which never waits
A layer over the sockets, TLS or a plugin's, no longer waits itself.
Its _prepoll only tells what it knows without asking the system: the
fds it knows to be ready, data TLS has already decrypted, or, returning
0, that the sockets have to be polled, for events it may rewrite: a
TLS read waiting for its socket to be writable. The wait is
sockpoll()'s, which calls _prepoll first and then polls, so that a wait
can be made other than with poll() in one place: a client of an event
loop is to wait in the loop. Whatever a rewritten wait gets is given to
the caller as what it asked for, its chance to try again, and the
events it asked for are given back. With no fds sockpoll() sleeps,
which WSAPoll() would refuse.

TLS remembers what a read or a write it could not do waits for. A read
needing to write, or a write to read, was an error which ended the
connection, as when TLS 1.3 answers a key update while the socket
cannot take more; it now waits for what TLS needs, as one needing what
it was asked for always did.

Incompatible for plugins, which have to be built again with the same
version of 3proxy, the structures they share not kept compatible:
- struct sockfuncs has _prepoll(state, fds, nfds) in place of
  _poll(state, fds, nfds, timeout). A plugin replacing _poll replaces
  _prepoll: it must not wait, returns the number of fds it set revents
  of (events then left as they are), 0 for the fds to be polled (with
  events as it leaves them, revents left to the poll) or -1, and calls
  the _prepoll it replaced when it has nothing to say itself.
- Waiting for sockets is sockpoll(sf, state, fds, nfds, timeout), not
  so._poll() of the socket functions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:54:03 +03:00
Vladimir Dubrovin
718798c7fc FilePlugin: socksend() given no clientparam, not a TLS state for one
socksend() takes the client's parameters, and was passed the socket
functions' state instead: NULL, the global functions used then, as
intended, but on a TLS connection the state of its TLS layer, which
socksend() read as a clientparam.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:25:38 +03:00
github-actions[bot]
21b077364a Update version files for 1.1.0-pre1 2026-10-24 13:18:56 +00:00
Vladimir Dubrovin
532408a1b4 Release as 1.1.0-pre1 2026-10-24 16:18:42 +03:00
Vladimir Dubrovin
451b9b4c28 Release builds on Ubuntu 26.04: rpm signing, GNU install for OpenWrt
ubuntu-latest is 26.04 now. Its rpm 6 runs %__gpg_sign_cmd without
taking the word after %{__gpg} for argv[0], as rpm 4 does, so the
command of our own broke there; rpm's own is given the passphrase file
and the digest through %_gpg_sign_cmd_extra_args and %_gpg_digest_algo
instead, which both read. Each rpm is then checked by the rpm of the EL
release it is for, against 3proxy-release-key.asc.

The OpenWrt SDK refuses an install which is not GNU's. 26.04 has the
Rust coreutils, and GNU's as gnuinstall and the like: gnuinstall is
linked as install ahead in PATH. ipk also reads DEVEL when there is no
RELEASE, so it can be run on the development branch.

workflow_dispatch takes a sign input, to sign the packages as a release
does without uploading anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:42:20 +03:00
Vladimir Dubrovin
9ce5de971f Release workflows: a minimal set for a release of the development branch
Some checks are pending
C/C++ CI Linux / ${{ matrix.target }} (ubuntu-24.04-arm) (push) Waiting to run
C/C++ CI Linux / ${{ matrix.target }} (ubuntu-latest) (push) Waiting to run
C/C++ CI MacOS / ${{ matrix.target }} (macos-15) (push) Waiting to run
C/C++ CI Windows / ${{ matrix.target }} (windows-2022) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (macos-15) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (ubuntu-24.04-arm) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (ubuntu-latest) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (windows-2022) (push) Waiting to run
C/C++ CI cmake / ubuntu-latest (wolfSSL) (push) Waiting to run
A release whose tree has DEVEL and no RELEASE is one of the development
branch (release-kind.yml, called by the workflows below). For it only
the Windows x64 zip, the x86_64 and arm64 deb packages and the rpm for
EL 9 and 10 (x86_64 and arm64) are built and added to the release: no
Docker images, no source tarball, no ipk, armhf, watcom, win32 or
Windows arm64 builds. docker.yml makes it a prerelease, not "Latest",
which also keeps it out of the package repository 3proxy.github.io
builds from releases.

The packaging reads the version from RELEASE or DEVEL. rpm names the
source archive and its directory after the spec's Version, which for
2.0.0-devel is 2.0.0~devel, so that %setup finds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:10:59 +03:00
Vladimir Dubrovin
7fab9c488d Reattach: a new service's slot for its next client taken before it is found
A TCP service keeps one of its maxconn slots for the next client it
accepts, but took it only once its thread reached the accept loop. A
reload puts the new service on its listener (mainclaim(), in the thread
reading the configuration) before its thread even starts, and the
clients of the old services move to it as soon as they see it there: in
that window they could take every slot, the one it keeps included. The
reattach test, with maxconn 2, then had both of two downloads go on
instead of one, now and then on a loaded runner, every time with a
300 ms sleep put in the window.

mainclaim() takes the slot first; srvrun() takes it over, and
srvdiscard() gives it back for a service which never ran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:00:29 +03:00
Vladimir Dubrovin
2052dc0a4f Logs: a thread and a queue for each log; build-time defaults
Once 3proxy runs (after the first configuration is in force, so after a
daemon's fork), each logger has a thread of its own writing from a
queue, and a client never waits for a log: ODBC, RADIUS, a full disk or
a pipe nobody reads hold up only that log's thread.

A queued line is fixed size: the part of struct clientparam logging
reads (everything before the filters: addresses, counters, names and
credentials, which hold no pointer it follows but srv), the time it was
logged, and its text, 256 bytes inline or a copy of its own if longer.
The thread formats it: struct tm is per call now, timechanged() and
dologname() included, and the type's format() takes the time.

minlogqueue (64) slots are allocated as the thread starts and kept,
maxlogqueue (4096) lines are queued at most. A line which finds the
queue full is dropped and counted on the last one queued; once that is
written, "Log X has its queue full, N records dropped" goes to where
errors go. A log which fails is no longer written to the error log
instead: its lines are dropped and counted while it is retried every
minute, and the count is told when it is open again, or when it was
reopened after failed writes.

A service which ended is stopped at once (srvstop(): sockets, listener,
its clients gone), but freed (srvrelease()) through its log's queue,
behind the lines which refer to it and to its configuration: the event
is part of struct srvparam, so a full queue never drops it. logfinish()
writes what is queued, 10 seconds at most, also as 3proxy exits on Unix.
Standalone services write as before, as each line comes.

LOGQUEUEMIN, LOGQUEUEMAX, DEFAULT_MAXCHILD, DEFAULT_POOLMAX,
DEFAULT_POOLTIMEOUT and POOLPERSERVICE (the automatic thread pool cap
for each service, which was 32 in the code) can be set with -D at build
time.

Tests: a log nobody reads (a FIFO) holds no client up, drops past its
queue and says so, and lines queued for a service a reload removed are
written as it formatted them; /dev/full says what it dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:41:48 +03:00
Vladimir Dubrovin
a3c417c82a Logs: the error of opening a log returned, not passed in errno
Some checks are pending
C/C++ CI Linux / ${{ matrix.target }} (ubuntu-24.04-arm) (push) Waiting to run
C/C++ CI Linux / ${{ matrix.target }} (ubuntu-latest) (push) Waiting to run
C/C++ CI MacOS / ${{ matrix.target }} (macos-15) (push) Waiting to run
C/C++ CI Windows / ${{ matrix.target }} (windows-2022) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (macos-15) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (ubuntu-24.04-arm) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (ubuntu-latest) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (windows-2022) (push) Waiting to run
C/C++ CI cmake / ubuntu-latest (wolfSSL) (push) Waiting to run
On Windows proxy.h makes errno WSAGetLastError(), which cannot be
assigned, so the Windows build failed, and the error of fopen() would
not have been read there either. A type's init returns the errno value
(the C library's, read with _get_errno() on Windows), and logget() gives
it to its caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:20:56 +03:00
Vladimir Dubrovin
30e2e34dd6 Sockets: sends with MSG_NOSIGNAL where the system has it
A peer gone is then an error of the send rather than SIGPIPE. SIGPIPE
stays ignored for what cannot pass the flag: TLS writes and splice().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:23:04 +03:00
Vladimir Dubrovin
83d8138818 Tests: udploop's silence counted from the last answer
The last datagram's time was taken after the sleep following it, half a
second and a slow runner's overshoot late, so a session ending on time
looked early. The test resolver no longer raises when its socket is
closed under it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:56:38 +03:00
Vladimir Dubrovin
e619a105b4 MITM: a serial number of its own when wolfSSL cannot copy the source's
wolfSSL built with WOLFSSL_QT, as Ubuntu 26.04's 5.9.1 is, returns a
serial number without the ASN.1 header its X509_set_serialNumber()
requires, so every certificate copy failed. The copy then gets a
positive serial number from the hash of the source certificate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:56:38 +03:00
Vladimir Dubrovin
f9b56fc15d SIGPIPE ignored before any service starts, and in standalone services
3proxy ignored SIGPIPE only after readconfig(), which starts the
services: a client gone while written to in the meantime killed the
process (as auto's case saw on macOS). Standalone services never ignored
it. Sends pass no MSG_NOSIGNAL, so this is the only guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:56:38 +03:00
Vladimir Dubrovin
477a2d3822 Logs: an ODBC log named by its data source, user and password its parameters
The string up to the first comma names an ODBC log; the user and the
password are kept apart as its parameters, never shown, and do not tell
logs apart. Another log or -l naming the same data source with other
parameters gives them to the logger, which reconnects with them. The
password is whatever follows the comma after the user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:08:06 +03:00
Vladimir Dubrovin
e87ffcd7e1 Logs: no ODBC user and password in messages about a log
A log string is shown only up to its first comma, where an ODBC string
goes on with the user and the password: in the message on a log which
failed, and in the errors on a log or -l which cannot be opened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:47:23 +03:00
Vladimir Dubrovin
d613750b94 Logs: a logger for each log string, shared and closed once unused
A logger (struct logger) is identified by the string log or -l gives it:
"" stdout, "/dev/null" none, "@IDENT" syslog, "&DSN" ODBC, "radius",
anything else a file. Each type (struct logtype) has init, formatting of
a connection into a buffer, writing, close, and a periodic tick for
rotation and flushing; ODBC and RADIUS register their types from 3proxy,
which alone has them. Services and configurations hold the logger they
use; the last to let go closes it, except the standard ones (none,
stdout, stderr, syslog), which are opened at startup and kept.

-l takes every log type now, and a file named by several services or by
both log and -l is opened once. rotate and archiver are bound to the
logger of the last log command when its configuration comes into force,
and every file logger is rotated by the main loop.

Errors with no connection go to a new stderr logger; after daemon, or as
a Windows service, stdout logging writes nothing and errors go to the
last log configured. A log whose writes keep failing is closed and
reopened; while it cannot be, its lines go to stderr and it is retried
every minute. ODBC's own reconnection throttle gives way to this.

Formatting (logbuf) is separate from counting traffic and clearing the
statistics, done once by logdo(), the services' log function, so a later
step can format or copy a connection's line and write it from a thread
of its own. dologname() and timechanged() move to log.c, which the
standalone services link as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:44:55 +03:00
Vladimir Dubrovin
57818ad497 UDP loop: deadlines in a heap, on a monotonic clock
Some checks are pending
C/C++ CI Linux / ${{ matrix.target }} (ubuntu-24.04-arm) (push) Waiting to run
C/C++ CI Linux / ${{ matrix.target }} (ubuntu-latest) (push) Waiting to run
C/C++ CI MacOS / ${{ matrix.target }} (macos-15) (push) Waiting to run
C/C++ CI Windows / ${{ matrix.target }} (windows-2022) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (macos-15) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (ubuntu-24.04-arm) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (ubuntu-latest) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (windows-2022) (push) Waiting to run
C/C++ CI cmake / ubuntu-latest (wolfSSL) (push) Waiting to run
The loop went through every session three times a turn: for the nearest
deadline, to register its sockets with epoll, and for the deadlines come
and the sessions ended. A datagram cost as much as the sessions there
were, idle ones too.

- The deadlines are in a binary heap, the nearest at its top: the wait
  is until the top, and the sessions timed out are taken from the top
  down to the first not due. A datagram only moves a session's deadline
  later, which leaves the heap as it is: the session is looked at again
  when its old deadline comes, and put back where its deadline is then,
  once in its timeout however many datagrams it had. A deadline set
  earlier by a handler goes up the heap.
- A session ended is put on a list and let go at the end of the turn,
  and its sockets are registered with epoll after the loop or a handler
  had it, not every session every turn.
- Deadlines are milliseconds of monotime(), CLOCK_MONOTONIC (the
  performance counter on Windows), which the time of day being set does
  not move: before, a clock set back kept sessions for as long, and one
  set forward ended them all. The loop reads it once a turn, in u->now.

With epoll, a datagram costs the same with 20000 idle sessions as with
none (38-40 us of the proxy's CPU a round trip, ARM), where it took 59
before. poll() elsewhere still builds its set every turn.

Tests: a session heard from every half second lives through two seconds
of idle timeout, and ends two seconds after the last datagram.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:53:06 +03:00
Vladimir Dubrovin
f69eb13e9a sockrecvfrom: poll again when a readable socket has nothing to read
Some checks are pending
C/C++ CI Linux / ${{ matrix.target }} (ubuntu-24.04-arm) (push) Waiting to run
C/C++ CI Linux / ${{ matrix.target }} (ubuntu-latest) (push) Waiting to run
C/C++ CI MacOS / ${{ matrix.target }} (macos-15) (push) Waiting to run
C/C++ CI Windows / ${{ matrix.target }} (windows-2022) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (macos-15) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (ubuntu-24.04-arm) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (ubuntu-latest) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (windows-2022) (push) Waiting to run
C/C++ CI cmake / ubuntu-latest (wolfSSL) (push) Waiting to run
sockrecvfrom() polled once and then read in a loop for as long as the
read failed with EAGAIN. Over TLS a socket is readable when only a part
of a record has come, and the read fails with EAGAIN until the rest
does: a client sending half a record and then nothing kept a thread
taking all of a CPU, and the read never timed out. On EAGAIN the socket
is now polled again, the read repeated at once only for EINTR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:03:21 +03:00
Vladimir Dubrovin
2514fb3dbf TLS: a part of a record read ahead is not pending data
SSL_has_pending() is true when only a part of a record has been read
ahead, which is no data yet. ssl_poll() took it for data: poll()
returned at once, over and over and without its timeout, until the rest
came, so a peer sending half a record kept a thread spinning and its
connection from timing out. ssl_pending() is now true only when
decrypted data waits, or when what was read ahead makes a whole record
(an SSL_peek() of one byte, the sockets not blocking); otherwise the
socket is polled for the rest.

splice() over kernel TLS still needs to know of any byte OpenSSL holds,
a part of a record too: ssl_buffered() tells it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:05:02 +03:00
Vladimir Dubrovin
3d20e1104e resolver: per service, defaultresolve, fakeresolve or sysresolve
Some checks are pending
C/C++ CI Linux / ${{ matrix.target }} (ubuntu-24.04-arm) (push) Waiting to run
C/C++ CI Linux / ${{ matrix.target }} (ubuntu-latest) (push) Waiting to run
C/C++ CI MacOS / ${{ matrix.target }} (macos-15) (push) Waiting to run
C/C++ CI Windows / ${{ matrix.target }} (windows-2022) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (macos-15) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (ubuntu-24.04-arm) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (ubuntu-latest) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (windows-2022) (push) Waiting to run
C/C++ CI cmake / ubuntu-latest (wolfSSL) (push) Waiting to run
Each service resolves names the way the last of defaultresolve (the name
servers in force, configured or the system's with the hosts file),
fakeresolve (127.0.0.2) and sysresolve (the C library) read before it
says; the last one in the configuration also applies outside services.
defaultresolve is the new command, and the default.

The name servers and the hosts file no longer depend on the mode: nserver
lines, or the system's, are always in force, so dnspr keeps forwarding;
with fakeresolve it does not, as before. fakeresolver moves to common.c,
which the standalone services link without resolve.c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:45:48 +03:00