Networking 101:写给程序员的网络入门指南
跟随一次 Web 请求理解 DNS、socket、路由、HTTP、TLS、代理与 TCP,并通过小实验把概念变得具体。
一个程序想和另一个程序通信
很多网络入门材料一上来就是一堆术语:IP、TCP、UDP、DNS、HTTP、TLS、NAT、socket。我觉得这样很难学。在记住一个答案之前,我得先理解:究竟是什么问题,让人们需要发明它?
所以,我们先从一个熟悉的例子开始:
curl https://example.com/index.htmlshcurl 是一个传输数据的命令行工具。这里,我们让它从一个网站获取资源。你在浏览器里输入 URL 时,浏览器也会做类似的事,然后再把结果展示出来。
从程序的角度看,任务似乎很简单:
我们的程序 <--> Web 服务器text但计算机怎么理解 example.com?字节如何到达那里?到达之后,该由哪个程序接收,那个程序又怎么知道我们想要什么?返回的结果可信吗?中途出错了怎么办?
这些问题构成了我们理解网络的一条路线:
| 问题 | 会用到的概念 |
|---|---|
| 我想和谁通信? | 名字、DNS、IP 地址、端口、socket |
| 我的字节怎么到达那里? | 路由、下一跳、本地链路 |
| 多个会话如何共享网络? | 封装、复用、解复用 |
| 接收方如何理解我的字节? | 协议、HTTP、消息边界 |
| 我能信任对端吗? | HTTPS、TLS、证书 |
| 流量经过中间人转发时,会发生什么? | SOCKS5、代理、NAT、VPN、CDN |
| 数据包丢失或乱序时怎么办? | TCP、UDP、缓冲、超时 |
只要有基本的编程知识,就可以继续读下去。文中的 shell 示例适用于 macOS、Linux 或 WSL 中的 Unix shell。有些实验需要 dig、nslookup、lsof、traceroute 或 nc(netcat),可以通过系统的包管理器安装。例如,Debian 和 Ubuntu 的 dnsutils 包提供了 dig 和 nslookup。本地实验还会用到 Python 3。
如果想结合图示阅读,也可以下载我的 Network Principles for Programmers 幻灯片(英文 PDF,100 页,2 MB)。
网络组成的网络
互联网连接着不同组织运营的网络:家庭、大学、公司、互联网服务提供商,等等。没有一个运营者管理着从你的电脑到远程服务器的整条路径。这就是互联网的联邦式结构(federated)。
这些网络还可以采用不同的技术。你的电脑可能通过 Wi-Fi 开始通信,之后的路程则经过以太网或光纤链路。我们需要一种共同的方式,把数据送过这些不同的网络。这就是 IP,也就是互联网协议的职责。
这个设计还带来了两个有用的概念:
- 端到端(end-to-end): 需要理解完整会话才能完成的工作,例如确认应用操作是否成功,应由端点负责。中间设备帮助传送流量,却无法提供应用需要的全部保证。
- 尽力而为(best effort): IP 不承诺数据一定送达、按顺序送达、不重复,或在固定时间内送达。上层按需补充相应的行为。
分层有什么用?
你可能见过七层 OSI 模型。它的实际价值是帮助我们区分职责。对这篇文章来说,一张更小的地图就够了:
| 层次 | 职责 | 例子 |
|---|---|---|
| 应用层 | 赋予数据含义 | HTTP、DNS、SOCKS5 |
| 传输层 | 在通信端点之间传递数据 | TCP、UDP |
| 网络层 | 跨网络寻址和转发数据包 | IP |
| 链路层 | 在本地链路上传递帧 | 以太网、Wi-Fi |
| 物理层 | 传输信号 | 铜线、光纤、无线电 |
OSI 还定义了会话层和表示层。在日常开发中,对应的工作经常由库和应用协议完成:维护登录会话、把数据编码成 JSON,或协商加密。理解这些职责很有帮助,但真实软件不一定整齐地分成七个盒子。
分层让 Web 应用可以运行在各种网络之上。路由器不必实现你的应用 API,也能转发 IP 包。后面每当分层能解释一个具体决策时,我们就会回到这张地图。
我想和谁通信?
我们的 URL 包含几部分信息:
https://example.com/index.html
| | |
协议方案 主机名 路径text协议方案选择了 HTTPS,其默认端口是 443。主机名给服务命名,路径标识该服务上的资源。这几部分会在通信的不同阶段发挥作用。
DNS:从名字找到地址
即使背后的机器发生变化,域名也可以保持不变。一个名字可以对应多个地址,一个地址也可以服务于多个名字。这就是名字和地址需要分开的原因。
**DNS(域名系统)**让程序能够查询与名字关联的记录:
| 记录类型 | 描述的内容 |
|---|---|
A | IPv4 地址 |
AAAA | IPv6 地址 |
CNAME | 另一个名字的别名 |
NS | 某个区域的权威域名服务器 |
MX | 某个域名的邮件服务器 |
TXT | 文本元数据 |
建立 Web 连接时,我们首先关心地址记录。试试看:
nslookup example.com
dig example.com A
dig example.com AAAAsh观察答案部分,以及是哪台服务器回答的。结果可能包含多个地址,也可能随着网络位置或时间而变化。教程里出现的地址,并不是那个域名永久不变的属性。
谁来回答 DNS 查询?
通常,你的机器会询问一个递归解析器(recursive resolver)。它可能由当前网络提供,也可能配置在系统或应用中。你的机器请求一个完整答案;解析器可以使用缓存,也可以沿着委派关系继续查找。
在没有可用缓存的情况下,一次查询大致如下:
你的机器 -> 递归解析器
|
+-> 根服务器:谁负责 .com?
+-> .com 服务器:谁负责 example.com?
+-> 权威服务器:它的 A 记录是什么?
|
你的机器 <- 地址答案text根服务器并不保存每个网站的地址。它把解析器引向 .com 这样的顶级域名服务器,后者再将解析器引向 example.com 的权威服务器。权威服务器保存着自己负责的那部分命名空间,也就是**区域(zone)**中的记录。
从你的机器看,请求是递归的:“请帮我找到答案。”解析器与其他服务器之间的交互通常是迭代的:“请给我答案,或者告诉我下一步问谁。”这是 DNS 解析模型 ↗中的职责划分。
你可以自己查看这些委派关系:
dig +trace example.com Ash这里是 dig 自己沿着委派关系查询,并不是在回放你平时使用的解析器做过什么。只想观察根服务器的回答,可以运行:
dig @a.root-servers.net example.com A +norecursesh寻找指向 .com 服务器的 NS 记录,通常还会附带帮助你联系这些服务器的地址记录。具体服务器名字可能变化。有些网络限制直接向外部服务器发送 DNS 查询,所以即使通过默认解析器查询正常,+trace 也可能失败。
缓存:为什么不用每次都从根开始?
DNS 记录有一个 TTL(time to live,生存时间),限制它通常可以被缓存多久。例如,下面是一个示意答案,并不是 example.com 的实际地址:
example.com. 300 IN A 203.0.113.10
|
TTL,单位为秒text解析器可以重复使用这条记录最多 300 秒,之后通常需要刷新。重复执行 dig example.com A,你可能看到缓存的剩余 TTL 逐渐减少。不过,共享解析器的内部结构和刷新行为,也会让数字不那么规律。
缓存解释了为什么修改 DNS 后,并非所有人都会立刻看到变化。不同查询也可以复用委派链中不同部分的缓存。
后面的示意图用 203.0.113.10 代表服务器。这是文档专用地址;运行命令时,请使用命令里的域名或回环地址。
IP 地址:数据包可以被路由到哪里?
IP 地址给了网络一个可以路由的目标。IPv4 地址有 32 位,IPv6 地址有 128 位:
IPv4: 203.0.113.10
IPv6: 2001:db8::10text“IP 标识一台机器”可以作为最初的理解。更准确地说,地址与网络接口相关,也可以代表虚拟服务或共享基础设施。一台机器可以有多个地址。
一个数据包携带源地址和目的地址。路由器根据目的地址做转发决策。普通的 IP 转发不需要知道最初使用的域名。
端口:那台机器上的哪个端点?
一台机器可以同时运行 Web 服务器、SSH 服务器和许多其他程序。光靠 IP 地址,操作系统还不知道该把数据交给哪个通信端点。
TCP 和 UDP 加入了端口:取值从 0 到 65535 的 16 位数字。常见约定包括:
| 服务 | 约定的服务器端口 |
|---|---|
| SSH | TCP 22 |
| DNS | UDP 或 TCP 53 |
| HTTP | TCP 80 |
| HTTPS | TCP 443;HTTP/3 使用 UDP 443 |
这些是约定,并不意味着操作系统会自动识别协议。向端口 80 发送任意字节,并不会让它们变成 HTTP。TCP 和 UDP 的端口空间也相互独立:TCP 53 和 UDP 53 可以对应不同的 socket。
客户端也有端口。客户端建立 TCP 连接时,操作系统通常会选择一个临时端口(ephemeral port):
电脑 Web 服务器
192.168.1.23:52001 ----------> 203.0.113.10:443
192.168.1.23:52001 <---------- 203.0.113.10:443text响应发回客户端的 52001 端口。浏览器访问 HTTPS 网站时,通常是连接到 443 端口,并不需要自己监听 443 端口。浏览器标签页也不是每个都分配一个固定端口;浏览器会管理连接,并在内部把数据关联到相应请求。
Socket:程序手中的句柄
Socket 是操作系统中表示一个通信端点的对象。在 Unix 类系统中,程序通过文件描述符访问它;编程语言的库通常会再把描述符包装成一个对象。
TCP 客户端创建 socket,将它连接到一个地址和端口,然后发送和接收字节。名字解析可能由库在连接前完成;底层连接操作使用的是地址。
TCP 服务器的初始化过程可以写成下面这段 Python:
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(("127.0.0.1", 9330))
server.listen()
conn, addr = server.accept()
# 使用 conn 读写;再次调用 accept() 接受下一个客户端。python这里有两个不同的 socket。server 是等待连接的监听 socket。accept() 返回的 conn 是面向某个特定对端的已连接 socket。监听 socket 仍然可以接受更多连接,而程序需要安排如何处理这些连接,例如使用线程或事件循环。
可以把它想成前台把每位来电者交给单独的接线员,自己继续接听新来电。我在 What Is a Socket? 中更详细地讨论了操作系统句柄;理解这里的区别,就足够继续看后面的例子。
绑定 127.0.0.1 会创建一个 IPv4 回环服务,可以从同一台机器访问。绑定 0.0.0.0 则监听所有本地 IPv4 地址;其他机器能否访问,仍然取决于路由和防火墙。0.0.0.0 是绑定时的通配地址,不是远程客户端应该连接的地址。
动手试试:找到 Socket 的所有者
在一个终端中,创建一个临时页面并启动服务:
network_demo_dir=$(mktemp -d)
printf 'Hello World!' > "$network_demo_dir/index.html"
python3 -m http.server 9330 --bind 127.0.0.1 --directory "$network_demo_dir"sh让这个终端继续运行。在另一个终端执行:
curl --noproxy '*' -v http://127.0.0.1:9330/index.html
lsof -nP -iTCP:9330sh响应正文应该是 Hello World!。--noproxy '*' 让 curl 在这次实验中直接连接,即使环境变量配置了代理。在 lsof 的结果中,寻找 Python 进程、它的 PID、FD 列中的 socket 描述符,以及 LISTEN 状态。
请求可能结束得太快,以至于你看不到 ESTABLISHED 状态。想保持一个连接,可以在第三个终端运行 nc 127.0.0.1 9330,先不输入请求,再执行一次 lsof。这时应该既能看到监听 socket,也能看到已接受的连接。用 Ctrl-C 关闭 nc。保留 Python 服务器,后面的 HTTP 和 TCP 实验还会用到它。
我的字节怎么到达那里?
现在有了端点,还需要一条连接它们的路径。
路由表选择下一跳
你的电脑通常不会计算出通往网站的每一台路由器。它查询自己的路由表,选择一个出站接口,并在需要时选择下一跳路由器。之后每台路由器都会重复这个决策。
电脑 -> 家庭路由器 -> ISP 路由器 -> ... -> 目标网络 -> 服务器text查看你自己的机器会采用什么路由:
# Linux / WSL
ip route get 1.1.1.1
# macOS
route -n get 1.1.1.1sh一个示意性的 Linux 结果是:
1.1.1.1 via 192.168.1.1 dev wlan0 src 192.168.1.23text意思是:使用源地址 192.168.1.23,通过 wlan0 发出,先把数据包交给 192.168.1.1。你的接口名和地址会不同,尤其是在 WSL 中或使用 VPN 时。
路由通常描述称为**前缀(prefix)**的地址范围。例如,192.168.1.0/24 覆盖从 192.168.1.0 到 192.168.1.255 的地址;/24 表示前 24 位固定。一张简单的路由表可能包含:
| 目的地址 | 发往哪里 |
|---|---|
192.168.1.0/24 | 直接在本地网络上发送 |
10.8.0.0/16 | 通过 VPN 接口发送 |
0.0.0.0/0 | 通过默认网关发送 |
在基于目的地址的路由中,最具体的匹配前缀优先。一个匹配的 /24 会优先于 /0 默认路由。默认网关是兜底选择,并不一定是所有非本地地址的下一跳。
有些路由来自直连网络或本地配置。路由器还可以通过路由协议学习可达性。关于路由表如何建立,我在 IP 与路由入门文章中有更详细的介绍。跟随一个数据包时,关键是理解:每台设备独立决定自己的下一跳。
本地投递:先到达下一跳
假设目的 IP 是 203.0.113.10,下一跳却是路由器 192.168.1.1。你的电脑怎样把包交给这台路由器?
在以太网链路上,它把 IP 包放进一个帧(frame),帧的目的地址填写路由器的 MAC 地址,也就是链路层地址。以太网和 Wi-Fi 都使用 MAC 地址进行本地投递,但帧格式不同。MAC 地址也不一定是永久不变的出厂身份,软件可以设置或随机化它。
IP 目的地址: 203.0.113.10 (远程服务器)
下一跳 IP: 192.168.1.1 (本地路由器)
以太网目的地址: 路由器的 MAC (这条链路上的接收者)textIPv4 使用 ARP 把链路上的 IP 地址映射到 MAC 地址:“谁拥有 192.168.1.1?”路由器回应,电脑缓存这个结果。IPv6 则使用**邻居发现(Neighbor Discovery)**完成这项工作。
查看本地邻居信息:
# Linux
ip neigh
# macOS:IPv4 ARP 缓存
arp -ash这里应该看到的是本地邻居,而不是互联网另一端服务器的 MAC 地址。如果目的主机就在本地链路上,帧会直接发给它,而不是网关。
在路由器处,入站的链路层封装被移除,IP 包再使用适合出站链路的封装转发出去。在不涉及地址转换的普通路由中,目的 IP 一直是服务器的地址,而本地投递地址会在经过路由的每一跳发生变化。
动手试试:观察一些跳数
traceroute example.comshTraceroute 发送 IP TTL 逐渐增加的探测包;IPv6 中对应字段叫 Hop Limit。路由器递减这个值,当它变为零时,路由器可以发回 ICMP Time Exceeded 响应。这些响应帮助我们发现中间的跳点。
这里的 TTL 限制包能经过多少跳,与控制缓存时间的 DNS TTL 不同。同一个缩写,在不同协议中有不同用途。
* 表示某次探测没有及时收到响应,不代表那台路由器一定无法转发流量。路由器可能过滤或限制回复,路径也可能变化,返回路径还可能与去程不同。Traceroute 提供的是关于这些探测包的证据,并不是所有后续 Web 请求必经路径的保证。
共享链路与尽力投递
来自不同会话的包共享链路和路由器队列,这就是分组交换(packet switching)。一个 TCP 连接不会在存在期间独占一条物理线缆。
共享会带来延迟和失败:繁忙的队列可能让包等待,也可能因溢出而丢包。包还可能乱序或重复。IP 不会替应用修复所有这些问题。等理解了应用究竟要交换什么,我们再讨论可靠性选择。
多个会话如何共享网络?
浏览器、编辑器、SSH 会话和视频通话可能都使用同一个 Wi-Fi 连接。**复用(multiplexing)**把它们的流量放到共享资源上;**解复用(demultiplexing)**则拆分收到的流量,把各部分交给正确的接收者。
让这一切成为可能的标签,就放在协议头部中。
封装:每一层加入自己的信息
对于通过 TCP 和以太网传输的简单 HTTP/1.1 交互,嵌套关系是这样的:
以太网帧
└── IP 包
└── TCP 段
└── HTTP 会话中的一部分字节text每一层把上层数据视为载荷,再加入自己的信息,这就是封装(encapsulation)。接收方逐层移除封装并解读对应字段。一条应用消息可以跨越多个包,所以这个嵌套关系并不意味着一个 TCP 段对应一个 HTTP 请求。
| 信息 | 帮助做出的决策 |
|---|---|
| 以太网目的 MAC | 本地链路上的哪个接收者? |
| 以太网 EtherType | 载荷是 IPv4、IPv6、ARP,还是其他协议? |
| IP 目的地址 | 是交给本机,还是继续转发到哪里? |
| IPv4 Protocol / IPv6 Next Header | 后面跟着什么,例如 TCP、UDP 或 ICMP? |
| TCP 或 UDP 的地址和端口 | 应该交给哪个传输端点? |
| 应用字段 | 涉及哪个网站、资源或逻辑请求? |
普通的 IP 转发决策不需要 HTTP 解析器。防火墙等中间设备也可能查看更多字段,所以分层描述的是职责,并不保证没有设备会检查更深层的内容。
一个服务器端口,多个 TCP 连接
Web 服务器可以在 443 端口接受成千上万个连接:
客户端 A 192.168.1.10:52001 -> 203.0.113.10:443
客户端 B 192.168.1.11:52002 -> 203.0.113.10:443
客户端 C 192.168.1.12:52003 -> 203.0.113.10:443text这里展示的是可能发生地址转换之前的端点。在 TCP 内,一个连接由四元组标识:
源 IP + 源端口 + 目的 IP + 目的端口text只有目的端口是不够的。每个已接受的 socket 都有特定的对端,即使它们共享服务器上的同一个本地端口。操作系统根据连接信息把数据交给对应的 socket。这属于传输层解复用。
跨协议描述流量时,工具通常会使用五元组,再加上传输协议。这样就能区分地址和端口数字相同的 TCP 流与 UDP 流。
一个连接里的多个请求
共享也会发生在 TCP 之上。HTTP/2 可以在一个 TCP 连接中承载多个应用流。它的帧包含流标识符,让客户端和服务器能够交错处理多个请求:
一个 TCP 连接
├── HTTP/2 流 1:/index.html
├── HTTP/2 流 3:/style.css
└── HTTP/2 流 5:/app.jstext操作系统把 TCP 字节流交给 socket。HTTP/2 实现解析帧,再根据流 ID 把数据关联到请求。这是两个不同的解复用决策。HTTP/2 规范描述了这种流模型 ↗。
所以,标签页、请求、socket 和 TCP 连接的数量并不需要相等。不同层次可以各自共享资源。
接收方如何理解我的字节?
把字节送到正确的 socket,只完成了通信的一部分。看看这段数据:
47 45 54 20 2f 20 48 54 54 50 2f 31 2e 31text这些十六进制值是 GET / HTTP/1.1 的 ASCII 编码。接收者需要一个共同约定,才能把它理解成请求,而不是任意字符串。
**协议(protocol)**提供这个约定:消息格式、字段含义、允许的交互过程,以及数据无效时应如何处理。
HTTP:方法、资源、元数据、正文
HTTP/1.1 的请求行和头部使用文本,很适合观察这种约定:
GET /index.html HTTP/1.1
Host: example.com
User-Agent: curl
Accept: */*
Connection: close
http第一行给出方法、请求目标和 HTTP 版本。GET 请求获取 /index.html 对应资源的一种表示。Host 头指定目标主机;多个网站共享一个地址时,它就很重要。其他头部携带元数据。
实际传输时,这些行以 \r\n 结尾,也就是回车和换行。一个空行标记头部结束。这个请求没有正文,其他请求则可以携带正文,例如提交给 API 的数据。HTTP/1.1 要求包含 Host,符合规范的服务器会拒绝缺少它的请求。这些语法规则定义在 HTTP/1.1 规范 ↗中。
消息在哪里结束?
下面是一个简单响应,正文恰好 12 字节,末尾没有额外换行:
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 12
Hello World!http空行结束头部,Content-Length 指明正文的字节数。接收者读完 Hello World!,就能判断这个正文完整了。
其他 HTTP 响应可能使用分块传输编码等不同的分帧规则,有些响应则没有正文。某些响应还可以用连接关闭来标记结束。关键是:消息边界由协议定义,既不是数据包边界,也不是一次 recv() 的返回边界。HTTP/1.1 明确规定了正文长度的判断规则 ↗。
动手试试:不用 HTTP 客户端也能说 HTTP
保持前面 socket 实验中的 Python 服务器运行,用 netcat 发送请求:
printf 'GET /index.html HTTP/1.1\r\nHost: localhost:9330\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 9330sh你应该看到状态行、包含 Content-Length: 12 的头部、一个空行,以及 Hello World!。Python 的基础服务器默认通常用 HTTP/1.0 回复,这里是正常现象。Connection: close 请求服务器在响应后关闭连接,让这个小实验有明确的终点。
把 /index.html 改成 /missing 再试一次。连接依然成功,但响应应该变成 404 Not Found:传输已经完成,应用告诉你请求的资源不存在。
也可以向 example.com 的 80 端口发送同样形式的请求,将 Host 改成 example.com。公网服务器可能返回重定向或其他内容。去掉 Host 可以测试服务器对 HTTP/1.1 的校验;宽松的教学服务器可能仍然接受请求,即使规范要求拒绝。
无状态协议如何记住你?
HTTP 本身不会把一系列请求自动识别为同一个已登录用户的操作。应用可以通过 cookie 或令牌建立这种联系:
Cookie: session_id=abc123http服务器可能用这个值查找保存在其他地方的会话状态。另一个应用则可能要求 Authorization: Bearer ... 头,并验证其中的令牌。
这里又出现了一种名字:应用层的用户或会话身份。它与 IP 地址、TCP 连接的生命周期相互独立。新建一个连接不一定会让你退出登录,共享一个 IP 地址也不会让两个人变成同一个用户。
我能信任对端吗?
我们已经能到达服务,并交换有意义的请求。但收到一个答案,并不能证明它来自预期的服务。
明文 HTTP 没有提供密码学保护,无法阻止路径上的中间人读取或修改内容。HTTPS 使用 **TLS(传输层安全协议)**保护 HTTP。对于这里讨论的 HTTP/1.1 和 HTTP/2 连接,关系是:
HTTP -> TLS -> TCP -> IP -> 本地链路textTLS 提供三个不同的属性:
| 属性 | 回答的问题 |
|---|---|
| 机密性 | 外部观察者能读到受保护的数据吗? |
| 完整性 | 外部参与者能不被发现地篡改数据吗? |
| 身份认证 | 对端是我原本打算联系的那个身份吗? |
只有加密还不够。连接到冒充者的加密连接,仍然把数据送给了错误的人。TLS 将数据保护与对端认证结合起来;浏览网页时通常认证服务器,客户端证书认证则是可选的。参见 TLS 的安全模型 ↗。
证书把名字与密钥联系起来
证书将公钥与某个身份,例如域名,关联起来。正常建立 HTTPS 连接时,客户端会检查:
- 证书是否覆盖它原本想访问的主机名。
- 证书是否处于有效期内。
- 证书链是否能够连接到该客户端信任的根证书。
- 握手是否证明对端持有相应的私钥。
受信任根证书来自客户端的信任配置,可能由操作系统、浏览器或应用提供。证书颁发机构,也就是 CA,参与签发构成证书链的证书。
DNS 帮助找到连接地址,证书验证则在安全连接中认证预期的主机名。即使 DNS 把你指向别处,也不会仅凭这一点就让那个目的地拥有客户端愿意接受的、适用于原主机名的证书。
这种认证不证明网站内容或经营行为诚实;它是在客户端的信任模型下确认对端身份。
动手试试:比较 HTTP 和 HTTPS
curl --noproxy '*' -v --max-time 15 http://example.com/
curl --noproxy '*' -v --max-time 15 https://example.com/sh在详细输出中,两者都可以观察地址解析和连接建立。HTTPS 的输出还可以看到 TLS 协商和证书验证信息。具体文字取决于 curl 的 TLS 后端和版本。Curl 可能协商使用 HTTP/2;如果想与前面的文本例子保持一致,可以加上 --http1.1。
Curl 能显示请求和响应,是因为它本身就是端点,能够访问明文。在 curl 输出中看到这些文字,并不意味着网络上的观察者也能读到。
中间人什么时候能检查 HTTPS?
普通路由器或转发程序无需解密 HTTP,也能转发加密流量。它仍然可以观察地址、时序和数据大小等元信息,也可以干扰投递。
HTTPS 检查代理则会在两侧分别终止 TLS:
客户端 <== TLS 连接 1 ==> 检查代理 <== TLS 连接 2 ==> 服务器text代理解密客户端的数据,可以查看或修改,再为另一条连接加密。调试工具通常通过让客户端信任它的 CA 来做到这一点:它可以签发客户端会接受的证书。凭据泄露或验证机制失效也可能破坏认证;安装 CA 是一种机制,并不是唯一的失败方式。
应该追问的是:经过认证的 TLS 连接在哪里结束?一个设备位于传输路径上,并不自动意味着它是 TLS 端点。
流量经过中间人转发时,会发生什么?
许多连接使用间接通信(indirection):借助中间设备到达目标或提供服务。这样可以访问另一个网络、分配负载、执行策略,或返回缓存内容。
正向代理把这个额外参与者明确地放在图中:
客户端 <--> 代理 <--> 目标服务器textSOCKS5:“请替我连接”
SOCKS5 代理实现了一种让客户端请求网络操作的协议。对于其中的 TCP CONNECT 操作,过程如下:
- 客户端与代理建立 TCP 连接。
- 双方协商认证方法,并在需要时完成认证。
- 客户端提供目标地址和端口。
- 代理连接目标,报告成功或失败。
- 成功后,代理在两个方向转发数据。
目标可以用 IPv4 地址、IPv6 地址或域名表示。使用域名时,由代理一侧完成解析。SOCKS5 还定义了 UDP 关联等其他操作;这里讨论的是 TCP 转发。这些操作定义在 SOCKS 第五版规范 ↗中。
现在有两个 TCP 连接:
客户端:52001 <--> 代理:1080
代理:53002 <--> 目标:443text服务器看到的 TCP 对端是代理发起的出站连接,途中还可能发生进一步的地址转换。代理把接受到的客户端 socket 与连接目标的出站 socket 配成一对。服务多个客户端时,正确维护这些配对关系,是操作系统完成 socket 解复用之后,应用层仍要承担的职责。
SOCKS5 转发与 HTTPS 检查代理
通过普通 SOCKS5 TCP 代理传输 HTTPS 时,TCP 和 TLS 的边界不同:
TCP:客户端 <----> SOCKS5 代理 <----> 服务器
TLS:客户端 <=====================> 服务器text客户端通过代理与目标进行 TLS 握手,并验证目标的证书。代理复制的是加密字节。把 TCP 分成两个连接,并不自动把 TLS 也分成两个会话。
如果使用的是检查代理,TLS 的关系就变成:
TLS:客户端 <====> 检查代理 <====> 服务器text这个区别解释了为什么加上 SOCKS5 代理,并不会自动让代理读到 HTTPS 请求。它能看到请求的目的地和转发元信息,却不能直接解密 HTTP 内容。SOCKS5 本身也不会为普通转发流量提供加密:如果没有其他保护,明文 HTTP 仍然是明文。
动手试试:选择由谁解析域名
如果你已经有一个 SOCKS5 代理监听在 127.0.0.1:1080,可以比较:
# 直接连接
curl --noproxy '*' -v --max-time 15 https://example.com/
# 通过 SOCKS5,由 curl 在本地解析目标域名
curl --noproxy '' --socks5 127.0.0.1:1080 -v --max-time 15 https://example.com/
# 通过 SOCKS5,由代理解析目标域名
curl --noproxy '' --socks5-hostname 127.0.0.1:1080 -v --max-time 15 https://example.com/sh这些命令使用已经运行的代理,并不会启动代理。如果该地址没有监听服务,连接代理这一步就会失败。
观察最初到 1080 端口的连接、SOCKS 协商,以及之后与目标进行的 TLS 交互。--noproxy '' 清空这些实验中的代理绕过规则。Curl 文档解释了 --socks5 和 --socks5-hostname ↗ 在域名解析位置上的区别。
改变解析位置,可能影响可达性,以及哪个解析器能观察到查询。但在这两种代理方式下,HTTPS 原本要认证的身份仍然是 example.com。
其他间接通信形式
这些系统改变了路径中的不同部分:
| 机制 | 做了什么 |
|---|---|
| NAT | 改写 IP 地址,通常也改写端口,让多个私有网络客户端共享公网地址 |
| VPN | 把选定的流量通过隧道送到另一个网络或出口 |
| CDN | 从分布式边缘服务器提供网站内容,通常会缓存源站响应 |
| 负载均衡器 | 把流量分配给后端服务器;可以转发包,也可以终止连接 |
| 反向代理 | 代表服务接受请求,再转发给后端服务器 |
它们并不都等同于 SOCKS5 转发。普通 NAT 转换数据包,却不成为 TCP 端点。VPN 可以隧道化 IP 包,而不终止其中的 TCP 连接;分流 VPN 只承载选定路由的流量。CDN 则可能作为网站授权的端点,有意终止 TLS。
理解一个具体部署时,画出实际连接,并标注 TCP、TLS 和应用协议在哪里结束。这比把所有中间的盒子都叫作“代理”更有帮助。
数据包丢失、乱序或数据被拆分时怎么办?
到目前为止,我们描述会话时,好像字节总能顺利到达。但底层网络仍然只提供尽力投递。应用需要选择自己想要哪些保证,以及哪些失败由自己处理。
TCP:可靠、有序的字节流
TCP 在端点之间提供可靠、有序的字节流。几个机制一起完成这件事:
| 机制 | 用途 |
|---|---|
| 序列号 | 跟踪字节在流中的位置 |
| 确认 | 报告已经收到的数据 |
| 重传 | 恢复被认为已经丢失的数据 |
| 流量控制 | 限制发送量,使其不超出接收方的承受能力 |
| 拥塞控制 | 根据网络状况调整发送行为 |
流量控制和拥塞控制解决的是不同的瓶颈:接收端,以及网络路径。TCP 处理重传和排序,但连接仍然可能失败。它不承诺每次传输尝试最终都会成功。TCP 规范 ↗描述了这些职责。
写入操作不是消息边界
假设发送者成功写入这些字节:
sock.sendall(b"hello")
sock.sendall(b"world")python接收者可能看到:
recv() -> b"helloworld"text也可能看到:
recv() -> b"hel"
recv() -> b"lowor"
recv() -> b"ld"text两种情况下,把收到的字节拼起来都是 helloworld。TCP 保留的是顺序,不是发送方调用的边界。接收缓冲区大小只是这次调用最多读取多少字节,并不是要求读取一条多长的消息。
这也解释了前面 HTTP 正文长度规则的重要性。接收者需要一个累积字节并识别完整消息的解析器。常见方案包括固定长度、分隔符和长度前缀:
[ 长度 = 5 ][ hello ][ 长度 = 5 ][ world ]text连长度字段本身都可能跨越多次读取。程序要保留不完整数据,等收到足够字节后,再解析下一部分。SOCKS5 问候消息、TLS 记录和 HTTP 响应,都在 TCP 字节流之上定义了各自的分帧规则。
动手试试:每次只读十个字节
保持本地 Python HTTP 服务器运行,把下面的代码保存为 read_stream.py,再执行 python3 read_stream.py:
import socket
request = (
b"GET /index.html HTTP/1.1\r\n"
b"Host: localhost:9330\r\n"
b"Connection: close\r\n"
b"\r\n"
)
with socket.create_connection(("127.0.0.1", 9330), timeout=5) as sock:
sock.sendall(request)
chunks = []
while True:
data = sock.recv(10)
if not data:
break
print(repr(data))
chunks.append(data)
print("\nReassembled response:")
print(b"".join(chunks).decode("utf-8"))python每次打印的片段最多十字节。一行状态、一条头部或一段正文,都可能跨越多次读取。把片段拼起来,就重建了响应。这些分块边界来自应用读取操作,不是在观察网络数据包的边界。
sendall() 替这个小型阻塞示例处理部分写入。recv(10) 可以返回不足十字节;返回 b"" 表示缓冲中的数据读完后,对端已经关闭其发送方向。超时设置避免示例无限等待。这些行为记录在 Python socket API 文档 ↗中。
这里一直读到连接关闭,是因为请求明确要求服务器关闭连接。通用 HTTP 客户端必须解析 HTTP 分帧,不能假设每个响应都以断开连接结束。现在可以用 Ctrl-C 停止本地服务器了。
包、段和消息有不同的边界
链路有一个 MTU(最大传输单元),限制它能承载的数据包大小。普通以太网的 IP MTU 经常是 1500 字节,其中包含 IP 头部。TCP 根据路径把字节流划分成合适大小的段,载荷需要给相关头部留出空间。
这种分段与 IP 分片不同,后者拆分的是一个 IP 包。IPv4 可以允许路由器执行分片,而 IPv6 路由器不执行分片。这两种机制都不定义应用所看到的消息。
要区分三个单位:IP 传递包,TCP 暴露字节流,应用协议定义消息。一条消息可以跨越许多包,一次读取也可以包含多条消息的字节。
UDP:保留数据报边界,提供更少保证
UDP 传递数据报(datagram)。收到的数据报会保留各自的边界,但 UDP 没有内置的连接握手、重传或顺序保证。应用可以对 UDP socket 调用 connect() 来指定对端,不过这不会建立 TCP 式握手或可靠连接。
接收缓冲区必须足够大:在常见的 socket API 中,缓冲区太小可能导致数据报被截断。应用也不能假设任意大的数据报都能顺利通过网络路径。
合适的选择取决于应用需求:
| 需求 | 可能采用的方式 |
|---|---|
| SSH 或文件传输需要有序字节流 | TCP |
| 简短的 DNS 查询与响应 | 经常使用 UDP,由应用重试;DNS 也使用 TCP 和加密传输 |
| 及时送达的媒体或游戏更新 | 经常使用基于 UDP 的协议,自行处理丢失与时序 |
| 不通过 TCP 提供可靠流 | 在 UDP 之上构建传输协议,例如 QUIC |
使用 UDP 不意味着应用必须放弃可靠性。QUIC 在 UDP 之上构建安全、可靠的流 ↗,HTTP/3 就使用这种传输。因此,“Web 一定使用 TCP”和“UDP 应用一定不可靠”都只是过度简化。
程序还需要自己决定什么?
即使使用 TCP,程序仍然要处理超时、部分读取、写入失败、关闭和连接重置。成功写入 socket,只说明本地网络栈接受了字节,不证明远程应用已经处理请求。即使收到 TCP 确认,也不等于收到应用层的成功响应。
假设你提交一个操作,但在响应到来之前连接断开。服务器可能已经完成操作,也可能根本没收到。设计重试策略时,必须考虑重复执行是否安全,可能需要应用定义的请求标识符来避免重复工作。
代理还需要处理背压(backpressure)。如果代理持续从快速客户端读取,而目标接收得很慢,无限缓存最终会耗尽内存。它需要限制缓冲区大小,并在出站端跟不上时暂停读取。转发字节不仅要配对 socket,也要管理速率和生命周期。
从头到尾跟随一次请求
回到最初的命令。为了明确路径,我们直接请求 HTTP/1.1:
curl --noproxy '*' --http1.1 -v --max-time 15 https://example.com/index.htmlsh对于一次全新的连接,在没有现成连接可复用时:
- Curl 解析 URL:HTTPS、主机名
example.com、默认端口 443、路径/index.html。 - 名字解析提供候选地址,也可能直接使用缓存。Curl 选择一个地址尝试连接。
- 操作系统选择源地址、本地端口、出站接口和路由。在本地以太网或 Wi-Fi 链路上,如果下一跳链路地址尚未缓存,邻居解析会补齐它。
- TCP 建立连接。相关数据包经过本地投递和逐跳路由;远程操作系统把连接关联到服务器的监听端点。
- Curl 与对端协商 TLS,并根据预期主机名和信任配置验证证书。
- Curl 在 TLS 中发送 HTTP 请求。服务器读取请求,根据主机、路径、方法和其他字段决定如何响应。
- 响应经网络返回。TCP 按顺序提供字节,TLS 验证并解密,HTTP 解析则区分头部和正文,并判断响应是否完整。
- Curl 展示结果。连接可以根据协议和客户端行为关闭或被复用。
缓存、连接复用、代理和 HTTP/3 会改变其中一些步骤。只要找出发生变化的是哪项职责,就可以继续分析。
这也给了我们排查问题的方法:
| 观察到的现象 | 接下来检查什么 |
|---|---|
| 域名查询失败 | 解析器配置、DNS 答案、解析器可达性 |
| 连接被拒绝 | 目标地址和端口、监听服务、防火墙是否主动拒绝 |
| 连接超时 | 路由、过滤、可达性、服务器状态;没有回应本身不足以定位原因 |
| TLS 验证失败 | 预期主机名、证书链、有效期、客户端信任配置 |
HTTP 返回 404 | 请求的主机和路径、应用路由 |
| 程序收到部分字节后一直等待 | 消息分帧、缓冲逻辑,是否错误地等待关闭或假设一次读取包含完整消息 |
| 代理只有使用远程 DNS 时才能工作 | 客户端和代理端在解析结果或可达性上的差异 |
当我在网络问题中迷路时,我会回到那个正在等待的程序:它已经知道什么,想完成什么,下一块信息应该由哪一层提供?带着这个问题去观察一次查询、一个 socket 或一条请求,那些术语就容易用起来了。