Tutorial: Self-Hosted VPN/Proxy Service
适用环境:Debian 12/13、Ubuntu 22.04/24.04,具备公网 IPv4 的 VPS。
本文以 3X-UI + VLESS/REALITY + Aimili-VPNGate + Nginx 订阅包装 为例。
请仅用于个人隐私保护、远程访问和合法网络用途,并遵守 VPS 所在地及使用地法律法规。
目录
一、购买 VPS
1. VPS 最低要求
建议配置:
| 项目 | 建议 |
|---|---|
| CPU | 1 核或以上 |
| 内存 | 1 GB 或以上 |
| 硬盘 | 10 GB 或以上 |
| 系统 | Debian 12/13 或 Ubuntu 22.04/24.04 |
| 网络 | 独立公网 IPv4 |
| 虚拟化 | KVM 优先 |
| 流量 | 每月 1 TB 或以上 |
| TUN/TAP | 部署 Aimili-VPNGate 时必须支持 |
2. 首次登录
购买后,服务商通常会提供:
1
2
3
服务器 IP
SSH 端口
root 密码
连接示例:
1
ssh root@你的VPS_IP
首次登录后更新系统:
1
2
apt update
apt upgrade -y
安装常用工具:
1
2
apt install -y curl wget git nano unzip ca-certificates \
python3 python3-yaml nginx
设置时区:
1
timedatectl set-timezone
检查公网地址:
1
2
curl -4 https://api.ipify.org
echo
检查 TUN:
1
ls -l /dev/net/tun
3. 基础安全设置
防火墙原则
只开放实际使用的端口:
- SSH 端口
- 3X-UI 面板端口
- 3X-UI 入站端口
- Nginx 的 80/443
- Aimili 管理端口仅按需开放
若使用 UFW:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
apt update
apt install -y ufw
# 默认策略:拒绝所有入站,允许所有出站
ufw default deny incoming
ufw default allow outgoing
# SSH
sudo ufw allow 22/tcp comment 'SSH'
# Nginx 网站
sudo ufw allow 80/tcp comment 'HTTP'
sudo ufw allow 443/tcp comment 'HTTPS'
# 3X-UI
sudo ufw allow 2096/tcp comment '3X-UI service'
sudo ufw allow 42257/tcp comment '3X-UI panel'
# Xray 入站
sudo ufw allow 41663/tcp comment 'Xray inbound'
sudo ufw allow 43189/tcp comment 'Xray inbound'
sudo ufw allow 50548/tcp comment 'Xray inbound'
# VPNGate 管理程序
sudo ufw allow 8787/tcp comment 'VPNGate manager'
# AI API
sudo ufw allow 4000/tcp comment 'aiapiport'
sudo ufw allow 4001/tcp comment 'aiapiport'
# 启用防火墙
ufw enable
# 查看规则
ufw status numbered
不要直接照抄端口。先确认 SSH 真实端口,避免把自己锁在服务器外。
二、搭建 3X-UI
3X-UI 是用于管理 Xray-core 的 Web 面板,支持 VLESS、VMess、Trojan、Shadowsocks、WireGuard、Hysteria2 等协议,并支持客户端流量统计、到期时间、订阅链接和路由管理。
官方项目:
1
https://github.com/MHSanaei/3x-ui
1. 一键安装
以 root 用户执行:
1
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
安装程序通常会生成:
- 随机面板用户名
- 随机面板密码
- 随机面板访问路径
- 面板端口
安装后可以随时打开管理菜单:
1
x-ui
查看服务状态:
1
systemctl status x-ui --no-pager
2. 登录面板
访问:
1
http://你的VPS_IP:面板端口/随机路径
首次登录后立即完成:
- 修改管理员用户名和密码。
- 保留随机 Web 路径,不要改成
/admin等常见路径。 - 开启两步验证。
- 配置 HTTPS。
- 备份面板数据库。
默认 SQLite 数据库通常位于:
1
/etc/x-ui/x-ui.db
备份示例:
1
cp /etc/x-ui/x-ui.db /root/x-ui-$(date +%F).db
3. 创建 VLESS + REALITY 入站
在 3X-UI 中进入:
1
入站列表 → 添加入站
推荐参数思路:
| 项目 | 建议 |
|---|---|
| 协议 | VLESS |
| 传输 | TCP/RAW |
| 安全 | REALITY |
| Flow | xtls-rprx-vision |
| 监听 IP | 留空或 0.0.0.0 |
| 端口 | 自定义高位端口 |
| Sniffing | 按需启用 |
| Fingerprint | chrome |
| 用户 UUID | 让面板自动生成 |
REALITY 的目标站点应选择:
- 支持 TLS 1.3
- 可从 VPS 正常访问
- 稳定可靠
- 不要使用自己的敏感业务域名
配置完成后:
- 保存入站。
- 放行对应端口。
- 在客户端导入分享链接或订阅链接。
- 测试延迟和实际访问。
防火墙放行示例
假设入站端口为 44321:
1
ufw allow 44321/tcp
云厂商还需要在安全组中放行相同端口。
4. 创建订阅
在 3X-UI 中为客户端开启订阅后,会得到类似地址:
1
https://你的IP或域名:订阅端口/clash/随机订阅令牌
注意:
- 订阅 URL 等同于节点密钥。
- 不要上传到公开仓库。
- 不要发到公开群聊。
- 怀疑泄露时应立即重建订阅令牌或客户端 UUID。
在 Clash Verge Rev 中导入后,3X-UI 订阅通常会通过响应头返回:
1
2
3
Profile-Update-Interval: 12
Profile-Web-Page-Url: ...
Subscription-Userinfo: upload=...; download=...; total=...; expire=...
其中 Subscription-Userinfo 用于显示已用流量、总流量和到期时间。
5. 3X-UI 常用管理命令
1
2
3
4
x-ui
systemctl status x-ui --no-pager
systemctl restart x-ui
journalctl -u x-ui -n 100 --no-pager
三、Aimili-VPNGate 部署
Aimili-VPNGate 是一个 Linux VPS 出站代理网关工具,会从 VPNGate 获取公开节点、测速并通过本机 HTTP/SOCKS5 代理提供出口。
项目地址:
1
https://github.com/baoweise-bot/aimili-vpngate
VPNGate 节点由第三方志愿者提供,稳定性、隐私性和可用性无法与自有或商业出口保证相同。不要通过公共节点传输高敏感明文数据。
1. 安装前检查
确认 TUN 设备:
1
ls -l /dev/net/tun
安装基础依赖:
1
2
apt update
apt install -y curl ca-certificates
2. 一键部署
以 root 用户执行:
1
bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
部署完成后,终端会显示管理地址,通常类似:
1
http://你的VPS_IP:8787/随机安全路径
命令行管理菜单:
1
ml
3. 默认端口
常见默认值:
| 功能 | 地址/端口 |
|---|---|
| Web 管理后台 | 8787/tcp |
| 本机 HTTP/SOCKS5 代理 | 127.0.0.1:7928 |
默认代理只监听本机是更安全的做法。不要无认证地将 7928 暴露到公网。
查看监听状态:
1
ss -lntp | grep -E '8787|7928'
4. 防火墙
只需要远程打开管理后台时:
1
ufw allow from 你的家庭公网IP to any port 8787 proto tcp
不建议:
1
ufw allow 7928/tcp
因为这可能将匿名代理暴露到公网,导致扫描、滥用和 VPS 被封。
5. 测试本机代理
测试 HTTP 代理:
1
2
curl -x http://127.0.0.1:7928 https://api.ipify.org
echo
测试 SOCKS5:
1
2
curl --socks5-hostname 127.0.0.1:7928 https://api.ipify.org
echo
查看直连出口:
1
2
curl https://api.ipify.org
echo
如果代理出口 IP 与 VPS 原 IP 不同,说明 VPNGate 出口已工作。
6. 让 VPS 上的程序使用 Aimili
临时设置 Shell 代理:
1
2
export http_proxy="http://127.0.0.1:7928"
export https_proxy="http://127.0.0.1:7928"
取消:
1
unset http_proxy https_proxy
Python 示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import requests
proxies = {
"http": "http://127.0.0.1:7928",
"https": "http://127.0.0.1:7928",
}
response = requests.get(
"https://api.ipify.org",
proxies=proxies,
timeout=20,
)
print(response.text)
7. 与 3X-UI 的关系
Aimili 默认提供的是 VPS 本机出口代理。它不会自动接管 3X-UI 的全部用户流量。
可选方案:
- 仅让 VPS 上特定程序走
127.0.0.1:7928。 - 在 Xray 出站中增加 HTTP/SOCKS 出站,再通过路由规则把特定域名流量送入该出站。
- 保持普通代理流量直接从 VPS 出口,仅在需要特定出口 IP 时使用 Aimili。
由于 3X-UI 不同版本的”出站”和”路由规则”界面可能变化,配置前应:
- 先备份
/etc/x-ui/x-ui.db。 - 在面板的 Xray 设置中添加 HTTP 或 SOCKS 出站。
- 地址填写
127.0.0.1。 - 端口填写
7928。 - 设置唯一 Tag,例如
aimili-out。 - 仅对测试域名创建路由规则。
- 验证无误后再扩大范围。
不要直接把所有流量切换到公共 VPNGate 节点,否则节点掉线时所有用户都会断网。
8. 常见问题
Cannot allocate tun
说明 VPS 没有启用 TUN/TAP。需要在服务商面板开启。
管理后台打不开
检查:
1
2
ss -lntp | grep 8787
ufw status
同时检查云厂商安全组。
节点池为 0
可能是 DNS 或 VPNGate API 无法访问。先检查:
1
curl -I https://www.vpngate.net/
已连接但没有流量
可能与 rp_filter 有关。优先使用:
1
ml
按工具提示进行检测和修复,不要盲目全局关闭系统安全设置。
四、Nginx 包装规则和使用量
本章实现以下链路:
1
2
3
4
5
6
7
8
9
10
11
12
13
3X-UI 原始 Clash 订阅
↓
Python 定时下载
↓
自动提取 rules.js 中的规则
↓
创建/更新 PROXY 策略组
↓
生成新的 clash.yaml
↓
保存原订阅流量响应头
↓
Nginx 对外提供新订阅
最终 Clash Verge Rev 订阅:
1
http://你的VPS_IP/clash.yaml
生产使用建议配置域名和 HTTPS,并将路径改成随机私密路径。
1. 安装依赖
1
2
apt update
apt install -y python3 python3-yaml nginx
创建目录:
1
2
mkdir -p /opt/clash-sub
chmod 700 /opt/clash-sub
2. 保存原订阅 URL
创建:
1
nano /opt/clash-sub/source_url
文件中只放一行:
1
https://你的3XUI地址:订阅端口/clash/你的随机令牌
设置权限:
1
chmod 600 /opt/clash-sub/source_url
3. 准备全局规则脚本
将 Clash Verge Rev 的全局扩展脚本上传为:
1
/opt/clash-sub/rules.js
例如:
1
ls -lh /opt/clash-sub/rules.js
更新脚本会从 JavaScript 数组中自动提取以下规则类型:
1
2
3
4
5
6
7
8
DOMAIN
DOMAIN-SUFFIX
PROCESS-NAME
IP-CIDR
IP-CIDR6
IP-ASN
GEOSITE
GEOIP
规则中的策略组名称使用:
1
2
PROXY
DIRECT
4. 创建 Nginx 响应头片段
1
2
3
4
5
cat > /etc/nginx/snippets/clash-sub-headers.conf <<'EOF'
add_header Profile-Update-Interval "12" always;
add_header Profile-Web-Page-Url "" always;
add_header Subscription-Userinfo "upload=0; download=0; total=0; expire=0" always;
EOF
5. 配置 Nginx
编辑:
1
nano /etc/nginx/sites-enabled/default
在 server { ... } 内、普通 location / 之前加入:
1
2
3
4
location = /clash.yaml {
try_files /clash.yaml =404;
include /etc/nginx/snippets/clash-sub-headers.conf;
}
完整结构示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/html;
server_name _;
location = /clash.yaml {
try_files /clash.yaml =404;
include /etc/nginx/snippets/clash-sub-headers.conf;
}
location / {
try_files $uri $uri/ =404;
}
}
检查并重载:
1
2
nginx -t
systemctl reload nginx
6. 创建订阅加工脚本
创建:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
cat > /opt/clash-sub/update.py <<'PY'
#!/usr/bin/env python3
import re
import ssl
import subprocess
import urllib.request
from pathlib import Path
import yaml
BASE = Path("/opt/clash-sub")
SOURCE_FILE = BASE / "source_url"
RULES_FILE = BASE / "rules.txt"
RULES_JS_FILE = BASE / "rules.js"
OUTPUT_FILE = Path("/var/www/html/clash.yaml")
NGINX_HEADERS_FILE = Path(
"/etc/nginx/snippets/clash-sub-headers.conf"
)
def download(url: str):
context = ssl._create_unverified_context()
request = urllib.request.Request(
url,
headers={"User-Agent": "clash-verge/v2"},
)
with urllib.request.urlopen(
request,
context=context,
timeout=30,
) as response:
raw = response.read()
headers = {
key.lower(): value
for key, value in response.headers.items()
}
return raw, headers
def extract_rules():
if RULES_JS_FILE.exists():
js_content = RULES_JS_FILE.read_text(
encoding="utf-8"
)
rules = re.findall(
r'^\s*"((?:DOMAIN|DOMAIN-SUFFIX|PROCESS-NAME|'
r'IP-CIDR|IP-CIDR6|IP-ASN|GEOSITE|GEOIP)'
r'[^"]+)"\s*,?\s*$',
js_content,
flags=re.MULTILINE,
)
if not rules:
raise RuntimeError(
"rules.js 存在,但没有提取到规则"
)
RULES_FILE.write_text(
"\n".join(rules) + "\n",
encoding="utf-8",
)
return rules
if RULES_FILE.exists():
return [
line.strip()
for line in RULES_FILE.read_text(
encoding="utf-8"
).splitlines()
if line.strip()
and not line.lstrip().startswith("#")
]
return []
def update_nginx_headers(headers):
subscription_info = headers.get(
"subscription-userinfo",
"upload=0; download=0; total=0; expire=0",
)
update_interval = headers.get(
"profile-update-interval",
"12",
)
profile_url = headers.get(
"profile-web-page-url",
"",
)
def escape(value: str) -> str:
return (
value.replace("\\", "\\\\")
.replace('"', '\\"')
)
content = (
f'add_header Profile-Update-Interval '
f'"{escape(update_interval)}" always;\n'
f'add_header Profile-Web-Page-Url '
f'"{escape(profile_url)}" always;\n'
f'add_header Subscription-Userinfo '
f'"{escape(subscription_info)}" always;\n'
)
temp_file = NGINX_HEADERS_FILE.with_suffix(
".tmp"
)
temp_file.write_text(
content,
encoding="utf-8",
)
temp_file.replace(NGINX_HEADERS_FILE)
subprocess.run(
["nginx", "-t"],
check=True,
stdout=subprocess.DEVNULL,
)
subprocess.run(
["systemctl", "reload", "nginx"],
check=True,
)
return subscription_info
def main():
source_url = SOURCE_FILE.read_text(
encoding="utf-8"
).strip()
if not source_url:
raise RuntimeError("source_url 为空")
raw, headers = download(source_url)
config = yaml.safe_load(raw)
if not isinstance(config, dict):
raise RuntimeError("原始订阅不是有效 YAML")
proxies = config.get("proxies", [])
if not isinstance(proxies, list) or not proxies:
raise RuntimeError("原始订阅中没有节点")
proxy_names = [
proxy.get("name")
for proxy in proxies
if isinstance(proxy, dict)
and proxy.get("name")
]
groups = config.get("proxy-groups")
if not isinstance(groups, list):
groups = []
proxy_group = next(
(
group
for group in groups
if isinstance(group, dict)
and group.get("name") == "PROXY"
),
None,
)
if proxy_group is None:
groups.insert(
0,
{
"name": "PROXY",
"type": "select",
"proxies": proxy_names,
},
)
else:
proxy_group["proxies"] = proxy_names
config["proxy-groups"] = groups
custom_rules = extract_rules()
old_rules = config.get("rules", [])
if not isinstance(old_rules, list):
old_rules = []
config["rules"] = custom_rules + old_rules
if not any(
isinstance(rule, str)
and rule.startswith(("MATCH,", "FINAL,"))
for rule in config["rules"]
):
config["rules"].append("MATCH,PROXY")
OUTPUT_FILE.parent.mkdir(
parents=True,
exist_ok=True,
)
temp_file = OUTPUT_FILE.with_suffix(".tmp")
temp_file.write_text(
yaml.safe_dump(
config,
allow_unicode=True,
sort_keys=False,
default_flow_style=False,
),
encoding="utf-8",
)
temp_file.replace(OUTPUT_FILE)
subscription_info = update_nginx_headers(
headers
)
print(f"生成成功:{OUTPUT_FILE}")
print(f"节点数量:{len(proxy_names)}")
print(f"自定义规则:{len(custom_rules)}")
print(f"流量信息:{subscription_info}")
if __name__ == "__main__":
main()
PY
设置权限:
1
chmod 700 /opt/clash-sub/update.py
7. 手动运行
1
python3 /opt/clash-sub/update.py
正常输出示例:
1
2
3
4
生成成功:/var/www/html/clash.yaml
节点数量:2
自定义规则:211
流量信息:upload=...; download=...; total=...; expire=0
检查生成内容:
1
2
grep -nE 'name: PROXY|DOMAIN-SUFFIX|MATCH,' \
/var/www/html/clash.yaml | head
检查 Nginx 响应头:
1
curl -sSI http://127.0.0.1/clash.yaml
应看到:
1
2
3
HTTP/1.1 200 OK
Profile-Update-Interval: 12
Subscription-Userinfo: upload=...; download=...; total=...; expire=...
8. 创建自动更新任务
创建服务:
1
2
3
4
5
6
7
8
9
10
cat > /etc/systemd/system/clash-sub-update.service <<'EOF'
[Unit]
Description=Update processed Clash subscription
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /opt/clash-sub/update.py
EOF
创建定时器:
1
2
3
4
5
6
7
8
9
10
11
12
cat > /etc/systemd/system/clash-sub-update.timer <<'EOF'
[Unit]
Description=Update processed Clash subscription every 10 minutes
[Timer]
OnBootSec=1min
OnUnitActiveSec=10min
Persistent=true
[Install]
WantedBy=timers.target
EOF
启用:
1
2
systemctl daemon-reload
systemctl enable --now clash-sub-update.timer
查看:
1
2
systemctl status clash-sub-update.timer --no-pager
systemctl list-timers --all | grep clash-sub
查看最近一次运行日志:
1
2
journalctl -u clash-sub-update.service \
-n 100 --no-pager
9. 客户端订阅
在 Clash Verge Rev 中添加:
1
http://你的VPS_IP/clash.yaml
点击更新订阅后应看到:
- 原 3X-UI 节点
PROXY策略组- 自定义规则
- 已用流量与总流量
10. 修改规则后的更新逻辑
直接修改:
1
/opt/clash-sub/rules.js
系统会在最多 10 分钟内:
- 重新下载 3X-UI 原订阅。
- 从
rules.js重新提取规则。 - 更新
rules.txt。 - 生成新的
clash.yaml。 - 更新流量响应头。
- 重载 Nginx。
立即手动更新:
1
python3 /opt/clash-sub/update.py
然后在 Clash Verge Rev 中点击”更新订阅”。
11. 使用随机私密路径
公开 /clash.yaml 容易被扫描。更安全的方式是使用随机路径,例如:
1
/sub/7c31a4f26b984c32.yaml
修改 Nginx:
1
2
3
4
5
location = /sub/7c31a4f26b984c32.yaml {
alias /var/www/html/clash.yaml;
default_type application/yaml;
include /etc/nginx/snippets/clash-sub-headers.conf;
}
不要使用本文示例字符串,应自行生成:
1
openssl rand -hex 16
检查并重载:
1
nginx -t && systemctl reload nginx
12. 配置 HTTPS
推荐为 VPS 绑定域名,然后通过 Certbot 配置证书:
1
2
apt install -y certbot python3-certbot-nginx
certbot --nginx -d sub.example.com
最终订阅示例:
1
https://sub.example.com/sub/随机字符串.yaml
没有域名时也能使用 HTTP,但:
- 订阅内容可能在传输途中被看到。
- 订阅 URL 中包含节点凭证。
- 不建议长期使用明文 HTTP。
五、最终架构
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
客户端 Clash Verge Rev
│
│ HTTPS/HTTP 订阅
▼
Nginx
│
├── 返回 clash.yaml
├── 返回 Profile-Update-Interval
└── 返回 Subscription-Userinfo
│
▼
/opt/clash-sub/update.py
│
├── 下载 3X-UI 原订阅
├── 读取节点和流量信息
├── 提取 rules.js
├── 注入 PROXY 策略组
└── 生成最终 YAML
│
▼
3X-UI
│
└── Xray/VLESS/REALITY 入站
Aimili-VPNGate
│
├── 获取 VPNGate 公共节点
└── 在 127.0.0.1:7928 提供可选出站代理
六、参考项目
- 3X-UI:
https://github.com/MHSanaei/3x-ui - 3X-UI 安装文档:
https://github.com/MHSanaei/3x-ui/wiki/Installation - Aimili-VPNGate:
https://github.com/baoweise-bot/aimili-vpngate - VPNGate:
https://www.vpngate.net/
免责声明
本文仅用于个人服务器管理、隐私保护、远程访问和技术学习。公共 VPNGate 节点不应被视为可信隐私服务。请勿使用本教程搭建开放代理、销售未授权网络服务、规避组织安全管理或从事任何违法活动。
Applicable environment: Debian 12/13, Ubuntu 22.04/24.04, VPS with a public IPv4 address.
This article uses 3X-UI + VLESS/REALITY + Aimili-VPNGate + Nginx subscription wrapping as an example.
Please use only for personal privacy protection, remote access, and legal network purposes, and comply with the laws and regulations of both the VPS location and your usage location.
Table of Contents
- Purchasing a VPS
- Setting Up 3X-UI
- Deploying Aimili-VPNGate
- Using Nginx to Wrap Subscriptions, Inject Rules, and Preserve Traffic Usage
- Final Architecture
- Reference Projects
1. Purchasing a VPS
1.1 Minimum VPS Requirements
Recommended configuration:
| Item | Recommendation |
|---|---|
| CPU | 1 core or more |
| Memory | 1 GB or more |
| Disk | 10 GB or more |
| OS | Debian 12/13 or Ubuntu 22.04/24.04 |
| Network | Dedicated public IPv4 |
| Virtualization | KVM preferred |
| Bandwidth | 1 TB per month or more |
| TUN/TAP | Required for Aimili-VPNGate deployment |
Key points to verify before purchasing:
- The VPS is not a NAT VPS, or at least can map the required ports.
- The provider allows personal proxy, VPN, or tunneling use.
- TUN/TAP is supported.
- The IP is not heavily blocked by commonly used websites.
- Network latency and packet loss to your location are acceptable.
- The cloud provider’s security group allows opening custom TCP/UDP ports.
1.2 First Login
After purchase, the provider typically provides:
1
2
3
Server IP
SSH port
root password
Connection example:
1
ssh root@your_VPS_IP
Update the system after first login:
1
2
apt update
apt upgrade -y
Install common tools:
1
2
apt install -y curl wget git nano unzip ca-certificates \
python3 python3-yaml nginx
Set the timezone:
1
timedatectl set-timezone Asia/Shanghai
Check the public IP address:
1
2
curl -4 https://api.ipify.org
echo
Check TUN:
1
ls -l /dev/net/tun
You should see something like:
1
crw-rw-rw- 1 root root ... /dev/net/tun
If it doesn’t exist, you need to enable TUN/TAP in the VPS control panel or contact the provider.
1.3 Basic Security Setup
Change SSH Password
1
passwd
Recommended: Enable SSH Key Authentication
Generate a key on your local machine:
1
ssh-keygen -t ed25519
Copy the public key to the VPS:
1
ssh-copy-id root@your_VPS_IP
After confirming key login works, consider disabling SSH password login.
Firewall Principles
Only open ports that are actually used:
- SSH port
- 3X-UI panel port
- 3X-UI inbound ports
- Nginx ports 80/443
- Aimili management port only on demand
If using UFW:
1
2
3
4
5
6
apt install -y ufw
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
ufw status
Don’t copy ports blindly. First confirm the actual SSH port to avoid locking yourself out of the server.
2. Setting Up 3X-UI
3X-UI is a web panel for managing Xray-core, supporting VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, and other protocols, with support for client traffic statistics, expiration times, subscription links, and routing management.
Official project:
1
https://github.com/MHSanaei/3x-ui
2.1 One-Click Installation
Run as root:
1
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
The installer typically generates:
- Random panel username
- Random panel password
- Random panel access path
- Panel port
After installation, you can open the management menu at any time:
1
x-ui
Check service status:
1
systemctl status x-ui --no-pager
2.2 Logging into the Panel
Access:
1
http://your_VPS_IP:panel_port/random_path
After first login, immediately complete:
- Change the admin username and password.
- Keep the random web path; don’t change it to common paths like
/admin. - Enable two-factor authentication.
- Configure HTTPS.
- Back up the panel database.
The default SQLite database is typically located at:
1
/etc/x-ui/x-ui.db
Backup example:
1
cp /etc/x-ui/x-ui.db /root/x-ui-$(date +%F).db
2.3 Creating a VLESS + REALITY Inbound
In 3X-UI, go to:
1
Inbound List → Add Inbound
Recommended parameter approach:
| Item | Recommendation |
|---|---|
| Protocol | VLESS |
| Transport | TCP/RAW |
| Security | REALITY |
| Flow | xtls-rprx-vision |
| Listen IP | Leave blank or 0.0.0.0 |
| Port | Custom high port |
| Sniffing | Enable as needed |
| Fingerprint | chrome |
| User UUID | Let the panel auto-generate |
REALITY destination sites should be chosen based on:
- Supports TLS 1.3
- Accessible from the VPS
- Stable and reliable
- Do not use your own sensitive business domains
After configuration:
- Save the inbound.
- Open the corresponding port.
- Import the share link or subscription link on the client.
- Test latency and actual access.
Firewall Port Opening Example
Assuming the inbound port is 44321:
1
ufw allow 44321/tcp
Cloud providers also need to open the same port in the security group.
2.4 Creating a Subscription
After enabling subscriptions for clients in 3X-UI, you’ll get an address like:
1
https://your_IP_or_domain:subscription_port/clash/random_subscription_token
Note:
- The subscription URL is equivalent to a node key.
- Do not upload to public repositories.
- Do not share in public group chats.
- If you suspect a leak, immediately rebuild the subscription token or client UUID.
After importing in Clash Verge Rev, the 3X-UI subscription typically returns via response headers:
1
2
3
Profile-Update-Interval: 12
Profile-Web-Page-Url: ...
Subscription-Userinfo: upload=...; download=...; total=...; expire=...
Where Subscription-Userinfo displays used traffic, total traffic, and expiration time.
2.5 Common 3X-UI Management Commands
1
2
3
4
x-ui
systemctl status x-ui --no-pager
systemctl restart x-ui
journalctl -u x-ui -n 100 --no-pager
3. Deploying Aimili-VPNGate
Aimili-VPNGate is a Linux VPS outbound proxy gateway tool that obtains public nodes from VPNGate, tests their speed, and provides an egress through a local HTTP/SOCKS5 proxy.
Project address:
1
https://github.com/baoweise-bot/aimili-vpngate
VPNGate nodes are provided by third-party volunteers. Stability, privacy, and availability cannot be guaranteed to the same level as self-owned or commercial egress. Do not transmit highly sensitive plaintext data through public nodes.
3.1 Pre-Installation Check
Confirm TUN device:
1
ls -l /dev/net/tun
Install basic dependencies:
1
2
apt update
apt install -y curl ca-certificates
3.2 One-Click Deployment
Run as root:
1
bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
After deployment, the terminal will display the management address, typically like:
1
http://your_VPS_IP:8787/random_security_path
Command-line management menu:
1
ml
3.3 Default Ports
Common defaults:
| Function | Address/Port |
|---|---|
| Web admin panel | 8787/tcp |
| Local HTTP/SOCKS5 proxy | 127.0.0.1:7928 |
The default proxy only listening on localhost is the safer approach. Do not expose 7928 to the public internet without authentication.
Check listening status:
1
ss -lntp | grep -E '8787|7928'
3.4 Firewall
Only when remote access to the admin panel is needed:
1
ufw allow from your_home_public_IP to any port 8787 proto tcp
Not recommended:
1
ufw allow 7928/tcp
This could expose the anonymous proxy to the public internet, leading to scanning, abuse, and VPS banning.
3.5 Testing the Local Proxy
Test HTTP proxy:
1
2
curl -x http://127.0.0.1:7928 https://api.ipify.org
echo
Test SOCKS5:
1
2
curl --socks5-hostname 127.0.0.1:7928 https://api.ipify.org
echo
Check direct connection egress:
1
2
curl https://api.ipify.org
echo
If the proxy egress IP differs from the VPS’s original IP, the VPNGate egress is working.
3.6 Making Programs on the VPS Use Aimili
Temporarily set shell proxy:
1
2
export http_proxy="http://127.0.0.1:7928"
export https_proxy="http://127.0.0.1:7928"
Cancel:
1
unset http_proxy https_proxy
Python example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import requests
proxies = {
"http": "http://127.0.0.1:7928",
"https": "http://127.0.0.1:7928",
}
response = requests.get(
"https://api.ipify.org",
proxies=proxies,
timeout=20,
)
print(response.text)
3.7 Relationship with 3X-UI
Aimili provides a VPS local egress proxy by default. It does not automatically take over all user traffic from 3X-UI.
Optional approaches:
- Only let specific programs on the VPS use
127.0.0.1:7928. - Add an HTTP/SOCKS outbound in Xray outbounds, then use routing rules to direct specific domain traffic to that outbound.
- Keep regular proxy traffic going directly from the VPS egress, only using Aimili when a specific egress IP is needed.
Since the “Outbounds” and “Routing Rules” interfaces may vary across different versions of 3X-UI, before configuration:
- First back up
/etc/x-ui/x-ui.db. - Add an HTTP or SOCKS outbound in the panel’s Xray settings.
- Set the address to
127.0.0.1. - Set the port to
7928. - Set a unique Tag, e.g.,
aimili-out. - Only create routing rules for test domains.
- Expand the scope only after verification.
Do not directly switch all traffic to public VPNGate nodes, otherwise all users will lose connectivity when the node goes offline.
3.8 Common Issues
Cannot allocate tun
Indicates the VPS has not enabled TUN/TAP. Enable it in the provider’s panel.
Admin Panel Inaccessible
Check:
1
2
ss -lntp | grep 8787
ufw status
Also check the cloud provider’s security group.
Node Pool is 0
May be due to DNS or VPNGate API being inaccessible. First check:
1
curl -I https://www.vpngate.net/
Connected but No Traffic
May be related to rp_filter. Prioritize using:
1
ml
Follow the tool’s prompts for detection and repair. Do not blindly disable system security settings globally.
4. Nginx Rules and Usage Wrapping
This chapter implements the following pipeline:
1
2
3
4
5
6
7
8
9
10
11
12
13
3X-UI Original Clash Subscription
↓
Python scheduled download
↓
Automatically extract rules from rules.js
↓
Create/update PROXY policy group
↓
Generate new clash.yaml
↓
Preserve original subscription traffic response headers
↓
Nginx serves the new subscription externally
Final Clash Verge Rev subscription:
1
http://your_VPS_IP/clash.yaml
For production use, it is recommended to configure a domain name and HTTPS, and change the path to a random private path.
4.1 Install Dependencies
1
2
apt update
apt install -y python3 python3-yaml nginx
Create directory:
1
2
mkdir -p /opt/clash-sub
chmod 700 /opt/clash-sub
4.2 Save the Original Subscription URL
Create:
1
nano /opt/clash-sub/source_url
Put only one line in the file:
1
https://your_3XUI_address:subscription_port/clash/your_random_token
Set permissions:
1
chmod 600 /opt/clash-sub/source_url
4.3 Prepare the Global Rules Script
Upload the Clash Verge Rev global extension script as:
1
/opt/clash-sub/rules.js
For example:
1
ls -lh /opt/clash-sub/rules.js
The update script will automatically extract the following rule types from the JavaScript array:
1
2
3
4
5
6
7
8
DOMAIN
DOMAIN-SUFFIX
PROCESS-NAME
IP-CIDR
IP-CIDR6
IP-ASN
GEOSITE
GEOIP
Policy group names used in the rules:
1
2
PROXY
DIRECT
4.4 Create Nginx Response Header Snippet
1
2
3
4
5
cat > /etc/nginx/snippets/clash-sub-headers.conf <<'EOF'
add_header Profile-Update-Interval "12" always;
add_header Profile-Web-Page-Url "" always;
add_header Subscription-Userinfo "upload=0; download=0; total=0; expire=0" always;
EOF
4.5 Configure Nginx
Edit:
1
nano /etc/nginx/sites-enabled/default
Inside server { ... }, before the regular location /, add:
1
2
3
4
location = /clash.yaml {
try_files /clash.yaml =404;
include /etc/nginx/snippets/clash-sub-headers.conf;
}
Complete structure example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/html;
server_name _;
location = /clash.yaml {
try_files /clash.yaml =404;
include /etc/nginx/snippets/clash-sub-headers.conf;
}
location / {
try_files $uri $uri/ =404;
}
}
Check and reload:
1
2
nginx -t
systemctl reload nginx
4.6 Create the Subscription Processing Script
Create:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
cat > /opt/clash-sub/update.py <<'PY'
#!/usr/bin/env python3
import re
import ssl
import subprocess
import urllib.request
from pathlib import Path
import yaml
BASE = Path("/opt/clash-sub")
SOURCE_FILE = BASE / "source_url"
RULES_FILE = BASE / "rules.txt"
RULES_JS_FILE = BASE / "rules.js"
OUTPUT_FILE = Path("/var/www/html/clash.yaml")
NGINX_HEADERS_FILE = Path(
"/etc/nginx/snippets/clash-sub-headers.conf"
)
def download(url: str):
context = ssl._create_unverified_context()
request = urllib.request.Request(
url,
headers={"User-Agent": "clash-verge/v2"},
)
with urllib.request.urlopen(
request,
context=context,
timeout=30,
) as response:
raw = response.read()
headers = {
key.lower(): value
for key, value in response.headers.items()
}
return raw, headers
def extract_rules():
if RULES_JS_FILE.exists():
js_content = RULES_JS_FILE.read_text(
encoding="utf-8"
)
rules = re.findall(
r'^\s*"((?:DOMAIN|DOMAIN-SUFFIX|PROCESS-NAME|'
r'IP-CIDR|IP-CIDR6|IP-ASN|GEOSITE|GEOIP)'
r'[^"]+)"\s*,?\s*$',
js_content,
flags=re.MULTILINE,
)
if not rules:
raise RuntimeError(
"rules.js exists but no rules were extracted"
)
RULES_FILE.write_text(
"\n".join(rules) + "\n",
encoding="utf-8",
)
return rules
if RULES_FILE.exists():
return [
line.strip()
for line in RULES_FILE.read_text(
encoding="utf-8"
).splitlines()
if line.strip()
and not line.lstrip().startswith("#")
]
return []
def update_nginx_headers(headers):
subscription_info = headers.get(
"subscription-userinfo",
"upload=0; download=0; total=0; expire=0",
)
update_interval = headers.get(
"profile-update-interval",
"12",
)
profile_url = headers.get(
"profile-web-page-url",
"",
)
def escape(value: str) -> str:
return (
value.replace("\\", "\\\\")
.replace('"', '\\"')
)
content = (
f'add_header Profile-Update-Interval '
f'"{escape(update_interval)}" always;\n'
f'add_header Profile-Web-Page-Url '
f'"{escape(profile_url)}" always;\n'
f'add_header Subscription-Userinfo '
f'"{escape(subscription_info)}" always;\n'
)
temp_file = NGINX_HEADERS_FILE.with_suffix(
".tmp"
)
temp_file.write_text(
content,
encoding="utf-8",
)
temp_file.replace(NGINX_HEADERS_FILE)
subprocess.run(
["nginx", "-t"],
check=True,
stdout=subprocess.DEVNULL,
)
subprocess.run(
["systemctl", "reload", "nginx"],
check=True,
)
return subscription_info
def main():
source_url = SOURCE_FILE.read_text(
encoding="utf-8"
).strip()
if not source_url:
raise RuntimeError("source_url is empty")
raw, headers = download(source_url)
config = yaml.safe_load(raw)
if not isinstance(config, dict):
raise RuntimeError("Original subscription is not valid YAML")
proxies = config.get("proxies", [])
if not isinstance(proxies, list) or not proxies:
raise RuntimeError("No nodes in original subscription")
proxy_names = [
proxy.get("name")
for proxy in proxies
if isinstance(proxy, dict)
and proxy.get("name")
]
groups = config.get("proxy-groups")
if not isinstance(groups, list):
groups = []
proxy_group = next(
(
group
for group in groups
if isinstance(group, dict)
and group.get("name") == "PROXY"
),
None,
)
if proxy_group is None:
groups.insert(
0,
{
"name": "PROXY",
"type": "select",
"proxies": proxy_names,
},
)
else:
proxy_group["proxies"] = proxy_names
config["proxy-groups"] = groups
custom_rules = extract_rules()
old_rules = config.get("rules", [])
if not isinstance(old_rules, list):
old_rules = []
config["rules"] = custom_rules + old_rules
if not any(
isinstance(rule, str)
and rule.startswith(("MATCH,", "FINAL,"))
for rule in config["rules"]
):
config["rules"].append("MATCH,PROXY")
OUTPUT_FILE.parent.mkdir(
parents=True,
exist_ok=True,
)
temp_file = OUTPUT_FILE.with_suffix(".tmp")
temp_file.write_text(
yaml.safe_dump(
config,
allow_unicode=True,
sort_keys=False,
default_flow_style=False,
),
encoding="utf-8",
)
temp_file.replace(OUTPUT_FILE)
subscription_info = update_nginx_headers(
headers
)
print(f"Generation successful: {OUTPUT_FILE}")
print(f"Node count: {len(proxy_names)}")
print(f"Custom rules: {len(custom_rules)}")
print(f"Traffic info: {subscription_info}")
if __name__ == "__main__":
main()
PY
Set permissions:
1
chmod 700 /opt/clash-sub/update.py
4.7 Manual Run
1
python3 /opt/clash-sub/update.py
Expected output example:
1
2
3
4
Generation successful: /var/www/html/clash.yaml
Node count: 2
Custom rules: 211
Traffic info: upload=...; download=...; total=...; expire=0
Check generated content:
1
2
grep -nE 'name: PROXY|DOMAIN-SUFFIX|MATCH,' \
/var/www/html/clash.yaml | head
Check Nginx response headers:
1
curl -sSI http://127.0.0.1/clash.yaml
Should see:
1
2
3
HTTP/1.1 200 OK
Profile-Update-Interval: 12
Subscription-Userinfo: upload=...; download=...; total=...; expire=...
4.8 Create Auto-Update Task
Create service:
1
2
3
4
5
6
7
8
9
10
cat > /etc/systemd/system/clash-sub-update.service <<'EOF'
[Unit]
Description=Update processed Clash subscription
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /opt/clash-sub/update.py
EOF
Create timer:
1
2
3
4
5
6
7
8
9
10
11
12
cat > /etc/systemd/system/clash-sub-update.timer <<'EOF'
[Unit]
Description=Update processed Clash subscription every 10 minutes
[Timer]
OnBootSec=1min
OnUnitActiveSec=10min
Persistent=true
[Install]
WantedBy=timers.target
EOF
Enable:
1
2
systemctl daemon-reload
systemctl enable --now clash-sub-update.timer
View:
1
2
systemctl status clash-sub-update.timer --no-pager
systemctl list-timers --all | grep clash-sub
View the most recent run log:
1
2
journalctl -u clash-sub-update.service \
-n 100 --no-pager
4.9 Client Subscription
In Clash Verge Rev, add:
1
http://your_VPS_IP/clash.yaml
After clicking update subscription, you should see:
- Original 3X-UI nodes
PROXYpolicy group- Custom rules
- Used traffic and total traffic
4.10 Update Logic After Rule Changes
Directly modify:
1
/opt/clash-sub/rules.js
Within a maximum of 10 minutes, the system will:
- Re-download the original 3X-UI subscription.
- Re-extract rules from
rules.js. - Update
rules.txt. - Generate a new
clash.yaml. - Update traffic response headers.
- Reload Nginx.
Immediate manual update:
1
python3 /opt/clash-sub/update.py
Then click “Update Subscription” in Clash Verge Rev.
4.11 Using a Random Private Path
Exposing /clash.yaml publicly is easily scanned. A more secure approach is to use a random path, for example:
1
/sub/7c31a4f26b984c32.yaml
Modify Nginx:
1
2
3
4
5
location = /sub/7c31a4f26b984c32.yaml {
alias /var/www/html/clash.yaml;
default_type application/yaml;
include /etc/nginx/snippets/clash-sub-headers.conf;
}
Do not use the example string from this article. Generate your own:
1
openssl rand -hex 16
Check and reload:
1
nginx -t && systemctl reload nginx
4.12 Configure HTTPS
It is recommended to bind a domain name to the VPS, then configure a certificate via Certbot:
1
2
apt install -y certbot python3-certbot-nginx
certbot --nginx -d sub.example.com
Final subscription example:
1
https://sub.example.com/sub/random_string.yaml
HTTP can also be used without a domain name, but:
- Subscription content may be visible during transmission.
- The subscription URL contains node credentials.
- Long-term use of plaintext HTTP is not recommended.
5. Final Architecture
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Client: Clash Verge Rev
│
│ HTTPS/HTTP Subscription
▼
Nginx
│
├── Returns clash.yaml
├── Returns Profile-Update-Interval
└── Returns Subscription-Userinfo
│
▼
/opt/clash-sub/update.py
│
├── Downloads 3X-UI original subscription
├── Reads node and traffic information
├── Extracts rules.js
├── Injects PROXY policy group
└── Generates final YAML
│
▼
3X-UI
│
└── Xray/VLESS/REALITY inbound
Aimili-VPNGate
│
├── Obtains VPNGate public nodes
└── Provides optional egress proxy at 127.0.0.1:7928
6. Reference Projects
- 3X-UI:
https://github.com/MHSanaei/3x-ui - 3X-UI Installation Docs:
https://github.com/MHSanaei/3x-ui/wiki/Installation - Aimili-VPNGate:
https://github.com/baoweise-bot/aimili-vpngate - VPNGate:
https://www.vpngate.net/
Disclaimer
This article is for personal server management, privacy protection, remote access, and technical learning purposes only. Public VPNGate nodes should not be considered trusted privacy services. Do not use this tutorial to set up open proxies, sell unauthorized network services, circumvent organizational security management, or engage in any illegal activities.