自己数字节:用 mitmproxy 核对代理用量表
自己动手核对代理用量表:把 mitmproxy 串在代理前面,逐条记录请求和响应字节数,再看清差距里哪些来自 TLS、gzip 压缩和重试。
想自己核对代理的用量表,办法是把 mitmproxy 放在客户端和代理之间,用一个很短的插件把每个请求、每个响应的大小写进 CSV 文件,再拿这份文件去对服务商的逐条请求日志。访问普通 http:// 网站时,两边的数字应该一个字节都不差;访问 https:// 网站时,两边不可能完全一致,但差多少、差在哪里,是可以事先算出来的。
怎么核对你的代理服务商用量表是否诚实讲的是道理:一个你没法还原的用量数字只是说法,不是账单。这一篇是动手的那一半:要敲的命令、我们实际跑出来的数字,以及这些数字能证明什么、不能证明什么。
需要准备什么
- Docker,以及官方镜像
mitmproxy/mitmproxy:12.1.2,这是我们测试用的版本。 - curl,或者你真正想核对的那个脚本。
- 一套 HTTP 协议的代理登录信息(
HOST、PORT、USERNAME、PASSWORD)。mitmproxy 的上游模式只会用 HTTP 和下一级代理通信:用--mode upstream:socks5://…启动会立刻报错Invalid server scheme: socks5。住宅代理的端口本来就接受 HTTP;ISP 和数据中心服务请在测试期间到控制台把协议切换成 HTTP。
我们是怎么测的
为了同时看到用量表两边的数字,我们自己搭了一个代理坐在服务商的位置上:带登录的 Squid 6.13,它会记下每条连接的请求字节和响应字节。它后面是两个本地测试网站:一个 httpbin 的复刻(go-httpbin 2.25.0),一个开了 gzip 的 nginx 1.27。全部跑在同一台机器的容器里,没有用到任何外部服务商。下文说的“用量表”,指的就是这份 Squid 日志。真实服务商的数字在细节上会不一样,原因见后文。
第一步:计数插件
mitmproxy 会对经过它的每个请求运行 Python 插件。把下面的代码存为 count_bytes.py:
import csv
from mitmproxy import ctx, http
from mitmproxy.net.http.http1.assemble import assemble_request_head, assemble_response_head
class CountBytes:
def __init__(self):
self.file = open("bytes.csv", "w", newline="")
self.out = csv.writer(self.file)
self.out.writerow(["host", "path", "status", "sent", "received", "body_on_wire", "body_decoded"])
self.sent = self.received = 0
def write(self, row):
self.out.writerow(row)
self.file.flush()
self.sent += row[3]
self.received += row[4]
def response(self, flow: http.HTTPFlow):
sent = len(assemble_request_head(flow.request)) + len(flow.request.raw_content or b"")
body = len(flow.response.raw_content or b"")
decoded = len(flow.response.get_content(strict=False) or b"")
received = len(assemble_response_head(flow.response)) + body
self.write([flow.request.host, flow.request.path, flow.response.status_code, sent, received, body, decoded])
def error(self, flow: http.HTTPFlow):
self.write([flow.request.host, flow.request.path, "failed", 0, 0, 0, 0])
def done(self):
ctx.log.info(f"sent {self.sent} bytes, received {self.received} bytes")
self.file.close()
addons = [CountBytes()]
received 是状态行、响应头,加上线路上实际传输的正文,压缩过就按压缩后的大小算。body_decoded 是同一份正文解压之后的大小。单独列出来,是因为把这两个数弄混,是自己核对时最常见的出错方式。没有收到任何响应的请求,会记成 failed,各列都是零。
第二步:把 mitmproxy 串到代理前面
mkdir -p ~/.mitmproxy
docker run --rm -it -p 127.0.0.1:8080:8080 \
-v "$PWD":/work -w /work \
-v ~/.mitmproxy:/home/mitmproxy/.mitmproxy \
mitmproxy/mitmproxy:12.1.2 \
mitmdump --mode upstream:http://HOST:PORT --upstream-auth USERNAME:PASSWORD \
-s count_bytes.py --set http2=false
--mode upstream: 让 mitmproxy 不自己去连网站,而是把所有流量转给你的代理;--upstream-auth 负责发送登录信息,所以客户端那边不用再填代理密码。--set http2=false 让所有交互都走 HTTP/1.1,头部就是可以直接数的纯文本。端口只绑定在 127.0.0.1 上,局域网里的其他设备用不了它。我们在实验环境里还加了 --set ssl_verify_upstream_trusted_ca=,指向测试网站的自签名证书;换成真实网站时,不要加这一项。
第一次启动时,mitmproxy 会在 ~/.mitmproxy 里生成自己的证书颁发机构(CA)。客户端必须信任它,mitmproxy 才看得到 https:// 流量的内容;否则客户端会拒绝连接,curl 报的是 curl: (60) SSL certificate problem: unable to get local issuer certificate。
第三步:让流量经过它
curl -x http://127.0.0.1:8080 --cacert ~/.mitmproxy/mitmproxy-ca-cert.pem \
-o /dev/null https://httpbin.org/bytes/1000
用 requests 写的 Python 脚本,设两个环境变量就行,我们用 requests 2.32.5 验证过:
HTTPS_PROXY=http://127.0.0.1:8080 \
REQUESTS_CA_BUNDLE=~/.mitmproxy/mitmproxy-ca-cert.pem \
python3 your_script.py
按 Ctrl+C 停掉 mitmdump,插件会打印总数,我们那次是 sent 543 bytes, received 13729 bytes,旁边就是 bytes.csv。下面就是那一次的文件,实验里的 origin 是 httpbin 复刻,shop 是 nginx:
host,path,status,sent,received,body_on_wire,body_decoded
origin,/bytes/1000,200,84,1190,1000,1000
shop,/catalogue.html,200,123,11721,11470,182045
origin,/status/429,429,84,203,0,0
origin,/status/503,503,84,205,0,0
origin,/status/503,503,84,205,0,0
origin,/status/503,503,84,205,0,0
nowhere.invalid,/,failed,0,0,0,0
如果上游的登录信息不对,mitmdump 会记下 Upstream proxy HOST:PORT refused HTTP CONNECT request: 407 Proxy Authentication Required,curl 则从 mitmproxy 那里拿到一个 502。常见原因见解决 407 错误。
第四步:和服务商的日志逐行对照
导出同一时间段的逐条请求日志,按时间和主机把两边的行对上,把你的 received 列和对方的字节数列放在一起看。对照期间最好一个请求用一条连接:分开执行的每条 curl 命令都会新开连接,这样你的一行正好对着对方的一行。
这些数字能证明什么
普通 http:一个字节都不差
访问 http://plain:8080/bytes/1000 时,我们的 CSV 记下发送 179 字节、接收 1,294 字节,用量表记的也是 179 和 1,294。能读懂明文 HTTP 的代理,看到的和你看到的完全一样,连它自己加上的头部也算在内(我们的 Squid 加了 Via 和 Cache-Status,这两个头同样到达了 mitmproxy)。这里要是出现实打实的差距,就值得发邮件去问。
https:用量表数的是隧道
访问 https:// 网站时,客户端请代理打开一条 CONNECT 隧道,隧道里的一切都是加密的。代理处的用量表只能数穿过隧道的东西:TLS 握手、网站证书、每个加密记录上那几个字节的封装,再加上头部和正文。mitmproxy 会解密,所以它只数隧道里面的 HTTP。实际结果如下:
| 抓取内容 | mitmproxy 的 received |
用量表:隧道回传字节 |
|---|---|---|
| 1 个 1,000 字节的响应,新连接 | 1,190 | 3,858 |
| 同样 5 个,复用同一条长连接 | 5,950 | 8,706 |
| 10 个 100,000 字节的响应,同一条连接 | 1,001,920 | 1,006,216 |
| 同样 10 个,每个都新开连接 | 1,001,920 | 1,031,020 |
每条连接的握手大约多出 2.7 KB,而我们的测试网站只发了一张很小的自签名证书。真实网站发的是证书链,常常有好几 KB,所以实际会更多。很小的响应走新连接时,被计入的大头是握手;大响应走复用的连接,差距降到了 0.4%;每个 100 KB 的响应都新开连接,差距是 2.9%。还要留意,5 个请求在用量表里只占一行隧道记录:如果日志的一行代表一条连接,那一行里可能装着你好几个页面。
压缩:数线路上走过的字节
测试用的商品目录页是 182,045 字节的 HTML,gzip 压缩后是 11,470 字节。Python requests 默认就会请求 gzip,并把解压后的正文交给你,于是 len(r.content) 打印出 182,045,而用量表看到的这次传输是 13,732 字节。请数 body_on_wire,或者在测量期间设置 Accept-Encoding: identity。
重试也是流量
用 curl --retry 2 请求一个返回 503 的地址,一共发出 3 个请求,CSV 里是 3 行,而这 3 次都走同一条隧道,用量表记成一行 3,305 字节。网站每一次都回应了,所以每一次都花了流量。处理封锁和重试,别白白烧掉代理流量一文讲了怎样避免为同一个拒绝付两次钱。
网站报错不等于连接失败
那个 429 回来时,是 2,849 字节隧道里装着的 203 字节 HTTP:网站作了回应,所以算流量。一个根本不存在的主机,在 CSV 里留下一行全是零的 failed,用量表也记下了这次尝试,但没有从任何网站收到任何东西。
这些数字证明不了什么
- mitmproxy 会改变它测量的连接。 它会自己和网站建立 TLS 会话,所以用量表数到的握手是 mitmproxy 的,不是你的客户端的,网站看到的也是 mitmproxy 的 TLS 指纹。请拿
httpbin.org这类中立的地址来核对,不要拿你真正要抓取的网站。 - HTTP/2 的头部更小。 我们关掉了 HTTP/2,头部按 HTTP/1.1 文本计算。走 HTTP/2 时头部在线路上是压缩过的,插件会把它们算多。
- 它只看得到经过它的流量。 另一个程序用同一套代理登录信息产生的流量,会出现在服务商的日志里,却不会出现在你的文件里。
- 它看不到服务商内部。 本地计数只能给出你应该预期的范围。值得提出来的,是普通 http 上的明显差距,或者 https 长连接大流量传输中的明显差距。
不想写脚本:Charles 和 Fiddler
更习惯图形界面的话,Charles 和 Fiddler Everywhere 都能逐条列出请求和大小,也都能把流量转给另一个代理。Charles 里这项功能叫 External Proxies,HTTP、HTTPS 和 SOCKS 可以分别设置,支持 Basic 登录。Fiddler Everywhere 放在 Gateway 下的 Manual proxy configuration;它的文档没有提到给这个上游填写登录信息,所以建议搭配 IP 白名单使用。上面的对照方法照样适用,证书那一步也一样:两者都要先信任各自的根证书,才能显示 https:// 流量。
快速问答
不靠服务商,能核对代理用量表吗? 你能量自己这一侧。要拿去对照,就需要对方的逐条请求日志,所以买之前就值得问一句有没有。
为什么 https 下服务商的数字比我的大? 对方的用量表数的是加密隧道,握手和封装都算在内;mitmproxy 数的是隧道里的 HTTP。响应越小、越是新连接,差距越大。
429 或封锁页算不算流量? 只要用量表数的是网站发来的字节,就算:那些字节确实是网站发的。
ProxyPanda 的用量表是怎么写明的
我们的实话实说页写得很清楚:计费的是响应正文加响应头,在我们的代理处测量,请求字节不收费。在 https 隧道里,我们的代理看不到网站的状态码,所以用量表数的是穿过隧道的字节。超时、连接重置和我们自己网关的错误计零,并照样留在日志里标为免费,整份日志可以导出成 CSV。如果你数的和我们数的相差超出那里写明的容差,把两个数字都发给我们,我们先按你的数字重算,再去查原因。用量表那篇文章列出了值得问任何服务商的几个问题,价格页写着这些字节要乘上的单价。
下一步
照上面的方法启动 mitmdump,在一条 curl 命令里连续抓取 10 次 https://httpbin.org/bytes/100000,这样会复用同一条连接;再分 10 条命令各抓一次。把两组总数和服务商同一时间段的日志放在一起:第一组应该很接近,第二组则告诉你在这个网络上一次握手要花多少。数字看不明白的话,把 CSV 删掉登录信息后发到 Discord。