本教程从零在 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 用) |
架构(先理解这个,后面才不会绕)
核心设计只有一句话:让 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 |
发布 443、12700-12710、12703/udp |
aimilivpn |
host 网络 | 代理 7928 / 管理页 8787 |
由 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/tcp、443/tcp、12703/udp
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+
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
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');"
别用带个人信息的名字——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}。
§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-VPN(http,指向
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)
tun0 不存在,代理会报
ERR_ROUTE_DEV_NOT_FOUND。这是正常现象,不是故障。另外免费节点很不稳定,出口 IP 每次都可能不一样;如果在面板里把路由锁成
「固定地区 + 只挑住宅 IP」,可用节点池会大幅收窄,中断会更频繁、恢复更慢。
10. 三个节点
或
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":"/"}}}
target(dest 只是兼容别名)。📌
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. 防火墙(这一步最容易做错)
-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
以后就算域名开了 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>
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
(客户端软件里的「TCP Fast Open」开关)。没开 TFO 的客户端行为完全不变,
所以这个改动对现有客户端是零风险的。
少数运营商会丢弃「带数据的 SYN」,如果客户端开了 TFO 后反而连不上,优先怀疑这里,直接回滚。
延迟敏感就用 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/update 的 allowedIPs 报 cannot 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 Path 与 secret_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/TLS → Overview → Configure |
Full 或 Full (strict),绝不能 Flexible |
| Always Use HTTPS | SSL/TLS → Edge Certificates |
关闭(否则 LE 续期可能失败) |
| WebSockets | Network |
默认全局支持,通常没有开关,看不到就是开着 |
Flexible:NPM 的 80 端口是 301 → HTTPS(
ssl_forced=true)。Flexible 下 CF 用 HTTP 回源 80 → 被 301 → 再回源 →无限重定向,站点和面板全挂。
18. 运维备忘
- 证书续期后要重启 Xray:Hysteria2 在启动时读取
live/npm-N/*.pem。
NPM 自动续期后(一般 90 天一次),执行docker restart 3xui或在面板点「重启 Xray」。 - AimiliVPN 重启后 2~3 分钟才首连成功,免费 vpngate 节点出口 IP 每次都变。
- 内存:1GB 机器跑这套大约剩 300~400MB,加服务前先看
free -m。 - 凭据管理:所有密码只放
/root/vps-stack/.env(chmod 600)。
对外分享部署记录时,把域名、IP、账号、密码、UUID、Reality 公私钥、shortId、
Hysteria2 密码、subId、以及 WS 路径全部替换成占位符。 - 端口与归属(改架构前先看这张表,避免踩到共用关系):
| 宿主机端口 | 归属 | 说明 |
|---|---|---|
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)
暂无评论