ARTICLE · 1291081
【AI工具链】跟着敲一遍⑬:80/443 被占怎么查



.
.
阅读本文 
系列:跟着敲一遍 · 21 天本地 AI 工具链 Day 13(第 13/21 篇) 前情:⑫ 把 vLLM 跑起来了;⑬ 处理"入口"——所有服务都关在一层门后面。实测环境:openEuler 22.03 / VMware 虚拟机 / 8 vCPU / Dify 17 容器 / Open WebUI / Nginx 1.27
前 12 天,我们在这台机器上堆了三个服务:Dify(15 个容器)、Open WebUI、vLLM。它们各自占着8088、3000、8000,每一个都直接暴露在局域网上。
问题在三条命令里就能看出来。
第一条,服务在报自己的版本号:curl -sI http://127.0.0.1/ | grep -i server Server: nginx/1.31.3 攻击者拿到 1.31.3 就能直接查这个版本有哪些已知漏洞。
第二条,没有 HTTPS。所有对话内容、API Key、知识库文件都在明文传输。
第三条,没有限流。任何人写个 while true; do curl ...; done 就能把 8 核的机器打到假死——这个我们在 ⑫ 里已经亲眼见过一次了。这篇做的事,就是在所有服务前面立一道门:TLS 终止 + 隐藏版本 + 六个安全响应头 + 限流 + 统一入口。做完之后,同样那条命令返回:curl -sI https://127.0.0.1/ -k | grep -i server server: nginx
先看清加固前是什么样子:

图 1:加固前的响应头。Server: nginx/1.31.3 完整暴露版本;Vary 和 Link 里带 next-router 说明这是 Dify 的 Next.js 前端。
HTTP/1.1 307 Temporary RedirectServer: nginx/1.31.3 <- 版本号完整暴露x-frame-options: DENYVary: rsc, next-router-state-tree, ...Link: </_next/static/chunks/...>; rel=preload
再看容器和端口:

图 2:docker-nginx-1 占着 0.0.0.0:80 和 0.0.0.0:443,这是 Dify 自带的网关,不是我们的边缘层。
三个具体风险
| 版本指纹 | Server: nginx/1.31.3 | |
| 明文传输 | ||
| 无访问控制 | ||
| 端口全暴露 |
目标拓扑
加固前 加固后----------------- -----------------浏览器 --80/443--> Dify 浏览器 --443--> nginx-edge(TLS 终止 + 加固)浏览器 --3000---> Open WebUI│客户端 --8000---> vLLM|--8088--> Dify|--3000--> Open WebUI`--(vLLM 不出网)
边缘层是唯一对外入口,内部服务全部退到内网端口。 这就是"边缘"的含义。
先看当前 .env 的配置:

图 3:EXPOSE_NGINX_PORT=80 / EXPOSE_NGINX_SSL_PORT=443,Dify 用的是默认值,直接把宿主机 80/443 占了。
这两个变量是 Dify 暴露给宿主机的端口。 只要它们还是 80/443,边缘层就没地方站。
修复:改两个变量,不是改一个
[root@LinuxPythonWorkshop ~]# cd ~/dify/docker && cp .env .env.bak.$(date +%Y%m%d-%H%M%S)[root@LinuxPythonWorkshop ~]# sed -i 's/^EXPOSE_NGINX_PORT=.*/EXPOSE_NGINX_PORT=8088/; s/^EXPOSE_NGINX_SSL_PORT=.*/EXPOSE_NGINX_SSL_PORT=8443/' .env[root@LinuxPythonWorkshop ~]# grep -E "^EXPOSE_NGINX" .env
⚠️ 两行都要改,这是最常见的失败点。
我在第一次做这件事时只改了
EXPOSE_NGINX_PORT(80→8088),以为 443 会自动跟着变。结果docker compose up之后,Dify 让出了 80 但还占着 443,后面启动边缘容器时直接报:
text docker: Error response from daemon: driver failed programming external connectivity: Bind for 0.0.0.0:443 failed: port is already allocated.而真正的错误被埋在几十行日志里,第一眼看像是"容器创建失败",很容易误判成镜像问题。
⚠️ 坑一:--force-recreate 会连带重建依赖链
改完 .env 需要重建 nginx 让配置生效。我最初写的是:
[root@LinuxPythonWorkshop ~]# docker compose up -d --force-recreate nginx # ❌ 有问题结果出乎意料:

图 4:只指定了 nginx,但实际重建了 8 个服务——因为 nginx 依赖 web,web 依赖 plugin_daemon 和 init_permissions。
Container docker-init_permissions-1 RecreateContainer docker-web-1 Recreate <- 没打算动它Container docker-redis-1 Running <- 数据容器,未受影响Container docker-db_postgres-1 Healthy <- 数据库,未受影响Container docker-plugin_daemon-1 RecreateContainer docker-agent_backend-1 StartedContainer docker-api-1 StartedContainer docker-nginx-1 Started
--force-recreate 会沿依赖链向上,重建所有相关服务。 指定 nginx 一个,实际动了六个。
幸好两个数据容器(redis、db_postgres)显示 Running / Healthy,没有被重建——数据是安全的,重建的只是无状态容器。
正确的写法是加 --no-deps:
docker compose up -d --force-recreate --no-deps nginx # ✅ 只重建 nginx 💡 动手前先看影响范围:
bash docker compose up -d --dry-run --force-recreate nginx
--dry-run会列出将被重建的容器,不改任何东西。这一步多花 2 秒,能避免"以为只重启一个服务,结果动了一片"。
核验:80/443 真的空了吗
[root@LinuxPythonWorkshop ~]# ss -ltnp | grep -E ':80 |:443 '
图 5:docker-nginx-1 的端口已变成 0.0.0.0:8088->80/tcp, 0.0.0.0:8443->443/tcp。

图 6:ss 只剩下 8088 和 8443,80 和 443 彻底消失——这是让位成功的标志。
然后确认 Dify 在新端口上还活着:

图 7:8088=307(Dify 的登录跳转,正常);80=000(连接被拒,说明端口真的空了)。
8088=307 ← Dify 正常响应80=000 ← 连接被拒,端口已让出
80=000 这个"失败"其实是成功信号。 它证明 80 端口上确实没有任何服务在监听了。
回滚方法(一定要先准备好)
[root@LinuxPythonWorkshop ~]# cd ~/dify/docker && cp $(ls -t .env.bak.* | head -1) .env && docker compose up -d --force-recreate --no-deps nginx有备份在,30 秒就能回到原状。改生产配置前先备份,这不是谨慎,是基本纪律。
[root@LinuxPythonWorkshop ~]# mkdir -p /root/nginx-edge/{conf,certs,logs}[root@LinuxPythonWorkshop ~]# cd /root/nginx-edge/certs && openssl req -x509 -nodes -newkey rsa:2048 -days 3650 \-keyout edge.key -out edge.crt \-subj "/C=CN/ST=Guangdong/L=Dongguan/O=LinuxPythonWorkshop/CN=192.168.149.132" \-addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:192.168.149.132"

图 8:生成 edge.crt(1399 字节)和 edge.key(1704 字节),有效期 3650 天。
⚠️ 坑二:不带 SAN 的证书等于废纸
上面那条命令最关键的部分是最后一行:
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:192.168.149.132"没有它,证书就是废的。
原因:CN(Common Name)字段从 2017 年起就被主流浏览器和 curl 忽略了。TLS 校验现在只看 SAN(Subject Alternative Name)扩展。 不带 SAN 的证书会报:
curl: (60) SSL: no alternative certificate subject name matches target host nameNET::ERR_CERT_COMMON_NAME_INVALID
即使 CN 写得完全正确也一样——因为校验逻辑根本不读 CN 了。
💡 三种写法,选一种:
场景 -addext内容 只用 IP 访问 IP:192.168.1.100,IP:127.0.0.1用域名访问 DNS:ai.local,DNS:*.local两者都可能 DNS:ai.local,IP:192.168.1.100,IP:127.0.0.1自签证书只适合内网和测试。 真实域名请用
certbot申请 Let's Encrypt 证书,免费且被所有浏览器信任。
/root/nginx-edge/conf/nginx.conf。下面按功能拆开讲。
① 隐藏版本号
http {server_tokens off;}
就这一行,Server: nginx/1.31.3 变成 Server: nginx。
为什么重要:版本号是最省事的指纹。攻击者不需要扫描,看一眼响应头就知道该用哪个 exploit。隐藏版本不能阻止攻击,但能提高探测成本——安全从来不是"绝对防住",而是"让对方多花时间"。
②限制:两个维度
http {limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;limit_conn_zone $binary_remote_addr zone=conn:10m;limit_req_status 429;limit_conn_status 429;}server {limit_req zone=perip burst=20 nodelay;limit_conn conn 20;}
limit_req_zone | ||
burst=20 | ||
nodelay | ||
limit_conn |
用 $binary_remote_addr 而不是 $remote_addr:前者是 4 字节二进制,后者是变长字符串,内存占用差一个数量级(10MB 的 zone 用二进制能存约 16 万个 IP)。
limit_req_status 429:默认是 503。429 是 Too Many Requests 的专用状态码,语义更准确,客户端和监控系统能正确区分"限流"和"服务不可用"。
③ TLS 配置
ssl_certificate /etc/nginx/certs/edge.crt;ssl_certificate_key /etc/nginx/certs/edge.key;ssl_protocols TLSv1.2 TLSv1.3;ssl_ciphers HIGH:!aNULL:!MD5;ssl_session_cache shared:SSL:10m;ssl_session_timeout 10m;
TLSv1.2 TLSv1.3 | 禁用 TLS 1.0/1.1 |
HIGH:!aNULL:!MD5 | |
session_cache |
④ 六个安全响应头
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;add_header X-Content-Type-Options "nosniff" always;add_header X-Frame-Options "SAMEORIGIN" always;add_header Referrer-Policy "strict-origin-when-cross-origin" always;add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
Strict-Transport-Security | |
X-Content-Type-Options: nosniff | |
X-Frame-Options: SAMEORIGIN | |
Referrer-Policy | |
Permissions-Policy |
always 参数不能省:不加它,add_header 只在 2xx/3xx 响应上生效,4xx/5xx 错误页就没有这些头了——而错误页恰恰是攻击者最容易利用的页面。
⑤ HTTP 强制跳转 HTTPS
server {listen 80;server_name _;return 301 https://$host$request_uri;}
用 301 而不是 302:301 是永久重定向,浏览器会缓存,后续访问直接走 HTTPS,省一次往返。302 是临时的,每次都要先问一次 HTTP。
⑥ 反向代理与 WebSocket
map $http_upgrade $connection_upgrade {default upgrade;'' close;}location / {proxy_pass http://dify_upstream;proxy_http_version 1.1;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme;proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection $connection_upgrade;proxy_read_timeout 300s;proxy_buffering off;}
四个关键点:
proxy_http_version 1.1 | ||
map $http_upgrade | Connection 头 | Connection: upgrade 会让普通请求也带 upgrade |
X-Forwarded-Proto | ||
proxy_read_timeout 300s |
proxy_buffering off:对 SSE 流式输出必须关。开着的话 Nginx 会攒满缓冲区再发给客户端,表现为"AI 回复卡住不动,最后一秒全出来"。
⑦ 启动容器
[root@LinuxPythonWorkshop ~]# docker run -d --name nginx-edge \--restart unless-stopped \-p 80:80 -p 443:443 \--add-host=host.docker.internal:host-gateway \-v /root/nginx-edge/conf/nginx.conf:/etc/nginx/nginx.conf:ro \-v /root/nginx-edge/certs:/etc/nginx/certs:ro \-v /root/nginx-edge/logs:/var/log/nginx \nginx:1.27-alpine

图 9:nginx-edge Up,端口 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp,日志显示 Configuration complete; ready for start up。
三个参数值得单独说:
--add-host=host.docker.internal:host-gateway —— Linux 上的 Docker 没有host.docker.internal 这个域名(那是 Docker Desktop 的专属功能)。这条参数把它映射到宿主机网关,容器内才能访问宿主机的 8088 端口。
nginx:1.27-alpine —— http2 on; 是 Nginx 1.25.1 之后的新语法。低于这个版本要用旧写法 listen 443 ssl http2;。
三个 :ro 挂载 —— 配置和证书只读挂载。容器内无法篡改,即使被拿下也改不了配置。
[root@LinuxPythonWorkshop ~]# curl -sI https://127.0.0.1/ -k | head -14
图 10:server: nginx——版本号消失了。但注意 x-frame-options 出现了两次。
HTTP/2 307server: nginx <- ✅ 版本隐藏strict-transport-security: max-age=31536000; includeSubDomainsx-content-type-options: nosniffx-frame-options: DENY <- ⚠️ 上游 Dify 的x-frame-options: SAMEORIGIN <- ⚠️ 我们加的,重复了referrer-policy: strict-origin-when-cross-originpermissions-policy: geolocation=(), microphone=(), camera=()
大部分验证到这里就结束了,但这里有个问题(见 06 节坑三)。
② HTTP 是否强制跳转
[root@LinuxPythonWorkshop ~]# curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" http://127.0.0.1/
图 11:301 -> https://127.0.0.1/,HTTP 流量被强制导向 HTTPS。
③ TLS 版本与套件
[root@LinuxPythonWorkshop ~]# echo | openssl s_client -connect 127.0.0.1:443 -servername localhost 2>/dev/null | grep -E "Protocol|Cipher|Verify"
图 12:Protocol: TLSv1.3、Cipher: TLS_AES_256_GCM_SHA384,Verify return code: 18 (self signed certificate)——自签证书的正常提示。
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384Verify return code: 18 (self signed certificate)
18 这个返回码不是错误,是"证书自签、不在信任链里"的正常提示。浏览器会弹警告,点"继续访问"即可——内网自签场景这是预期行为。
④ 限流是否真的生效(这里最容易误判)
第一次测试用的是串行循环:
[root@LinuxPythonWorkshop ~]# bash -c 'for i in $(seq 1 120); do curl -s -o /dev/null -w "%{http_code}\n" -k https://127.0.0.1/; done | sort | uniq -c'
图 13:120 个请求全是 307,一个 429 都没有。看起来配置完全失效了。
但配置是好的,是测试方法错了。
先确认配置真的加载了:
[root@LinuxPythonWorkshop ~]# docker exec nginx-edge nginx -T 2>&1 | grep -nE "limit_req|limit_conn"
图 14:limit_req_zone、limit_conn_zone、limit_req_status、limit_req、limit_conn 全部在配置里。
配置没问题,那就是请求速率根本没超过阈值。
改用并发压测,并给测试计时:
[root@LinuxPythonWorkshop ~]# bash -c 'time (seq 1 200 | xargs -P 50 -I{} curl -s -o /dev/null -w "%{http_code}\n" -k --http1.1 https://127.0.0.1/ | sort | uniq -c)'
图 15:21 个 307、179 个 429,总耗时 4.185 秒。
21 307179 429real 0m4.185s
这次成功了,而且 21 这个数字精确对得上配置:
rate=10r/s,burst=20,nodelay → 瞬时放行 20 个(burst)+ 令牌桶现补 1 个 = 21 → 之后全部 429 实际速率 200 ÷ 4.185s ≈ 47.8 r/s,远超阈值 10 r/s,所以 179 个被拦。
💡 这是本文最重要的方法论:测限流必须先算"实际速率是否超过阈值"。
上一轮 120 个串行请求,每个要 100ms 以上(TLS 握手 + 请求),总耗时超过 12 秒,平均速率不到 10 r/s——根本没超速,自然不会限流。
判断方法:给压测加
time,用请求数 ÷ real算出真实速率。低于阈值的测试结果毫无意义,反而会让你误判成"配置失效"去改一个本来正确的配置。
⑤ 上游是否可达
[root@LinuxPythonWorkshop ~]# curl -s -o /dev/null -w "上游=%{http_code}\n" -k https://127.0.0.1/上游=307
307 是 Dify 的登录跳转,说明代理链路完整:浏览器 → 边缘 Nginx → 宿主机 8088 → Dify nginx → web。
--force-recreate 连带重建依赖链已在 02 节详述。核心:加 --no-deps,动手前用 --dry-run 看影响范围。
坑二:证书不带 SAN
已在 03 节详述。核心:-addext "subjectAltName=..." 不能省。
坑三:add_header 是追加,不是替换
第一次验证时,响应头里出现了两条 x-frame-options:
x-frame-options: DENY <- 上游 Dify 返回的x-frame-options: SAMEORIGIN <- 边缘层加的
原因:Nginx 的 add_header不会覆盖上游已经返回的同名头,而是追加。上游的 DENY 原样透传,我们的 SAMEORIGIN 又加了一条。
为什么这是安全问题:浏览器遇到重复的 X-Frame-Options 行为不确定——有的取第一条,有的直接拒绝渲染,有的按最严格的执行。安全头出现歧义,等于没有。
修复:先把上游的头摘掉,再由边缘统一设置。
location / {proxy_pass http://dify_upstream;proxy_hide_header X-Frame-Options; # 摘掉上游的proxy_hide_header X-Powered-By; # 顺手隐藏技术栈...}
X-Powered-By: Next.js 也在泄露技术栈,一并处理。
改完必须先测语法再热加载:
[root@LinuxPythonWorkshop ~]# docker exec nginx-edge nginx -t && docker exec nginx-edge nginx -s reload
图 16:syntax is ok + test is successful,&& 保证只有通过才 reload。
nginx -t && nginx -s reload 里的 && 不能换成 ;:万一语法有错,; 会照样执行 reload,导致 Nginx 拒绝加载新配置甚至退出。先测后应用,是改 Nginx 的铁律。
最终结果:

图 17:server: nginx、x-frame-options: SAMEORIGIN(只剩一条)、x-powered-by 完全消失。
server: nginxstrict-transport-security: max-age=31536000; includeSubDomainsx-content-type-options: nosniffx-frame-options: SAMEORIGINreferrer-policy: strict-origin-when-cross-originpermissions-policy: geolocation=(), microphone=(), camera=()
六项头全部就位,无重复、无泄露。
坑四:EXPOSE_NGINX_SSL_PORT 忘了改
已在 02 节详述。核心:80 和 443 是两个独立变量。
Server | nginx/1.31.3 | nginx |
| TLS 1.3 + 强制跳转 | ||
X-Frame-Options) | 6 个,无重复 | |
| 10 r/s,突发 20,超出 429 | ||
X-Powered-By: Next.js | 已隐藏 | |
| 仅 443 |
[root@LinuxPythonWorkshop ~]# curl -sI https://127.0.0.1/ -k | grep -iE "^server|x-frame|x-powered"server: nginxx-frame-options: SAMEORIGIN
没有版本号,没有技术栈,只有一个头。 这就是加固的全部成果。
一、选择题(每题4个选项)
1. server_tokens off 的作用是?
A. 关闭服务器
B. 隐藏 Nginx 版本号
C. 禁用 token 认证
D. 限制请求频率
2. 自签证书必须包含哪个扩展,否则现代浏览器会拒绝?
A. basicConstraints
B. keyUsage
C. subjectAltName(SAN)
D. extendedKeyUsage
3. docker compose up --force-recreate nginx 会重建几个容器?
A. 只有 nginx
B. nginx 及其整条依赖链上的容器
C. 全部容器
D. 不重建任何容器
4. Nginx 的 add_header 遇到上游同名响应头时?
A. 覆盖上游的
B. 追加,形成两条
C. 报错退出
D. 忽略自己的
5. 测限流时返回全是 200,最可能的原因是?
A. 配置没加载
B. Nginx 版本太低
C. 实际请求速率没超过阈值
D. 限流模块未编译
二、填空题(3 题)
1. 只重建一个服务而不动依赖链,需要给 docker compose up 加 ______ 参数。
2. 让 Nginx 隐藏上游返回的技术栈头,用 ____________ 指令。
3. 改完 Nginx 配置后,应先执行 nginx ____ 检查语法,通过后再 nginx -s reload。
三、简答题(1 题)
1.为什么 limit_req_zone 要用 $binary_remote_addr 而不是 $remote_addr?请从内存占用的角度解释。

🎁 答对粉丝福利:
将答案按顺序发至评论区, 全部答对的前50位粉丝,将获得:
✅ 赠送完整的《21天工具链实验手册 》



#Nginx #边缘代理 #TLS #安全加固 #限流 #Dify #反向代理 #AI工具链















































































