dark3proxy: add hostname-preserving socks router on :2000
This commit is contained in:
@@ -57,6 +57,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
iproute2 \
|
||||
iputils-ping \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy binaries using the correct absolute paths from Stage 1
|
||||
@@ -68,10 +69,12 @@ COPY --from=builder_yggdrasil /out/genkeys /usr/bin/genkeys
|
||||
|
||||
# Copy local runtime helpers/config
|
||||
COPY ./entrypoint.sh /entrypoint.sh
|
||||
COPY ./socks_router.py /usr/local/bin/socks_router.py
|
||||
COPY ./yggdrasil.conf /etc/yggdrasil/yggdrasil.conf
|
||||
|
||||
# Setup config directories
|
||||
RUN mkdir -p /etc/3proxy /etc/yggdrasil \
|
||||
&& chmod +x /usr/local/bin/socks_router.py \
|
||||
&& chmod +x /entrypoint.sh
|
||||
|
||||
# Ensure these files exist in your local directory where you run 'docker build'
|
||||
|
||||
+21
-1
@@ -6,10 +6,12 @@ YGG_CONNECT_WAIT_SECONDS="${YGG_CONNECT_WAIT_SECONDS:-8}"
|
||||
YGG_RUNTIME_CONF="${YGG_RUNTIME_CONF:-/run/yggdrasil.conf}"
|
||||
PROXY_CFG_PRIMARY="${PROXY_CFG_PRIMARY:-/etc/3proxy/first-instanse.cfg}"
|
||||
PROXY_CFG_SECONDARY="${PROXY_CFG_SECONDARY:-/etc/3proxy/second-instanse.cfg}"
|
||||
SOCKS_ROUTER_BIN="${SOCKS_ROUTER_BIN:-/usr/local/bin/socks_router.py}"
|
||||
|
||||
ygg_pid=""
|
||||
proxy_primary_pid=""
|
||||
proxy_secondary_pid=""
|
||||
router_pid=""
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||
@@ -22,6 +24,9 @@ cleanup() {
|
||||
if [[ -n "${proxy_secondary_pid}" ]]; then
|
||||
kill -TERM "${proxy_secondary_pid}" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n "${router_pid}" ]]; then
|
||||
kill -TERM "${router_pid}" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n "${ygg_pid}" ]]; then
|
||||
kill -TERM "${ygg_pid}" 2>/dev/null || true
|
||||
fi
|
||||
@@ -45,6 +50,11 @@ if [[ ! -f "${PROXY_CFG_SECONDARY}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${SOCKS_ROUTER_BIN}" ]]; then
|
||||
log "Missing SOCKS router at ${SOCKS_ROUTER_BIN}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "${YGG_CONF}" "${YGG_RUNTIME_CONF}"
|
||||
|
||||
# Populate keys once if config still has empty key fields.
|
||||
@@ -87,6 +97,16 @@ if ! kill -0 "${proxy_secondary_pid}" 2>/dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Starting hostname-preserving SOCKS router (${SOCKS_ROUTER_BIN})"
|
||||
python3 "${SOCKS_ROUTER_BIN}" &
|
||||
router_pid=$!
|
||||
|
||||
sleep 1
|
||||
if ! kill -0 "${router_pid}" 2>/dev/null; then
|
||||
log "SOCKS router exited during startup"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Starting primary 3proxy instance (${PROXY_CFG_PRIMARY})"
|
||||
/usr/bin/3proxy "${PROXY_CFG_PRIMARY}" &
|
||||
proxy_primary_pid=$!
|
||||
@@ -97,7 +117,7 @@ if ! kill -0 "${proxy_primary_pid}" 2>/dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
wait -n "${proxy_secondary_pid}" "${proxy_primary_pid}" "${ygg_pid}"
|
||||
wait -n "${proxy_secondary_pid}" "${proxy_primary_pid}" "${router_pid}" "${ygg_pid}"
|
||||
exit_code=$?
|
||||
log "A managed process exited, shutting down"
|
||||
cleanup
|
||||
|
||||
@@ -52,5 +52,5 @@ flush
|
||||
allow *
|
||||
parent 1000 socks5+ 10.5.0.7 9050
|
||||
|
||||
socks -olSO_REUSEADDR,SO_REUSEPORT -ocTCP_TIMESTAMPS,TCP_NODELAY -osTCP_NODELAY -46 -i0.0.0.0 -p1080
|
||||
socks -olSO_REUSEADDR,SO_REUSEPORT -ocTCP_TIMESTAMPS,TCP_NODELAY -osTCP_NODELAY -46 -i0.0.0.0 -p1088
|
||||
proxy -olSO_REUSEADDR,SO_REUSEPORT -ocTCP_TIMESTAMPS,TCP_NODELAY -osTCP_NODELAY -46 -a1 -i0.0.0.0 -p3128
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import os
|
||||
import signal
|
||||
import struct
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
|
||||
SOCKS_VERSION = 5
|
||||
CMD_CONNECT = 1
|
||||
ATYP_IPV4 = 1
|
||||
ATYP_DOMAIN = 3
|
||||
ATYP_IPV6 = 4
|
||||
|
||||
REP_SUCCEEDED = 0
|
||||
REP_GENERAL_FAILURE = 1
|
||||
REP_CONNECTION_NOT_ALLOWED = 2
|
||||
REP_NETWORK_UNREACHABLE = 3
|
||||
REP_HOST_UNREACHABLE = 4
|
||||
REP_COMMAND_NOT_SUPPORTED = 7
|
||||
REP_ADDRESS_TYPE_NOT_SUPPORTED = 8
|
||||
|
||||
YGG_NET = ipaddress.ip_network("200::/7")
|
||||
|
||||
|
||||
def env_endpoint(var_name: str, default: str) -> Tuple[str, int]:
|
||||
raw = os.getenv(var_name, default).strip()
|
||||
host, sep, port_s = raw.rpartition(":")
|
||||
if not sep:
|
||||
raise ValueError(f"Invalid endpoint for {var_name}: {raw}")
|
||||
return host, int(port_s)
|
||||
|
||||
|
||||
LISTEN_HOST = os.getenv("ROUTER_LISTEN_HOST", "0.0.0.0")
|
||||
LISTEN_PORT = int(os.getenv("ROUTER_LISTEN_PORT", "1080"))
|
||||
UPSTREAM_TOR = env_endpoint("ROUTER_UPSTREAM_TOR", "10.5.0.7:9050")
|
||||
UPSTREAM_I2P = env_endpoint("ROUTER_UPSTREAM_I2P", "10.5.0.2:4447")
|
||||
UPSTREAM_YGG = env_endpoint("ROUTER_UPSTREAM_YGG", "127.0.0.1:1085")
|
||||
|
||||
BLOCKLIST = [
|
||||
item.strip().lower()
|
||||
for item in os.getenv("ROUTER_BLOCKLIST", "vk.com,ok.ru,mail.ru,gosuslugi.ru").split(",")
|
||||
if item.strip()
|
||||
]
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"[{ts}] [socks-router] {msg}", flush=True)
|
||||
|
||||
|
||||
def is_blocked_domain(hostname: str) -> bool:
|
||||
host = hostname.rstrip(".").lower()
|
||||
for blocked in BLOCKLIST:
|
||||
if host == blocked or host.endswith("." + blocked):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def pick_upstream(host: str, atyp: int) -> Optional[Tuple[str, int]]:
|
||||
if atyp == ATYP_DOMAIN:
|
||||
dhost = host.rstrip(".").lower()
|
||||
if is_blocked_domain(dhost):
|
||||
return None
|
||||
if dhost.endswith(".onion"):
|
||||
return UPSTREAM_TOR
|
||||
if dhost.endswith(".i2p"):
|
||||
return UPSTREAM_I2P
|
||||
if dhost.endswith(".ygg") or dhost.endswith(".meshname") or dhost.endswith(".meship"):
|
||||
return UPSTREAM_YGG
|
||||
return UPSTREAM_TOR
|
||||
|
||||
if atyp == ATYP_IPV6:
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
if ip in YGG_NET:
|
||||
return UPSTREAM_YGG
|
||||
except ValueError:
|
||||
return None
|
||||
return UPSTREAM_TOR
|
||||
|
||||
if atyp == ATYP_IPV4:
|
||||
return UPSTREAM_TOR
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def read_exact(reader: asyncio.StreamReader, n: int) -> bytes:
|
||||
data = await reader.readexactly(n)
|
||||
if len(data) != n:
|
||||
raise asyncio.IncompleteReadError(partial=data, expected=n)
|
||||
return data
|
||||
|
||||
|
||||
async def send_reply(writer: asyncio.StreamWriter, rep: int) -> None:
|
||||
writer.write(b"\x05" + bytes([rep]) + b"\x00\x01\x00\x00\x00\x00\x00\x00")
|
||||
await writer.drain()
|
||||
|
||||
|
||||
async def pipe(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
try:
|
||||
while True:
|
||||
chunk = await reader.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
writer.write(chunk)
|
||||
await writer.drain()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def handle_client(client_reader: asyncio.StreamReader, client_writer: asyncio.StreamWriter) -> None:
|
||||
peer = client_writer.get_extra_info("peername")
|
||||
upstream_writer = None
|
||||
|
||||
try:
|
||||
header = await read_exact(client_reader, 2)
|
||||
ver, n_methods = header[0], header[1]
|
||||
if ver != SOCKS_VERSION:
|
||||
client_writer.close()
|
||||
await client_writer.wait_closed()
|
||||
return
|
||||
|
||||
_ = await read_exact(client_reader, n_methods)
|
||||
client_writer.write(b"\x05\x00")
|
||||
await client_writer.drain()
|
||||
|
||||
req = await read_exact(client_reader, 4)
|
||||
ver, cmd, _rsv, atyp = req
|
||||
if ver != SOCKS_VERSION or cmd != CMD_CONNECT:
|
||||
await send_reply(client_writer, REP_COMMAND_NOT_SUPPORTED)
|
||||
return
|
||||
|
||||
if atyp == ATYP_IPV4:
|
||||
raw_addr = await read_exact(client_reader, 4)
|
||||
dst_host = str(ipaddress.IPv4Address(raw_addr))
|
||||
elif atyp == ATYP_IPV6:
|
||||
raw_addr = await read_exact(client_reader, 16)
|
||||
dst_host = str(ipaddress.IPv6Address(raw_addr))
|
||||
elif atyp == ATYP_DOMAIN:
|
||||
dlen = (await read_exact(client_reader, 1))[0]
|
||||
raw_addr = await read_exact(client_reader, dlen)
|
||||
dst_host = raw_addr.decode("ascii", errors="ignore")
|
||||
else:
|
||||
await send_reply(client_writer, REP_ADDRESS_TYPE_NOT_SUPPORTED)
|
||||
return
|
||||
|
||||
dst_port = struct.unpack("!H", await read_exact(client_reader, 2))[0]
|
||||
|
||||
upstream = pick_upstream(dst_host, atyp)
|
||||
if upstream is None:
|
||||
await send_reply(client_writer, REP_CONNECTION_NOT_ALLOWED)
|
||||
log(f"deny peer={peer} dst={dst_host}:{dst_port}")
|
||||
return
|
||||
|
||||
up_host, up_port = upstream
|
||||
log(f"route peer={peer} dst={dst_host}:{dst_port} via={up_host}:{up_port}")
|
||||
|
||||
try:
|
||||
upstream_reader, upstream_writer = await asyncio.open_connection(up_host, up_port)
|
||||
except Exception:
|
||||
await send_reply(client_writer, REP_NETWORK_UNREACHABLE)
|
||||
return
|
||||
|
||||
# SOCKS5 greeting to upstream.
|
||||
upstream_writer.write(b"\x05\x01\x00")
|
||||
await upstream_writer.drain()
|
||||
up_auth = await read_exact(upstream_reader, 2)
|
||||
if up_auth[0] != SOCKS_VERSION or up_auth[1] != 0x00:
|
||||
await send_reply(client_writer, REP_GENERAL_FAILURE)
|
||||
return
|
||||
|
||||
connect_req = bytearray(b"\x05\x01\x00")
|
||||
connect_req.append(atyp)
|
||||
if atyp == ATYP_DOMAIN:
|
||||
try:
|
||||
host_bytes = dst_host.encode("idna")
|
||||
except UnicodeError:
|
||||
host_bytes = dst_host.encode("ascii", errors="ignore")
|
||||
if len(host_bytes) > 255:
|
||||
await send_reply(client_writer, REP_HOST_UNREACHABLE)
|
||||
return
|
||||
connect_req.append(len(host_bytes))
|
||||
connect_req.extend(host_bytes)
|
||||
else:
|
||||
connect_req.extend(raw_addr)
|
||||
connect_req.extend(struct.pack("!H", dst_port))
|
||||
|
||||
upstream_writer.write(connect_req)
|
||||
await upstream_writer.drain()
|
||||
|
||||
up_resp = await read_exact(upstream_reader, 4)
|
||||
if up_resp[0] != SOCKS_VERSION:
|
||||
await send_reply(client_writer, REP_GENERAL_FAILURE)
|
||||
return
|
||||
|
||||
up_rep = up_resp[1]
|
||||
up_atyp = up_resp[3]
|
||||
if up_atyp == ATYP_IPV4:
|
||||
_ = await read_exact(upstream_reader, 4)
|
||||
elif up_atyp == ATYP_IPV6:
|
||||
_ = await read_exact(upstream_reader, 16)
|
||||
elif up_atyp == ATYP_DOMAIN:
|
||||
ln = (await read_exact(upstream_reader, 1))[0]
|
||||
_ = await read_exact(upstream_reader, ln)
|
||||
else:
|
||||
await send_reply(client_writer, REP_GENERAL_FAILURE)
|
||||
return
|
||||
_ = await read_exact(upstream_reader, 2)
|
||||
|
||||
if up_rep != REP_SUCCEEDED:
|
||||
await send_reply(client_writer, up_rep if up_rep <= REP_ADDRESS_TYPE_NOT_SUPPORTED else REP_GENERAL_FAILURE)
|
||||
return
|
||||
|
||||
await send_reply(client_writer, REP_SUCCEEDED)
|
||||
|
||||
await asyncio.gather(
|
||||
pipe(client_reader, upstream_writer),
|
||||
pipe(upstream_reader, client_writer),
|
||||
)
|
||||
|
||||
except asyncio.IncompleteReadError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
log(f"error peer={peer} err={exc}")
|
||||
try:
|
||||
await send_reply(client_writer, REP_GENERAL_FAILURE)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
client_writer.close()
|
||||
await client_writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
server = await asyncio.start_server(handle_client, LISTEN_HOST, LISTEN_PORT)
|
||||
sockets = ", ".join(str(s.getsockname()) for s in (server.sockets or []))
|
||||
log(f"listening on {sockets}")
|
||||
|
||||
stop_event = asyncio.Event()
|
||||
|
||||
def _stop() -> None:
|
||||
stop_event.set()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(sig, _stop)
|
||||
except NotImplementedError:
|
||||
pass
|
||||
|
||||
await stop_event.wait()
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
log("shutdown complete")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
except KeyboardInterrupt:
|
||||
raise SystemExit(0)
|
||||
@@ -29,7 +29,7 @@ blocking/forwarding behavior.
|
||||
|
||||
This container runs the following vontainers in the stack.
|
||||
|
||||
3proxy-eagle - Socks5/http proxy
|
||||
dark3proxy - SOCKS5/http proxy with hostname-preserving router
|
||||
- DNS
|
||||
- pihole - ad blocking and metrics
|
||||
- PopuraDNS - non-recursive DNS forwards requests to the following:
|
||||
@@ -46,8 +46,18 @@ This container runs the following vontainers in the stack.
|
||||
|
||||
Proxy access is exposed on a single host port:
|
||||
|
||||
* `<host-ip>:2000` -> direct Tor SOCKS (`darktor:9050`), externally reachable.
|
||||
- Supports `.onion` and clearnet via `--socks5-hostname`.
|
||||
* `<host-ip>:2000` -> `dark3proxy` SOCKS (`dark3proxy:1080`), externally reachable.
|
||||
- Intended as the single client entrypoint for clearnet, onion, and Ygg/I2P routing rules.
|
||||
|
||||
Router internals for `:2000`:
|
||||
|
||||
* Front-end process: `3proxy/socks_router.py` (SOCKS5), keeps destination hostnames intact.
|
||||
* Upstream dispatch:
|
||||
- `*.onion` -> Tor SOCKS (`10.5.0.7:9050`)
|
||||
- `*.i2p` -> i2pd SOCKS (`10.5.0.2:4447`)
|
||||
- `*.ygg`, `*.meshname`, `*.meship`, and `200::/7` -> local Ygg-guarded SOCKS (`127.0.0.1:1085`)
|
||||
- all other targets -> Tor SOCKS (`10.5.0.7:9050`)
|
||||
* Existing 3proxy primary instance still runs for admin/legacy rules, with SOCKS moved to internal port `1088` to avoid collision.
|
||||
|
||||
Additional non-proxy endpoint:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user