jimyag's Blog

用 netcat 分层检查 TCP、HTTP 与 TLS 连接

最后更新:

一个服务连不上,先区分“TCP 连接建不起来”和“连上了但应用不给正确响应”。nc(netcat)既能只尝试建立连接,也能在连接上收发原始数据。本文使用 Debian 上的 netcat-openbsd 语法;BusyBox、传统 netcat 和 Nmap Ncat 的参数并不完全相同,先用 nc -h 核对本机版本。TCP 通了之后,再用 nc 手写 HTTP 请求,用 OpenSSL 和 curl 检查 TLS,一层层往上排除。

先复现一次本机连接

在终端 A 只监听回环地址,避免把实验端口暴露到局域网:

1
nc -l 127.0.0.1 9009
  • -l(listen,监听):等待客户端连接。不加 -l 时,nc 默认作为客户端,连接后面指定的地址和端口。
  • 127.0.0.1:本机 IPv4 回环地址。
  • 9009:监听端口。

括号中的英文用于助记,不代表这些短选项都有对应的长选项。本文没有使用 -u(UDP),因此默认使用 TCP;开头的 nc -h(help,帮助)则是查看帮助。

在终端 B 写一行,再对发送方向做半关闭:

1
printf 'hello\n' | nc -N -w 2 127.0.0.1 9009
  • printf 'hello\n':输出 hello 和一个换行。
  • |:把左侧命令的输出交给 nc 的标准输入。
  • -N:OpenBSD nc 的选项,标准输入读到 EOF(End Of File,输入结束)后关闭 socket 的写入方向,仍可读取对端发送的内容。手册没有给出 N 的英文全称,可按行为记为 shutdown after EOF。
  • -w 2(wait,等待):把连接建立和连接空闲的超时设为 2 秒,并不保证命令总共只运行 2 秒;监听模式下它也不限制等待客户端的时间。

终端 A 会看到 hello;这个单次监听没有使用 -k,会话结束后退出。Debian 12 容器中,这条发送命令和随后关闭端口时的拒绝连接均已验证。

检查目标端口

1
nc -zv -w 2 127.0.0.1 9009

-zv 是 -z -v 的合写:

  • -z(zero-I/O,零应用数据收发助记):只检查能不能建立连接,不发送应用数据;TCP 握手仍然会发包。
  • -v(verbose,详细输出):显示连接成功或失败的诊断信息。
  • -w 2:超时含义同上。

先重新启动终端 A 的监听可观察成功,关闭后可观察 Connection refused。一次成功只证明从该客户端可以和目标地址、端口建立 TCP 连接。拒绝通常表示目标没有监听或中间设备主动拒绝;超时可能是流量被丢弃、目标不可达或回包路径有问题。单凭一个结果不能定位到具体防火墙。也不要先用 -z 检查一次性实验监听,再以为下一条发送命令还能连上同一个监听进程。

端口正确,但监听地址不对

另开一个终端,用 -k 保留监听,便于连续测试。这里显式只监听 IPv4 回环:

1
nc -4 -k -l 127.0.0.1 9009
  • -4:强制使用 IPv4。
  • -6:强制使用 IPv6,用于下面的 IPv6 检查。
  • -k(keep listening,保持监听):与 -l 配合,让 nc 在一次连接结束后继续接受后续连接。

IPv6 的回环地址是 ::1,对应 IPv4 的 127.0.0.1。

从同一台机器分别检查 IPv4 和 IPv6,再看内核的监听地址:

1
2
3
nc -4 -zv -w 2 127.0.0.1 9009
nc -6 -zv -w 2 ::1 9009
ss -ltn 'sport = :9009'

ss 用来查看 socket 状态。这里的 -ltn 合并了三个选项,后面跟一个过滤表达式:

  • -l / --listening(listening,监听中):只显示监听中的 socket。
  • -t / --tcp:只看 TCP。
  • -n / --numeric(numeric,数字形式):用数字显示地址和端口,不解析成主机名或服务名。
  • 'sport = :9009':只保留本地端口为 9009 的条目;引号让 shell 把整个表达式作为一个参数传给 ss。

注意,ss -l 是筛选已有监听,nc -l 是创建监听。

在同时启用 IPv4 和 IPv6 的 Linux 上,第一条能连,第二条通常被拒绝,ss 只显示 127.0.0.1:9009,没有 [::1]:9009。如果系统禁用了 IPv6,第二条可能直接报告地址族不可用。服务只绑定回环时,宿主之外的客户端也不能借此访问它。遇到“本机能连、另一台机器不能连”,先对照服务端监听地址和客户端实际连接的地址;域名同时有 A、AAAA 记录时,可分别用 nc -4 和 nc -6 验证。容器内外也要分别检查,容器的 127.0.0.1 是它自己的回环。检查结束后用 Ctrl+C 停掉这个监听。

连上之后,发送应用协议要求的数据

如果目标是明文 HTTP,可以先在回环上起一个只服务测试目录的 HTTP 服务。在终端 A 执行,目录中只放测试文件:

1
2
3
4
5
6
7
(
  set -e
  lab=$(mktemp -d "$PWD/.nc-http.XXXXXX")
  trap 'rm -rf -- "$lab"' EXIT
  printf 'hello\n' >"$lab/index.html"
  python3 -m http.server 8000 --bind 127.0.0.1 --directory "$lab"
)

HTTP 服务命令中的参数是:

  • -m http.server(module,模块):运行 Python 自带的 HTTP 服务模块。
  • 8000:监听端口。
  • --bind 127.0.0.1(bind,绑定):绑定回环地址,限制为本机访问。
  • --directory "$lab"(directory,目录):指定服务的文件目录。

其余 shell 语法负责创建和清理实验目录,后面的 TLS 实验复用同样的方式:

  • ( ... ):在子 shell 中运行这些命令。
  • set -e:命令失败时停止实验。
  • mktemp -d(directory,目录):创建临时目录,模板中的 XXXXXX 会替换成随机字符。
  • $PWD、lab=$(...):前者是当前目录,后者把命令输出的新目录路径保存到变量 lab 中。
  • trap ... EXIT:在子 shell 退出时执行清理命令。
  • rm -rf -- "$lab":递归删除该目录,其中 -r 表示递归,-f 不提示确认,-- 表示后面都是路径。
  • >:把输出写入文件。
  • "$lab":变量外的双引号避免路径中的空格被拆开。

终端 B 发两条完整的 HEAD 请求。最后的空行结束请求头;Connection: close 便于读完本次响应后关闭连接。

1
2
3
4
for path in / /not-found; do
  printf 'HEAD %s HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' "$path" |
    nc -N -w 2 127.0.0.1 8000
done

for 循环依次把 / 和 /not-found 赋给 path。请求中的各部分含义是:

  • %s:printf 的占位符,替换成 "$path" 的值。
  • HEAD:只取响应头,不取响应正文。
  • HTTP/1.1:请求使用的协议版本。
  • Host: localhost:指定要访问的主机。
  • Connection: close:要求服务端在本次响应后关闭连接。
  • \r\n:HTTP 要求的回车加换行。末尾的 \r\n\r\n 表示请求头结束;如果少了最后的空行,服务端可能继续等待输入。

Debian 12 容器中,第一条收到 HTTP/1.0 200 OK,第二条收到 HTTP/1.0 404 File not found。两条请求都完成了 TCP 和 HTTP 往返,区别在资源路径。如果反向代理返回 404,可能是代理本身生成了响应,不能据此断言请求到达了上游;应按请求走过的每一跳核对状态或日志。完成后在终端 A 按 Ctrl+C 停止测试服务。

TCP 可达,也可能卡在 TLS

下面用 OpenSSL 在回环上起一个 TLS 服务,证书只为这次实验签发,不安装到系统信任库。终端 A 执行,记下打印出的目录:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
(
  set -e
  lab=$(mktemp -d "$PWD/.nc-tls.XXXXXX")
  trap 'rm -rf -- "$lab"' EXIT
  cd "$lab"
  printf '%s\n' "$PWD"
  openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
    -subj '/CN=localhost' -addext 'subjectAltName=DNS:localhost' \
    -keyout key.pem -out cert.pem
  openssl s_server -quiet -accept 127.0.0.1:9443 -cert cert.pem -key key.pem -www
)

这里有两条 OpenSSL 命令。第一条 openssl req 生成实验用的私钥和证书,参数分别是:

  • -x509:直接生成自签名证书,而不是证书签名请求。
  • -newkey rsa:2048:新建一把 2048 位的 RSA 私钥。
  • -nodes:不加密输出的私钥,避免启动服务时交互输入私钥密码;它不表示关闭 TLS 加密。
  • -days 1:证书有效期为 1 天。
  • -subj '/CN=localhost':设置证书主题,其中 CN 是 Common Name(通用名称)。
  • -addext 'subjectAltName=DNS:localhost':添加 SAN(Subject Alternative Name,主题备用名称),声明证书适用于 DNS 名称 localhost,供客户端校验。
  • -keyout key.pem、-out cert.pem:分别指定私钥文件和证书文件的输出路径。

第二条 openssl s_server 启动 TLS 测试服务:

  • -accept 127.0.0.1:9443:指定监听地址和端口。
  • -cert cert.pem:指定服务端使用的证书。
  • -key key.pem:指定服务端使用的私钥。
  • -www:在收到 HTTP 请求后返回内置的状态页面,供后面的 curl 请求使用。
  • -quiet:减少会话和证书信息的输出。

命令末尾的 \ 只是 shell 的续行符,让一条命令可以分多行写。cd "$lab" 进入实验目录,printf '%s\n' "$PWD" 打印该目录的路径。

终端 B 进入打印的目录。-servername 是发给服务端的 SNI,用于选择站点;-verify_hostname 是客户端要求证书匹配的名字。这里保持 SNI 不变,只换校验名称:

1
2
3
4
5
nc -zv -w 2 127.0.0.1 9443
for name in localhost wrong.example; do
  openssl s_client -brief -connect 127.0.0.1:9443 -servername localhost \
    -verify_hostname "$name" -verify_return_error -CAfile cert.pem </dev/null
done

openssl s_client 是 TLS 诊断客户端,其余参数的作用是:

  • -brief:简要显示协商的 TLS 版本、加密套件和证书校验结果。
  • -connect 127.0.0.1:9443:指定实际连接的 IP 和端口。
  • -servername localhost:发送 SNI(Server Name Indication,服务器名称指示),让服务端知道客户端要访问哪个站点;它不负责证书名称校验。
  • -verify_hostname "$name":要求证书适用于变量 name 中的主机名,循环依次检查 localhost 和 wrong.example。
  • -verify_return_error:遇到证书校验错误就中止连接;否则 s_client 默认可能在报告校验错误后继续。
  • -CAfile cert.pem:把本次实验的自签名证书作为信任依据,不修改系统信任库。

最后的 </dev/null 是 shell 的输入重定向,让客户端立即读到 EOF,不等待键盘输入。这个例子只检查 TLS 握手和证书,没有发送 HTTP 请求。

nc 能连接,校验 localhost 也能成功,换成 wrong.example 则会报 hostname mismatch 并返回非零状态。这个反例说明 TCP 通了,还要继续检查证书链、名称和客户端的 TLS 设置。不要用“跳过证书校验后能打开”当作证书已修复的证据。

若要排除 DNS 的影响并完成一次 HTTPS 请求,可以用 curl 的 --resolve 给本次请求指定 IP,同时保留 URL 中的主机名。仍在上面的证书目录执行:

1
2
curl --noproxy localhost --resolve localhost:9443:127.0.0.1 \
  --cacert cert.pem -sS -o /dev/null -w '%{http_code}\n' https://localhost:9443/

这条命令中:

  • --noproxy localhost:访问 localhost 时不走代理,避免代理环境变量改变实验路径。
  • --resolve localhost:9443:127.0.0.1:按 主机名:端口:IP 的格式,为这次调用指定解析结果;URL 中仍保留 localhost。
  • --cacert cert.pem:使用指定证书文件作为信任依据,对应前面的 OpenSSL -CAfile。
  • -sS:合写 -s(silent,静默)和 -S(show error,显示错误),隐藏进度信息,但在请求失败时仍显示错误。
  • -o /dev/null / --output(output,输出):丢弃响应正文,避免内置状态页面干扰观察。
  • -w '%{http_code}\n' / --write-out(write out,写出):传输结束后输出 HTTP 状态码和一个换行。curl 的 -w 是输出格式选项,与 nc -w 的超时含义不同。
  • https://localhost:9443/:使用 HTTPS,访问 localhost 的 9443 端口和根路径 /。

预期输出 200。这个请求既连到指定 IP,又用 localhost 做 SNI 和证书名称校验;把 URL 直接写成 IP,比较的就不是同一次请求了。实际排查虚拟主机时,把示例里的主机名、端口和 IP 换成有权限验证的目标即可。完成后停止测试服务。

上面的方法都依赖 TCP 握手。nc -uz 中的 -u 把传输协议改为 UDP,-z 仍表示探测模式;UDP 没有握手,所以命令没报错也不能证明对端收到了数据,要靠应用响应或抓包验证。

参考:OpenBSD nc(1)、Debian Bookworm nc.openbsd(1)、Linux connect(2)、ss(8)、Python http.server、OpenSSL req、s_server、s_client、curl 参数手册。

#Linux #网络