便宜 VPS 的宿命之一是 IP 被墙:SSH、面板、代理全挂,国内直连丢包率感人。但只要域名还在、机器还活着,流量就可以改走 Cloudflare CDN 中转——墙挡的是 IP,挡不住遍布全球的 CF 边缘节点。

这套方案我在 1G 内存的小机器上端到端跑通过,下面是完整手册,命令均可直接复制。所有敏感值用占位符表示,动手前先通读一遍占位符表。

链路

客户端(v2rayN 等)
   → CF 边缘(<DOMAIN>,橙云,Full strict)
   → NPM(docker, 443, Cloudflare Origin CA 证书)
   → 127.0.0.1 docker 桥:<WSPORT>(3x-ui 主 xray 的 VLESS+WS 入站,无 TLS)
   → freedom 出网

核心思路:客户端与 CF 之间、CF 与源站之间都是 TLS(WebSocket 载荷),源站上的 xray 入站本身不加密——TLS 由 NPM 统一终结,一张证书服务所有子域。

占位符表

占位符含义示例形状
<DOMAIN>你的裸域example.com
<ORIGIN_IP>VPS 公网 IP203.0.113.10
<PPORT>3x-ui 面板端口(随机高位)45678
<WSPORT>VLESS+WS 入站端口(随机高位)23456

随机高位端口的生成习惯:shuf -i 10000-65000 -n 1,避开已占用即可。

第 0 步:Cloudflare 侧

  1. 域名托管到 CF。DNS 建两条 A 记录:<DOMAIN> 与 *.<DOMAIN> 都指向 <ORIGIN_IP>,均开橙云(Proxied)。
  2. SSL/TLS → 加密模式设为 Full (strict)。
  3. SSL/TLS → Origin Server → Create Certificate:主机名默认含裸域加一级泛域,有效期直接拉满 15 年。把证书块和私钥块分别存成 certs/<DOMAIN>/fullchain.pem 和 privkey.pem(权限 600)。
  4. 本地校验证书配对:两个命令输出的 md5 应相等。
openssl x509 -in certs/<DOMAIN>/fullchain.pem -noout -modulus | md5
openssl rsa  -in certs/<DOMAIN>/privkey.pem  -noout -modulus | md5

坑:Origin 证书必须在你当前这个 CF 账号签发。 历史存档、别的账号签的同名证书,在 Full (strict) 下一律 526(Invalid SSL certificate),跨账号没有豁免。526 排查顺序:证书账号对不对 → 是否被吊销 → SAN 是否覆盖访问的主机名。

服务器侧的前置(BBR、ufw、fail2ban、3x-ui 安装)按你惯用的基础安全流程做完,面板端口和访问路径做随机化。以下从 NPM 开始。

第 1 步:Nginx Proxy Manager(docker compose)

mkdir -p /opt/nginx-proxy-manager && cd /opt/nginx-proxy-manager
cat > docker-compose.yml <<'EOF'
services:
  npm:
    image: jc21/nginx-proxy-manager:latest
    container_name: nginx-proxy-manager
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "127.0.0.1:81:81"
    volumes:
      - ./data:/data
      - ./letsencrypt:/etc/letsencrypt
EOF
docker compose up -d

管理界面 81 端口只绑 127.0.0.1,后续要么 SSH 隧道访问,要么(像本方案一样)给它配一个反代子域。

第 2 步:NPM v3 管理员 bootstrap(三表,缺一不可)

2025 年以后的 :latest 镜像不再预置 [email protected] / changeme 默认管理员,而且只建 user 加 auth 能登录但所有 API 全 403,日志报 Cannot read properties of null (reading 'visibility')。根因是 user 表的 HasOne 关联 user_permission 行缺失——权限校验数据结构空引用,和角色是不是 admin 无关。要手工插齐三张表:

DB=/opt/nginx-proxy-manager/data/database.sqlite
NPW=$(openssl rand -base64 18)
HASH=$(docker exec -e PW="$NPW" nginx-proxy-manager node -e 'console.log(require("bcrypt").hashSync(process.env.PW,10))')
NOW=$(date '+%Y-%m-%d %H:%M:%S')
sqlite3 $DB "insert into user (created_on,modified_on,is_deleted,is_disabled,email,name,nickname,avatar,roles) values ('$NOW','$NOW',0,0,'admin@<DOMAIN>','admin','admin','','[\"admin\"]');"
NEWUID=$(sqlite3 $DB "select max(id) from user;")   # 注意:别用 UID,bash 的 UID 是只读变量
sqlite3 $DB "insert into auth (created_on,modified_on,user_id,type,secret,meta,is_deleted) values ('$NOW','$NOW',$NEWUID,'password','$HASH','{}',0);"
sqlite3 $DB "insert into user_permission (created_on,modified_on,user_id,visibility,proxy_hosts,redirection_hosts,dead_hosts,streams,access_lists,certificates) values ('$NOW','$NOW',$NEWUID,'all','manage','manage','manage','manage','manage','manage');"
umask 077; printf 'NPM admin (http://127.0.0.1:81)\nemail: admin@<DOMAIN>\npassword: %s\n' "$NPW" > /root/npm-creds.txt
# 验证登录(只输出 OK/FAIL,不回显 token)
curl -s -X POST http://127.0.0.1:81/api/tokens -H "Content-Type: application/json" \
  -d "{\"identity\":\"admin@<DOMAIN>\","'"secret":"'"$NPW\"}" | jq -r 'if .token then "OK" else "FAIL" end'

第 3 步:VLESS+WS 入站(3x-ui,停服直写数据库)

密钥在服务器上生成,不经过任何客户端:

XRAY=/usr/local/x-ui/bin/$(ls /usr/local/x-ui/bin/ | grep -i '^xray' | head -1)
UUID=$($XRAY uuid); EMAIL=$(openssl rand -hex 5); WSPATH=/ws-$(openssl rand -hex 6); SUBID=$(openssl rand -hex 8)
umask 077; printf 'UUID=%s\nWSPATH=%s\nSUBID=%s\n' "$UUID" "$WSPATH" "$SUBID" > /root/ws-setup.env

这一步用「停服直写 SQLite」的方式建入站,而不是走面板 UI——面板上点出来的流控/嗅探字段经常和预期不符,直写库所见即所得。写库前先备份(下面第一行),失败可回滚:

sqlite3 /etc/x-ui/x-ui.db ".backup /root/x-ui.db.bak-$(date +%F-%H%M%S)"
systemctl stop x-ui
UUID="$UUID" WSPATH="$WSPATH" EMAIL="$EMAIL" SUBID="$SUBID" python3 - <<'PY'
import sqlite3, json, os, time
U, WS, EM, SUB = os.environ['UUID'], os.environ['WSPATH'], os.environ['EMAIL'], os.environ['SUBID']
now = int(time.time()*1000)
db = sqlite3.connect('/etc/x-ui/x-ui.db'); cur = db.cursor()
if cur.execute("SELECT id FROM inbounds WHERE port=<WSPORT>").fetchone(): raise SystemExit("port exists")
settings = json.dumps({"clients":[{"id":U,"email":EM,"flow":"","enable":True,"expiryTime":0,"limitIp":0,"totalGB":0,"reset":0,"subId":SUB,"tgId":0,"security":"auto","comment":""}],"decryption":"none","fallbacks":[],"insecure":False})
stream = json.dumps({"network":"ws","security":"none","wsSettings":{"path":WS,"host":"<DOMAIN>","headers":{"Host":"<DOMAIN>"}},"externalProxy":[{"dest":"<DOMAIN>","port":443,"forceTls":"tls","sni":"<DOMAIN>","hostHeader":"<DOMAIN>","remark":"cf-cdn"}]})
sniff = json.dumps({"enabled":True,"destOverride":["http","tls","quic"]})   # 不要配 fingerprint
cur.execute("INSERT INTO inbounds (user_id,up,down,total,remark,enable,listen,port,protocol,settings,stream_settings,tag,sniffing,node_id,share_addr_strategy,share_addr,disable_flow) VALUES (1,0,0,0,'vless-ws-cf',1,'',<WSPORT>,'vless',?,?,?,'inbound-<WSPORT>-ws',NULL,'node','',0)", (settings,stream,sniff))
iid = cur.lastrowid
cur.execute("INSERT INTO clients (email,sub_id,uuid,security,enable,created_at,updated_at) VALUES (?,?,?,'auto',1,?,?)", (EM,SUB,U,now,now))
cid = cur.lastrowid
cur.execute("INSERT INTO client_inbounds (client_id,inbound_id,flow_override,created_at) VALUES (?,?,'',?)", (cid,iid,now))
cur.execute("INSERT INTO client_traffics (inbound_id,enable,email,up,down,expiry_time,total) VALUES (?,1,?,0,0,0,0)", (iid,EM))
db.commit(); print("inbound=%d client=%d" % (iid,cid))
PY
systemctl start x-ui; sleep 8
ss -tlnp | grep ":<WSPORT> "          # 应看到 xray 主进程在监听
jq -c '[.inbounds[]|select(.port==<WSPORT>)|{tag,clients:(.settings.clients|length)}]' /usr/local/x-ui/bin/config.json

两个致命细节,踩过才知道疼:

  1. node_id 必须是 NULL,不能填 0。 xray 配置生成逻辑对 NodeID != nil 的入站直接跳过——0 是非 nil 指针,整行被静默忽略且零日志。NULL 才等于「由本机 xray 承载」。建完必须用上面两行命令核验监听与配置真的存在。
  2. 入站用户列表是三表 JOIN 出来的(clients + client_inbounds + client_traffics),settings JSON 里内嵌的 clients 数组会被覆写。三表都要插齐,email 和 uuid 保持一致,缺一行 = 握手静默失败零日志。

externalProxy 字段的作用:让面板生成的分享链接呈现 CDN 形状(<DOMAIN>:443 + tls),运行时自动剥离,不影响 xray 本体。

第 4 步:上传证书,建 proxy host(全部走 API)

证书上传的字段名是 certificate / certificate_key,不是直觉的 cert_file:

TOK=$(curl -s -X POST http://127.0.0.1:81/api/tokens -H "Content-Type: application/json" -d "{\"identity\":\"admin@<DOMAIN>\",\"secret\":\"$(grep ^password /root/npm-creds.txt | cut -d' ' -f2)\"}" | jq -r .token)
CID=$(curl -s -X POST http://127.0.0.1:81/api/nginx/certificates -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" -d '{"provider":"other","nice_name":"<DOMAIN>-cf-origin"}' | jq -r .id)
curl -s -X POST http://127.0.0.1:81/api/nginx/certificates/$CID/upload -H "Authorization: Bearer $TOK" \
  -F certificate=@certs/<DOMAIN>/fullchain.pem -F certificate_key=@certs/<DOMAIN>/privkey.pem

然后建 host。先传证书再建 host——顺序反了,conf 会写成 .conf.err 且重启不会重新生成。.conf.err 的复活术:证书补传成功后 mv N.conf.err N.conf 再 docker exec nginx-proxy-manager nginx -s reload(NPM 只在 host 增改删时重写 conf):

mkhost(){ curl -s -X POST http://127.0.0.1:81/api/nginx/proxy-hosts -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" -d "$1" | jq -r 'if .id then "OK "+(.domain_names|join(",")) else (.error.message) end'; }
# 1) 裸域 → xray ws(websocket 升级头靠 host 级 allow_websocket_upgrade)
mkhost "$(jq -nc --argjson cid $CID '{domain_names:["<DOMAIN>"],forward_scheme:"http",forward_host:"172.17.0.1",forward_port:<WSPORT>,certificate_id:$cid,ssl_forced:true,http2_support:true,hsts_enabled:false,caching_enabled:false,block_exploits:true,allow_websocket_upgrade:true,meta:{}}')"
# 2) nginx.<DOMAIN> → NPM 自身管理界面
mkhost "$(jq -nc --argjson cid $CID '{domain_names:["nginx.<DOMAIN>"],forward_scheme:"http",forward_host:"127.0.0.1",forward_port:81,certificate_id:$cid,ssl_forced:true,http2_support:true,hsts_enabled:false,caching_enabled:false,allow_websocket_upgrade:true,meta:{}}')"
# 3) xui.<DOMAIN> → 3x-ui 面板
mkhost "$(jq -nc --argjson cid $CID '{domain_names:["xui.<DOMAIN>"],forward_scheme:"http",forward_host:"172.17.0.1",forward_port:<PPORT>,certificate_id:$cid,ssl_forced:true,http2_support:true,hsts_enabled:false,caching_enabled:false,allow_websocket_upgrade:true,meta:{}}')"

NPM 容器访问宿主机上的服务(3x-ui、xray 入站),forward_host 必须写 docker 桥地址 172.17.0.1,写 127.0.0.1 会打到容器自己身上。compose 建的网络在 172.18.x,docker0 在 172.17.x——所以防火墙要放行整个私网段:

ufw allow 80/tcp comment 'NPM http'; ufw allow 443/tcp comment 'NPM https'
ufw allow from 172.16.0.0/12 to any port <WSPORT> proto tcp comment 'docker bridges to x-ui xray ws'
ufw allow from 172.16.0.0/12 to any port <PPORT> proto tcp comment 'docker bridges to x-ui panel'

<WSPORT> 和 <PPORT> 公网侧默认 deny 即被挡住。验证防火墙务必从外部机器测,本机上 /dev/tcp/<ORIGIN_IP>/port 走 lo 恒通,结果失真。

第 5 步:客户端与验收

到面板里刷新后复制实际生成的分享链接(期望形状如下,但手工拼链接不算验收,必须核对面板/订阅实际输出):

vless://<UUID>@<DOMAIN>:443?encryption=none&security=tls&sni=<DOMAIN>&type=ws&host=<DOMAIN>&path=<WSPATH>#<DOMAIN>-ws-cf

TLS 分层要看清:后端入站 security=none,公网客户端经 NPM/CF 走 TLS,externalProxy 描述的是公网入口形状。WS Host 从 wsSettings.host 读,别指望 externalProxy.hostHeader 会被分享生成器采用。

CF 边缘连通性速查:

curl -s -o /dev/null -w "%{http_code}\n" https://<DOMAIN>/            # 400/404(xray 应答,预期)
curl -s -o /dev/null -w "%{http_code}\n" https://nginx.<DOMAIN>/      # 200
# ws 握手探测:带升级头打真实 path,返 400 = 链路通(xray 拒假握手属预期)
curl -s --resolve <DOMAIN>:443:127.0.0.1 -H "Connection: Upgrade" -H "Upgrade: websocket" \
     -o /dev/null -w "%{http_code}\n" https://<DOMAIN><WSPATH>

但这些都只是分层探测,唯一可信的验收是硬验收:在目标网络环境跑真实内核,https://www.google.com 返回 200,且 https://ifconfig.me 显示出口 IP 为 <ORIGIN_IP>。假握手 400 或根路径 404 不能替代它。

无头复用法(适合拿台干净 Linux 当测试机):从 v2rayN 目录取 xray 二进制,按分享链接参数生成一个 socks 入站在 10888 的 client config,然后:

curl --socks5-hostname 127.0.0.1:10888 -o /dev/null -w "%{http_code}\n" https://www.google.com
curl --socks5-hostname 127.0.0.1:10888 https://ifconfig.me

避坑表(全部实测,含根因)

#坑解
1手工插库的入站被静默跳过、无日志node_id 必须 NULL;建后核验 ss 监听 + config.json
23x-ui 面板 API POST 一律 403 空体,账密对错都一样CSRF 中间件在密码校验前拒绝;token 在 /csrf-token 响应的 .obj 字段,不是 .token
3NPM :latest 不 seed 管理员;只建 user+auth 则 API 全 403三表插齐:user + auth + user_permission
4证书上传报 “Certificate file was not provided”multipart 字段名 certificate/certificate_key
5host conf 成了 .conf.err 且重启不恢复证书先于 host 上传;已成 .err 则 rename + reload
6nginx 反代宿主机服务超时forward_host 写 172.17.0.1;ufw 放行 172.16.0.0/12
7CF 526(Invalid SSL certificate)strict 下证书须当前账号签发、未吊销、SAN 匹配
8bash 双引号包 SQL 吞掉 JSON 双引号,语句截断python sqlite3 参数化,或 quoted heredoc
9VLESS 用户列表对不上clients JOIN client_inbounds 生成,三表都要插齐且 email/uuid 一致
10面板二维码报 “no subId, no shareable link”clients 表 sub_id 列与 settings JSON 的 subId 都要填;外发订阅还需 subURI 设置
11分享链接里出现面板域名、后端端口或 security=none补 externalProxy 的 dest/port/forceTls/sni;WS Host 配 wsSettings.host 与 headers.Host
12面板 UI 里重置过 UUID,已保存的直链失效直链以数据库为准重建,或引导走订阅链接(自动跟随)
13WS 经 CF 空闲断连免费档超时不可调,客户端开心跳;CF 重启断连属正常

运维备忘

  • Origin CA 证书 15 年有效,免续期;换证书只需重传 + reload。
  • 换 WS path 或 UUID:改数据库三表 → 重启 x-ui → 重发链接。
  • CF 排查顺序:526(证书)→ 521/522(源站 80/443 不通)→ 客户端 502(加密模式掉到 Flexible,务必 Full 以上)。
  • 所有凭据落盘 600 权限,git 仓库里只记路径不记真值。

这套手册本身是我维护的一份部署 skill 的公开版——原版供我自己的 agent 直接执行,这份去掉了所有环境特定值。照抄命令时留意把占位符全部替换干净,尤其散落在 JSON 里的 <DOMAIN>。

下载

手册的完整 skill 版(含 frontmatter,可直接给 agent 用)打包在这里:

文件与本文同源维护;如果发现命令对不上当前版本的 3x-ui / NPM,以各自官方文档为准。