文章

教程:个人自建 VPN/代理服务

教程:个人自建 VPN/代理服务

适用环境:Debian 12/13、Ubuntu 22.04/24.04,具备公网 IPv4 的 VPS。
本文以 3X-UI + VLESS/REALITY + Aimili-VPNGate + Nginx 订阅包装 为例。
请仅用于个人隐私保护、远程访问和合法网络用途,并遵守 VPS 所在地及使用地法律法规。


目录

  1. 购买 VPS
  2. 搭建 3X-UI
  3. 部署 Aimili-VPNGate
  4. 使用 Nginx 包装订阅、注入规则和保留流量用量
  5. 最终架构
  6. 参考项目

一、购买 VPS

1. VPS 最低要求

建议配置:

项目建议
CPU1 核或以上
内存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:面板端口/随机路径

首次登录后立即完成:

  1. 修改管理员用户名和密码。
  2. 保留随机 Web 路径,不要改成 /admin 等常见路径。
  3. 开启两步验证。
  4. 配置 HTTPS。
  5. 备份面板数据库。

默认 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
Flowxtls-rprx-vision
监听 IP留空或 0.0.0.0
端口自定义高位端口
Sniffing按需启用
Fingerprintchrome
用户 UUID让面板自动生成

REALITY 的目标站点应选择:

  • 支持 TLS 1.3
  • 可从 VPS 正常访问
  • 稳定可靠
  • 不要使用自己的敏感业务域名

配置完成后:

  1. 保存入站。
  2. 放行对应端口。
  3. 在客户端导入分享链接或订阅链接。
  4. 测试延迟和实际访问。

防火墙放行示例

假设入站端口为 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 不同版本的”出站”和”路由规则”界面可能变化,配置前应:

  1. 先备份 /etc/x-ui/x-ui.db
  2. 在面板的 Xray 设置中添加 HTTP 或 SOCKS 出站。
  3. 地址填写 127.0.0.1
  4. 端口填写 7928
  5. 设置唯一 Tag,例如 aimili-out
  6. 仅对测试域名创建路由规则。
  7. 验证无误后再扩大范围。

不要直接把所有流量切换到公共 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 分钟内:

  1. 重新下载 3X-UI 原订阅。
  2. rules.js 重新提取规则。
  3. 更新 rules.txt
  4. 生成新的 clash.yaml
  5. 更新流量响应头。
  6. 重载 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

  1. Purchasing a VPS
  2. Setting Up 3X-UI
  3. Deploying Aimili-VPNGate
  4. Using Nginx to Wrap Subscriptions, Inject Rules, and Preserve Traffic Usage
  5. Final Architecture
  6. Reference Projects

1. Purchasing a VPS

1.1 Minimum VPS Requirements

Recommended configuration:

ItemRecommendation
CPU1 core or more
Memory1 GB or more
Disk10 GB or more
OSDebian 12/13 or Ubuntu 22.04/24.04
NetworkDedicated public IPv4
VirtualizationKVM preferred
Bandwidth1 TB per month or more
TUN/TAPRequired 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

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:

  1. Change the admin username and password.
  2. Keep the random web path; don’t change it to common paths like /admin.
  3. Enable two-factor authentication.
  4. Configure HTTPS.
  5. 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:

ItemRecommendation
ProtocolVLESS
TransportTCP/RAW
SecurityREALITY
Flowxtls-rprx-vision
Listen IPLeave blank or 0.0.0.0
PortCustom high port
SniffingEnable as needed
Fingerprintchrome
User UUIDLet 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:

  1. Save the inbound.
  2. Open the corresponding port.
  3. Import the share link or subscription link on the client.
  4. 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:

FunctionAddress/Port
Web admin panel8787/tcp
Local HTTP/SOCKS5 proxy127.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:

  1. First back up /etc/x-ui/x-ui.db.
  2. Add an HTTP or SOCKS outbound in the panel’s Xray settings.
  3. Set the address to 127.0.0.1.
  4. Set the port to 7928.
  5. Set a unique Tag, e.g., aimili-out.
  6. Only create routing rules for test domains.
  7. 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
  • PROXY policy 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:

  1. Re-download the original 3X-UI subscription.
  2. Re-extract rules from rules.js.
  3. Update rules.txt.
  4. Generate a new clash.yaml.
  5. Update traffic response headers.
  6. 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.