From 73fbf9d262294bc50c6ecf86b313ae1fe326be28 Mon Sep 17 00:00:00 2001
From: Vladimir Dubrovin <3proxy@3proxy.ru>
Date: Tue, 25 Aug 2026 18:03:50 +0300
Subject: [PATCH] Add tests
---
.github/workflows/c-cpp-Linux.yml | 6 +-
.github/workflows/c-cpp-MacOS.yml | 6 +-
.github/workflows/c-cpp-Windows.yml | 7 +-
.github/workflows/c-cpp-cmake.yml | 13 +-
tests/.gitignore | 2 +
tests/README.md | 54 ++++
tests/cases/admin.py | 69 +++++
tests/cases/httpsrv_auth.py | 49 +++
tests/cases/httpsrv_ops.py | 78 +++++
tests/cases/httpsrv_parsing.py | 64 ++++
tests/cases/httpsrv_rules.py | 69 +++++
tests/cases/parent_ports.py | 131 ++++++++
tests/cases/proxy_http.py | 108 +++++++
tests/cases/socks.py | 57 ++++
tests/harness.py | 460 ++++++++++++++++++++++++++++
tests/run.py | 141 +++++++++
16 files changed, 1304 insertions(+), 10 deletions(-)
create mode 100644 tests/.gitignore
create mode 100644 tests/README.md
create mode 100644 tests/cases/admin.py
create mode 100644 tests/cases/httpsrv_auth.py
create mode 100644 tests/cases/httpsrv_ops.py
create mode 100644 tests/cases/httpsrv_parsing.py
create mode 100644 tests/cases/httpsrv_rules.py
create mode 100644 tests/cases/parent_ports.py
create mode 100644 tests/cases/proxy_http.py
create mode 100644 tests/cases/socks.py
create mode 100644 tests/harness.py
create mode 100644 tests/run.py
diff --git a/.github/workflows/c-cpp-Linux.yml b/.github/workflows/c-cpp-Linux.yml
index 5995c3c..1551944 100644
--- a/.github/workflows/c-cpp-Linux.yml
+++ b/.github/workflows/c-cpp-Linux.yml
@@ -2,9 +2,9 @@ name: C/C++ CI Linux
on:
push:
- paths: [ '**.c', '**.h', 'Makefile.Linux', '.github/configs', '.github/workflows/c-cpp-Linux.yml' ]
+ paths: [ '**.c', '**.h', 'Makefile.Linux', 'tests/**', '.github/configs', '.github/workflows/c-cpp-Linux.yml' ]
pull_request:
- paths: [ "**.c", "**.h", "Makefile.Linux", ".github/configs", ".github/workflows/c-cpp-Linux.yml" ]
+ paths: [ "**.c", "**.h", "Makefile.Linux", "tests/**", ".github/configs", ".github/workflows/c-cpp-Linux.yml" ]
workflow_dispatch:
permissions:
@@ -28,6 +28,8 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y libssl-dev libpam-dev libpcre2-dev
- name: make
run: make -f Makefile.Linux
+ - name: regression tests
+ run: python3 tests/run.py
- name: mkdir
run: mkdir ~/3proxy
- name: make install
diff --git a/.github/workflows/c-cpp-MacOS.yml b/.github/workflows/c-cpp-MacOS.yml
index e29069e..161d817 100644
--- a/.github/workflows/c-cpp-MacOS.yml
+++ b/.github/workflows/c-cpp-MacOS.yml
@@ -2,9 +2,9 @@ name: C/C++ CI MacOS
on:
push:
- paths: [ '**.c', '**.h', 'Makefile.FreeBSD', '.github/configs', '.github/workflows/c-cpp-MacOS.yml' ]
+ paths: [ '**.c', '**.h', 'Makefile.FreeBSD', 'tests/**', '.github/configs', '.github/workflows/c-cpp-MacOS.yml' ]
pull_request:
- paths: [ "**.c", "**.h", "Makefile.FreeBSD", ".github/configs", ".github/workflows/c-cpp-MacOS.yml" ]
+ paths: [ "**.c", "**.h", "Makefile.FreeBSD", "tests/**", ".github/configs", ".github/workflows/c-cpp-MacOS.yml" ]
workflow_dispatch:
permissions:
@@ -29,5 +29,7 @@ jobs:
env:
LDFLAGS: "-L/usr/local/lib -L/opt/homebrew/lib -L/opt/homebrew/opt/openssl/lib"
CFLAGS: "-I/usr/local/include -I/opt/homebrew/include -I/usr/local/opt/openssl/include -I/opt/homebrew/opt/openssl/include"
+ - name: regression tests
+ run: python3 tests/run.py
- name: make clean MacOS
run: make -f Makefile.FreeBSD clean
diff --git a/.github/workflows/c-cpp-Windows.yml b/.github/workflows/c-cpp-Windows.yml
index 7407cf0..3bfde5c 100644
--- a/.github/workflows/c-cpp-Windows.yml
+++ b/.github/workflows/c-cpp-Windows.yml
@@ -2,9 +2,9 @@ name: C/C++ CI Windows
on:
push:
- paths: [ '**.c', '**.h', 'Makefile.msvc', '.github/configs', '.github/workflows/c-cpp-Windows.yml' ]
+ paths: [ '**.c', '**.h', 'Makefile.msvc', 'tests/**', '.github/configs', '.github/workflows/c-cpp-Windows.yml' ]
pull_request:
- paths: [ "**.c", "**.h", "Makefile.msvc", ".github/configs", ".github/workflows/c-cpp-Windows.yml" ]
+ paths: [ "**.c", "**.h", "Makefile.msvc", "tests/**", ".github/configs", ".github/workflows/c-cpp-Windows.yml" ]
workflow_dispatch:
permissions:
@@ -27,6 +27,8 @@ jobs:
env:
LDFLAGS: '-L "c:/msys64/mingw64/lib"'
CFLAGS: '-I "c:/msys64/mingw64/include"'
+ - name: regression tests (MinGW)
+ run: python tests\run.py --bin bin\3proxy.exe
- name: make clean Windows
run: make -f Makefile.win clean
- name: Add msbuild to PATH
@@ -40,4 +42,5 @@ jobs:
set "LIB=%LIB%;c:/vcpkg/installed/x64-windows-static/lib;c:/vcpkg/installed/x64-windows/lib"
set "INCLUDE=%INCLUDE%;c:/vcpkg/installed/x64-windows-static/include;c:/vcpkg/installed/x64-windows/include"
nmake /F Makefile.msvc WOLFSSL=1 || exit /b 1
+ python tests\run.py --bin bin\3proxy.exe || exit /b 1
nmake /F Makefile.msvc clean
diff --git a/.github/workflows/c-cpp-cmake.yml b/.github/workflows/c-cpp-cmake.yml
index 7f50941..7c76128 100644
--- a/.github/workflows/c-cpp-cmake.yml
+++ b/.github/workflows/c-cpp-cmake.yml
@@ -2,9 +2,9 @@ name: C/C++ CI cmake
on:
push:
- paths: [ '**.c', '**.h', '**.cmake', 'CMakeLists.txt', '.github/configs', '.github/workflows/c-cpp-cmake.yml' ]
+ paths: [ '**.c', '**.h', '**.cmake', 'CMakeLists.txt', 'tests/**', '.github/configs', '.github/workflows/c-cpp-cmake.yml' ]
pull_request:
- paths: [ "**.c", "**.h", "**.cmake", "CMakeLists.txt", ".github/configs", ".github/workflows/c-cpp-cmake.yml" ]
+ paths: [ "**.c", "**.h", "**.cmake", "CMakeLists.txt", "tests/**", ".github/configs", ".github/workflows/c-cpp-cmake.yml" ]
workflow_dispatch:
permissions:
@@ -43,7 +43,9 @@ jobs:
cmake --build .
mkdir ~/3proxy
DESTDIR=~/3proxy cmake --install .
- cd .. && rm -rf build/
+ cd ..
+ python3 tests/run.py --bin build/bin/3proxy
+ rm -rf build/
- name: make with CMake Win
if: ${{ startsWith(matrix.target, 'windows') }}
shell: cmd
@@ -56,6 +58,7 @@ jobs:
dir
cmake --build .
cd ..
+ python tests\run.py || exit /b 1
rmdir /s /q build
wolfssl:
@@ -71,4 +74,6 @@ jobs:
cd build
cmake ..
cmake --build .
- cd .. && rm -rf build/
+ cd ..
+ python3 tests/run.py --bin build/bin/3proxy
+ rm -rf build/
diff --git a/tests/.gitignore b/tests/.gitignore
new file mode 100644
index 0000000..7a60b85
--- /dev/null
+++ b/tests/.gitignore
@@ -0,0 +1,2 @@
+__pycache__/
+*.pyc
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 0000000..b681367
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,54 @@
+# Regression tests
+
+ python3 tests/run.py # every case
+ python3 tests/run.py httpsrv # cases whose name matches
+ python3 tests/run.py --bin build/bin/3proxy
+ python3 tests/run.py -v # print every check
+ python3 tests/run.py --keep # keep the configurations and logs
+
+Python 3.6 or later and a built 3proxy are the only requirements: the suite
+is standard library throughout, so it runs wherever 3proxy builds. With no
+`--bin` it looks in `bin/`, then `build/bin/`, then the per-configuration
+directories a multi-configuration CMake generator uses.
+
+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
+of a requested size, framing, status and pace. So a case can state what a
+proxy should do to a request and then read off what actually reached the
+other side.
+
+## Adding a case
+
+A case is a module under `cases/` exporting `run(t)`. It writes the
+configurations it needs, starts them, and says what it expects:
+
+```python
+def run(t):
+ srv = t.free_port()
+ t.start("my_case", f"""
+ log
+ auth iponly
+ allow *
+ http * /echo echo
+ httpsrv -p{srv}
+ """, ports=[srv])
+
+ r = t.http(f"http://127.0.0.1:{srv}/echo")
+ t.eq(200, r.status, "the server answers")
+ t.contains(r, "method=GET", "the method is reported")
+```
+
+Servers are stopped for you when the case ends, whether or not it passed.
+
+`t` offers `http()` (direct, through an HTTP proxy, or over a CONNECT
+tunnel), `socks_http()` and `socks_connect()` for SOCKS4 and SOCKS5,
+`socks_udp_associate()`, `raw()` for bytes a real client would never send,
+and `run_config()` for configurations that are meant to be rejected.
+Assertions are `eq`, `ne`, `contains`, `not_contains`, `in_range`,
+`not_in_range`, plus `ok`, `fail` and `skip`. `harness.field()` and
+`int_field()` pull a single line out of an `echo` reply.
+
+Note that access rules accumulate until `flush`, so a service section that
+means to stand on its own should start with one - otherwise an earlier
+`allow *` matches first and the rule under test is never reached.
diff --git a/tests/cases/admin.py b/tests/cases/admin.py
new file mode 100644
index 0000000..e2455a9
--- /dev/null
+++ b/tests/cases/admin.py
@@ -0,0 +1,69 @@
+"""The admin interface, now a set of handlers on the HTTP server."""
+
+
+def run(t):
+ adm = t.free_port()
+ lim = t.free_port()
+ t.start("admin", f"""
+ log
+ auth iponly
+ allow *
+ countin 1 D 100 * * *
+ countin 2 D 200 * * *
+ admin -p{adm}
+
+ flush
+ auth iponly
+ allow *
+ admin -p{lim} -s1
+ """, ports=[adm, lim])
+
+ url = f"http://127.0.0.1:{adm}"
+
+ # --- the predefined pages -----------------------------------------
+ t.eq(200, t.http(url + "/").status, "the main page")
+ t.eq(200, t.http(url + "/C").status, "the counters page")
+ t.eq(200, t.http(url + "/R").status, "the reload page")
+ t.eq(200, t.http(url + "/S").status, "the services page")
+
+ counters = t.http(url + "/C")
+ t.contains(counters, "countin", "the counters page names the counter type")
+ t.contains(counters, "
", "the counters page renders a table")
+ t.contains(t.http(url + "/R"), "Reload", "the reload page confirms the request")
+ t.contains(t.http(url + "/S"), "<", "the services page returns markup")
+
+ # --- the menu no longer offers the removed config editor -----------
+ main = t.http(url + "/")
+ t.contains(main, "HREF='/C'", "the menu links to the counters")
+ t.contains(main, "HREF='/R'", "the menu links to reload")
+ t.contains(main, "HREF='/S'", "the menu links to the services")
+ t.not_contains(main, "HREF='/F'",
+ "the menu no longer links to the config editor")
+
+ # /F and /U are gone, so they fall through to the catch-all rule
+ t.eq(200, t.http(url + "/F").status, "the removed /F falls through")
+ t.eq(200, t.http(url + "/U").status, "the removed /U falls through")
+ t.contains(t.http(url + "/F"), "configuration", "/F yields the main page")
+
+ # --- counter control through the glob ------------------------------
+ # /C is routed by the /C* rule, the action arriving as
+ # the glob
+ t.http(url + "/CD0")
+ t.contains(t.http(url + "/C"), ">NO<", "a counter can be disabled")
+ t.http(url + "/CS0")
+ t.contains(t.http(url + "/C"), ">YES<", "a counter can be enabled again")
+
+ # --- limited mode ---------------------------------------------------
+ limited = f"http://127.0.0.1:{lim}"
+ t.eq(200, t.http(limited + "/").status, "limited mode serves the main page")
+ t.eq(200, t.http(limited + "/C").status, "limited mode serves the counters")
+ t.not_contains(t.http(limited + "/R"), "Reload scheduled",
+ "limited mode refuses a reload")
+
+ # --- the writable command is gone ------------------------------------
+ output = t.run_config("writable", f"""
+ log
+ writable
+ admin -p{t.free_port()}
+ """)
+ t.contains(output, "Unknown command", "the writable command is rejected")
diff --git a/tests/cases/httpsrv_auth.py b/tests/cases/httpsrv_auth.py
new file mode 100644
index 0000000..67bf2d4
--- /dev/null
+++ b/tests/cases/httpsrv_auth.py
@@ -0,0 +1,49 @@
+"""Authentication and access rules in front of the HTTP server."""
+
+
+def run(t):
+ srv = t.free_port()
+ openport = t.free_port()
+ t.start("httpsrv_auth", f"""
+ log
+ http * /echo echo
+ auth strong
+ users alice:CL:secret bob:CL:hunter2
+ allow alice
+ httpsrv -p{srv}
+
+ flush
+ http * /echo echo
+ auth iponly
+ allow *
+ httpsrv -p{openport}
+ """, ports=[srv, openport])
+
+ url = f"http://127.0.0.1:{srv}"
+
+ r = t.http(url + "/echo")
+ t.eq(401, r.status, "no credentials gives 401")
+ t.ne(None, r.header("WWW-Authenticate"),
+ "the 401 carries a WWW-Authenticate header")
+
+ t.eq(200, t.http(url + "/echo", auth=("alice", "secret")).status,
+ "valid credentials pass")
+ t.eq(401, t.http(url + "/echo", auth=("alice", "wrong")).status,
+ "a wrong password gives 401")
+ t.eq(401, t.http(url + "/echo", auth=("nobody", "secret")).status,
+ "an unknown user gives 401")
+
+ # bob authenticates, but no rule admits him
+ t.eq(403, t.http(url + "/echo", auth=("bob", "hunter2")).status,
+ "authenticated but not allowed gives 403")
+
+ # authentication comes before dispatch, so an unmatched URL still needs it
+ t.eq(401, t.http(url + "/nosuchpath").status,
+ "authentication precedes the rule lookup")
+
+ # the second service kept its own iponly authentication
+ t.eq(200, t.http(f"http://127.0.0.1:{openport}/echo").status,
+ "the open service needs no credentials")
+
+ t.contains(t.http(url + "/echo", auth=("alice", "secret")), "path=/echo",
+ "an authenticated request is dispatched")
diff --git a/tests/cases/httpsrv_ops.py b/tests/cases/httpsrv_ops.py
new file mode 100644
index 0000000..d257a43
--- /dev/null
+++ b/tests/cases/httpsrv_ops.py
@@ -0,0 +1,78 @@
+"""The built-in HTTP server: the echo and data operations."""
+
+import time
+
+
+def run(t):
+ srv = t.free_port()
+ t.start("httpsrv_ops", f"""
+ log
+ auth iponly
+ allow *
+ http * /echo* echo
+ http * /data data
+ http * /small data size=64
+ httpsrv -p{srv}
+ """, ports=[srv])
+
+ url = f"http://127.0.0.1:{srv}"
+
+ # --- echo: request introspection ---------------------------------
+ r = t.http(url + "/echo?a=1")
+ t.eq(200, r.status, "echo answers 200")
+ t.contains(r, "method=GET", "echo reports the method")
+ t.contains(r, "path=/echo", "echo reports the path")
+ t.contains(r, "query=a=1", "echo reports the query")
+ t.contains(r, "peer.addr=127.0.0.1", "echo reports the peer address")
+ t.contains(r, f"host=127.0.0.1:{srv}", "echo reports the Host header")
+
+ # the glob is the wildcard-matched tail, which is how admin routes its
+ # sub-pages
+ r = t.http(url + "/echoXYZ")
+ t.contains(r, "glob=XYZ", "echo reports the glob text")
+ t.contains(r, "glob.len=3", "echo reports the glob length")
+
+ # --- data: generated payload -------------------------------------
+ t.eq(1000, t.http(url + "/data?size=1000").length, "data honours size")
+ t.eq(0, t.http(url + "/data?size=0").length, "data size=0 sends an empty body")
+ t.eq(64, t.http(url + "/small").length, "data takes its size from the rule")
+ t.eq(1000, t.http(url + "/small?size=1000").length,
+ "the query overrides the rule parameters")
+
+ # a size past one block exercises the send loop
+ t.eq(70000, t.http(url + "/data?size=70000").length,
+ "data spans several blocks")
+ t.eq(70000, t.http(url + "/data?size=70000&block=1024").length,
+ "data honours the block size")
+
+ # --- status and framing ------------------------------------------
+ t.eq(404, t.http(url + "/data?size=10&status=404").status,
+ "data honours the status")
+ t.eq(503, t.http(url + "/data?size=10&status=503").status,
+ "data returns 503 when asked")
+ t.eq(200, t.http(url + "/data?size=10&status=99").status,
+ "an out-of-range status falls back to 200")
+
+ r = t.http(url + "/data?size=100")
+ t.eq("100", r.header("Content-Length"), "an identity reply sets Content-Length")
+
+ r = t.http(url + "/data?size=100&chunked=1")
+ t.eq("chunked", r.header("Transfer-Encoding"),
+ "a chunked reply sets Transfer-Encoding")
+ t.eq(None, r.header("Content-Length"),
+ "a chunked reply omits Content-Length")
+ t.eq(100, r.length, "a chunked body decodes to the size asked for")
+ t.eq(70000, t.http(url + "/data?size=70000&chunked=1").length,
+ "a chunked body spans several blocks")
+
+ # --- delay --------------------------------------------------------
+ start = time.time()
+ t.http(url + "/data?size=4096&block=1024&delay=100")
+ elapsed = time.time() - start
+ if elapsed >= 0.3:
+ t.ok("delay slows the transfer")
+ else:
+ t.fail("delay slows the transfer", ">=0.3s", f"{elapsed:.2f}s")
+
+ # --- unmatched ----------------------------------------------------
+ t.eq(404, t.http(url + "/nosuchthing").status, "an unmatched URL gives 404")
diff --git a/tests/cases/httpsrv_parsing.py b/tests/cases/httpsrv_parsing.py
new file mode 100644
index 0000000..72beaf4
--- /dev/null
+++ b/tests/cases/httpsrv_parsing.py
@@ -0,0 +1,64 @@
+"""Request parsing: decoding, path safety, malformed and oversized input.
+
+These go over a raw socket, because a well-behaved client would normalise
+most of them away before they ever reached the server.
+"""
+
+
+def run(t):
+ srv = t.free_port()
+ t.start("httpsrv_parsing", f"""
+ log
+ auth iponly
+ allow *
+ http * /echo* echo
+ http * /safe/* echo
+ httpsrv -p{srv}
+ """, ports=[srv])
+
+ def request(path, host="t", extra=""):
+ return t.raw(srv, f"GET {path} HTTP/1.0\r\nHost: {host}\r\n{extra}\r\n")
+
+ # --- percent-decoding ---------------------------------------------
+ reply = request("/%65cho")
+ t.contains(reply, "200 OK", "a percent-encoded path is decoded before matching")
+ t.contains(reply, "path=/echo", "the decoded path is what gets reported")
+ t.contains(request("/echo%20space"), "glob= space",
+ "an encoded space decodes into the glob")
+
+ # --- traversal -----------------------------------------------------
+ for path in ("/safe/../etc/passwd", "/safe/%2e%2e/etc", "/safe/..%2fetc",
+ "/echo/../../x"):
+ t.not_contains(request(path), "200 OK", f"traversal is refused: {path}")
+
+ t.contains(request("/safe/./ok"), "200 OK",
+ "a harmless dot segment is still served")
+
+ # --- injection ------------------------------------------------------
+ t.not_contains(request("/echo%0d%0aInjected:%20yes"), "Injected: yes",
+ "an encoded CRLF cannot inject a header")
+ t.not_contains(request("/echo%00cut"), "200 OK", "an encoded NUL is refused")
+
+ # a header value cannot smuggle a newline into the echoed output
+ reply = request("/echo", host="evil", extra="X-Injected: yes\r\n")
+ t.not_contains(reply, "host=evil\nX-Injected",
+ "header values stay in their own fields")
+
+ # --- malformed ------------------------------------------------------
+ t.not_contains(t.raw(srv, "GARBAGE\r\n\r\n"), "200 OK",
+ "a malformed request line is not served")
+ t.not_contains(t.raw(srv, "GET\r\n\r\n"), "200 OK",
+ "a request line with no URL is not served")
+
+ # an over-long path has to be refused rather than quietly truncated to
+ # something shorter that might match another rule
+ t.not_contains(request("/echo" + "a" * 9000), "200 OK",
+ "an over-long path is refused, not truncated")
+
+ # --- methods --------------------------------------------------------
+ url = f"http://127.0.0.1:{srv}"
+ t.eq(200, t.http(url + "/echo", method="HEAD").status, "HEAD is accepted")
+ r = t.http(url + "/echo", method="POST", body="payload=1",
+ headers={"Content-Type": "application/x-www-form-urlencoded"})
+ t.contains(r, "method=POST", "POST reaches the handler")
+ t.contains(r, "content.length=9", "the POST content length is parsed")
diff --git a/tests/cases/httpsrv_rules.py b/tests/cases/httpsrv_rules.py
new file mode 100644
index 0000000..6026453
--- /dev/null
+++ b/tests/cases/httpsrv_rules.py
@@ -0,0 +1,69 @@
+"""Rule dispatch: host and URL patterns, and per-service rule sets."""
+
+
+def run(t):
+ srv = t.free_port()
+ srv2 = t.free_port()
+ t.start("httpsrv_rules", f"""
+ log
+ auth iponly
+ allow *
+ http * /exact echo
+ http * /pre* echo
+ http * *.suffix echo
+ http * *mid* echo
+ http host.example.com /byhost echo
+ http *.wild.example.com /bywild echo
+ http * /only-first echo
+ httpsrv -p{srv}
+
+ flush
+ auth iponly
+ allow *
+ http * /only-second echo
+ httpsrv -p{srv2}
+ """, ports=[srv, srv2])
+
+ url = f"http://127.0.0.1:{srv}"
+
+ # --- URL patterns -------------------------------------------------
+ t.eq(200, t.http(url + "/exact").status, "an exact URL matches")
+ t.eq(404, t.http(url + "/exactly").status,
+ "an exact URL does not match a longer path")
+ t.eq(200, t.http(url + "/pre").status, "a prefix matches the bare prefix")
+ t.eq(200, t.http(url + "/pretty/deep").status,
+ "a prefix matches a longer path")
+ t.eq(200, t.http(url + "/any.suffix").status, "a suffix matches")
+ t.eq(404, t.http(url + "/any.suffixx").status,
+ "a suffix is anchored at the end")
+ t.eq(200, t.http(url + "/xxmidxx").status, "a substring matches")
+ t.eq(404, t.http(url + "/nomatch").status, "an unmatched URL gives 404")
+
+ # --- host patterns ------------------------------------------------
+ def with_host(path, host):
+ return t.http(url + path, headers={"Host": host})
+
+ t.eq(200, with_host("/byhost", "host.example.com").status,
+ "an exact host matches")
+ t.eq(404, with_host("/byhost", "other.example.com").status,
+ "another host does not match")
+ t.eq(200, with_host("/bywild", "a.wild.example.com").status,
+ "a wildcard host matches")
+ t.eq(404, with_host("/bywild", "a.other.example.com").status,
+ "a wildcard host rejects another domain")
+
+ # the rules are ordered, and the first match wins
+ t.contains(t.http(url + "/exact"), "path=/exact",
+ "the first matching rule handles the request")
+
+ # --- per-service rule sets ----------------------------------------
+ # Rules accumulate until a service starts, which takes them; later rules
+ # belong to the next service only.
+ t.eq(200, t.http(f"http://127.0.0.1:{srv}/only-first").status,
+ "the first service has its own rules")
+ t.eq(404, t.http(f"http://127.0.0.1:{srv}/only-second").status,
+ "the first service does not have the later rules")
+ t.eq(200, t.http(f"http://127.0.0.1:{srv2}/only-second").status,
+ "the second service has its own rules")
+ t.eq(404, t.http(f"http://127.0.0.1:{srv2}/only-first").status,
+ "the second service does not have the earlier rules")
diff --git a/tests/cases/parent_ports.py b/tests/cases/parent_ports.py
new file mode 100644
index 0000000..7273025
--- /dev/null
+++ b/tests/cases/parent_ports.py
@@ -0,0 +1,131 @@
+"""extport and intport: binding the local side of a connection to a range.
+
+Access rules accumulate until "flush": without it an earlier "allow *"
+matches first and the rule carrying the range is never reached.
+"""
+
+from harness import int_field
+
+LOW = 21400
+HIGH = 21449
+ILOW = 21500
+IHIGH = 21549
+
+
+def run(t):
+ srv = t.free_port()
+ prx = t.free_port()
+ sks = t.free_port()
+ meth = t.free_port()
+
+ t.start("parent_ports", f"""
+ log
+ auth iponly
+ allow *
+ http * /echo* echo
+ httpsrv -p{srv}
+
+ # every outgoing connection binds inside the range
+ flush
+ auth iponly
+ allow *
+ parent 1000 extport 0.0.0.0 {LOW}-{HIGH}
+ proxy -p{prx}
+
+ # the range applies only to CONNECT: an HTTP proxy CONNECT is
+ # HTTP_CONNECT, the bare CONNECT operation being the SOCKS one
+ flush
+ auth iponly
+ allow * * * * HTTP_CONNECT
+ parent 1000 extport 0.0.0.0 {LOW}-{HIGH}
+ allow *
+ proxy -p{meth}
+
+ # socks, for the same setting on another service
+ flush
+ auth iponly
+ allow *
+ parent 1000 extport 0.0.0.0 {LOW}-{HIGH}
+ socks -p{sks}
+ """, ports=[srv, prx, sks, meth])
+
+ origin = f"http://127.0.0.1:{srv}"
+ proxy = f"127.0.0.1:{prx}"
+
+ # --- extport ---------------------------------------------------------
+ # the origin reports the source port it actually saw
+ port = int_field(t.http(origin + "/echo", proxy=proxy), "peer.port")
+ t.in_range(port, LOW, HIGH, "the outgoing connection binds inside the range")
+
+ seen = []
+ for _ in range(5):
+ seen.append(int_field(t.http(origin + "/echo", proxy=proxy), "peer.port"))
+ outside = [p for p in seen if p is None or not LOW <= p <= HIGH]
+ t.eq([], outside, "repeated connections all bind inside the range")
+
+ port = int_field(t.socks_http(f"127.0.0.1:{sks}", origin + "/echo"),
+ "peer.port")
+ t.in_range(port, LOW, HIGH,
+ "socks binds the outgoing connection inside the range")
+
+ # --- per-method scoping ------------------------------------------------
+ method_proxy = f"127.0.0.1:{meth}"
+ port = int_field(t.http(origin + "/echo", proxy=method_proxy, tunnel=True),
+ "peer.port")
+ t.in_range(port, LOW, HIGH, "CONNECT uses the range its rule sets")
+
+ # a plain GET matches the later rule, which sets no range
+ port = int_field(t.http(origin + "/echo", proxy=method_proxy), "peer.port")
+ t.not_in_range(port, LOW, HIGH,
+ "a method outside that rule keeps an ephemeral port")
+
+ # --- intport -----------------------------------------------------------
+ # A UDP association allocates its socket after the destination is known,
+ # so the range has to be applied when the rule matches rather than when
+ # the chain is walked.
+ udps = t.free_port()
+ t.start("parent_intport", f"""
+ log
+ flush
+ auth iponly
+ allow *
+ parent 1000 intport 0.0.0.0 {ILOW}-{IHIGH}
+ socks -p{udps}
+ """, ports=[udps])
+ t.in_range(t.socks_udp_associate(udps), ILOW, IHIGH,
+ "UDP ASSOCIATE binds inside the internal range")
+
+ # without a range the association still works, on an ephemeral port
+ udps2 = t.free_port()
+ t.start("parent_intport_none", f"""
+ log
+ flush
+ auth iponly
+ allow *
+ socks -p{udps2}
+ """, ports=[udps2])
+ t.ne(None, t.socks_udp_associate(udps2),
+ "UDP ASSOCIATE works without a range")
+
+ # --- configuration errors ------------------------------------------------
+ dead = t.free_port()
+ t.contains(t.run_config("badaddr", f"""
+ log
+ allow *
+ parent 1000 extport 127.0.0.1 {LOW}-{HIGH}
+ proxy -p{dead}
+ """), "requires 0.0.0.0", "a non-zero address with extport is rejected")
+
+ t.contains(t.run_config("badrange", f"""
+ log
+ allow *
+ parent 1000 extport 0.0.0.0 notaport
+ proxy -p{dead}
+ """), "bad port range", "a malformed range is rejected")
+
+ t.contains(t.run_config("badorder", f"""
+ log
+ allow *
+ parent 1000 extport 0.0.0.0 {HIGH}-{LOW}
+ proxy -p{dead}
+ """), "bad port range", "a reversed range is rejected")
diff --git a/tests/cases/proxy_http.py b/tests/cases/proxy_http.py
new file mode 100644
index 0000000..43b63d8
--- /dev/null
+++ b/tests/cases/proxy_http.py
@@ -0,0 +1,108 @@
+"""The HTTP proxy, with the built-in server as the origin.
+
+Access rules accumulate until "flush", so each service section here starts
+from a clean list.
+"""
+
+
+def run(t):
+ srv = t.free_port()
+ other = t.free_port()
+ prx = t.free_port()
+ deny = t.free_port()
+ auth = t.free_port()
+
+ t.start("proxy_http", f"""
+ log
+ auth iponly
+ allow *
+ http * /echo* echo
+ http * /data data
+ httpsrv -p{srv}
+
+ # a second origin, used as a destination the rules must keep out
+ flush
+ auth iponly
+ allow *
+ http * /echo* echo
+ httpsrv -p{other}
+
+ # an open proxy
+ flush
+ auth iponly
+ allow *
+ proxy -p{prx}
+
+ # only the first origin is reachable
+ flush
+ auth iponly
+ allow * * * {srv}
+ proxy -p{deny}
+
+ # credentials required
+ flush
+ auth strong
+ users alice:CL:secret
+ allow alice
+ proxy -p{auth}
+ """, ports=[srv, other, prx, deny, auth])
+
+ origin = f"http://127.0.0.1:{srv}"
+ second = f"http://127.0.0.1:{other}"
+ open_proxy = f"127.0.0.1:{prx}"
+
+ # --- plain proxying -------------------------------------------------
+ r = t.http(origin + "/echo", proxy=open_proxy)
+ t.eq(200, r.status, "a GET through the proxy")
+ t.contains(r, "path=/echo", "the origin sees the proxied path")
+ t.contains(r, "peer.addr=127.0.0.1", "the origin sees the proxy as the peer")
+
+ t.eq(10000, t.http(origin + "/data?size=10000", proxy=open_proxy).length,
+ "a sized body survives proxying")
+ t.eq(10000,
+ t.http(origin + "/data?size=10000&chunked=1", proxy=open_proxy).length,
+ "a chunked body survives proxying")
+ t.eq(503, t.http(origin + "/data?size=5&status=503", proxy=open_proxy).status,
+ "the origin status is relayed")
+
+ # --- POST and keep-alive ---------------------------------------------
+ r = t.http(origin + "/echo", proxy=open_proxy, method="POST", body="x=1")
+ t.contains(r, "method=POST", "POST is proxied")
+
+ # two requests on one connection, which may carry different methods
+ conn = t.connection("127.0.0.1", srv, proxy=open_proxy)
+ try:
+ first = t.http(origin + "/echo", proxy=open_proxy, method="POST",
+ body="x=1", conn=conn)
+ second_reply = t.http(origin + "/echo", proxy=open_proxy, conn=conn)
+ t.eq((200, 200), (first.status, second_reply.status),
+ "two requests on one proxied connection")
+ finally:
+ conn.close()
+
+ # --- CONNECT ----------------------------------------------------------
+ t.eq(200, t.http(origin + "/echo", proxy=open_proxy, tunnel=True).status,
+ "CONNECT tunnels to the origin")
+
+ # --- access control ----------------------------------------------------
+ denying = f"127.0.0.1:{deny}"
+ t.eq(200, t.http(origin + "/echo", proxy=denying).status,
+ "the permitted destination is reachable")
+ t.ne(200, t.http(second + "/echo", proxy=denying).status,
+ "a destination outside the rules is refused")
+ t.ne(200, t.http(second + "/echo", proxy=denying, tunnel=True).status,
+ "CONNECT to a destination outside the rules is refused")
+ # the open proxy still reaches it, so the refusal came from the rules
+ t.eq(200, t.http(second + "/echo", proxy=open_proxy).status,
+ "the same destination is reachable through the open proxy")
+
+ # --- proxy authentication -----------------------------------------------
+ needs_auth = f"127.0.0.1:{auth}"
+ t.eq(407, t.http(origin + "/echo", proxy=needs_auth).status,
+ "the proxy demands credentials")
+ t.eq(200, t.http(origin + "/echo", proxy=needs_auth,
+ proxy_auth=("alice", "secret")).status,
+ "valid proxy credentials pass")
+ t.eq(407, t.http(origin + "/echo", proxy=needs_auth,
+ proxy_auth=("alice", "wrong")).status,
+ "wrong proxy credentials are refused")
diff --git a/tests/cases/socks.py b/tests/cases/socks.py
new file mode 100644
index 0000000..378dfd8
--- /dev/null
+++ b/tests/cases/socks.py
@@ -0,0 +1,57 @@
+"""The SOCKS proxy, reaching the built-in server."""
+
+
+def run(t):
+ srv = t.free_port()
+ sks = t.free_port()
+ sauth = t.free_port()
+
+ t.start("socks", f"""
+ log
+ auth iponly
+ allow *
+ http * /echo* echo
+ http * /data data
+ httpsrv -p{srv}
+
+ flush
+ auth iponly
+ allow *
+ socks -p{sks}
+
+ flush
+ auth strong
+ users alice:CL:secret
+ allow alice
+ socks -p{sauth}
+ """, ports=[srv, sks, sauth])
+
+ origin = f"http://127.0.0.1:{srv}"
+ plain = f"127.0.0.1:{sks}"
+ guarded = f"127.0.0.1:{sauth}"
+
+ # --- SOCKS5 ---------------------------------------------------------
+ r = t.socks_http(plain, origin + "/echo")
+ t.eq(200, r.status, "a SOCKS5 connection")
+ t.contains(r, "path=/echo", "the origin sees the request made over SOCKS5")
+ t.eq(10000, t.socks_http(plain, origin + "/data?size=10000").length,
+ "a body survives SOCKS5")
+
+ # resolution delegated to the proxy
+ t.eq(200, t.socks_http(plain, f"http://localhost:{srv}/echo",
+ remote_dns=True).status,
+ "SOCKS5 resolves the hostname itself")
+
+ # --- SOCKS4 -----------------------------------------------------------
+ t.eq(200, t.socks_http(plain, origin + "/echo", socks4=True).status,
+ "a SOCKS4 connection")
+
+ # --- authentication ----------------------------------------------------
+ t.eq(200, t.socks_http(guarded, origin + "/echo",
+ auth=("alice", "secret")).status,
+ "valid SOCKS5 credentials pass")
+ t.ne(None, t.socks_connect(guarded, "127.0.0.1", srv,
+ auth=("alice", "wrong")),
+ "wrong SOCKS5 credentials are refused")
+ t.ne(None, t.socks_connect(guarded, "127.0.0.1", srv),
+ "SOCKS5 without credentials is refused")
diff --git a/tests/harness.py b/tests/harness.py
new file mode 100644
index 0000000..dd64c73
--- /dev/null
+++ b/tests/harness.py
@@ -0,0 +1,460 @@
+"""Support code for the 3proxy regression tests.
+
+Everything here is standard library, so the suite runs wherever 3proxy
+builds: no shell, no curl, no netcat.
+
+A test case is a module under tests/cases/ exporting run(t). It writes the
+configurations it needs, starts them, and states what it expects:
+
+ def run(t):
+ srv = t.free_port()
+ t.start("echo", f'''
+ log
+ auth iponly
+ allow *
+ http * /echo echo
+ httpsrv -p{srv}
+ ''', ports=[srv])
+ r = t.http(f"http://127.0.0.1:{srv}/echo")
+ t.eq(200, r.status, "the server answers")
+"""
+
+import base64
+import http.client
+import os
+import socket
+import struct
+import subprocess
+import sys
+import textwrap
+import time
+
+
+class Response:
+ """A reply, or the reason there wasn't one."""
+
+ def __init__(self, status=None, body=b"", headers=None, error=None):
+ self.status = status
+ self.body = body
+ self.headers = headers or {}
+ self.error = error
+
+ @property
+ def text(self):
+ return self.body.decode("utf-8", "replace")
+
+ @property
+ def length(self):
+ return len(self.body)
+
+ def header(self, name):
+ for k, v in self.headers.items():
+ if k.lower() == name.lower():
+ return v
+ return None
+
+ def __repr__(self):
+ if self.error:
+ return f""
+ return f"<{self.status}, {len(self.body)} bytes>"
+
+
+class Server:
+ """A running 3proxy, with the configuration it was given."""
+
+ def __init__(self, name, path, proc, logfile):
+ self.name = name
+ self.path = path
+ self.proc = proc
+ self.logfile = logfile
+
+ def output(self):
+ try:
+ with open(self.logfile, "rb") as fp:
+ return fp.read().decode("utf-8", "replace")
+ except OSError:
+ return ""
+
+ def stop(self):
+ if self.proc.poll() is None:
+ self.proc.terminate()
+ try:
+ self.proc.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ self.proc.kill()
+ self.proc.wait(timeout=5)
+
+
+class Failure(Exception):
+ """Raised when a case cannot go on, e.g. a server refused to start."""
+
+
+class Tester:
+ """The API a case runs against: start servers, make requests, assert."""
+
+ def __init__(self, binary, tmpdir, case):
+ self.binary = binary
+ self.tmpdir = tmpdir
+ self.case = case
+ self.servers = []
+ self.checks = []
+ self.timeout = 10
+ self._skipped = 0
+
+ # ---- servers -----------------------------------------------------
+
+ def free_port(self):
+ """A port nothing is listening on. Closed again before it is used,
+ which is racy in principle and reliable enough in practice."""
+ s = socket.socket()
+ try:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+ finally:
+ s.close()
+
+ def write_config(self, name, config):
+ path = os.path.join(self.tmpdir, name + ".cfg")
+ text = textwrap.dedent(config).strip() + "\n"
+ # newline="" keeps the line endings as written, rather than letting
+ # Windows turn them into CRLF behind the parser's back
+ with open(path, "w", newline="") as fp:
+ fp.write(text)
+ return path
+
+ def start(self, name, config, ports=()):
+ """Write a configuration, run it, and wait for its ports to open."""
+ path = self.write_config(name, config)
+ logfile = os.path.join(self.tmpdir, name + ".out")
+ with open(logfile, "wb") as out:
+ proc = subprocess.Popen([self.binary, path], stdout=out,
+ stderr=subprocess.STDOUT)
+ server = Server(name, path, proc, logfile)
+ self.servers.append(server)
+
+ for port in ports:
+ if not self.wait_port(port):
+ raise Failure(
+ f"{name} never listened on port {port}\n"
+ f"--- configuration ---\n{open(path).read()}"
+ f"--- output ---\n{server.output()}")
+ return server
+
+ def run_config(self, name, config):
+ """Run a configuration expected to be rejected; return its output."""
+ path = self.write_config(name, config)
+ done = subprocess.run([self.binary, path], stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT, timeout=15)
+ return done.stdout.decode("utf-8", "replace")
+
+ def wait_port(self, port, timeout=5.0):
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ try:
+ with socket.create_connection(("127.0.0.1", port), 0.25):
+ return True
+ except OSError:
+ time.sleep(0.02)
+ return False
+
+ def stop_all(self):
+ for server in self.servers:
+ server.stop()
+ self.servers = []
+
+ # ---- requests ----------------------------------------------------
+
+ def http(self, url, proxy=None, socks=None, socks4=False,
+ remote_dns=False, method="GET", body=None, headers=None,
+ auth=None, proxy_auth=None, tunnel=False, conn=None):
+ """Make a request, directly or through a proxy, and read the reply.
+
+ proxy "host:port" of an HTTP proxy
+ socks "host:port" of a SOCKS proxy
+ tunnel reach the origin with CONNECT rather than an absolute URI
+ conn reuse a connection returned by connection()
+ """
+ host, port, path = self._split(url)
+ headers = dict(headers or {})
+ if auth:
+ headers["Authorization"] = self._basic(auth)
+ if proxy_auth:
+ headers["Proxy-Authorization"] = self._basic(proxy_auth)
+
+ own = conn is None
+ try:
+ if own:
+ conn = self.connection(host, port, proxy=proxy, socks=socks,
+ socks4=socks4, remote_dns=remote_dns,
+ tunnel=tunnel)
+ target = path
+ if proxy and not tunnel:
+ target = f"http://{host}:{port}{path}"
+ if body is not None and not isinstance(body, bytes):
+ body = body.encode()
+ conn.request(method, target, body=body, headers=headers)
+ reply = conn.getresponse()
+ data = reply.read()
+ return Response(reply.status, data, dict(reply.getheaders()))
+ except (OSError, http.client.HTTPException) as exc:
+ return Response(error=f"{type(exc).__name__}: {exc}")
+ finally:
+ if own and conn is not None:
+ try:
+ conn.close()
+ except OSError:
+ pass
+
+ def connection(self, host, port, proxy=None, socks=None, socks4=False,
+ remote_dns=False, tunnel=False):
+ """A connection to an origin, kept open for reuse."""
+ if socks:
+ shost, sport = self._hostport(socks)
+ sock = self._socks_connect(shost, sport, host, port,
+ socks4=socks4, remote_dns=remote_dns)
+ conn = http.client.HTTPConnection(host, port, timeout=self.timeout)
+ conn.sock = sock
+ return conn
+ if proxy:
+ phost, pport = self._hostport(proxy)
+ conn = http.client.HTTPConnection(phost, pport, timeout=self.timeout)
+ if tunnel:
+ conn.set_tunnel(host, port)
+ return conn
+ return http.client.HTTPConnection(host, port, timeout=self.timeout)
+
+ def raw(self, port, request, host="127.0.0.1"):
+ """Send bytes as they are and return whatever comes back."""
+ if not isinstance(request, bytes):
+ request = request.encode("latin-1")
+ try:
+ with socket.create_connection((host, port), self.timeout) as sock:
+ sock.settimeout(self.timeout)
+ sock.sendall(request)
+ chunks = []
+ while True:
+ try:
+ piece = sock.recv(65536)
+ except OSError:
+ # a timeout, or a reset once the server is done:
+ # either way keep whatever already arrived
+ break
+ if not piece:
+ break
+ chunks.append(piece)
+ return b"".join(chunks).decode("utf-8", "replace")
+ except OSError as exc:
+ return f""
+
+ # ---- SOCKS -------------------------------------------------------
+
+ def _socks_connect(self, shost, sport, host, port, socks4=False,
+ remote_dns=False, auth=None):
+ sock = socket.create_connection((shost, sport), self.timeout)
+ sock.settimeout(self.timeout)
+ try:
+ if socks4:
+ addr = socket.inet_aton(socket.gethostbyname(host))
+ sock.sendall(b"\x04\x01" + struct.pack("!H", port) + addr + b"\x00")
+ reply = self._recvall(sock, 8)
+ if len(reply) < 2 or reply[1] != 0x5a:
+ raise OSError("SOCKS4 request refused")
+ return sock
+
+ if auth:
+ sock.sendall(b"\x05\x02\x00\x02")
+ else:
+ sock.sendall(b"\x05\x01\x00")
+ reply = self._recvall(sock, 2)
+ if len(reply) < 2 or reply[0] != 5:
+ raise OSError("SOCKS5 handshake failed")
+ if reply[1] == 0x02:
+ if not auth:
+ raise OSError("SOCKS5 server demands credentials")
+ user, password = auth
+ sock.sendall(b"\x01" + bytes([len(user)]) + user.encode() +
+ bytes([len(password)]) + password.encode())
+ status = self._recvall(sock, 2)
+ if len(status) < 2 or status[1] != 0:
+ raise OSError("SOCKS5 credentials refused")
+ elif reply[1] != 0x00:
+ raise OSError("SOCKS5 offered no acceptable method")
+
+ if remote_dns:
+ target = b"\x03" + bytes([len(host)]) + host.encode()
+ else:
+ target = b"\x01" + socket.inet_aton(socket.gethostbyname(host))
+ sock.sendall(b"\x05\x01\x00" + target + struct.pack("!H", port))
+ reply = self._recvall(sock, 4)
+ if len(reply) < 4 or reply[1] != 0:
+ raise OSError("SOCKS5 request refused")
+ self._read_socks_addr(sock, reply[3])
+ return sock
+ except Exception:
+ sock.close()
+ raise
+
+ def socks_connect(self, socks, host, port, socks4=False, remote_dns=False,
+ auth=None):
+ """Open a SOCKS connection, reporting failure rather than raising."""
+ shost, sport = self._hostport(socks)
+ try:
+ sock = self._socks_connect(shost, sport, host, port, socks4=socks4,
+ remote_dns=remote_dns, auth=auth)
+ sock.close()
+ return None
+ except OSError as exc:
+ return str(exc)
+
+ def socks_http(self, socks, url, auth=None, **kwargs):
+ """A request through SOCKS, with optional SOCKS credentials."""
+ host, port, path = self._split(url)
+ shost, sport = self._hostport(socks)
+ try:
+ sock = self._socks_connect(shost, sport, host, port, auth=auth,
+ **kwargs)
+ except OSError as exc:
+ return Response(error=str(exc))
+ conn = http.client.HTTPConnection(host, port, timeout=self.timeout)
+ conn.sock = sock
+ try:
+ conn.request("GET", path)
+ reply = conn.getresponse()
+ return Response(reply.status, reply.read(), dict(reply.getheaders()))
+ except (OSError, http.client.HTTPException) as exc:
+ return Response(error=str(exc))
+ finally:
+ conn.close()
+
+ def socks_udp_associate(self, port, host="127.0.0.1"):
+ """Ask for a UDP association and report the port handed back.
+
+ That socket is allocated per association, which is where an intport
+ range has to take effect.
+ """
+ try:
+ with socket.create_connection((host, port), self.timeout) as sock:
+ sock.settimeout(self.timeout)
+ sock.sendall(b"\x05\x01\x00")
+ if self._recvall(sock, 2) != b"\x05\x00":
+ return None
+ sock.sendall(b"\x05\x03\x00\x01\x00\x00\x00\x00" +
+ struct.pack("!H", 0))
+ reply = self._recvall(sock, 4)
+ if len(reply) < 4 or reply[1] != 0:
+ return None
+ _, bound = self._read_socks_addr(sock, reply[3])
+ return bound
+ except OSError:
+ return None
+
+ def _read_socks_addr(self, sock, atyp):
+ if atyp == 1:
+ addr = socket.inet_ntoa(self._recvall(sock, 4))
+ elif atyp == 3:
+ length = self._recvall(sock, 1)[0]
+ addr = self._recvall(sock, length).decode()
+ elif atyp == 4:
+ addr = self._recvall(sock, 16).hex()
+ else:
+ raise OSError(f"unknown SOCKS address type {atyp}")
+ port = struct.unpack("!H", self._recvall(sock, 2))[0]
+ return addr, port
+
+ @staticmethod
+ def _recvall(sock, count):
+ data = b""
+ while len(data) < count:
+ piece = sock.recv(count - len(data))
+ if not piece:
+ break
+ data += piece
+ return data
+
+ # ---- helpers -----------------------------------------------------
+
+ @staticmethod
+ def _basic(credentials):
+ user, password = credentials
+ token = base64.b64encode(f"{user}:{password}".encode()).decode()
+ return "Basic " + token
+
+ @staticmethod
+ def _hostport(value):
+ host, _, port = value.rpartition(":")
+ return host or "127.0.0.1", int(port)
+
+ @staticmethod
+ def _split(url):
+ prefix = "http://"
+ if url.startswith(prefix):
+ url = url[len(prefix):]
+ authority, _, path = url.partition("/")
+ host, _, port = authority.rpartition(":")
+ return host or "127.0.0.1", int(port), "/" + path
+
+ # ---- assertions --------------------------------------------------
+
+ def _record(self, passed, label, expected=None, actual=None):
+ self.checks.append((passed, label, expected, actual))
+ return passed
+
+ def ok(self, label):
+ return self._record(True, label)
+
+ def fail(self, label, expected=None, actual=None):
+ return self._record(False, label, expected, actual)
+
+ def eq(self, expected, actual, label):
+ return self._record(expected == actual, label, expected, actual)
+
+ def ne(self, unexpected, actual, label):
+ return self._record(unexpected != actual, label,
+ f"anything but {unexpected!r}", actual)
+
+ def contains(self, haystack, needle, label):
+ if isinstance(haystack, Response):
+ haystack = haystack.text
+ return self._record(needle in haystack, label,
+ f"text containing {needle!r}", self._clip(haystack))
+
+ def not_contains(self, haystack, needle, label):
+ if isinstance(haystack, Response):
+ haystack = haystack.text
+ return self._record(needle not in haystack, label,
+ f"text without {needle!r}", self._clip(haystack))
+
+ def in_range(self, value, low, high, label):
+ good = isinstance(value, int) and low <= value <= high
+ return self._record(good, label, f"between {low} and {high}", value)
+
+ def not_in_range(self, value, low, high, label):
+ good = isinstance(value, int) and not (low <= value <= high)
+ return self._record(good, label, f"outside {low}-{high}", value)
+
+ def skip(self, label):
+ self._skipped += 1
+ self.checks.append((None, label, None, None))
+
+ @staticmethod
+ def _clip(text, limit=200):
+ text = str(text).replace("\r\n", " ").replace("\n", " ")
+ return text[:limit] + ("..." if len(text) > limit else "")
+
+
+def field(response, name):
+ """Pull one 'key=value' line out of an echo reply."""
+ text = response.text if isinstance(response, Response) else response
+ for line in text.splitlines():
+ key, _, value = line.partition("=")
+ if key == name:
+ return value
+ return None
+
+
+def int_field(response, name):
+ value = field(response, name)
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return None
diff --git a/tests/run.py b/tests/run.py
new file mode 100644
index 0000000..7caa83b
--- /dev/null
+++ b/tests/run.py
@@ -0,0 +1,141 @@
+#!/usr/bin/env python3
+"""Run the 3proxy regression tests.
+
+ python3 tests/run.py every case
+ python3 tests/run.py httpsrv cases whose name matches
+ python3 tests/run.py --bin build/bin/3proxy
+ python3 tests/run.py --keep leave the temporary files behind
+
+Each case under tests/cases/ defines the configurations it needs and the
+positive and negative scenarios expected from them.
+"""
+
+import argparse
+import importlib.util
+import os
+import shutil
+import sys
+import tempfile
+import traceback
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from harness import Failure, Tester # noqa: E402
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+
+def default_binary():
+ """Find a built 3proxy: the Makefiles put it in bin/, CMake in build/bin/,
+ and multi-configuration generators one level below that again."""
+ name = "3proxy.exe" if os.name == "nt" else "3proxy"
+ candidates = [os.path.join(ROOT, "bin", name),
+ os.path.join(ROOT, "build", "bin", name)]
+ for config in ("Release", "Debug", "RelWithDebInfo", "MinSizeRel"):
+ candidates.append(os.path.join(ROOT, "build", "bin", config, name))
+ for candidate in candidates:
+ if os.path.isfile(candidate):
+ return candidate
+ return candidates[0]
+
+
+def load_case(path):
+ name = os.path.splitext(os.path.basename(path))[0]
+ spec = importlib.util.spec_from_file_location("case_" + name, path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return name, module
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("pattern", nargs="?", default="",
+ help="only run cases whose name contains this")
+ parser.add_argument("--bin", dest="binary", default=None,
+ help="the 3proxy binary to test")
+ parser.add_argument("--keep", action="store_true",
+ help="keep the temporary directory")
+ parser.add_argument("-v", "--verbose", action="store_true",
+ help="print every check, not just the failures")
+ args = parser.parse_args()
+
+ binary = args.binary or os.environ.get("BIN") or default_binary()
+ binary = os.path.abspath(binary)
+ if not os.path.isfile(binary):
+ print(f"no 3proxy binary at {binary} (build first, or pass --bin)",
+ file=sys.stderr)
+ return 2
+
+ case_dir = os.path.join(ROOT, "tests", "cases")
+ paths = sorted(os.path.join(case_dir, f) for f in os.listdir(case_dir)
+ if f.endswith(".py") and not f.startswith("_"))
+ paths = [p for p in paths if args.pattern in os.path.basename(p)]
+ if not paths:
+ print(f"no cases matched {args.pattern!r}", file=sys.stderr)
+ return 2
+
+ tmpdir = tempfile.mkdtemp(prefix="3proxy-tests.")
+ print(f"3proxy tests: {binary}")
+ print(f"working in: {tmpdir}\n")
+
+ passed = failed = skipped = 0
+ failures = []
+
+ try:
+ for path in paths:
+ name, module = load_case(path)
+ print(f" {name}")
+ tester = Tester(binary, tmpdir, name)
+ error = None
+ try:
+ module.run(tester)
+ except Failure as exc:
+ error = str(exc)
+ except Exception:
+ error = traceback.format_exc()
+ finally:
+ tester.stop_all()
+
+ for status, label, expected, actual in tester.checks:
+ if status is None:
+ skipped += 1
+ print(f" skip {label}")
+ elif status:
+ passed += 1
+ if args.verbose:
+ print(f" ok {label}")
+ else:
+ failed += 1
+ failures.append(f"{name}: {label}")
+ print(f" FAIL {label}")
+ if expected is not None:
+ print(f" expected: {expected}")
+ if actual is not None:
+ print(f" actual: {actual}")
+
+ if error:
+ failed += 1
+ failures.append(f"{name}: case aborted")
+ print(" ERROR the case could not finish:")
+ for line in error.rstrip().splitlines():
+ print(f" {line}")
+ print()
+ finally:
+ if args.keep:
+ print(f"temporary files left in {tmpdir}")
+ else:
+ shutil.rmtree(tmpdir, ignore_errors=True)
+
+ print("-" * 41)
+ total = passed + failed
+ summary = f"cases: {len(paths)} checks: {total} passed: {passed} failed: {failed}"
+ if skipped:
+ summary += f" skipped: {skipped}"
+ print(summary)
+ for item in failures:
+ print(f" FAIL {item}")
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())