From 38b62bd30bb26c673819716cd92315d1b3bc16e2 Mon Sep 17 00:00:00 2001 From: Vladimir Dubrovin <3proxy@3proxy.ru> Date: Fri, 30 Oct 2026 14:17:54 +0300 Subject: [PATCH] help: the features of the build listed, and the tests skip what they need 3proxy --help (and the usage message of a bad invocation) now lists what the build was made with: TLS and its backend, RADIUS, PCRE, IPv6, UNIX sockets, the built-in HTTP server, transparency, ODBC and the optional proxy services. The test runner reads the list before the first case and the cases state the features they need with t.skip_unless(), so a build without a feature skips its cases instead of failing them; a binary too old to report anything skips the cases with needs and runs the rest. --- src/3proxy.c | 34 +++++++++++------ src/mainconf.c | 63 ++++++++++++++++++++++++++++++++ src/proxy.h | 1 + tests/README.md | 8 ++++ tests/cases/admin.py | 2 + tests/cases/auto.py | 4 +- tests/cases/counters.py | 2 + tests/cases/ftppr.py | 2 + tests/cases/httpsrv_auth.py | 2 + tests/cases/httpsrv_files.py | 2 + tests/cases/httpsrv_keepalive.py | 2 + tests/cases/httpsrv_ops.py | 2 + tests/cases/httpsrv_parsing.py | 2 + tests/cases/httpsrv_proxypass.py | 2 + tests/cases/httpsrv_rules.py | 2 + tests/cases/ipv6.py | 2 + tests/cases/ktls.py | 2 + tests/cases/logs.py | 2 + tests/cases/maxconn.py | 2 + tests/cases/parent_failover.py | 2 + tests/cases/parent_ports.py | 2 + tests/cases/parent_weights.py | 2 + tests/cases/pcre.py | 2 + tests/cases/portmap.py | 2 + tests/cases/proxy_http.py | 2 + tests/cases/reattach.py | 2 + tests/cases/reload.py | 2 + tests/cases/socks.py | 2 + tests/cases/splice.py | 2 + tests/cases/ssl.py | 2 + tests/cases/sysdns.py | 2 + tests/cases/takeover.py | 2 + tests/cases/tlspr.py | 2 + tests/cases/transparent.py | 2 + tests/cases/unixsock.py | 2 + tests/harness.py | 22 ++++++++++- tests/run.py | 35 +++++++++++++++++- 37 files changed, 212 insertions(+), 15 deletions(-) diff --git a/src/3proxy.c b/src/3proxy.c index 60769b5..1af109f 100644 --- a/src/3proxy.c +++ b/src/3proxy.c @@ -296,6 +296,21 @@ void cyclestep(void){ #define RETURN(x) {res = x; goto CLEARRETURN;} +static void printusage(struct extparam *cconf, int argc, char * argv[]){ + + fprintf(stderr, "Usage: %s [conffile]\n", argv[0]); +#ifdef _WIN32 + fprintf(stderr, "\n\t%s --install [conffile]\n\tto install as service\n" + "\n\t%s --remove\n\tto remove service\n", argv[0], argv[0]); +#else + fprintf(stderr, "\n if conffile is missing, configuration is expected from stdin\n"); +#endif + fprintf(stderr, "\n\t%s --help\n\tthis message\n", argv[0]); + fprintf(stderr, "available socket options:\n\t%s\n", printopts("\n\t")); + fprintf(stderr, "built with:\n\t%s\n", printfeatures("\n\t")); + fprintf(stderr, "\n%s %s\n%s\n", cconf->stringtable[2], cconf->stringtable[3], copyright); +} + #ifndef _WINCE int main(int argc, char * argv[]) { @@ -338,6 +353,10 @@ int WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLine, int #endif conf->stringtable = strings; + if(argc == 2 && (!strcmp((char *)argv[1], "--help") || !strcmp((char *)argv[1], "-h"))) { + printusage(conf, argc, argv); + return 0; + } #ifdef _WIN32 #ifndef _WINCE if((argc == 2 || argc == 3)&& !strcmp((char *)argv[1], "--install")) { @@ -448,20 +467,11 @@ int WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLine, int if(!fp) fp = stdin; #endif } - if(argc > 2 || !(fp)) { - - fprintf(stderr, "Usage: %s [conffile]\n", argv[0]); -#ifdef _WIN32 - fprintf(stderr, "\n\t%s --install [conffile]\n\tto install as service\n" - "\n\t%s --remove\n\tto remove service\n", argv[0], argv[0]); -#else - fprintf(stderr, "\n if conffile is missing, configuration is expected from stdin\n"); -#endif - fprintf(stderr, "available socket options:\n\t%s\n", printopts("\n\t")); - fprintf(stderr, "\n%s %s\n%s\n", conf->stringtable[2], conf->stringtable[3], copyright); + if(argc > 2 || !(fp)) { + printusage(conf, argc, argv); return 1; - } + } #ifdef __linux__ nsopen(); /* before a chroot in the configuration takes /proc away */ diff --git a/src/mainconf.c b/src/mainconf.c index 6bc4137..acb64d4 100644 --- a/src/mainconf.c +++ b/src/mainconf.c @@ -86,6 +86,69 @@ char * printopts(char *sep){ } +char featsbuf[1024]; + +/* The features of this build, by the names the manual and the test suite + know them, so a user can see what a binary was made with. */ +char * printfeatures(char *sep){ + int pos=0; + +#define ADDFEATURE(x) pos += sprintf(featsbuf+pos,"%s%s",pos?sep:"",x) +#ifdef WITH_SSL + ADDFEATURE("ssl"); +#ifdef WITH_WOLFSSL + ADDFEATURE("wolfssl"); +#else + ADDFEATURE("openssl"); +#endif +#endif +#ifndef NORADIUS + ADDFEATURE("radius"); +#endif +#ifdef WITH_PCRE + ADDFEATURE("pcre"); +#endif +#ifndef NOIPV6 + ADDFEATURE("ipv6"); +#endif +#ifdef WITH_UN + ADDFEATURE("unix"); +#endif +#ifdef WITH_HTTPSRV + ADDFEATURE("httpsrv"); +#endif +#ifdef WITH_TRANSPARENT + ADDFEATURE("transparent"); +#endif +#ifdef WITH_NETFILTER + ADDFEATURE("netfilter"); +#endif +#ifdef WITH_PF + ADDFEATURE("pf"); +#endif +#ifdef WITH_ODBC + ADDFEATURE("odbc"); +#endif +#ifdef WITH_FTP + ADDFEATURE("ftppr"); +#endif +#ifdef WITH_POP3P + ADDFEATURE("pop3p"); +#endif +#ifdef WITH_SMTPP + ADDFEATURE("smtpp"); +#endif +#ifdef WITH_IMAPP + ADDFEATURE("imapp"); +#endif +#ifndef NOPLUGINS + ADDFEATURE("plugins"); +#endif +#undef ADDFEATURE + return featsbuf; +} + + /* A comma separated list of options, each NAME or NAME=VALUE; a name alone stands for 1, or for TCP_MAXSEG for the value of maxseg. Options are added to what is already set, so a default is changed only by naming it, and diff --git a/src/proxy.h b/src/proxy.h index 0cbb5a5..269397b 100644 --- a/src/proxy.h +++ b/src/proxy.h @@ -627,6 +627,7 @@ extern struct socketoptions { int getopts(const char *s, struct sockoptset *opts); void setopts(SOCKET s, const struct sockoptset *opts, int maxseg); char * printopts(char *sep); +char * printfeatures(char *sep); #ifdef _WINCE char * CEToUnicode (const char *str); diff --git a/tests/README.md b/tests/README.md index 5ef149b..9f11274 100644 --- a/tests/README.md +++ b/tests/README.md @@ -13,6 +13,14 @@ skips itself when that is missing or the build has no TLS support. With no `--bin` it looks in `bin/`, then `build/bin/`, then the per-configuration directories a multi-configuration CMake generator uses. +The binary reports what it was built with in `3proxy --help` (a `built +with:` list), and the runner reads that before the first case: a case names +the features it needs with `if not t.skip_unless("ssl"): return`, and +skips on a build without them instead of failing. A binary too old to +report its features skips every case that needs one, and the cases with no +feature needs still run; some cases also probe at runtime, as a build can +leave a feature compiled in but unusable. + The proxy under test is also the origin server the tests talk to: the `http` command's `echo` operation reports back how a request arrived - method, path, query, host, and the source port it came from - and `data` generates a body diff --git a/tests/cases/admin.py b/tests/cases/admin.py index cec469c..e2fc5cb 100644 --- a/tests/cases/admin.py +++ b/tests/cases/admin.py @@ -4,6 +4,8 @@ import time def run(t): + if not t.skip_unless("httpsrv"): + return adm = t.free_port() lim = t.free_port() server = t.start("admin", f""" diff --git a/tests/cases/auto.py b/tests/cases/auto.py index eb7c409..684db26 100644 --- a/tests/cases/auto.py +++ b/tests/cases/auto.py @@ -7,7 +7,9 @@ handshake and the service supplies the port. def run(t): - certs = t.certs() + if not t.skip_unless("httpsrv"): + return + certs = t.certs() if t.has("ssl") else None plain = t.free_port() port = t.free_port() secure = t.free_port() if certs else None diff --git a/tests/cases/counters.py b/tests/cases/counters.py index cfa2c3c..c95e497 100644 --- a/tests/cases/counters.py +++ b/tests/cases/counters.py @@ -7,6 +7,8 @@ import time def run(t): + if not t.skip_unless("httpsrv"): + return origin = t.free_port() proxy = t.free_port() adm = t.free_port() diff --git a/tests/cases/ftppr.py b/tests/cases/ftppr.py index 0fd9837..31e358a 100644 --- a/tests/cases/ftppr.py +++ b/tests/cases/ftppr.py @@ -60,6 +60,8 @@ def reply(f): def run(t): + if not t.skip_unless("ftppr"): + return servers = {} for name, (user, password) in LOGINS.items(): listener = socket.socket() diff --git a/tests/cases/httpsrv_auth.py b/tests/cases/httpsrv_auth.py index a6a5a49..c0b971e 100644 --- a/tests/cases/httpsrv_auth.py +++ b/tests/cases/httpsrv_auth.py @@ -2,6 +2,8 @@ def run(t): + if not t.skip_unless("httpsrv"): + return srv = t.free_port() openport = t.free_port() t.start("httpsrv_auth", f""" diff --git a/tests/cases/httpsrv_files.py b/tests/cases/httpsrv_files.py index 2031fe3..08f1825 100644 --- a/tests/cases/httpsrv_files.py +++ b/tests/cases/httpsrv_files.py @@ -8,6 +8,8 @@ import os def run(t): + if not t.skip_unless("httpsrv"): + return root = os.path.join(t.tmpdir, "web") os.makedirs(os.path.join(root, "picts", "set"), exist_ok=True) with open(os.path.join(root, "a.html"), "w") as fp: diff --git a/tests/cases/httpsrv_keepalive.py b/tests/cases/httpsrv_keepalive.py index 7a4862b..d8eecb0 100644 --- a/tests/cases/httpsrv_keepalive.py +++ b/tests/cases/httpsrv_keepalive.py @@ -10,6 +10,8 @@ import os def run(t): + if not t.skip_unless("httpsrv"): + return root = os.path.join(t.tmpdir, "ka") os.makedirs(root, exist_ok=True) with open(os.path.join(root, "a.html"), "w") as fp: diff --git a/tests/cases/httpsrv_ops.py b/tests/cases/httpsrv_ops.py index f2fcf5b..1f610a9 100644 --- a/tests/cases/httpsrv_ops.py +++ b/tests/cases/httpsrv_ops.py @@ -4,6 +4,8 @@ import time def run(t): + if not t.skip_unless("httpsrv"): + return srv = t.free_port() t.start("httpsrv_ops", f""" log diff --git a/tests/cases/httpsrv_parsing.py b/tests/cases/httpsrv_parsing.py index ddf6cfe..22b3487 100644 --- a/tests/cases/httpsrv_parsing.py +++ b/tests/cases/httpsrv_parsing.py @@ -6,6 +6,8 @@ most of them away before they ever reached the server. def run(t): + if not t.skip_unless("httpsrv"): + return srv = t.free_port() t.start("httpsrv_parsing", f""" log diff --git a/tests/cases/httpsrv_proxypass.py b/tests/cases/httpsrv_proxypass.py index a41bd92..cd1460e 100644 --- a/tests/cases/httpsrv_proxypass.py +++ b/tests/cases/httpsrv_proxypass.py @@ -9,6 +9,8 @@ import os def run(t): + if not t.skip_unless("httpsrv"): + return root = os.path.join(t.tmpdir, "pp") os.makedirs(root, exist_ok=True) with open(os.path.join(root, "a.html"), "w") as fp: diff --git a/tests/cases/httpsrv_rules.py b/tests/cases/httpsrv_rules.py index 61cc2e8..c2508b9 100644 --- a/tests/cases/httpsrv_rules.py +++ b/tests/cases/httpsrv_rules.py @@ -2,6 +2,8 @@ def run(t): + if not t.skip_unless("httpsrv"): + return srv = t.free_port() srv2 = t.free_port() t.start("httpsrv_rules", f""" diff --git a/tests/cases/ipv6.py b/tests/cases/ipv6.py index 986e1eb..0104a52 100644 --- a/tests/cases/ipv6.py +++ b/tests/cases/ipv6.py @@ -7,6 +7,8 @@ nscache6: nscache holds the IPv4 side and nothing else. def run(t): + if not t.skip_unless("httpsrv", "ipv6"): + return if not t.has_ipv6(): t.skip("IPv6 (this machine has no IPv6 loopback)") return diff --git a/tests/cases/ktls.py b/tests/cases/ktls.py index e164ebb..8d427e0 100644 --- a/tests/cases/ktls.py +++ b/tests/cases/ktls.py @@ -8,6 +8,8 @@ data and the report, not that the kernel took it. def run(t): + if not t.skip_unless("httpsrv", "ssl"): + return certs = t.certs() if not certs: t.skip("kernel TLS (openssl is not available to generate certificates)") diff --git a/tests/cases/logs.py b/tests/cases/logs.py index 428039c..2d480e4 100644 --- a/tests/cases/logs.py +++ b/tests/cases/logs.py @@ -130,6 +130,8 @@ def config(origin, p1, p2, p3, adm, main, shared=None): def run(t): + if not t.skip_unless("httpsrv"): + return origin, p1, p2, p3, adm = (t.free_port() for _ in range(5)) first = os.path.join(t.tmpdir, "logs-first.log") second = os.path.join(t.tmpdir, "logs-second.log") diff --git a/tests/cases/maxconn.py b/tests/cases/maxconn.py index 8c7ca13..ebe8ea5 100644 --- a/tests/cases/maxconn.py +++ b/tests/cases/maxconn.py @@ -8,6 +8,8 @@ import time def run(t): + if not t.skip_unless("httpsrv"): + return # The origin runs in a process of its own, so the limit only applies to # the mapper in front of it. origin = t.free_port() diff --git a/tests/cases/parent_failover.py b/tests/cases/parent_failover.py index d5fd726..cabfce7 100644 --- a/tests/cases/parent_failover.py +++ b/tests/cases/parent_failover.py @@ -30,6 +30,8 @@ def wait_served(server, needle, count, since=0, timeout=5.0): def run(t): + if not t.skip_unless("httpsrv"): + return srv = t.free_port() good = t.free_port() spare = t.free_port() diff --git a/tests/cases/parent_ports.py b/tests/cases/parent_ports.py index df26241..dcb83e1 100644 --- a/tests/cases/parent_ports.py +++ b/tests/cases/parent_ports.py @@ -46,6 +46,8 @@ UNHONOURED = (1, 99) def run(t): + if not t.skip_unless("httpsrv"): + return srv = t.free_port() prx = t.free_port() sks = t.free_port() diff --git a/tests/cases/parent_weights.py b/tests/cases/parent_weights.py index 90aac1b..b80873e 100644 --- a/tests/cases/parent_weights.py +++ b/tests/cases/parent_weights.py @@ -37,6 +37,8 @@ def wait_served(server, needle, count, since=0, timeout=5.0): def run(t): + if not t.skip_unless("httpsrv"): + return adm = t.free_port() prx = t.free_port() dummy = t.free_port() diff --git a/tests/cases/pcre.py b/tests/cases/pcre.py index 5af80da..0999515 100644 --- a/tests/cases/pcre.py +++ b/tests/cases/pcre.py @@ -20,6 +20,8 @@ def _has_pcre(t): def run(t): + if not t.skip_unless("httpsrv", "pcre"): + return if not _has_pcre(t): t.skip("PCRE (this build has no PCRE support)") return diff --git a/tests/cases/portmap.py b/tests/cases/portmap.py index d456192..3e3aaa8 100644 --- a/tests/cases/portmap.py +++ b/tests/cases/portmap.py @@ -26,6 +26,8 @@ def halfclose_origin(listener): def run(t): + if not t.skip_unless("httpsrv"): + return # --- tcppm --------------------------------------------------------- origin = t.free_port() mapped = t.free_port() diff --git a/tests/cases/proxy_http.py b/tests/cases/proxy_http.py index 558497b..f9cf276 100644 --- a/tests/cases/proxy_http.py +++ b/tests/cases/proxy_http.py @@ -6,6 +6,8 @@ from a clean list. def run(t): + if not t.skip_unless("httpsrv"): + return srv = t.free_port() other = t.free_port() prx = t.free_port() diff --git a/tests/cases/reattach.py b/tests/cases/reattach.py index 903a120..d6a03a8 100644 --- a/tests/cases/reattach.py +++ b/tests/cases/reattach.py @@ -10,6 +10,8 @@ import time def run(t): + if not t.skip_unless("httpsrv"): + return origin = t.free_port() proxy = t.free_port() adm = t.free_port() diff --git a/tests/cases/reload.py b/tests/cases/reload.py index 6620fc3..4d82004 100644 --- a/tests/cases/reload.py +++ b/tests/cases/reload.py @@ -29,6 +29,8 @@ def config(origin, proxy, adm, user, extra=""): def run(t): + if not t.skip_unless("httpsrv"): + return origin = t.free_port() first = t.free_port() second = t.free_port() diff --git a/tests/cases/socks.py b/tests/cases/socks.py index 96069be..8f63edf 100644 --- a/tests/cases/socks.py +++ b/tests/cases/socks.py @@ -2,6 +2,8 @@ def run(t): + if not t.skip_unless("httpsrv"): + return srv = t.free_port() sks = t.free_port() sauth = t.free_port() diff --git a/tests/cases/splice.py b/tests/cases/splice.py index 2f90e3b..db42038 100644 --- a/tests/cases/splice.py +++ b/tests/cases/splice.py @@ -61,6 +61,8 @@ def echo_through(port, data): def run(t): + if not t.skip_unless("httpsrv"): + return origin = t.free_port() listener = socket.socket() listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) diff --git a/tests/cases/ssl.py b/tests/cases/ssl.py index c8b8731..501bc99 100644 --- a/tests/cases/ssl.py +++ b/tests/cases/ssl.py @@ -11,6 +11,8 @@ def _no_tls(t, server): def run(t): + if not t.skip_unless("httpsrv", "ssl"): + return certs = t.certs() if not certs: t.skip("TLS (openssl is not available to generate certificates)") diff --git a/tests/cases/sysdns.py b/tests/cases/sysdns.py index 30f4ab1..bd8d762 100644 --- a/tests/cases/sysdns.py +++ b/tests/cases/sysdns.py @@ -49,6 +49,8 @@ def wait(t, port, name): def run(t): + if not t.skip_unless("httpsrv"): + return if not hosts_localhost() or not system_nameserver(): t.skip("no localhost in the hosts file, or no system name server") return diff --git a/tests/cases/takeover.py b/tests/cases/takeover.py index fe4e40b..75ab2c6 100644 --- a/tests/cases/takeover.py +++ b/tests/cases/takeover.py @@ -9,6 +9,8 @@ import time def run(t): + if not t.skip_unless("httpsrv"): + return origin = t.free_port() proxy = t.free_port() adm = t.free_port() diff --git a/tests/cases/tlspr.py b/tests/cases/tlspr.py index 6cc82eb..c4abfa0 100644 --- a/tests/cases/tlspr.py +++ b/tests/cases/tlspr.py @@ -2,6 +2,8 @@ def run(t): + if not t.skip_unless("httpsrv", "ssl"): + return certs = t.certs() if not certs: t.skip("tlspr (openssl is not available to generate certificates)") diff --git a/tests/cases/transparent.py b/tests/cases/transparent.py index 19fb303..11a4818 100644 --- a/tests/cases/transparent.py +++ b/tests/cases/transparent.py @@ -39,6 +39,8 @@ def _config(t, name, body): def run(t): + if not t.skip_unless("httpsrv"): + return # --- the command and its modes ------------------------------------ # These need no redirection, so they run wherever the feature is built. out = _config(t, "transparent_probe", "log\ntransparent") diff --git a/tests/cases/unixsock.py b/tests/cases/unixsock.py index 2690917..81d4f70 100644 --- a/tests/cases/unixsock.py +++ b/tests/cases/unixsock.py @@ -37,6 +37,8 @@ def unix_get(path, url, timeout=10): def run(t): + if not t.skip_unless("httpsrv", "unix"): + return if not hasattr(socket, "AF_UNIX") or sys.platform.startswith("win"): t.skip("Unix domain sockets (not on this platform)") return diff --git a/tests/harness.py b/tests/harness.py index 163255b..dc6af0f 100644 --- a/tests/harness.py +++ b/tests/harness.py @@ -122,10 +122,11 @@ class Failure(Exception): class Tester: """The API a case runs against: start servers, make requests, assert.""" - def __init__(self, binary, tmpdir, case): + def __init__(self, binary, tmpdir, case, features=None): self.binary = binary self.tmpdir = tmpdir self.case = case + self.features = features if features is not None else frozenset() self.servers = [] self.checks = [] self.timeout = 10 @@ -135,6 +136,25 @@ class Tester: self.logs = [] self.udp_servers = [] + # ---- build features ---------------------------------------------- + + def has(self, *names): + """Whether the binary reports every named feature.""" + return all(name in self.features for name in names) + + def skip_unless(self, *names): + """Skip what needs a feature the binary was built without. + + Returns False after recording the skip, so a case says + `if not t.skip_unless("ssl"): return` and keeps its checks out of + the failed count on a build that cannot pass them. + """ + missing = [name for name in names if name not in self.features] + if not missing: + return True + self.skip("built without: " + ", ".join(missing)) + return False + # ---- servers ----------------------------------------------------- def has_ipv6(self): diff --git a/tests/run.py b/tests/run.py index 9501072..e5def87 100644 --- a/tests/run.py +++ b/tests/run.py @@ -48,6 +48,37 @@ def load_case(path): return name, module +def built_features(binary): + """The features the binary reports in `3proxy --help`, as a set. + + A binary too old to have the list, or one that cannot be run at all, + reports nothing: every case that needs a feature then skips, and the + ones that need none still run. + """ + import subprocess + try: + done = subprocess.run([binary, "--help"], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=15) + text = done.stdout.decode("utf-8", "replace") + except (OSError, subprocess.SubprocessError): + return frozenset() + features = set() + collecting = False + for line in text.splitlines(): + stripped = line.strip() + if line.startswith("built with:"): + collecting = True + rest = stripped[len("built with:"):].strip() + if rest: + features.update(rest.split()) + continue + if collecting: + if not line[:1].isspace() or not stripped: + break + features.update(stripped.split()) + return frozenset(features) + + def main(): parser = argparse.ArgumentParser() parser.add_argument("pattern", nargs="?", default="", @@ -76,7 +107,9 @@ def main(): return 2 tmpdir = tempfile.mkdtemp(prefix="3proxy-tests.") + features = built_features(binary) print(f"3proxy tests: {binary}") + print(f"built with: {' '.join(sorted(features)) or '(nothing reported)'}") print(f"working in: {tmpdir}\n") passed = failed = skipped = 0 @@ -86,7 +119,7 @@ def main(): for path in paths: name, module = load_case(path) print(f" {name}") - tester = Tester(binary, tmpdir, name) + tester = Tester(binary, tmpdir, name, features) error = None try: module.run(tester)