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
This commit is contained in:
9
Makefile
9
Makefile
@@ -1,10 +1,17 @@
|
|||||||
APP_NAME := s5p
|
APP_NAME := s5p
|
||||||
|
|
||||||
.PHONY: install test lint clean build up down logs
|
.PHONY: install install-service test lint clean build up down logs
|
||||||
|
|
||||||
install:
|
install:
|
||||||
pip install -e .
|
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:
|
test:
|
||||||
pytest tests/ -v
|
pytest tests/ -v
|
||||||
|
|
||||||
|
|||||||
10
ROADMAP.md
10
ROADMAP.md
@@ -1,6 +1,6 @@
|
|||||||
# s5p -- Roadmap
|
# s5p -- Roadmap
|
||||||
|
|
||||||
## v0.1.0 (current)
|
## v0.1.0
|
||||||
|
|
||||||
- [x] SOCKS5 server (CONNECT command)
|
- [x] SOCKS5 server (CONNECT command)
|
||||||
- [x] Proxy chaining (SOCKS5, SOCKS4/4a, HTTP CONNECT)
|
- [x] Proxy chaining (SOCKS5, SOCKS4/4a, HTTP CONNECT)
|
||||||
@@ -32,9 +32,13 @@
|
|||||||
- [x] Named proxy pools with per-listener assignment (`proxy_pools:`)
|
- [x] Named proxy pools with per-listener assignment (`proxy_pools:`)
|
||||||
- [x] MITM source filter (`mitm: true/false` on pool sources)
|
- [x] MITM source filter (`mitm: true/false` on pool sources)
|
||||||
|
|
||||||
## v0.3.0
|
## v0.3.0 (current)
|
||||||
|
|
||||||
- [x] SOCKS5 server authentication (username/password)
|
- [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)
|
- [ ] UDP ASSOCIATE support (SOCKS5 UDP relay)
|
||||||
- [ ] BIND support
|
- [ ] BIND support
|
||||||
- [ ] Chain randomization (random order, random subset)
|
- [ ] Chain randomization (random order, random subset)
|
||||||
@@ -42,6 +46,4 @@
|
|||||||
## v1.0.0
|
## v1.0.0
|
||||||
|
|
||||||
- [ ] Stable API and config format
|
- [ ] Stable API and config format
|
||||||
- [ ] Comprehensive test suite with mock proxies (integration tests done)
|
|
||||||
- [ ] Systemd service unit
|
|
||||||
- [ ] Performance benchmarks
|
- [ ] Performance benchmarks
|
||||||
|
|||||||
12
TASKS.md
12
TASKS.md
@@ -67,6 +67,14 @@
|
|||||||
|
|
||||||
- [x] Gitea CI workflow (lint + test + Harbor image push)
|
- [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
|
## Next
|
||||||
- [x] Integration tests with mock proxy server
|
- [ ] UDP ASSOCIATE support
|
||||||
- [x] SOCKS5 server-side authentication
|
- [ ] BIND support
|
||||||
|
- [ ] Chain randomization
|
||||||
|
|||||||
15
config/s5p.service
Normal file
15
config/s5p.service
Normal file
@@ -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
|
||||||
@@ -21,6 +21,18 @@ s5p --tracemalloc # memory profile (top 10)
|
|||||||
s5p --tracemalloc 20 # memory profile (top 20)
|
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
|
## Container
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
source. Config is mounted at runtime. The compose.yaml volume mount
|
||||||
overrides source for local dev.
|
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)
|
## Install Tor (optional)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
297
docs/USAGE.md
297
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
|
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
|
All responses are `application/json`. Errors return `{"error": "message"}` with
|
||||||
appropriate status code (400, 404, 405, 500).
|
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
|
```bash
|
||||||
# Runtime status
|
|
||||||
curl -s http://127.0.0.1:1081/status | jq .
|
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 .
|
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 .
|
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'
|
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 .
|
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 .
|
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 .
|
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 .
|
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
|
## Tor Control Port
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "s5p"
|
name = "s5p"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
description = "SOCKS5 proxy with Tor and proxy-chain support"
|
description = "SOCKS5 proxy with Tor and proxy-chain support"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["pyyaml>=6.0"]
|
dependencies = ["pyyaml>=6.0"]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""s5p -- SOCKS5 proxy with chain support."""
|
"""s5p -- SOCKS5 proxy with chain support."""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.3.0"
|
||||||
|
|||||||
153
tests/test_cli.py
Normal file
153
tests/test_cli.py
Normal file
@@ -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"])
|
||||||
@@ -1,6 +1,66 @@
|
|||||||
"""Tests for protocol helpers."""
|
"""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:
|
class TestEncodeAddress:
|
||||||
@@ -11,12 +71,265 @@ class TestEncodeAddress:
|
|||||||
assert atyp == Socks5AddrType.IPV4
|
assert atyp == Socks5AddrType.IPV4
|
||||||
assert data == b"\x7f\x00\x00\x01"
|
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):
|
def test_ipv6(self):
|
||||||
atyp, data = encode_address("::1")
|
atyp, data = encode_address("::1")
|
||||||
assert atyp == Socks5AddrType.IPV6
|
assert atyp == Socks5AddrType.IPV6
|
||||||
assert len(data) == 16
|
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):
|
def test_domain(self):
|
||||||
atyp, data = encode_address("example.com")
|
atyp, data = encode_address("example.com")
|
||||||
assert atyp == Socks5AddrType.DOMAIN
|
assert atyp == Socks5AddrType.DOMAIN
|
||||||
assert data == bytes([11]) + b"example.com"
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user