From 629a978c008cb6c5eb79958c876cfd8cbc0180b4 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 | 32 ++++++++++------ src/proxy.h | 1 + src/proxymain.c | 63 ++++++++++++++++++++++++++++++++ tests/README.md | 8 ++++ tests/cases/admin.py | 2 + tests/cases/auto.py | 4 +- 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/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/socks.py | 2 + tests/cases/ssl.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 +++++++++++++++++- 29 files changed, 195 insertions(+), 14 deletions(-) diff --git a/src/3proxy.c b/src/3proxy.c index 1f6d41e..9b3c4f7 100644 --- a/src/3proxy.c +++ b/src/3proxy.c @@ -382,6 +382,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[]) { @@ -424,6 +439,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")) { @@ -536,18 +555,9 @@ int WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLine, int } 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); - + printusage(&conf, argc, argv); return 1; - } + } #ifndef _WIN32 /* before readconfig() starts the services: a client gone while it is diff --git a/src/proxy.h b/src/proxy.h index 25886f5..539b4de 100644 --- a/src/proxy.h +++ b/src/proxy.h @@ -512,6 +512,7 @@ extern struct socketoptions { int getopts(const char *s, struct sockoptset *opts); void setopts(SOCKET s, const struct sockoptset *opts); char * printopts(char *sep); +char * printfeatures(char *sep); #ifdef _WINCE char * CEToUnicode (const char *str); diff --git a/src/proxymain.c b/src/proxymain.c index 085c045..b21d872 100644 --- a/src/proxymain.c +++ b/src/proxymain.c @@ -226,6 +226,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/tests/README.md b/tests/README.md index 03efb58..a119afd 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/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/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/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/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/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 0bc9d09..36d3ad6 100644 --- a/tests/cases/unixsock.py +++ b/tests/cases/unixsock.py @@ -36,6 +36,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 5b6cf09..50e6bb8 100644 --- a/tests/run.py +++ b/tests/run.py @@ -47,6 +47,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="", @@ -75,7 +106,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 @@ -85,7 +118,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)