feat: initial SOCKS5 proxy with chain support
Asyncio-based SOCKS5 server that tunnels connections through configurable chains of SOCKS5, SOCKS4/4a, and HTTP CONNECT proxies. Tor integration via standard SOCKS5 hop.
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
*.egg
|
||||
.eggs/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
14
Makefile
Normal file
14
Makefile
Normal file
@@ -0,0 +1,14 @@
|
||||
.PHONY: install test lint clean
|
||||
|
||||
install:
|
||||
pip install -e .
|
||||
|
||||
test:
|
||||
pytest tests/ -v
|
||||
|
||||
lint:
|
||||
ruff check src/ tests/
|
||||
|
||||
clean:
|
||||
rm -rf build/ dist/ src/*.egg-info
|
||||
find . -type d -name __pycache__ -exec rm -rf {} +
|
||||
40
PROJECT.md
Normal file
40
PROJECT.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# s5p -- Project
|
||||
|
||||
## Purpose
|
||||
|
||||
A lightweight SOCKS5 proxy server that chains connections through Tor and/or
|
||||
arbitrary proxy hops (SOCKS4, SOCKS5, HTTP CONNECT).
|
||||
|
||||
## Motivation
|
||||
|
||||
Existing solutions (`proxychains-ng`) rely on `LD_PRELOAD` hacks, only work
|
||||
on Linux, and intercept at the library level. s5p is a proper SOCKS5 server
|
||||
that any application can use natively -- no injection required.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
TCP tunnel tunnel
|
||||
Client -------> s5p -------> Hop 1 -------> Hop 2 -------> Target
|
||||
SOCKS5 proto1 proto2 protoN
|
||||
```
|
||||
|
||||
- **server.py** -- asyncio SOCKS5 server, chain builder, bidirectional relay
|
||||
- **proto.py** -- protocol handshake implementations (SOCKS5, SOCKS4/4a, HTTP CONNECT)
|
||||
- **config.py** -- YAML config loading, proxy URL parsing
|
||||
- **cli.py** -- argparse CLI, logging setup
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| pyyaml | Config file parsing |
|
||||
|
||||
All other functionality uses Python stdlib (`asyncio`, `socket`, `struct`).
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **No LD_PRELOAD** -- clean SOCKS5 server, works with any client
|
||||
- **asyncio** -- single-threaded event loop, efficient for I/O-bound proxying
|
||||
- **Domain passthrough** -- never resolve DNS locally to prevent leaks
|
||||
- **Tor as a hop** -- no special Tor handling; it's just `socks5://127.0.0.1:9050`
|
||||
72
README.md
Normal file
72
README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# s5p
|
||||
|
||||
SOCKS5 proxy server with Tor and proxy-chain support. Routes connections
|
||||
through configurable chains of SOCKS4, SOCKS5, and HTTP CONNECT proxies.
|
||||
|
||||
## Features
|
||||
|
||||
- SOCKS5 server (RFC 1928)
|
||||
- Proxy chaining: tunnel through multiple hops in sequence
|
||||
- Supported hop protocols: SOCKS5, SOCKS4/4a, HTTP CONNECT
|
||||
- Per-hop authentication (username/password)
|
||||
- DNS leak prevention (domain names forwarded to proxies, never resolved locally)
|
||||
- Tor integration (Tor is just another SOCKS5 hop)
|
||||
- Pure Python, asyncio-based, minimal dependencies
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install
|
||||
cd ~/git/s5p
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -e .
|
||||
|
||||
# Run with Tor
|
||||
s5p -C socks5://127.0.0.1:9050
|
||||
|
||||
# Run with a chain: Tor -> external proxy
|
||||
s5p -C socks5://127.0.0.1:9050,socks5://proxy:1080
|
||||
|
||||
# Run with config file
|
||||
s5p -c config/example.yaml
|
||||
|
||||
# Test it
|
||||
curl --proxy socks5h://127.0.0.1:1080 https://check.torproject.org/api/ip
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
listen: 127.0.0.1:1080
|
||||
timeout: 10
|
||||
|
||||
chain:
|
||||
- socks5://127.0.0.1:9050 # Tor
|
||||
- socks5://user:pass@proxy:1080 # exit-side proxy
|
||||
- http://proxy2:8080 # HTTP CONNECT proxy
|
||||
```
|
||||
|
||||
## CLI Reference
|
||||
|
||||
```
|
||||
s5p [-c FILE] [-l [HOST:]PORT] [-C URL[,URL,...]] [-t SEC] [-v|-q]
|
||||
|
||||
Options:
|
||||
-c, --config FILE YAML config file
|
||||
-l, --listen [HOST:]PORT Listen address (default: 127.0.0.1:1080)
|
||||
-C, --chain URL[,URL] Comma-separated proxy chain
|
||||
-t, --timeout SEC Per-hop timeout (default: 10)
|
||||
-v, --verbose Debug logging
|
||||
-q, --quiet Errors only
|
||||
-V, --version Show version
|
||||
```
|
||||
|
||||
## How Chaining Works
|
||||
|
||||
```
|
||||
Client -> s5p -> Hop1 -> Hop2 -> ... -> HopN -> Destination
|
||||
```
|
||||
|
||||
s5p connects to Hop1 via TCP, negotiates the hop protocol (SOCKS5/4/HTTP),
|
||||
then over that tunnel negotiates with Hop2, and so on. The final hop connects
|
||||
to the actual destination. Each hop only sees its immediate neighbors.
|
||||
30
ROADMAP.md
Normal file
30
ROADMAP.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# s5p -- Roadmap
|
||||
|
||||
## v0.1.0 (current)
|
||||
|
||||
- [x] SOCKS5 server (CONNECT command)
|
||||
- [x] Proxy chaining (SOCKS5, SOCKS4/4a, HTTP CONNECT)
|
||||
- [x] Per-hop authentication
|
||||
- [x] YAML config + CLI flags
|
||||
- [x] DNS leak prevention
|
||||
|
||||
## v0.2.0
|
||||
|
||||
- [ ] SOCKS5 server authentication (username/password)
|
||||
- [ ] Tor control port integration (circuit renewal via NEWNYM)
|
||||
- [ ] Connection retry with configurable backoff
|
||||
- [ ] Metrics (connections/sec, bytes relayed, hop latency)
|
||||
|
||||
## v0.3.0
|
||||
|
||||
- [ ] UDP ASSOCIATE support (SOCKS5 UDP relay)
|
||||
- [ ] BIND support
|
||||
- [ ] Chain randomization (random order, random subset)
|
||||
- [ ] Hot-reload config on SIGHUP
|
||||
|
||||
## v1.0.0
|
||||
|
||||
- [ ] Stable API and config format
|
||||
- [ ] Comprehensive test suite with mock proxies
|
||||
- [ ] Systemd service unit
|
||||
- [ ] Performance benchmarks
|
||||
18
TASKS.md
Normal file
18
TASKS.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# s5p -- Tasks
|
||||
|
||||
## Current
|
||||
|
||||
- [x] Scaffold project structure
|
||||
- [x] Implement SOCKS5 server
|
||||
- [x] Implement protocol handshakes (SOCKS5, SOCKS4/4a, HTTP CONNECT)
|
||||
- [x] Implement chain builder
|
||||
- [x] CLI and config loading
|
||||
- [x] Unit tests (config, proto)
|
||||
- [x] Documentation
|
||||
- [ ] Smoke test with Tor
|
||||
|
||||
## Next
|
||||
|
||||
- [ ] Integration tests with mock proxy server
|
||||
- [ ] SOCKS5 server-side authentication
|
||||
- [ ] Tor control port integration
|
||||
26
TODO.md
Normal file
26
TODO.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# s5p -- Backlog
|
||||
|
||||
## Features
|
||||
|
||||
- SOCKS5 BIND and UDP ASSOCIATE commands
|
||||
- Chain randomization modes (random, round-robin)
|
||||
- Per-destination chain rules (bypass chain for local addresses)
|
||||
- Hot config reload on SIGHUP
|
||||
- Systemd socket activation
|
||||
|
||||
## Performance
|
||||
|
||||
- Benchmark relay throughput vs direct connection
|
||||
- Tune buffer sizes for different workloads
|
||||
- Connection pooling for frequently-used chains
|
||||
|
||||
## Security
|
||||
|
||||
- Optional SOCKS5 server authentication
|
||||
- Rate limiting per source IP
|
||||
- Access control lists
|
||||
|
||||
## Docs
|
||||
|
||||
- Man page
|
||||
- Architecture diagram
|
||||
15
config/example.yaml
Normal file
15
config/example.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
# s5p configuration
|
||||
|
||||
listen: 127.0.0.1:1080
|
||||
timeout: 10
|
||||
log_level: info
|
||||
|
||||
# Proxy chain -- connections tunnel through each hop in order.
|
||||
# Supported protocols: socks5://, socks4://, http://
|
||||
#
|
||||
# Example: route through Tor, then an external SOCKS5 proxy
|
||||
chain:
|
||||
- socks5://127.0.0.1:9050 # Tor
|
||||
# - socks5://user:pass@proxy:1080 # authenticated SOCKS5
|
||||
# - socks4://proxy:1080 # SOCKS4/4a
|
||||
# - http://user:pass@proxy:8080 # HTTP CONNECT
|
||||
46
docs/CHEATSHEET.md
Normal file
46
docs/CHEATSHEET.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# s5p -- Cheatsheet
|
||||
|
||||
## CLI
|
||||
|
||||
```
|
||||
s5p # direct, listen :1080
|
||||
s5p -C socks5://127.0.0.1:9050 # through Tor
|
||||
s5p -C socks5://tor:9050,http://px:8080 # Tor + HTTP proxy
|
||||
s5p -c config/example.yaml # from config file
|
||||
s5p -l 0.0.0.0:9999 # custom listen address
|
||||
s5p -t 30 # 30s per-hop timeout
|
||||
s5p -v # debug logging
|
||||
s5p -q # errors only
|
||||
```
|
||||
|
||||
## Proxy URLs
|
||||
|
||||
```
|
||||
socks5://host:port
|
||||
socks5://user:pass@host:port
|
||||
socks4://host:port
|
||||
http://host:port
|
||||
http://user:pass@host:port
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Check exit IP
|
||||
curl -x socks5h://127.0.0.1:1080 https://httpbin.org/ip
|
||||
|
||||
# Verbose curl
|
||||
curl -v -x socks5h://127.0.0.1:1080 https://example.com
|
||||
|
||||
# With timeout
|
||||
curl --max-time 30 -x socks5h://127.0.0.1:1080 https://example.com
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
|---------|-------|
|
||||
| Connection refused | Is Tor running? `ss -tlnp \| grep 9050` |
|
||||
| Timeout | Increase `-t`, check proxy reachability |
|
||||
| DNS leak | Use `socks5h://` (not `socks5://`) in client |
|
||||
| Auth failed | Verify credentials in proxy URL |
|
||||
39
docs/INSTALL.md
Normal file
39
docs/INSTALL.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# s5p -- Installation
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python >= 3.11
|
||||
- pip
|
||||
- Tor (optional, for Tor-based chains)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cd ~/git/s5p
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
s5p --version
|
||||
which s5p
|
||||
```
|
||||
|
||||
## Install Tor (optional)
|
||||
|
||||
```bash
|
||||
sudo apt install tor
|
||||
sudo systemctl enable --now tor
|
||||
|
||||
# Verify Tor SOCKS5 port
|
||||
ss -tlnp | grep 9050
|
||||
```
|
||||
|
||||
## Symlink (alternative)
|
||||
|
||||
```bash
|
||||
ln -sf ~/git/s5p/.venv/bin/s5p ~/.local/bin/s5p
|
||||
```
|
||||
73
docs/USAGE.md
Normal file
73
docs/USAGE.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# s5p -- Usage
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```bash
|
||||
# Direct proxy (no chain, just a SOCKS5 server)
|
||||
s5p
|
||||
|
||||
# Through Tor
|
||||
s5p -C socks5://127.0.0.1:9050
|
||||
|
||||
# Through Tor + another proxy
|
||||
s5p -C socks5://127.0.0.1:9050,socks5://proxy:1080
|
||||
|
||||
# Custom listen address
|
||||
s5p -l 0.0.0.0:9999 -C socks5://127.0.0.1:9050
|
||||
|
||||
# From config file
|
||||
s5p -c config/example.yaml
|
||||
|
||||
# Debug mode
|
||||
s5p -v -C socks5://127.0.0.1:9050
|
||||
```
|
||||
|
||||
## Config File
|
||||
|
||||
```yaml
|
||||
listen: 127.0.0.1:1080
|
||||
timeout: 10
|
||||
log_level: info
|
||||
|
||||
chain:
|
||||
- socks5://127.0.0.1:9050
|
||||
- http://user:pass@proxy:8080
|
||||
```
|
||||
|
||||
## Proxy URL Format
|
||||
|
||||
```
|
||||
protocol://[username:password@]host[:port]
|
||||
```
|
||||
|
||||
| Protocol | Default Port | Auth Support |
|
||||
|----------|-------------|-------------|
|
||||
| socks5 | 1080 | username/password |
|
||||
| socks4 | 1080 | none |
|
||||
| http | 8080 | Basic |
|
||||
|
||||
## Testing the Proxy
|
||||
|
||||
```bash
|
||||
# Check exit IP via Tor
|
||||
curl --proxy socks5h://127.0.0.1:1080 https://check.torproject.org/api/ip
|
||||
|
||||
# Fetch a page
|
||||
curl --proxy socks5h://127.0.0.1:1080 https://example.com
|
||||
|
||||
# Use with Firefox: set SOCKS5 proxy to 127.0.0.1:1080, enable remote DNS
|
||||
```
|
||||
|
||||
Note: use `socks5h://` (not `socks5://`) with curl to send DNS through the proxy.
|
||||
|
||||
## Chain Order
|
||||
|
||||
Hops are traversed left-to-right:
|
||||
|
||||
```
|
||||
-C hop1,hop2,hop3
|
||||
|
||||
Client -> s5p -> hop1 -> hop2 -> hop3 -> Destination
|
||||
```
|
||||
|
||||
Each hop only sees its immediate neighbors.
|
||||
26
pyproject.toml
Normal file
26
pyproject.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "s5p"
|
||||
version = "0.1.0"
|
||||
description = "SOCKS5 proxy with Tor and proxy-chain support"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["pyyaml>=6.0"]
|
||||
|
||||
[project.scripts]
|
||||
s5p = "s5p.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "N", "UP"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
3
src/s5p/__init__.py
Normal file
3
src/s5p/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""s5p -- SOCKS5 proxy with chain support."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
7
src/s5p/__main__.py
Normal file
7
src/s5p/__main__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Allow running as: python -m s5p."""
|
||||
|
||||
import sys
|
||||
|
||||
from .cli import main
|
||||
|
||||
sys.exit(main())
|
||||
83
src/s5p/cli.py
Normal file
83
src/s5p/cli.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Command-line interface for s5p."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from . import __version__
|
||||
from .config import Config, load_config, parse_proxy_url
|
||||
from .server import serve
|
||||
|
||||
|
||||
def _setup_logging(level: str) -> None:
|
||||
"""Configure logging with a compact muted format."""
|
||||
numeric = getattr(logging, level.upper(), logging.INFO)
|
||||
fmt = "\033[2m%(asctime)s\033[0m %(levelname).1s %(message)s"
|
||||
logging.basicConfig(level=numeric, format=fmt, datefmt="%H:%M:%S")
|
||||
|
||||
|
||||
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
"""Parse command-line arguments."""
|
||||
p = argparse.ArgumentParser(
|
||||
prog="s5p",
|
||||
description="SOCKS5 proxy with Tor and proxy-chain support",
|
||||
)
|
||||
p.add_argument(
|
||||
"-V", "--version", action="version", version=f"s5p {__version__}"
|
||||
)
|
||||
p.add_argument("-c", "--config", metavar="FILE", help="YAML config file")
|
||||
p.add_argument(
|
||||
"-l", "--listen", metavar="[HOST:]PORT",
|
||||
help="listen address (default: 127.0.0.1:1080)",
|
||||
)
|
||||
p.add_argument(
|
||||
"-C", "--chain", metavar="URL[,URL,...]",
|
||||
help="comma-separated proxy chain URLs",
|
||||
)
|
||||
p.add_argument(
|
||||
"-t", "--timeout", type=float, metavar="SEC",
|
||||
help="per-hop timeout in seconds (default: 10)",
|
||||
)
|
||||
p.add_argument("-v", "--verbose", action="store_true", help="debug logging")
|
||||
p.add_argument("-q", "--quiet", action="store_true", help="errors only")
|
||||
return p.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Entry point."""
|
||||
args = _parse_args(argv)
|
||||
|
||||
config = load_config(args.config) if args.config else Config()
|
||||
|
||||
if args.listen:
|
||||
if ":" in args.listen:
|
||||
host, port_str = args.listen.rsplit(":", 1)
|
||||
config.listen_host = host
|
||||
config.listen_port = int(port_str)
|
||||
else:
|
||||
config.listen_port = int(args.listen)
|
||||
|
||||
if args.chain:
|
||||
config.chain = [parse_proxy_url(u.strip()) for u in args.chain.split(",")]
|
||||
|
||||
if args.timeout is not None:
|
||||
config.timeout = args.timeout
|
||||
|
||||
if args.verbose:
|
||||
config.log_level = "debug"
|
||||
elif args.quiet:
|
||||
config.log_level = "error"
|
||||
|
||||
_setup_logging(config.log_level)
|
||||
|
||||
try:
|
||||
asyncio.run(serve(config))
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
except Exception as e:
|
||||
logging.getLogger("s5p").error("%s", e)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
101
src/s5p/config.py
Normal file
101
src/s5p/config.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Configuration loading and proxy URL parsing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
DEFAULT_PORTS = {"socks5": 1080, "socks4": 1080, "http": 8080}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChainHop:
|
||||
"""A single proxy hop in the chain."""
|
||||
|
||||
proto: str
|
||||
host: str
|
||||
port: int
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
auth = f"{self.username}@" if self.username else ""
|
||||
return f"{self.proto}://{auth}{self.host}:{self.port}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Server configuration."""
|
||||
|
||||
listen_host: str = "127.0.0.1"
|
||||
listen_port: int = 1080
|
||||
chain: list[ChainHop] = field(default_factory=list)
|
||||
timeout: float = 10.0
|
||||
log_level: str = "info"
|
||||
|
||||
|
||||
def parse_proxy_url(url: str) -> ChainHop:
|
||||
"""Parse a proxy URL like socks5://user:pass@host:port."""
|
||||
parsed = urlparse(url)
|
||||
proto = parsed.scheme.lower()
|
||||
if proto not in ("socks5", "socks4", "http"):
|
||||
raise ValueError(f"unsupported protocol: {proto}")
|
||||
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
raise ValueError(f"missing host in proxy URL: {url}")
|
||||
|
||||
port = parsed.port or DEFAULT_PORTS.get(proto, 1080)
|
||||
|
||||
return ChainHop(
|
||||
proto=proto,
|
||||
host=host,
|
||||
port=port,
|
||||
username=parsed.username,
|
||||
password=parsed.password,
|
||||
)
|
||||
|
||||
|
||||
def load_config(path: str | Path) -> Config:
|
||||
"""Load configuration from a YAML file."""
|
||||
path = Path(path)
|
||||
with path.open() as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
|
||||
config = Config()
|
||||
|
||||
if "listen" in raw:
|
||||
listen = raw["listen"]
|
||||
if isinstance(listen, str) and ":" in listen:
|
||||
host, port_str = listen.rsplit(":", 1)
|
||||
config.listen_host = host
|
||||
config.listen_port = int(port_str)
|
||||
elif isinstance(listen, (str, int)):
|
||||
config.listen_port = int(listen)
|
||||
|
||||
if "timeout" in raw:
|
||||
config.timeout = float(raw["timeout"])
|
||||
|
||||
if "log_level" in raw:
|
||||
config.log_level = raw["log_level"]
|
||||
|
||||
if "chain" in raw:
|
||||
for entry in raw["chain"]:
|
||||
if isinstance(entry, str):
|
||||
config.chain.append(parse_proxy_url(entry))
|
||||
elif isinstance(entry, dict):
|
||||
config.chain.append(
|
||||
ChainHop(
|
||||
proto=entry.get("proto", "socks5"),
|
||||
host=entry["host"],
|
||||
port=int(entry["port"]),
|
||||
username=entry.get("username"),
|
||||
password=entry.get("password"),
|
||||
)
|
||||
)
|
||||
|
||||
return config
|
||||
183
src/s5p/proto.py
Normal file
183
src/s5p/proto.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""Protocol handshake implementations for SOCKS4/4a, SOCKS5, and HTTP CONNECT."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import socket
|
||||
import struct
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class Socks5Reply(IntEnum):
|
||||
"""SOCKS5 reply codes (RFC 1928)."""
|
||||
|
||||
SUCCEEDED = 0x00
|
||||
GENERAL_FAILURE = 0x01
|
||||
NOT_ALLOWED = 0x02
|
||||
NETWORK_UNREACHABLE = 0x03
|
||||
HOST_UNREACHABLE = 0x04
|
||||
CONNECTION_REFUSED = 0x05
|
||||
TTL_EXPIRED = 0x06
|
||||
COMMAND_NOT_SUPPORTED = 0x07
|
||||
ADDRESS_TYPE_NOT_SUPPORTED = 0x08
|
||||
|
||||
|
||||
class Socks5AddrType(IntEnum):
|
||||
"""SOCKS5 address types."""
|
||||
|
||||
IPV4 = 0x01
|
||||
DOMAIN = 0x03
|
||||
IPV6 = 0x04
|
||||
|
||||
|
||||
class ProtoError(Exception):
|
||||
"""Protocol negotiation error."""
|
||||
|
||||
def __init__(self, message: str, reply: int = Socks5Reply.GENERAL_FAILURE):
|
||||
super().__init__(message)
|
||||
self.reply = reply
|
||||
|
||||
|
||||
def encode_address(host: str) -> tuple[int, bytes]:
|
||||
"""Encode host as SOCKS5 address. Returns (atyp, encoded_bytes)."""
|
||||
try:
|
||||
return Socks5AddrType.IPV4, socket.inet_aton(host)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
return Socks5AddrType.IPV6, socket.inet_pton(socket.AF_INET6, host)
|
||||
except OSError:
|
||||
pass
|
||||
encoded = host.encode("ascii")
|
||||
return Socks5AddrType.DOMAIN, bytes([len(encoded)]) + encoded
|
||||
|
||||
|
||||
async def read_socks5_address(
|
||||
reader: asyncio.StreamReader,
|
||||
) -> tuple[str, int]:
|
||||
"""Read a SOCKS5 address (atyp + addr + port) from the stream."""
|
||||
atyp = (await reader.readexactly(1))[0]
|
||||
if atyp == Socks5AddrType.IPV4:
|
||||
addr = socket.inet_ntoa(await reader.readexactly(4))
|
||||
elif atyp == Socks5AddrType.DOMAIN:
|
||||
length = (await reader.readexactly(1))[0]
|
||||
addr = (await reader.readexactly(length)).decode("ascii")
|
||||
elif atyp == Socks5AddrType.IPV6:
|
||||
addr = socket.inet_ntop(socket.AF_INET6, await reader.readexactly(16))
|
||||
else:
|
||||
raise ProtoError(
|
||||
f"unsupported address type: {atyp:#x}",
|
||||
Socks5Reply.ADDRESS_TYPE_NOT_SUPPORTED,
|
||||
)
|
||||
port = struct.unpack("!H", await reader.readexactly(2))[0]
|
||||
return addr, port
|
||||
|
||||
|
||||
async def socks5_connect(
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
host: str,
|
||||
port: int,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
) -> None:
|
||||
"""Negotiate a SOCKS5 CONNECT through the given stream."""
|
||||
methods = b"\x00\x02" if username and password else b"\x00"
|
||||
writer.write(bytes([0x05, len(methods)]) + methods)
|
||||
await writer.drain()
|
||||
|
||||
data = await reader.readexactly(2)
|
||||
if data[0] != 0x05:
|
||||
raise ProtoError(f"socks5: unexpected version {data[0]:#x}")
|
||||
|
||||
method = data[1]
|
||||
if method == 0x02:
|
||||
if not (username and password):
|
||||
raise ProtoError("socks5: server requires auth but no credentials")
|
||||
uname = username.encode("utf-8")
|
||||
passwd = password.encode("utf-8")
|
||||
writer.write(
|
||||
bytes([0x01, len(uname)]) + uname + bytes([len(passwd)]) + passwd
|
||||
)
|
||||
await writer.drain()
|
||||
auth_resp = await reader.readexactly(2)
|
||||
if auth_resp[1] != 0x00:
|
||||
raise ProtoError("socks5: authentication failed")
|
||||
elif method == 0xFF:
|
||||
raise ProtoError("socks5: no acceptable authentication methods")
|
||||
elif method != 0x00:
|
||||
raise ProtoError(f"socks5: unsupported method {method:#x}")
|
||||
|
||||
atyp, addr_bytes = encode_address(host)
|
||||
writer.write(
|
||||
struct.pack("!BBB", 0x05, 0x01, 0x00)
|
||||
+ bytes([atyp])
|
||||
+ addr_bytes
|
||||
+ struct.pack("!H", port)
|
||||
)
|
||||
await writer.drain()
|
||||
|
||||
resp = await reader.readexactly(3) # VER, REP, RSV
|
||||
if resp[1] != 0x00:
|
||||
raise ProtoError(f"socks5: connect failed (reply={resp[1]:#x})", resp[1])
|
||||
await read_socks5_address(reader)
|
||||
|
||||
|
||||
async def socks4_connect(
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
host: str,
|
||||
port: int,
|
||||
) -> None:
|
||||
"""Negotiate a SOCKS4/4a CONNECT through the given stream."""
|
||||
port_bytes = struct.pack("!H", port)
|
||||
try:
|
||||
ip_bytes = socket.inet_aton(host)
|
||||
writer.write(b"\x04\x01" + port_bytes + ip_bytes + b"\x00")
|
||||
except OSError:
|
||||
# SOCKS4a: sentinel IP 0.0.0.1, then domain after userid null
|
||||
writer.write(
|
||||
b"\x04\x01"
|
||||
+ port_bytes
|
||||
+ b"\x00\x00\x00\x01\x00"
|
||||
+ host.encode("ascii")
|
||||
+ b"\x00"
|
||||
)
|
||||
await writer.drain()
|
||||
|
||||
resp = await reader.readexactly(8)
|
||||
if resp[1] != 0x5A:
|
||||
raise ProtoError(f"socks4: request rejected (status={resp[1]:#x})")
|
||||
|
||||
|
||||
async def http_connect(
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
host: str,
|
||||
port: int,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
) -> None:
|
||||
"""Negotiate an HTTP CONNECT tunnel through the given stream."""
|
||||
request = f"CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n"
|
||||
if username and password:
|
||||
creds = base64.b64encode(f"{username}:{password}".encode()).decode()
|
||||
request += f"Proxy-Authorization: Basic {creds}\r\n"
|
||||
request += "\r\n"
|
||||
writer.write(request.encode())
|
||||
await writer.drain()
|
||||
|
||||
line = await reader.readline()
|
||||
if not line:
|
||||
raise ProtoError("http: empty response")
|
||||
parts = line.decode("utf-8", errors="replace").split(None, 2)
|
||||
if len(parts) < 2 or not parts[1].startswith("2"):
|
||||
raise ProtoError(
|
||||
f"http: connect failed: {line.decode('utf-8', errors='replace').strip()}"
|
||||
)
|
||||
|
||||
while True:
|
||||
header_line = await reader.readline()
|
||||
if header_line in (b"\r\n", b"\n", b""):
|
||||
break
|
||||
235
src/s5p/server.py
Normal file
235
src/s5p/server.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""SOCKS5 proxy server with proxy-chain support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import struct
|
||||
import time
|
||||
|
||||
from .config import ChainHop, Config
|
||||
from .proto import (
|
||||
ProtoError,
|
||||
Socks5Reply,
|
||||
http_connect,
|
||||
read_socks5_address,
|
||||
socks4_connect,
|
||||
socks5_connect,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("s5p")
|
||||
|
||||
BUFFER_SIZE = 65536
|
||||
|
||||
|
||||
# -- relay -------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _relay(
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
) -> None:
|
||||
"""Unidirectional data relay."""
|
||||
try:
|
||||
while True:
|
||||
data = await reader.read(BUFFER_SIZE)
|
||||
if not data:
|
||||
break
|
||||
writer.write(data)
|
||||
await writer.drain()
|
||||
except (ConnectionError, asyncio.CancelledError, OSError):
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except (OSError, ConnectionError):
|
||||
pass
|
||||
|
||||
|
||||
# -- chain building ----------------------------------------------------------
|
||||
|
||||
|
||||
async def _negotiate_hop(
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
hop: ChainHop,
|
||||
dest_host: str,
|
||||
dest_port: int,
|
||||
) -> None:
|
||||
"""Negotiate a single hop in the chain."""
|
||||
if hop.proto == "socks5":
|
||||
await socks5_connect(reader, writer, dest_host, dest_port, hop.username, hop.password)
|
||||
elif hop.proto == "socks4":
|
||||
await socks4_connect(reader, writer, dest_host, dest_port)
|
||||
elif hop.proto == "http":
|
||||
await http_connect(reader, writer, dest_host, dest_port, hop.username, hop.password)
|
||||
else:
|
||||
raise ProtoError(f"unsupported protocol: {hop.proto}")
|
||||
|
||||
|
||||
async def build_chain(
|
||||
chain: list[ChainHop],
|
||||
target_host: str,
|
||||
target_port: int,
|
||||
timeout: float = 10.0,
|
||||
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
||||
"""Build a tunnel through the proxy chain to the target.
|
||||
|
||||
Connects to the first hop via TCP, then negotiates each subsequent
|
||||
hop over the tunnel established by the previous one.
|
||||
"""
|
||||
if not chain:
|
||||
return await asyncio.wait_for(
|
||||
asyncio.open_connection(target_host, target_port),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(chain[0].host, chain[0].port),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
try:
|
||||
for i, hop in enumerate(chain):
|
||||
if i + 1 < len(chain):
|
||||
dest_host = chain[i + 1].host
|
||||
dest_port = chain[i + 1].port
|
||||
else:
|
||||
dest_host = target_host
|
||||
dest_port = target_port
|
||||
|
||||
await asyncio.wait_for(
|
||||
_negotiate_hop(reader, writer, hop, dest_host, dest_port),
|
||||
timeout=timeout,
|
||||
)
|
||||
logger.debug(
|
||||
"hop %d/%d ok %s -> %s:%d",
|
||||
i + 1,
|
||||
len(chain),
|
||||
hop.proto,
|
||||
dest_host,
|
||||
dest_port,
|
||||
)
|
||||
except Exception:
|
||||
writer.close()
|
||||
raise
|
||||
|
||||
return reader, writer
|
||||
|
||||
|
||||
# -- SOCKS5 server -----------------------------------------------------------
|
||||
|
||||
|
||||
def _socks5_reply(rep: int) -> bytes:
|
||||
"""Build a minimal SOCKS5 reply packet."""
|
||||
return struct.pack("!BBB", 0x05, rep, 0x00) + b"\x01\x00\x00\x00\x00\x00\x00"
|
||||
|
||||
|
||||
async def _handle_client(
|
||||
client_reader: asyncio.StreamReader,
|
||||
client_writer: asyncio.StreamWriter,
|
||||
config: Config,
|
||||
) -> None:
|
||||
"""Handle a single SOCKS5 client connection."""
|
||||
peer = client_writer.get_extra_info("peername")
|
||||
tag = f"{peer[0]}:{peer[1]}" if peer else "?"
|
||||
|
||||
try:
|
||||
# -- greeting --
|
||||
header = await asyncio.wait_for(client_reader.readexactly(2), timeout=10.0)
|
||||
if header[0] != 0x05:
|
||||
logger.warning("[%s] bad socks version: %d", tag, header[0])
|
||||
return
|
||||
|
||||
methods = await client_reader.readexactly(header[1])
|
||||
if 0x00 not in methods:
|
||||
client_writer.write(b"\x05\xff")
|
||||
await client_writer.drain()
|
||||
return
|
||||
|
||||
client_writer.write(b"\x05\x00")
|
||||
await client_writer.drain()
|
||||
|
||||
# -- connect request --
|
||||
req = await asyncio.wait_for(client_reader.readexactly(3), timeout=10.0)
|
||||
if req[0] != 0x05:
|
||||
return
|
||||
if req[1] != 0x01:
|
||||
client_writer.write(_socks5_reply(Socks5Reply.COMMAND_NOT_SUPPORTED))
|
||||
await client_writer.drain()
|
||||
return
|
||||
|
||||
target_host, target_port = await read_socks5_address(client_reader)
|
||||
logger.info("[%s] connect %s:%d", tag, target_host, target_port)
|
||||
|
||||
# -- build chain --
|
||||
t0 = time.monotonic()
|
||||
remote_reader, remote_writer = await build_chain(
|
||||
config.chain, target_host, target_port, timeout=config.timeout
|
||||
)
|
||||
dt = time.monotonic() - t0
|
||||
logger.debug("[%s] chain up in %.0fms", tag, dt * 1000)
|
||||
|
||||
# -- success --
|
||||
client_writer.write(_socks5_reply(Socks5Reply.SUCCEEDED))
|
||||
await client_writer.drain()
|
||||
|
||||
# -- relay --
|
||||
await asyncio.gather(
|
||||
_relay(client_reader, remote_writer),
|
||||
_relay(remote_reader, client_writer),
|
||||
)
|
||||
|
||||
except ProtoError as e:
|
||||
logger.warning("[%s] %s", tag, e)
|
||||
try:
|
||||
client_writer.write(_socks5_reply(e.reply))
|
||||
await client_writer.drain()
|
||||
except OSError:
|
||||
pass
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("[%s] timeout", tag)
|
||||
try:
|
||||
client_writer.write(_socks5_reply(Socks5Reply.TTL_EXPIRED))
|
||||
await client_writer.drain()
|
||||
except OSError:
|
||||
pass
|
||||
except (ConnectionError, OSError) as e:
|
||||
logger.debug("[%s] %s", tag, e)
|
||||
try:
|
||||
client_writer.write(_socks5_reply(Socks5Reply.CONNECTION_REFUSED))
|
||||
await client_writer.drain()
|
||||
except OSError:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("[%s] unexpected error", tag)
|
||||
finally:
|
||||
try:
|
||||
client_writer.close()
|
||||
await client_writer.wait_closed()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# -- entry point -------------------------------------------------------------
|
||||
|
||||
|
||||
async def serve(config: Config) -> None:
|
||||
"""Start the SOCKS5 proxy server."""
|
||||
|
||||
async def on_client(r: asyncio.StreamReader, w: asyncio.StreamWriter) -> None:
|
||||
await _handle_client(r, w, config)
|
||||
|
||||
srv = await asyncio.start_server(on_client, config.listen_host, config.listen_port)
|
||||
addrs = ", ".join(str(s.getsockname()) for s in srv.sockets)
|
||||
logger.info("listening on %s", addrs)
|
||||
|
||||
if config.chain:
|
||||
for i, hop in enumerate(config.chain):
|
||||
logger.info(" chain[%d] %s", i, hop)
|
||||
else:
|
||||
logger.info(" mode: direct (no chain)")
|
||||
|
||||
async with srv:
|
||||
await srv.serve_forever()
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
76
tests/test_config.py
Normal file
76
tests/test_config.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Tests for configuration loading and proxy URL parsing."""
|
||||
|
||||
import pytest
|
||||
|
||||
from s5p.config import ChainHop, Config, parse_proxy_url
|
||||
|
||||
|
||||
class TestParseProxyUrl:
|
||||
"""Test proxy URL parsing."""
|
||||
|
||||
def test_socks5_basic(self):
|
||||
hop = parse_proxy_url("socks5://127.0.0.1:9050")
|
||||
assert hop.proto == "socks5"
|
||||
assert hop.host == "127.0.0.1"
|
||||
assert hop.port == 9050
|
||||
assert hop.username is None
|
||||
assert hop.password is None
|
||||
|
||||
def test_socks5_with_auth(self):
|
||||
hop = parse_proxy_url("socks5://user:pass@proxy.example.com:1080")
|
||||
assert hop.proto == "socks5"
|
||||
assert hop.host == "proxy.example.com"
|
||||
assert hop.port == 1080
|
||||
assert hop.username == "user"
|
||||
assert hop.password == "pass"
|
||||
|
||||
def test_socks4(self):
|
||||
hop = parse_proxy_url("socks4://10.0.0.1:1080")
|
||||
assert hop.proto == "socks4"
|
||||
assert hop.host == "10.0.0.1"
|
||||
assert hop.port == 1080
|
||||
|
||||
def test_http_connect(self):
|
||||
hop = parse_proxy_url("http://proxy:8080")
|
||||
assert hop.proto == "http"
|
||||
assert hop.host == "proxy"
|
||||
assert hop.port == 8080
|
||||
|
||||
def test_default_port_socks5(self):
|
||||
hop = parse_proxy_url("socks5://host")
|
||||
assert hop.port == 1080
|
||||
|
||||
def test_default_port_http(self):
|
||||
hop = parse_proxy_url("http://host")
|
||||
assert hop.port == 8080
|
||||
|
||||
def test_unsupported_protocol(self):
|
||||
with pytest.raises(ValueError, match="unsupported protocol"):
|
||||
parse_proxy_url("ftp://host:21")
|
||||
|
||||
def test_missing_host(self):
|
||||
with pytest.raises(ValueError, match="missing host"):
|
||||
parse_proxy_url("socks5://")
|
||||
|
||||
|
||||
class TestChainHop:
|
||||
"""Test ChainHop string representation."""
|
||||
|
||||
def test_str_without_auth(self):
|
||||
hop = ChainHop(proto="socks5", host="localhost", port=9050)
|
||||
assert str(hop) == "socks5://localhost:9050"
|
||||
|
||||
def test_str_with_auth(self):
|
||||
hop = ChainHop(proto="http", host="proxy", port=8080, username="u", password="p")
|
||||
assert str(hop) == "http://u@proxy:8080"
|
||||
|
||||
|
||||
class TestConfig:
|
||||
"""Test Config defaults."""
|
||||
|
||||
def test_defaults(self):
|
||||
c = Config()
|
||||
assert c.listen_host == "127.0.0.1"
|
||||
assert c.listen_port == 1080
|
||||
assert c.chain == []
|
||||
assert c.timeout == 10.0
|
||||
22
tests/test_proto.py
Normal file
22
tests/test_proto.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Tests for protocol helpers."""
|
||||
|
||||
from s5p.proto import Socks5AddrType, encode_address
|
||||
|
||||
|
||||
class TestEncodeAddress:
|
||||
"""Test SOCKS5 address encoding."""
|
||||
|
||||
def test_ipv4(self):
|
||||
atyp, data = encode_address("127.0.0.1")
|
||||
assert atyp == Socks5AddrType.IPV4
|
||||
assert data == b"\x7f\x00\x00\x01"
|
||||
|
||||
def test_ipv6(self):
|
||||
atyp, data = encode_address("::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"
|
||||
Reference in New Issue
Block a user