A single-file, standard-library-only SOCKS5 (RFC 1928)
proxy built on asyncio. It does exactly four things: SOCKS5 negotiation, optional
RFC 1929 username/password authentication, TCP CONNECT,
and bidirectional TCP forwarding. It is meant for development, testing, homelab and CTF use.
It replaces the earlier thread-per-connection s5.py, which is still in the git history.
- Python 3.6 or newer. Nothing else: standard library only, zero third-party packages.
- Nothing needs to be installed or written to disk. The whole program is
proxy.py. - Developed and tested on Linux. It uses no Linux-specific calls, but Windows and macOS were not tested
(on Windows
SIGINT/SIGTERMhandlers are unavailable and Ctrl-C arrives asKeyboardInterrupt).
python3 proxy.py # listen on 127.0.0.1:1080, no output
python3 proxy.py --host 127.0.0.1 --port 9050
python3 proxy.py --verbose # log to stderr
# Run straight from stdin (nothing is written to the target system);
# everything after "-" is passed to the program as its arguments:
python3 - --port 1080 < proxy.py| Option | Default | Meaning |
|---|---|---|
--host |
127.0.0.1 |
Address to listen on. The default is loopback only. 0.0.0.0 is every IPv4 interface, :: every IPv6 interface (IPv6 only, that is how asyncio binds it); --host '' listens on both. |
--port |
1080 |
TCP port to listen on (0 = pick a free port; visible with --verbose). |
--verbose |
off | Log connections, failures and diagnostics to stderr. |
--auth |
off | Require RFC 1929 username/password authentication (see below). |
Stop it with Ctrl-C or SIGTERM; it stops listening, cancels every connection and exits with status 0.
Silent by default. Without --verbose the program prints nothing, not even errors.
Failures show up only in the exit status: 1 = could not start (port in use, unusable --host, ...),
2 = invalid command line (argparse's own usage message is the one exception to "no output").
Run again with --verbose to see the reason.
Authentication is off by default. Turning it on is explicit:
SOCKS5_USER=alice SOCKS5_PASS='s3cret' python3 proxy.py --auth
curl --proxy socks5h://alice:s3cret@127.0.0.1:1080 http://example.com/- With
--auththe proxy offers only the username/password method; clients that offer only "no authentication" get05 FF(no acceptable method) and are disconnected. - Credentials come from the environment (
SOCKS5_USER,SOCKS5_PASS), not from the command line, so they do not show up in the process list, and nothing has to be written to disk.--authwithout both variables set is a usage error: the proxy never silently falls back to open. The environment of a process is still readable by the same user and by root (/proc/PID/environ). The example above also leaves the password in your shell history, and itscurlline puts it in curl's command line, where the process list shows it. To avoid both (bash or zsh), read it without echo and give it to the proxy alone, then let curl ask for it:read -rs pw; SOCKS5_USER=alice SOCKS5_PASS="$pw" python3 proxy.py --authandcurl -U alice --proxy socks5h://127.0.0.1:1080 http://example.com/. - The reverse is refused as well:
SOCKS5_USER/SOCKS5_PASSset without--authis a usage error (exit status 2). Credentials that were silently ignored could leave you running an open proxy while believing it is protected. Unset the variables to run without authentication. - RFC 1929 sends the password in clear text. Use it on a trusted network or inside an encrypted tunnel such as SSH. There is no rate limiting.
- Without
--auththe proxy is open to everybody who can reach the listening address. That is why the default is loopback. If you use--host 0.0.0.0(or any non-loopback address) without--auth,--verboseprints a warning.
Keep the proxy on loopback and expose the forwarder instead, using a tool you already have.
For example, to use a proxy running on proxy-host from your workstation through an ordinary
SSH local forward (authenticated and encrypted by SSH):
# on proxy-host
python3 proxy.py --port 1080
# on the workstation: local port 1081 -> proxy-host's loopback port 1080
ssh -N -L 127.0.0.1:1081:127.0.0.1:1080 user@proxy-host
curl --proxy socks5h://127.0.0.1:1081 http://intranet.example/A plain forwarder such as socat works the same way, but it adds no authentication or encryption,
so combine it with --auth and only on a network you trust:
socat TCP-LISTEN:1081,bind=192.168.1.10,reuseaddr,fork TCP:127.0.0.1:1080proxy.py itself adds no tunnelling, obfuscation or evasion features: it is only a SOCKS5 server.
Note that whoever can reach the proxy can connect to anything the proxy host can reach, including
services bound to that host's own loopback.
| Aspect | Behaviour |
|---|---|
| Version | SOCKS5 only. A first byte other than 05 closes the connection without a reply (no SOCKS4). |
| Auth methods | 00 no authentication (default) or 02 username/password (--auth). GSSAPI (01) is not implemented, which is a deviation from RFC 1928's "MUST", common in small servers. |
| Commands | CONNECT (01) only. BIND (02), UDP ASSOCIATE (03) and unknown commands get REP 07. |
| Address types | IPv4 (01), domain name (03, resolved by the proxy), IPv6 (04). Anything else gets REP 08. |
| Success reply | BND.ADDR/BND.PORT are the address of the proxy's outgoing socket. |
| Error reply | 05 REP 00 01 00000000 0000, then the connection is closed. Exception details are never sent to the client. |
| Partial I/O | Every read is an exact-length read, so requests split across TCP segments work; bytes a client sends right after its request are kept and forwarded. |
Reply codes and what produces them:
REP |
Meaning | Produced by |
|---|---|---|
00 |
succeeded | the TCP connection to the target was established |
01 |
general SOCKS server failure | any other error, and a request with the wrong version byte |
02 |
connection not allowed | EACCES / EPERM from connect() (local firewall or policy) |
03 |
network unreachable | ENETUNREACH, ENETDOWN, EAFNOSUPPORT (e.g. IPv6 target, no IPv6 here) |
04 |
host unreachable | EHOSTUNREACH, EHOSTDOWN, DNS failure, an empty, undecodable, unencodable or malformed name |
05 |
connection refused | ECONNREFUSED |
06 |
TTL expired | connect/DNS timeout (asyncio.TimeoutError, socket.timeout, ETIMEDOUT) |
07 |
command not supported | any command other than CONNECT |
08 |
address type not supported | any ATYP other than 01, 03, 04 |
If a name resolves to several addresses, they are tried in order within one connect budget, and each
gets an equal share of what is left, so one blackholed address, typically IPv6, cannot use up the whole
budget before a working one is tried, and time an early address did not need (it failed fast) goes to the
later ones.
When all of them fail, the most informative failure is reported (refused, then timeout, host
unreachable, network unreachable, not allowed, general), so a dual-stack localhost with nothing
listening reports "refused" instead of a generic failure.
IP literals (ATYP 1 and 4, or a domain-typed request that is itself an IP address) are connected to
without a name lookup, so they keep working when the resolver is slow or wedged (a scoped IPv6 address such as
fe80::1%eth0 is the one exception: asyncio itself resolves the scope).
Host names are checked before they are resolved: an empty name, a name that is not valid UTF-8 and a name with a
control character (a NUL would cut the name off in the resolver, a newline could forge log lines) get REP 04.
The command is checked before the name is, so BIND/UDP ASSOCIATE get REP 07 whatever the address is. The one
exception is an unknown address type (ATYP other than 01, 03, 04): its length is unknown, so it is answered
with REP 08 first.
Non-ASCII names are answered normally. In the --verbose log every non-ASCII character of a name is written as a
backslash escape (\u2028), because some of them start a new line or reorder the text for whoever reads the log. A
backslash is doubled, so a name cannot imitate an escape, and the error text of a failed connect is escaped the same way.
| Phase | Limit | Constant | On expiry |
|---|---|---|---|
| Greeting + authentication + request | 15 s in total | HANDSHAKE_TIMEOUT |
connection closed |
| Name resolution + connecting to target | 10 s in total, shared between the addresses of a name | CONNECT_TIMEOUT |
REP 06, connection closed |
| Established connection, no data in either direction | 300 s | IDLE_TIMEOUT |
both sockets closed |
- The idle timeout is per connection, not per direction: data flowing in either direction keeps the connection alive. A download that the client is not sending anything for is not idle. Time spent waiting for the first byte after the connection was established counts as idle.
- Half-close is honoured. When one side finishes sending (EOF), the proxy passes that on as a
shutdown(SHUT_WR)to the other side and keeps forwarding the opposite direction until it ends too. Tools that send a request, shut down their sending side and then wait for the answer (for examplenc -N host port) therefore receive the complete response. A connection that stays half-open, for example a client that has gone away while the target stays silent, is held until the idle timeout reaps it. - A reset or error on either side tears down both sides immediately.
- Every socket is closed on every path. Data still buffered for a peer that has stopped reading
is dropped 5 seconds after the close (
CLOSE_GRACE) so such a peer cannot hold a file descriptor forever. - A relayed connection holds two file descriptors. With the common default limit of 1024 per process that is
roughly 500 concurrent tunnels; raise it with
ulimit -nif you need more. There is no cap of its own. If the process does run out of descriptors,accept()fails withEMFILE: asyncio pauses accepting and retries every second, waiting connections queue in the kernel, and the proxy serves again by itself as soon as descriptors are free (a regression test pins this). With--verbose, asyncio logs a traceback for every failed accept (about 1,800 lines in a 20 s test on Python 3.13), so do not run--verboseon an exhausted proxy whose stderr you cannot afford to flood; without--verbosenothing is printed. - The timeouts are module constants and constructor arguments of
Socks5Server(handshake_timeout,connect_timeout,idle_timeout). They are deliberately not command-line options. - Memory per connection is bounded: data is copied in 64 KiB chunks and every write is followed by
drain(), so a fast sender cannot make the proxy buffer more than the slow receiver accepts.
--verbose writes timestamped lines to stderr (stdout is never used):
2026-09-30 10:53:39,254 INFO listening on 127.0.0.1:1080 (no authentication)
2026-09-30 10:53:40,148 INFO 127.0.0.1:52230 -> 127.0.0.1:18080: connected from 127.0.0.1:47544
2026-09-30 10:53:40,155 INFO 127.0.0.1:52230 -> 127.0.0.1:18080: closed (closed), 88 bytes up, 205 bytes down
2026-09-30 10:53:40,199 INFO 127.0.0.1:52246 -> 127.0.0.1:1: connect failed, REP 0x05 (ConnectionRefusedError: ...)
Rejected handshakes, dropped clients, the reason a relay ended (closed, error, idle timeout)
and unexpected exceptions with tracebacks are logged too. This is the only place where the
underlying exception text appears: clients only ever see the numeric REP code.
The suite uses only unittest (no pytest, no third-party packages). Run everything from the repository root.
| Module | Tests | What it covers |
|---|---|---|
tests/test_protocol.py |
119 | Unit tests with fake readers/writers: reply encoding; greeting and method selection; RFC 1929; requests for IPv4, IPv6 and domain names; malformed, truncated (every prefix of a valid request) and partial input; unsupported commands and address types; the exception-to-REP mapping; candidate ranking and time budget of connect_upstream; close_writer; argument parsing. |
tests/test_integration.py |
42 | Integration tests on ephemeral loopback ports with real asyncio sockets: successful connect (IPv4, IPv6, localhost fallback), refused and unreachable targets, unsupported command / address type, bidirectional forwarding and a 4 MiB echo, half-close both ways, fragmented and pipelined handshakes, handshake and idle timeouts, client and target disconnects (graceful and reset), shutdown, authentication, 60 concurrent clients, logging. |
tests/test_cli.py |
28 | The real program as a subprocess: silent by default (exact empty stdout and stderr, also with -X dev -W default), --verbose, defaults and bind address, python - < proxy.py, startup failure exit status, usage errors, --auth end to end, signals. |
tests/test_hardening.py |
26 | Regression tests for problems found in review: shutdown when tasks swallow cancellations, the connect budget shared between addresses, IP literals with a saturated resolver pool, a real blackholed first address, backpressure (a sender is stalled when the target does not read), byte counters in the log, hostile Unicode in host names escaped in the log, silent startup errors, non-ASCII credentials, and the exit path (repeated and late signals, a resolver thread that never returns), and descriptor exhaustion (a proxy limited to 40 descriptors runs out of them, and must serve again by itself once the clients leave; Linux only). |
tests/test_compat.py |
6 | Static guards for the Python 3.6 requirement (grammar check, banned newer asyncio names, no cancellation-swallowing except in coroutines, standard library only). |
tests/test_fuzz.py |
10 | Seeded, deterministic fuzzing of negotiate(), read_request() (including the RFC 1929 sub-negotiation) and encode_reply(): arbitrary and mutated bytes only ever produce ProtocolError or IncompleteReadError, replies are well-formed, valid messages round-trip, and trailing bytes stay unread. |
All 231 tests use nothing but loopback: no Internet access. Failure modes that cannot be
produced reliably on loopback (ENETUNREACH, EHOSTUNREACH, timeouts) are injected at the boundary of the
real code path (socket.getaddrinfo / asyncio.open_connection). A few tests skip themselves where the platform
cannot support them (no IPv6 loopback, Linux-only behaviour such as a full accept queue as a blackhole).
Timing-based tests use short timeouts (fractions of a second up to a few seconds) with generous margins.
python3 -m unittest discover -s tests -t . -v # everything (about 90 s on a small Linux VM, faster on a fast desktop)
python3 -m unittest tests.test_protocol # just one module
python3 -X dev -W error -m unittest discover -s tests -t . # strict: any ResourceWarning becomes an errorThe base class fails a test if anything logged at ERROR level (an unexpected exception inside the proxy would
otherwise look like an ordinary disconnect) and drains leftover tasks and sockets after every test.
Python 3.6 is checked by running the real suite on a real 3.6 interpreter. With Docker that needs nothing installed:
docker run --rm -v "$PWD":/w:ro -w /w python:3.6-slim python -m unittest discover -s tests -t . -vA GitHub Actions workflow (.github/workflows/tests.yml) runs the suite for every push and pull request on
Python 3.6 to 3.14: the end-of-life versions (3.6 to 3.10) in the official python:X-slim containers, like the
command above, and 3.11 to 3.14 with actions/setup-python. From 3.7 on it runs in the strict -X dev -W error mode.
The full suite was run and passed on CPython 3.6.15, 3.7.17, 3.8.20, 3.9.25, 3.10.22, 3.11.15, 3.12.15, 3.13.14 and 3.14.7 (also on 3.6 with IPv6 disabled, where the IPv6 tests skip). To look for timing flakiness it was also run six times at once next to twelve busy loops on six cores; all six runs passed.
proxy.py is one file with four parts, kept apart by function, not by class hierarchy:
| Part | Code | Notes |
|---|---|---|
| 1. Protocol | negotiate, read_request, encode_reply, rep_for_exception |
Parses and encodes bytes on a stream. Never opens a socket, so it is unit-tested with fake readers/writers. |
| 2. Connecting | connect_upstream |
Name resolution and the outgoing TCP connection. |
| 3. Forwarding | relay, close_writer |
Two pump tasks, the idle clock and half-close handling. |
| 4. Server and CLI | Socks5Server, main |
asyncio.start_server(), one task per client, shutdown, argparse. |
accept ──► Socks5Server._on_connect ──► one Task per client: _serve(reader, writer)
│
├─ handshake (HANDSHAKE_TIMEOUT) negotiate → [RFC 1929] → read_request
│ └─ ProtocolError → send its reply if it has one → close
├─ connect (CONNECT_TIMEOUT) connect_upstream
│ └─ any error → REP from rep_for_exception → close
├─ reply 05 00 00 <ATYP> <outgoing socket address>
├─ relay pump(client→target) ‖ pump(target→client), shared idle clock
└─ finally close both writers (always)
Socks5Server.stop() (Ctrl-C / SIGTERM) closes the listener, cancels every client task and waits for
them, so nothing is left running.
A proxy spends nearly all of its time waiting on sockets, and most of its connections are idle most of the time.
- Cost. A coroutine costs a few kilobytes; a thread costs a stack, a kernel object and scheduler and GIL contention. Thousands of mostly idle tunnels are cheap with coroutines and painful with threads.
- Timeouts and cancellation. A blocked thread cannot be stopped from the outside. With asyncio the
idle timeout, the handshake and connect timeouts, and shutdown are all ordinary cancellation of tasks,
so cleanup is deterministic. The baseline could block forever in
sendall()on a peer that stopped reading. - Backpressure.
await writer.drain()after every write keeps memory bounded without extra code. - Threads still appear in exactly one place:
getaddrinfo()runs in asyncio's default thread-pool executor, like in any asyncio program. That is a small fixed-size pool, not one thread per connection.
- Own address loop in
connect_upstream.loop.create_connection(host, port)with several failing addresses raises a bareOSError('Multiple exceptions: ...')without an errno (the messages contain the addresses, so they never match). That would turn "connection refused" into "general failure" for any dual-stack name such aslocalhost. Resolving first and trying each address keeps the errno, so the reply is precise. - One shared idle clock instead of a timeout per read. A per-direction timeout would kill a long
download because the client sent nothing for a while.
asyncio.wait(..., timeout=)is used rather thanwait_for()around each read, which needs a wrapper task per read and has had cancellation races in older Pythons. - Half-close instead of "first EOF ends everything", so request/response-after-shutdown protocols work. Abandoned half-open connections are reaped by the idle timeout.
- Client tasks are tracked by a plain callback.
start_server()is given an ordinary function that creates the task itself (ensure_future) and keeps it in a set. That givesstop()something to cancel withoutasyncio.current_task()(Python 3.7+) and works identically on 3.6 to current versions. - Shutdown cancels until everything has ended, then leaves through
os._exit(). On Python 3.8 to 3.11,asyncio.wait_for()can swallow a cancellation that arrives just as the awaited coroutine finishes (a CPython bug fixed in 3.12), and the client task would then carry on relaying.stop()therefore re-cancels the remaining tasks every quarter of a second until they have ended. The process then exits withos._exit()instead of returning normally, because at interpreter shutdown Python waits for resolver threads, and one stuck ingetaddrinfo()(a dead DNS server) would hold the exit for as long as the resolver takes to give up. The event loop is deliberately not closed first: closing it removes its signal handlers one at a time (after closing the wakeup descriptor), and a second Ctrl-C orSIGTERMin that moment would kill the process with a signal status or print a traceback. With the handlers left in place a late signal is a harmless no-op, so any number of signals, at any time, ends in status 0 and no output. - Authentication is opt-in and only via RFC 1929.
--authis the one flag beyond the three required options. The design leaves room for it in one place:negotiate()picks the method,_authenticate()runs the sub-negotiation, and everything after it is unaware of authentication. - Timeouts are constants, not flags, to keep the command line to what is needed; they are constructor arguments
of
Socks5Server, which is also how the tests shorten them. - Silence is enforced, not assumed. Without
--verbose,loggingis disabled entirely and Python warnings are ignored, so neither theasynciologger norDeprecationWarning/RuntimeWarningcan write to stderr.
The target is Python 3.6+. Newer APIs were replaced by the oldest equivalent (also listed at the top of proxy.py):
| Not used (added in) | Used instead |
|---|---|
asyncio.run() (3.7) |
new_event_loop() + run_until_complete() |
asyncio.get_running_loop() (3.7) |
asyncio.get_event_loop(), only called inside coroutines |
asyncio.create_task() (3.7) |
asyncio.ensure_future() / loop.create_task() |
asyncio.current_task(), all_tasks() (3.7) |
explicit task tracking |
StreamWriter.wait_closed(), is_closing() (3.7) |
transport.close(), transport.get_write_buffer_size() |
Version differences that are handled explicitly rather than assumed away:
asyncio.CancelledErrorderives fromExceptionbefore 3.8, so everyexcept Exceptionthat could see a cancellation is preceded byexcept asyncio.CancelledError: raise.asyncio.TimeoutError,socket.timeoutand the builtinTimeoutErrorare different classes before 3.10/3.11;rep_for_exception()maps all three toREP 06.asyncio.wait_for()can drop a cancellation on 3.8 to 3.11 (see "Other decisions");stop()compensates.- Newer Pythons deprecate or remove
asyncio.get_event_loop()outside a running loop (3.12 warns, 3.14 raises); the entry point creates and installs its own loop and only callsget_event_loop()from inside coroutines.
Found in a security review (2026-10-14); the figures were measured on Python 3.13. None has a complete fix with the
standard library alone. A connection cap and a refusal to connect to the proxy itself, open decisions D-1 and D-3 in
ROADMAP.md, would soften the first and the last. Fine for the intended use, relevant if you expose the proxy beyond a
network you trust (use --auth, a firewall and ulimit).
- A client can chain through the proxy to itself.
CONNECTto the proxy's own listening address works, so one client connection can stack layers of it. Each layer costs two descriptors and another copy of every byte through the one event loop (100 layers held about 200 descriptors, and a 4 MiB transfer took 9 s of CPU instead of 0.15 s). Everything is released when the client leaves. - Name lookups share one fixed thread pool (asyncio's default executor: min(32, CPUs + 4) threads on Python 3.8 and
later, five per CPU on 3.6 and 3.7). Clients that send names whose lookup hangs can keep every thread busy, and
then other names fail with
REP 06after the 10 s connect budget. IP literals are not affected. - A stalled tunnel holds up to about 0.5 MiB of user-space memory per stalled direction, about 1 MiB if both
stall, not the 64 KiB of one chunk: asyncio pauses reading only once a stream buffer holds more than twice the
64 KiB limit, a single read can add 256 KiB to it, and the write buffer takes up to about 128 KiB more. Memory is
still bounded, but with
ulimit -n 65536a flood of stalled tunnels can mean gigabytes.
SOCKS4/4a, BIND, UDP ASSOCIATE, GSSAPI authentication, access-control lists, rate limiting, a cap on
concurrent connections, and any kind of tunnelling, obfuscation or evasion. Use the operating system's
limits (ulimit -n) and firewall for resource and access control.