commit bde535c77ddf768c68ce41dcc9ff645e43315a5a Author: phptits Date: Wed Sep 9 04:16:44 2026 +0000 init diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..2be4ef0 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,4 @@ +# 默认使用 musl 目标,产出完全静态链接的单文件二进制(Linux 下免运行库运行)。 +# 首次编译前需安装目标: rustup target add x86_64-unknown-linux-musl +[build] +target = "x86_64-unknown-linux-musl" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..8a0bab3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,131 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "mini-socks" +version = "0.1.0" +dependencies = [ + "tokio", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..93ae0ba --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "mini-socks" +version = "0.1.0" +edition = "2021" +description = "Linux 下免运行库运行的轻量级 SOCKS5 服务器 (CONNECT / BIND / UDP, 多出口 IP)" +license = "MIT" + +[dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util"] } + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true +panic = "abort" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3ae5399 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 mini-socks contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf3c1cc --- /dev/null +++ b/README.md @@ -0,0 +1,179 @@ +# mini-socks + +![Platform](https://img.shields.io/badge/platform-Linux-blue) +![Language](https://img.shields.io/badge/language-Rust-orange) +![License](https://img.shields.io/badge/license-MIT-green) +![Version](https://img.shields.io/badge/version-0.1.0-brightgreen) + +**Linux 下免运行库运行的轻量级 SOCKS5 服务器。** 完全静态编译的单文件二进制,无任何运行时依赖,拷贝到任何 x86_64 Linux 上即可运行。 + +## 特性 + +- **SOCKS5 全特性**(RFC 1928): + - `CONNECT` —— TCP 代理 + - `BIND` —— 反向连入(FTP 等场景) + - `UDP ASSOCIATE` —— UDP 中继 +- **用户名/密码认证**(RFC 1929),未配置时免认证 +- **多出口 IP**:客户端从哪个本机 IP 连入,出站流量就以该 IP 为源地址 +- **systemd 服务**:一条命令安装/卸载,开机自启、掉线自动重启 +- **零依赖**:musl 静态链接,无 glibc 版本问题,无 OpenSSL,无 Python + +## 编译 + +需要 Rust 工具链(1.70+)与 musl 目标: + +```bash +rustup target add x86_64-unknown-linux-musl +cargo build --release +``` + +产物为完全静态链接的二进制: + +``` +target/x86_64-unknown-linux-musl/release/mini-socks # 约 700 KB +``` + +> 项目已通过 `.cargo/config.toml` 默认启用 musl 目标。musl 目标仅在编译时需要,运行环境无任何要求。 + +## 快速开始 + +```bash +# 免认证,监听 0.0.0.0:1080 +./mini-socks -p 1080 + +# 启用用户名/密码认证 +./mini-socks -p 1080 -a alice:s3cret +``` + +客户端连接示例: + +```bash +curl --socks5-hostname 127.0.0.1:1080 https://example.com +# 带认证 +curl -U alice:s3cret --socks5-hostname 127.0.0.1:1080 https://example.com +``` + +浏览器/软件代理设置:协议 `socks5`,地址 `<服务器 IP>`,端口 `1080`,账号密码可选。 + +## 命令行参数 + +``` +mini-socks [-p <端口>] [-a ] 前台运行 +mini-socks [-p <端口>] [-a ] -i 安装为 systemd 服务并启动(需 root) +mini-socks -u 停止并删除 systemd 服务(需 root) +mini-socks -h 显示版本、监听地址与帮助 +``` + +| 参数 | 说明 | +| ---- | ---- | +| `-p <端口>` | 监听端口(1-65535),默认 `1080`,监听 `0.0.0.0:<端口>` | +| `-a ` | 启用 SOCKS5 用户名/密码认证;缺省为免认证 | +| `-i` | 安装为 systemd 服务(`mini-socks.service`),立即启动并开机自启 | +| `-u` | 停止服务并删除 systemd 服务 | +| `-h` | 显示版本、监听地址与帮助 | + +## systemd 服务 + +在服务器上以 root 执行,`-p` / `-a` 参数会被固化进服务: + +```bash +sudo ./mini-socks -p 1080 -a alice:s3cret -i # 安装并立即启动 +``` + +```bash +systemctl status mini-socks # 查看状态 +journalctl -u mini-socks -f # 实时日志 +systemctl restart mini-socks # 重启 +sudo ./mini-socks -u # 停止并卸载服务 +``` + +说明: + +- `-i` 直接以当前二进制的绝对路径作为 `ExecStart`,**不复制文件**;移动或删除二进制前请先 `-u` 卸载 +- 重复执行 `-i` 为覆盖安装,以最后一次的参数为准并自动重启服务(例如改端口后重新 `-i` 即可生效) +- `-u` 只停止并删除服务,不删除二进制文件 +- 服务崩溃后 3 秒自动拉起(`Restart=on-failure`),并设置开机自启 + +## 多出口 IP 模式 + +服务器上配置了多个 IP 时,mini-socks 按 **"从哪个 IP 连入,就从哪个 IP 出去"** 自动选择出口源地址: + +``` +用户 A ──socks5://10.0.0.2:1080──▶ ┌─────────────┐ ──源 IP 10.0.0.2──▶ 目标网站 + │ mini-socks │ +用户 B ──socks5://10.0.0.3:1080──▶ └─────────────┘ ──源 IP 10.0.0.3──▶ 目标网站 +``` + +- 实现方式:TCP 出站绑定源地址(connect 前 bind),UDP 中继套接字同样绑定该地址 +- 前提:这些 IP 已配置在本机网卡上(`ip addr` 可见)。云服务器请把多个公网/弹性 IP 绑定到同一块网卡 +- 若入口 IP 不可用作源地址(如经过端口映射),自动回退系统默认路由并在日志中提示 + +多 IP 服务器上用户可见的效果: + +- 访问 `socks5://10.0.0.2:1080` 时,目标网站看到的出口 IP 是 **10.0.0.2** +- 访问 `socks5://10.0.0.3:1080` 时,目标网站看到的出口 IP 是 **10.0.0.3** + +## UDP 说明 + +- 遵循 RFC 1928 `UDP ASSOCIATE`:TCP 控制连接关闭即结束 UDP 会话 +- 中继地址通告为客户端连入所用的 IP,保证可达 +- 暂不支持分片(FRAG ≠ 0 的数据报丢弃),主流应用(游戏、语音、QUIC)均不使用分片 + +## 日志 + +日志输出到 stderr(systemd 下由 journald 收集),示例: + +``` +2026-09-06 15:23:25 [INFO] mini-socks v0.1.0 已启动,监听 0.0.0.0:1780,认证: 用户名/密码 (RFC 1929) +2026-09-06 15:23:25 [INFO] 认证通过: 用户 alice +2026-09-06 15:23:25 [INFO] CONNECT example.com:443 <- 10.0.0.2:51344,出口 10.0.0.2,转发 1024/20480 字节(上行/下行) +``` + +## 防火墙 + +放行 SOCKS5 监听端口(TCP),如需 UDP 还需放行相应 UDP 流量: + +```bash +# firewalld +firewall-cmd --permanent --add-port=1080/tcp && firewall-cmd --reload +# ufw +ufw allow 1080/tcp +``` + +> 公网部署强烈建议配合 `-a` 开启认证,或用防火墙限制来源 IP。 + +## 常见问题 + +
+BIND 命令有什么用? + +SOCKS5 的 `BIND` 用于让远端主动反向连入(典型如 FTP 主动模式数据连接)。日常 HTTP/网页代理不会用到,但 mini-socks 已完整实现,可配合需要的客户端使用。 +
+ +
+多出口 IP 需要特殊配置吗? + +不需要修改本程序参数。只要服务器网卡上已配置多个 IP,监听在 `0.0.0.0` 的 mini-socks 就会自动按连入 IP 选择出口。若某入口 IP 未配置在本机(如 NAT 映射场景),该连接会回退默认路由。 +
+ +
+忘记服务参数想修改怎么办? + +带新参数重新执行 `sudo ./mini-socks -p <端口> -a -i` 覆盖安装即可,服务会自动重启到新配置。 +
+ +## 项目结构 + +``` +mini-socks/ +├── src/ +│ ├── main.rs # 命令行解析、帮助信息、日志 +│ ├── socks.rs # SOCKS5 协议(CONNECT/BIND/UDP)、认证、多出口 IP +│ └── install.rs # systemd 服务安装/卸载 +├── Cargo.toml +└── .cargo/config.toml +``` + +## License + +[MIT](LICENSE) diff --git a/chat.md b/chat.md new file mode 100644 index 0000000..b852cc7 --- /dev/null +++ b/chat.md @@ -0,0 +1,65 @@ +# 优化mini-socks: +- `-i` 安装到 /usr/bin/mini-socks +- `-u` 删除服务 与 /usr/bin +- `-a` 如果没有参数就随机生成 user:pass(12位), user只有小写字母6位字符, pass为12位随机数字与字母 +- `-s` 提交一个代理到 supersite.host, 使用以下来提交代理: 以下API与TOKEN 都是硬编码 +```bash +curl -X POST 'https://supersite.host/api/proxy/submit' \ + -H 'Authorization: Bearer px_live_7cb412cec32c4906a1243f0f2ee906bf46dcbf6208373c40' \ + -H 'Content-Type: application/json' \ + -d '{"protocol":"socks5","host":"proxy.example.com","port":8080,"username":"user","password":"secret","country_code":"US"}' +``` + - 通过: https://api-ipv4.ip.sb/geoip (硬编码) 获取代理的IP地址与国家代码, 示例: + ```json + { + "ip": "127.0.0.1", + "country_code": "JP", + } + ``` +- 有什么问题与建议, 请发给我确认再编码. + + +`-s` 就是在安装服务成功后提交一次, 不写到systemd服务文件中. + + +安装完成后输出信息中加上: socks://user:pass@ip:port 这样的格式, 示例: +```bash +socks://user:pass@127.0.0.1:1080 +``` + +要确认一下 -a 空参数, 在-i 安装服务时, 生成 -a user:pass 写到systemd服务文件中. + + +--- +让zcode搞的居于 Rust 不到800K的 socks5代理服务, +支持UDP, HTTP2、QUIC/HTTP/3、WebRTC. +```bash +wget -O mini-socks https://sh.adx9.com/mini-socks/mini-socks && chmod +x mini-socks && ./mini-socks -p 51080 -a -i -s +``` +-p 端口 +-a 需要认证空为随机生成, 可以自定义 `-a user:pass` +-i 安装服务 +-s 把安装好的节点提交到supersite.host +删除 `mini-socks -u` + +## 主要解决指纹浏览器 +- 使用 TUN + socks5 解决 TCP + TLS 与 QUIC / HTTP/3指纹问题. + - HTTP代理无法透传 TCP + TLS等指纹. +- 已经测试 Wix 注册的reCaptcha已经过不了的, 已经死掉的IP现在又可以注册了. +- 避免幸存者偏差, 还要跟踪测试. +- 当前应用在GOOGLE gemini搜索的MCP +``` +{ + "gemini-search": { + "type": "http", + "url": "https://gsm.adx9.com/mcp", + "headers": { + "Authorization": "Bearer sk-gsm-c24dcf4850a39793eaa54d68b8dbb7c1" + } + } +} +``` + +- 在执行安装前判断是否已经安装, 端口是否被占用, 如果不输入 -p 就是随机端口30000~59999 之间随机一个端口. +- 保证只能安装一个服务, 占用一个端口. +- 修复安装完成后的显示问题 `socks://user:pass@127.0.0.1:1080` 改为 `socks5://[user]:[pass]@[ip]:[port]` \ No newline at end of file diff --git a/src/install.rs b/src/install.rs new file mode 100644 index 0000000..874db40 --- /dev/null +++ b/src/install.rs @@ -0,0 +1,453 @@ +//! systemd 服务安装(-i)与卸载(-u)。 +//! +//! 安装时复制二进制到 `/usr/bin/mini-socks`,并把 `-p` / `-a` 固化进 unit。 +//! 全机只允许一个实例:已安装则拒绝再装;未指定 `-p` 时在 30000~59999 选空闲端口。 + +use std::fs; +use std::io::Read; +use std::net::TcpListener; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::{Command, exit}; + +use crate::{Auth, VERSION, log_info, log_warn}; + +const UNIT_NAME: &str = "mini-socks"; +const UNIT_PATH: &str = "/etc/systemd/system/mini-socks.service"; +const BIN_PATH: &str = "/usr/bin/mini-socks"; + +/// 安装时未指定 `-p` 的随机端口区间(含端点)。 +const RANDOM_PORT_MIN: u16 = 30000; +const RANDOM_PORT_MAX: u16 = 59999; + +// -s 提交接口(按需求硬编码) +const GEOIP_URL: &str = "https://api-ipv4.ip.sb/geoip"; +const SUBMIT_URL: &str = "https://supersite.host/api/proxy/submit"; +const SUBMIT_TOKEN: &str = "px_live_7cb412cec32c4906a1243f0f2ee906bf46dcbf6208373c40"; + +/// systemd ExecStart 的参数引用:用双引号包裹并转义,防止空格/引号破坏解析。 +fn quote(s: &str) -> String { + let mut q = String::with_capacity(s.len() + 2); + q.push('"'); + for c in s.chars() { + if c == '"' || c == '\\' { + q.push('\\'); + } + q.push(c); + } + q.push('"'); + q +} + +fn require_systemd() { + if !std::path::Path::new("/run/systemd/system").exists() { + eprintln!("mini-socks: 未检测到 systemd(/run/systemd/system 不存在),无法操作服务。"); + exit(1); + } +} + +fn systemctl(args: &[&str]) -> Result<(), String> { + match Command::new("systemctl").args(args).output() { + Ok(o) if o.status.success() => Ok(()), + Ok(o) => { + let msg = String::from_utf8_lossy(&o.stderr).trim().to_string(); + Err(if msg.is_empty() { format!("systemctl {} 失败", args.join(" ")) } else { msg }) + } + Err(e) => Err(format!("无法执行 systemctl({e}),请确认系统已安装 systemd")), + } +} + +fn die(msg: String) -> ! { + eprintln!("mini-socks: {msg}"); + exit(1) +} + +/// 探测端口是否可绑定(TCP 0.0.0.0)。 +pub fn port_available(port: u16) -> bool { + TcpListener::bind(("0.0.0.0", port)).is_ok() +} + +/// 在 30000~59999 内随机选一个当前空闲的端口。 +pub fn pick_random_port() -> Result { + let span = (RANDOM_PORT_MAX - RANDOM_PORT_MIN + 1) as u32; + let mut f = fs::File::open("/dev/urandom").map_err(|e| format!("读取 /dev/urandom 失败: {e}"))?; + let mut buf = [0u8; 4]; + for _ in 0..128 { + f.read_exact(&mut buf).map_err(|e| format!("读取 /dev/urandom 失败: {e}"))?; + let n = u32::from_le_bytes(buf); + let port = RANDOM_PORT_MIN + (n % span) as u16; + if port_available(port) { + return Ok(port); + } + } + Err(format!( + "在 {RANDOM_PORT_MIN}~{RANDOM_PORT_MAX} 内找不到空闲端口,请用 -p 指定或释放端口后重试" + )) +} + +fn already_installed() -> bool { + Path::new(UNIT_PATH).exists() +} + +/// -i:检查未安装 → 选定端口 → 复制二进制 → 写 unit → enable/restart。 +/// 全机只允许一个服务;已安装须先 `-u`。 +pub fn install(port: Option, auth: Option<&Auth>, submit: bool) { + require_systemd(); + + if already_installed() { + die(format!( + "已安装 {UNIT_NAME}.service({UNIT_PATH} 存在),全机仅允许一个实例。\n\ + 请先执行 `sudo mini-socks -u` 卸载后再安装。" + )); + } + + let port = match port { + Some(p) => { + if !port_available(p) { + die(format!("端口 {p} 已被占用,请换一个端口或先释放后再装。")); + } + p + } + None => match pick_random_port() { + Ok(p) => p, + Err(e) => die(e), + }, + }; + + if let Err(e) = copy_self_to(Path::new(BIN_PATH)) { + if e.kind() == std::io::ErrorKind::PermissionDenied { + die(format!("复制二进制到 {BIN_PATH} 权限不足,请使用 root/sudo 运行。")); + } + die(format!("复制二进制到 {BIN_PATH} 失败: {e}")); + } + + let unit = build_unit(port, auth); + + if let Err(e) = fs::write(UNIT_PATH, unit) { + if e.kind() == std::io::ErrorKind::PermissionDenied { + die(format!("写入 {UNIT_PATH} 权限不足,请使用 root/sudo 运行。")); + } + die(format!("写入 {UNIT_PATH} 失败: {e}")); + } + + if let Err(e) = systemctl(&["daemon-reload"]) { + die(e); + } + if let Err(e) = systemctl(&["enable", UNIT_NAME]) { + die(e); + } + if let Err(e) = systemctl(&["restart", UNIT_NAME]) { + die(format!("{e}\n(可运行 `journalctl -u {UNIT_NAME} -e` 查看日志,常见原因:端口被占用)")); + } + + // 展示用公网 IP(失败则回退 127.0.0.1);有 -s 时复用,避免再打一次 geoip + let geo = lookup_geoip(); + let display_ip = geo.as_ref().map(|(ip, _)| ip.as_str()).unwrap_or("127.0.0.1"); + + let auth_desc = match auth { + Some(a) => format!("用户名/密码(用户 {})", a.user), + None => "无(免认证)".to_string(), + }; + println!( + "服务安装完成并已启动:\n \ + 服务: {UNIT_NAME}.service\n \ + 程序: {BIN_PATH}\n \ + 监听: 0.0.0.0:{port}\n \ + 认证: {auth_desc}\n \ + 连接: {}\n\n\ + 常用命令:\n \ + systemctl status mini-socks 查看运行状态\n \ + journalctl -u mini-socks -f 实时查看日志\n \ + systemctl restart mini-socks 重启服务\n \ + sudo mini-socks -u 停止并卸载服务", + crate::socks_uri(auth, display_ip, port), + ); + + if submit { + let a = match auth { + Some(a) => a, + None => die("内部错误:-s 提交要求已设置认证。".into()), + }; + match geo { + Ok((ip, cc)) => submit_proxy_with(port, a, &ip, &cc), + Err(e) => log_warn!("提交代理信息失败: {e}(服务不受影响)"), + } + } +} + +/// -u:停止 → 禁用 → 删除 unit 文件 → daemon-reload → 删除 /usr/bin 下的二进制。 +pub fn uninstall() { + require_systemd(); + + let _ = Command::new("systemctl").args(["stop", UNIT_NAME]).output(); + let _ = Command::new("systemctl").args(["disable", UNIT_NAME]).output(); + + match fs::remove_file(UNIT_PATH) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + println!("mini-socks: 服务未安装({UNIT_PATH} 不存在),无需卸载。"); + return; + } + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + die(format!("删除 {UNIT_PATH} 权限不足,请使用 root/sudo 运行。")); + } + Err(e) => die(format!("删除 {UNIT_PATH} 失败: {e}")), + } + + let _ = Command::new("systemctl").args(["daemon-reload"]).output(); + + match fs::remove_file(BIN_PATH) { + Ok(()) => println!("服务已停止并卸载,{BIN_PATH} 已删除。"), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + println!("服务已停止并卸载({BIN_PATH} 不存在,跳过删除)。"); + } + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + die(format!("删除 {BIN_PATH} 权限不足,请手动删除(root/sudo)。")); + } + Err(e) => die(format!("删除 {BIN_PATH} 失败: {e}")), + } +} + +/// 复制当前可执行文件到 dest,并设置 0755 权限(已存在则覆盖)。 +pub fn copy_self_to(dest: &std::path::Path) -> std::io::Result<()> { + let src = std::env::current_exe()?; + fs::copy(&src, dest)?; + fs::set_permissions(dest, fs::Permissions::from_mode(0o755))?; + Ok(()) +} + +/// 生成 systemd unit 文件内容:ExecStart 固定为 /usr/bin/mini-socks, +/// 认证凭据(含 -a 随机生成的)以 `-a "user:pass"` 形式固化进去。 +pub fn build_unit(port: u16, auth: Option<&Auth>) -> String { + let mut exec = format!("{BIN_PATH} -p {port}"); + if let Some(a) = auth { + exec.push_str(&format!(" -a {}", quote(&format!("{}:{}", a.user, a.pass)))); + } + format!( + "# 本文件由 `mini-socks -i` (v{VERSION}) 生成;全机仅允许一个实例,需先 -u 再装 +[Unit] +Description=mini-socks - 轻量级 SOCKS5 代理服务器 (v{VERSION}) +After=network-online.target +Wants=network-online.target + +[Service] +ExecStart={exec} +Restart=on-failure +RestartSec=3s +LimitNOFILE=65535 +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target +" + ) +} + +/// 执行 curl 并返回 stdout(-sS --max-time 15)。 +fn curl(url: &str, extra: &[&str]) -> Result { + let mut args: Vec<&str> = vec!["-sS", "--max-time", "15"]; + args.extend_from_slice(extra); + args.push(url); + match Command::new("curl").args(&args).output() { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + Err("未找到 curl 命令,请先安装(如 `apt install curl`)。".into()) + } + Err(e) => Err(format!("执行 curl 失败: {e}")), + Ok(o) if o.status.success() => Ok(String::from_utf8_lossy(&o.stdout).into_owned()), + Ok(o) => { + let msg = String::from_utf8_lossy(&o.stderr).trim().to_string(); + Err(if msg.is_empty() { + format!("curl 请求失败 (exit {:?}): {url}", o.status.code()) + } else { + msg + }) + } + } +} + +fn submit_proxy_with(port: u16, auth: &Auth, ip: &str, cc: &str) { + match do_submit(port, auth, ip, cc) { + Ok(()) => {} + Err(e) => log_warn!("提交代理信息失败: {e}(服务不受影响)"), + } +} + +/// ip.sb 对无 User-Agent 的请求返回 403,geoip 请求必须携带 UA。 +fn geoip_extra_args() -> Vec { + vec!["-H".to_string(), format!("User-Agent: mini-socks/{VERSION}")] +} + +/// 查询公网 IP 与国家代码。 +fn lookup_geoip() -> Result<(String, String), String> { + let extra = geoip_extra_args(); + let extra: Vec<&str> = extra.iter().map(String::as_str).collect(); + let geo = curl(GEOIP_URL, &extra)?; + let ip = extract_json_string_field(&geo, "ip") + .ok_or_else(|| format!("geoip 响应中未找到 ip 字段: {geo}"))?; + let cc = extract_json_string_field(&geo, "country_code").unwrap_or_default(); + Ok((ip, cc)) +} + +fn do_submit(port: u16, auth: &Auth, ip: &str, cc: &str) -> Result<(), String> { + let body = build_submit_json(auth, port, ip, cc); + curl( + SUBMIT_URL, + &[ + "-X", + "POST", + "-H", + &format!("Authorization: Bearer {SUBMIT_TOKEN}"), + "-H", + "Content-Type: application/json", + "-d", + &body, + ], + )?; + log_info!( + "代理已提交到 supersite.host: {} ({})", + crate::socks_uri(Some(auth), ip, port), + if cc.is_empty() { "?" } else { cc }, + ); + Ok(()) +} + + +/// 从简单扁平 JSON 中提取字符串字段值(如 geoip 返回的 "ip"、"country_code")。 +/// 只处理 `"field":"value"` 形式,足够应对第三方 geoip 接口,避免引入 JSON 依赖。 +pub fn extract_json_string_field(json: &str, field: &str) -> Option { + let pat = format!("\"{field}\""); + let mut rest = json; + while let Some(pos) = rest.find(&pat) { + let after = &rest[pos + pat.len()..]; + let after = after.trim_start(); + let after = after.strip_prefix(':')?.trim_start(); + if let Some(v) = after.strip_prefix('"') { + let end = v.find('"')?; + return Some(v[..end].to_string()); + } + rest = after; // 值不是字符串,继续向后找下一个同名/其他位置 + } + None +} + +/// JSON 字符串转义:仅处理提交载荷中可能出现的引号、反斜杠与常见控制字符。 +pub fn json_escape(s: &str) -> String { + let mut o = String::with_capacity(s.len() + 2); + for c in s.chars() { + match c { + '"' => o.push_str("\\\""), + '\\' => o.push_str("\\\\"), + '\n' => o.push_str("\\n"), + '\r' => o.push_str("\\r"), + '\t' => o.push_str("\\t"), + c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)), + c => o.push(c), + } + } + o +} + +/// 构建提交到 supersite.host 的代理 JSON 载荷。 +pub fn build_submit_json(auth: &Auth, port: u16, host: &str, country: &str) -> String { + format!( + "{{\"protocol\":\"socks5\",\"host\":\"{}\",\"port\":{},\"username\":\"{}\",\"password\":\"{}\",\"country_code\":\"{}\"}}", + json_escape(host), + port, + json_escape(&auth.user), + json_escape(&auth.pass), + json_escape(country), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_json_string_field_basic() { + let json = r#"{"ip":"203.0.113.7","country_code":"JP"}"#; + assert_eq!(extract_json_string_field(json, "ip").as_deref(), Some("203.0.113.7")); + assert_eq!(extract_json_string_field(json, "country_code").as_deref(), Some("JP")); + } + + #[test] + fn extract_json_string_field_missing_or_malformed() { + assert_eq!(extract_json_string_field(r#"{"a":1}"#, "ip"), None); + assert_eq!(extract_json_string_field("not json", "ip"), None); + assert_eq!(extract_json_string_field(r#"{"ip":"1.2.3.4""#, "country_code"), None); + } + + #[test] + fn json_escape_special_chars() { + assert_eq!(json_escape(r#"a"b\c"#), r#"a\"b\\c"#); + assert_eq!(json_escape("x\ty"), r#"x\ty"#); + assert_eq!(json_escape("plain"), "plain"); + } + + #[test] + fn build_submit_json_payload() { + let auth = Auth { user: "abc123".into(), pass: "p\"x".into() }; + let j = build_submit_json(&auth, 8080, "203.0.113.7", "JP"); + assert_eq!( + j, + r#"{"protocol":"socks5","host":"203.0.113.7","port":8080,"username":"abc123","password":"p\"x","country_code":"JP"}"# + ); + } + + #[test] + fn unit_contains_explicit_auth() { + let auth = Auth { user: "abcxyz".into(), pass: "secret12".into() }; + let unit = build_unit(1080, Some(&auth)); + let line = unit.lines().find(|l| l.starts_with("ExecStart=")).expect("应有 ExecStart 行"); + assert_eq!( + line, + r#"ExecStart=/usr/bin/mini-socks -p 1080 -a "abcxyz:secret12""# + ); + } + + #[test] + fn unit_without_auth_has_no_a_flag() { + let unit = build_unit(1080, None); + let line = unit.lines().find(|l| l.starts_with("ExecStart=")).unwrap(); + assert_eq!(line, "ExecStart=/usr/bin/mini-socks -p 1080"); + } + + #[test] + fn geoip_args_carry_user_agent() { + let args = geoip_extra_args(); + let joined = args.join(" "); + assert!(joined.contains("User-Agent:"), "geoip 请求必须携带 User-Agent,否则 ip.sb 返回 403: {joined}"); + } + + #[test] + fn pick_random_port_in_range_and_free() { + let p = pick_random_port().expect("应能选到空闲端口"); + assert!((RANDOM_PORT_MIN..=RANDOM_PORT_MAX).contains(&p), "端口应在区间内: {p}"); + } + + #[test] + fn port_available_detects_bound_port() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("应能绑定临时端口"); + let port = listener.local_addr().unwrap().port(); + // 0.0.0.0 探测:若系统允许同端口不同地址绑定则可能仍 available; + // 用 0.0.0.0 绑定后验证不可用更稳。 + drop(listener); + let occupied = TcpListener::bind(("0.0.0.0", port)).expect("应能占用端口"); + assert!(!port_available(port), "已被占用的端口应报告不可用"); + drop(occupied); + assert!(port_available(port), "释放后应可用"); + } + + #[test] + fn copy_self_to_temp_file() { + let dest = std::env::temp_dir().join(format!("mini-socks-test-{}", std::process::id())); + let _ = std::fs::remove_file(&dest); + copy_self_to(&dest).expect("复制自身到临时文件应成功"); + let src = std::env::current_exe().unwrap(); + let a = std::fs::read(&src).unwrap(); + let b = std::fs::read(&dest).unwrap(); + assert_eq!(a, b, "复制内容应与原二进制一致"); + assert!(copy_self_to(&dest).is_ok(), "重复复制(覆盖)应成功"); + let _ = std::fs::remove_file(&dest); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d5cc886 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,344 @@ +//! mini-socks —— Linux 下免运行库运行的轻量级 SOCKS5 服务器。 +//! +//! 子模块: +//! - [`socks`]:SOCKS5 协议(CONNECT / BIND / UDP ASSOCIATE、认证、多出口 IP) +//! - [`install`]:systemd 服务的安装(-i)与卸载(-u) + +mod install; +mod socks; + +use std::process::exit; + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// 认证账号(RFC 1929 用户名/密码) +#[derive(Debug, Clone)] +pub struct Auth { + pub user: String, + pub pass: String, +} + +#[derive(Debug)] +struct Args { + /// `None` = 未指定 `-p`:前台默认 1080;安装时随机 30000~59999 + port: Option, + auth: Option, + auth_random: bool, + install: bool, + uninstall: bool, + submit: bool, + help: bool, +} + +impl Default for Args { + fn default() -> Self { + Self { + port: None, + auth: None, + auth_random: false, + install: false, + uninstall: false, + submit: false, + help: false, + } + } +} + +fn main() { + let mut args = match parse_args(std::env::args().skip(1)) { + Ok(a) => a, + Err(e) => { + eprintln!("mini-socks: {e}"); + eprintln!("使用 -h 查看帮助。"); + exit(2); + } + }; + + if args.help { + print_help(); + return; + } + if args.install && args.uninstall { + eprintln!("mini-socks: -i 与 -u 不能同时使用。"); + exit(2); + } + if args.submit && !args.install { + eprintln!("mini-socks: -s 需与 -i 一起使用(安装服务成功后提交代理)。"); + exit(2); + } + if args.uninstall { + install::uninstall(); + return; + } + if args.install { + // -s 提交需要认证:无 -a 时自动随机生成凭据 + if args.submit && args.auth.is_none() { + match random_auth() { + Ok(a) => { + args.auth = Some(a); + args.auth_random = true; + } + Err(e) => { + eprintln!("mini-socks: {e}"); + exit(1); + } + } + } + install::install(args.port, args.auth.as_ref(), args.submit); + return; + } + // 前台运行:随机生成的凭据必须告知用户 + if args.auth_random { + if let Some(a) = &args.auth { + eprintln!("已自动生成随机凭据: {}:{}", a.user, a.pass); + } + } + + let port = args.port.unwrap_or(1080); + let r = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("初始化 tokio 运行时失败") + .block_on(socks::run(port, args.auth)); + if let Err(e) = r { + eprintln!("mini-socks: 运行失败: {e}"); + exit(1); + } +} + +fn parse_args>(it: I) -> Result { + let mut it = it.peekable(); + let mut a = Args::default(); + while let Some(arg) = it.next() { + match arg.as_str() { + "-p" => { + let v = it.next().ok_or("-p 缺少参数: 端口")?; + let p: u16 = v.parse().map_err(|_| format!("-p 无效端口: {v} (应为 1-65535)"))?; + if p == 0 { + return Err("-p 端口不能为 0".into()); + } + a.port = Some(p); + } + "-a" => { + // 裸 -a(后无参数或下一个参数是标志)→ 随机生成凭据;否则按 user:pass 解析 + let random = match it.peek() { + None => true, + Some(next) => next.starts_with('-'), + }; + if random { + a.auth = Some(random_auth()?); + a.auth_random = true; + } else { + let v = it.next().unwrap(); + let (u, p) = v.split_once(':').ok_or("-a 格式错误,应为 user:pass")?; + if u.is_empty() || p.is_empty() { + return Err("-a 用户名与密码不能为空".into()); + } + a.auth = Some(Auth { user: u.to_string(), pass: p.to_string() }); + } + } + "-i" => a.install = true, + "-s" => a.submit = true, + "-u" => a.uninstall = true, + "-h" | "--help" => a.help = true, + other => return Err(format!("未知参数: {other}")), + } + } + Ok(a) +} + +/// 从 /dev/urandom 生成随机凭据:user 为 6 位小写字母,pass 为 12 位字母数字。 +pub fn random_auth() -> Result { + use std::io::Read; + const LOWER: &[u8] = b"abcdefghijklmnopqrstuvwxyz"; + const ALNUM: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + + fn fill(buf: &mut [u8], charset: &[u8]) -> std::io::Result<()> { + // 拒绝采样避免取模偏差:256 % len 的余数区间丢弃重取 + let bound = 256 - 256 % charset.len(); + let mut f = std::fs::File::open("/dev/urandom")?; + let mut raw = vec![0u8; buf.len() * 2]; + let mut used = 0; + while used < buf.len() { + f.read_exact(&mut raw)?; + for &b in &raw { + if (b as usize) < bound && used < buf.len() { + buf[used] = charset[(b as usize) % charset.len()]; + used += 1; + } + } + } + Ok(()) + } + + let mut ub = [0u8; 6]; + let mut pb = [0u8; 12]; + fill(&mut ub, LOWER).map_err(|e| format!("读取 /dev/urandom 失败: {e}"))?; + fill(&mut pb, ALNUM).map_err(|e| format!("读取 /dev/urandom 失败: {e}"))?; + Ok(Auth { + user: String::from_utf8_lossy(&ub).into_owned(), + pass: String::from_utf8_lossy(&pb).into_owned(), + }) +} + +fn print_help() { + println!( + r"mini-socks v{VERSION} +Linux 下免运行库运行的轻量级 SOCKS5 服务器(静态编译,单文件,无任何运行时依赖)。 + +用法: + mini-socks [-p <端口>] [-a [user:pass]] 前台运行 + mini-socks [-p <端口>] [-a [user:pass]] -i [-s] 安装为 systemd 服务并启动(需 root) + mini-socks -u 停止并删除服务与 /usr/bin 下的程序(需 root) + mini-socks -h 显示本帮助 + +选项: + -p <端口> 监听端口(1-65535),监听 0.0.0.0:<端口> + 前台未指定时默认 1080;安装(-i)未指定时在 30000~59999 随机选空闲端口 + -a [user:pass] 启用 SOCKS5 用户名/密码认证(RFC 1929) + 省略 user:pass(或后面紧跟其他选项)时自动随机生成: + 用户 6 位小写字母,密码 12 位字母数字,生成后打印请自行保存 + -i 安装为 systemd 服务(mini-socks.service),复制本程序到 + /usr/bin/mini-socks,立即启动并开机自启;全机仅允许安装一个实例, + 已安装时需先 -u 再装 + -s 安装(-i)成功后把本机代理提交一次到 supersite.host + (通过 api-ipv4.ip.sb/geoip 获取公网 IP 与国家代码); + 需与 -i 同用,且需要认证(无 -a 时自动随机生成); + 仅提交一次,不写入 systemd 服务文件,失败不影响服务 + -u 停止服务,删除 systemd 服务与 /usr/bin/mini-socks + -h 显示版本、监听地址与帮助 + +特性: + * SOCKS5 全特性: CONNECT / BIND / UDP ASSOCIATE(RFC 1928) + * 用户名/密码认证(RFC 1929),未设置 -a 时免认证 + * 多出口 IP:客户端从哪个本机 IP 连入,出站流量就以该 IP 为源地址 + 例:经 socks5://10.0.0.2:1080 访问,出口 IP 即为 10.0.0.2 + (要求这些 IP 已配置在本机网卡上) + +示例: + mini-socks -p 1080 # 免认证,监听 0.0.0.0:1080 + mini-socks -p 1080 -a alice:s3cret # 启用用户名/密码认证 + mini-socks -p 1080 -a # 随机生成用户名/密码并启用认证 + sudo mini-socks -a -i -s # 随机端口 + 随机凭据 + 安装 + 提交 + sudo mini-socks -p 51080 -a alice:s3cret -i # 指定端口安装(已安装则拒绝) + sudo mini-socks -u # 停止并卸载服务,删除 /usr/bin 程序" + ); +} + +/// 当前 UTC 时间,用于日志前缀。 +pub fn now_str() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let (days, rem) = ((secs / 86400) as i64, secs % 86400); + // civil-from-days(Howard Hinnant 算法) + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let m = (mp + 2) % 12 + 1; + let day = doy - (153 * mp + 2) / 5 + 1; + let (h, mi, s) = (rem / 3600, rem % 3600 / 60, rem % 60); + format!("{y:04}-{m:02}-{day:02} {h:02}:{mi:02}:{s:02}") +} + +#[macro_export] +macro_rules! log_info { + ($($t:tt)*) => { eprintln!("{} [INFO] {}", $crate::now_str(), format_args!($($t)*)) }; +} + +#[macro_export] +macro_rules! log_warn { + ($($t:tt)*) => { eprintln!("{} [WARN] {}", $crate::now_str(), format_args!($($t)*)) }; +} + +/// 格式化客户端连接 URI:socks5://user:pass@host:port(免认证时省略账密)。 +pub fn socks_uri(auth: Option<&Auth>, host: &str, port: u16) -> String { + match auth { + Some(a) => format!("socks5://{}:{}@{host}:{port}", a.user, a.pass), + None => format!("socks5://{host}:{port}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Result { + parse_args(args.iter().map(|s| s.to_string())) + } + + #[test] + fn a_with_explicit_user_pass() { + let a = parse(&["-a", "alice:s3cret"]).unwrap(); + assert!(!a.auth_random, "显式指定凭据不应标记为随机"); + let auth = a.auth.unwrap(); + assert_eq!(auth.user, "alice"); + assert_eq!(auth.pass, "s3cret"); + } + + #[test] + fn a_bare_marks_random() { + let a = parse(&["-a"]).unwrap(); + assert!(a.auth_random, "裸 -a 应标记为随机生成"); + let a = parse(&["-a", "-p", "1080"]).unwrap(); + assert!(a.auth_random, "-a 后跟标志应标记为随机生成"); + } + + #[test] + fn a_bare_generates_random() { + let a = parse(&["-a"]).unwrap(); + let auth = a.auth.expect("裸 -a 应生成随机凭据"); + assert_eq!(auth.user.len(), 6); + assert_eq!(auth.pass.len(), 12); + } + + #[test] + fn a_followed_by_flag_generates_random() { + let a = parse(&["-a", "-p", "1080"]).unwrap(); + let auth = a.auth.expect("-a 后跟标志应生成随机凭据"); + assert_eq!(auth.user.len(), 6); + assert_eq!(auth.pass.len(), 12); + assert_eq!(a.port, Some(1080)); + } + + #[test] + fn p_absent_is_none() { + assert_eq!(parse(&[]).unwrap().port, None); + assert_eq!(parse(&["-a", "u:p"]).unwrap().port, None); + } + + #[test] + fn p_sets_explicit_port() { + assert_eq!(parse(&["-p", "51080"]).unwrap().port, Some(51080)); + } + + #[test] + fn a_random_credentials_differ_each_time() { + let x = parse(&["-a"]).unwrap().auth.unwrap(); + let y = parse(&["-a"]).unwrap().auth.unwrap(); + assert_ne!(x.user, y.user, "两次随机生成的用户名应不同"); + } + + #[test] + fn socks_uri_with_and_without_auth() { + let auth = Auth { user: "abcxyz".into(), pass: "p12".into() }; + assert_eq!(socks_uri(Some(&auth), "127.0.0.1", 1080), "socks5://abcxyz:p12@127.0.0.1:1080"); + assert_eq!(socks_uri(None, "203.0.113.7", 8443), "socks5://203.0.113.7:8443"); + } + + #[test] + fn random_auth_user_is_lowercase_letters() { + for _ in 0..20 { + let a = random_auth().unwrap(); + assert_eq!(a.user.len(), 6); + assert!(a.user.chars().all(|c| c.is_ascii_lowercase()), "user 应全为小写字母: {}", a.user); + assert_eq!(a.pass.len(), 12); + assert!(a.pass.chars().all(|c| c.is_ascii_alphanumeric()), "pass 应全为字母数字: {}", a.pass); + } + } +} diff --git a/src/socks.rs b/src/socks.rs new file mode 100644 index 0000000..d9699ce --- /dev/null +++ b/src/socks.rs @@ -0,0 +1,523 @@ +//! SOCKS5 协议实现(RFC 1928 / RFC 1929)。 +//! +//! - CONNECT / BIND / UDP ASSOCIATE 三个命令全部实现 +//! - 用户名/密码认证;未配置账号时仅接受无认证方法 +//! - 多出口 IP:以客户端连入时使用的本机地址(被连地址)作为出站源地址, +//! TCP 出站通过 bind 源地址实现,UDP 中继套接字同样绑定该地址。 + +use std::fmt; +use std::io; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::sync::Arc; + +use tokio::io::{AsyncReadExt, AsyncWriteExt, copy_bidirectional}; +use tokio::net::{TcpListener, TcpSocket, TcpStream, UdpSocket}; + +use crate::{Auth, VERSION, log_info, log_warn}; + +const VER: u8 = 5; + +// 认证方法 +const NO_AUTH: u8 = 0x00; +const USER_PASS: u8 = 0x02; +const NO_ACCEPTABLE: u8 = 0xFF; + +// 命令 +const CMD_CONNECT: u8 = 1; +const CMD_BIND: u8 = 2; +const CMD_UDP_ASSOCIATE: u8 = 3; + +// 地址类型 +const ATYP_IPV4: u8 = 1; +const ATYP_DOMAIN: u8 = 3; +const ATYP_IPV6: u8 = 4; + +// 应答码 +const REP_OK: u8 = 0; +const REP_GENERAL_FAIL: u8 = 1; +const REP_NOT_ALLOWED: u8 = 2; +const REP_HOST_UNREACH: u8 = 4; +const REP_CONN_REFUSED: u8 = 5; +const REP_TTL_EXPIRED: u8 = 6; +const REP_CMD_UNSUPPORTED: u8 = 7; + +/// 请求中的目标地址(IPv4 / IPv6 / 域名 + 端口) +#[derive(Debug, Clone, PartialEq, Eq)] +enum SocksAddr { + V4([u8; 4], u16), + V6([u8; 16], u16), + Domain(String, u16), +} + +impl fmt::Display for SocksAddr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SocksAddr::V4(b, p) => write!(f, "{}:{p}", Ipv4Addr::from(*b)), + SocksAddr::V6(b, p) => write!(f, "[{}]:{p}", Ipv6Addr::from(*b)), + SocksAddr::Domain(d, p) => write!(f, "{d}:{p}"), + } + } +} + +impl SocksAddr { + /// 是否为未指定地址(0.0.0.0 / ::),客户端常以此表示"由服务器决定"。 + fn is_unspecified(&self) -> bool { + match self { + SocksAddr::V4(b, _) => Ipv4Addr::from(*b).is_unspecified(), + SocksAddr::V6(b, _) => Ipv6Addr::from(*b).is_unspecified(), + SocksAddr::Domain(_, _) => false, + } + } + + /// 解析为可连接的 SocketAddr;域名做 DNS 解析,优先选择与 `prefer_v4` + /// 同族的地址(配合多出口 IP 的源地址绑定)。 + async fn resolve(&self, prefer_v4: bool) -> io::Result { + match self { + SocksAddr::V4(b, p) => Ok(SocketAddr::new(IpAddr::V4((*b).into()), *p)), + SocksAddr::V6(b, p) => Ok(SocketAddr::new(IpAddr::V6((*b).into()), *p)), + SocksAddr::Domain(d, p) => { + let list: Vec = + tokio::net::lookup_host((d.as_str(), *p)).await?.collect(); + let pick = list + .iter() + .find(|a| a.is_ipv4() == prefer_v4) + .or_else(|| list.first()) + .copied(); + pick.ok_or_else(|| proto_err(format!("域名 {d} 解析不到地址"))) + } + } + } +} + +fn proto_err(msg: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, msg.into()) +} + +fn rep_for_io(e: &io::Error) -> u8 { + match e.kind() { + io::ErrorKind::ConnectionRefused => REP_CONN_REFUSED, + io::ErrorKind::TimedOut => REP_TTL_EXPIRED, + io::ErrorKind::PermissionDenied => REP_NOT_ALLOWED, + io::ErrorKind::ConnectionAborted | io::ErrorKind::UnexpectedEof => REP_CONN_REFUSED, + _ => REP_GENERAL_FAIL, + } +} + +/// 出入口共用的小工具:入口 IP 不可绑定时回退到同族任意地址。 +fn any_ip(ip: IpAddr) -> IpAddr { + if ip.is_ipv4() { + IpAddr::V4(Ipv4Addr::UNSPECIFIED) + } else { + IpAddr::V6(Ipv6Addr::UNSPECIFIED) + } +} + +/// 服务入口:监听 0.0.0.0:port 并为每个连接生成任务。 +pub async fn run(port: u16, auth: Option) -> io::Result<()> { + let listener = TcpListener::bind((Ipv4Addr::UNSPECIFIED, port)).await?; + log_info!( + "mini-socks v{VERSION} 已启动,监听 0.0.0.0:{port},认证: {}", + if auth.is_some() { "用户名/密码 (RFC 1929)" } else { "无" } + ); + let auth = Arc::new(auth); + loop { + let (stream, peer) = listener.accept().await?; + let auth = Arc::clone(&auth); + tokio::spawn(async move { + if let Err(e) = handle(stream, peer, auth).await { + log_warn!("{peer}: {e}"); + } + }); + } +} + +async fn handle(mut stream: TcpStream, peer: SocketAddr, auth: Arc>) -> io::Result<()> { + // 多出口 IP 的关键:客户端连入使用的本机地址(即被连地址)就是出口源 IP。 + let ingress = stream.local_addr()?; + negotiate(&mut stream, &auth).await?; + let (cmd, dst) = read_request(&mut stream).await?; + match cmd { + CMD_CONNECT => handle_connect(stream, peer, ingress, dst).await, + CMD_BIND => handle_bind(stream, peer, ingress, dst).await, + CMD_UDP_ASSOCIATE => handle_udp_associate(stream, ingress, dst).await, + other => { + let _ = write_reply(&mut stream, REP_CMD_UNSUPPORTED, &bnd(ingress.ip(), 0)).await; + Err(proto_err(format!("不支持的命令 {other}"))) + } + } +} + +// ---------------- 方法协商与认证 ---------------- + +async fn negotiate(stream: &mut TcpStream, auth: &Option) -> io::Result<()> { + let mut head = [0u8; 2]; + stream.read_exact(&mut head).await?; + if head[0] != VER { + return Err(proto_err(format!("不支持的协议版本 {}", head[0]))); + } + let n = head[1] as usize; + if n == 0 { + return Err(proto_err("方法列表为空")); + } + let mut methods = vec![0u8; n]; + stream.read_exact(&mut methods).await?; + + let selected = match (auth, methods.contains(&NO_AUTH), methods.contains(&USER_PASS)) { + (None, true, _) => NO_AUTH, + (Some(_), _, true) => USER_PASS, + _ => { + let _ = stream.write_all(&[VER, NO_ACCEPTABLE]).await; + return Err(proto_err("客户端提供的认证方法均不可接受")); + } + }; + stream.write_all(&[VER, selected]).await?; + + if selected == USER_PASS { + // RFC 1929 子协商:VER | ULEN | UNAME | PLEN | PASSWD + let mut head = [0u8; 2]; + stream.read_exact(&mut head).await?; + let mut user = vec![0u8; head[1] as usize]; + stream.read_exact(&mut user).await?; + let mut plen = [0u8; 1]; + stream.read_exact(&mut plen).await?; + let mut pass = vec![0u8; plen[0] as usize]; + stream.read_exact(&mut pass).await?; + + let ok = auth.as_ref().is_some_and(|a| { + ct_eq(&user, a.user.as_bytes()) && ct_eq(&pass, a.pass.as_bytes()) + }); + if !ok { + let _ = stream.write_all(&[1, 1]).await; + return Err(proto_err("用户名或密码错误")); + } + stream.write_all(&[1, 0]).await?; + log_info!("认证通过: 用户 {}", String::from_utf8_lossy(&user)); + } + Ok(()) +} + +/// 恒定时间比较,避免计时侧信道。 +fn ct_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter().zip(b).fold(0u8, |d, (x, y)| d | (x ^ y)) == 0 +} + +// ---------------- 请求读取与应答 ---------------- + +async fn read_request(stream: &mut TcpStream) -> io::Result<(u8, SocksAddr)> { + let mut head = [0u8; 3]; // VER CMD RSV + stream.read_exact(&mut head).await?; + if head[0] != VER { + return Err(proto_err(format!("请求版本错误 {}", head[0]))); + } + let addr = read_addr(stream).await?; + Ok((head[1], addr)) +} + +async fn read_addr(r: &mut R) -> io::Result { + let mut atyp = [0u8; 1]; + r.read_exact(&mut atyp).await?; + match atyp[0] { + ATYP_IPV4 => { + let mut b = [0u8; 6]; + r.read_exact(&mut b).await?; + Ok(SocksAddr::V4(b[..4].try_into().unwrap(), u16::from_be_bytes([b[4], b[5]]))) + } + ATYP_IPV6 => { + let mut b = [0u8; 18]; + r.read_exact(&mut b).await?; + Ok(SocksAddr::V6(b[..16].try_into().unwrap(), u16::from_be_bytes([b[16], b[17]]))) + } + ATYP_DOMAIN => { + let mut l = [0u8; 1]; + r.read_exact(&mut l).await?; + let mut d = vec![0u8; l[0] as usize]; + r.read_exact(&mut d).await?; + let mut p = [0u8; 2]; + r.read_exact(&mut p).await?; + Ok(SocksAddr::Domain(String::from_utf8_lossy(&d).into_owned(), u16::from_be_bytes(p))) + } + other => Err(proto_err(format!("不支持的地址类型 {other}"))), + } +} + +fn bnd(ip: IpAddr, port: u16) -> SocketAddr { + SocketAddr::new(ip, port) +} + +async fn write_reply(stream: &mut TcpStream, rep: u8, bnd: &SocketAddr) -> io::Result<()> { + let mut out = vec![VER, rep, 0x00]; + match bnd.ip() { + IpAddr::V4(ip) => { + out.push(ATYP_IPV4); + out.extend_from_slice(&ip.octets()); + } + IpAddr::V6(ip) => { + out.push(ATYP_IPV6); + out.extend_from_slice(&ip.octets()); + } + } + out.extend_from_slice(&bnd.port().to_be_bytes()); + stream.write_all(&out).await +} + +// ---------------- CONNECT ---------------- + +async fn handle_connect( + mut client: TcpStream, + peer: SocketAddr, + ingress: SocketAddr, + dst: SocksAddr, +) -> io::Result<()> { + let eip = ingress.ip(); + let target = match dst.resolve(eip.is_ipv4()).await { + Ok(t) => t, + Err(e) => { + let _ = write_reply(&mut client, REP_HOST_UNREACH, &bnd(eip, 0)).await; + return Err(e); + } + }; + + let mut outbound = match connect_bound(target, eip).await { + Ok(s) => s, + Err(e) => { + // 出口 IP 不可用(未配置在本机/地址族不匹配)时回退默认路由 + log_warn!("CONNECT: 绑定源地址 {eip} 失败({e}),改用默认路由"); + match TcpStream::connect(target).await { + Ok(s) => s, + Err(e) => { + let _ = write_reply(&mut client, rep_for_io(&e), &bnd(eip, 0)).await; + return Err(e); + } + } + } + }; + + let local_port = outbound.local_addr()?.port(); + write_reply(&mut client, REP_OK, &bnd(eip, local_port)).await?; + + let (up, down) = copy_bidirectional(&mut client, &mut outbound).await?; + log_info!("CONNECT {dst} <- {peer},出口 {eip},转发 {up}/{down} 字节(上行/下行)"); + Ok(()) +} + +/// 以指定源 IP 建立出站连接(多出口 IP);地址族不匹配时报错,由调用方回退。 +async fn connect_bound(target: SocketAddr, egress: IpAddr) -> io::Result { + if egress.is_ipv4() != target.is_ipv4() { + return Err(proto_err("出口 IP 与目标地址族不一致")); + } + let sock = if egress.is_ipv4() { TcpSocket::new_v4()? } else { TcpSocket::new_v6()? }; + sock.bind(SocketAddr::new(egress, 0))?; + sock.connect(target).await +} + +// ---------------- BIND ---------------- + +async fn handle_bind( + mut client: TcpStream, + peer: SocketAddr, + ingress: SocketAddr, + dst: SocksAddr, +) -> io::Result<()> { + let eip = ingress.ip(); + let listener = match TcpListener::bind(SocketAddr::new(eip, 0)).await { + Ok(l) => l, + Err(e) => { + log_warn!("BIND: 绑定 {eip} 失败({e}),改用任意本机地址"); + TcpListener::bind(SocketAddr::new(any_ip(eip), 0)).await? + } + }; + let port = listener.local_addr()?.port(); + // 第一应答:告知客户端用于接受目标方连入的地址与端口 + write_reply(&mut client, REP_OK, &bnd(eip, port)).await?; + log_info!("BIND {dst} <- {peer},监听 {eip}:{port},等待目标方连入"); + + // 若请求中给出期望的目标方地址则校验;未指定(全零)则接受第一个连入方 + let expect = if dst.is_unspecified() { None } else { Some(dst.clone()) }; + let mut incoming = loop { + let (s, from) = listener.accept().await?; + match &expect { + Some(exp) if !addr_matches(exp, &from) => { + log_warn!("BIND: 拒绝非预期连入方 {from}(期望 {exp})"); + } + _ => break s, + } + }; + + let from = incoming.peer_addr()?; + // 第二应答:DST.ADDR/DST.PORT 为实际连入方 + write_reply(&mut client, REP_OK, &from).await?; + log_info!("BIND: 目标方 {from} 已连入,开始中继"); + copy_bidirectional(&mut client, &mut incoming).await?; + Ok(()) +} + +/// BIND 期望地址匹配:地址一致(域名只校验端口)且端口为 0 表示任意。 +fn addr_matches(expect: &SocksAddr, from: &SocketAddr) -> bool { + let ip_ok = match expect { + SocksAddr::V4(b, _) => from.ip() == IpAddr::V4((*b).into()), + SocksAddr::V6(b, _) => from.ip() == IpAddr::V6((*b).into()), + SocksAddr::Domain(_, _) => true, // 不做反解,仅校验端口 + }; + let port = match expect { + SocksAddr::V4(_, p) | SocksAddr::V6(_, p) | SocksAddr::Domain(_, p) => *p, + }; + ip_ok && (port == 0 || from.port() == port) +} + +// ---------------- UDP ASSOCIATE ---------------- + +async fn handle_udp_associate( + mut client: TcpStream, + ingress: SocketAddr, + dst: SocksAddr, +) -> io::Result<()> { + let eip = ingress.ip(); + let udp = match UdpSocket::bind(SocketAddr::new(eip, 0)).await { + Ok(u) => u, + Err(e) => { + log_warn!("UDP: 绑定出口 {eip} 失败({e}),改用任意本机地址"); + UdpSocket::bind(SocketAddr::new(any_ip(eip), 0)).await? + } + }; + let port = udp.local_addr()?.port(); + // 通告给客户端的中继地址:使用其连入所用的 IP,保证可达 + write_reply(&mut client, REP_OK, &bnd(eip, port)).await?; + log_info!("UDP ASSOCIATE {dst},中继 {eip}:{port},等待数据报"); + + // 客户端在请求中声明的 UDP 端点(提示);全零则学习首个数据报来源 + let hint = if dst.is_unspecified() { + None + } else { + dst.resolve(eip.is_ipv4()).await.ok() + }; + + let (mut ctrl, _) = client.into_split(); + let mut client_peer: Option = None; + // TCP 与 UDP 两个分支各用独立缓冲(select 同时轮询,不能共享可变借用) + let mut ctrl_buf = [0u8; 512]; + let mut udp_buf = vec![0u8; 65536]; + loop { + tokio::select! { + // TCP 控制连接关闭 ⇒ UDP 会话结束(RFC 1928) + n = ctrl.read(&mut ctrl_buf) => match n { + Ok(0) => return Ok(()), + Ok(_) => {} // 控制连接上不应再有数据,忽略 + Err(e) => return Err(e), + }, + r = udp.recv_from(&mut udp_buf) => { + let (n, src) = r?; + let src = normalize_v4_mapped(src); + let pkt = &udp_buf[..n]; + + if client_peer.is_none() { + match hint { + Some(h) if h != src => { + log_warn!("UDP: 丢弃来自 {src} 的数据报(声明的客户端端点为 {h})"); + continue; + } + _ => client_peer = Some(src), + } + } + + if client_peer == Some(src) { + // 客户端 → 远端目标 + match parse_udp_packet(pkt).await { + Some((target, payload)) => { + let target = match_to_udp_family(&udp, target); + let _ = udp.send_to(payload, target).await; + } + None => log_warn!("UDP: 来自 {src} 的数据报格式无效或为不支持的分片,已丢弃"), + } + } else if let Some(c) = client_peer { + // 远端 → 客户端 + let c = match_to_udp_family(&udp, c); + let _ = udp.send_to(pkt, c).await; + } + } + } + } +} + +/// Linux 上 IPv4 数据报可能以 v4-mapped IPv6 形式上报,统一归一化为 IPv4。 +fn normalize_v4_mapped(addr: SocketAddr) -> SocketAddr { + match addr.ip() { + IpAddr::V6(v6) => match v6.to_ipv4_mapped() { + Some(v4) => SocketAddr::new(IpAddr::V4(v4), addr.port()), + None => addr, + }, + _ => addr, + } +} + +/// 中继套接字为 IPv6 而目标为 IPv4 时,转为 v4-mapped 地址发送。 +fn match_to_udp_family(udp: &UdpSocket, addr: SocketAddr) -> SocketAddr { + let is_v6 = udp.local_addr().map(|l| l.is_ipv6()).unwrap_or(false); + match addr.ip() { + IpAddr::V4(v4) if is_v6 => SocketAddr::new(IpAddr::V6(v4.to_ipv6_mapped()), addr.port()), + _ => addr, + } +} + +/// 解析 SOCKS5 UDP 数据报头:RSV(2) FRAG(1) ATYP(1) ADDR PORT,暂不支持分片。 +async fn parse_udp_packet(pkt: &[u8]) -> Option<(SocketAddr, &[u8])> { + if pkt.len() < 4 { + return None; + } + if pkt[2] != 0 { + return None; // FRAG != 0,不支持分片重组 + } + match pkt[3] { + ATYP_IPV4 => { + let b = pkt.get(4..8)?; + let port = u16::from_be_bytes([*pkt.get(8)?, *pkt.get(9)?]); + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(b[0], b[1], b[2], b[3])), port); + Some((addr, pkt.get(10..)?)) + } + ATYP_IPV6 => { + let b: [u8; 16] = pkt.get(4..20)?.try_into().ok()?; + let port = u16::from_be_bytes([*pkt.get(20)?, *pkt.get(21)?]); + let addr = SocketAddr::new(IpAddr::V6(Ipv6Addr::from(b)), port); + Some((addr, pkt.get(22..)?)) + } + ATYP_DOMAIN => { + let l = *pkt.get(4)? as usize; + let host = std::str::from_utf8(pkt.get(5..5 + l)?).ok()?; + let port = u16::from_be_bytes([*pkt.get(5 + l)?, *pkt.get(6 + l)?]); + let addr = tokio::net::lookup_host((host, port)).await.ok()?.next()?; + Some((addr, pkt.get(7 + l..)?)) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ct_eq_basics() { + assert!(ct_eq(b"abc", b"abc")); + assert!(!ct_eq(b"abc", b"abd")); + assert!(!ct_eq(b"abc", b"ab")); + } + + #[tokio::test] + async fn parse_udp_packet_v4() { + let mut pkt = vec![0, 0, 0, ATYP_IPV4]; + pkt.extend_from_slice(&[8, 8, 8, 8]); + pkt.extend_from_slice(&53u16.to_be_bytes()); + pkt.extend_from_slice(b"hello"); + let (addr, payload) = parse_udp_packet(&pkt).await.unwrap(); + assert_eq!(addr, "8.8.8.8:53".parse().unwrap()); + assert_eq!(payload, b"hello"); + } + + #[test] + fn addr_unspecified() { + assert!(SocksAddr::V4([0, 0, 0, 0], 0).is_unspecified()); + assert!(!SocksAddr::Domain("example.com".into(), 0).is_unspecified()); + } +}