把「在已跑 nginx 的 VPS 上从零搭起橙云 + 灰云双链路、并用 3x-ui 管多用户」 的全过程沉淀成技能:五阶段工作流、五条硬性前提、三条易踩反的判据、19 条避坑。 - references/01 前置取证与选型:泛解析不等通、端口取证、nginx 约束下的选型 - references/02 服务端落地:3x-ui 与 sing-box 两条路线,含包名与 2096 冲突 - references/03 nginx 与证书:80 → 签证书 → 443 的顺序、AEAD 套件、口令文件位置 - references/04 面板与多用户:3x-ui 三层模型、加人步骤、CSRF 登录法 - references/05 避坑清单:19 条,正向写做法、括号里附踩过的坑 - scripts/verify-links.sh 与 scripts/3xui-api.py:两个脚本均对真实环境跑通 正文已脱敏,域名/端口/口令/UUID/主机 全部改为占位符。
4.4 KiB
03 nginx 与证书
顺序:80 → 签证书 → 443
三个阶段不能颠倒。没证书就写 listen 443 ssl,nginx -t 直接报错;域名没有 vhost 就签证书,challenge 会打到默认 server 而 404。
阶段一:只开 80
server
{
listen 80;
server_name <域名>;
root /www/wwwroot/<域名>;
location ^~ /.well-known/acme-challenge/ {
default_type text/plain;
root /www/wwwroot/<域名>;
}
# 有代理路径时,这里先写好 location,签证书不受影响
location /<路径> {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://127.0.0.1:<端口>;
}
location / { return 301 https://$host$request_uri; }
}
写完 nginx -t 再 nginx -s reload,然后用 curl -H "Host: <域名>" http://<源站IP>/ 确认返回 301。
阶段二:签证书
/root/.acme.sh/acme.sh --set-default-ca --server letsencrypt
/root/.acme.sh/acme.sh --issue -d <域名> -w /www/wwwroot/<域名> --keylength ec-256
acme.sh 安装时会把续期任务写进 root 的 crontab(每天多次检查),续期后靠 --reloadcmd 自动 reload nginx,所以 --install-cert 时把 reloadcmd 一起带上:
/root/.acme.sh/acme.sh --install-cert -d <域名> --ecc \
--key-file /www/server/panel/vhost/cert/<域名>/privkey.pem \
--fullchain-file /www/server/panel/vhost/cert/<域名>/fullchain.pem \
--reloadcmd "/www/server/nginx/sbin/nginx -s reload"
域名走橙云时 ACME 的 HTTP-01 请求会经 CF 回源,只要 80 上有正确的 vhost 且没有强制 HTTPS 重定向,验证能通过。
阶段三:加 443
在同一个 server 块里补 listen 443 ssl http2 与证书路径。TLS 参数用纯 AEAD 套件:
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL<名>:10m;
ssl_session_timeout 10m;
省略 ssl_ciphers 或沿用宝塔模板的 EECDH+AES128 写法会把 CBC 套件一起放进白名单,TLS1.2 下可被降级到 CBC。改完用 openssl s_client -tls1_2 -cipher 'ECDHE-ECDSA-AES128-SHA' 验证,返回 Cipher: 0000 表示已拒绝。
隐藏代理路径的指纹
不带 Upgrade 头访问真实代理路径时,后端会返回 400,而错误路径返回 404 —— 这个差异直接暴露了入口。在 location 首行加一行守卫即可让所有非 WebSocket 请求统一 404:
if ($http_upgrade = "") { return 404; }
加一层 basic auth
面板这类管理界面值得在外面再压一层口令,密码文件用 openssl passwd -apr1 生成:
printf 'user:%s\n' "$(openssl passwd -apr1 '<口令>')" > /www/wwwroot/<域名>/.pass
chown www:www /www/wwwroot/<域名>/.pass
chmod 640 /www/wwwroot/<域名>/.pass
在 location 里引用:
auth_basic "restricted";
auth_basic_user_file /www/wwwroot/<域名>/.pass;
密码文件的存放位置决定成败:/www/server/panel/vhost 及其子目录是 drw------- root root,nginx worker 以 www 身份穿不进目录,会报 open() ... failed (13: Permission denied) 并对外返回 500(不是 401)。放 webroot 下并 chown 给 www 即可;由于该 location 全部反代到后端,文件不会被当静态资源暴露。
全量验证
# 经域名的 TLS 与页面
curl -s -o /dev/null -w "%{http_code}\n" https://<域名>/
# 直连源站验证 SNI 与 vhost
curl -s -o /dev/null -w "%{http_code}\n" --resolve <域名>:443:<源站IP> https://<域名>/
# WS 转发(应看到 101;经 CDN 时先有 200 Connection Established)
curl -s -i --http1.1 -H "Upgrade: websocket" -H "Connection: Upgrade" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
https://<域名>/<路径> | head -5
带 basic auth 的站点要加 -u user:pass 并带浏览器 UA(-A "Mozilla/5.0 ... Chrome/131"),否则 aaPanel 这类面板会一律返回 404,造成误判。