C# 网络编程:SOCKS5 协议

C# 网络编程:SOCKS5 协议

当外星人想要连接“仙女座”网络

在遥远的宇宙角落,生活着一群特工外星人。他们想要访问传说中的“仙女座网络”(Andromeda Network)获取知识。然而,由于星球外层笼罩着一道强力的“AT 力场”(AT Field,即星球屏障),他们无法直接与仙女座建立连接。

为了穿越这道屏障,外星人通常会使用一种名为 Alienash 的空间折跃工具。在配置 Alienash 时,他们会接触到一个基础协议——SOCKS5

那么,SOCKS5 协议究竟是什么?它在这次“星际穿越”中扮演了什么角色?我们将如何利用 C# 复现这一协议?这正是本文要探讨的核心。

星际穿越:数据流的旅程

让我们重新梳理一下,当使用 Alienash 进行“星际穿越”时,数据究竟经历了什么。假设 Alienash 在本地开启了一个传输舱口(端口 7890):

  1. 发起呼叫curl 客户端想要访问 Andromeda.com:443。由于系统配置了代理,它不会直接向本地 DNS 询问地址,而是直接敲响了 Alienash 的大门(127.0.0.1:7890)。
  2. 建立本地管道curl 向 Alienash 发送 SOCKS5 握手包(或 HTTP CONNECT)。它的潜台词是:“Hey,Alienash,帮我搭建一条通往 Andromeda.com 的隧道。”
  3. 响应就绪:Alienash 回复:“通道已建立,请传送物资。”注意: 此时 curl 尚未发送任何真实的 HTTPS 数据,仅仅是建立了与 Alienash 的本地连接。
  4. 星图匹配:Alienash 拿到目标地址 Andromeda.com,查阅手中的《星际航行指南》(规则文件 Rule)。发现规则指示:Andromeda.com -> 需要走“星际中转站 A”(Proxy Node A)。
  5. 量子封装(关键步骤)
    • curl 开始发送 HTTPS 握手包(Client Hello)。
    • Alienash 截获了这个包。Alienash 看不懂内容,因为它是 HTTPS 加密的,对外星人来说就是一堆乱码。
    • Alienash 将这堆“HTTPS 乱码”作为载荷,装进它与远程中转站约定的“量子传输胶囊”里(协议:VMess, Trojan 等)。
    • Alienash 在胶囊外贴上标签:“发往中转站 IP”。
    • 外层伪装:为了防止星球表面的“AT 力场”识别出这是去往仙女座的违禁数据,Alienash 会对整个胶囊进行二次加密和混淆,使其看起来像普通的星际噪点。
  6. 穿越屏障:这个经过伪装的数据胶囊从网卡发出 -> 路由器 -> 运营商 -> AT 力场 -> 海底光缆。
  7. AT 力场的审视
    • 力场拦截了包裹,进行扫描。
    • 源坐标:你的 IP。
    • 目的坐标:星际中转站 IP(并非 Andromeda)。
    • 内容:一堆看起来像随机噪声的数据(因为被 Alienash 混淆过)。
    • 结论:看不出是去仙女座的,也解不开内容,虽然有点可疑但没证据,放行
  8. 接收与解封:远端的“星际中转站”收到了胶囊。因为它持有解密的密钥,它成功剥离了 Alienash 加的那层外壳(VMess/Trojan)。
  9. 读取指令:解封后,中转站看到了 Alienash 最初的便条:“请把里面的东西转发给 Andromeda.com:443”。
  10. 真实寻址:中转站此时才去查询 Andromeda.com 的真实 IP(因为中转站在屏障之外,这一步解析是纯净无污染的)。
  11. 代为转发:中转站拿着 curl 最初发出的那个 HTTPS 数据包,向 Andromeda 服务器发起 TCP 连接并发包。
  12. 送达:Andromeda 收到数据。在 Andromeda 看来,访问者是那个“星际中转站”,而不是躲在屏障后的你。
  13. 握手完成:Andromeda 的服务器与你的 curl 客户端(通过漫长的星际隧道)终于完成了 HTTPS 密钥交换。
  14. 回传物资:Andromeda 将网页内容加密(HTTPS) -> 发给中转站 -> 中转站加密封装(VMess) -> 穿过 AT 力场回到 Alienash -> Alienash 剥离外层 -> curl 解密内层(HTTPS) -> 屏幕显示 <html...>

SOCKS5 的角色定位

从上述流程中,我们可以提炼出关键信息:

SOCKS5 协议,仅用于“本地客户端”与“本地 Alienash”之间的通信。

它本质上是一个简单的本地分流器。Alienash 就像一个尽职的管家,通过 SOCKS5 协议接收主人的请求,然后根据《星际航行指南》(规则),决定是将请求直接丢出去,还是封装进“量子胶囊”发射到太空。

而真正穿越 AT 力场的核心技术,是 Alienash 与远程中转站之间的通信协议(如 VMess, Trojan, Shadowsocks)。这些才是真正的“星际运输协议”。例如,在 Alienash 的配置中:

1
- { name: '仙女座中转站', type: trojan, ...}

这里的 trojan,指明了 Alienash 与远程中转站之间使用的是 Trojan 协议进行加密传输。

数据包的“千层饼”结构

让我们再次透视一下数据包的结构。当外星人用 Alienash(以 VMess 协议为例)访问仙女座网络时,从内到外的洋葱结构如下:

  1. 核心货物:"我想搜索仙女座的美食"(明文数据)
  2. HTTP 层GET /search?q=...
  3. TLS 层 (Google 的锁)[加密乱码 A]
    • <-- 这是 SOCKS5 传输的内容,也是 curl 发出的原始包
    • <-- Alienash 在此处介入
  4. VMess 层 (中转站的锁)[加密乱码 B (内含乱码 A)] <-- Alienash 在这里进行打包
  5. 传输层 (伪装):Alienash 可能会把 乱码 B 伪装成普通的 WebSocket 或网页请求。
  6. TCP 层:源端口 -> 目的端口
  7. IP 层:你的 IP -> 中转站 IP

SOCKS5 协议:星际通讯手册

协议(Protocol)本质上就是“通信双方的约定”。就像人类交流需要统一语言一样,客户端和 Alienash 之间要对话,就必须严格遵守 SOCKS5 标准。

实现协议,本质上就是进行 TCP 编程:按照约定,一个字节一个字节地发送指令,再一个字节一个字节地解析回执。

整个 SOCKS5 的生命周期分为三个阶段:握手协商 -> 建立连接 -> 自由传输

第一阶段:握手协商 (The Handshake)

当 TCP 连接建立后,客户端(你的 curl 或浏览器)首先要向服务端(Alienash)亮明身份,确认版本并协商认证方式。

1. 客户端发送协商请求

格式如下(单位:字节):

VER NMETHODS METHODS
1 1 1-255
  • VER: SOCKS 版本号。这里必须是 0x05
  • NMETHODS: 后面 METHODS 字段的长度(有多少种认证方法)。
  • METHODS: 客户端支持的认证方法列表。

📝 知识小贴士:什么是 0x?

一个字节(Byte)由 8 个比特(bit)组成,能表示 0-255。0x十六进制的前缀。在网络编程中,我们通常用十六进制来表示字节数据,因为它更紧凑。例如 0x05 代表十进制的 50xFF 代表十进制的 255

常见的认证方法值定义: * 0x00: 无需认证(No Authentication Required)。这是最常见的情况,Alienash 本地使用通常不需要密码。 * 0x01: GSSAPI。 * 0x02: 用户名/密码认证。 * 0xFF: 没有支持的方法(即服务端拒绝服务)。

2. 服务端回应选择

服务端从客户端给出的列表里挑一个,告诉客户端:“我们就用这个方式说话”。

VER METHOD
1 1
  • VER: 0x05
  • METHOD: 服务端选中的方法。
    • 如果返回 0x00,说明服务端同意“无需认证”,直接进入下一阶段。
    • 如果返回 0xFF,说明“我不接受你的任何认证方式”,连接断开。

第二阶段:发送请求 (The Request)

协商完成后(通常是 0x00 无需认证),客户端正式发起“任务指令”:告诉服务端想要连接的目标是谁。

1. 客户端发送连接请求

VER CMD RSV ATYP DST.ADDR DST.PORT
1 1 1 1 动态 2
  • VER: 0x05
  • CMD (Command): 具体的命令动作。
    • 0x01: CONNECT (最常用,建立 TCP 连接)。
    • 0x02: BIND (端口绑定,FTP 可能会用)。
    • 0x03: UDP ASSOCIATE (UDP 转发)。
  • RSV: 保留字段,固定为 0x00
  • ATYP (Address Type): 目标地址类型。这一点对编程解析非常重要:
    • 0x01: IPv4 地址 (4 个字节)。
    • 0x03: 域名 (Domain Name)。第一个字节是域名长度,后面跟着域名内容。(跨越AT力场时最常用,因为我们要把域名交给远端去解析,防止本地 DNS 污染)
    • 0x04: IPv6 地址 (16 个字节)。
  • DST.ADDR: 目的地址(根据 ATYP 不同,长度不同)。
  • DST.PORT: 目的端口(2 个字节,固定网络字节序)。

2. 服务端(Alienash)响应结果

服务端尝试连接目标(或由 Alienash 记录下目标准备转发),然后告诉客户端结果。

VER REP RSV ATYP BND.ADDR BND.PORT
1 1 1 1 动态 2
  • VER: 0x05
  • REP (Reply): 应答状态码(你问的那个东西!)。它类似于 HTTP 的 200 OK 或 404。
    • 0x00: Succeeded (成功)。一切就绪!
    • 0x01: SOCKS 服务器故障。
    • 0x02: 规则不允许连接。
    • 0x03: 网络不可达。
    • 0x04: 主机不可达。
    • 0x05: 连接被拒绝。
    • ... 等等。
  • RSV: 固定 0x00
  • ATYP / BND.ADDR / BND.PORT: 服务器绑定的地址和端口。
    • CONNECT 命令成功后,这里通常告诉客户端:“我用哪个 IP 和端口替你去连的”。
    • 但在实际的代理实现中,客户端往往不关心这个值,服务端通常直接填 0.0.0.0:0 即可

第三阶段:自由传输 (The Relay)

一旦服务端发送了 REP0x00(成功)的响应包给客户端,SOCKS5 协议的任务就彻底结束了

此时,TCP 连接变成了一条透明的管道(Tunnel)。

  1. 客户端视角:它认为自己已经连上了 Andromeda,于是开始往这个 TCP 连接里写 HTTPS 数据(TLS Client Hello 等)。
  2. 服务端视角
    • 它不再解析任何 SOCKS 头部,不再判断什么 VERCMD
    • 它进入“无脑转发模式”
    • 读取客户端发来的所有字节 -> 原封不动地发给目标(或者封装后发给远程节点)。
    • 读取目标(或远程节点)发回的所有字节 -> 原封不动地发给客户端。

这就是“流式传输”的本质。 此时的代码逻辑将从“解析协议包”转变为“双向数据泵(Pipe)”。

用 C# 的伪代码表示,大概就是这种感觉:

1
2
3
4
5
6
7
8
// 握手和请求处理完毕,发送了 0x00 成功响应
Send(Socks5SuccessResponse);

// 开启双向转发
var task1 = CopyStreamAsync(clientStream, remoteStream); // 客户端 -> 远端
var task2 = CopyStreamAsync(remoteStream, clientStream); // 远端 -> 客户端

await Task.WhenAll(task1, task2);

这就好比电话接线员(SOCKS5 协议)帮你把线接通了。接通后,接线员就退场了,剩下的就是你和对方直接通话,无论你们说中文、英文还是加密的“火星语”,接线员都不再干涉,只负责传导电流。

C# 实现:构建星际港口

基本图像:港口的建立

实现一个 SOCKS5 服务器 Demo,用 AI 生成代码或许只需几秒钟。但我们的目的不是复制粘贴,而是理解这背后的“星际港口”是如何运作的。在 C# 的世界里,System.Net.Sockets 是构建通信设施的基础材料。要实现对某个频段(端口)的监听,本质上就是启动一个 TcpListener(引力波雷达/接收塔)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public async Task StartAsync()
{
// 1. 初始化雷达,监听所有维度的信号 (IPAddress.Any) 和指定频段 (_localPort)
_listener = new TcpListener(IPAddress.Any, _localPort);
_listener.Start();
Console.WriteLine($"Alienash Docking Bay open on port {_localPort}");

while (true)
{
try
{
// 2. 异步等待飞船接入(非阻塞)
var client = await _listener.AcceptTcpClientAsync();

// 3. 发射并遗忘:派一个小机器人(Task)去单独接待这艘飞船
_ = Task.Run(() => HandleClientAsync(client));
}
catch (Exception ex)
{
Console.WriteLine($"Docking error: {ex.Message}");
}
}
}

TCP 监听:盲目的接收塔

这就是整个服务器的心脏。TcpListener 启动后,就像一个不知疲倦的接收塔,时刻监听着 _localPort 频段的波动。

重点在于:它是“盲”的。

它监听的是传输层(Transport Layer)。它对货舱里的货物(应用层协议)一无所知,也不在乎。它收到的只是一连串 01二进制粒子流。 * 它不知道这些粒子组成的是 GET /search(地球人的 HTTP 请求); * 还是 \x05\x01\x00(SOCKS5 握手); * 亦或是被加密混淆过的乱码(Trojan/VMess 协议)。

它唯一的职责,就是保证这些粒子流按顺序到达,一个都不能少。所有的翻译工作(解析协议、解密数据),都需要开发者拿到 byte[] 后,自己去雇佣翻译官(编写代码)来处理。

维度错乱:当 TCP 遇上 UDP

假如,我们的 TcpListener 正在监听 7890 端口,此时突然有一堆 UDP 数据包 撞向了这个端口,会发生什么?

答案是:什么也不会发生,就像幽灵穿过墙壁。

虽然端口号都是 7890,但在操作系统的内核里,TCP 和 UDP 是两个完全平行的宇宙(命名空间)。内核的处理逻辑如下:

  1. 网卡接收:捕获到一个数据包。
  2. 内核扫描:发现 IP 头里的 Protocol 标记为 17 (UDP)。
  3. 查询星图:内核去查 “UDP 监听表”
  4. 结果:发现 7890 端口只有 TCP 监听记录,UDP 表里是空的。
  5. 裁决:这是一个“迷航”的包裹。
  6. 处置:直接丢弃,或者向发送者回射一发 ICMP Port Unreachable(维度不可达)信号。你的 C# 代码甚至感觉不到微风拂过。

语言不通:当 HTTP 遇上 Trojan

再换个场景:假如一个 HTTP 服务器(监听 TCP),收到了一串 Trojan 协议的加密数据呢?

这就不是维度问题了,而是语言问题。因为两者都基于 TCP,连接是可以建立成功的(握手完成)。数据会被送入应用程序,但在“翻译”时会发生惨剧:

  1. Trojan 客户端发送2a3b4c...9d0e(一串加密的哈希值)。
  2. HTTP 服务端接收:试图用《HTTP 字典》去解读。它期望看到 GETPOSTHEAD
  3. 认知崩溃:服务端困惑了——“你发的是什么天书?开头竟然是 2a?”
  4. 结局:标准的 Web 服务器(Nginx/Kestrel)会判定为 400 Bad Request 并切断连接。如果我们手写的逻辑是“读取前三个字节并强转为 string”,若没做异常处理,程序直接抛出异常(Crash)。

内存里的克隆战争:变量声明

回到代码细节,这里有一个极易被忽视但致命的陷阱。

1
2
// 正确写法
var client = await _listener.AcceptTcpClientAsync();

在这里,var client = ... 意味着每次循环都制造一个新的克隆体。 * 第 1 圈:在内存 0x1001 创建变量 client(张三)。Task 领走了张三。 * 第 2 圈:在内存 0x2005 创建变量 client(李四)。Task 领走了李四。 * 结果:每个 Task 领走的人都是独立的,互不干扰。

错误写法(变量复用):

1
2
3
4
5
TcpClient client; // 只有一个肉身,地址 0x9999
while (true) {
client = await Accept(); // 反复修改这个肉身的记忆
Task.Run(() => Handle(client));
}
  • 第 1 圈:把“张三”塞进 0x9999。Task A 刚启动,手里拿着 0x9999 的钥匙,准备进屋读取。
  • 第 2 圈:(Task A 还没来得及读)主线程动作太快,立刻把“李四”塞进了 0x9999,覆盖了张三。
  • 后果:Task A 终于进屋了,却发现屋里坐着李四。张三就这样在宇宙中消失了。这就是多线程编程中经典的“闭包陷阱”“竞态条件”

异步编程:时间膨胀与影分身

C# 的 Async/Await 是极其优雅的设计,它完美融合了两个看似矛盾的概念:逻辑上的同步物理上的异步

想象我们在经营一家星际餐厅,线程是服务员

1. await:逻辑同步,物理交权

1
2
var data = await ReadStreamAsync(); // 动作:读取数据
Process(data); // 后续:处理数据
  • 对代码逻辑来说await 是“等待”。在数据读完之前,绝不会执行 Process。逻辑线是连贯的,就像同步代码一样清晰。
  • 对线程来说await 是“交权”。
    • 服务员(线程)看到 ReadStream 是个耗时的 IO 操作(比如等厨师做菜)。
    • 不会站在厨房门口傻等(不阻塞)。
    • 他立刻当前的上下文(Context)挂起,开始休息或者去干别的。
    • 当菜做好了,系统会通过中断机制叫回服务员,回到刚才挂起的地方,拿起盘子继续执行 Process

2. Task.Run:影分身术

如果说 await 是为了让服务员不要傻等,那么 Task.Run 就是为了同时服务多个客人。

  • 没有 Task.Run:只有一个服务员。接待完张三的全套流程(点菜-做菜-吃饭)后,才能接待李四。李四会在门口等到饿死。
  • 有了 Task.Run:主线程是“领班”。
    • 来客人了?领班大喊:“来个服务员带张三去包间!”
    • 领班回到门口等李四。
    • 那个服务员带张三去包间,中间遇到 await 时,分身也会释放自己,绝不浪费算力。

在这里,(parameters) => expression 是 Lambda 表达式,用于创建一个匿名函数。

异步编程:代码逻辑 vs. 线程能力

我们可能会有一个疑问,既然 await 逻辑上等待,虽然线程不傻等,但是如果整个代码只用到了 await,是不是也无法让这个线程做多个事?答案是:是的。

假设我们是那个唯一的指挥官(单线程),看下面这个例子:

1
2
3
4
5
6
7
8
9
async Task HandleOneShip() 
{
// 1. 等待飞船 A 对接(耗时 5 秒)
await DockShipAsync("Ship_A");

// 2. 只有等 A 对接完了,代码才会走到这行
// 等待飞船 B 对接(耗时 5 秒)
await DockShipAsync("Ship_B");
}

即便 await 让你在等待飞船 A 的时候闲下来了,可是代码逻辑限制了你不能去处理飞船 B。因为第二行代码还没运行到啊!你闲下来只能去喝茶,干不了正事。总耗时 10 秒,在这个函数内部,它是串行的。

但是,我们的 SOCKS5 服务器是写在一个 while(true) 循环里的,而且更重要的是,它处理的是“事件”

让我们看看之前那段核心代码(去掉 Task.Run,只用 await 会发生什么):

1
2
3
4
5
6
7
8
9
// 假设这是单线程,没有 Task.Run,只有 await
while (true)
{
// 步骤 1:等待有飞船进入雷达范围
var client = await _listener.AcceptTcpClientAsync();

// 步骤 2:去处理这艘飞船(注意:这里用了 await)
await HandleClientAsync(client);
}

如果这样写,我们的担心就成真了:

  1. 飞船 A 来了。指挥官醒来。

  2. 指挥官去执行 HandleClientAsync

  3. HandleClient 里遇到 await stream.ReadAsync()(读数据)。

  4. 重点来了:指挥官确实被 await 释放了。但是,他被释放回到了哪里?

    他回到了调用栈的上一层。但在单线程模型下,如果逻辑被锁死在这个 while 循环的 await HandleClient 这一行,指挥官确实没法回到 while 的开头去 Accept 下一艘飞船。

所以,如果完全不开启新的任务分支(不使用 Task.Run 或者不把 HandleClient 变成“不等待”的任务),单线程在这个 while 循环里确实会变成串行处理:必须服务完 A,才能去接 B。

如果我们不用 Task.Run,想要单线程同时处理多件事,代码必须这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 这里的 HandleClient 不返回 Task,或者我们不 await 它
// 这种写法叫 "Fire and Forget" (发射后不管)
void StartHandling(TcpClient client)
{
// 这是一个 async void 方法(注意:通常只用于事件处理,服务器编程不推荐,但原理适用)
HandleClientAsync(client);
}

while (true)
{
var client = await _listener.AcceptTcpClientAsync();

// 关键点:我不 await 这个处理过程!
// 我只是触发了这个任务,让它开始跑,然后我立刻回到 while 开头
StartHandling(client);
}

这时候的流程:

  1. 指挥官:在 while 处等待连接。
  2. 飞船 A 到达。指挥官醒来。
  3. 指挥官:触发 HandleClientAsync(A)
    • 进入 Handle 函数,执行到 await ReadAsync
    • 发起 IO 请求(告诉网卡去读)。
    • 指挥官被释放
  4. 指挥官:因为没有 await 那个 Handle 任务,他立刻回到 while 循环的开头,再次 await Accept
  5. 飞船 B 到达。指挥官再次醒来,重复上述步骤。

此时,指挥官(单线程)就在“同时”做三件事:

  1. 盯着雷达(Accept)。
  2. 挂起状态,等待 A 的网卡数据。
  3. 挂起状态,等待 B 的网卡数据。

一旦 A 的数据到了(硬件中断),操作系统会把 A 的后续代码(状态机)扔进任务队列。指挥官一有空(比如雷达暂时没动静),就会从队列里取出 A 的后续代码继续执行。

这就是 Node.js 的工作原理:单线程,但是通过 await 和事件循环实现了高并发。

那么,既然单线程也能跑,为什么我们之前的代码里要用 Task.Run

1
_ = Task.Run(() => HandleClientAsync(client));

主要有两个原因:

原因一:避免“CPU 密集型”任务阻塞

await 只能释放 IO 等待(网卡、硬盘),如果 HandleClientAsync 里有一行代码是加密/解密(SOCKS5 转发通常涉及加密):

1
2
// 这是一个纯 CPU 计算,非常耗时
EncryptData(data);

如果是单线程模式(Node.js 模式),当指挥官在算加密的时候,整个服务器都卡死了。他算不完,就没法回到 while 开头去接客。

Task.Run,就是从线程池里叫来一个新的副官(Worker Thread)。 * 主指挥官:专心在门口接客(Accept)。 * 副官 A:在包间里专门负责飞船 A 的加密解密。 * 副官 B:在包间里专门负责飞船 B。

这样,即便加密计算再慢,也不会影响门口接客。C# 是多线程语言,利用多核 CPU 的优势是理所应当的。

原因二:利用线程池的自动调度

C# 的 AcceptTcpClientAsync 和网络 IO 本身就是针对多线程优化的。使用 Task.Run 是 .NET 服务器编程的标准范式,它能最大化利用多核 CPU。

所以总结一下:

  1. 仅有 await (串行写法)await A; await B; -> 串行。线程虽然闲,但逻辑锁死了顺序。
  2. 仅有 await (并发写法)Task.WhenAll(TaskA, TaskB) 或者 不 await 直接触发 -> 单线程并发。线程像个陀螺一样在多个任务间跳来跳去。这适用于 IO 密集型(Node.js 模式)。
  3. await + Task.Run多线程并行。主线程负责分发,工作线程负责干活。这是 C# 服务器端(ASP.NET Core / 我们的 Demo)的高性能标准配置,既能应对 IO 等待,也能应对 CPU 计算。

在我们的 SOCKS5 Demo 中,如果不加 Task.Run 且我们在 while 循环里直接 await HandleClient服务器确实会变成一次只能服务一个人的“串行服务器”

SOCKS5 握手与转发:建立星际航线

在接到了来自外星特工的 TCP 连接后,我们的代码就进入了 HandleClientAsync 方法。这个方法的前半部分是“对暗号”(SOCKS5 握手),后半部分是“开虫洞”(数据转发)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
private async Task HandleClientAsync(TcpClient client)
{
// 语法糖:当离开这个花括号范围时,自动销毁连接,防止资源泄漏
using var clientScope = client;
var stream = client.GetStream();

try
{
// ========================================================================
// 第一阶段:身份核验 (Authentication)
// 客户端发来暗号:[VER(1)][NMETHODS(1)][METHODS(1-255)]
// ========================================================================
var authBuffer = new byte[257];
// 必须读满 2 个字节(版本号 + 方法数),否则一直等
await stream.ReadExactlyAsync(authBuffer, 0, 2);

if (authBuffer[0] != 0x05) // 必须是 SOCKS5 协议
{
Console.WriteLine("Protocol Mismatch: Not SOCKS5.");
return;
}

int nMethods = authBuffer[1];
// 读取具体的方法列表
await stream.ReadExactlyAsync(authBuffer, 0, nMethods);

// 我们简单粗暴,回复客户端:无需密码,直接通过 (0x00)
// 回包格式:[VER(1)][METHOD(1)] => 0x05 0x00
await stream.WriteAsync(new byte[] { 0x05, 0x00 }, 0, 2);

// ========================================================================
// 第二阶段:解析航行指令 (Request)
// 客户端发来:[VER(1)][CMD(1)][RSV(1)][ATYP(1)][DST.ADDR(Var)][DST.PORT(2)]
// ========================================================================
var requestBuffer = new byte[4];
await stream.ReadExactlyAsync(requestBuffer, 0, 4);

var cmd = requestBuffer[1]; // 0x01 = CONNECT (建立连接)
var atyp = requestBuffer[3]; // 地址类型

if (cmd != 0x01) // 目前只支持 CONNECT,不支持 BIND 或 UDP
{
Console.WriteLine("Unsupported Command.");
return;
}

string targetHost;

// 解析目标坐标
if (atyp == 0x01) // IPv4 (4字节)
{
var ipBytes = new byte[4];
await stream.ReadExactlyAsync(ipBytes, 0, 4);
targetHost = new IPAddress(ipBytes).ToString();
}
else if (atyp == 0x03) // 域名 (变长) -> 翻墙最常用!
{
var lenBuffer = new byte[1];
await stream.ReadExactlyAsync(lenBuffer, 0, 1);
int domainLength = lenBuffer[0];

var domainBytes = new byte[domainLength];
await stream.ReadExactlyAsync(domainBytes, 0, domainLength);
targetHost = Encoding.UTF8.GetString(domainBytes);
}
else if (atyp == 0x04) // IPv6 (16字节)
{
var ipBytes = new byte[16];
await stream.ReadExactlyAsync(ipBytes, 0, 16);
targetHost = new IPAddress(ipBytes).ToString();
}
else
{
Console.WriteLine("Unknown Address Type.");
return;
}

// 解析目标端口 (2字节,大端序 Big Endian)
var portBytes = new byte[2];
await stream.ReadExactlyAsync(portBytes, 0, 2);
int targetPort = (portBytes[0] << 8) | portBytes[1];

Console.WriteLine($"Alienash requesting warp to: {targetHost}:{targetPort}");

// ========================================================================
// 第三阶段:连接目标星系 (Connect Target)
// ========================================================================
using var targetClient = new TcpClient();
try
{
// Alienash 尝试连接 Google/仙女座服务器
await targetClient.ConnectAsync(targetHost, targetPort);
}
catch
{
Console.WriteLine($"Connection failed: {targetHost}:{targetPort}");
return;
}

// 告诉客户端:连接成功,准备传输 (Response)
// 格式:[VER][REP][RSV][ATYP][BND.ADDR][BND.PORT]
// REP 0x00 = Success. 后面的地址通常填全0,客户端不关心。
byte[] response = { 0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0 };
await stream.WriteAsync(response, 0, response.Length);

// ========================================================================
// 第四阶段:建立双向虫洞 (Blind Forwarding)
// ========================================================================
var targetStream = targetClient.GetStream();

// 开启两个并行的搬运工任务
var clientToTarget = CopyStreamAsync(stream, targetStream); // 上行
var targetToClient = CopyStreamAsync(targetStream, stream); // 下行

// 关键逻辑:生死与共
await Task.WhenAny(clientToTarget, targetToClient);

Console.WriteLine("Warp tunnel closed.");
}
catch (Exception ex)
{
Console.WriteLine($"Tunnel collapse: {ex.Message}");
}
}

// 搬运工逻辑:从源读多少,往目标写多少
private async Task CopyStreamAsync(NetworkStream source, NetworkStream dest)
{
byte[] buffer = new byte[8192]; // 8KB 的搬运桶
try
{
while (true)
{
int bytesRead = await source.ReadAsync(buffer, 0, buffer.Length);
if (bytesRead == 0) break; // 读到 0 说明对方挂电话了 (FIN)
await dest.WriteAsync(buffer, 0, bytesRead);
}
}
catch { /* 忽略连接意外断开的错误 */ }
}

TCP 字节流:没有边界的水流

TCP 协议的设计哲学是:可靠、有序、无边界

  • 可靠:发出去的每一滴水(字节)都不会丢。
  • 有序:先发的先到,顺序绝不会乱。
  • 无边界(关键!):TCP 就像一根水管。客户端发了三次水(三次 Write),对于服务端来说,流过来的是连续不断的水流。

例如,客户端发送了:[A, B, C] ... [D, E] ... [F, G, H]

服务端接收到的缓冲区可能是:[A, B, C, D, E, F, G, H]

TCP 根本不知道你发了几次包,它只知道这里有一堆字节。“包”的概念是应用层(HTTP/SOCKS5)自己定义的。

舀水的艺术:ReadAsync vs ReadExactlyAsync

在 C# 的 NetworkStream 中,我们有两种舀水的方式,这在协议解析中至关重要:

  • ReadAsync (随缘舀水)
    • 逻辑:“缓冲区里有多少水,我就舀多少,最多舀满桶(Buffer)。”
    • 场景:用于数据转发。当我们在转发视频流时,只要来了数据就赶紧转手发出去,不用非得攒够 8192 字节再发。
    • 返回值:实际读到的字节数。哪怕只读到 1 个字节,它也会立即返回。
  • ReadExactlyAsync (精准定量)
    • 逻辑:“我必须舀满 N 升水。如果缓冲区里的水不够,我就端着桶在水管下面死等(异步等待),直到凑齐为止。”
    • 场景:用于协议解析
    • 为什么需要它? SOCKS5 协议规定,端口号一定是 2 个字节。如果你用 ReadAsync,可能运气不好只读到了 1 个字节(网络波动),剩下的 1 个字节还在路上。这时候你去解析端口,程序就崩了。ReadExactlyAsync 保证了协议解析的原子性和完整性。

这两个方法都有很多种参数重载,其中最经典的是:

1
2
Task<int> ReadAsync(byte[] buffer, int offset, int count);
ValueTask ReadExactlyAsync(byte[] buffer, int offset, int count);

byte[] buffer 是将读到的字节写到这个数组里,offset 是从 buffer 的哪个位置开始写,count 是读多少个字节。

要写入字节流就简单啦,因为不用担心写入几位的问题。TCP本身就是可靠有序的,我们只要一次都写进去就完事了,让对面去读。最经典的 WriteAsync 的参数就是:

1
2
Task WriteAsync(byte[] buffer, int offset, int count)
await stream.WriteAsync(new byte[] { 0x05, 0x00 }, 0, 2);

byte[] buffer 是想要写到流里的数据,int offset 是从 buffer 的第几个字节开始写,int count 是要写多少个字节。在上面第二行的这个例子里,就是写2字节。

using:自动毁灭装置

1
using var clientScope = client;

这是一种 C# 的语法糖(Scope-based Resource Management)。它相当于告诉编译器:“在这个变量超出作用域(函数执行完毕、抛出异常、return)的瞬间,必须自动触发 client.Dispose()。”

这就像是给飞船安装了离港自动清理程序。无论任务是成功还是失败,只要飞船离开(代码执行完),系统就会自动切断连接、释放内存句柄。如果没有这个,服务器运行几天后,内存就会被数千个“僵尸连接”撑爆。这和 Python 的 with open()... 很像。

核心逻辑:双向虫洞与“生死与共”

SOCKS5 服务器最核心的工作就是转发(Relaying)

由于 TCP 是全双工(Full-Duplex)的,数据可以同时双向流动。Alienash 既要听客户端说话(发给 Andromeda),又要听 Andromeda 说话(发回客户端)。这两个过程必须是并发的。

这里有一个精妙的设计:为什么使用 Task.WhenAny 而不是 WhenAll

1
await Task.WhenAny(clientToTarget, targetToClient);

这涉及到一个哲学原则:在透传隧道中,只要一方离场,另一方的存在就失去了意义。

假设你正在浏览网页,突然你关闭了浏览器标签页。

  1. 你(客户端):发送了 FIN 包(断开信号)。
  2. SOCKS5 服务器clientToTarget 任务读取到 0 字节,任务结束。
  3. Andromeda(服务端):因为 HTTP Keep-Alive 机制,Andromeda 并不这道你已经跑路了,它还傻傻地保持着连接,等待你的下一个请求。
  4. 你的代码 (WhenAll):卡住了!因为你在等待 targetToClient 结束。
  5. 后果:这条连接会一直挂在服务器上,直到几分钟后 Andromeda 超时踢人。这就叫半关闭状态(Half-Close)导致的资源泄漏

正确的逻辑是:

  1. 你(客户端):发送 FIN 包。
  2. SOCKS5 服务器clientToTarget 任务结束。
  3. Task.WhenAny:立即被触发,解除 await
  4. 代码继续执行:退出 using 作用域。
  5. 暴力拆迁client.Dispose() 被调用。这会强制向 Andromeda 发送 RSTFIN 包,并物理切断与 Andromeda 的连接。

总结:

  • 客户端断了 -> 我没法传话给服务端了 -> 挂断服务端。
  • 服务端断了 -> 我没法传话给客户端了 -> 挂断客户端。

这就是作为中间人(Middleman)的职业操守:绝不维护无意义的连接。

总结:从“苦工”到“星际指挥官”

至此,我们不仅用 C# 完成了一个 SOCKS5 协议的最小化实现,更重要的是,我们亲手拆解了 Alienash 那个神秘黑盒子的第一层包装。

回顾这段旅程,我们并没有在纠结“分号该放哪”或“类该怎么定义”这种语法细节上浪费时间——因为 AI 已经帮我们完成了这些“苦工”的工作。我们将精力集中在了更高维度的逻辑构建与原理洞察上:

  1. 祛魅协议:我们发现,所谓的 SOCKS5 握手,不过是几个字节的“暗号”交换;所谓的“星际隧道”,本质上就是两个 TCP Socket 之间的左手倒右手。
  2. 掌控时间:通过 async/awaitTask.WhenAny,我们理解了如何在单线程中通过“状态机”榨干 CPU 的效能,以及如何用“生死与共”的策略优雅地管理连接的生命周期。
  3. 透视数据:我们明白了 TCP 不是一个个打包好的包裹,而是连绵不绝的水流。理解了 ReadExactlyAsyncReadAsync 的区别,就是理解了“严谨的协议解析”与“高效的数据转发”之间的辩证关系。

正如文章开头所言,在 AI 时代,编程的门槛降低了,但软件工程的门槛变高了

当我们看着 AI 生成的那段代码时,如果我们不懂 TCP 的流式特性,就无法解释为什么有时候数据会粘包;如果我们不懂 WhenAny 的机制,就无法解决服务器内存泄漏的幽灵。AI 是我们手中最锋利的光剑,但唯有深厚的计算机原理功底,才能教会我们如何挥舞它。

现在的我们,不再是那个在森林里盲目砍树的苦工(Peon),而是站在舰桥上,指挥着 AI 构建星际航道的指挥官。

虽然星球表面的 AT 力场(AT Field) 依然存在,虽然通往 仙女座网络 的路途依然遥远且充满干扰,但至少现在,我们已经明白了脚下的路是如何铺就的。

保持好奇,保持对底层的敬畏。因为无论技术如何迭代,那些在该死的电缆中流淌的 0 和 1,永远是构建这片数字宇宙的基石。

Happy Coding, and safe travels to Andromeda. 🚀