This commit is contained in:
phptits
2026-09-09 04:16:44 +00:00
commit bde535c77d
10 changed files with 1737 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# 默认使用 musl 目标,产出完全静态链接的单文件二进制(Linux 下免运行库运行)。
# 首次编译前需安装目标: rustup target add x86_64-unknown-linux-musl
[build]
target = "x86_64-unknown-linux-musl"
+1
View File
@@ -0,0 +1 @@
/target
Generated
+131
View File
@@ -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",
]
+16
View File
@@ -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"
+21
View File
@@ -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.
+179
View File
@@ -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 <user:pass>] 前台运行
mini-socks [-p <端口>] [-a <user:pass>] -i 安装为 systemd 服务并启动(需 root)
mini-socks -u 停止并删除 systemd 服务(需 root)
mini-socks -h 显示版本、监听地址与帮助
```
| 参数 | 说明 |
| ---- | ---- |
| `-p <端口>` | 监听端口(1-65535),默认 `1080`,监听 `0.0.0.0:<端口>` |
| `-a <user:pass>` | 启用 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。
## 常见问题
<details>
<summary><b>BIND 命令有什么用?</b></summary>
SOCKS5 的 `BIND` 用于让远端主动反向连入(典型如 FTP 主动模式数据连接)。日常 HTTP/网页代理不会用到,但 mini-socks 已完整实现,可配合需要的客户端使用。
</details>
<details>
<summary><b>多出口 IP 需要特殊配置吗?</b></summary>
不需要修改本程序参数。只要服务器网卡上已配置多个 IP,监听在 `0.0.0.0` 的 mini-socks 就会自动按连入 IP 选择出口。若某入口 IP 未配置在本机(如 NAT 映射场景),该连接会回退默认路由。
</details>
<details>
<summary><b>忘记服务参数想修改怎么办?</b></summary>
带新参数重新执行 `sudo ./mini-socks -p <端口> -a <user:pass> -i` 覆盖安装即可,服务会自动重启到新配置。
</details>
## 项目结构
```
mini-socks/
├── src/
│ ├── main.rs # 命令行解析、帮助信息、日志
│ ├── socks.rs # SOCKS5 协议(CONNECT/BIND/UDP)、认证、多出口 IP
│ └── install.rs # systemd 服务安装/卸载
├── Cargo.toml
└── .cargo/config.toml
```
## License
[MIT](LICENSE)
+65
View File
@@ -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]`
+453
View File
@@ -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<u16, String> {
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<u16>, 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<String, String> {
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<String> {
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<String> {
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);
}
}
+344
View File
@@ -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<u16>,
auth: Option<Auth>,
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<I: Iterator<Item = String>>(it: I) -> Result<Args, String> {
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<Auth, String> {
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<Args, String> {
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);
}
}
}
+523
View File
@@ -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<SocketAddr> {
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<SocketAddr> =
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<String>) -> 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<Auth>) -> 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<Option<Auth>>) -> 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<Auth>) -> 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: tokio::io::AsyncRead + Unpin>(r: &mut R) -> io::Result<SocksAddr> {
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<TcpStream> {
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<SocketAddr> = 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());
}
}