代理服务器的穿透本质是个中转站。你的代理代码客户端先连上它,再由它替你访问目标地址。穿透但麻烦在于,代理代码很多应用层协议根本不认识代理——比如你写了个socket直接连目标IP,穿透它可不会自动去问代理“嘿,代理代码我该走哪条路”。穿透
所以穿透代理的代理代码核心就一句话:把原本直连的socket流量,硬塞进一条能理解代理协议的穿透通道里。
拿最常见的代理代码HTTP代理举例。假设你要访问example.com:80,穿透正常socket是代理代码connect("example.com", 80)。现在改成先connect(proxy_ip,穿透 proxy_port),然后往这个socket里写一行特殊的代理代码请求:
CONNECT example.com:80 HTTP/1.1\r\n Host: example.com:80\r\n \r\n 代理收到后,如果它允许,穿透会返回HTTP/1.1 200 Connection Established。这时候,你的socket和example.com之间就多了一条“隧道”,之后你往socket里写什么,代理就原封不动地往目标转发,返回的数据也一样。对上层应用来说,它根本感觉不到代理的存在——这就是穿透。
但现实比这个粗糙的例子恶心多了。我做过一个内网穿透工具,目标服务器只开放了HTTPS端口,而代理是个老旧的SOCKS4,不支持CONNECT方法。当时我盯着协议文档看了半小时,最后决定自己实现一个SOCKS5握手。过程不复杂,但细节特别容易踩坑:
比如SOCKS5需要先发版本协商,再发认证方式,再发目标地址。地址类型有IPv4、域名、IPv6三种,字节序不能搞错。我当时就因为在地址类型字段写错了值,导致代理返回0x07(命令不支持),排查了整整一个下午。后来翻到RFC 1928,才发现自己把ATYP和CMD搞混了。
代码层面,最土的办法是用select()同时监听两个socket——一个是本地客户端,一个是代理连接。但select有文件描述符上限,而且处理粘包要自己写缓冲区。后来我改用io_uring,异步轮询,性能直接翻倍。不过那是后话,初学者先从同步阻塞写起,反而更容易理解状态机。
有个常见的坑:代理返回的200响应里可能带额外头部,比如Proxy-Agent或者Keep-Alive。如果你只读了一部分就往下走,剩下的字节会污染你的业务数据流。正确做法是持续读,直到遇到空行\r\n\r\n为止。但要知道,有些代理实现不标准,可能直接关闭写端,这时候你得自己判断EOF。
还有,别忽略超时。代理服务器可能很慢,如果connect后10秒没响应,基本可以判定失败。建议用setsockopt(SO_RCVTIMEO)设置读超时,别傻等。我见过同事写的代码,代理挂了,客户端卡了5分钟才报错——那体验简直灾难。
如果你要穿透的是HTTPS流量,那更麻烦。因为TLS握手发生在代理隧道建立之后,所以你的客户端得先完成代理握手,再开始TLS。但有些代理(比如公司防火墙)会做中间人解密,你发过去的CONNECT请求会被拦下来检查。这时候就得用代理的认证功能,或者干脆用TLS封装代理协议,比如HTTPS_PROXY环境变量背后的实现。
最后说个我自己的观察:很多人写socket穿透代码时,喜欢把代理逻辑硬编码进业务层。这很蠢。正确做法是抽象一层transport接口,直连和代理都实现同一个connect()方法,上层只关心“我连上了没”。这样换代理类型,或者加个负载均衡,都只是换一个实现类的事。
代码这东西,跑通一次不难,难的是在边界条件里不炸。穿透代理的边界条件多到让人想骂娘——半关闭、零字节读、代理主动断开连接、DNS解析发生在代理端还是本地……每一个都能让你多掉几根头发。但当你真的把那条隧道打通,看到数据包从本地流向千里之外的服务器,再带着响应回来,那种感觉还挺爽的。
