签发自定义证书并反向代理 HTTPS 应用:以 nginx 代理 PVE 面板为例
本文讲述了如何用
openssl签发自定义证书(自签证书),以及如何使用 nginx 反向代理一个 HTTPS 应用。以 Proxmox VE(PVE)面板为例,完整走一遍「签发证书 → 配置反代 → 安装信任 → 兜底站加固」的流程。
背景内网里很多服务面板(Proxmox VE、NAS、监控平台等)默认监听 HTTPS 端口(如 PVE 的
8006)。直接访问没问题,但端口不好记、证书还是自签的,浏览器同样会警告。于是常规做法是:用一台 nginx 容器/机器做反向代理,统一域名入口,比如pve.internal。问题来了:后端本身就是 HTTPS,反代时必须妥善处理证书校验、Cookie 的
Secure标志、WebSocket 升级这三件事,否则就会出现「页面能打开、一登录就 401」的经典坑。
环境
- 反向代理:LXC 容器内 nginx
1.26(Debian 13),IP192.168.1.80- 后端:PVE 面板
https://192.168.1.101:8006- 域名:
pve.internal(内网 DNS 由 Pi-hole 提供,解析到 nginx 容器 IP)- 客户端:Windows / Linux / 手机
第一部分:签发自定义证书
自签证书(openssl 一行命令)
如果你的服务只在内网用,不需要公网 CA 认证,自签证书是最快的方案。关键点是必须加上 SAN(Subject Alternative Name),否则现代浏览器一律报「证书无效」——只填 -subj /CN=xxx 是不够的,Chrome 从 58 版本起就只认 SAN 不认 CN。
# 生成自签证书: 有效期 10 年, SAN 包含 pve.internal 和 *.internalmkdir -p /etc/nginx/sslopenssl req -x509 -nodes -newkey rsa:2048 -days 3650 \ -keyout /etc/nginx/ssl/pve.internal.key \ -out /etc/nginx/ssl/pve.internal.crt \ -subj "/CN=pve.internal" \ -addext "subjectAltName=DNS:pve.internal,DNS:*.internal"参数说明:
| 参数 | 作用 |
|---|---|
-x509 | 直接生成自签证书而不是证书请求(CSR) |
-nodes | 私钥不加密(nginx 启动无需输入密码) |
-newkey rsa:2048 | 同时生成新的 RSA 2048 私钥 |
-days 3650 | 有效期 10 年 |
-addext | 关键:写入 SAN,浏览器信任的依据 |
验证证书内容:
openssl x509 -in /etc/nginx/ssl/pve.internal.crt -noout -subject -ext subjectAltName# subject=CN=pve.internal# X509v3 Subject Alternative Name:# DNS:pve.internal, DNS:*.internalIMPORTANTSAN 里建议同时包含主域名和泛域名(
pve.internal+*.internal),这样以后加nas.internal、grafana.internal时证书不用重签。
安装到客户端信任库
自签证书的浏览器警告无法完全消除,但把证书安装到系统信任库后,客户端访问就会像正规证书一样静默通过(内网自用足够):
# Linux (Debian/Ubuntu): 放入系统 CA 目录后更新sudo cp pve.internal.crt /usr/local/share/ca-certificates/sudo update-ca-certificates
# Windows: 双击 .crt -> 安装证书 -> 本地计算机 -> 受信任的根证书颁发机构# macOS: 双击 .crt -> 钥匙串 -> 登录 -> 双击证书 -> 信任 -> 始终信任# Android: 设置 -> 安全 -> 加密与凭据 -> 安装证书 -> CA 证书NOTE与 Let’s Encrypt 的区别:公网服务器(如博客中 headscale 部署的阿里云)应优先用
certbot certonly --standalone -d 域名申请免费可信证书,因为客户端是外部用户,不可能逐台安装信任库。内网自用服务则反过来——自签 + 客户端装信任库更省事,还能避开 80 端口占用、自动续期等麻烦。
第二部分:nginx 反向代理 HTTPS 应用
基本配置
nginx 反代 HTTPS 后端时,proxy_pass 写 https:// 即可,但默认会校验后端证书(自签会报 502),所以需要关闭校验:
# 80 -> 443 强制跳转server { listen 80; server_name pve.internal; return 301 https://$host$request_uri;}
# HTTPS 入口server { listen 443 ssl; server_name pve.internal;
ssl_certificate /etc/nginx/ssl/pve.internal.crt; ssl_certificate_key /etc/nginx/ssl/pve.internal.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m;
charset utf-8; keepalive_timeout 70;
# 允许上传大文件 (ISO/备份/模板), 0 = 不限制 client_max_body_size 0;
location / { proxy_redirect off; proxy_pass https://192.168.1.101:8006; # PVE 面板 proxy_ssl_verify off; # 后端是自签证书, 关闭校验 proxy_ssl_protocols TLSv1.2 TLSv1.3; proxy_ssl_server_name on; # 向 HTTPS 后端发送 SNI
proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; # WebSocket 升级 proxy_set_header Connection "upgrade"; 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; }}测试并重载:
nginx -t && nginx -s reload四个关键点(缺一不可)
| 配置 | 作用 | 缺了会怎样 |
|---|---|---|
proxy_ssl_verify off | 不校验后端自签证书 | 502 Bad Gateway |
proxy_set_header Upgrade/Connection "upgrade" | 透传 WebSocket 升级头 | noVNC 控制台/SSH 终端打不开 |
client_max_body_size 0 | 不限上传体积 | 上传 ISO/备份 >1M 报 413 |
proxy_set_header Host $host | 透传原始域名 | 后端按 IP 判定虚拟主机出错 |
CAUTION最大的坑:Cookie 的
Secure标志。现象:
http://pve.internal打开登录页正常,输入账号密码后却一直弹连接错误 401: No ticket。根因:后端 PVE 走的是 HTTPS,它给
PVEAuthCookie打上了Secure标志——浏览器只会把这种 Cookie 在 https 请求中回传。如果 nginx 只暴露 80(HTTP),浏览器登录后每次请求都带不上 Cookie → 每次都被判定未认证 → 401。解法:让 nginx 自己也走 HTTPS(80 一律 301 到 443)。前端 https → 后端 https,Cookie 正常回传,问题消失。这就是本配置强制全 HTTPS 的原因。
兜底站:拒绝一切未匹配请求(加固)
反代配好后还有个隐患:nginx 默认会有一个 default_server(Debian 自带欢迎页),裸 IP 访问或伪造 Host 头会命中它,暴露 nginx 版本信息。替换成「拒绝一切」的兜底站:
//file: /etc/nginx/sites-available/default
# HTTP 兜底: 裸 IP / 其他 Host -> 直接断开server { listen 80 default_server; listen [::]:80 default_server; server_name _; return 444;}
# HTTPS 兜底: 防止裸 IP 访问 443 误撞进 PVE 反代server { listen 443 ssl default_server; listen [::]:443 ssl default_server; server_name _; ssl_certificate /etc/nginx/ssl/pve.internal.crt; ssl_certificate_key /etc/nginx/ssl/pve.internal.key; return 444;}NOTE
444是 nginx 特有的状态码:直接断开连接,不返回任何内容。用来拒绝无意义请求(扫描、裸 IP、伪造 Host)非常干净——攻击者看到的是一个黑洞,连版本号都探测不到。
验证部署效果
# 1. 裸 IP 访问 -> 应被断开 (444)curl -s -o /dev/null -w "%{http_code}\n" http://192.168.1.80/ || echo "连接被断开"
# 2. http 访问域名 -> 应 301 到 httpscurl -s -o /dev/null -w "HTTP %{http_code} -> %{redirect_url}\n" http://pve.internal/
# 3. https 访问域名 -> 应 200curl -sk -o /dev/null -w "HTTP %{http_code}\n" https://pve.internal/
# 4. 伪造 Host 头 -> 应被断开curl -s -o /dev/null -w "%{http_code}\n" http://192.168.1.80/ -H "Host: evil.example.com"
# 5. 证书 SANecho | openssl s_client -connect 192.168.1.80:443 -servername pve.internal 2>/dev/null \ | openssl x509 -noout -subject -ext subjectAltName我实测的完整结果:
| 测试 | 结果 |
|---|---|
裸 IP http / https | ✅ 连接被断开(444) |
http://pve.internal | ✅ 301 → https://pve.internal |
https://pve.internal 主页 | ✅ 200,正常渲染登录页 |
API /api2/json/version | ✅ 401(未认证,与直连一致) |
伪造 Host evil.example.com | ✅ 连接被断开 |
| WebSocket 升级头 | ✅ 正常透传 |
经验教训
- 自签证书必须带 SAN,只写 CN 在 Chrome/Edge 里等于无效证书
- HTTPS 后端 + HTTP 前端 = Cookie Secure 标志失效,登录必弹 401;反代 HTTPS 应用时前端也必须是 HTTPS
- 反代自签证书的后端要
proxy_ssl_verify off,否则 502 而不是「配置有问题」这种直观错误 - WebSocket 升级头要单独透传,控制台/终端类功能全靠它
- 兜底站用
444断连,比返回 404 更安全——不泄露任何信息 - 配置源头文件建议保存在宿主机(如
/root/pve-nginx.conf),容器内改动易丢失
以下是可爱的评论们:

输入用户名和邮箱后自动检查登录状态。登录后用户名和邮箱将被绑定, 只可以修改头像和主页链接。