
先聊聊这个项目到底在做一件什么事。用 Python 写一个 HTTP 代理服务器本身不算什么新鲜事网上类似的教程一抓一大把。但大多数要么只能处理简单的 GET 请求要么在并发场景下一压就崩要么直接把 CONNECT 方法丢掉导致 HTTPS 根本通不了。这次这个项目不是“玩具版”而是要做一个能用、能扛、能拿来干活的版本用 Python 多线程模型来支撑多个客户端同时请求底层不依赖 Flask、Django 这类重型框架纯 socket 标准库直接撸把 HTTP 代理的核心链路完整跑通包括普通 HTTP 转发和 HTTPS 隧道。这个标题拆开来看就三个关键词Python、多线程、HTTP 代理。Python 负责实现和快速迭代多线程负责并发处理HTTP 代理是核心功能。如果你经常需要抓包调试、本地联调第三方接口、给局域网内多台设备统一出口或者写爬虫时需要动态切换代理那这个东西就非常对胃口。整篇文章我会从原理讲到实现再到踩坑记录全程给出可直接复制运行的代码并把每一步为什么这么做讲清楚。1. 项目整体设计与思路拆解1.1 先搞懂 HTTP 代理到底在代理什么很多人一提“代理”就下意识往歪了想但这个项目里的 HTTP 代理就是一个标准的中间人转发服务用途也非常正经开发调试、接口联调、流量观察、局域网共享出口、爬虫请求分发等。它的基本工作流程分三步。第一步客户端比如浏览器、curl 命令、爬虫脚本把请求发给代理服务器而不是目标网站。第二步代理服务器解析请求行和头部拿到真实的目标域名、端口、路径。第三步代理服务器代替客户端去连接目标服务器发请求、收响应再把响应原样返回给客户端。整个过程中代理只负责“搬运”。它不修改业务数据不解析响应体也不做缓存或拦截就是一个拥有“第二双手”的搬运工。这也决定了它的性能瓶颈主要在连接调度和字节拷贝上而多线程正好能很好地掩盖网络 IO 等待带来的延迟。1.2 为什么选多线程而不是其他并发方案实现并发代理服务器Python 里至少有四种路线多线程、多进程、asyncio 协程以及用 Tornado 这类异步框架托底。我在这个项目里选多线程理由非常直接。第一HTTP 代理的每个请求处理逻辑天然独立不需要共享复杂状态。每个线程只需要拿到客户端 socket然后自己完成“连接目标服务器 → 转发请求 → 回传响应 → 关闭连接”相互之间完全不干扰。线程之间唯一要同步的就是打印日志时的锁但这几乎不构成并发压力。第二多线程模型在 Python 里是“心智负担最低”的方案。asyncio 虽然在高并发下表现很亮眼但它需要把所有代码写成非阻塞风格尤其是 socket 的读写、CONNECT 双向隧道转发异步处理起来要小心的地方太多。对大多数人来说多线程才是“逻辑上像同步代码实际又能并行处理多个连接”的折中方案。第三GIL 在这个场景下并没有想象中那么碍事。代理服务器的大量时间耗在 socket 的 recv 和 send 上而这些操作底层会释放 GIL所以线程之间的并发度是真实存在的并不是伪并发。实测下来用 100 个线程处理几十个并发连接性能完全够用。1.3 方案选型考虑手写 socket 还是扩展标准库一开始我心里的备选方案有两个一是直接继承http.server模块里的BaseHTTPRequestHandler搭配ThreadingHTTPServer使用二是从 socket 层面自己写协议解析。最终我选了后者。原因也很现实http.server虽然能快速起一个 HTTP 服务但它把请求解析、响应封装都做成了“半成品”对代理场景非常不友好。尤其是处理 CONNECT 方法建立的 HTTPS 隧道BaseHTTPRequestHandler需要你绕过它的请求处理框架反而更绕。而自己写 socket虽然要面对原始字节流但整个请求头怎么切、头部怎么重组、隧道怎么双向转发每一步都清楚可控。当然这不是说标准库方案一无是处。如果只是想写一个几十行的 DemoThreadingHTTPServer完全够用。但要做成一个能稳定跑的代理手写 socket 才是“一次写明白后面不返工”的做法。2. 环境准备与工具选型解析2.1 运行环境也就是 Python 3.8这个项目没有任何第三方依赖标准库走天下。理论上 Python 3.6 以上就能跑但我在实践中建议至少用 3.8因为后续如果要做类型注解、f-string 嵌套、dataclass之类的扩展3.8 一下的体验会差不少。如果你还在纠结 Python 怎么装、环境怎么配直接去官网下载安装包把“Add Python to PATH”勾上一路 Next 就好。装完后在命令行输入python --version能看到版本号就算成功。这里不展开安装细节但提醒一句装完 Python 之后建议顺手用python -m pip install --upgrade pip把 pip 更新一下后面装辅助测试工具会省心很多。2.2 辅助测试工具curl 和 nc开发代理服务器光看代码跑不跑得通不够还得有趁手的“探针”来验证每一层逻辑。我用来测代理的工具主要有三个curl最常用的 HTTP 客户端指定-x参数就能走代理发请求验证普通 HTTP 转发非常方便。ncnetcat在 Linux、macOS 上检查端口监听、手动模拟原始 TCP 请求排查问题的时候特别有用。Chrome/Edge 浏览器的“系统代理设置”或插件验证真实浏览器流量走代理的效果尤其能测 HTTPS 隧道是否正常。2.3 代理监听端口的选择与冲突排查代理服务器要监听一个本地端口这里建议避开 8000、8080、8888 这类过于大众化的端口因为很多开发工具默认会占用它们。我在项目里用的是 8888如果端口被占用启动时会直接抛OSError: [Errno 98] Address already in use。排查端口占用最简单的方法# Linux / macOS lsof -i :8888 # Windows netstat -ano | findstr :8888找到占用进程后要么换一个端口要么结束占用进程或者干脆加上SO_REUSEADDR让服务重启时能快速复用 TCP 端口这个后面在代码里会写进去。3. 核心代码实现与逐步拆解3.1 先搭出主循环与线程调用框架代理服务器的骨架不复杂一个 socket 监听端口一个无限循环 accept 客户端连接每拿到一个连接就开一个线程去处理。这里的关键是线程如何调度。import socket import threading LISTEN_IP 127.0.0.1 LISTEN_PORT 8888 BUFFER_SIZE 8192 MAX_CONN 100 def handle_client(client_sock, addr): print(f[connection] {addr} connected) try: # 实际代理逻辑 pass except Exception as e: print(f[error] {addr}: {e}) finally: client_sock.close() print(f[connection] {addr} closed) def main(): server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((LISTEN_IP, LISTEN_PORT)) server.listen(MAX_CONN) print(f[start] proxy listening on {LISTEN_IP}:{LISTEN_PORT}) while True: client_sock, addr server.accept() t threading.Thread(targethandle_client, args(client_sock, addr), daemonTrue) t.start() if __name__ __main__: main()几个细节要展开说一下。SO_REUSEADDR这个 socket 选项作用是在服务端主动关闭后端口还能立刻被重新绑定不然会进入一段 TIME_WAIT 状态重启服务时报端口被占用。开发期间频繁改代码重启这个选项能帮你省掉很多不必要的等待。server.listen(MAX_CONN)里的参数表示等待处理的连接队列最大长度不是最大并发线程数。真正并发上限是由你的系统资源和你创建线程的速度决定的这个参数更像是一个缓冲区的长度。线程设置了daemonTrue这很关键。主线程的 accept 循环会一直阻塞如果某天你想用 CtrlC 终止程序daemon 线程会随主进程一起退出不会出现一堆非守护线程卡住进程不退出的情况。3.2 读取客户端请求头并解析目标地址代理服务器拿到客户端 socket 后第一件事是读取请求头。HTTP 请求头以\r\n\r\n作为结束标志所以我们不需要一次性把所有数据都读完只要攒到出现空行位置就说明头部接收完整了。request b while b\r\n\r\n not in request: chunk client_sock.recv(BUFFER_SIZE) if not chunk: return request chunk这里有几个坑。第一recv可能返回空字节串这代表客户端已经关闭了连接此时必须停止读取否则会死循环。第二不能假设一次recv就能收到完整的请求头网络的 TCP 传输是流式的可能分好几段才能凑齐所以必须用循环拼接。第三BUFFER_SIZE设成 8192 是一个常见的平衡值太大浪费内存太小会导致循环次数变多、CPU 空转。拿到头部后第一行就是请求行例如GET http://example.com/index.html HTTP/1.1或者是CONNECT example.com:443 HTTP/1.1。按空格拆开就能得到三个部分请求方法、目标地址、HTTP 版本。目标地址分两种情况。浏览器走代理时对于普通 HTTP 请求URL 往往是完整的绝对地址比如http://example.com/path?query1。对于 HTTPS 请求浏览器发的是 CONNECT 方法目标地址是example.com:443这种“主机名 端口”的形式没有http://前缀。这两种情况都要分别处理。3.3 普通 HTTP 请求怎么转发拿到目标地址后代理需要做四件事解析出目标主机名、端口和路径。连接目标服务器。重新组装请求头并发送给目标服务器。把目标服务器的响应读回来原样转发给客户端。如果 URL 是完整形式我用urllib.parse.urlparse来解析这比手工切字符串要可靠得多。from urllib.parse import urlparse parsed urlparse(url) host parsed.hostname port parsed.port or 80 path parsed.path or / if parsed.query: path ? parsed.query如果 URL 不是完整形式而是GET /path HTTP/1.1那主机名就要从请求头的Host字段里取。注意 Host 字段可能带端口号比如Host: example.com:8080所以还需要单独剥离。拿到主机和端口后用socket.create_connection((host, port), timeout15)去建立连接。这里设置了 15 秒超时避免连不上的时候线程被无限阻塞。连接成功后需要重新组装请求行和请求头。原来的请求行里写的是完整 URL但代理向目标服务器发请求时路径部分只需要/path形式。头部里的Proxy-Connection这种代理专用字段在设计上不应该再传给目标服务器否则可能被对方拒绝或引发异常。new_request f{method} {path} HTTP/1.1\r\n for line in lines[1:]: if line.lower().startswith(bproxy-connection): continue if line.lower().startswith(bconnection): continue new_request line.decode(iso-8859-1) \r\n new_request Connection: close\r\n\r\n remote.sendall(new_request.encode(iso-8859-1))这里把Connection也过滤掉重新设置成close是为了让目标服务器返回响应后主动关闭连接这样代理读到 EOF 就知道响应结束了不用去解析 Content-Length 或 chunked 编码。对代理这种转发场景来说这招能省掉大量响应体长度判断的麻烦。响应回传就很简单了一个循环不断recv然后把数据写到客户端 socket 上直到读不到数据为止。3.4 HTTPS 请求怎么用 CONNECT 隧道实现普通 HTTP 请求的目标服务器返回的是明文数据代理可以直接中转。但 HTTPS 流量的内容是加密的代理根本看不懂也不能替客户端重新加密。所以 HTTPS 代理必须建立一个“隧道”。流程是客户端先发给代理一个 CONNECT 请求里面带上目标主机和端口例如CONNECT www.baidu.com:443 HTTP/1.1。代理收到后先去连接www.baidu.com:443如果连接成功就返回一个HTTP/1.1 200 Connection Established\r\n\r\n给客户端。从这一刻起代理不再解析任何后续流量只是把两个 socket 之间的字节流双向搬运。从代码上看CONNECT 的处理方式是先建远程连接回复 200然后开两个线程分别处理两个方向的转发。一个方向是客户端的数据发给目标服务器另一个方向是目标服务器的数据发给客户端。def relay(src, dst): try: while True: data src.recv(BUFFER_SIZE) if not data: break dst.sendall(data) except Exception: pass finally: try: dst.shutdown(socket.SHUT_WR) except OSError: pass remote socket.create_connection((host, port), timeout15) client_sock.sendall(bHTTP/1.1 200 Connection Established\r\n\r\n) t1 threading.Thread(targetrelay, args(client_sock, remote), daemonTrue) t2 threading.Thread(targetrelay, args(remote, client_sock), daemonTrue) t1.start() t2.start() t1.join() t2.join() remote.close()这里shutdown(socket.SHUT_WR)是一招很关键的避坑操作。正常情况下recv返回空串表示对端已经关闭了发送方向。但在双向转发中如果只调用close()另一侧可能还在发送数据直接关闭会导致数据丢失。shutdown(SHUT_WR)只是关闭本方的发送方向接收方向仍然打开这样另一侧还能把剩余数据读完。等双方都结束再统一关闭 socket 才是安全的做法。3.5 完整版的代码组装把这些逻辑组装在一起就能得到一个可运行的版本完整代码结构如下import socket import threading from urllib.parse import urlparse LISTEN_IP 127.0.0.1 LISTEN_PORT 8888 BUFFER_SIZE 8192 MAX_CONN 100 def relay(src, dst): try: while True: data src.recv(BUFFER_SIZE) if not data: break dst.sendall(data) except Exception: pass finally: try: dst.shutdown(socket.SHUT_WR) except OSError: pass def handle_client(client_sock, addr): print(f[connection] {addr} connected) try: client_sock.settimeout(30) request b while b\r\n\r\n not in request: chunk client_sock.recv(BUFFER_SIZE) if not chunk: return request chunk lines request.split(b\r\n) first_line lines[0].decode(iso-8859-1) method, target, version first_line.split( ) if method.upper() CONNECT: host, _, port target.rpartition(:) port int(port) remote socket.create_connection((host, port), timeout15) client_sock.sendall(bHTTP/1.1 200 Connection Established\r\n\r\n) t1 threading.Thread(targetrelay, args(client_sock, remote), daemonTrue) t2 threading.Thread(targetrelay, args(remote, client_sock), daemonTrue) t1.start() t2.start() t1.join() t2.join() remote.close() else: parsed urlparse(target) if not parsed.hostname: host None for line in lines[1:]: if line.lower().startswith(bhost:): host line.split(b:)[1].strip().decode(iso-8859-1) break port 80 path target else: host parsed.hostname port parsed.port or 80 path parsed.path or / if parsed.query: path ? parsed.query remote socket.create_connection((host, port), timeout15) new_request f{method} {path} HTTP/1.1\r\n for line in lines[1:]: lower_line line.lower() if lower_line.startswith(bproxy-connection): continue if lower_line.startswith(bconnection): continue new_request line.decode(iso-8859-1) \r\n new_request Connection: close\r\n\r\n remote.sendall(new_request.encode(iso-8859-1)) while True: data remote.recv(BUFFER_SIZE) if not data: break client_sock.sendall(data) remote.close() except Exception as e: print(f[error] {addr}: {e}) finally: client_sock.close() print(f[close] {addr} disconnected) def main(): server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((LISTEN_IP, LISTEN_PORT)) server.listen(MAX_CONN) print(f[start] proxy listening on {LISTEN_IP}:{LISTEN_PORT}) while True: client_sock, addr server.accept() t threading.Thread(targethandle_client, args(client_sock, addr), daemonTrue) t.start() if __name__ __main__: main()这个版本已经能处理 90% 的日常代理场景了。需要再次强调一点项目里所有请求头解析都是在 ISO-8859-1 编码下操作的这也是 HTTP 协议标准里头部字段的默认编码。如果错用 UTF-8遇到某些特殊字符时会直接抛出 UnicodeDecodeError导致连接异常断开。4. 常见问题与排查技巧实录写代理这类网络程序坑基本集中在边界情况上。你写的时候觉得逻辑天衣无缝但实际跑起来会发现各种匪夷所思的报错。下面这几个是我在调试中真实遇到过并且花了不少时间才解决的。4.1 连接超时导致线程堆积最初版本的代码里我并没有给recv和create_connection设置超时。正常情况下没问题但一旦目标服务器无响应线程就会一直阻塞在recv上永不退出。短时间内开了几十个连接几十个线程全挂在阻塞状态不仅资源浪费还会拖垮整个服务的响应速度。解决方式是给客户端 socket 设置超时时间并在建立远程连接时指定 timeout 参数。这里我选择了 30 秒超时是经验值。太短会导致某些慢一点的接口被误杀太长又起不到保护作用。如果你代理的是一些响应特别慢的大文件请求可以把超时调大或者干脆只对建立连接阶段设超时数据传输阶段设一个更长的值。4.2 recv 返回空串却被我当成了异常有一段时间我写的转发循环用recv返回空串作为结束标志这本来没有错。但我在relay函数里把空串情况当成异常做了处理结果导致隧道关闭时日志里刷满了异常信息而且有时候dst.shutdown(SHUT_WR)还没来得及执行就被异常处理提前跳过了。正确的逻辑是recv返回空串表示对端正常关闭不算异常直接 break 退出循环。只有在recv抛出socket.timeout或ConnectionResetError这类异常时才走异常分支。把“正常关闭”和“异常断开”分清楚代码才会稳定。4.3 Chrome 和 Firefox 代理设置后的行为差异浏览器走代理时对 CONNECT 方法的处理并不完全一致。Chrome 发送 CONNECT 请求后如果代理返回 200它会立刻开始 TLS 握手中间不做任何等待。而有些浏览器或者某些客户端库会在收到 200 后先发送一段空数据或者保险性请求如果你在 CONNECT 回复后调用了recv去“预读”很可能因为等不到数据而一直阻塞。所以 CONNECT 隧道建立之后的处理应该是“立即开启双向转发”绝对不能在此时去读取客户端的数据。这个顺序搞反了HTTPS 握手就会卡住。4.4 防火墙和系统代理环境变量带来的干扰在测试代理时我自己碰到过一种很懵的情况代码没问题curl 指定代理也正常但到了某个测试环境里请求总是走不到代理直接原路发出去了。查了半天发现是环境变量http_proxy和https_proxy的锅。一些开发工具比如 pip、部分爬虫框架、curl默认会读取这些环境变量如果你系统里曾经设置过别的代理地址这些工具就不会走你指定的代理端口。排查方法很简单在运行命令前检查一下环境变量env | grep -i proxy如果有残留配置业务代码里可以临时清掉或者用命令行参数强制指定避免干扰测试结果。4.5 常见问题速查表现象可能原因快速排查方法解决方案启动报 Address already in use本地端口被占用lsof -i :8888或netstat -ano换端口或确认无残留进程后加SO_REUSEADDR重启HTTP 请求返回 400/502请求头部转发不规范打印重组后的请求头检查 Host、Connection 字段删除 Proxy-ConnectionHTTPS 页面无法打开CONNECT 处理顺序错误看代理日志是否有 CONNECT 记录确保回复 200 后立即开双向转发不要提前 recv线程越来越多不释放recv 阻塞无超时ps -eLf看线程数给 socket 设置 settimeout孤立连接自动退出响应内容不完整响应长度判断错误用 curl 对比直连和代理响应大小使用 Connection: close 强制关闭靠 EOF 判断报文结束中文 URL 编码导致匹配失败请求头按 UTF-8 解码检查代码编码方式头部解析统一用 ISO-8859-15. 性能观察与调优经验5.1 小压力测试这个代理到底能扛多少并发写完之后我做了简单的压力测试。测试工具用 Python 自带的多线程发起 200 个并发请求目标是一个本地的简单接口同时对比了直连和走代理两种方式。结果是单个请求直连大约 5ms走代理大约 8ms多出来的 3ms 是代理转发消耗的时间合情合理。在并发 200 个请求全部走代理的情况下总耗时比直连慢大约 40%但所有请求没有失败全部正常返回。对于一个基础版代理来说这个表现完全可用。实际使用中瓶颈主要在目标服务器的响应速度和本机的文件描述符数量。5.2 控制最大连接数避免资源耗尽每个客户端连接对应一个线程而每个线程默认栈空间是 8MB 左右。虽然实际操作中线程栈是懒加载的不会立刻占满那么多物理内存但也不能无限开线程。如果想要给代理加上并发连接数限制一个简单有效的办法是用信号量import threading semaphore threading.BoundedSemaphore(200) def handle_client(client_sock, addr): with semaphore: # 原有的处理逻辑 pass这样超过 200 个并发时多余的连接会在 accept 后排队等待而不是无限创建线程能有效防止短时流量洪峰压垮进程。5.3 使用队列和线程池替换裸线程上面的版本是“每连接一线程”的经典模型。这个模型在连接数不多的时候非常灵活但连接数涨到几千时线程切换开销就会成为瓶颈。如果要继续优化建议改用ThreadPoolExecutor线程池。from concurrent.futures import ThreadPoolExecutor executor ThreadPoolExecutor(max_workers200) while True: client_sock, addr server.accept() executor.submit(handle_client, client_sock, addr)线程池的好处是线程创建和销毁的开销被摊销了连接来了直接丢给池子处理空闲线程最多是初始化时的数量。实际用下来的感受是对于 500 以内的并发量裸线程和线程池表现差别不大但是超过 1000 后线程池明显更稳定尤其不会出现高峰期瞬时创建一千个线程的极端情况。5.4 GIL 和网络 IO为什么多线程真的有用很多人说到 Python 多线程就条件反射地担心 GIL。这里要澄清一次GIL 机制限制的是同一进程内多个线程同时执行 CPU 密集型 Python 字节码但网络 IO 场景下recv和send在底层会释放 GIL线程进入等待状态时并不会把其他线程卡死。所以代理这种“IO 密集型”任务多线程提升并发度是真实有效的。真正的性能放大器有两个方向一是调整系统文件描述符上限二是合理利用连接复用。系统文件描述符上限可以用ulimit -n查看很多 Linux 系统默认只有 1024。如果代理同时处理大量连接很快会碰到 Too many open files 的错误。调高的方式ulimit -n 65535注意这个命令只对当前 shell 会话有效。想要永久生效需要改/etc/security/limits.conf这大家可以按自己的系统环境去查我不展开。6. 进一步扩展从能跑到好用6.1 给代理加日志和统计基础版代理跑通后第一个建议加的功能就是结构化日志。现在我的代码里只用了print看起来直观但生产环境里不好排查问题。可以用logging模块替代。更讲究一点的可以在内存里维护一个连接计数器记录累计处理请求数、当前并发数、传输总字节数甚至做一张简单的实时监控页面。日志记录一个关键点要把每个连接的产生、结束、异常都打上时间戳和客户端地址。这样出了问题才能快速定位是哪个连接、哪一步报的错。6.2 增加域名黑白名单与过滤能力这个扩展最实用尤其在做爬虫的时候。代理可以对目标域名做规则匹配命中黑名单的请求直接返回 403不在名单里的才继续转发。实现起来只需要在解析完目标主机之后加一层判断逻辑。BLACK_LIST [block.example.com] # 在 handle_client 中解析出 host 后 if any(host.endswith(domain) for domain in BLACK_LIST): client_sock.sendall(bHTTP/1.1 403 Forbidden\r\n\r\n) return更进一步还可以按请求方法、路径关键字、响应类型做条件过滤。代理之所以比客户端直接改代码要灵活就是因为所有流量都汇聚在一个点上规则只写一份就能对全部请求生效。6.3 改造成 asyncio 单线程高并发版本多线程版本能扛住几百并发但如果面对的是上万连接的长连接场景线程模型本身就会成为瓶颈。这个时候可以往 asyncio 方向发展把 socket 读写改成非阻塞。代理虽然不像 Web 服务那样适合 asyncio但用loop.sock_accept和loop.sock_recv做主动式轮询也能实现单线程支撑数千连接。不过我要说句实在话作为学习项目先把这个多线程版本跑透、跑明白再研究 asyncio 版本你会对 IO 模型有完全不同的理解。直接一上来就写异步代理容易在协程的边界条件里绕得头晕。6.4 与爬虫框架集成形成“代理池 调度”的能力写爬虫的人对这个场景应该最有共鸣。如果目标是请求大量不同网站或者同一个网站大量页面就会希望代理能支持动态切换出口。这时候代理服务器本身的实现反而不是重点重点是你能不能提供一个通用接口让爬虫代码动态指定代理地址。更高级的玩法是写一个简易代理池调度器用配置文件维护一组上游代理主代理收到请求后按轮询或随机策略选择上游代理去转发实现“代理的代理”。这种级联转发可以配合请求头X-Forwarded-For做链路追踪在调试和分析的时候特别好用。我自己在实际使用中的体会是代理这种网络基础组件看似不难但一旦要真正服务业务各种边缘情况会连环炸出来。C10K 级的高并发能力确实要上异步框架但如果只是团队内部联调、爬虫跑量、抓包看流量这个多线程版本已经绰绰有余。当前这套代码我一直在自己的本地开发环境里当作默认代理用稳定跑了很长时间没出过什么乱子。最后再分享一个小技巧调试代理的时候不要一开始就拿浏览器去试。先用 curl 加-v参数看完整交互过程一步一步验证普通 HTTP、CONNECT 隧道、响应回传确认每一层都正常再让浏览器接入。这样出了问题你能精准定位是协议解析的锅还是 socket 转发的锅而不是面对一个白屏页面无从下手。