From a741c0a017216dfd674ce19e1022c1dfd31dc58a Mon Sep 17 00:00:00 2001 From: user Date: Sat, 21 Feb 2026 18:35:51 +0100 Subject: [PATCH] feat: v0.3.0 stabilization -- systemd, tests, API docs - Bump version 0.1.0 -> 0.3.0 - Add systemd service unit (config/s5p.service) and install-service Makefile target - Add CLI argument parsing tests (tests/test_cli.py, 27 tests) - Expand protocol tests with SOCKS5/4/HTTP handshake, error, and auth coverage (tests/test_proto.py, 30 tests) - Add full API reference to docs/USAGE.md with response schemas for all GET/POST endpoints - Update INSTALL.md, CHEATSHEET.md with systemd section - Update ROADMAP.md, TASKS.md for v0.3.0 --- Makefile | 9 +- ROADMAP.md | 10 +- TASKS.md | 12 +- config/s5p.service | 15 +++ docs/CHEATSHEET.md | 12 ++ docs/INSTALL.md | 31 +++++ docs/USAGE.md | 297 +++++++++++++++++++++++++++++++++++++---- pyproject.toml | 2 +- src/s5p/__init__.py | 2 +- tests/test_cli.py | 153 +++++++++++++++++++++ tests/test_proto.py | 315 +++++++++++++++++++++++++++++++++++++++++++- 11 files changed, 820 insertions(+), 38 deletions(-) create mode 100644 config/s5p.service create mode 100644 tests/test_cli.py diff --git a/Makefile b/Makefile index c1e64d6..14dec4d 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,17 @@ APP_NAME := s5p -.PHONY: install test lint clean build up down logs +.PHONY: install install-service test lint clean build up down logs install: pip install -e . +install-service: + sudo mkdir -p /etc/s5p + sudo cp config/s5p.service /etc/systemd/system/s5p.service + sudo systemctl daemon-reload + @echo "Unit installed. Configure /etc/s5p/s5p.yaml, then:" + @echo " sudo systemctl enable --now s5p" + test: pytest tests/ -v diff --git a/ROADMAP.md b/ROADMAP.md index 69c4a35..1fe345b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # s5p -- Roadmap -## v0.1.0 (current) +## v0.1.0 - [x] SOCKS5 server (CONNECT command) - [x] Proxy chaining (SOCKS5, SOCKS4/4a, HTTP CONNECT) @@ -32,9 +32,13 @@ - [x] Named proxy pools with per-listener assignment (`proxy_pools:`) - [x] MITM source filter (`mitm: true/false` on pool sources) -## v0.3.0 +## v0.3.0 (current) - [x] SOCKS5 server authentication (username/password) +- [x] Systemd service unit +- [x] CLI test coverage +- [x] Protocol test coverage (SOCKS5/4/HTTP handshakes) +- [x] API documentation (full response schemas) - [ ] UDP ASSOCIATE support (SOCKS5 UDP relay) - [ ] BIND support - [ ] Chain randomization (random order, random subset) @@ -42,6 +46,4 @@ ## v1.0.0 - [ ] Stable API and config format -- [ ] Comprehensive test suite with mock proxies (integration tests done) -- [ ] Systemd service unit - [ ] Performance benchmarks diff --git a/TASKS.md b/TASKS.md index ff7b334..30ab8b8 100644 --- a/TASKS.md +++ b/TASKS.md @@ -67,6 +67,14 @@ - [x] Gitea CI workflow (lint + test + Harbor image push) +## v0.3.0 Stabilization +- [x] Version bump to 0.3.0 +- [x] Systemd service unit (`config/s5p.service`, `make install-service`) +- [x] CLI argument parsing tests (`tests/test_cli.py`) +- [x] Protocol handshake tests (`tests/test_proto.py` -- SOCKS5/4/HTTP) +- [x] API reference documentation (`docs/USAGE.md`) + ## Next -- [x] Integration tests with mock proxy server -- [x] SOCKS5 server-side authentication +- [ ] UDP ASSOCIATE support +- [ ] BIND support +- [ ] Chain randomization diff --git a/config/s5p.service b/config/s5p.service new file mode 100644 index 0000000..54d9d38 --- /dev/null +++ b/config/s5p.service @@ -0,0 +1,15 @@ +[Unit] +Description=s5p SOCKS5 proxy +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/s5p -c /etc/s5p/s5p.yaml +Restart=on-failure +RestartSec=5 +KillSignal=SIGTERM +TimeoutStopSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index 14354e2..0f53ed6 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -21,6 +21,18 @@ s5p --tracemalloc # memory profile (top 10) s5p --tracemalloc 20 # memory profile (top 20) ``` +## Systemd + +``` +make install-service # install unit + reload +sudo systemctl enable --now s5p # enable + start +sudo systemctl status s5p # check status +sudo systemctl restart s5p # restart +sudo systemctl stop s5p # stop +journalctl -u s5p -f # follow logs +journalctl -u s5p --since "5 min ago" # recent logs +``` + ## Container ``` diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 71ae3df..4912aaa 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -42,6 +42,37 @@ The Alpine-based image (~59MB) contains Python, PyYAML, and baked-in source. Config is mounted at runtime. The compose.yaml volume mount overrides source for local dev. +## Systemd Service + +Install s5p as a systemd service for automatic startup and restart. + +```bash +# Install the binary +cd ~/git/s5p +source .venv/bin/activate +pip install -e . + +# Copy config +sudo mkdir -p /etc/s5p +sudo cp config/example.yaml /etc/s5p/s5p.yaml +sudo nano /etc/s5p/s5p.yaml # edit with your settings + +# Install the unit (copies service file + daemon-reload) +make install-service + +# Enable and start +sudo systemctl enable --now s5p + +# Check status +sudo systemctl status s5p +journalctl -u s5p -f +``` + +The service unit expects: +- Binary at `/usr/local/bin/s5p` +- Config at `/etc/s5p/s5p.yaml` +- Restarts on failure with 5-second delay + ## Install Tor (optional) ```bash diff --git a/docs/USAGE.md b/docs/USAGE.md index 396d244..71c6fe7 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -523,56 +523,297 @@ api_listen: 127.0.0.1:1081 s5p --api 127.0.0.1:1081 -c config/s5p.yaml ``` -### Read endpoints - -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/status` | Combined summary: uptime, metrics, pool stats, chain | -| `GET` | `/metrics` | Full metrics counters (connections, bytes, rate, latency) | -| `GET` | `/pool` | All proxies with per-entry state | -| `GET` | `/pool/alive` | Alive proxies only | -| `GET` | `/config` | Current runtime config (sanitized) | - -### Write endpoints - -| Method | Path | Description | -|--------|------|-------------| -| `POST` | `/reload` | Re-read config file (replaces SIGHUP) | -| `POST` | `/pool/test` | Trigger immediate health test cycle | -| `POST` | `/pool/refresh` | Trigger immediate source re-fetch | - All responses are `application/json`. Errors return `{"error": "message"}` with appropriate status code (400, 404, 405, 500). -### Examples +Settings that require a restart: `listen`, `chain`, `pool_size`, `pool_max_idle`, `api_listen`. + +### API Reference + +#### `GET /status` + +Combined runtime summary: uptime, metrics, pool stats, listeners. ```bash -# Runtime status curl -s http://127.0.0.1:1081/status | jq . +``` -# Full metrics +```json +{ + "uptime": 3661.2, + "connections": 1842, + "success": 1790, + "failed": 52, + "active": 3, + "bytes_in": 52428800, + "bytes_out": 1073741824, + "rate": 4.72, + "latency": {"count": 1000, "min": 45.2, "max": 2841.7, "avg": 312.4, "p50": 198.3, "p95": 890.1, "p99": 1523.6}, + "pool": {"alive": 42, "total": 65}, + "pools": { + "clean": {"alive": 30, "total": 45}, + "mitm": {"alive": 12, "total": 20} + }, + "tor_nodes": ["socks5://10.200.1.1:9050", "socks5://10.200.1.254:9050"], + "listeners": [ + { + "listen": "0.0.0.0:1080", + "chain": ["socks5://10.200.1.13:9050"], + "pool_hops": 2, + "pool": "clean", + "auth": true, + "latency": {"count": 500, "p50": 1800.2, "p95": 8200.1, "p99": 10500.3, "...": "..."} + } + ] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `uptime` | float | Seconds since server start | +| `connections` | int | Total incoming connections | +| `success` | int | Successfully relayed | +| `failed` | int | All retries exhausted | +| `active` | int | Currently relaying | +| `bytes_in` | int | Bytes client -> remote | +| `bytes_out` | int | Bytes remote -> client | +| `rate` | float | Connections/sec (rolling window) | +| `latency` | object/null | Aggregate chain setup latency (ms), null if no samples | +| `pool` | object | Aggregate pool counts (present when pool active) | +| `pools` | object | Per-pool counts (present when multiple pools) | +| `tor_nodes` | array | Tor node URLs (present when configured) | +| `listeners` | array | Per-listener state with chain, pool, latency | +| `listeners[].auth` | bool | Present and `true` when auth is enabled | + +#### `GET /metrics` + +Full metrics counters with rate, latency percentiles, and per-listener breakdown. + +```bash curl -s http://127.0.0.1:1081/metrics | jq . +``` -# Pool state (all proxies) +```json +{ + "connections": 1842, + "success": 1790, + "failed": 52, + "retries": 67, + "auth_failures": 0, + "active": 3, + "bytes_in": 52428800, + "bytes_out": 1073741824, + "uptime": 3661.2, + "rate": 4.72, + "latency": { + "count": 1000, "min": 45.2, "max": 2841.7, + "avg": 312.4, "p50": 198.3, "p95": 890.1, "p99": 1523.6 + }, + "listener_latency": { + "0.0.0.0:1080": {"count": 500, "min": 800.1, "max": 12400.3, "avg": 2100.5, "p50": 1800.2, "p95": 8200.1, "p99": 10500.3}, + "0.0.0.0:1081": {"count": 300, "min": 400.5, "max": 5200.1, "avg": 1200.3, "p50": 1000.1, "p95": 3500.2, "p99": 4800.7} + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `retries` | int | Total retry attempts | +| `auth_failures` | int | SOCKS5 auth failures | +| `latency` | object/null | Aggregate latency stats (ms), null if no samples | +| `latency.count` | int | Number of samples in buffer (max 1000) | +| `latency.p50/p95/p99` | float | Percentile latency (ms) | +| `listener_latency` | object | Per-listener latency, keyed by `host:port` | + +#### `GET /pool` + +All proxies with per-entry state. + +```bash curl -s http://127.0.0.1:1081/pool | jq . +``` -# Alive proxies only +```json +{ + "alive": 42, + "total": 65, + "pools": { + "clean": {"alive": 30, "total": 45}, + "mitm": {"alive": 12, "total": 20} + }, + "proxies": { + "socks5://1.2.3.4:1080": { + "alive": true, + "fails": 0, + "tests": 12, + "last_ok": 1708012345.6, + "last_test": 1708012345.6, + "last_seen": 1708012300.0, + "pool": "clean" + } + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `alive` | int | Total alive proxies across all pools | +| `total` | int | Total proxies across all pools | +| `pools` | object | Per-pool counts (present when multiple pools) | +| `proxies` | object | Keyed by proxy URL | +| `proxies[].alive` | bool | Currently passing health tests | +| `proxies[].fails` | int | Consecutive failures | +| `proxies[].tests` | int | Total health tests performed | +| `proxies[].last_ok` | float | Unix timestamp of last successful test | +| `proxies[].last_test` | float | Unix timestamp of last test (pass or fail) | +| `proxies[].last_seen` | float | Unix timestamp of last source refresh that included this proxy | +| `proxies[].pool` | string | Pool name (present when multiple pools) | + +#### `GET /pool/alive` + +Same schema as `/pool`, filtered to alive proxies only. + +```bash curl -s http://127.0.0.1:1081/pool/alive | jq '.proxies | length' +``` -# Current config +#### `GET /config` + +Current runtime config (sanitized -- passwords are never exposed). + +```bash curl -s http://127.0.0.1:1081/config | jq . +``` -# Reload config (like SIGHUP) +```json +{ + "timeout": 10, + "retries": 3, + "log_level": "info", + "max_connections": 256, + "pool_size": 0, + "listeners": [ + { + "listen": "0.0.0.0:1080", + "chain": ["socks5://10.200.1.13:9050"], + "pool_hops": 2, + "pool": "clean", + "auth_users": 2 + } + ], + "tor_nodes": ["socks5://10.200.1.1:9050"], + "proxy_pools": { + "clean": { + "sources": [{"url": "http://10.200.1.250:8081/proxies/all", "mitm": false}], + "refresh": 300, + "test_interval": 120, + "max_fails": 3 + } + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `timeout` | float | Per-hop connection timeout (seconds) | +| `retries` | int | Max connection attempts per request | +| `log_level` | string | Current log level | +| `max_connections` | int | Concurrent connection cap | +| `pool_size` | int | Pre-warmed TCP connections to first hop | +| `listeners` | array | Listener configs | +| `listeners[].auth_users` | int | Number of auth users (present when auth enabled) | +| `tor_nodes` | array | Tor node URLs (present when configured) | +| `proxy_pools` | object | Pool configs (present when pools configured) | + +#### `GET /tor` + +Tor controller status. + +```bash +curl -s http://127.0.0.1:1081/tor | jq . +``` + +```json +{"enabled": true, "connected": true, "last_newnym": 45.2, "newnym_interval": 60} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `enabled` | bool | Whether Tor control is configured | +| `connected` | bool | Whether connected to Tor control port | +| `last_newnym` | float/null | Seconds since last NEWNYM signal | +| `newnym_interval` | int | Auto-rotation interval (0 = manual) | + +Returns `{"enabled": false}` when Tor control is not configured. + +#### `POST /reload` + +Re-read config file (equivalent to SIGHUP). + +```bash curl -s -X POST http://127.0.0.1:1081/reload | jq . +``` -# Trigger health tests now +```json +{"ok": true} +``` + +Returns `{"error": "..."}` (500) on failure. + +#### `POST /pool/test` + +Trigger immediate health test cycle for all pools. + +```bash curl -s -X POST http://127.0.0.1:1081/pool/test | jq . +``` -# Re-fetch proxy sources now +```json +{"ok": true} +``` + +Returns `{"error": "no proxy pool configured"}` (400) when no pool is active. + +#### `POST /pool/refresh` + +Trigger immediate source re-fetch for all pools. + +```bash curl -s -X POST http://127.0.0.1:1081/pool/refresh | jq . ``` -Settings that require a restart: `listen`, `chain`, `pool_size`, `pool_max_idle`, `api_listen`. +```json +{"ok": true} +``` + +Returns `{"error": "no proxy pool configured"}` (400) when no pool is active. + +#### `POST /tor/newnym` + +Request new Tor circuit (NEWNYM signal). + +```bash +curl -s -X POST http://127.0.0.1:1081/tor/newnym | jq . +``` + +```json +{"ok": true} +``` + +Returns `{"ok": false, "reason": "rate-limited or not connected"}` when the +signal cannot be sent. Returns `{"error": "tor control not configured"}` (400) +when Tor control is not configured. + +#### Error responses + +All endpoints return errors as JSON with appropriate HTTP status codes: + +| Status | Meaning | Example | +|--------|---------|---------| +| 400 | Bad request | `{"error": "no proxy pool configured"}` | +| 404 | Unknown path | `{"error": "not found"}` | +| 405 | Wrong method | `{"error": "use GET for /status"}` | +| 500 | Server error | `{"error": "reload not available"}` | ## Tor Control Port diff --git a/pyproject.toml b/pyproject.toml index 0d2241b..eb080f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "s5p" -version = "0.1.0" +version = "0.3.0" description = "SOCKS5 proxy with Tor and proxy-chain support" requires-python = ">=3.11" dependencies = ["pyyaml>=6.0"] diff --git a/src/s5p/__init__.py b/src/s5p/__init__.py index 2586a36..20eae08 100644 --- a/src/s5p/__init__.py +++ b/src/s5p/__init__.py @@ -1,3 +1,3 @@ """s5p -- SOCKS5 proxy with chain support.""" -__version__ = "0.1.0" +__version__ = "0.3.0" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a9e453b --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,153 @@ +"""Tests for CLI argument parsing.""" + +import pytest + +from s5p import __version__ +from s5p.cli import _parse_args + + +class TestDefaults: + """Default argument values.""" + + def test_no_args(self): + args = _parse_args([]) + assert args.config is None + assert args.listen is None + assert args.chain is None + assert args.timeout is None + assert args.retries is None + assert args.max_connections is None + assert args.verbose is False + assert args.quiet is False + assert args.proxy_source is None + assert args.api is None + assert args.cprofile is None + assert args.tracemalloc is None + + +class TestFlags: + """Flag parsing.""" + + def test_verbose(self): + args = _parse_args(["-v"]) + assert args.verbose is True + + def test_quiet(self): + args = _parse_args(["-q"]) + assert args.quiet is True + + def test_config(self): + args = _parse_args(["-c", "s5p.yaml"]) + assert args.config == "s5p.yaml" + + def test_config_long(self): + args = _parse_args(["--config", "s5p.yaml"]) + assert args.config == "s5p.yaml" + + def test_listen(self): + args = _parse_args(["-l", "0.0.0.0:9999"]) + assert args.listen == "0.0.0.0:9999" + + def test_chain(self): + args = _parse_args(["-C", "socks5://127.0.0.1:9050"]) + assert args.chain == "socks5://127.0.0.1:9050" + + def test_chain_multi(self): + args = _parse_args(["-C", "socks5://a:1080,http://b:8080"]) + assert args.chain == "socks5://a:1080,http://b:8080" + + def test_timeout(self): + args = _parse_args(["-t", "30"]) + assert args.timeout == 30.0 + + def test_retries(self): + args = _parse_args(["-r", "5"]) + assert args.retries == 5 + + def test_max_connections(self): + args = _parse_args(["-m", "512"]) + assert args.max_connections == 512 + + def test_proxy_source(self): + args = _parse_args(["-S", "http://api:8081/proxies"]) + assert args.proxy_source == "http://api:8081/proxies" + + def test_api(self): + args = _parse_args(["--api", "127.0.0.1:1081"]) + assert args.api == "127.0.0.1:1081" + + def test_cprofile_default(self): + args = _parse_args(["--cprofile"]) + assert args.cprofile == "s5p.prof" + + def test_cprofile_custom(self): + args = _parse_args(["--cprofile", "out.prof"]) + assert args.cprofile == "out.prof" + + def test_tracemalloc_default(self): + args = _parse_args(["--tracemalloc"]) + assert args.tracemalloc == 10 + + def test_tracemalloc_custom(self): + args = _parse_args(["--tracemalloc", "20"]) + assert args.tracemalloc == 20 + + +class TestVersion: + """--version flag.""" + + def test_version_output(self, capsys): + with pytest.raises(SystemExit, match="0"): + _parse_args(["--version"]) + captured = capsys.readouterr() + assert captured.out.strip() == f"s5p {__version__}" + + def test_version_short(self, capsys): + with pytest.raises(SystemExit, match="0"): + _parse_args(["-V"]) + captured = capsys.readouterr() + assert "0.3.0" in captured.out + + +class TestCombinations: + """Multiple flags together.""" + + def test_verbose_with_chain(self): + args = _parse_args(["-v", "-C", "socks5://tor:9050"]) + assert args.verbose is True + assert args.chain == "socks5://tor:9050" + + def test_config_with_api(self): + args = _parse_args(["-c", "s5p.yaml", "--api", "0.0.0.0:1090"]) + assert args.config == "s5p.yaml" + assert args.api == "0.0.0.0:1090" + + def test_listen_with_timeout_and_retries(self): + args = _parse_args(["-l", ":8080", "-t", "15", "-r", "3"]) + assert args.listen == ":8080" + assert args.timeout == 15.0 + assert args.retries == 3 + + +class TestInvalid: + """Invalid argument handling.""" + + def test_unknown_flag(self): + with pytest.raises(SystemExit, match="2"): + _parse_args(["--nonexistent"]) + + def test_timeout_non_numeric(self): + with pytest.raises(SystemExit, match="2"): + _parse_args(["-t", "abc"]) + + def test_retries_non_numeric(self): + with pytest.raises(SystemExit, match="2"): + _parse_args(["-r", "abc"]) + + def test_max_connections_non_numeric(self): + with pytest.raises(SystemExit, match="2"): + _parse_args(["-m", "abc"]) + + def test_tracemalloc_non_numeric(self): + with pytest.raises(SystemExit, match="2"): + _parse_args(["--tracemalloc", "abc"]) diff --git a/tests/test_proto.py b/tests/test_proto.py index 7a081a8..63b7ded 100644 --- a/tests/test_proto.py +++ b/tests/test_proto.py @@ -1,6 +1,66 @@ """Tests for protocol helpers.""" -from s5p.proto import Socks5AddrType, encode_address +import asyncio + +import pytest + +from s5p.proto import ( + ProtoError, + Socks5AddrType, + Socks5Reply, + encode_address, + http_connect, + socks4_connect, + socks5_connect, +) + +# -- helpers ----------------------------------------------------------------- + + +class _MockTransport(asyncio.Transport): + """Minimal transport that captures writes and supports drain.""" + + def __init__(self): + super().__init__() + self.written = bytearray() + self._closing = False + + def write(self, data): + self.written.extend(data) + + def is_closing(self): + return self._closing + + def close(self): + self._closing = True + + def get_extra_info(self, name, default=None): + return default + + +def _make_streams(response_data: bytes): + """Create mock reader/writer for protocol tests. + + Must be called from within a running event loop. + """ + reader = asyncio.StreamReader() + reader.feed_data(response_data) + reader.feed_eof() + + protocol = asyncio.StreamReaderProtocol(reader) + transport = _MockTransport() + protocol.connection_made(transport) + + writer = asyncio.StreamWriter(transport, protocol, reader, asyncio.get_running_loop()) + return reader, writer + + +def _run(coro): + """Run a coroutine in a fresh event loop.""" + asyncio.run(coro) + + +# -- encode_address ---------------------------------------------------------- class TestEncodeAddress: @@ -11,12 +71,265 @@ class TestEncodeAddress: assert atyp == Socks5AddrType.IPV4 assert data == b"\x7f\x00\x00\x01" + def test_ipv4_zeros(self): + atyp, data = encode_address("0.0.0.0") + assert atyp == Socks5AddrType.IPV4 + assert data == b"\x00\x00\x00\x00" + def test_ipv6(self): atyp, data = encode_address("::1") assert atyp == Socks5AddrType.IPV6 assert len(data) == 16 + assert data[-1] == 1 + + def test_ipv6_full(self): + atyp, data = encode_address("2001:db8::1") + assert atyp == Socks5AddrType.IPV6 + assert len(data) == 16 def test_domain(self): atyp, data = encode_address("example.com") assert atyp == Socks5AddrType.DOMAIN assert data == bytes([11]) + b"example.com" + + def test_domain_short(self): + atyp, data = encode_address("a.co") + assert atyp == Socks5AddrType.DOMAIN + assert data == bytes([4]) + b"a.co" + + def test_domain_long(self): + host = "sub.domain.example.com" + atyp, data = encode_address(host) + assert atyp == Socks5AddrType.DOMAIN + assert data[0] == len(host) + assert data[1:] == host.encode("ascii") + + +# -- socks5_connect ---------------------------------------------------------- + + +class TestSocks5Connect: + """Test SOCKS5 handshake building.""" + + def test_no_auth_success(self): + """Successful SOCKS5 connect without auth.""" + bind_addr = b"\x01\x00\x00\x00\x00\x00\x00" # IPv4 0.0.0.0:0 + response = b"\x05\x00" + b"\x05\x00\x00" + bind_addr + + async def run(): + reader, writer = _make_streams(response) + await socks5_connect(reader, writer, "example.com", 80) + + _run(run()) + + def test_auth_success(self): + """Successful SOCKS5 connect with username/password auth.""" + bind_addr = b"\x01\x00\x00\x00\x00\x00\x00" + response = b"\x05\x02" + b"\x01\x00" + b"\x05\x00\x00" + bind_addr + + async def run(): + reader, writer = _make_streams(response) + await socks5_connect(reader, writer, "example.com", 80, "user", "pass") + + _run(run()) + + def test_auth_failure(self): + """SOCKS5 auth rejected by server.""" + response = b"\x05\x02" + b"\x01\x01" + + async def run(): + reader, writer = _make_streams(response) + with pytest.raises(ProtoError, match="authentication failed"): + await socks5_connect(reader, writer, "example.com", 80, "user", "bad") + + _run(run()) + + def test_no_acceptable_methods(self): + """Server rejects all auth methods (0xFF).""" + response = b"\x05\xff" + + async def run(): + reader, writer = _make_streams(response) + with pytest.raises(ProtoError, match="no acceptable"): + await socks5_connect(reader, writer, "example.com", 80) + + _run(run()) + + def test_connect_refused(self): + """SOCKS5 connect reply with connection refused.""" + bind_addr = b"\x01\x00\x00\x00\x00\x00\x00" + response = b"\x05\x00" + b"\x05\x05\x00" + bind_addr + + async def run(): + reader, writer = _make_streams(response) + with pytest.raises(ProtoError, match="connect failed"): + await socks5_connect(reader, writer, "example.com", 80) + + _run(run()) + + def test_wrong_version(self): + """Server responds with wrong SOCKS version.""" + response = b"\x04\x00" + + async def run(): + reader, writer = _make_streams(response) + with pytest.raises(ProtoError, match="unexpected version"): + await socks5_connect(reader, writer, "example.com", 80) + + _run(run()) + + def test_server_requires_auth_no_creds(self): + """Server demands auth but no credentials provided.""" + response = b"\x05\x02" + + async def run(): + reader, writer = _make_streams(response) + with pytest.raises(ProtoError, match="requires auth"): + await socks5_connect(reader, writer, "example.com", 80) + + _run(run()) + + +# -- socks4_connect ---------------------------------------------------------- + + +class TestSocks4Connect: + """Test SOCKS4/4a request building.""" + + def test_ip_success(self): + """SOCKS4 connect with IP address.""" + response = b"\x00\x5a" + b"\x00" * 6 + + async def run(): + reader, writer = _make_streams(response) + await socks4_connect(reader, writer, "1.2.3.4", 80) + + _run(run()) + + def test_domain_success(self): + """SOCKS4a connect with domain name.""" + response = b"\x00\x5a" + b"\x00" * 6 + + async def run(): + reader, writer = _make_streams(response) + await socks4_connect(reader, writer, "example.com", 80) + + _run(run()) + + def test_rejected(self): + """SOCKS4 request rejected.""" + response = b"\x00\x5b" + b"\x00" * 6 + + async def run(): + reader, writer = _make_streams(response) + with pytest.raises(ProtoError, match="rejected"): + await socks4_connect(reader, writer, "1.2.3.4", 80) + + _run(run()) + + +# -- http_connect ------------------------------------------------------------ + + +class TestHttpConnect: + """Test HTTP CONNECT request building.""" + + def test_success_200(self): + """HTTP CONNECT with 200 response.""" + response = b"HTTP/1.1 200 Connection Established\r\n\r\n" + + async def run(): + reader, writer = _make_streams(response) + await http_connect(reader, writer, "example.com", 443) + + _run(run()) + + def test_success_with_headers(self): + """HTTP CONNECT with extra headers in response.""" + response = b"HTTP/1.1 200 OK\r\nX-Proxy: test\r\n\r\n" + + async def run(): + reader, writer = _make_streams(response) + await http_connect(reader, writer, "example.com", 443) + + _run(run()) + + def test_auth_success(self): + """HTTP CONNECT with proxy authentication.""" + response = b"HTTP/1.1 200 OK\r\n\r\n" + + async def run(): + reader, writer = _make_streams(response) + await http_connect(reader, writer, "example.com", 443, "user", "pass") + + _run(run()) + + def test_forbidden(self): + """HTTP CONNECT with 403 response.""" + response = b"HTTP/1.1 403 Forbidden\r\n\r\n" + + async def run(): + reader, writer = _make_streams(response) + with pytest.raises(ProtoError, match="connect failed"): + await http_connect(reader, writer, "example.com", 443) + + _run(run()) + + def test_proxy_auth_required(self): + """HTTP CONNECT with 407 response.""" + response = b"HTTP/1.1 407 Proxy Authentication Required\r\n\r\n" + + async def run(): + reader, writer = _make_streams(response) + with pytest.raises(ProtoError, match="connect failed"): + await http_connect(reader, writer, "example.com", 443) + + _run(run()) + + def test_empty_response(self): + """HTTP CONNECT with empty response.""" + + async def run(): + reader, writer = _make_streams(b"") + with pytest.raises(ProtoError, match="empty response"): + await http_connect(reader, writer, "example.com", 443) + + _run(run()) + + +# -- Socks5Reply enum ------------------------------------------------------- + + +class TestSocks5Reply: + """Test SOCKS5 reply code values.""" + + def test_succeeded(self): + assert Socks5Reply.SUCCEEDED == 0x00 + + def test_general_failure(self): + assert Socks5Reply.GENERAL_FAILURE == 0x01 + + def test_connection_refused(self): + assert Socks5Reply.CONNECTION_REFUSED == 0x05 + + def test_command_not_supported(self): + assert Socks5Reply.COMMAND_NOT_SUPPORTED == 0x07 + + def test_address_type_not_supported(self): + assert Socks5Reply.ADDRESS_TYPE_NOT_SUPPORTED == 0x08 + + +# -- ProtoError -------------------------------------------------------------- + + +class TestProtoError: + """Test ProtoError exception.""" + + def test_default_reply(self): + err = ProtoError("test error") + assert str(err) == "test error" + assert err.reply == Socks5Reply.GENERAL_FAILURE + + def test_custom_reply(self): + err = ProtoError("refused", Socks5Reply.CONNECTION_REFUSED) + assert err.reply == Socks5Reply.CONNECTION_REFUSED