#!/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 "=============================================="
