

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

<!--more-->

## 先复现一次本机连接

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

```bash
nc -l 127.0.0.1 9009
```

- `-l`（listen，监听）：等待客户端连接。不加 `-l` 时，`nc` 默认作为客户端，连接后面指定的地址和端口。
- `127.0.0.1`：本机 IPv4 回环地址。
- `9009`：监听端口。

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

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

```bash
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 容器中，这条发送命令和随后关闭端口时的拒绝连接均已验证。

## 检查目标端口

```bash
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 回环：

```bash
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，再看内核的监听地址：

```bash
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 执行，目录中只放测试文件：

```bash
(
  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` 便于读完本次响应后关闭连接。

```bash
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 执行，记下打印出的目录：

```bash
(
  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 不变，只换校验名称：

```bash
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`](https://curl.se/docs/manpage.html#--resolve) 给本次请求指定 IP，同时保留 URL 中的主机名。仍在上面的证书目录执行：

```bash
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)`](https://man.openbsd.org/nc.1)、[Debian Bookworm `nc.openbsd(1)`](https://manpages.debian.org/bookworm/netcat-openbsd/nc_openbsd.1.en.html)、[Linux `connect(2)`](https://man7.org/linux/man-pages/man2/connect.2.html)、[`ss(8)`](https://man7.org/linux/man-pages/man8/ss.8.html)、[Python `http.server`](https://docs.python.org/3/library/http.server.html)、[OpenSSL `req`](https://docs.openssl.org/3.0/man1/openssl-req/)、[`s_server`](https://docs.openssl.org/3.0/man1/openssl-s_server/)、[`s_client`](https://docs.openssl.org/3.0/man1/openssl-s_client/)、[curl 参数手册](https://curl.se/docs/manpage.html)。

