boxmoe_header_banner_img

Hello! 欢迎来到我的博客!

文章导读

Docker 搭建 Nginx Proxy Manager + MySQL + WordPress 最简教程


avatar
[email protected] 24 6 月, 2026 320
📅 最后更新:2026-09-16  | 
🖥️ 实测环境:Debian 13 VPS(1 vCPU / 1GB 内存)  | 
🏷️ 标签:Docker, NPM, MySQL, WordPress, 3x-ui, Reality, VLESS-WS-TLS, Hysteria2, AimiliVPN

本教程从零在 VPS 上部署一整套 Docker 服务栈,所有命令、JSON 载荷、坑点均为实测通过后才写进来的:

  • NPM 中文版(Nginx Proxy Manager)— 图形化反向代理 + Let’s Encrypt 证书管理
  • MySQL 5.7 — WordPress 数据库
  • WordPress — 对外伪装的博客站
  • 3x-ui(Xray 面板)— Reality / VLESS-WS-TLS / Hysteria2 三种节点
  • AimiliVPN(可选)— 让机房 VPS 也能访问 OpenAI 等站点
📌 目标:个人自用。请勿用于商业售卖节点。

关于占位符(照抄前先看这段)

本文不包含任何真实域名、IP、账号或密码。全文统一使用下列占位符:

占位符 含义 示例值
203.0.113.10 服务器公网 IP RFC 5737 文档专用地址,永远不要照抄
example.com 主域名(博客站) 你自己的域名
panel.example.com 3x-ui 面板域名 你自己的域名
npm.example.com NPM 管理面板域名 你自己的域名
/ws VLESS+WS 的路径 自己定,别用有意义/可猜的词
<强密码> 所有密码 用密码管理器生成
[email protected] 管理员邮箱 要能收信(Let’s Encrypt 用)
⚠️ 动手前先把它们全部替换成你自己的值。
下面 §2 会把它们集中写进 .env
后续命令直接引用变量,就不需要满篇改字符串了。

架构(先理解这个,后面才不会绕)

核心设计只有一句话:让 3x-ui 独占 443,非 Reality 流量 fallback 到 NPM
这样服务器对外看起来就是一个正常的中文博客站,而代理功能藏在同一个端口里。

外部 443/tcp   ──► 3x-ui 容器 (Xray)
                    ├─ Reality 客户端(认证通过)──► 走代理出网
                    └─ 其他 TLS(浏览器 / 扫描器)──► fallback ──► NPM:443 ──► 真实网站
外部 80/tcp    ──► NPM 容器(Let's Encrypt HTTP-01 校验 + HTTP→HTTPS 跳转)
外部 12703/udp ──► 3x-ui 容器(Hysteria2)

容器与固定 IP(自建 bridge 网络 web = 172.20.0.0/16):

容器 IP 端口
mysql57 172.20.0.10 3306(仅内网)
npm 172.20.0.20 只发布 80;81 / 443 仅容器内
wordpress 172.20.0.30 80(仅内网)
3xui 172.20.0.50 发布 44312700-1271012703/udp
aimilivpn host 网络 代理 7928 / 管理页 8787
📌 为什么 NPM 不发布 443:443 已经被 3x-ui 占用,NPM 的 443 只在容器内监听,
由 Xray 的 fallback 转发进来。
📌 为什么 NPM 不发布 81:管理面板没必要暴露公网,走 npm.example.com 反代即可。

0. 前置条件

  • VPS:Debian 12/13,建议 ≥ 1 vCPU / 1GB 内存
  • 域名托管在 Cloudflare,先设 灰云(DNS Only),指向 VPS IP:
    • example.com(站点)、panel.example.com(面板)、npm.example.com(NPM 面板)
  • 云厂商安全组放行:SSH 端口、80/tcp443/tcp12703/udp
⚠️ 先灰云,别急着开小黄云。原因见文末「CF 橙云」一节。

1. 系统准备

apt update && apt -y upgrade
timedatectl set-timezone Etc/UTC            # 换成离你机器最近的时区(例 Asia/Tokyo)
apt -y install curl git python3 net-tools
curl -fsSL https://get.docker.com | sh
docker version --format '{{.Server.Version}}'   # 期望 24+
⚠️ 不要用第三方一键脚本(如 kejilion.sh)——它会留下
@reboot iptables-restore 之类的定时任务,后面会把你的防火墙策略覆盖掉
§11 会讲怎么清)。

2. 凭据与目录

mkdir -p /root/vps-stack && cd /root/vps-stack
cat > .env <<'EOF'
SERVER_IP=203.0.113.10
SSH_PORT=22
DOMAIN_MAIN=example.com
DOMAIN_PANEL=panel.example.com
DOMAIN_NPM=npm.example.com
WS_PATH=/ws
MYSQL_ROOT_PASSWORD=<强密码>
WP_DB_NAME=wp
WP_DB_USER=wp
WP_DB_PASSWORD=<强密码>
[email protected]
NPM_ADMIN_PASSWORD=<强密码>
XUI_USERNAME=admin
XUI_PASSWORD=<强密码>
EOF
chmod 600 .env
set -a; . /root/vps-stack/.env; set +a    # 后续命令直接引用 $DOMAIN_MAIN 等
⚠️ 用同一个强密码也行,但 .env 必须 chmod 600
永远不要把这个文件提交到 git 或贴给别人

3. Docker 网络

docker network create --driver bridge --subnet 172.20.0.0/16 --gateway 172.20.0.1 web

4. MySQL 5.7

mkdir -p /home/mysql/data
docker run --name mysql57 --net web --ip 172.20.0.10 \
  -v /home/mysql/data:/var/lib/mysql \
  -e MYSQL_ROOT_PASSWORD="$MYSQL_ROOT_PASSWORD" \
  --restart unless-stopped -d mysql:5.7 \
  --character-set-server=utf8 --collation-server=utf8_unicode_ci

sleep 20   # 等它初始化完
docker exec mysql57 mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -e "
CREATE DATABASE IF NOT EXISTS wp DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci;
CREATE USER IF NOT EXISTS 'wp'@'%' IDENTIFIED BY '$WP_DB_PASSWORD';
GRANT ALL PRIVILEGES ON wp.* TO 'wp'@'%';
FLUSH PRIVILEGES;"
📌 mysql:5.7 已 EOL,个人自用可接受;要换 mysql:8.0,上面 SQL 完全兼容。

5. NPM(中文版,只发布 80

mkdir -p /home/npm/data /home/npm/letsencrypt
docker run --name npm --net web --ip 172.20.0.20 \
  -v /home/npm/data:/data \
  -v /home/npm/letsencrypt:/etc/letsencrypt \
  -p 80:80 \
  --restart unless-stopped -d chishin/nginx-proxy-manager-zh:release

首次登录并改密码(面板 http://172.20.0.20:81,默认 [email protected] / changeme):

# 取 token
TOKEN=$(curl -s -X POST http://172.20.0.20:81/api/tokens \
  -H 'Content-Type: application/json' \
  -d '{"identity":"[email protected]","secret":"changeme"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
echo "$TOKEN" > /root/vps-stack/.npm_token

# 改密码(body 必须是这个形状)
curl -s -X PUT http://172.20.0.20:81/api/users/1/auth \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d "{\"type\":\"password\",\"current\":\"changeme\",\"secret\":\"$NPM_ADMIN_PASSWORD\"}"

# 改资料(注意:body 只能有 name/nickname/email/roles,带 avatar 会 400)
curl -s -X PUT http://172.20.0.20:81/api/users/1 \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d "{\"name\":\"Admin\",\"nickname\":\"Admin\",\"email\":\"$NPM_ADMIN_EMAIL\",\"roles\":[\"admin\"]}"

6. 3x-ui

mkdir -p /home/3xui/db /home/3xui/cert
docker run -d --name 3xui --net web --ip 172.20.0.50 \
  --restart unless-stopped \
  -p 443:443 \
  -p 12700-12710:12700-12710 \
  -p 12703:12703/udp \
  -v /home/3xui/db:/etc/x-ui/ \
  -v /home/3xui/cert:/root/cert/ \
  -v /home/npm/letsencrypt:/npm-certs \
  -e XRAY_VMESS_AEAD_FORCED=false \
  ghcr.io/mhsanaei/3x-ui:latest

改默认凭据(3.8.0 有 CSRF,直接改库最省事):

docker exec 3xui /app/x-ui setting -username "$XUI_USERNAME" -password "$XUI_PASSWORD"
docker restart 3xui          # 改完必须重启才生效

拿 API token注意:每次调用都会作废上一个):

docker exec 3xui /app/x-ui setting -getApiToken
echo '<上面输出的token>' > /root/vps-stack/.xui_token

两个救命接口

# 全量 API 规范(180+ 条路由 + schema),比翻二进制快得多
curl -s -H "Authorization: Bearer $TOKEN" http://172.20.0.50:2053/panel/api/openapi.json
# 生成 Reality 密钥对
curl -s -H "Authorization: Bearer $TOKEN" http://172.20.0.50:2053/panel/api/server/getNewX25519Cert
⚠️ v3 的客户端/入站结构与 v2 完全不同inbounds / clients /
client_inbounds / hosts),网上 v2 的教程不能照抄。

7. WordPress

/home/wp/docker-compose.yml

services:
  wordpress:
    image: wordpress:latest
    container_name: wordpress
    restart: unless-stopped
    environment:
      WORDPRESS_DB_HOST: mysql57:3306
      WORDPRESS_DB_NAME: wp
      WORDPRESS_DB_USER: wp
      WORDPRESS_DB_PASSWORD: <强密码>
    volumes:
      - /home/wordpress/data:/var/www/html
    networks:
      web:
        ipv4_address: 172.20.0.30
networks:
  web:
    external: true
mkdir -p /home/wordpress/data && cd /home/wp && docker compose up -d
docker exec npm sh -c 'curl -s -o /dev/null -w "%{http_code}\n" http://172.20.0.30:80/'   # 期望 200

浏览器打开 http://203.0.113.10/ 走完安装向导。
装完后把站点地址改成 https(否则后面反代会跳来跳去):

docker exec wordpress wp option update siteurl "https://$DOMAIN_MAIN" --allow-root 2>/dev/null \
 || docker exec mysql57 mysql -uroot -p"$MYSQL_ROOT_PASSWORD" wp \
      -e "UPDATE wp_options SET option_value='https://$DOMAIN_MAIN' WHERE option_name IN ('siteurl','home');"
⚠️ 顺手做一件事:把 WordPress 站点标题/副标题改成通用的(比如「我的博客」),
别用带个人信息的名字——443 端口对外伪装的就是这个站,标题会暴露在探测结果里。

8. NPM 反代 + 证书

先申请证书(HTTP-01,要求 80 端口直达 NPM、域名是灰云):

TOKEN=$(cat /root/vps-stack/.npm_token)
for D in "$DOMAIN_MAIN" "$DOMAIN_PANEL" "$DOMAIN_NPM"; do
  curl -s -X POST http://172.20.0.20:81/api/nginx/certificates \
    -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
    -d "{\"provider\":\"letsencrypt\",\"domain_names\":[\"$D\"],\"meta\":{\"letsencrypt_email\":\"$NPM_ADMIN_EMAIL\",\"letsencrypt_agree\":true,\"dns_challenge\":false}}"
  echo
done
# 记下返回的 id(本例依次是 1 / 2 / 3)

建三个代理主机(JSON 用 heredoc 写,$变量 会被 shell 展开):

TOKEN=$(cat /root/vps-stack/.npm_token)

# 1) 主站 → WordPress,并挂上 WS 的自定义位置
cat > /tmp/host1.json <<EOF
{"domain_names":["$DOMAIN_MAIN"],"forward_scheme":"http","forward_host":"172.20.0.30","forward_port":80,
 "certificate_id":1,"ssl_forced":true,"block_exploits":true,"caching_enabled":false,
 "allow_websocket_upgrade":true,"http2_support":true,"advanced_config":"",
 "meta":{"letsencrypt_agree":true,"dns_challenge":false},
 "locations":[{"path":"$WS_PATH","forward_scheme":"http","forward_host":"172.20.0.50",
               "forward_port":12702,"advanced_config":"","forward_path":""}]}
EOF
curl -s -X POST http://172.20.0.20:81/api/nginx/proxy-hosts \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d @/tmp/host1.json

# 2) 3x-ui 面板
cat > /tmp/host2.json <<EOF
{"domain_names":["$DOMAIN_PANEL"],"forward_scheme":"http","forward_host":"172.20.0.50","forward_port":2053,
 "certificate_id":2,"ssl_forced":true,"block_exploits":true,"allow_websocket_upgrade":true,
 "meta":{"letsencrypt_agree":true,"dns_challenge":false},"locations":[]}
EOF
curl -s -X POST http://172.20.0.20:81/api/nginx/proxy-hosts \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d @/tmp/host2.json

# 3) NPM 自身面板
cat > /tmp/host3.json <<EOF
{"domain_names":["$DOMAIN_NPM"],"forward_scheme":"http","forward_host":"172.20.0.20","forward_port":81,
 "certificate_id":3,"ssl_forced":true,"block_exploits":true,"allow_websocket_upgrade":true,
 "meta":{"letsencrypt_agree":true,"dns_challenge":false},"locations":[]}
EOF
curl -s -X POST http://172.20.0.20:81/api/nginx/proxy-hosts \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d @/tmp/host3.json
⚠️ locations 数组是全量覆盖:以后要加/改自定义位置,必须把已有的
(如 $WS_PATH)一起带上,否则会被删掉。改已有主机用
GET → 改 → PUT /api/nginx/proxy-hosts/{id}
✅ 此时 443 还没通(3x-ui 里还没有 443 入站),这是正常的——建完
§10 就通了。

9. AimiliVPN + 分流 可选

用于让机房 VPS 也能访问 OpenAI 等站点。

mkdir -p /opt && cd /opt
git clone https://github.com/xiaoxinkeji/aimili-vpngate.git aimilivpn
cd /opt/aimilivpn
sed -i 's/COPY proxy_server.py/COPY metrics.py proxy_server.py/' Dockerfile   # 关键补丁
mkdir -p /opt/aimilivpn/vpngate_data

/opt/aimilivpn/docker-compose.yml

services:
  aimilivpn:
    build: {context: ., args: {IMAGE_VERSION: dev, BUILD_DATE: unknown, GIT_COMMIT: unknown}}
    image: ghcr.io/xiaoxinkeji/aimili-vpngate:latest
    container_name: aimilivpn
    restart: unless-stopped
    network_mode: host
    cap_add: [NET_ADMIN, NET_RAW]
    devices: ["/dev/net/tun:/dev/net/tun"]
    volumes: ["/opt/aimilivpn/vpngate_data:/opt/aimilivpn/vpngate_data"]
    environment:
      - VPNGATE_DATA_DIR=/opt/aimilivpn/vpngate_data
      - LOCAL_PROXY_HOST=172.20.0.1     # host 网络必须绑 web 网桥网关,容器才连得上
      - LOCAL_PROXY_PORT=7928
      - UI_HOST=172.20.0.1
      - UI_PORT=8787
      - METRICS_ENABLED=true
      - METRICS_PORT=9798
      - METRICS_REFRESH_INTERVAL=15
cat > /etc/sysctl.d/99-zz-aimilivpn.conf <<'EOF'
net.ipv4.ip_forward=1
net.ipv4.conf.all.rp_filter=2
net.ipv4.conf.default.rp_filter=2
EOF
sysctl --system
# ⚠️ 文件名必须带 zz- 前缀:sysctl.d 按文件名字典序加载,zz- 才能排在别的优化文件之后生效

# daemon.json 必须「合并」而不是覆盖
python3 - <<'PY'
import json
from pathlib import Path
p = Path("/etc/docker/daemon.json"); d = {}
if p.exists():
    try: d = json.loads(p.read_text()) or {}
    except Exception: d = {}
d["ip-forward-no-drop"] = True
p.write_text(json.dumps(d, indent=2) + "\n")
PY
systemctl restart docker

cd /opt/aimilivpn && docker compose up -d --build

在 3x-ui 里加分流规则:出站新增一个 AIMILI-VPNhttp,指向
172.20.0.1:7928),routing 里把 openai.com / chatgpt.com /
oaistatic.com / oaiusercontent.com / ai.com 指过去,其余走 direct

验证

docker exec 3xui sh -c 'curl -s -4 --max-time 15 --proxy http://172.20.0.1:7928 https://api.ipify.org'
# 返回 VPN 出口 IP 即成功(不是你的服务器 IP)
⚠️ 重启后要等 2~3 分钟:免费 vpngate 节点要先扫描试连,期间 tun0 不存在,
代理会报 ERR_ROUTE_DEV_NOT_FOUND这是正常现象,不是故障。
另外免费节点很不稳定,出口 IP 每次都可能不一样;如果在面板里把路由锁成
「固定地区 + 只挑住宅 IP」,可用节点池会大幅收窄,中断会更频繁、恢复更慢。

10. 三个节点

📌 建议直接在面板里建(字段值照抄下表)。Reality 的密钥点面板上的「生成」,
GET /panel/api/server/getNewX25519Cert

10.1 Reality(443,本项目最关键的一步)

字段
协议 / 端口 vless / 443
传输 / 安全 tcp / reality
目标 (target) 172.20.0.20:443 ← NPM 容器,不是 www.cloudflare.com:443
SNI / serverNames example.com,panel.example.com,npm.example.com(你自己的三个域名)
Flow xtls-rprx-vision
指纹 chrome
公钥/私钥、ShortId 点「生成」
xver 0(不用 PROXY protocol)

实际生成出的 streamSettings

{"network":"tcp","security":"reality","realitySettings":{
  "show":false,"xver":0,
  "target":"172.20.0.20:443",
  "serverNames":["example.com","panel.example.com","npm.example.com"],
  "privateKey":"<私钥>","shortIds":["<shortId>"],
  "settings":{"publicKey":"<公钥>","fingerprint":"chrome","serverName":"example.com","spiderX":"/"}}}
📌 v3 里字段名是 targetdest 只是兼容别名)。
📌 target 的拨号是 Xray 内部直连,不走 outbound 路由,
所以模板里 direct 出站的 block geoip:private 不会阻断它。

10.2 VLESS + WS + TLS(12702,经 NPM 的 $WS_PATH

字段
协议 / 端口 vless / 12702
传输 / 安全 ws / none(TLS 由 NPM 负责)
路径 /ws(自己定)
{"network":"ws","security":"none","wsSettings":{"path":"/ws","host":""}}

对外链路:

客户端 → example.com:443 → Xray(非 Reality) → NPM:443 → 自定义位置 /ws → 172.20.0.50:12702

10.3 Hysteria2(12703/udp,复用 NPM 的证书)

字段
协议 / 端口 hysteria / 12703
版本 / UDP 空闲超时 2 / 60
安全 / SNI / ALPN tls / example.com / h3
证书 /npm-certs/live/npm-N/fullchain.pem + privkey.pem
UDP Mask 不用(客户端别填 Salamander)
{"network":"hysteria","security":"tls",
 "tlsSettings":{"serverName":"example.com","alpn":["h3"],
   "certificates":[{"certificateFile":"/npm-certs/live/npm-1/fullchain.pem",
                    "keyFile":"/npm-certs/live/npm-1/privkey.pem"}]},
 "hysteriaSettings":{"version":2,"udpIdleTimeout":60}}
⚠️ npm-N 的编号不要硬编码,先查:
docker exec 3xui ls -la /npm-certs/live/
(symlink 指向 archive/,续期后路径不变,安全。)

10.4 改完重启 Xray

不要手动跑 xray 二进制,在面板点「重启 Xray」,或:

curl -s -X POST -H "Authorization: Bearer $TOKEN" http://172.20.0.50:2053/panel/api/server/restartXrayService

11. 防火墙(这一步最容易做错)

⚠️ 核心认知:Docker -p 发布的端口走
nat/PREROUTING DNAT + FORWARD
根本不经过 INPUT。所以网上那些
iptables -I INPUT --dport 12701 -j ACCEPT 全是无效的
iptables -L INPUT 能看到规则,很容易误判成功)。

端口访问控制必须写在 DOCKER-USER 链。

/usr/local/sbin/docker-firewall.sh(幂等,可重复执行):

#!/bin/bash
set -e
IPTABLES="/usr/sbin/iptables"

# 1) FORWARD 默认策略 DROP
$IPTABLES -P FORWARD DROP

# 2) 容器 → 宿主机的 AimiliVPN 代理/管理页(这两条才走 INPUT)
for P in 7928 8787; do
  $IPTABLES -C INPUT -s 172.20.0.0/16 -d 172.20.0.1 -p tcp --dport $P -j ACCEPT 2>/dev/null || \
  $IPTABLES -I INPUT 1 -s 172.20.0.0/16 -d 172.20.0.1 -p tcp --dport $P -j ACCEPT
done

# 3) 独立链:白名单放行 + 其余全 DROP(不要用"枚举要 DROP 的端口",容易漏)
$IPTABLES -N AIMILI-3XUI 2>/dev/null || true
$IPTABLES -F AIMILI-3XUI
$IPTABLES -A AIMILI-3XUI -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
$IPTABLES -A AIMILI-3XUI -d 172.20.0.50 -p tcp --dport 443   -j ACCEPT
$IPTABLES -A AIMILI-3XUI -d 172.20.0.50 -p udp --dport 12703 -j ACCEPT
$IPTABLES -A AIMILI-3XUI -d 172.20.0.50 -j DROP

# 4) 挂到 DOCKER-USER 首位(先清旧跳转,保证幂等)
while $IPTABLES -C DOCKER-USER -j AIMILI-3XUI 2>/dev/null; do
  $IPTABLES -D DOCKER-USER -j AIMILI-3XUI
done
$IPTABLES -I DOCKER-USER 1 -j AIMILI-3XUI

/etc/systemd/system/docker-firewall.service

[Unit]
Description=Apply custom Docker firewall rules after Docker starts
Requires=docker.service
After=docker.service
PartOf=docker.service

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/docker-firewall.sh
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
chmod 755 /usr/local/sbin/docker-firewall.sh
systemctl daemon-reload
systemctl enable --now docker-firewall.service

清理第三方脚本的遗留必做,否则重启后 FORWARD 会变回 ACCEPT):

crontab -l                                  # 看看有没有 @reboot iptables-restore
crontab -l | grep -v iptables-restore | crontab -
grep -rn "iptables" /etc/cron.d/ /etc/cron.daily/ 2>/dev/null
# 顺手把 rules.v4 的策略也改成 DROP,以防万一
sed -i 's/^:FORWARD ACCEPT/:FORWARD DROP/' /etc/iptables/rules.v4
⚠️ 不要装 iptables-persistent:它会把 Docker 的动态链
DOCKER / DOCKER-FORWARD / nat 表 DNAT)一起保存,
重启后和 Docker 自建规则打架。交给上面这个 systemd 服务在 Docker 起来后应用即可。

验证

iptables -L FORWARD -n | head -1              # policy DROP
iptables -L DOCKER-USER -n --line-numbers     # 第 1 条 AIMILI-3XUI
iptables -L AIMILI-3XUI -n --line-numbers     # 443/tcp + 12703/udp ACCEPT,其余 DROP

12. 订阅 可选

3x-ui 的订阅服务在容器内监听 2096,但没发布到宿主机,所以要经 NPM 反代出去。

12.1 关键前提:subId 全局唯一

v3 里 subId 在客户端之间必须唯一(重复会报 Duplicate subId)。
所以「一个订阅包含多个节点」的正确做法是:建一个客户端,同时挂到多个入站上

TOKEN=$(cat /root/vps-stack/.xui_token)
# 一个客户端挂 3 个入站;uuid(vless) 与 auth(hysteria) 服务端会自动生成
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"client":{"email":"sub-all","subId":"<你自己生成的随机串>","flow":"","enable":true},
       "inboundIds":[1,2,3]}' \
  http://172.20.0.50:2053/panel/api/clients/add
⚠️ flow 必须留空:统一客户端要兼容 WS,设了 xtls-rprx-vision 会破坏 WS 那条。

12.2 打开订阅服务并设置路径

curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  http://172.20.0.50:2053/panel/api/setting/all > /tmp/settings.json
python3 - <<PY
import json, os
d = json.load(open("/tmp/settings.json"))["obj"]
d["subEnable"] = True
d["subPort"]   = 2096
d["subPath"]   = "/sub/"
d["subDomain"] = os.environ["DOMAIN_MAIN"]
d["subURI"]    = "https://" + os.environ["DOMAIN_MAIN"] + "/sub/"
json.dump(d, open("/tmp/settings_new.json","w"), ensure_ascii=False)
PY
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d @/tmp/settings_new.json http://172.20.0.50:2053/panel/api/setting/update
docker restart 3xui        # 改 subPath 后必须重启才会生效
📌 setting/update 要传完整设置对象setting/all 返回的
obj,约 150+ 个键)。
📌 好消息:xrayTemplateConfig 不在 /all 返回里,所以整体回写不会碰到它。

12.3 让 Reality / Hy2 导出 IP,WS 导出域名

subDomain统一把地址改成域名。想按入站分别指定,用
hosts 记录(它会覆盖 subDomain):

# Reality → IP
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d "{\"inboundIds\":[1],\"remark\":\"reality-ip\",\"hosts\":[\"$SERVER_IP\"],\"port\":443,\"security\":\"same\",\"tags\":[\"Reality\"]}" \
  http://172.20.0.50:2053/panel/api/hosts/add
# Hysteria2 → IP
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d "{\"inboundIds\":[3],\"remark\":\"hy2-ip\",\"hosts\":[\"$SERVER_IP\"],\"port\":12703,\"security\":\"same\",\"tags\":[\"Hysteria2\"]}" \
  http://172.20.0.50:2053/panel/api/hosts/add
# WS → 域名(TLS 必需,security 要显式写 tls,因为入站本身是 none)
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d "{\"inboundIds\":[2],\"remark\":\"ws-tls\",\"hosts\":[\"$DOMAIN_MAIN\"],\"port\":443,\"security\":\"tls\",
       \"sni\":\"$DOMAIN_MAIN\",\"hostHeader\":\"$DOMAIN_MAIN\",\"path\":\"$WS_PATH\",\"tags\":[\"WS\"]}" \
  http://172.20.0.50:2053/panel/api/hosts/add
为什么值得这么做:Reality 和 Hy2 用 IP 就完全不依赖 DNS——
以后就算域名开了 CF 小黄云(解析到 CF IP),这两个节点照样能用。

12.4 用 NPM 把 2096 暴露出去

TOKEN=$(cat /root/vps-stack/.npm_token)
curl -s -H "Authorization: Bearer $TOKEN" http://172.20.0.20:81/api/nginx/proxy-hosts/1 > /tmp/h1.json
python3 - <<'PY'
import json
d = json.load(open("/tmp/h1.json"))
d["locations"] = [l for l in d["locations"] if l["path"] != "/sub"]
d["locations"].append({"path":"/sub","forward_scheme":"http","forward_host":"172.20.0.50",
                       "forward_port":2096,"advanced_config":"","forward_path":""})
json.dump(d, open("/tmp/h1_new.json","w"), ensure_ascii=False)
PY
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d @/tmp/h1_new.json http://172.20.0.20:81/api/nginx/proxy-hosts/1

订阅地址就是:https://example.com/sub/<你的subId>

⚠️ 订阅服务会校验 Host 头,必须用域名访问;用 IP 会返回 403

13. 把 AimiliVPN 管理页放到公网 可选

AimiliVPN 的管理页(172.20.0.1:8787)默认只能走 SSH 隧道。想用域名访问,
先要理解这个 UI 的路径守卫——它不是一个普通 Web 应用:

  • 它只服务 /{secret_path}/ 前缀下的请求,其余一律 404;访问 /302/{secret_path}/
    secret_path 是首次启动自动生成的随机串,存在 vpngate_data/ui_auth.json
  • 登录 cookie 的 Path硬编码/{secret_path}/
  • 所有 POST 都有 CSRF 校验:Origin / Referer 的 netloc 必须等于 Host
  • 前端所有请求都是相对路径fetch("./api/...")
📌 所以正确做法是让 secret_path 本身等于对外路径段
而不是在 nginx 里改写路径(改写会让 cookie Path 对不上,登录静默失败)。
# 1) 选一个对外路径,比如 /vp,然后把它设成 secret_path
#    该值只在进程启动时读入,运行期改文件会被覆盖 → 必须先停容器
docker stop aimilivpn
python3 - <<'PY'
import json
p = "/opt/aimilivpn/vpngate_data/ui_auth.json"
d = json.load(open(p))
d["secret_path"] = "vp"          # 只用字母数字,且别用可猜的词
json.dump(d, open(p, "w"), indent=2, ensure_ascii=False)
PY
docker start aimilivpn           # 重启后代理要 2~3 分钟才恢复,正常

# 2) 在 NPM 的「面板域名」上挂一条自定义位置
TOKEN=$(cat /root/vps-stack/.npm_token)
curl -s -H "Authorization: Bearer $TOKEN" http://172.20.0.20:81/api/nginx/proxy-hosts/2 > /tmp/h2.json
python3 - <<'PY'
import json
d = json.load(open("/tmp/h2.json"))
d["locations"] = [l for l in d["locations"] if l["path"] != "/vp"]
d["locations"].insert(0, {"path":"/vp","forward_scheme":"http","forward_host":"172.20.0.1",
                          "forward_port":8787,"advanced_config":"","forward_path":""})
json.dump(d, open("/tmp/h2_new.json","w"), ensure_ascii=False)
PY
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d @/tmp/h2_new.json http://172.20.0.20:81/api/nginx/proxy-hosts/2

访问 https://panel.example.com/vp/ 即可。

⚠️ proxy_pass 不能带 URI 段(要写成 http://172.20.0.1:8787),
否则路径改写会破坏 cookie 与 CSRF 的一致性。NPM 生成的自定义位置块本身会带
proxy_set_header Host $host,所以 CSRF 校验能自然通过。
🔒 安全提醒:这么做等于把管理页放到公网,而 secret_path 从随机串变成了可猜的词——
安全性就只剩账号密码了。想收紧二选一:

  • 在 NPM 这条 location 上加 Access List / basic auth
  • 或给这个面板单独设一个强密码(别和服务器上其它服务共用)

改这个面板的账号密码用它自带的接口(改完立即生效,不用重启):

B="https://panel.example.com"; P="vp"
curl -s -c /tmp/cj -X POST -H 'Content-Type: application/json' \
  -H "Origin: $B" -H "Referer: $B/$P/" \
  -d '{"username":"<账号>","password":"<强密码>"}' "$B/$P/api/login"
curl -s -b /tmp/cj -X POST -H 'Content-Type: application/json' \
  -H "Origin: $B" -H "Referer: $B/$P/" \
  -d "{\"username\":\"<账号>\",\"password\":\"<强密码>\",\"port\":8787,\"secret_path\":\"$P\"}" \
  "$B/$P/api/update_credentials"
📌 注意:改用户名或密码会清空所有会话(需重新登录),不重启进程;
端口或 secret_path 会让进程自杀并由 entrypoint 自动重启。
📌 另外它会在数据目录写一份 CREDENTIALS.txt,但每次容器启动都会重新生成
且里面的「密码」字段是哈希不是明文——别把它当密码来源,真正的凭据放你自己的 .env

14. 延迟调优 可选

14.1 先接受一个事实:Reality 会比 Hysteria2 慢

客户端里 Reality 的延迟通常是 Hysteria2 的 2 倍左右,这是协议设计差异,不是故障

Reality Hysteria2
传输 TCP + TLS 1.3 QUIC / UDP
首字节前往返 2 个 RTT(TCP 握手 + TLS 握手) 1 个 RTT(会话复用可 0-RTT)
丢包时 重传、慢启动、队头阻塞 Brutal 拥塞控制忽略丢包
客户端测延迟时 每次探测常要新建 TCP+TLS 常复用已建立的 QUIC 连接

排查方法:测一下到服务器的 TCP RTT 和完整 TLS 1.3 握手耗时,
再测一个就近目标做对照。如果就近目标的 TLS 握手也是它 TCP RTT 的 2~3 倍,
说明是接入链路让额外往返变贵,不是服务器问题。

14.2 两个低风险优化(都不改架构)

① 关掉空闲后的慢启动(代理长连接空闲再突发时,TCP 会退回慢启动,首屏明显变慢):

cat > /etc/sysctl.d/99-zz-proxy-tuning.conf <<'EOF'
# 代理栈 TCP 调优:长连接空闲后不退回慢启动
# 回滚:删除本文件,并 sysctl -w net.ipv4.tcp_slow_start_after_idle=1
net.ipv4.tcp_slow_start_after_idle = 0
EOF
sysctl --system
sysctl -n net.ipv4.tcp_slow_start_after_idle      # 期望 0

② 给 Reality 入站开 TCP Fast Open(重复连接省掉 TCP 握手那 1 个 RTT):

# 先确认内核已开:sysctl -n net.ipv4.tcp_fastopen   期望 3(1=客户端 2=服务端 3=都开)
# 再给入站加 sockopt(整对象回写,不能只传部分字段)
TOKEN=$(cat /root/vps-stack/.xui_token)
curl -s -H "Authorization: Bearer $TOKEN" http://172.20.0.50:2053/panel/api/inbounds/get/1 > /tmp/ib1.json
python3 - <<'PY'
import json
d = json.load(open("/tmp/ib1.json"))["obj"]
d["streamSettings"].setdefault("sockopt", {})["tcpFastOpen"] = True
json.dump(d, open("/tmp/ib1_new.json", "w"), ensure_ascii=False)
PY
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d @/tmp/ib1_new.json http://172.20.0.50:2053/panel/api/inbounds/update/1
⚠️ 服务器单边开启只是「允许」,不会自动生效——客户端也要打开 TFO
(客户端软件里的「TCP Fast Open」开关)。没开 TFO 的客户端行为完全不变,
所以这个改动对现有客户端是零风险的。
少数运营商会丢弃「带数据的 SYN」,如果客户端开了 TFO 后反而连不上,优先怀疑这里,直接回滚。
这两项都动不了「QUIC 少一个往返」的结构性优势。
延迟敏感就用 Hysteria2,要伪装和抗封锁就用 Reality——别强求拉平。

15. 验证清单

# 容器全在
docker ps --format 'table {{.Names}}\t{{.Status}}'

# 出站分流(返回 VPN 出口 IP,不是服务器 IP)
docker exec 3xui sh -c 'curl -s -4 --max-time 15 --proxy http://172.20.0.1:7928 https://api.ipify.org'
# 直连出网(返回服务器 IP)
docker exec 3xui sh -c 'curl -s -4 --max-time 15 https://api.ipify.org'

# 伪装是否正常(应拿到 WordPress 首页)
curl -k --resolve example.com:443:203.0.113.10 https://example.com/ | grep -o '<title>[^<]*</title>'

# WS 链路(期望 101)
curl -k -o /dev/null -w '%{http_code}\n' \
  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
  --resolve example.com:443:203.0.113.10 https://example.com/ws

# 证书目录编号
docker exec 3xui ls -la /npm-certs/live/
最终验收:reboot 之后——容器全自启、FORWARD 仍 DROP、
三个节点仍可连、https://你的域名 打得开站点。

16. 踩坑速查表

# 解法
1 iptables -I INPUT --dport xxx 放行节点端口无效 Docker 发布的端口走 FORWARD,规则要写 DOCKER-USER
2 第三方脚本的 @reboot iptables-restore 把 FORWARD 改回 ACCEPT 删掉该 crontab,并修正 rules.v4
3 /etc/docker/daemon.json 被整体覆盖 用 Python 合并写入
4 iptables-persistent 保存了 Docker 动态链导致重启冲突 不装,交给 systemd 服务在 Docker 之后应用
5 手动跑 xray 二进制导致配置错乱 只从面板「重启 Xray」
6 Hysteria2 证书路径硬编码 npm-3 ls /npm-certs/live/ 实测
7 mysql:5.7 已 EOL 个人自用可接受,或换 8.0
8 NPM 默认密码没改 首登立刻改
9 POST setting/update 静默丢弃 xrayTemplateConfig 停容器 → 直接写 SQLite → 启容器
10 POST clients/updateallowedIPscannot unmarshal string into []string 最小载荷(email/uuid/flow/subId/enable)
11 subId 重复报 Duplicate subId 一个客户端挂多个入站,而不是多个客户端共用一个 subId
12 subPath 不生效 订阅服务只在启动时读设置 → docker restart 3xui
13 订阅链接是内网地址 172.20.0.50 subDomain,或用 hosts 按入站指定
14 订阅用 IP 访问返回 403 必须用域名(Host 头要等于 subDomain
15 NPM 的 locations 被清空 PUT 时要带上已有位置(全量覆盖)
16 重启后代理不通,日志报 tun0 不存在 等 2~3 分钟,AimiliVPN 在扫 vpngate 节点,正常现象
17 用 IP 当 SNI 访问 443 失败 Reality 正常行为(IP 不在 serverNames),不是故障
18 pkill -f <配置名> 把 ssh 远程 shell 也杀了 用 pidfile 记 PID,按 PID kill
19 容器内端口用宿主机 ss 看不到 docker exec <容器> netstat -lntp
20 自定义 sysctl 不生效 sysctl.d 按文件名字典序加载,文件名要带 zz- 前缀才排最后
21 改 AimiliVPN 的 secret_path 不生效 / 被改回去 该值只在进程启动时读入,必须 docker stop → 改 JSON → docker start
22 AimiliVPN 管理页反代后登录失败 它的 cookie Pathsecret_path 绑定,不能用 nginx 改写路径,要让 secret_path 等于对外路径
23 开了 TFO 却没变快 服务器只是「允许」,客户端也要开
24 服务端 POST inbounds/update 后字段丢失 必须整对象回写(先 GET,改完再 POST 回去)
25 面板里锁了「固定地区 + 只挑住宅 IP」后分流频繁断 免费节点池被收窄,把 IP 类型放回 all 会明显改善

17. 关于 CF 小黄云(橙云)

可以开,但只有 WS 那条节点会受影响——前提是你按
§12.3 把 Reality / Hy2 导出成了 IP。

开之前必须确认 3 件事:

检查项 位置 要设成
SSL/TLS 加密模式 SSL/TLSOverview → Configure FullFull (strict)绝不能 Flexible
Always Use HTTPS SSL/TLSEdge Certificates 关闭(否则 LE 续期可能失败)
WebSockets Network 默认全局支持,通常没有开关,看不到就是开着
⚠️ 为什么不能 FlexibleNPM 的 80 端口是 301 → HTTPS
ssl_forced=true)。Flexible 下 CF 用 HTTP 回源 80 → 被 301 → 再回源 →
无限重定向,站点和面板全挂。

18. 运维备忘

  1. 证书续期后要重启 Xray:Hysteria2 在启动时读取 live/npm-N/*.pem
    NPM 自动续期后(一般 90 天一次),执行 docker restart 3xui 或在面板点「重启 Xray」。
  2. AimiliVPN 重启后 2~3 分钟才首连成功,免费 vpngate 节点出口 IP 每次都变。
  3. 内存:1GB 机器跑这套大约剩 300~400MB,加服务前先看 free -m
  4. 凭据管理:所有密码只放 /root/vps-stack/.envchmod 600)。
    对外分享部署记录时,把域名、IP、账号、密码、UUID、Reality 公私钥、shortId、
    Hysteria2 密码、subId、以及 WS 路径全部替换成占位符。
  5. 端口与归属(改架构前先看这张表,避免踩到共用关系):
宿主机端口 归属 说明
443/tcp 3x-ui(Xray) Reality 入站;非 Reality 流量 fallback 到 NPM 容器内 443
80/tcp NPM Let’s Encrypt HTTP-01 校验 + HTTP→HTTPS 跳转
81/tcp NPM(不发布 管理面板,仅经反代访问
12703/udp 3x-ui Hysteria2
12700-12710/tcp 3x-ui 仅发布;除 443 外全部被防火墙 DROP
🎉 部署完成。

443 由 3x-ui 独占,非 Reality 流量 fallback 到 NPM,对外只表现为一个普通博客站。

节点端口访问控制写在 DOCKER-USER 链,FORWARD 默认 DROP。

四个容器全部 unless-stopped,重启后自动恢复。

动手前请把所有占位符替换成你自己的值。



评论(0)

查看评论列表

暂无评论


发表评论

表情 颜文字
插入代码

访客信息

您的IP 216.73.216.87
归属地 查询中…
天气 查询中…