From 77d8066f85d1d5a7c2b1867b1e5553ac02983836 Mon Sep 17 00:00:00 2001 From: Hermes-1 Date: Fri, 19 Jun 2026 07:41:51 +0000 Subject: [PATCH] Initial: minimal sub-to-SOCKS container - Python entrypoint fetches subscription URL, picks VLESS node, generates sing-box JSON config on the fly, then execs sing-box. Required env var: SUB_URL Optional: SOCKS_PORT, SOCKS_USER/PASS, DNS_SERVER, PICK_STRATEGY, LOG_LEVEL --- .gitignore | 2 + Dockerfile | 10 +++ README.md | 96 ++++++++++++++++++++ entrypoint.py | 236 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 344 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 entrypoint.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f0b60ff --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +# ========== Stage 1: extract sing-box binary ========== +FROM ghcr.io/sagernet/sing-box:latest AS sb-bin +# ========== Stage 2: slim Python runtime + entrypoint.py ========== +FROM python:3.13-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=sb-bin /usr/local/bin/sing-box /usr/local/bin/sing-box +RUN chmod +x /usr/local/bin/sing-box +WORKDIR /src +COPY entrypoint.py . +ENTRYPOINT ["python3", "entrypoint.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ce8a29 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# Sub2SOCKS — minimal subscription-to-SOCKS container + +**One env var, SOCKS proxy.** Pulls a subscription URL at startup, picks the first VLESS node from it, generates a sing-box JSON config on the fly and starts `sing-box` with a SOCKS5 inbound → VLESS outbound. + +## Quick start + +```bash +docker run -d --name sub2socks \ + -p 1080:1080 \ + -e SUB_URL="https://your-sub-endpoint.example.com/link/xxxxxxx" \ + sub2socks:latest +``` + +Connect to `socks5://localhost:1080`. + +## Environment variables + +| Variable | Required? | Default | Description | +|----------|-----------|----------------------|---------------------------------------------------| +| `SUB_URL` | **yes** | — | Subscription group URL (any format with VLESS nodes) | +| `SOCKS_PORT` | no | `1080` | SOCKS5 listen port | +| `SOCKS_USER` | no | `""` | Auth username (leave empty to skip auth) | +| `SOCKS_PASS` | no | `""` | Auth password | +| `DNS_SERVER` | no | `tls://8.8.8.8` | Upstream DNS resolver address | +| `PICK_STRATEGY` | no | `first` | Node selction: `first` (default) or `random` | +| `LOG_LEVEL` | no | `info` | sing-box log level (`debug`, `warn`, `error`) | + +### With auth + local DNS + +```bash +docker run -d --name sub2socks \ + -p 1080:1080 \ + -e SUB_URL="https://..." \ + -e SOCKS_USER=myuser \ + -e SOCKS_PASS=secret \ + -e DNS_SERVER=tls://192.168.1.53 # local AdGuard Home, etc. + sub2socks:latest +``` + +### With random node selection + +```bash +docker run -d --name sub2socks \ + -p 1080:1080 \ + -e SUB_URL="https://..." \ + -e PICK_STRATEGY=random \ + sub2socks:latest +``` + +### In Docker Compose + +```yaml +services: + sub2socks: + image: sub2socks:latest + container_name: sub2socks + ports: + - "1080:1080" + environment: + SUB_URL: "https://your-sub-endpoint.example.com/sub/xxxxxxx" + DNS_SERVER: "tls://192.168.1.53" # your local AdGuard Home +``` + +## How it works + +1. **Entrypoint runs** `python3 entrypoint.py` +2. Downloads the subscription content & handles base64-encoded payloads automatically +3. Extracts all VLESS nodes, picks one (`first` or `random`) +4. Writes `/tmp/.sub2socks.json` — a full sing-box config with that node as outbound +5. **Execs** `sing-box run -c /tmp/.sub2socks.json` (process replacement — no Python in the runtime tree) + +## Testing connection + +```bash +docker exec sub2socks python3 -c " +import socket, struct +s = socket.create_connection(('127.0.0.1', 1080), timeout=15) +s.sendall(b'\x05\x01\x00') # handshake (no auth) +resp = s.recv(2); assert resp[1] == 0 # no auth needed + +# CONNECT request to httpbin.org:443 +target = b'httpbin.org' +req = b'\x05\x01\x00\x03' + bytes([len(target)]) + target + struct.pack('!H', 443) +s.sendall(req) +resp = s.recv(5) +print('OK!' if resp[1] == 0 else 'FAILED') +" +``` + +## Build from source + +```bash +docker build -t sub2socks /path/to/sub2socks/ +``` + +The final image is ~180 MB (python:3.13-slim + sing-box binary). diff --git a/entrypoint.py b/entrypoint.py new file mode 100644 index 0000000..fb7bb18 --- /dev/null +++ b/entrypoint.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +""" +Sub2SOCKS bootstrap - fetch subscription -> pick VLESS node -> sing-box SOCKS proxy. + +Only required env var: SUB_URL (subscription group URL with VLESS nodes) + +Optional env vars: + SOCKS_PORT (default 1080) SOCKS5 listen port + SOCKS_USER / SOCKS_PASS auth credentials (empty -> no auth) + DNS_SERVER (default tls://8.8.8.8) upstream resolver e.g. tls:// + PICK_STRATEGY first | random (default "first") + LOG_LEVEL info | debug | warn | error (default "info") +""" + +import base64 +import json +import os +import random +import sys +import textwrap +import urllib.error +import urllib.parse as up +import urllib.request + +SUB_URL = os.environ["SUB_URL"] +SOCKS_PORT = int(os.environ.get("SOCKS_PORT", "1080")) +SOCKS_USER = os.environ.get("SOCKS_USER", "") +SOCKS_PASS = os.environ.get("SOCKS_PASS", "") +DNS_SERVER = os.environ.get("DNS_SERVER", "tls://8.8.8.8") +PICK_STRATEGY = os.environ.get("PICK_STRATEGY", "first") +LOG_LEVEL = os.environ.get("LOG_LEVEL", "info") + +CONFIG_FILE = "/tmp/sub2socks.json" + + +def _fetch(url): + """Download sub content, auto decode base64 if needed.""" + req = urllib.request.Request(url, headers={"User-Agent": "clash.meta"}) + with urllib.request.urlopen(req, timeout=30) as r: + raw = r.read() + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + return base64.b64decode(raw).decode("utf-8") + + +def _vless_lines(text): + """Return VLESS URI lines (handles base64-encoded lines too).""" + out = [] + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if line.lower().startswith("vless://"): + out.append(line) + continue + try: + d = base64.b64decode(line).decode("utf-8") + if d.lower().startswith("vless://"): + out.append(d) + except Exception: + pass + return out + + +def _parse(url_str): + """Parse a VLESS URL into component dict.""" + p = up.urlparse(url_str.strip()) + q = up.parse_qs(p.query) + def g(key, default=None): + if key in q: + return q[key][0] + return default + return { + "uuid": p.username, + "server": p.hostname, + "port": p.port or (443 if p.scheme == "vless+tls" else 80), + "security": g("security", "tls"), + "type": g("type", "tcp"), + "sni": g("sni") or "", + "alpn": g("alpn") or "", + "pbk": g("public_key") or g("pbk") or "", + "sid": g("sid") or "", + "spX": g("spX") or "", + "path": g("path") or "/", + "serviceName": g("serviceName") or g("service-name") or "", + "host": g("host") or "", + "flow": g("flow") or "", + } + + +def _config(proxy): + """Build sing-box JSON config.""" + ob = { + "type": "vless", + "tag": "proxy", + "server": proxy["server"], + "server_port": proxy["port"], + "uuid": proxy["uuid"], + "packet_encoding": "xudp", + } + + sec = proxy.get("security") or "tls" + + if sec == "reality": + ob["tls"] = { + "enabled": True, + "server_name": proxy.get("sni"), + "utls": {"enabled": True, "fingerprint": "chrome"}, + "reality": { + "enabled": True, + "public_key": proxy["pbk"], + "short_id": proxy.get("sid") or proxy.get("spX"), + }, + } + elif sec == "tls": + ob["tls"] = { + "enabled": True, + "server_name": proxy.get("sni"), + "utls": {"enabled": True, "fingerprint": "chrome"}, + } + alpn_raw = proxy.get("alpn") + if alpn_raw: + ob["tls"]["alpn"] = [x.strip() for x in alpn_raw.split(",") if x.strip()] + + ttype = proxy.get("type") or "tcp" + tp = {"type": ttype} + + if ttype == "ws": + tp["path"] = proxy.get("path") or "/" + hv = proxy.get("host") + if hv: + tp["headers"] = {"Host": hv} + elif ttype == "http": + hv = proxy.get("host") + if hv: + tp["host"] = [x.strip() for x in hv.split(",") if x.strip()] + tp["path"] = proxy.get("path") or "/" + elif ttype == "grpc": + svc = proxy.get("serviceName") or proxy.get("path") + if svc: + tp["service_name"] = svc + + ob["transport"] = tp + + cfg = { + "log": {"level": LOG_LEVEL}, + "dns": { + "servers": [ + {"tag": "remote", "address": DNS_SERVER, "detour": "direct"}, + ], + "rules": [ + {"outbound": "any", "server": "remote"}, + ], + }, + "inbounds": [{ + "type": "socks", + "listen": "::", + "listen_port": SOCKS_PORT, + }], + "outbounds": [ + ob, # index 0 -> tag: proxy + {"type": "direct", "tag": "direct"}, # DNS detour / bypass + ], + "route": { + "rules": [{ + "clash_mode": "direct", + "outbound": "direct", + }], + "final": "proxy", # default -> outbound[0] + }, + } + + if SOCKS_USER: + cfg["inbounds"][0]["users"] = [ + {"username": SOCKS_USER, "password": SOCKS_PASS}, + ] + return cfg + + +def main(): + print(textwrap.dedent(f""" + +-------------------------------------------------------+ + | Sub2SOCKS -- bootstrapping | + +-------------------------------------------------------+ + | SUB_URL : {SUB_URL} + | SOCKS port : {SOCKS_PORT:>6d} + | DNS upstream : {DNS_SERVER} + | PICK_STRATEGY : {PICK_STRATEGY} + | LOG_LEVEL : {LOG_LEVEL} + +-------------------------------------------------------+ + """).strip()) + + # 1. fetch and filter + try: + data = _fetch(SUB_URL) + except urllib.error.URLError as e: + print(f"ERROR: Cannot reach {SUB_URL}: {e}", flush=True) + sys.exit(1) + except Exception as e: + print(f"ERROR: Subscription error: {e}", flush=True) + sys.exit(1) + + lines = _vless_lines(data) + if not lines: + print("ERROR: No VLESS nodes found in subscription!") + print(data[:500]) + sys.exit(1) + + print(f" Found {len(lines)} VLESS node(s) - picking one.", flush=True) + + chosen = random.choice(lines) if PICK_STRATEGY == "random" else lines[0] + + proxy = _parse(chosen) + print(f" Node: {proxy['server']}:{proxy['port']}", flush=True) + + conf = _config(proxy) + with open(CONFIG_FILE, "w") as fp: + json.dump(conf, fp, indent=2) + + print(f" Config written -> {CONFIG_FILE}", flush=True) + + # exec sing-box in-place + env_copy = dict(os.environ) + env_copy["ENABLE_DEPRECATED_LEGACY_DNS_SERVERS"] = "true" + env_copy["ENABLE_DEPRECATED_OUTBOUND_DNS_RULE_ITEM"] = "true" + + os.execve( + "/usr/local/bin/sing-box", + ["sing-box", "run", "-c", CONFIG_FILE], + env_copy, + ) + + +if __name__ == "__main__": + main()