Compare commits

...

10 Commits

Author SHA1 Message Date
Vladimir Dubrovin
137ff3beea Put the vcpkg DLL directory on PATH for the cmake Windows tests
Some checks are pending
C/C++ CI Linux / ${{ matrix.target }} (ubuntu-24.04-arm) (push) Waiting to run
C/C++ CI Linux / ${{ matrix.target }} (ubuntu-latest) (push) Waiting to run
C/C++ CI MacOS / ${{ matrix.target }} (macos-15) (push) Waiting to run
C/C++ CI Windows / ${{ matrix.target }} (windows-2022) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (macos-15) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (ubuntu-24.04-arm) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (ubuntu-latest) (push) Waiting to run
C/C++ CI cmake / ${{ matrix.target }} (windows-2022) (push) Waiting to run
C/C++ CI cmake / ubuntu-latest (wolfSSL) (push) Waiting to run
Update HTML documentation / docs (push) Waiting to run
Update wiki / wiki (push) Waiting to run
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.
2026-08-25 19:16:38 +03:00
github-actions[bot]
e78c1d2c07 Update HTML documentation from man pages 2026-08-25 16:08:21 +00:00
Vladimir Dubrovin
cb9effab9b Fix: insufficient default stack for wolfSSL with glibc 2026-08-25 19:07:20 +03:00
Vladimir Dubrovin
a0ae86957f fix port range test 2026-08-25 18:37:40 +03:00
Vladimir Dubrovin
73fbf9d262 Add tests 2026-08-25 18:03:50 +03:00
Vladimir Dubrovin
7f430ccc79 Fix non-linux cmake 2026-08-25 17:32:02 +03:00
github-actions[bot]
307e6d2c49 Update HTML documentation from man pages 2026-08-25 14:22:00 +00:00
Vladimir Dubrovin
3526759e59 implement small http server for admin and testing 2026-08-25 17:21:33 +03:00
github-actions[bot]
a3b40e6176 Update HTML documentation from man pages 2026-08-25 12:23:39 +00:00
Vladimir Dubrovin
6ca4a2686d port range support (parent extport / intport) 2026-08-25 14:23:33 +03:00
45 changed files with 2514 additions and 312 deletions

View File

@ -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

View File

@ -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

View File

@ -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,10 @@ jobs:
env:
LDFLAGS: '-L "c:/msys64/mingw64/lib"'
CFLAGS: '-I "c:/msys64/mingw64/include"'
- name: regression tests (MinGW)
run: |
$env:PATH = "c:\msys64\mingw64\bin;$env:PATH"
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 +44,6 @@ 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
set "PATH=%PATH%;c:/vcpkg/installed/x64-windows/bin"
python tests\run.py --bin bin\3proxy.exe || exit /b 1
nmake /F Makefile.msvc clean

View File

@ -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,8 @@ jobs:
dir
cmake --build .
cd ..
set "PATH=%PATH%;c:/vcpkg/installed/x64-windows/bin"
python tests\run.py || exit /b 1
rmdir /s /q build
wolfssl:
@ -71,4 +75,6 @@ jobs:
cd build
cmake ..
cmake --build .
cd .. && rm -rf build/
cd ..
python3 tests/run.py --bin build/bin/3proxy
rm -rf build/

View File

@ -56,6 +56,7 @@ option(3PROXY_USE_POLL "Use poll() instead of select() (Unix only)" ON)
option(3PROXY_USE_WSAPOLL "Use WSAPoll instead of select() (Windows only)" ON)
option(3PROXY_USE_NETFILTER "Enable Linux netfilter support (Linux only)" ON)
option(3PROXY_USE_UNIX_SOCKETS "Enable Unix domain socket support (Unix only)" ON)
option(3PROXY_USE_HTTPSRV "Build the HTTP server and the admin interface on top of it" ON)
if(NOT WIN32 AND NOT APPLE)
option(3PROXY_STATIC_LINK "Statically link libraries using -Wl,-Bstatic (Linux/Unix only)" OFF)
@ -236,6 +237,10 @@ else()
)
endif()
if(3PROXY_USE_HTTPSRV)
add_compile_definitions(WITH_HTTPSRV)
endif()
# Unix domain sockets off: NO_UN also undefines WITH_UN if it arrives from
# elsewhere, e.g. CFLAGS
if(NOT 3PROXY_USE_UNIX_SOCKETS)
@ -403,6 +408,7 @@ add_library(srv_modules OBJECT
src/auto.c
src/socks.c
src/webadmin.c
src/httpsrv.c
src/dnspr.c
)

View File

@ -24,6 +24,11 @@ LDFLAGS += $(EXTRA_LDFLAGS)
# -lpthreads may be reuiured on some platforms instead of -pthreads
# -ldl or -lld may be required for some platforms
DCFLAGS ?= -fPIC
HTTPSRV ?= true
ifeq ($(HTTPSRV),true)
CFLAGS += -DWITH_HTTPSRV
HTTPSRV_OBJS = srvhttpsrv$(OBJSUFFICS) srvwebadmin$(OBJSUFFICS)
endif
DLFLAGS ?= -shared
DLSUFFICS = .so
LIBS ?=

View File

@ -25,6 +25,13 @@ LDFLAGS += -fno-strict-aliasing -pthread
# makefile, including the += above and the STATIC/LIBSTATIC handling below.
CFLAGS += $(EXTRA_CFLAGS)
LDFLAGS += $(EXTRA_LDFLAGS)
# The HTTP server serves the endpoints declared by http lines. The admin
# interface is built on top of it, so turning it off removes both.
HTTPSRV ?= true
ifeq ($(HTTPSRV),true)
CFLAGS += -DWITH_HTTPSRV
HTTPSRV_OBJS = srvhttpsrv$(OBJSUFFICS) srvwebadmin$(OBJSUFFICS)
endif
DLFLAGS ?= -shared
DLSUFFICS = .ld.so
# -lpthreads may be reuqired on some platforms instead of -pthreads

View File

@ -14,6 +14,11 @@ COUT = -o ./
LN = $(CC)
LDFLAGS = -xO3
DCFLAGS = -fPIC
HTTPSRV ?= true
ifeq ($(HTTPSRV),true)
CFLAGS += -DWITH_HTTPSRV
HTTPSRV_OBJS = srvhttpsrv$(OBJSUFFICS) srvwebadmin$(OBJSUFFICS)
endif
DLFLAGS = -shared
DLSUFFICS = .ld.so
LIBS = -lpthread -lsocket -lnsl -lresolv -ldl

View File

@ -18,7 +18,7 @@ SSL_LIBS = wolfssl.lib
SSL_DEFS = /D "WITH_SSL"
SSL_LIBS = libcrypto.lib libssl.lib
!ENDIF
CFLAGS = /nologo /MT /W3 /Ox /GS /EHs- /GA /GF /D "MSVC" /D "WITH_WSAPOLL" /D "NDEBUG" /D "WIN32" $(SSL_DEFS) /D "WITH_PCRE" /D "WITH_ODBC" /D "_CONSOLE" /D "_MBCS" /D "_WIN32" /Fp"proxy.pch" /FD /c $(BUILDDATE) $(VERSION)
CFLAGS = /D "WITH_HTTPSRV" /nologo /MT /W3 /Ox /GS /EHs- /GA /GF /D "MSVC" /D "WITH_WSAPOLL" /D "NDEBUG" /D "WIN32" $(SSL_DEFS) /D "WITH_PCRE" /D "WITH_ODBC" /D "_CONSOLE" /D "_MBCS" /D "_WIN32" /Fp"proxy.pch" /FD /c $(BUILDDATE) $(VERSION)
COUT = /Fo
LN = link
LDFLAGS = /nologo /subsystem:console /incremental:no
@ -40,6 +40,7 @@ MAKEFILE = Makefile.msvc
PLUGINS = utf8tocp1251 WindowsAuthentication TrafficPlugin StringsPlugin FilePlugin
SSL_OBJS = ssllib$(OBJSUFFICS) ssl$(OBJSUFFICS)
PCRE_OBJS = pcre$(OBJSUFFICS)
HTTPSRV_OBJS = srvhttpsrv$(OBJSUFFICS) srvwebadmin$(OBJSUFFICS)
VERFILE = 3proxy.res $(VERFILE)
VERSIONDEP = 3proxy.res $(VERSIONDEP)
AFTERCLEAN = if exist src\*.res (del src\*.res) && if exist src\*.err (del src\*.err)

View File

@ -26,6 +26,11 @@ LDFLAGS += $(EXTRA_LDFLAGS)
# -lpthreads may be reuqired on some platforms instead of -pthreads
# -ldl or -lld may be required for some platforms
DCFLAGS ?= -fPIC
HTTPSRV ?= true
ifeq ($(HTTPSRV),true)
CFLAGS += -DWITH_HTTPSRV
HTTPSRV_OBJS = srvhttpsrv$(OBJSUFFICS) srvwebadmin$(OBJSUFFICS)
endif
DLFLAGS ?= -shared
DLSUFFICS ?= .ld.so
LIBS ?=

View File

@ -8,7 +8,7 @@ BUILDDIR = ../bin/
PREFIX = 3proxy_
CRYPT_PREFIX = 3proxy_
CC = cl
CFLAGS = /nologo /Ox /MT /D "NOIPV6" /D "NO_UN" /D "NODEBUG" /D "NORADIUS" /D"WATCOM" /D "MSVC" /D "WIN32" /D "_CONSOLE" /D "_MBCS" /D "_WIN32" /D "PRId64=\"I64d\"" /D "PRIu64=\"I64u\"" /D "SCNu64=\"I64u\"" /D "SCNx64=\"I64x\"" /D "SCNd64=\"I64d\"" /D "PRIx64=\"I64x\"" /c $(VERSION) $(BUILDDATE)
CFLAGS = /D "WITH_HTTPSRV" /nologo /Ox /MT /D "NOIPV6" /D "NO_UN" /D "NODEBUG" /D "NORADIUS" /D"WATCOM" /D "MSVC" /D "WIN32" /D "_CONSOLE" /D "_MBCS" /D "_WIN32" /D "PRId64=\"I64d\"" /D "PRIu64=\"I64u\"" /D "SCNu64=\"I64u\"" /D "SCNx64=\"I64x\"" /D "SCNd64=\"I64d\"" /D "PRIx64=\"I64x\"" /c $(VERSION) $(BUILDDATE)
COUT = /Fo
LN = link
LDFLAGS = /nologo /subsystem:console /incremental:no
@ -21,6 +21,7 @@ LIBEXT = .lib
LNOUT = /out:
EXESUFFICS = .exe
OBJSUFFICS = .obj
HTTPSRV_OBJS = srvhttpsrv$(OBJSUFFICS) srvwebadmin$(OBJSUFFICS)
DEFINEOPTION = /D
COMPFILES = *.pch *.idb *.err
REMOVECOMMAND = del 2>NUL >NUL

View File

@ -20,6 +20,11 @@ LDFLAGS += -fno-strict-aliasing -mthreads
# makefile, including the += above and the STATIC/LIBSTATIC handling below.
CFLAGS += $(EXTRA_CFLAGS)
LDFLAGS += $(EXTRA_LDFLAGS)
HTTPSRV ?= true
ifeq ($(HTTPSRV),true)
CFLAGS += -DWITH_HTTPSRV
HTTPSRV_OBJS = srvhttpsrv$(OBJSUFFICS) srvwebadmin$(OBJSUFFICS)
endif
DLFLAGS ?= -shared
DLSUFFICS = .dll
LIBS += -lws2_32 -lodbc32 -ladvapi32 -luser32 -lbcrypt

View File

@ -292,16 +292,7 @@ Include config file</p>
<p style="margin-left:9%; margin-top: 1em"><b>config</b>
<i>&lt;path&gt;</i> <br>
Path to configuration file to use on 3proxy restart or to
save configuration.</p>
<p style="margin-left:9%; margin-top: 1em"><b>writable</b>
<br>
ReOpens configuration file for write access via Web
interface, and rereads it. Usually should be first command
on config file but in combination with config it can be used
anywhere to open alternate config file. Think twice before
using it.</p>
Path to configuration file to use on 3proxy restart.</p>
<p style="margin-left:9%; margin-top: 1em"><b>end</b> <br>
End of configuration</p>
@ -825,6 +816,31 @@ the external address for this request to <i>&lt;ip&gt;</i>.
It can be chained with another parent type. It&rsquo;s
useful to set the external IP based on ACL or make it
random. <b><br>
extport</b> does not redirect the request; it sets the range
the local port of outgoing connections is taken from, given
as <i>FIRST-LAST</i> inclusive in place of the port
argument, with 0.0.0.0 as the address, for example <b>parent
1000 extport 0.0.0.0 40000-40100</b>. Where the system can
be asked to pick the port itself (Linux
<b>IP_LOCAL_PORT_RANGE</b>) it does, otherwise a port is
picked at random from the range and retried if it is already
in use, up to ten times. On Linux the range has to lie
within <i>net.ipv4.ip_local_port_range</i>, commonly
32768-60999: the kernel ignores a range outside it and picks
an ordinary ephemeral port instead. If no port in the range
can be bound, an ephemeral port is used rather than failing
the connection. It can be chained with another parent type,
and the access rule it belongs to decides which requests it
applies to, so <b>allow * * * * UDPASSOC</b> followed by
<b>parent 1000 extport 0.0.0.0 40000-40100</b> limits it to
UDP associations. The range is applied when the outgoing
connection is made, so a kept alive connection carrying
several requests uses the rule that matched when it was
opened. <b><br>
intport</b> is the same for sockets bound on the side facing
the client: the port a UDP association tells the client to
send its datagrams to, and the FTP proxy data connection.
<b><br>
tcp</b> simply redirect connection. TCP is always last in
chain. This type of proxy is a simple TCP redirection, it
does not support parent authentication. <b><br>

View File

@ -282,16 +282,7 @@ proxy on a client with FTP proxy support. Username format is one of
.BR config
\fI<path>\fR
.br
Path to configuration file to use on 3proxy restart or to save configuration.
.br
.B writable
.br
ReOpens configuration file for write access via Web interface,
and rereads it. Usually should be first command on config file
but in combination with config
it can be used anywhere to open
alternate config file. Think twice before using it.
Path to configuration file to use on 3proxy restart.
.br
.B end
@ -879,6 +870,10 @@ with probability of 0.7) for outgoing web connections. Chains are only applied t
type is one of:
.br
\fBextip\fR does not actually redirect the request; it sets the external address for this request to \fI<ip>\fR. It can be chained with another parent type. It's useful to set the external IP based on ACL or make it random.
.br
\fBextport\fR does not redirect the request; it sets the range the local port of outgoing connections is taken from, given as \fIFIRST-LAST\fR inclusive in place of the port argument, with 0.0.0.0 as the address, for example \fBparent 1000 extport 0.0.0.0 40000-40100\fR. Where the system can be asked to pick the port itself (Linux \fBIP_LOCAL_PORT_RANGE\fR) it does, otherwise a port is picked at random from the range and retried if it is already in use, up to ten times. On Linux the range has to lie within \fInet.ipv4.ip_local_port_range\fR, commonly 32768-60999: the kernel ignores a range outside it and picks an ordinary ephemeral port instead. If no port in the range can be bound, an ephemeral port is used rather than failing the connection. It can be chained with another parent type, and the access rule it belongs to decides which requests it applies to, so \fBallow * * * * UDPASSOC\fR followed by \fBparent 1000 extport 0.0.0.0 40000-40100\fR limits it to UDP associations. The range is applied when the outgoing connection is made, so a kept alive connection carrying several requests uses the rule that matched when it was opened.
.br
\fBintport\fR is the same for sockets bound on the side facing the client: the port a UDP association tells the client to send its datagrams to, and the FTP proxy data connection.
.br
\fBtcp\fR simply redirect connection. TCP is always last in chain. This type of proxy is a simple TCP redirection, it does not support parent authentication.
.br

View File

@ -24,7 +24,6 @@ Content-type: text/html; charset=utf-8\n
<A HREF='/C'>Счетчики</A><br><br>\n
<A HREF='/R'>Перезагрузка конфигурации сервера</A><br><br>\n
<A HREF='/S'>Запущенные сервисы</A><br><br>\n
<A HREF='/F'>Настройка сервера</A>\n
</td><td>
<h2>%s %s Конфигурация</h2>
[end]

View File

@ -28,7 +28,6 @@ void pcre_install(void);
FILE * confopen();
extern unsigned char *strings[];
extern FILE *writable;
extern struct counter_header cheader;
extern struct counter_record crecord;
@ -537,7 +536,7 @@ int WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLine, int
conf.version++;
if(res) RETURN(res);
if(!writable){fclose(fp); fp = NULL;}
fclose(fp); fp = NULL;
#ifdef _WIN32

View File

@ -116,6 +116,9 @@ srvsocks$(OBJSUFFICS): socks.c proxy.h structures.h
srvwebadmin$(OBJSUFFICS): webadmin.c proxy.h structures.h
$(CC) $(COUT)srvwebadmin$(OBJSUFFICS) $(CFLAGS) webadmin.c
srvhttpsrv$(OBJSUFFICS): httpsrv.c proxy.h structures.h
$(CC) $(COUT)srvhttpsrv$(OBJSUFFICS) $(CFLAGS) httpsrv.c
srvudppm$(OBJSUFFICS): udppm.c proxy.h structures.h
$(CC) $(COUT)srvudppm$(OBJSUFFICS) $(CFLAGS) udppm.c
@ -188,6 +191,6 @@ ssl$(OBJSUFFICS): ssl.c structures.h proxy.h ssl.h
pcre$(OBJSUFFICS): pcre.c structures.h
$(CC) $(COUT)pcre$(OBJSUFFICS) $(CFLAGS) $(DEFINEOPTION)WITH_PCRE pcre.c
$(BUILDDIR)3proxy$(EXESUFFICS): 3proxy$(OBJSUFFICS) mainfunc$(OBJSUFFICS) srvproxy$(OBJSUFFICS) srvpop3p$(OBJSUFFICS) srvimapp$(OBJSUFFICS) srvsmtpp$(OBJSUFFICS) srvftppr$(OBJSUFFICS) srvsocks$(OBJSUFFICS) srvtcppm$(OBJSUFFICS) srvtlspr$(OBJSUFFICS) srvauto$(OBJSUFFICS) srvudppm$(OBJSUFFICS) sockmap$(OBJSUFFICS) udpsockmap$(OBJSUFFICS) sockgetchar$(OBJSUFFICS) common$(OBJSUFFICS) auth$(OBJSUFFICS) acl$(OBJSUFFICS) limiter$(OBJSUFFICS) redirect$(OBJSUFFICS) authradius$(OBJSUFFICS) hash$(OBJSUFFICS) hashtables$(OBJSUFFICS) resolve$(OBJSUFFICS) sql$(OBJSUFFICS) conf$(OBJSUFFICS) log$(OBJSUFFICS) datatypes$(OBJSUFFICS) md4$(OBJSUFFICS) md5$(OBJSUFFICS) blake2$(OBJSUFFICS) 3proxy_crypt$(OBJSUFFICS) base64$(OBJSUFFICS) ftp$(OBJSUFFICS) stringtable$(OBJSUFFICS) srvwebadmin$(OBJSUFFICS) srvdnspr$(OBJSUFFICS) plugins$(OBJSUFFICS) mdhash$(OBJSUFFICS) $(SSL_OBJS) $(PCRE_OBJS) $(COMPATLIBS) $(VERSIONDEP)
$(LN) $(LNOUT)$(BUILDDIR)3proxy$(EXESUFFICS) $(LDFLAGS) $(VERFILE) 3proxy$(OBJSUFFICS) mainfunc$(OBJSUFFICS) auth$(OBJSUFFICS) acl$(OBJSUFFICS) limiter$(OBJSUFFICS) redirect$(OBJSUFFICS) authradius$(OBJSUFFICS) hash$(OBJSUFFICS) hashtables$(OBJSUFFICS) resolve$(OBJSUFFICS) sql$(OBJSUFFICS) conf$(OBJSUFFICS) datatypes$(OBJSUFFICS) srvauto$(OBJSUFFICS) srvproxy$(OBJSUFFICS) srvpop3p$(OBJSUFFICS) srvimapp$(OBJSUFFICS) srvsmtpp$(OBJSUFFICS) srvftppr$(OBJSUFFICS) srvsocks$(OBJSUFFICS) srvtcppm$(OBJSUFFICS) srvtlspr$(OBJSUFFICS) srvudppm$(OBJSUFFICS) sockmap$(OBJSUFFICS) udpsockmap$(OBJSUFFICS) sockgetchar$(OBJSUFFICS) common$(OBJSUFFICS) log$(OBJSUFFICS) 3proxy_crypt$(OBJSUFFICS) md4$(OBJSUFFICS) md5$(OBJSUFFICS) blake2$(OBJSUFFICS) base64$(OBJSUFFICS) ftp$(OBJSUFFICS) stringtable$(OBJSUFFICS) srvwebadmin$(OBJSUFFICS) srvdnspr$(OBJSUFFICS) plugins$(OBJSUFFICS) mdhash$(OBJSUFFICS) $(SSL_OBJS) $(PCRE_OBJS) $(COMPATLIBS) $(LIBS) $(PCRE_LIBS)
$(BUILDDIR)3proxy$(EXESUFFICS): 3proxy$(OBJSUFFICS) mainfunc$(OBJSUFFICS) srvproxy$(OBJSUFFICS) srvpop3p$(OBJSUFFICS) srvimapp$(OBJSUFFICS) srvsmtpp$(OBJSUFFICS) srvftppr$(OBJSUFFICS) srvsocks$(OBJSUFFICS) srvtcppm$(OBJSUFFICS) srvtlspr$(OBJSUFFICS) srvauto$(OBJSUFFICS) srvudppm$(OBJSUFFICS) sockmap$(OBJSUFFICS) udpsockmap$(OBJSUFFICS) sockgetchar$(OBJSUFFICS) common$(OBJSUFFICS) auth$(OBJSUFFICS) acl$(OBJSUFFICS) limiter$(OBJSUFFICS) redirect$(OBJSUFFICS) authradius$(OBJSUFFICS) hash$(OBJSUFFICS) hashtables$(OBJSUFFICS) resolve$(OBJSUFFICS) sql$(OBJSUFFICS) conf$(OBJSUFFICS) log$(OBJSUFFICS) datatypes$(OBJSUFFICS) md4$(OBJSUFFICS) md5$(OBJSUFFICS) blake2$(OBJSUFFICS) 3proxy_crypt$(OBJSUFFICS) base64$(OBJSUFFICS) ftp$(OBJSUFFICS) stringtable$(OBJSUFFICS) $(HTTPSRV_OBJS) srvdnspr$(OBJSUFFICS) plugins$(OBJSUFFICS) mdhash$(OBJSUFFICS) $(SSL_OBJS) $(PCRE_OBJS) $(COMPATLIBS) $(VERSIONDEP)
$(LN) $(LNOUT)$(BUILDDIR)3proxy$(EXESUFFICS) $(LDFLAGS) $(VERFILE) 3proxy$(OBJSUFFICS) mainfunc$(OBJSUFFICS) auth$(OBJSUFFICS) acl$(OBJSUFFICS) limiter$(OBJSUFFICS) redirect$(OBJSUFFICS) authradius$(OBJSUFFICS) hash$(OBJSUFFICS) hashtables$(OBJSUFFICS) resolve$(OBJSUFFICS) sql$(OBJSUFFICS) conf$(OBJSUFFICS) datatypes$(OBJSUFFICS) srvauto$(OBJSUFFICS) srvproxy$(OBJSUFFICS) srvpop3p$(OBJSUFFICS) srvimapp$(OBJSUFFICS) srvsmtpp$(OBJSUFFICS) srvftppr$(OBJSUFFICS) srvsocks$(OBJSUFFICS) srvtcppm$(OBJSUFFICS) srvtlspr$(OBJSUFFICS) srvudppm$(OBJSUFFICS) sockmap$(OBJSUFFICS) udpsockmap$(OBJSUFFICS) sockgetchar$(OBJSUFFICS) common$(OBJSUFFICS) log$(OBJSUFFICS) 3proxy_crypt$(OBJSUFFICS) md4$(OBJSUFFICS) md5$(OBJSUFFICS) blake2$(OBJSUFFICS) base64$(OBJSUFFICS) ftp$(OBJSUFFICS) stringtable$(OBJSUFFICS) $(HTTPSRV_OBJS) srvdnspr$(OBJSUFFICS) plugins$(OBJSUFFICS) mdhash$(OBJSUFFICS) $(SSL_OBJS) $(PCRE_OBJS) $(COMPATLIBS) $(LIBS) $(PCRE_LIBS)

View File

@ -62,36 +62,7 @@ int ACLmatches(struct ace* acentry, struct clientparam * param){
}
while(i > 5 && param->hostname[i-1] == '.') param->hostname[i-1] = 0;
for(hstentry = acentry->dstnames; hstentry; hstentry = hstentry->next){
int lname, lhost;
switch(hstentry->matchtype){
case 0:
#ifndef _WIN32
if(strcasestr((char *)param->hostname, (char *)hstentry->name)) match = 1;
#else
if(strstr((char *)param->hostname, (char *)hstentry->name)) match = 1;
#endif
break;
case 1:
if(!strncasecmp((char *)param->hostname, (char *)hstentry->name, strlen((char *)hstentry->name)))
match = 1;
break;
case 2:
lname = strlen((char *)hstentry->name);
lhost = strlen((char *)param->hostname);
if(lhost > lname){
if(!strncasecmp((char *)param->hostname + (lhost - lname),
(char *)hstentry->name,
lname))
match = 1;
}
break;
default:
if(!strcasecmp((char *)param->hostname, (char *)hstentry->name)) match = 1;
break;
}
if(patternmatch(hstentry, param->hostname)) match = 1;
if(match) break;
}
}
@ -164,7 +135,10 @@ int checkACL(struct clientparam * param){
continue;
}
param->lastace = acentry;
if(param->preauth) return 2;
if(param->preauth) {
applyportranges(param, acentry);
return 2;
}
if((param->operation == UDPASSOC)? (param->ctrlsocksrv != INVALID_SOCKET) : (param->remsock != INVALID_SOCKET)) {
return 0;
}
@ -187,3 +161,31 @@ int checkACL(struct clientparam * param){
}
return 3;
}
char * aceaction (int action){
switch (action) {
case ALLOW:
case REDIRECT:
return "allow";
case DENY:
return "deny";
case BANDLIM:
return "bandlim";
case NOBANDLIM:
return "nobandlim";
case COUNTIN:
return "countin";
case NOCOUNTIN:
return "nocountin";
case COUNTOUT:
return "countout";
case NOCOUNTOUT:
return "nocountout";
case COUNTALL:
return "countall";
case NOCOUNTALL:
return "nocountall";
default:
return "unknown";
}
}

View File

@ -18,7 +18,13 @@ int alwaysauth(struct clientparam * param){
if(conf.connlimiter && !param->connlim && startconnlims(param)) return 10;
#ifdef WITH_HTTPSRV
/* The http server answers the request itself, so authorization must not
try to reach a destination that does not exist. */
res = (param->srv->service == S_HTTPSRV)? 0 : doconnect(param);
#else
res = doconnect(param);
#endif
if(!res){
if(conf.bandlimfunc && (conf.bandlimiter||conf.bandlimiterout)){
_3proxy_mutex_lock(&bandlim_mutex);

View File

@ -182,7 +182,7 @@ int timeouts[12] = {
EINVAL below it and the thread silently gets the 8M system default stack.
*/
size_t threadstacksize(int extra){
long size = BASESTACKSIZE + extra;
long size = BASESTACKSIZE + TLSSTACKSIZE + extra;
if(size < (long)PTHREAD_STACK_MIN) size = (long)PTHREAD_STACK_MIN;
return (size_t)size;
@ -746,7 +746,7 @@ int doconnect(struct clientparam * param){
#ifdef WITH_UN
if(*SAFAMILY(&param->sinsl) != AF_UNIX)
#endif
if(param->srv->so._bind(param->sostate, param->remsock, (struct sockaddr*)&param->sinsl, SASIZE(&param->sinsl))==-1) {
if(bindwithrange(param, param->remsock, &param->sinsl, param->extport)==-1) {
return 12;
}
@ -767,6 +767,145 @@ int doconnect(struct clientparam * param){
return 0;
}
/* Number of ports tried before giving up when the range has to be searched by
* hand. The kernel option picks a free port itself and needs no retries. */
#define RANGETRIES 10
/* Bind sock to sa, taking the local port from the range if one is set. The
* range is packed as first | last << 16.
*
* IP_LOCAL_PORT_RANGE leaves the choice to the kernel, which knows which ports
* are free. Where the option does not exist, or the kernel refuses it, or the
* address family is not one it covers, pick a port at random instead and retry
* on failure, since the one picked may already be taken.
*/
int bindwithrange(struct clientparam *param, SOCKET sock, PROXYSOCKADDRTYPE *sa, uint32_t range)
{
uint16_t first, last;
int i;
if(!range) return param->srv->so._bind(param->sostate, sock, (struct sockaddr *)sa, SASIZE(sa));
#ifdef IP_LOCAL_PORT_RANGE
if(*SAFAMILY(sa) == AF_INET &&
!param->srv->so._setsockopt(param->sostate, sock, IPPROTO_IP, IP_LOCAL_PORT_RANGE,
(char *)&range, sizeof(range))){
*SAPORT(sa) = 0;
return param->srv->so._bind(param->sostate, sock, (struct sockaddr *)sa, SASIZE(sa));
}
#endif
first = (uint16_t)(range & 0xffff);
last = (uint16_t)(range >> 16);
for(i = 0; i < RANGETRIES; i++){
*SAPORT(sa) = htons((uint16_t)(first + (myrand() % (unsigned)(last - first + 1))));
if(!param->srv->so._bind(param->sostate, sock, (struct sockaddr *)sa, SASIZE(sa))) return 0;
}
/* Every port tried was taken. Fall back to an ephemeral one, which is
what the kernel option above does when it cannot honour the range, so
an exhausted range behaves the same way on every platform. */
*SAPORT(sa) = 0;
return param->srv->so._bind(param->sostate, sock, (struct sockaddr *)sa, SASIZE(sa));
}
/* Host lists in access rules have always accepted name, name*, *name and
*name*, with the leading and trailing star recorded as a match type rather
than kept in the string. The parser and the comparison are here so that
anything else matching a name against a pattern - the http command, and
whatever replaces the star with a regular expression later - behaves the same
way and gains the same syntax at the same time.
*/
int parsepattern(struct hostname *h, unsigned char *arg)
{
int arglen;
unsigned char *pattern;
arglen = (int)strlen((char *)arg);
h->matchtype = 3;
pattern = arg;
if(arglen && pattern[arglen-1] == '*'){
arglen--;
pattern[arglen] = 0;
h->matchtype ^= MATCHEND;
}
if(arglen && pattern[0] == '*'){
pattern++;
arglen--;
h->matchtype ^= MATCHBEGIN;
}
h->name = (unsigned char *)strdup((char *)pattern);
return h->name? 0 : 1;
}
/* Matches str against a pattern and reports the part a star stood for. Where a
pattern has a star at both ends the trailing one is reported, since that is
the part following the text that was matched. An exact pattern leaves an
empty span. */
int patternmatchpos(const struct hostname *h, const unsigned char *str, int *start, int *len)
{
int lname, lstr, pos = 0, match = 0;
char *found;
if(!h->name || !str) return 0;
lname = (int)strlen((char *)h->name);
lstr = (int)strlen((char *)str);
switch(h->matchtype){
case 0:
#ifndef _WIN32
found = strcasestr((char *)str, (char *)h->name);
#else
found = strstr((char *)str, (char *)h->name);
#endif
if(found){
match = 1;
pos = (int)(found - (char *)str) + lname;
}
break;
case 1:
if(!strncasecmp((char *)str, (char *)h->name, lname)){
match = 1;
pos = lname;
}
break;
case 2:
if(lstr >= lname &&
!strncasecmp((char *)str + (lstr - lname), (char *)h->name, lname)){
match = 1;
pos = 0;
if(start) *start = 0;
if(len) *len = lstr - lname;
return 1;
}
break;
default:
if(!strcasecmp((char *)str, (char *)h->name)){
match = 1;
pos = lstr;
}
break;
}
if(!match) return 0;
if(start) *start = pos;
if(len) *len = lstr - pos;
return 1;
}
int patternmatch(const struct hostname *h, const unsigned char *str)
{
return patternmatchpos(h, str, NULL, NULL);
}
int scanaddr(const unsigned char *s, uint32_t * ip, uint32_t * mask) {
unsigned d1, d2, d3, d4, m;
int res;

View File

@ -7,6 +7,10 @@
*/
#include "proxy.h"
#ifdef WITH_HTTPSRV
static int addhttprule(char *host, char *url, char *op, char *params);
#endif
#include "mdhash.h"
#ifdef WITH_SSL
void ssl_install(void);
@ -35,7 +39,6 @@ _3proxy_mutex_t config_mutex;
int haveerror = 0;
int linenum = 0;
FILE *writable;
struct counter_header cheader = {"3CF", (time_t)0};
struct counter_record crecord;
@ -60,10 +63,6 @@ FILE * confopen(){
curconf += strlen(chrootp);
}
#endif
if(writable) {
rewind(writable);
return writable;
}
return fopen(curconf, "r");
}
@ -158,7 +157,12 @@ int start_proxy_thread(struct child * chp){
pthread_attr_init(&pa);
pthread_attr_setstacksize(&pa,threadstacksize(conf.stacksize));
pthread_attr_setdetachstate(&pa,PTHREAD_CREATE_DETACHED);
pthread_create(&thread, &pa, startsrv, (void *)chp);
if(pthread_create(&thread, &pa, startsrv, (void *)chp)){
pthread_attr_destroy(&pa);
fprintf(stderr, "Failed to create service thread on line %d, try to set larger stacksize\n", linenum);
_3proxy_sem_unlock(conf.threadinit);
return(40);
}
pthread_attr_destroy(&pa);
#endif
_3proxy_sem_lock(conf.threadinit);
@ -253,12 +257,32 @@ static int h_proxy(int argc, unsigned char ** argv){
childdef.service = S_UDPPM;
childdef.helpmessage = " -s single packet UDP service for request/reply (DNS-like) services\n";
}
#ifdef WITH_HTTPSRV
else if(!strcmp((char *)argv[0], "admin")) {
childdef.pf = adminchild;
/* The same service as httpsrv, with the administration pages
declared for it. */
if(addhttprule("*", "/C*", "admin_counters", NULL) ||
addhttprule("*", "/R", "admin_reload", NULL) ||
addhttprule("*", "/S*", "admin_services", NULL) ||
addhttprule("*", "*", "admin", NULL)){
fprintf(stderr, "Failed to declare the admin pages, line %d\n", linenum);
return 1;
}
childdef.pf = httpsrvchild;
childdef.port = 80;
childdef.isudp = 0;
childdef.service = S_ADMIN;
childdef.service = S_HTTPSRV;
}
#endif
#ifdef WITH_HTTPSRV
else if(!strcmp((char *)argv[0], "httpsrv")) {
childdef.pf = httpsrvchild;
childdef.port = 80;
childdef.isudp = 0;
childdef.service = S_HTTPSRV;
childdef.helpmessage = " HTTP server, /echo describes the connection, /data?size=N returns N bytes\n";
}
#endif
else if(!strcmp((char *)argv[0], "dnspr")) {
childdef.pf = dnsprchild;
childdef.port = 53;
@ -765,14 +789,67 @@ struct redirdesc redirs[] = {
{R_SOCKS5P, "socks5+", sockschild},
{R_SOCKS4B, "socks4b", sockschild},
{R_SOCKS5B, "socks5b", sockschild},
{R_ADMIN, "admin", adminchild},
{R_EXTIP, "extip", NULL},
{R_EXTPORT, "extport", NULL},
{R_INTPORT, "intport", NULL},
{R_TLS, "tls", tlsprchild},
{R_HA, "ha", NULL},
{R_DNS, "dns", dnsprchild},
{0, NULL, NULL}
};
#ifdef WITH_HTTPSRV
/* Installs one rule from code, for the pages a service predefines. */
static int addhttprule(char *host, char *url, char *op, char *params)
{
struct httprule *rule, *tail;
unsigned char hostbuf[64], urlbuf[128];
rule = malloc(sizeof(struct httprule));
if(!rule) return 1;
memset(rule, 0, sizeof(struct httprule));
rule->op = httpopbyname((unsigned char *)op);
if(rule->op < 0){
free(rule);
return 1;
}
strcpy((char *)hostbuf, host);
strcpy((char *)urlbuf, url);
if(parsepattern(&rule->host, hostbuf) || parsepattern(&rule->url, urlbuf)){
free(rule->host.name);
free(rule);
return 1;
}
if(params) rule->params = (unsigned char *)strdup(params);
if(!conf.httprules) conf.httprules = rule;
else {
for(tail = conf.httprules; tail->next; tail = tail->next);
tail->next = rule;
}
return 0;
}
#endif
/* Parses an inclusive FIRST-LAST local port range into first | last << 16. */
static int parserange(unsigned char *arg, uint32_t *range)
{
char *end;
unsigned long first, last;
first = strtoul((char *)arg, &end, 10);
if(end == (char *)arg || *end != '-' || !first || first > 65535) return 1;
arg = (unsigned char *)end + 1;
last = strtoul((char *)arg, &end, 10);
if(end == (char *)arg || *end || !last || last > 65535 || last < first) return 1;
*range = (uint32_t)first | ((uint32_t)last << 16);
return 0;
}
static int h_parent(int argc, unsigned char **argv){
struct ace *acl = NULL;
struct chain *chains;
@ -838,7 +915,21 @@ static int h_parent(int argc, unsigned char **argv){
*cidr = '/';
chains->cidr = atoi(cidr + 1);
}
*SAPORT(&chains->addr) = htons((uint16_t)atoi((char *)argv[4]));
if(chains->type == R_EXTPORT || chains->type == R_INTPORT){
if(!SAISNULL(&chains->addr)){
fprintf(stderr, "Chaining error: chain type (%s) sets a local port range, it requires 0.0.0.0 as address on line %d\n", argv[2], linenum);
free(chains->exthost);
free(chains);
return(4);
}
if(parserange(argv[4], &chains->range)){
fprintf(stderr, "Chaining error: bad port range (%s) on line %d\n", argv[4], linenum);
free(chains->exthost);
free(chains);
return(3);
}
}
else *SAPORT(&chains->addr) = htons((uint16_t)atoi((char *)argv[4]));
switch(chains->type){
case R_POP3:
case R_SMTP:
@ -872,6 +963,50 @@ static int h_parent(int argc, unsigned char **argv){
}
#ifdef WITH_HTTPSRV
/* http <hostname> <url> <operation> [parameters]
Rules are matched in the order they are given, first match wins. */
static int h_http(int argc, unsigned char **argv){
struct httprule *rule, *tail;
int op;
op = httpopbyname(argv[3]);
if(op < 0){
fprintf(stderr, "Unknown http operation: %s line %d\n", argv[3], linenum);
return(1);
}
rule = malloc(sizeof(struct httprule));
if(!rule) return(21);
memset(rule, 0, sizeof(struct httprule));
rule->op = op;
if(parsepattern(&rule->host, argv[1]) || parsepattern(&rule->url, argv[2])){
fprintf(stderr, "No memory for http rule, line %d\n", linenum);
free(rule->host.name);
free(rule);
return(21);
}
if(argc > 4){
rule->params = (unsigned char *)strdup((char *)argv[4]);
if(!rule->params){
free(rule->host.name);
free(rule->url.name);
free(rule);
return(21);
}
}
if(!conf.httprules) conf.httprules = rule;
else {
for(tail = conf.httprules; tail->next; tail = tail->next);
tail->next = rule;
}
return 0;
}
#endif
static int h_nolog(int argc, unsigned char **argv){
struct ace *acl = NULL;
@ -1010,20 +1145,7 @@ struct ace * make_ace (int argc, unsigned char ** argv){
return(NULL);
}
memset(hostnamel, 0, sizeof(struct hostname));
hostnamel->matchtype = 3;
pattern = arg;
if(pattern[arglen-1] == '*'){
arglen --;
pattern[arglen] = 0;
hostnamel->matchtype ^= MATCHEND;
}
if(pattern[0] == '*'){
pattern++;
arglen--;
hostnamel->matchtype ^= MATCHBEGIN;
}
hostnamel->name = (unsigned char *) strdup( (char *)pattern);
if(!hostnamel->name) {
if(parsepattern(hostnamel, arg)) {
fprintf(stderr, "No memory for ACL entry, line %d\n", linenum);
return(NULL);
}
@ -1687,7 +1809,13 @@ struct commands commandhandlers[]={
{NULL, "socks", h_proxy, 1, 0},
{NULL, "tcppm", h_proxy, 4, 0},
{NULL, "udppm", h_proxy, 4, 0},
#ifdef WITH_HTTPSRV
{NULL, "admin", h_proxy, 1, 0},
#endif
#ifdef WITH_HTTPSRV
{NULL, "httpsrv", h_proxy, 1, 0},
{NULL, "http", h_http, 4, 5},
#endif
{NULL, "dnspr", h_proxy, 1, 0},
{NULL, "internal", h_internal, 2, 2},
{NULL, "external", h_external, 2, 2},
@ -1935,16 +2063,6 @@ int readconfig(FILE * fp){
if(!strcmp((char *)argv[0], "end") && argc == 1) {
break;
}
else if(!strcmp((char *)argv[0], "writable") && argc == 1) {
if(!writable){
writable = freopen(curconf, "r+", fp);
if(!writable){
fprintf(stderr, "Unable to reopen config for writing: %s\n", curconf);
return 1;
}
}
continue;
}
res = 1;
for(cm = commandhandlers; cm; cm = cm->next){
@ -2106,7 +2224,7 @@ int reload (void){
if(error) {
freeconf(&conf);
}
if(!writable)fclose(fp);
fclose(fp);
}
_3proxy_mutex_unlock(&config_mutex);
return error;

View File

@ -391,8 +391,6 @@ static void * ef_ace_next(struct node * node){
}
char * aceaction (int action);
static void * ef_ace_type(struct node * node){
return aceaction(((struct ace *)node->value) -> action);
}

View File

@ -149,7 +149,7 @@ void * dnsprchild(struct clientparam* param) {
}
memset(&param->sinsl, 0, sizeof(param->sinsl));
*SAFAMILY(&param->sinsl) = *SAFAMILY(&nservers[0].addr);
if(param->srv->so._bind(param->sostate, param->remsock,(struct sockaddr *)&param->sinsl,SASIZE(&param->sinsl))) {
if(bindwithrange(param, param->remsock, &param->sinsl, param->extport)) {
RETURN(819);
}
param->sinsr = nservers[0].addr;

View File

@ -121,7 +121,7 @@ void * ftpprchild(struct clientparam* param) {
}
if ((clidatasock=socket(SASOCK(&param->sincl), SOCK_STREAM, IPPROTO_TCP)) == INVALID_SOCKET) {RETURN(821);}
*SAPORT(&param->sincl) = 0;
if(param->srv->so._bind(param->sostate, clidatasock, (struct sockaddr *)&param->sincl, SASIZE(&param->sincl))){RETURN(822);}
if(bindwithrange(param, clidatasock, &param->sincl, param->intport)){RETURN(822);}
if (pasv) {
if(param->srv->so._listen(param->sostate, clidatasock, 1)) {RETURN(823);}
sasize = sizeof(param->sincl);

517
src/httpsrv.c Normal file
View File

@ -0,0 +1,517 @@
/*
3proxy - HTTP server
A small HTTP/1.0 server. The request is parsed into a struct httpreq and
handed to a handler chosen from a table by path, so the transport, request
parsing and response helpers are shared and a new endpoint is one row in
httphandlers[] plus a function.
The handlers built today generate deterministic responses for the regression
tests: /echo describes the connection as seen by the server, which is how a
test tells which source address and port a request arrived from, and /data
produces a requested amount of output.
Parsing here is deliberately blunt - fixed buffers, bounded reads, no shared
request parser - so that a fault in the code under test cannot be cancelled
out by the same fault in the server used to observe it.
*/
#include "proxy.h"
#ifdef WITH_HTTPSRV
#include <stdarg.h>
#define RETURN(xxx) { param->res = xxx; goto CLEANRET; }
#define HTTPSRV_LINE 1024
#define HTTPSRV_BLOCK 8192
#define HTTPSRV_MAXHDR 64
/* Returns the value of a query parameter, or def when it is missing or not a
number. Values are clamped by the caller, not here. */
/* Copies a request field, refusing anything that does not fit rather than
storing a shortened copy. strncpy would leave the result unterminated at
exactly the length that overflows - it is only safe with a zeroed struct -
and a truncated path or host is worse than a rejected one, since it would be
matched against the rules as though the client had sent the shorter string.
*/
static int hexval(int c)
{
if(c >= '0' && c <= '9') return c - '0';
if(c >= 'a' && c <= 'f') return c - 'a' + 10;
if(c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
/* Decodes %XX sequences. A malformed sequence, or one decoding to a NUL that
would cut the path short, is refused rather than passed on. */
static int urldecode(char *dst, size_t size, const char *src)
{
size_t o = 0;
while(*src){
int c = (unsigned char)*src++;
if(c == '%'){
int hi, lo;
hi = hexval((unsigned char)src[0]);
if(hi < 0) return 1;
lo = hexval((unsigned char)src[1]);
if(lo < 0) return 1;
c = (hi << 4) | lo;
src += 2;
}
if(!c) return 1;
if(o + 1 >= size) return 1;
dst[o++] = (char)c;
}
dst[o] = 0;
return 0;
}
/* Checked after decoding, because the encoded form hides both of these. */
static int pathunsafe(const char *path)
{
if(strstr(path, "/..")) return 1;
if(strchr(path, '\r') || strchr(path, '\n')) return 1;
return 0;
}
static int copyfield(char *dst, size_t size, const char *src)
{
size_t len = strlen(src);
if(len >= size) return 1;
memcpy(dst, src, len + 1);
return 0;
}
static long qparam(const char *query, const char *name, long def)
{
const char *p;
size_t len;
char *end;
long val;
if(!query || !*query) return def;
len = strlen(name);
for(p = query; *p; ){
if(!strncmp(p, name, len) && p[len] == '='){
val = strtol(p + len + 1, &end, 10);
if(end == p + len + 1) return def;
return val;
}
p = strchr(p, '&');
if(!p) break;
p++;
}
return def;
}
static int httpsrv_send(struct httpreq *r, const char *buf, int len)
{
return socksend(r->param, r->param->clisock, (unsigned char *)buf, len,
conf.timeouts[STRING_S]) != len;
}
static int httpsrv_printf(struct httpreq *r, const char *fmt, ...)
{
char buf[HTTPSRV_LINE];
int len;
va_list ap;
va_start(ap, fmt);
len = vsnprintf(buf, sizeof(buf), fmt, ap);
va_end(ap);
if(len < 0) return 1;
if(len > (int)sizeof(buf) - 1) len = (int)sizeof(buf) - 1;
return httpsrv_send(r, buf, len);
}
/* Writes the status line and headers. A negative length asks for chunked
encoding, which is how a response of unknown or deliberately unstated size is
produced. */
static int httpsrv_head(struct httpreq *r, int status, const char *ctype, long len)
{
const char *text;
switch(status){
case 200: text = "OK"; break;
case 204: text = "No Content"; break;
case 400: text = "Bad Request"; break;
case 404: text = "Not Found"; break;
case 500: text = "Internal Server Error"; break;
case 503: text = "Service Unavailable"; break;
default: text = "Unknown"; break;
}
if(httpsrv_printf(r, "HTTP/1.0 %d %s\r\n", status, text)) return 1;
if(httpsrv_printf(r, "Content-Type: %s\r\n", ctype)) return 1;
if(len >= 0){
if(httpsrv_printf(r, "Content-Length: %ld\r\n", len)) return 1;
}
else if(httpsrv_printf(r, "Transfer-Encoding: chunked\r\n")) return 1;
return httpsrv_printf(r, "Connection: close\r\n\r\n");
}
/* Wraps one block as a chunk, a zero length writing the terminating chunk.
Takes the client rather than a request so that anything writing a chunked
response can use it. */
int httpchunk(struct clientparam *param, const char *buf, int len)
{
char hdr[16];
int hlen;
if(len <= 0){
return socksend(param, param->clisock, (unsigned char *)"0\r\n\r\n", 5,
conf.timeouts[STRING_S]) != 5;
}
hlen = sprintf(hdr, "%x\r\n", len);
if(socksend(param, param->clisock, (unsigned char *)hdr, hlen,
conf.timeouts[STRING_S]) != hlen) return 1;
if(socksend(param, param->clisock, (unsigned char *)buf, len,
conf.timeouts[STRING_S]) != len) return 1;
return socksend(param, param->clisock, (unsigned char *)"\r\n", 2,
conf.timeouts[STRING_S]) != 2;
}
/* Fills buf with a repeating pattern carrying its own offset, so a truncated or
reordered body is visible in the output rather than looking like a short
read. */
static void httpsrv_fill(char *buf, int len, unsigned long offset)
{
int i;
for(i = 0; i < len; i++){
unsigned long pos = offset + (unsigned long)i;
buf[i] = (pos % 64 == 63)? '\n' : (char)('0' + (int)((pos / 64) % 10));
}
}
static int op_echo(struct httpreq *r, const unsigned char *params)
{
struct clientparam *param = r->param;
char addr[64];
char body[HTTPSRV_LINE * 2];
int len;
PROXYSOCKADDRTYPE sa;
SASIZETYPE sasize = sizeof(sa);
memset(&sa, 0, sizeof(sa));
if(param->srv->so._getpeername(param->sostate, param->clisock,
(struct sockaddr *)&sa, &sasize) ||
!myinet_ntop(*SAFAMILY(&sa), SAADDR(&sa), addr, sizeof(addr))){
strcpy(addr, "unknown");
}
len = snprintf(body, sizeof(body),
"peer.addr=%s\n"
"peer.port=%hu\n"
"method=%s\n"
"path=%s\n"
"query=%s\n"
"host=%s\n"
"content.length=%lu\n"
"glob.start=%d\n"
"glob.len=%d\n"
"glob=%.*s\n",
addr, ntohs(*SAPORT(&sa)), r->method, r->path, r->query,
r->host, r->contentlen, r->globstart, r->globlen,
r->globlen, r->path + r->globstart);
if(len < 0) return 1;
if(len > (int)sizeof(body) - 1) len = (int)sizeof(body) - 1;
if(httpsrv_head(r, 200, "text/plain", (long)len)) return 1;
return httpsrv_send(r, body, len);
}
/* /data?size=N&chunked=0|1&status=NNN&block=N&delay=ms
Produces exactly N bytes of body. */
static int op_data(struct httpreq *r, const unsigned char *params)
{
char buf[HTTPSRV_BLOCK];
long size, block, delay, status;
int chunked;
unsigned long sent = 0;
size = qparam((const char *)params, "size", 0);
size = qparam(r->query, "size", size);
if(size < 0) size = 0;
block = qparam((const char *)params, "block", HTTPSRV_BLOCK);
block = qparam(r->query, "block", block);
if(block < 1 || block > HTTPSRV_BLOCK) block = HTTPSRV_BLOCK;
status = qparam((const char *)params, "status", 200);
status = qparam(r->query, "status", status);
if(status < 100 || status > 599) status = 200;
chunked = qparam(r->query, "chunked", qparam((const char *)params, "chunked", 0)) != 0;
delay = qparam(r->query, "delay", qparam((const char *)params, "delay", 0));
if(httpsrv_head(r, (int)status, "application/octet-stream",
chunked? -1 : size)) return 1;
while(sent < (unsigned long)size){
int len = (int)block;
if((unsigned long)len > (unsigned long)size - sent) len = (int)(size - sent);
httpsrv_fill(buf, len, sent);
if(delay > 0){
#ifdef _WIN32
usleep(delay);
#else
usleep(delay * 1000);
#endif
}
if(chunked){
if(httpchunk(r->param, buf, len)) return 1;
}
else if(httpsrv_send(r, buf, len)) return 1;
sent += (unsigned long)len;
}
if(chunked) return httpchunk(r->param, NULL, 0);
return 0;
}
static int op_authrequired(struct httpreq *r)
{
static const char body[] = "authentication required\n";
if(httpsrv_printf(r, "HTTP/1.0 401 Authentication Required\r\n"
"WWW-Authenticate: Basic realm=\"3proxy\"\r\n"
"Content-Type: text/plain\r\n"
"Content-Length: %d\r\n"
"Connection: close\r\n\r\n", (int)sizeof(body) - 1)) return 1;
return httpsrv_send(r, body, (int)sizeof(body) - 1);
}
static int op_forbidden(struct httpreq *r)
{
static const char body[] = "forbidden\n";
if(httpsrv_head(r, 403, "text/plain", (long)sizeof(body) - 1)) return 1;
return httpsrv_send(r, body, (int)sizeof(body) - 1);
}
static int op_badrequest(struct httpreq *r)
{
static const char body[] = "bad request\n";
if(httpsrv_head(r, 400, "text/plain", (long)sizeof(body) - 1)) return 1;
return httpsrv_send(r, body, (int)sizeof(body) - 1);
}
static int op_notfound(struct httpreq *r)
{
static const char body[] = "not found\n";
if(httpsrv_head(r, 404, "text/plain", (long)sizeof(body) - 1)) return 1;
return httpsrv_send(r, body, (int)sizeof(body) - 1);
}
/* Operations an http line can name. The rule supplies the parameters, so the
same operation serves different content on different urls. */
static struct httpop {
const char *name;
int (*fn)(struct httpreq *, const unsigned char *params);
} httpops[] = {
{"echo", op_echo},
{"data", op_data},
{"admin", op_admin},
{"admin_counters", op_admin_counters},
{"admin_reload", op_admin_reload},
{"admin_services", op_admin_services},
{NULL, NULL}
};
void freehttprules(struct httprule *rule)
{
struct httprule *next;
while(rule){
next = rule->next;
if(rule->host.name) free(rule->host.name);
if(rule->url.name) free(rule->url.name);
if(rule->params) free(rule->params);
free(rule);
rule = next;
}
}
int httpopbyname(const unsigned char *name)
{
int i;
for(i = 0; httpops[i].name; i++){
if(!strcmp((char *)name, httpops[i].name)) return i;
}
return -1;
}
void * httpsrvchild(struct clientparam *param)
{
struct httpreq r;
char buf[HTTPSRV_LINE];
char *sp, *q;
struct httprule *rule;
int i, hdrs = 0;
memset(&r, 0, sizeof(r));
r.param = param;
i = sockgetlinebuf(param, CLIENT, (unsigned char *)buf, sizeof(buf) - 1, '\n',
conf.timeouts[STRING_S]);
if(i < 5) RETURN(701);
buf[i] = 0;
sp = strchr(buf, ' ');
if(!sp) RETURN(702);
*sp = 0;
if(copyfield(r.method, sizeof(r.method), buf)) RETURN(703);
if(!strcasecmp(r.method, "GET")) param->operation = HTTP_GET;
else if(!strcasecmp(r.method, "POST")) param->operation = HTTP_POST;
else if(!strcasecmp(r.method, "PUT")) param->operation = HTTP_PUT;
else if(!strcasecmp(r.method, "HEAD")) param->operation = HTTP_HEAD;
else param->operation = HTTP_OTHER;
while(*++sp == ' ');
q = strchr(sp, ' ');
if(q) *q = 0;
q = sp + strcspn(sp, "\r\n");
*q = 0;
q = strchr(sp, '?');
if(q){
*q = 0;
if(copyfield(r.query, sizeof(r.query), q + 1)) RETURN(704);
}
{
char decoded[sizeof(r.path)];
/* Keep the raw path first so a refused request still records what
was asked for. */
if(copyfield(r.path, sizeof(r.path), sp)) RETURN(705);
if(urldecode(decoded, sizeof(decoded), sp)) RETURN(707);
if(pathunsafe(decoded)) RETURN(708);
strcpy(r.path, decoded);
}
while(hdrs++ < HTTPSRV_MAXHDR &&
(i = sockgetlinebuf(param, CLIENT, (unsigned char *)buf, sizeof(buf) - 1,
'\n', conf.timeouts[STRING_S])) > 2){
buf[i] = 0;
if(!strncasecmp(buf, "host:", 5)){
sp = buf + 5;
while(isspace((unsigned char)*sp)) sp++;
sp[strcspn(sp, "\r\n")] = 0;
if(copyfield(r.host, sizeof(r.host), sp)) RETURN(706);
}
else if(!strncasecmp(buf, "authorization:", 14)){
char creds[256];
int clen;
sp = buf + 14;
while(isspace((unsigned char)*sp)) sp++;
if(strncasecmp(sp, "basic", 5)) continue;
sp += 5;
while(isspace((unsigned char)*sp)) sp++;
sp[strcspn(sp, "\r\n")] = 0;
clen = de64((unsigned char *)sp, (unsigned char *)creds, sizeof(creds) - 1);
if(clen <= 0) continue;
creds[clen] = 0;
q = strchr(creds, ':');
if(q){
*q = 0;
if(param->password) free(param->password);
param->password = (unsigned char *)strdup(q + 1);
}
if(param->username) free(param->username);
param->username = (unsigned char *)strdup(creds);
}
else if(!strncasecmp(buf, "content-length:", 15)){
sscanf(buf + 15, "%lu", &r.contentlen);
}
}
if(r.host[0]){
char host[sizeof(r.host)];
char *colon;
/* Access rules match a bare name, so drop the port the client sent.
An address in brackets keeps its colons. */
strcpy(host, r.host);
colon = (*host == '[')? strchr(host, ']') : host;
if(colon){
colon = strchr(colon, ':');
if(colon) *colon = 0;
}
if(*host == '['){
memmove(host, host + 1, strlen(host));
colon = strchr(host, ']');
if(colon) *colon = 0;
}
if(param->hostname) free(param->hostname);
param->hostname = (unsigned char *)strdup(host);
}
/* The request is answered here, so the address it was sent to is the
destination an access rule should match. Authorization skips doconnect
for this service, so naming a destination cannot start a connection. */
param->req = param->sincl;
i = (*param->srv->authfunc)(param);
if(i && i != 10){
/* 4 no credentials, 5 unknown user, 6 wrong password: all of them
should let the client offer credentials again. */
if(i >= 4 && i <= 6) op_authrequired(&r);
else op_forbidden(&r);
RETURN(i);
}
for(rule = param->srv->httprules; rule; rule = rule->next){
if(patternmatch(&rule->host, (unsigned char *)r.host) &&
patternmatchpos(&rule->url, (unsigned char *)r.path,
&r.globstart, &r.globlen)){
httpops[rule->op].fn(&r, rule->params);
RETURN(0);
}
}
op_notfound(&r);
RETURN(404);
CLEANRET:
if(param->res >= 700 && param->res < 800) op_badrequest(&r);
/* Log the request the way the proxy does: the parameters decide what was
served, so a bare path is not enough to explain a response. */
{
char logbuf[sizeof(r.method) + sizeof(r.host) + sizeof(r.path) +
sizeof(r.query) + 8];
sprintf(logbuf, "%s %s %s%s%s", r.method[0]? r.method : "-",
r.host[0]? r.host : "-", r.path,
r.query[0]? "?" : "", r.query);
dolog(param, (unsigned char *)logbuf);
}
return NULL;
}
#endif

View File

@ -15,7 +15,9 @@ void decodeurl(unsigned char *s, int allowcr);
int parsestr (unsigned char *str, unsigned char **argm, int nitems, unsigned char ** buff, int *inbuf, int *bufsize);
struct ace * make_ace (int argc, unsigned char ** argv);
extern char * proxy_stringtable[];
#ifdef WITH_HTTPSRV
extern char * admin_stringtable[];
#endif
extern struct schedule * schedule;
int start_proxy_thread(struct child * chp);
@ -59,7 +61,6 @@ struct symbol symbols[] = {
{symbols+34, "socks", (void *) sockschild},
{symbols+35, "tcppm", (void *) tcppmchild},
{symbols+36, "udppm", (void *) udppmchild},
{symbols+37, "admin", (void *) adminchild},
{symbols+38, "ftppr", (void *) ftpprchild},
{symbols+39, "smtpp", (void *) smtppchild},
{symbols+40, "auto", (void *) smtppchild},
@ -121,7 +122,11 @@ struct pluginlink pluginlink = {
proxy_stringtable,
&schedule,
freeacl,
#ifdef WITH_HTTPSRV
admin_stringtable,
#else
NULL,
#endif
&childdef,
start_proxy_thread,
freeparam,

View File

@ -135,6 +135,23 @@ void daemonize(void);
#endif
#endif
/* wolfSSL reserves around 48K of static thread-local storage. glibc counts
that against the thread stack, so pthread_create() fails with EINVAL and
no thread starts at all. musl places the block next to the stack instead
of inside it and needs nothing extra, and OpenSSL has no static TLS.
musl identifies itself by no macro of its own, but it does not define
__GLIBC__, which any libc header pulled in above would have set.
*/
#ifndef TLSSTACKSIZE
#if defined(__linux__) && !defined(__GLIBC__)
#define TLSSTACKSIZE 0
#elif defined(WITH_WOLFSSL)
#define TLSSTACKSIZE 49152
#else
#define TLSSTACKSIZE 0
#endif
#endif
#ifndef _WIN32
size_t threadstacksize(int extra);
#endif
@ -322,6 +339,7 @@ int parseusername(char *username, struct clientparam *param, int extpasswd);
int parseconnusername(char *username, struct clientparam *param, int extpasswd, uint16_t port);
int ACLmatches(struct ace* acentry, struct clientparam * param);
int checkACL(struct clientparam * param);
char * aceaction (int action);
extern int havelog;
uint32_t udpresolve(int af, unsigned char * name, unsigned char * value, uint32_t *retttl, struct clientparam* param, int makeauth);
@ -352,6 +370,11 @@ unsigned char * dologname (unsigned char *buf, unsigned char *name, const unsign
int readconfig(FILE * fp);
void initcommands(void);
int connectwithpoll(struct clientparam *param, SOCKET sock, struct sockaddr *sa, SASIZETYPE size, int to);
int bindwithrange(struct clientparam *param, SOCKET sock, PROXYSOCKADDRTYPE *sa, uint32_t range);
int parsepattern(struct hostname *h, unsigned char *arg);
int patternmatch(const struct hostname *h, const unsigned char *str);
int patternmatchpos(const struct hostname *h, const unsigned char *str, int *start, int *len);
void applyportranges(struct clientparam * param, struct ace * acentry);
uint32_t myrand(void);
@ -371,7 +394,18 @@ void * sockschild(struct clientparam * param);
void * tcppmchild(struct clientparam * param);
void * autochild(struct clientparam * param);
void * udppmchild(struct clientparam * param);
void * adminchild(struct clientparam * param);
#ifdef WITH_HTTPSRV
int op_admin(struct httpreq *r, const unsigned char *params);
int op_admin_counters(struct httpreq *r, const unsigned char *params);
int op_admin_reload(struct httpreq *r, const unsigned char *params);
int op_admin_services(struct httpreq *r, const unsigned char *params);
#endif
#ifdef WITH_HTTPSRV
void * httpsrvchild(struct clientparam * param);
int httpopbyname(const unsigned char *name);
int httpchunk(struct clientparam *param, const char *buf, int len);
void freehttprules(struct httprule *rule);
#endif
void * ftpprchild(struct clientparam * param);
void * tlsprchild(struct clientparam * param);
/* Child functions return the child to redirect the request to, or NULL if

View File

@ -414,6 +414,15 @@ int MODULEMAINFUNC (int argc, char** argv){
#endif
srv.service = defparam.service = childdef.service;
#ifdef WITH_HTTPSRV
/* http lines accumulate until a service claims them, so each httpsrv takes
the rules written above it and the next one starts empty. */
if(srv.service == S_HTTPSRV){
srv.httprules = conf.httprules;
conf.httprules = NULL;
}
#endif
#ifndef STDMAIN
if(conf.acl){
srv.acl = copyacl(conf.acl);
@ -1335,6 +1344,9 @@ void srvfree(struct srvparam * srv){
}
if(srv->acl)freeacl(srv->acl);
#ifdef WITH_HTTPSRV
if(srv->httprules)freehttprules(srv->httprules);
#endif
if(srv->authfuncs)freeauth(srv->authfuncs);
#endif
_3proxy_mutex_destroy(&srv->counter_mutex);

View File

@ -259,6 +259,20 @@ int clientnegotiate(struct chain * redir, struct clientparam * param, struct soc
}
/* The local port ranges do not depend on the destination, so they can be taken
* as soon as a rule matches. UDP ASSOCIATE is authorized before the destination
* is known and returns before the chain is walked, which would otherwise leave
* the socket the client sends its datagrams to outside the configured range.
*/
void applyportranges(struct clientparam * param, struct ace * acentry){
struct chain *cur;
for(cur = acentry->chains; cur; cur = cur->next){
if(cur->type == R_EXTPORT) param->extport = cur->range;
else if(cur->type == R_INTPORT) param->intport = cur->range;
}
}
int handleredirect(struct clientparam * param, struct ace * acentry){
int connected = 0;
int weight = 1000;
@ -285,7 +299,8 @@ int handleredirect(struct clientparam * param, struct ace * acentry){
}
continue;
}
if(cur->type != R_EXTIP && cur->type != R_HA) param->redirected++;
if(cur->type != R_EXTIP && cur->type != R_HA &&
cur->type != R_EXTPORT && cur->type != R_INTPORT) param->redirected++;
done = 1;
if(weight <= 0) {
weight += 1000;
@ -293,6 +308,12 @@ int handleredirect(struct clientparam * param, struct ace * acentry){
r2 = (myrand()%1000);
}
if(!connected){
if(cur->type == R_EXTPORT || cur->type == R_INTPORT){
if(cur->type == R_EXTPORT) param->extport = cur->range;
else param->intport = cur->range;
if(cur->next)continue;
return 0;
}
if(cur->type == R_EXTIP){
param->sinsl = cur->addr;
if(SAISNULL(&param->sinsl) && (*SAFAMILY(&param->sincr) == AF_INET || *SAFAMILY(&param->sincr) == AF_INET6))param->sinsl = param->sincr;

View File

@ -218,7 +218,10 @@ void * sockschild(struct clientparam* param) {
if((res = udpbind(param))) {RETURN(res);}
}
else if(command == 2) {
if(param->srv->so._bind(param->sostate, param->remsock,(struct sockaddr *)&param->sinsl,SASIZE(&param->sinsl))) {
if(bindwithrange(param, param->remsock, &param->sinsl, param->extport)) {
/* a range has already been searched, retrying on any port would
ignore what was asked for */
if(param->extport) RETURN (12);
*SAPORT(&param->sinsl) = 0;
if(param->srv->so._bind(param->sostate, param->remsock,(struct sockaddr *)&param->sinsl,SASIZE(&param->sinsl)))RETURN (12);
#if SOCKSTRACE > 0
@ -243,7 +246,8 @@ fflush(stderr);
#endif
sin = param->sincl;
*SAPORT(&sin) = 0;
if(param->srv->so._bind(param->sostate, param->clisock,(struct sockaddr *)&sin,SASIZE(&sin))) {RETURN (12);}
/* the port the client is told to send its datagrams to */
if(bindwithrange(param, param->clisock, &sin, param->intport)) {RETURN (12);}
sasize = SASIZE(&sin);
param->srv->so._getsockname(param->sostate, param->clisock, (struct sockaddr *)&sin, &sasize);
#if SOCKSTRACE > 0

View File

@ -216,6 +216,7 @@ typedef enum {
S_AUTO,
S_TLSPR,
S_IMAPP,
S_HTTPSRV,
S_ZOMBIE
}PROXYSERVICE;
@ -313,7 +314,9 @@ typedef enum {
R_TLS,
R_HA,
R_DNS,
R_IMAP
R_IMAP,
R_EXTPORT,
R_INTPORT
} REDIRTYPE;
struct redirdesc {
@ -335,6 +338,8 @@ struct chain {
unsigned char * extpass;
unsigned short weight;
unsigned short cidr;
/* local port range for extport/intport, first in the low half */
uint32_t range;
};
struct period {
@ -352,6 +357,28 @@ struct hostname {
int matchtype;
};
/* A request handed to an http operation. */
struct httpreq {
struct clientparam *param;
char method[16];
char path[256];
char query[512];
char host[256];
unsigned long contentlen;
int globstart, globlen;
};
/* One "http" line: which host and url it answers for, which operation serves
it and the parameters that operation takes. Patterns use the same syntax and
the same matcher as host lists in access rules. */
struct httprule {
struct httprule *next;
struct hostname host;
struct hostname url;
int op;
unsigned char *params;
};
struct ace {
struct ace *next;
int action;
@ -579,6 +606,9 @@ struct srvparam {
struct auth *authenticate;
struct pollfd * srvfds;
struct ace *acl;
#ifdef WITH_HTTPSRV
struct httprule *httprules;
#endif
struct auth *authfuncs;
struct filter *filter;
unsigned char * logformat;
@ -669,6 +699,7 @@ struct clientparam {
maxtrafout64;
PROXYSOCKADDRTYPE sincl, sincr;
PROXYSOCKADDRTYPE sinsl, sinsr, req;
uint32_t extport, intport;
uint64_t statscli64,
statssrv64;
@ -697,6 +728,9 @@ struct extparam {
_3proxy_sem_t threadinit;
int *timeouts;
struct ace * acl;
#ifdef WITH_HTTPSRV
struct httprule *httprules;
#endif
char * conffile;
struct bandlim * bandlimiter, *bandlimiterout;
struct connlim * connlimiter;

View File

@ -121,8 +121,12 @@ int udpbind(struct clientparam *param)
fcntl(s, F_SETFL, O_NONBLOCK | fcntl(s, F_GETFL));
#endif
param->remsock = s;
if (param->srv->so._bind(param->sostate, param->remsock,
(struct sockaddr *)&param->sinsl, SASIZE(&param->sinsl))) {
if (bindwithrange(param, param->remsock, &param->sinsl, param->extport)) {
if (param->extport) {
param->srv->so._closesocket(param->sostate, param->remsock);
param->remsock = INVALID_SOCKET;
return 12;
}
*SAPORT(&param->sinsl) = 0;
if (param->srv->so._bind(param->sostate, param->remsock,
(struct sockaddr *)&param->sinsl, SASIZE(&param->sinsl))) {

View File

@ -8,11 +8,12 @@
#include "proxy.h"
#ifdef WITH_HTTPSRV
#define RETURN(xxx) { param->res = xxx; goto CLEANRET; }
#define LINESIZE 65536
extern FILE *writable;
FILE * confopen();
extern void decodeurl(unsigned char *s, int filter);
@ -24,35 +25,6 @@ struct printparam {
struct clientparam *cp;
};
char * aceaction (int action){
switch (action) {
case ALLOW:
case REDIRECT:
return "allow";
case DENY:
return "deny";
case BANDLIM:
return "bandlim";
case NOBANDLIM:
return "nobandlim";
case COUNTIN:
return "countin";
case NOCOUNTIN:
return "nocountin";
case COUNTOUT:
return "countout";
case NOCOUNTOUT:
return "nocountout";
case COUNTALL:
return "countall";
case NOCOUNTALL:
return "nocountall";
default:
return "unknown";
}
}
static void stdpr(struct printparam* pp, char *buf, int inbuf){
if((pp->inbuf + inbuf > 1024) || !buf) {
socksend(pp->cp, pp->cp->clisock, (unsigned char *)pp->buf, pp->inbuf, conf.timeouts[STRING_S]);
@ -211,8 +183,7 @@ char * admin_stringtable[]={
"&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</h2>\r\n"
"<A HREF=\'/C'>Counters</A><br>\r\n"
"<A HREF=\'/R'>Reload</A><br>\r\n"
"<A HREF=\'/S'>Running Services</A><br>\r\n"
"<A HREF=\'/F'>Config</A>\r\n"
"<A HREF=\'/S'>Running Services</A>\r\n"
"</td><td>"
"<h2>%s %s configuration</h2>",
@ -367,92 +338,62 @@ static int printiplist(char *buf, int bufsize, struct iplist* ipl, char * delim)
return printed;
}
void * adminchild(struct clientparam* param) {
int i, res;
/* The admin pages are http operations: the service, request parsing and
authorization belong to httpsrv, and what is left here is the page itself.
A star in the url carries the selector the pages used to read out of the
path, so /C with a star gives D2 or S2 to disable or enable a counter. */
static char * admin_open(struct printparam *pp, struct clientparam *param)
{
char *buf;
char username[256];
char *sb;
char *req = NULL;
struct printparam pp;
unsigned contentlen = 0;
int isform = 0;
int limited = 0;
limited =param->srv->s_option;
pp.inbuf = 0;
pp.cp = param;
pp->inbuf = 0;
pp->cp = param;
buf = malloc(LINESIZE);
if(!buf) {RETURN(555);}
i = sockgetlinebuf(param, CLIENT, (unsigned char *)buf, LINESIZE - 1, '\n', conf.timeouts[STRING_S]);
if(i<5 || ((buf[0]!='G' || buf[1]!='E' || buf[2]!='T' || buf[3]!=' ' || buf[4]!='/') &&
(buf[0]!='P' || buf[1]!='O' || buf[2]!='S' || buf[3]!='T' || buf[4]!=' ' || buf[5]!='/')))
if(!buf) return NULL;
sprintf(buf, ok, conf.stringtable?(char *)conf.stringtable[2]:"3proxy",
conf.stringtable?(char *)conf.stringtable[2]:"3[APA3A] tiny proxy",
conf.stringtable?(char *)conf.stringtable[3]:"");
printstr(pp, buf);
return buf;
}
static void admin_close(struct printparam *pp, char *buf)
{
RETURN(701);
printstr(pp, tail);
printstr(pp, NULL);
if(buf) free(buf);
}
buf[i] = 0;
sb = strchr(buf+5, ' ');
if(!sb){
RETURN(702);
int op_admin(struct httpreq *r, const unsigned char *params)
{
struct printparam pp;
char *buf;
buf = admin_open(&pp, r->param);
if(!buf) return 1;
printstr(&pp, (char *)conf.stringtable[WEBBANNERS]);
admin_close(&pp, buf);
return 0;
}
*sb = 0;
req = strdup(buf + ((*buf == 'P')? 6 : 5));
while((i = sockgetlinebuf(param, CLIENT, (unsigned char *)buf, LINESIZE - 1, '\n', conf.timeouts[STRING_S])) > 2){
buf[i] = 0;
if(i > 19 && (!strncasecmp(buf, "authorization", 13))){
sb = strchr(buf, ':');
if(!sb)continue;
++sb;
while(isspace(*sb))sb++;
if(!*sb || strncasecmp(sb, "basic", 5)){
continue;
}
sb+=5;
while(isspace(*sb))sb++;
i = de64((unsigned char *)sb, (unsigned char *)username, 255);
if(i<=0)continue;
username[i] = 0;
sb = strchr((char *)username, ':');
if(sb){
*sb = 0;
if(param->password)free(param->password);
param->password = (unsigned char *)strdup(sb+1);
}
if(param->username) free(param->username);
param->username = (unsigned char *)strdup(username);
continue;
}
else if(i > 15 && (!strncasecmp(buf, "content-length:", 15))){
sb = buf + 15;
while(isspace(*sb))sb++;
sscanf(sb, "%u", &contentlen);
if(contentlen > LINESIZE*1024) contentlen = 0;
}
else if(i > 13 && (!strncasecmp(buf, "content-type:", 13))){
sb = buf + 13;
while(isspace(*sb))sb++;
if(!strncasecmp(sb, "x-www-form-urlencoded", 21)) isform = 1;
}
}
param->operation = ADMIN;
if(isform && contentlen) {
printstr(&pp, "HTTP/1.0 100 Continue\r\n\r\n");
stdpr(&pp, NULL, 0);
}
res = (*param->srv->authfunc)(param);
if(res && res != 10) {
printstr(&pp, authreq);
RETURN(res);
}
if(limited || param->redirected){
if(*req == 'C') req[1] = 0;
else *req = 0;
}
sprintf(buf, ok, conf.stringtable?(char *)conf.stringtable[2]:"3proxy", conf.stringtable?(char *)conf.stringtable[2]:"3[APA3A] tiny proxy", conf.stringtable?(char *)conf.stringtable[3]:"");
if(*req != 'S') printstr(&pp, buf);
switch(*req){
case 'C':
int op_admin_counters(struct httpreq *r, const unsigned char *params)
{
struct clientparam *param = r->param;
struct printparam pp;
char *buf;
const char *sel;
int limited;
limited = param->srv->s_option;
/* In limited mode a counter may be looked at but not switched. */
sel = limited? "" : r->path + r->globstart;
buf = admin_open(&pp, param);
if(!buf) return 1;
printstr(&pp, counters);
{
struct trafcount *cp;
@ -463,8 +404,8 @@ void * adminchild(struct clientparam* param) {
if(cp->ace && (limited || param->redirected)){
if(!ACLmatches(cp->ace, param))continue;
}
if(req[1] == 'S' && atoi(req+2) == num) cp->disabled=0;
if(req[1] == 'D' && atoi(req+2) == num) cp->disabled=1;
if(sel[0] == 'S' && atoi(sel+1) == num) cp->disabled=0;
if(sel[0] == 'D' && atoi(sel+1) == num) cp->disabled=1;
inbuf += sprintf(buf, "<tr><td>%s</td><td>", cp->ace?aceaction(cp->ace->action):"-");
if(cp->number || cp->comment)
inbuf += sprintf(buf+inbuf, "%d/%s</td>" , cp->number,
@ -535,85 +476,55 @@ void * adminchild(struct clientparam* param) {
}
printstr(&pp, counterstail);
break;
case 'R':
admin_close(&pp, buf);
return 0;
}
int op_admin_reload(struct httpreq *r, const unsigned char *params)
{
struct printparam pp;
char *buf;
buf = admin_open(&pp, r->param);
if(!buf) return 1;
if(r->param->srv->s_option) printstr(&pp, (char *)conf.stringtable[WEBBANNERS]);
else {
conf.needreload = 1;
printstr(&pp, "<h3>Reload scheduled</h3>");
break;
case 'S':
{
if(req[1] == 'X'){
printstr(&pp, style);
break;
}
admin_close(&pp, buf);
return 0;
}
int op_admin_services(struct httpreq *r, const unsigned char *params)
{
struct clientparam *param = r->param;
struct printparam pp;
char *buf;
const char *sel;
if(param->srv->s_option) return op_admin(r, params);
sel = r->path + r->globstart;
/* This page is xml, so it carries its own headers instead of the html
wrapper the other pages share. */
pp.inbuf = 0;
pp.cp = param;
buf = NULL;
if(sel[0] == 'X') printstr(&pp, style);
else {
printstr(&pp, xml);
printval(conf.services, TYPE_SERVER, 0, &pp);
printstr(&pp, postxml);
}
break;
case 'F':
{
FILE *fp;
char buf[256];
fp = confopen();
if(!fp){
printstr(&pp, "<h3><font color=\"red\">Failed to open config file</font></h3>");
break;
}
printstr(&pp, "<h3>Please be careful editing config file remotely</h3>");
printstr(&pp, "<form method=\"POST\" action=\"/U\" enctype=\"application/x-www-form-urlencoded\"><textarea cols=\"80\" rows=\"30\" name=\"conffile\">");
while(fgets(buf, 256, fp)){
printstr(&pp, buf);
}
if(!writable) fclose(fp);
printstr(&pp, "</textarea><br><input type=\"Submit\"></form>");
break;
}
case 'U':
{
unsigned l=0;
int error = 0;
if(!writable || !contentlen || fseek(writable, 0, 0)){
error = 1;
}
while(l < contentlen && (i = sockgetlinebuf(param, CLIENT, (unsigned char *)buf, (contentlen - l) > LINESIZE - 1?LINESIZE - 1:contentlen - l, '+', conf.timeouts[STRING_S])) > 0){
if((unsigned)i > (contentlen - l)) i = (contentlen - l);
if(!l){
if(i<9 || strncasecmp(buf, "conffile=", 9)) error = 1;
}
if(!error){
buf[i] = 0;
decodeurl((unsigned char *)buf, 1);
fprintf(writable, "%s", l? buf : buf + 9);
}
l += i;
}
if(writable && !error){
fflush(writable);
#ifndef _WINCE
if(ftruncate(fileno(writable), ftell(writable))){}
#endif
}
printstr(&pp, error? "<h3><font color=\"red\">Config file is not writable</font></h3>Make sure you have \"writable\" command in configuration file":
"<h3>Configuration updated</h3>");
}
break;
default:
printstr(&pp, (char *)conf.stringtable[WEBBANNERS]);
break;
}
if(*req != 'S') printstr(&pp, tail);
CLEANRET:
printstr(&pp, NULL);
if(buf) free(buf);
dolog(param, (unsigned char *)req);
if(req)free(req);
return (NULL);
return 0;
}
#endif

2
tests/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
__pycache__/
*.pyc

54
tests/README.md Normal file
View File

@ -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.

69
tests/cases/admin.py Normal file
View File

@ -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, "<tr>", "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<action><number> 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")

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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")

167
tests/cases/parent_ports.py Normal file
View File

@ -0,0 +1,167 @@
"""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
def _windows():
"""Pick port windows this platform will actually honour.
On Linux the kernel applies IP_LOCAL_PORT_RANGE only within
net.ipv4.ip_local_port_range; a window outside it is ignored and an
ordinary ephemeral port is used, so a fixed low window would be
measuring the kernel's own choice rather than the setting.
"""
try:
with open("/proc/sys/net/ipv4/ip_local_port_range") as fp:
low, high = (int(part) for part in fp.read().split()[:2])
except (OSError, ValueError):
return (21400, 21449), (21500, 21549)
base = low + 1000 if low + 1150 <= high else low
return (base, base + 49), (base + 100, base + 149)
(LOW, HIGH), (ILOW, IHIGH) = _windows()
# below the Linux window on purpose: the kernel ignores such a range
UNHONOURED = (21400, 21449)
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")
# --- a range the platform cannot honour --------------------------------
# Linux ignores a range outside net.ipv4.ip_local_port_range, and any
# platform can run out of free ports in a range. Either way the
# connection falls back to an ephemeral port instead of failing.
unhonoured = t.free_port()
t.start("parent_unhonoured", f"""
log
flush
auth iponly
allow *
parent 1000 extport 0.0.0.0 {UNHONOURED[0]}-{UNHONOURED[1]}
proxy -p{unhonoured}
""", ports=[unhonoured])
r = t.http(origin + "/echo", proxy=f"127.0.0.1:{unhonoured}")
t.eq(200, r.status, "a range the platform cannot honour still connects")
t.ne(None, int_field(r, "peer.port"),
"the connection still has a source 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")

108
tests/cases/proxy_http.py Normal file
View File

@ -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")

57
tests/cases/socks.py Normal file
View File

@ -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")

476
tests/harness.py Normal file
View File

@ -0,0 +1,476 @@
"""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"<no reply: {self.error}>"
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):
code = proc.poll()
if code is None:
died = "the process is still running"
else:
died = f"the process exited with code {code}"
if os.name == "nt" and code is not None and code & 0xFFFFFFFF == 0xC0000135:
died += " (a DLL it needs was not found)"
raise Failure(
f"{name} never listened on port {port}: {died}\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"<no reply: {exc}>"
# ---- 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)
@staticmethod
def _as_text(value):
"""A reply that never arrived has no text, so report the reason."""
if isinstance(value, Response):
if value.error:
return f"<no reply: {value.error}>"
if not value.body and value.status is not None:
return f"<{value.status}, empty body>"
return value.text
return value
def contains(self, haystack, needle, label):
haystack = self._as_text(haystack)
return self._record(needle in haystack, label,
f"text containing {needle!r}", self._clip(haystack))
def not_contains(self, haystack, needle, label):
haystack = self._as_text(haystack)
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

141
tests/run.py Normal file
View File

@ -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())