41 of 112 configuration commands appear in a test. Record the rest as a
plan, ordered by how much of the product each gap covers against how much
of a fixture it needs, so the next person does not have to redo the survey.
Includes the two limitations left deliberately unasserted, and why pinning
the current behaviour there would be wrong.
tcppm and udppm forward a port each, so test both directions of each: a
request through the TCP mapper reaching its target, a datagram through the
UDP one coming back answered, and a mapper whose rules deny the client
answering nothing.
tlspr takes its destination from the name in the handshake, so point that
name at 127.0.0.1 with nsrecord and give the certificate the same name: the
name then both chooses where the request goes and is what the client checks.
dnspr answers from its cache, including the documented 0.0.0.0 record, which
is handed out as an address rather than withheld.
auto is asked to serve an HTTP proxy request, SOCKS4, SOCKS5 and a TLS
handshake on one port, and to make nothing of a request that is none of
them. Its protocols reach different places, so there are two origins.
The SOCKS UDP association was only checked for the port it binds. Send
datagrams through it as well, large and small, and check a second
association gets its own port - and that one bound inside an intport range
still relays.
A UDP service has no socket to connect to, so readiness is found by asking
until it answers rather than racing it.
A rewrite only reached the copy of the request kept for logging. On a
direct connection the server is sent the request line held in the header
buffer, which was parsed and reduced to its path before the filters ran, so
the rewrite showed up in the log and nowhere else. Through an HTTP parent
the rewritten copy is what goes out, which is why it appeared to work
there.
Splice the new path back into that buffer. The destination was chosen, and
the access rules applied to it, before the rewrite happened, so a rewrite
that changes the method or the authority is left alone: acting on it would
send the request somewhere the rules never saw. Say so in the manual.
The copy needed to notice a rewrite is only taken when a request filter
exists, so a proxy without one does no extra work.
The filter walked its list of access rules but tested the first entry each
time round, so anything pcre_extend appended was never consulted and the
command did nothing at all. Test the entry the loop is on.
pcre_extend takes an ACE and no FILTER_ACTION - the rule keeps the action it
was given - so correct the manual, which documented an argument the command
does not read.
The tests cover matching and denial, rule order, client headers, matching
options, the access rule a pcre rule carries, pcre_extend, and rewriting a
reply header and reply data. Request rewriting is covered through an HTTP
parent, which is the path where it reaches the wire.
The CA was created with no extensions, so it is not usable as a CA and
clients report that they cannot get the local issuer certificate. Add
basicConstraints, keyCertSign and a subject key identifier, in a file
rather than through -addext, which LibreSSL - the openssl on macOS and some
BSDs - does not apply the same way.
Ask for the key identifiers on the signed certificates too: OpenSSL 3 adds
them when it signs and LibreSSL does not, and Python has verified strictly
since 3.13, refusing a chain whose certificate carries no
authorityKeyIdentifier. Finish with openssl verify -x509_strict, which is
the check the client will make.
Both recipes were run against OpenSSL 3.6 and LibreSSL 3.3: the old one
fails strict verification, the new one passes on both.
OpenSSL 3 adds a subject and authority key identifier when it signs;
LibreSSL, which is the openssl on a stock macOS, does not. Python has
verified strictly since 3.13 and rejects a chain whose certificate has no
Authority Key Identifier, so the macOS runners refused a certificate the
Linux ones accepted. Ask for both by name, and make the self-check strict
so the next such gap is caught before a handshake.
httpsrv parsed Content-Length and never read what followed. The reply is
followed by a close, and closing a socket that still holds unread data
resets the connection instead of ending it, so a POST could cost the client
the reply it was about to read. Windows does that reliably; the same test
passes on Linux and macOS, which is why it looked flaky. Drain the body,
bounded at a megabyte.
The test CA was built with -addext, which LibreSSL - the openssl on a stock
macOS - does not apply the same way, leaving a certificate that is not
usable as a CA and a client that cannot build a chain to it. Put the
extensions in a file both accept, and verify the generated chain before any
of it is handed to a proxy, so a failure there is not read as a fault in
the proxy.
The MITM case reached its origin by address, so it depended on the
certificate 3proxy spoofs carrying an IP alternative name. It does when the
upstream certificate is copied, which is what happens on Linux and macOS
but not on Windows, where the client then refused the connection. Point a
name at 127.0.0.1 with nsrecord instead, and check the chain rather than
the name: an intercepted certificate names the upstream host, not the one
that was asked for. The log assertions gain from it too, since the name is
better evidence than a port that the request was seen.
nsrecord needs nserver as well as nscache, and has to follow nscache, so
say that in the manual: the record goes into the table nscache allocates,
and the table is only consulted when nserver is set.
The port-range fallback was exercised with a range the same case had
already used, so on a busy machine it could fail to bind for the ordinary
reason rather than the one under test. Use privileged ports, which nothing
can take.
When a case fails, print what its servers wrote: the reason usually goes to
the server's stderr, which was captured and then thrown away.
Three arrangements, with key material generated for the run rather than
kept in the tree: a proxy wrapped in TLS, a proxy that reaches a TLS parent
and verifies it against the CA, and MITM.
The MITM case checks what interception is for: the decrypted request line,
URI and all, reaches the log, where the same request through a plain
CONNECT tunnel leaves only the host and port.
The origin runs in its own process there so the proxy log holds only what
the proxy saw, and log assertions wait, since a record is written when the
connection finishes rather than when the reply arrives.
Verification of the spoofed certificate is deliberately not strict: 3proxy
issues those without an Authority Key Identifier, which Python rejects
under its 3.13 defaults.
The cmake Windows build links vcpkg's pcre2, whose DLL lives in
c:/vcpkg/installed/x64-windows/bin. Running the tests without that on PATH
started a 3proxy that died at load with 0xC0000135 before opening a socket,
which the suite could only report as "never listened".
Report the reason instead of leaving it blank: name the exit code, and say
when a body is empty because the request failed rather than because the
reply carried nothing.
The release carried nothing installable on a router. Build the OpenWrt package
in the SDK for the four architectures that cover most consumer hardware:
mipsel_24kc ramips, MediaTek MT7620/MT7621
mips_24kc ath79, Atheros/QCA
arm_cortex-a7_neon-vfpv4 ipq40xx
aarch64_cortex-a53 mediatek filogic and similar
Linking against the distribution's libraries rather than building static keeps
the package near 100kB and gives it TLS and PCRE support.
The SDK file name carries the toolchain flavour and differs between targets -
ipq40xx is musl_eabi where the others are musl - so it is taken from the
directory listing instead of being assembled from the target name.
The package is built from the checked out tree rather than the published
release archive: the workflow runs when a release is created, and depending on
GitHub having generated that archive already would be a race.
opkg verifies the signature of a feed index and never of a package file, so
the packages are published with checksums and a signature over them, in the
same shape as the other artifacts, and are not signed in any opkg specific
way.
3proxy is not in the OpenWrt feed, and the static binaries built here install
nowhere in particular: they carry no service definition, no configuration and
no way to survive a sysupgrade.
Add the package as it would be submitted to openwrt/packages - a Makefile, a
procd init script and a UCI schema. Built in the 24.10 SDK for mipsel_24kc
against the distribution's libraries, so the result is a 220kB package
depending on libopenssl and libpcre2 rather than a static binary, with TLS and
PCRE support actually present.
3proxy.cfg is order dependent, which is the whole difficulty of generating it
from UCI, where sections are unordered:
- access rules are named sections referenced by a service through an ordered
list, so the reference order decides precedence, and the list is flushed
before each service so rules do not leak into the next one
- parent proxies extend the allow rule they follow, so they hang off the
access rule rather than the service
- the TLS switches apply to every service below them, so the generator
tracks what is in effect and emits a directive only when a service needs a
different state, rather than letting certificate spoofing leak into a
service that did not ask for it
- bandwidth, connection and counter limits are global and match on their own
ACL pattern, so they are a separate ordered list
Nothing is enabled by default: the global switch is off, no service ships
enabled, and a service section that omits the option does not start either, so
installing the package opens no ports.
Values that would make 3proxy reject the whole configuration at boot are
checked while writing it - unknown access actions, limiter types, pcre types
and actions - and reported instead of being passed through.
The wiki pages were copies of doc/html made by a script run by hand outside
this repository, so the wiki drifted from master whenever that step was
skipped.
Copy them from a workflow instead, taking master as the source and committing
only when a page actually changed.
GitHub wikis cannot run Actions, so the workflow lives here and pushes to the
wiki repository rather than the other way round.
It runs on a push touching doc/html, and also after the documentation workflow
finishes: that workflow commits with GITHUB_TOKEN, and GitHub does not raise
push events for those commits, so a push trigger alone would never see
regenerated HTML.
The HTML under doc/html was produced by a script kept outside this repository,
so editing a man page and refreshing its HTML were separate manual steps and
the two could drift apart.
Run the same groff conversion in a workflow triggered by a change under man/.
It rebuilds doc/html/index.html and the man5 and man8 pages, and commits only
when something actually changed.
Output was verified byte for byte against the committed tree: all twelve man
pages and index.html are reproduced exactly.
The build date stored in version.h is now the single time source for a
release. Each package workflow converts it to SOURCE_DATE_EPOCH and passes
that into the build containers, the debian changelog entry is stamped from it
rather than from the build clock, and rpmbuild is told to use it as the build
time and to clamp file mtimes to it.
rpm also records the build host, which is a container id and therefore differs
on every run, so pin it as well - otherwise the timestamps alone do not make
the package reproducible.
Rebuilding the same commit now produces byte identical packages, verified for
both formats by building twice and comparing checksums.
In the version workflow the clock is read exactly once, when a new build date
is minted. YEAR3PROXY is derived from the build date instead of being read
separately, so the two cannot straddle a year boundary and a re-run in a later
year no longer rewrites version.h.
The version files were derived from RELEASE by a script kept outside this
repository, so bumping RELEASE and updating src/version.h, the spec version
and debian/changelog were separate manual steps.
Do it in a workflow triggered by a change to RELEASE. It writes the same
content the external script produced, verified byte for byte against the
current tree.
The build date is now minted once, when RELEASE changes, and stored in
version.h rather than taken from the clock of whichever build is running. The
package workflows read it from there, so rebuilding a commit produces the same
version string and the same package file names. Re-running the workflow
without a RELEASE change keeps the existing build date and makes no commit.
The rpm build runs in a container as root, so the packages arrived in the
workspace owned by root. Renaming them still worked, since that only needs
write permission on the directory, but rpm --addsign rewrites the file in
place and failed:
error: 3proxy-0.9.9.0.el9.x86_64.rpm: open failed: Permission denied
Restore the invoking user's ownership before leaving the container, falling
back to a mode change where the container cannot chown, as under a rootless
container runtime.
dpkg-buildpackage ran on the runner itself, so the packages inherited its ABI.
On ubuntu-latest that means glibc 2.38 and libssl3t64, which restricts the
packages to Ubuntu 24.04 and newer - Debian 12, Debian 13 and Ubuntu 22.04
cannot install them.
Nothing in the source needs glibc 2.38. The floor comes from the build host:
gcc 13 with glibc 2.38 redirects sscanf and strtol to __isoc23_ variants, and
arc4random arrived in glibc 2.36.
Build in an Ubuntu 22.04 container instead. That lowers the dependencies to
libc6 (>= 2.34) and libssl3, and libssl3t64 declares Provides: libssl3, so one
package covers Ubuntu 22.04 and later as well as Debian 12 and later. Verified
by installing on all four.
The armhf cross build moves into the same container, keeping the cross
toolchain on PATH so that install -s uses the ARM strip rather than the host
one.
No published release was ever signed with it - signing was introduced after
0.9.9 - so it verifies nothing and only adds a second key for users to reason
about.
rpmbuild ran directly on the Ubuntu runner, so the packages carried Ubuntu's
ABI: glibc 2.38, libssl.so.3 and libpcre2-8. No RPM distribution can satisfy
that - RHEL 9 has glibc 2.34, RHEL 8 has 2.28 and OpenSSL 1.1 - so the rpms
were installable nowhere.
Build them in AlmaLinux 8, 9 and 10 containers instead, covering supported
AlmaLinux and CentOS Stream releases. Release: 1%{?dist} already in the spec
now expands, so the packages are named el8, el9 and el10 and can coexist.
Two spec changes are needed for a build outside Ubuntu:
debug_package is disabled. Enterprise Linux enables debuginfo extraction by
default while Ubuntu's rpm does not; the build produces no debug sources, so
rpmbuild aborted on an empty debugsourcefiles.list.
/bin/3proxy is excluded from automatic requires. The installed configuration
file is executable and begins with #!/bin/3proxy, so rpm generates a
dependency on it. rpm 4.16 and later canonicalise that to /usr/bin/3proxy
while %files declares /bin/3proxy, leaving a dependency that can never be
satisfied and making the package uninstallable on el9 and el10.
Enterprise Linux has no 32-bit ARM, so the armhf workflow now builds only the
deb package.
The OpenSSL, wolfSSL, PCRE2 and PAM probes built their test program with
echo "...\n...". make runs recipes through /bin/sh, which is dash on Debian
and Ubuntu, where the builtin echo expands \n. On distributions where /bin/sh
is bash - every RPM based distribution, and macOS - it does not, so the probe
compiled
#include <openssl/ssl.h>n int main(){return 0;}
which is not valid C. Every probe therefore failed and the build silently
dropped TLS, PCRE2 and PAM support with no diagnostic.
Use printf, which expands escapes the same way everywhere.
rpm 4.14 (RHEL/CentOS/Rocky 8) cannot import an Ed25519 public key at all:
the import fails and package verification reports SIGNATURES NOT OK. RHEL 8
is supported until 2029 and is a realistic target for a signed package repo,
so the Ed25519 key excludes a large part of the rpm audience.
Publish an RSA-4096 signing key as 3proxy-release-key.asc and keep the old
Ed25519 key as 3proxy-release-key-ed25519.asc so artifacts up to 0.9.9 stay
verifiable. Workflows derive the key id from the imported secret key, so no
workflow change is needed.
The release key is ed25519, so rpm stores the header signature in
DSAHEADER (EdDSA/SHA256) and RSAHEADER is empty. The verification only
looked at RSAHEADER and failed correctly signed packages.
Do not rely on the gpg-agent passphrase cache surviving between steps:
the signing command gets the passphrase from a file. A test signature is
made first, so a wrong passphrase or a key which can not sign fails with
a clear gpg error instead of an rpm which is silently left unsigned.
On failure the rpm version, the sign command and the key capabilities are
printed.
rpm on Debian/Ubuntu defaults %__gpg to /usr/bin/gpg2, which does not exist,
so rpm --addsign failed with 'Could not exec gpg' and packages were left
unsigned.