每个代码块右上角都有「一键复制」

家宽出口 · 普通人版(只会复制粘贴也能搭)

目标:在一台海外 VPS 上,搭出一条能在小火箭里用的「某国家出口」线路。
你几乎不需要懂原理,按步骤复制粘贴就行。
所有 IP、密码、密钥都用 <尖括号> 表示,换成你自己的值,尖括号本身也要删掉。


你最终会得到什么

一条可以导入小火箭的链接,大概长这样:

vless://……@你的VPS的IP:2087?……#Home-US

连上之后,Safari 打开 https://ipinfo.io,看到的国家会是你选的那个国家(例如美国),而不是 VPS 所在地。


开始前准备好这些

东西 怎么拿 大概多少钱
一台海外 VPS,系统选 Ubuntu 22.04 甲骨文免费套餐,或其他便宜 VPS 0~10 美元/月
能 SSH 登录这台机器 Windows 用 Termius;Mac 用「终端」 免费
一个空闲的 TCP 端口,例如 2087 后面脚本会检查是否空着 —
iPhone + Shadowrocket(小火箭) 非国区 Apple ID 购买 约 2.99 美元
云控制台能登录(甲骨文 / 阿里云国际等) 用来放行端口 —

重要: 如果这台 VPS 上已经有别人帮你装过面板(比如 3x-ui、x-ui),本教程用的是另一套程序、另一个端口,不会去改原来的面板。但请你先确认自己要用的端口没被占用。


第 1 步:登录 VPS

在电脑上打开终端(或 Termius),输入(把 IP 换成你的):

ssh ubuntu@<你的VPS_IP>

你应该看到: 提示输入密码或直接进去,最后出现类似 ubuntu@某个名字:~$ 的提示符。

如果提示 Are you sure you want to continue connecting,输入 yes 回车。


第 2 步:把一键脚本放到 VPS 上,改 4 个变量,运行

脚本文件是本教程同目录的 install-home-line.sh。二选一:

方法 A:从电脑上传(推荐)。 在电脑上另开一个终端(不是 VPS 里),进入脚本所在文件夹后运行:

scp install-home-line.sh ubuntu@<你的VPS_IP>:~/

方法 B:直接粘贴。 在 VPS 终端里运行 nano install-home-line.sh,用记事本打开脚本文件,全选复制,粘贴进 nano,然后按 Ctrl+O、回车保存,再按 Ctrl+X 退出。

然后在 VPS 里改最上面 4 个变量:

nano install-home-line.sh
COUNTRY="US"      # 国家:JP日本 KR韩国 US美国 GB英国 RU俄罗斯 TH泰国 VN越南 ID印尼
NAME="us"         # 线路名字,用小写字母,别和已有的重复
PORT="2087"       # 对外端口,云控制台也要放行这个
SOCKS_PORT="8085" # 本机内部用,多条线路时不能重复

改完按 Ctrl+O、回车保存,再按 Ctrl+X 退出,然后运行:

sudo bash install-home-line.sh

你应该看到(大约 5~10 分钟):

  1. == 0. 检查端口是否空闲
  2. == 1. 安装依赖和 Docker(第一次会下载不少东西)
  3. == 2. 写网关和管理器文件
  4. == 3. 启动容器
  5. == 4. 安装独立 Xray
  6. == 5. 生成密钥
  7. == 6. systemd 服务 ……
  8. == 7. 放行本机防火墙端口 ……
  9. == 8. 等待线路连通 ……
  10. 最后出现一大段:
==============================================
线路已连通,出口 IP:x.x.x.x
别忘了:去云控制台安全列表放行 TCP 2087
小火箭链接(已保存到 /etc/xray-home-us/client.txt):
vless://……
==============================================

把最后那行 vless:// 开头的链接整行复制下来(或之后用 sudo cat /etc/xray-home-us/client.txt 再看)。

如果提示「端口已被占用」:换一个 PORT / SOCKS_PORT / NAME 再跑。
如果提示「线路暂未连通」:这个国家现在可能没有可用节点,等几十分钟再试;日本、韩国一般更容易成功。


第 3 步:去云控制台放行端口(必须做,否则手机连不上)

脚本只放行了 VPS 自己的防火墙。云厂商外面还有一道门,要你手动开。

甲骨文云(界面可能更新,以你看到的为准):

  1. 浏览器登录甲骨文云控制台
  2. 左上角菜单 →「计算」→「实例」→ 点进你的那台机器
  3. 找到「主 VNIC」里的「子网」,点进去 →「安全列表」→ 打开默认安全列表
  4. 「添加入站规则」:
  5. 源 CIDR:0.0.0.0/0
  6. IP 协议:TCP
  7. 目标端口范围:你在脚本里设的 PORT(例如 2087)
  8. 保存。原来的规则一条都不要动、不要删。

其他云厂商: 到「安全组 / 防火墙规则」里加一条「入站 TCP 你的端口,来源 0.0.0.0/0」。

你应该看到: 规则列表里多了一行 TCP 2087(或你选的端口)。

在自己电脑上测一下(Mac / Linux):

nc -vz <你的VPS_IP> 2087

成功时大致显示: Connection to … port 2087 [tcp/*] succeeded!
如果一直卡住或 timed out:安全列表还没放对,再检查一遍。


第 4 步:导入小火箭

  1. 在 iPhone 上复制整行 vless://…… 链接
  2. 打开 Shadowrocket
  3. 一般会弹出「添加」提示 → 点添加
  4. 若没有弹出:点首页右上角 +,手动粘贴,或用左上角扫码导入
  5. 点选刚加的节点,打开顶部连接开关

你应该看到: 顶部状态变成已连接(或显示节点名称)。


第 5 步:验证有没有真的出国

手机浏览器打开:

https://ipinfo.io

成功: 页面上的国家 / 城市是你选的国家(例如 United States),IP 不是 你的 VPS IP。
失败: 还是 VPS 所在地,或网页打不开 → 看下面「出问题了怎么办」。


出问题了怎么办(普通人版)

你看到的现象 最常见原因 你可以做什么
小火箭一直转圈 / 超时 云控制台没放行端口 回去做第 3 步;用 nc -vz 再测
能连上但网页打不开 这个国家暂时没有可用节点 换日本或韩国再装一条;或等几小时
脚本说端口被占用 这台机器上已有服务占用了 换一组 PORT / SOCKS_PORT / NAME
AUTH_FAILED 刷屏 志愿者节点拒绝连接 脚本里的管理器会自动换点,等即可
怕搞坏原来的 3x-ui — 本教程不碰面板;只要别用面板占用的端口就行

需要把错误信息发给会折腾的朋友或 AI 时,把下面两条命令的输出一起发(不要发 .env、私钥、整条 vless 链接):

docker logs --tail 40 vpngw-us-manager
systemctl status xray-home-us --no-pager

(把名字里的 us 换成你设的 NAME。)


想再加一个国家?

再跑一遍脚本,只改最上面 4 个变量,例如:

COUNTRY="JP"
NAME="jp"
PORT="2083"
SOCKS_PORT="8083"

四条都不能和已有线路重复。 每条线路都要在云控制台单独放行对应的 PORT。


一句话提醒


脚本文件:与本教程同目录的 install-home-line.sh。云控制台界面以实际上看到的为准。

附:一键安装脚本全文 install-home-line.sh

先修改顶部的 4 个变量,再保存为 install-home-line.sh,然后运行 sudo bash install-home-line.sh。

#!/bin/bash
# 家宽出口一键安装脚本:网关 + 管理器 + 独立 Xray + systemd,最后打印 vless 链接
# 只改下面 4 个变量即可。每条线路的 NAME / PORT / SOCKS_PORT 都不能和已有的重复。
COUNTRY="US"          # VPN Gate 国家代码:JP KR US GB RU TH VN ID ...
NAME="us"             # 线路名(小写字母),决定目录和服务名
PORT="2087"           # 对外 Reality 端口(要在云安全列表放行)
SOCKS_PORT="8085"     # 本机 SOCKS 端口(只监听 127.0.0.1)
SNI="www.apple.com"

set -euo pipefail
[ "$(id -u)" -eq 0 ] || { echo "请用 sudo 运行:sudo bash $0"; exit 1; }
REAL_USER="${SUDO_USER:-root}"
HOME_DIR=$(getent passwd "$REAL_USER" | cut -d: -f6)
DIR="$HOME_DIR/vpngw-$NAME"
XDIR="/etc/xray-home-$NAME"
SVC="xray-home-$NAME"

echo "== 0. 检查端口是否空闲"
for p in "$PORT" "$SOCKS_PORT"; do
  if ss -lnt | awk '{print $4}' | grep -Eq "[:.]$p\$"; then echo "端口 $p 已被占用,请换一个后重试"; exit 1; fi
done
[ -e "$DIR" ] && { echo "$DIR 已存在,请换 NAME"; exit 1; }
[ -e "$XDIR" ] && { echo "$XDIR 已存在,请换 NAME"; exit 1; }

echo "== 1. 安装依赖和 Docker"
apt-get update -y
apt-get install -y curl unzip openssl iptables
command -v docker >/dev/null || curl -fsSL https://get.docker.com | sh
usermod -aG docker "$REAL_USER" || true

echo "== 2. 写网关和管理器文件"
mkdir -p "$DIR/gateway" "$DIR/manager" "$DIR/ovpn" "$DIR/state"
cat > "$DIR/gateway/Dockerfile" <<'EOF'
FROM alpine:3.20 AS builder
RUN apk add --no-cache build-base git \
 && git clone --depth 1 https://github.com/rofl0r/microsocks /tmp/microsocks \
 && make -C /tmp/microsocks

FROM alpine:3.20
COPY --from=builder /tmp/microsocks/microsocks /usr/local/bin/microsocks
RUN apk add --no-cache openvpn iptables iproute2 su-exec bash curl \
 && adduser -D -s /bin/sh dante \
 && mkdir -p /scripts /vpn
COPY entrypoint.sh /entrypoint.sh
COPY up.sh /scripts/up.sh
COPY down.sh /scripts/down.sh
RUN chmod +x /entrypoint.sh /scripts/up.sh /scripts/down.sh
ENTRYPOINT ["/entrypoint.sh"]
EOF
cat > "$DIR/gateway/entrypoint.sh" <<'EOF'
#!/bin/bash
set -e
: "${GATE_USER:?need GATE_USER}"
: "${GATE_PASS:?need GATE_PASS}"
# openvpn 的 --up 脚本拿不到容器环境变量,所以先把密码写到文件里
printf 'GATE_USER=%s\nGATE_PASS=%s\n' "$GATE_USER" "$GATE_PASS" > /etc/socks.auth
chmod 600 /etc/socks.auth

echo "[entrypoint] waiting for /vpn/client.ovpn ..."
while [ ! -f /vpn/client.ovpn ]; do sleep 5; done

while true; do
  openvpn --config /vpn/client.ovpn \
    --script-security 2 \
    --up /scripts/up.sh \
    --down /scripts/down.sh \
    --up-restart \
    || true
  echo "[entrypoint] openvpn exited, retry in 10s..."
  sleep 10
done
EOF
cat > "$DIR/gateway/up.sh" <<'EOF'
#!/bin/bash
TUN="$1"
set -e
. /etc/socks.auth

# dante 用户的新连接打 mark 0x1,走 table 100 → tun0
ip rule add fwmark 0x1 table 100 2>/dev/null || true
ip route replace default dev "$TUN" table 100
iptables -t mangle -C OUTPUT -m owner --uid-owner dante -j CONNMARK --restore-mark 2>/dev/null \
  || iptables -t mangle -A OUTPUT -m owner --uid-owner dante -j CONNMARK --restore-mark
iptables -t mangle -C OUTPUT -m owner --uid-owner dante -m mark --mark 0 -m conntrack --ctstate NEW -j MARK --set-mark 0x1 2>/dev/null \
  || iptables -t mangle -A OUTPUT -m owner --uid-owner dante -m mark --mark 0 -m conntrack --ctstate NEW -j MARK --set-mark 0x1
iptables -t mangle -C OUTPUT -m owner --uid-owner dante -m mark --mark 0x1 -j CONNMARK --save-mark 2>/dev/null \
  || iptables -t mangle -A OUTPUT -m owner --uid-owner dante -m mark --mark 0x1 -j CONNMARK --save-mark

# 修复非对称路由:连进 1080 的回包打 0x2,走 table 101 → eth0
GW4=$(ip route show default dev eth0 2>/dev/null | awk '/^default/ {print $3; exit}')
NET4=$(ip route show dev eth0 scope link 2>/dev/null | awk '/^[0-9]+\./ {print $1; exit}')
if [ -n "$GW4" ]; then
  ip rule add fwmark 0x2 table 101 2>/dev/null || true
  [ -n "$NET4" ] && ip route replace "$NET4" dev eth0 scope link table 101 2>/dev/null || true
  ip route replace default via "$GW4" dev eth0 table 101
fi
iptables -t mangle -C PREROUTING -i eth0 -p tcp --dport 1080 -m conntrack --ctstate NEW -j CONNMARK --set-mark 0x2 2>/dev/null \
  || iptables -t mangle -A PREROUTING -i eth0 -p tcp --dport 1080 -m conntrack --ctstate NEW -j CONNMARK --set-mark 0x2

# 启动带认证的 SOCKS5(容器内 1080;宿主机只映射到 127.0.0.1)
if ! pgrep -x microsocks >/dev/null 2>&1; then
  su-exec dante microsocks -i 0.0.0.0 -p 1080 -u "${GATE_USER}" -P "${GATE_PASS}" &
fi
exit 0
EOF
cat > "$DIR/gateway/down.sh" <<'EOF'
#!/bin/bash
pkill -x microsocks 2>/dev/null || true
iptables -t mangle -D OUTPUT -m owner --uid-owner dante -j CONNMARK --restore-mark 2>/dev/null || true
iptables -t mangle -D OUTPUT -m owner --uid-owner dante -m mark --mark 0 -m conntrack --ctstate NEW -j MARK --set-mark 0x1 2>/dev/null || true
iptables -t mangle -D OUTPUT -m owner --uid-owner dante -m mark --mark 0x1 -j CONNMARK --save-mark 2>/dev/null || true
ip rule del fwmark 0x1 table 100 2>/dev/null || true
iptables -t mangle -D PREROUTING -i eth0 -p tcp --dport 1080 -m conntrack --ctstate NEW -j CONNMARK --set-mark 0x2 2>/dev/null || true
ip rule del fwmark 0x2 table 101 2>/dev/null || true
ip route flush table 101 2>/dev/null || true
exit 0
EOF
cat > "$DIR/manager/Dockerfile" <<'EOF'
FROM python:3.12-alpine
RUN apk add --no-cache docker-cli curl
COPY vpngate.py /vpngate.py
CMD ["python", "/vpngate.py"]
EOF
cat > "$DIR/manager/vpngate.py" <<'EOF'
#!/usr/bin/env python3
import os, sys, re, json, base64, time, socket, random, subprocess, urllib.request

DATA_URLS = [
    "https://www.vpngate.net/api/iphone/",
    "https://cdn.jsdelivr.net/gh/GeorgeXie2333/vpngate-list-mirror@latest/data/vpngate.csv",
]
COUNTRIES = [c.strip().upper() for c in os.environ.get("COUNTRIES", "JP").split(",") if c.strip()]
GATEWAY_NAME = os.environ.get("GATEWAY_NAME", "vpngw-us-gateway")
GATE_USER = os.environ.get("GATE_USER", "")
GATE_PASS = os.environ.get("GATE_PASS", "")
OVPN_PATH = "/ovpn/client.ovpn"
STATE_PATH = "/state/current.json"
BL_PATH = "/state/blacklist.json"
CHECK_INTERVAL = int(os.environ.get("CHECK_INTERVAL", "60"))
ROTATE_MINUTES = int(os.environ.get("ROTATE_MINUTES", "60"))
BL_SECONDS = int(os.environ.get("BL_SECONDS", "21600"))
FAIL_THRESHOLD = 3
IP_ECHO_URLS = ["https://api.ipify.org", "https://ifconfig.me",
                "https://icanhazip.com", "https://checkip.amazonaws.com"]

def log(*a):
    print(f"[manager {time.strftime('%H:%M:%S')}]", *a, flush=True)

def sh(cmd, timeout=30):
    try:
        r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
        return r.returncode, r.stdout.strip(), r.stderr.strip()
    except Exception as e:
        return 1, "", str(e)

def gateway_cid():
    rc, out, _ = sh(f'docker ps -q --filter "name={GATEWAY_NAME}" | head -1')
    return out.strip() if rc == 0 else ""

def fetch_text(url):
    req = urllib.request.Request(url, headers={"User-Agent": "vpngw-manager/1.0"})
    with urllib.request.urlopen(req, timeout=60) as r:
        return r.read().decode("utf-8", "ignore")

def parse_csv(text):
    nodes = []
    for line in text.splitlines():
        line = line.strip()
        if not line or line.startswith("*") or line.startswith("#"):
            continue
        p = line.split(",", 14)          # 最后一列可能含逗号
        if len(p) < 15:
            continue
        try:
            nodes.append({"host": p[0], "ip": p[1],
                          "score": int(p[2] or 0),
                          "ping": int(p[3]) if p[3].isdigit() else 9999,   # ping 为 "-" 的修复
                          "cc": (p[6] or "").upper(), "config_b64": p[14]})
        except ValueError:
            continue
    return nodes

def load_nodes():
    for u in DATA_URLS:
        try:
            log("fetching", u)
            nodes = parse_csv(fetch_text(u))
            if nodes:
                log(f"got {len(nodes)} nodes")
                return nodes
        except Exception as e:
            log("fetch failed:", u, str(e)[:120])
    raise RuntimeError("all sources failed")

def candidates(nodes, exclude):
    out = []
    for n in nodes:
        if n["cc"] not in COUNTRIES or n["ip"] in exclude:
            continue
        try:
            cfg = base64.b64decode(n["config_b64"]).decode("utf-8", "ignore")
        except Exception:
            continue
        if "proto tcp" not in cfg.lower():
            continue
        m = re.search(r"^remote (\S+) (\d+)", cfg, re.M)
        if not m:
            continue
        try:                              # TCP 探测:连不上的直接跳过
            socket.create_connection((m.group(1), int(m.group(2))), timeout=4).close()
        except Exception:
            continue
        out.append((n, cfg))
    out.sort(key=lambda x: (-x[0]["score"], x[0]["ping"]))
    return out

def build_ovpn(node, cfg):
    lines = [l for l in cfg.splitlines() if not l.strip().lower().startswith("redirect-gateway")]
    lines.append(f"route {node['ip']} 255.255.255.255 net_gateway")
    lines.append("<auth-user-pass>\nvpn\nvpn\n</auth-user-pass>")   # VPN Gate 公共账号
    lines += ["script-security 2", "up /scripts/up.sh", "down /scripts/down.sh"]
    return "\n".join(lines) + "\n"

def save_state(node):
    with open(STATE_PATH, "w") as f:
        json.dump({"ip": node["ip"], "host": node["host"], "cc": node["cc"],
                   "since": time.time()}, f)

def load_state():
    try:
        return json.load(open(STATE_PATH))
    except Exception:
        return {}

def load_bl():
    try:
        bl = json.load(open(BL_PATH))
    except Exception:
        bl = {}
    now = time.time()
    return {k: v for k, v in bl.items() if v > now}

def blacklist(ip, why):
    bl = load_bl(); bl[ip] = time.time() + BL_SECONDS
    json.dump(bl, open(BL_PATH, "w"))
    log(f"blacklisted {ip} ({why})")

def check_auth_failed(ip):
    cid = gateway_cid()
    if ip and cid:
        _, out, _ = sh(f"docker logs --since 5m {cid} 2>&1 | grep -c AUTH_FAILED")
        if out.strip().isdigit() and int(out.strip()) > 0:
            blacklist(ip, "AUTH_FAILED")

def rotate(extra=()):
    st = load_state()
    check_auth_failed(st.get("ip", ""))
    bl = set(load_bl())
    exclude = set(extra) | bl | ({st["ip"]} if st.get("ip") else set())
    try:
        nodes = load_nodes()
    except RuntimeError as e:
        log("rotate aborted:", e); return False
    cands = candidates(nodes, exclude) or candidates(nodes, bl)
    if not cands:
        log(f"no usable {COUNTRIES} TCP nodes"); return False
    node, cfg = random.choice(cands[:8])
    with open(OVPN_PATH, "w") as f:
        f.write(build_ovpn(node, cfg))
    cid = gateway_cid()
    if not cid:
        log("gateway container not found!"); return False
    log(f"switching to {node['host']} ({node['ip']}, {node['cc']})")
    sh(f"docker restart {cid}", timeout=60)
    save_state(node)
    return True

def exit_ip_via_socks():
    for url in IP_ECHO_URLS:
        rc, out, _ = sh(f'curl -s -m 15 --socks5-hostname gateway:1080 '
                        f'--proxy-user "{GATE_USER}:{GATE_PASS}" {url}', timeout=20)
        ip = out.split()[0] if out else ""
        if rc == 0 and re.fullmatch(r"\d+\.\d+\.\d+\.\d+", ip):
            return ip
    return None

def main():
    log(f"start countries={COUNTRIES}")
    if not os.path.exists(OVPN_PATH):
        rotate()
    _, vps_ip, _ = sh("curl -s -m 15 https://ifconfig.me", timeout=25)
    fails = 0
    while True:
        time.sleep(CHECK_INTERVAL)
        st = load_state()
        got = exit_ip_via_socks()
        if got and got == vps_ip:
            log("LEAK! exit == vps ip, rotating"); rotate([st.get("ip", "")]); fails = 0
        elif got:
            fails = 0; log(f"ok exit={got}")
        else:
            fails += 1; log(f"health fail {fails}/{FAIL_THRESHOLD}")
            if fails >= FAIL_THRESHOLD:
                rotate([st.get("ip", "")]); fails = 0; continue
        st = load_state()
        if st.get("since") and time.time() - st["since"] > ROTATE_MINUTES * 60:
            log("proactive rotation"); rotate([st.get("ip", "")])

if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        sys.exit(0)
EOF

GATE_USER="gw$(openssl rand -hex 3)"
GATE_PASS="$(openssl rand -hex 16)"
printf 'GATE_USER=%s\nGATE_PASS=%s\n' "$GATE_USER" "$GATE_PASS" > "$DIR/.env"
chmod 600 "$DIR/.env"

cat > "$DIR/docker-compose.yml" <<EOF
services:
  gateway:
    build: ./gateway
    container_name: vpngw-$NAME-gateway
    cap_add:
      - NET_ADMIN
    devices:
      - /dev/net/tun
    sysctls:
      net.ipv4.ip_forward: "1"
    ports:
      - "127.0.0.1:$SOCKS_PORT:1080"
    volumes:
      - ./ovpn:/vpn
      - ./state:/state
    environment:
      GATE_USER: \${GATE_USER}
      GATE_PASS: \${GATE_PASS}
    restart: unless-stopped
  manager:
    build: ./manager
    container_name: vpngw-$NAME-manager
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./ovpn:/ovpn
      - ./state:/state
    environment:
      - COUNTRIES=$COUNTRY
      - GATEWAY_NAME=vpngw-$NAME-gateway
      - GATE_USER=\${GATE_USER}
      - GATE_PASS=\${GATE_PASS}
    restart: unless-stopped
EOF
chown -R "$REAL_USER:" "$DIR"

echo "== 3. 启动容器(第一次构建要几分钟)"
(cd "$DIR" && docker compose up -d --build)

echo "== 4. 安装独立 Xray 到 $XDIR"
case "$(uname -m)" in
  aarch64|arm64) PKG="Xray-linux-arm64-v8a.zip" ;;
  x86_64|amd64)  PKG="Xray-linux-64.zip" ;;
  *) echo "不支持的架构 $(uname -m)"; exit 1 ;;
esac
mkdir -p "$XDIR"
curl -fL -o /tmp/xray-$NAME.zip "https://github.com/XTLS/Xray-core/releases/latest/download/$PKG"
unzip -o /tmp/xray-$NAME.zip xray -d "$XDIR/"
chmod +x "$XDIR/xray"

echo "== 5. 生成密钥"
KEYS=$("$XDIR/xray" x25519)
PRIV=$(echo "$KEYS" | awk -F': *' '/^Private ?[Kk]ey/ {print $2; exit}')
PUB=$(echo "$KEYS"  | awk -F': *' '/^(Public ?[Kk]ey|Password)/ {print $2; exit}')
UUID=$("$XDIR/xray" uuid)
SID=$(openssl rand -hex 8)
[ -n "$PRIV" ] && [ -n "$PUB" ] || { echo "密钥解析失败,xray x25519 输出:"; echo "$KEYS"; exit 1; }

cat > "$XDIR/config.json" <<EOF
{
  "log": {"loglevel": "warning"},
  "inbounds": [{
    "port": $PORT, "protocol": "vless",
    "settings": {"clients": [{"id": "$UUID", "flow": "xtls-rprx-vision"}], "decryption": "none"},
    "streamSettings": {"network": "tcp", "security": "reality",
      "realitySettings": {"dest": "$SNI:443", "serverNames": ["$SNI"],
        "privateKey": "$PRIV", "shortIds": ["$SID"]}}
  }],
  "outbounds": [
    {"protocol": "socks", "settings": {"servers": [{"address": "127.0.0.1", "port": $SOCKS_PORT,
      "users": [{"user": "$GATE_USER", "pass": "$GATE_PASS"}]}]}},
    {"tag": "block", "protocol": "blackhole"}
  ],
  "routing": {"rules": [{"type": "field", "network": "udp", "outboundTag": "block"}]}
}
EOF
chmod 600 "$XDIR/config.json"
"$XDIR/xray" run -test -c "$XDIR/config.json"

echo "== 6. systemd 服务 $SVC"
cat > "/etc/systemd/system/$SVC.service" <<EOF
[Unit]
Description=Standalone Xray $SVC (VLESS-Reality :$PORT -> socks 127.0.0.1:$SOCKS_PORT)
After=network-online.target docker.service
[Service]
ExecStart=$XDIR/xray run -c $XDIR/config.json
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now "$SVC"

echo "== 7. 放行本机防火墙端口 $PORT"
iptables -C INPUT -p tcp --dport "$PORT" -j ACCEPT 2>/dev/null || iptables -I INPUT -p tcp --dport "$PORT" -j ACCEPT
command -v netfilter-persistent >/dev/null && netfilter-persistent save || true
command -v ufw >/dev/null && ufw status | grep -q active && ufw allow "$PORT/tcp" || true

echo "== 8. 等待线路连通(最多约 5 分钟)"
EXIT_IP=""
for i in $(seq 1 30); do
  EXIT_IP=$(curl -s -m 10 --socks5-hostname "127.0.0.1:$SOCKS_PORT" --proxy-user "$GATE_USER:$GATE_PASS" https://api.ipify.org || true)
  [ -n "$EXIT_IP" ] && break
  sleep 10
done
VPS_IP=$(curl -s -m 10 https://api.ipify.org || echo "<你的VPS_IP>")
LINK="vless://$UUID@$VPS_IP:$PORT?encryption=none&flow=xtls-rprx-vision&security=reality&sni=$SNI&fp=chrome&pbk=$PUB&sid=$SID&type=tcp#Home-$COUNTRY"
echo "$LINK" > "$XDIR/client.txt"; chmod 600 "$XDIR/client.txt"

echo
echo "=============================================="
if [ -n "$EXIT_IP" ]; then echo "线路已连通,出口 IP:$EXIT_IP"; else echo "线路暂未连通(节点可能暂时没有),管理器会自动重试;可稍后再试"; fi
echo "别忘了:去云控制台安全列表放行 TCP $PORT"
echo "小火箭链接(已保存到 $XDIR/client.txt):"
echo "$LINK"
echo "=============================================="