一键搭建VLESS、HY2节点教程

#!/usr/bin/env bash
# ============================================================
# sb.sh - sing-box 双协议节点一键脚本 (无面板)
#   主协议: Hysteria2 (UDP 443, Salamander 混淆, 端口跳跃 20000-30000)
#   备协议: VLESS+REALITY (TCP 443, 不带 xtls-rprx-vision flow)
# 适用系统: Ubuntu 22.04 LTS (其他系统未验证)
#
# 用法 (在新服务器上, 以 root 身份):
#   bash sb.sh              一键生成节点 (已装过则只显示链接)
#   bash sb.sh install      同上
#   bash sb.sh show         一键查询节点链接 (IP自动重新获取)
#   bash sb.sh show 1.2.3.4 指定IP查询 (自动获取失败时用)
#   bash sb.sh status       查看服务与监听状态
#   bash sb.sh reinstall    重新生成全部参数 (旧链接立即失效)
#   bash sb.sh uninstall    卸载本脚本创建的配置与服务
#
# 说明: show 对手工按同教程搭建、没有参数记录文件的机器同样可用,
#       会自动从 /etc/sing-box/config.json 反查参数并推导 REALITY 公钥。
#
# 云厂商防火墙需另行放行 (脚本在机器内无法代开云防火墙):
#   TCP 443, UDP 443, UDP 20000-30000 (SSH 的 TCP 22 按原有规则保留)
# ============================================================
set -euo pipefail

NODE_DIR="${NODE_DIR:-/etc/sing-box}"
INFO_FILE="$NODE_DIR/node-info.env"
CERT_DIR="$NODE_DIR/certs"
CONFIG_FILE="$NODE_DIR/config.json"
SNI="${SNI:-www.bing.com}"
HOP_START=20000
HOP_END=30000
NAME_PREFIX="${NAME_PREFIX:-NODE}"

msg()  { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*"; }
die()  { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; }

need_root() {
  [ "$(id -u)" -eq 0 ] || die "请以 root 身份运行: sudo bash $0 install"
}

# ---------- 公网 IP 获取 (临时IP变化后 show 会自动用新IP拼链接) ----------
detect_public_ip() {
  local ip=""
  ip="$(curl -fsS --max-time 8 https://api.ipify.org 2>/dev/null || true)"
  if ! printf '%s' "$ip" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then
    ip="$(curl -fsS --max-time 8 https://ifconfig.me/ip 2>/dev/null || true)"
  fi
  printf '%s' "$ip" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' || return 1
  printf '%s' "$ip"
}

load_info() {
  [ -f "$INFO_FILE" ] || die "未找到 $INFO_FILE, 本机尚未用此脚本安装。先运行: bash $0 install"
  set -a
  # shellcheck disable=SC1090
  . "$INFO_FILE"
  set +a
}

print_links() {
  local ip="$1"
  echo
  echo "==================== 节点链接 (手机/电脑通用) ===================="
  echo "--- Hysteria2 主节点 (固定速率: 上行 50 / 下行 100) ---"
  echo "hysteria2://${HY2_PASSWORD}@${ip}:443?mport=${HOP_START}-${HOP_END}&obfs=salamander&obfs-password=${OBFS_PASSWORD}&insecure=1&sni=${SNI}&upmbps=50&downmbps=100#${NAME_PREFIX}-HY2"
  echo
  echo "--- Hysteria2 自适应备版 (不带固定速率) ---"
  echo "hysteria2://${HY2_PASSWORD}@${ip}:443?mport=${HOP_START}-${HOP_END}&obfs=salamander&obfs-password=${OBFS_PASSWORD}&insecure=1&sni=${SNI}#${NAME_PREFIX}-HY2-AUTO"
  echo
  echo "--- VLESS+REALITY 备节点 ---"
  echo "vless://${UUID}@${ip}:443?encryption=none&security=reality&sni=${SNI}&fp=chrome&pbk=${REALITY_PUBLIC}&sid=${SHORT_ID}&type=tcp#${NAME_PREFIX}-VLESS"
  echo "==================================================================="
  echo "提醒: REALITY 链接刻意不带 flow 参数, 这是本方案实测可用的口径, 不要自行加回 xtls-rprx-vision。"
}

# ---------- 系统调优: 2GB swap / 日志上限 / BBR ----------
system_tuning() {
  msg "系统调优 (swap / 日志上限 / BBR)"
  if ! swapon --show | grep -q . && [ ! -f /swapfile ]; then
    fallocate -l 2G /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=2048
    chmod 600 /swapfile
    mkswap /swapfile >/dev/null
    swapon /swapfile
    grep -q '^/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab
  fi
  mkdir -p /etc/systemd/journald.conf.d
  printf '[Journal]\nSystemMaxUse=100M\n' > /etc/systemd/journald.conf.d/99-limit.conf
  systemctl restart systemd-journald 2>/dev/null || true
  printf 'net.core.default_qdisc=fq\nnet.ipv4.tcp_congestion_control=bbr\n' > /etc/sysctl.d/99-bbr.conf
  modprobe tcp_bbr 2>/dev/null || true
  sysctl -p /etc/sysctl.d/99-bbr.conf >/dev/null 2>&1 || true
}

# ---------- 安装 sing-box (官方一键脚本, 失败则 GitHub 官方 deb 直装) ----------
# 注: sing-box.app 的 APT 仓库源在部分机房拉取 InRelease 会报 NOSPLIT 而失败,
# 故不用 APT 仓库法; 版本与今日实测通过的 1.14.3 对齐,可用 SB_VERSION 覆盖。
install_singbox() {
  export DEBIAN_FRONTEND=noninteractive
  rm -f /etc/apt/sources.list.d/sing-box.list
  apt-get update || warn "apt update 有软件源失败, 继续尝试安装"
  apt-get install -y curl ca-certificates openssl iptables python3 iproute2 || true
  if command -v sing-box >/dev/null 2>&1; then
    sing-box version 2>/dev/null | head -n 1 || true
    return
  fi
  msg "安装 sing-box (官方一键脚本)"
  if curl -fsSL https://sing-box.app/install.sh | bash; then
    :
  else
    warn "官方一键脚本安装失败, 改用 GitHub 官方 deb 包直装"
  fi
  if ! command -v sing-box >/dev/null 2>&1; then
    local ver arch deb
    ver="${SB_VERSION:-1.14.3}"
    case "$(uname -m)" in
      x86_64)  arch="amd64" ;;
      aarch64) arch="arm64" ;;
      *) die "不支持的 CPU 架构: $(uname -m)" ;;
    esac
    deb="/tmp/sing-box_${ver}_linux_${arch}.deb"
    msg "下载 sing-box v${ver} 官方 deb 包 (${arch})"
    curl -fL --retry 3 -o "$deb" "https://github.com/SagerNet/sing-box/releases/download/v${ver}/sing-box_${ver}_linux_${arch}.deb"
    dpkg -i "$deb" || apt-get install -f -y
  fi
  command -v sing-box >/dev/null 2>&1 || die "sing-box 安装失败, 请把上方报错发回"
  sing-box version 2>/dev/null | head -n 1 || true
}

# ---------- 生成全部参数并保存 (权限 600, show 查询时复用) ----------
generate_params() {
  UUID="$(sing-box generate uuid 2>/dev/null || cat /proc/sys/kernel/random/uuid)"
  local kp
  kp="$(sing-box generate reality-keypair)"
  REALITY_PRIVATE="$(printf '%s' "$kp" | awk '/PrivateKey/ {print $2}')"
  REALITY_PUBLIC="$(printf '%s' "$kp" | awk '/PublicKey/ {print $2}')"
  [ -n "$REALITY_PRIVATE" ] && [ -n "$REALITY_PUBLIC" ] || die "REALITY 密钥生成失败"
  SHORT_ID="$(openssl rand -hex 8)"
  HY2_PASSWORD="$(openssl rand -hex 16)"
  OBFS_PASSWORD="$(openssl rand -hex 16)"
  mkdir -p "$NODE_DIR"
  chmod 700 "$NODE_DIR"
  cat > "$INFO_FILE" <<EOF
UUID=$UUID
REALITY_PRIVATE=$REALITY_PRIVATE
REALITY_PUBLIC=$REALITY_PUBLIC
SHORT_ID=$SHORT_ID
HY2_PASSWORD=$HY2_PASSWORD
OBFS_PASSWORD=$OBFS_PASSWORD
SNI=$SNI
EOF
  chmod 600 "$INFO_FILE"
}

make_cert() {
  mkdir -p "$CERT_DIR"
  if [ ! -f "$CERT_DIR/cert.pem" ]; then
    msg "生成自签证书 (SNI: $SNI)"
    openssl req -x509 -nodes -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
      -keyout "$CERT_DIR/key.pem" -out "$CERT_DIR/cert.pem" -days 3650 \
      -subj "/CN=$SNI" -addext "subjectAltName=DNS:$SNI" >/dev/null 2>&1
  fi
  chmod 600 "$CERT_DIR/key.pem"
}

# ---------- 写 sing-box 配置 (HY2 + VLESS, 无 TUIC) ----------
write_config() {
  python3 - <<'PYEOF'
import json, os
d = os.environ
cfg = {
  "log": {"level": "info", "timestamp": True},
  "inbounds": [
    {
      "type": "hysteria2", "tag": "hy2-in",
      "listen": "::", "listen_port": 443,
      "users": [{"password": d["HY2_PASSWORD"]}],
      "obfs": {"type": "salamander", "password": d["OBFS_PASSWORD"]},
      "tls": {"enabled": True, "server_name": d["SNI"], "alpn": ["h3"],
              "certificate_path": d["CERT_DIR"] + "/cert.pem",
              "key_path": d["CERT_DIR"] + "/key.pem"}
    },
    {
      "type": "vless", "tag": "vless-in",
      "listen": "::", "listen_port": 443,
      "users": [{"uuid": d["UUID"]}],
      "tls": {"enabled": True, "server_name": d["SNI"],
              "reality": {"enabled": True,
                          "handshake": {"server": d["SNI"], "server_port": 443},
                          "private_key": d["REALITY_PRIVATE"],
                          "short_id": [d["SHORT_ID"]]}}
    }
  ],
  "outbounds": [{"type": "direct", "tag": "direct"}],
  "route": {"final": "direct"}
}
with open(os.path.join(d["NODE_DIR"], "config.json"), "w") as f:
    json.dump(cfg, f, indent=2)
print("config written")
PYEOF
}

# ---------- 端口跳跃: UDP 20000-30000 -> 443 (systemd 持久化) ----------
setup_hop() {
  msg "配置端口跳跃 UDP ${HOP_START}-${HOP_END} -> 443"
  local IPT
  IPT="$(command -v iptables)"
  cat > /etc/systemd/system/hy2-hop.service <<EOF
[Unit]
Description=Hysteria2 port hopping NAT (UDP ${HOP_START}-${HOP_END} -> 443)
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/bash -c '$IPT -t nat -C PREROUTING -p udp --dport ${HOP_START}:${HOP_END} -j REDIRECT --to-ports 443 2>/dev/null || $IPT -t nat -A PREROUTING -p udp --dport ${HOP_START}:${HOP_END} -j REDIRECT --to-ports 443'
ExecStop=-$IPT -t nat -D PREROUTING -p udp --dport ${HOP_START}:${HOP_END} -j REDIRECT --to-ports 443

[Install]
WantedBy=multi-user.target
EOF
  systemctl daemon-reload
  systemctl enable --now hy2-hop.service
}

setup_ufw() {
  if command -v ufw >/dev/null 2>&1 && ufw status | grep -qi '^Status: active'; then
    msg "UFW 已启用, 补放行节点端口"
    ufw allow 443/tcp >/dev/null || true
    ufw allow 443/udp >/dev/null || true
    ufw allow ${HOP_START}:${HOP_END}/udp >/dev/null || true
  fi
}

do_install() {
  need_root
  if [ -f "$INFO_FILE" ] && [ "${1:-}" != "force" ]; then
    warn "本机已用此脚本安装过, 不重复生成 (旧链接继续有效)。直接显示现有链接:"
    load_info
    local ip
    ip="$(detect_public_ip || true)"
    print_links "${ip:-公网IP获取失败, 请用 show 命令指定IP}"
    echo "如需全部重新生成 (旧链接立即失效): bash $0 reinstall"
    exit 0
  fi
  msg "开始一键搭建 (Ubuntu 22.04 / sing-box / HY2 + VLESS)"
  system_tuning
  install_singbox
  generate_params
  make_cert
  export UUID REALITY_PRIVATE REALITY_PUBLIC SHORT_ID HY2_PASSWORD OBFS_PASSWORD SNI CERT_DIR NODE_DIR
  write_config
  sing-box check -c "$CONFIG_FILE" || die "sing-box 配置校验失败, 未启动服务"
  setup_hop
  setup_ufw
  systemctl enable sing-box >/dev/null 2>&1 || true
  systemctl restart sing-box
  sleep 1
  if ! systemctl is-active --quiet sing-box; then
    journalctl -u sing-box -n 30 --no-pager
    die "sing-box 未能启动, 上方为最近日志"
  fi
  msg "服务已启动, 监听确认:"
  ss -tulnp | grep -E ':443' || true
  local ip
  ip="$(detect_public_ip || true)"
  [ -n "$ip" ] || warn "公网IP自动获取失败, 链接中的IP请用 show 命令手动指定"
  print_links "${ip:-请替换为服务器公网IP}"
  echo
  warn "最后一步 (机器外): 请在云厂商防火墙放行 TCP 443, UDP 443, UDP ${HOP_START}-${HOP_END}, 否则客户端连不上。"
  echo "以后在服务器上运行 bash $0 show 即可随时重新打印两条链接。"
}

# ---------- 查询: 优先参数记录文件; 没有则从现有 config.json 反查 (兼容手工搭建) ----------
do_show() {
  local ip="${1:-}"
  if [ -z "$ip" ]; then
    ip="$(detect_public_ip || true)"
  fi
  [ -n "$ip" ] || die "公网IP自动获取失败, 请运行: bash $0 show 服务器公网IP"
  if [ ! -f "$INFO_FILE" ] && [ -f "$CONFIG_FILE" ]; then
    warn "未找到参数记录文件, 改从现有 config.json 反查参数 (兼容手工搭建的机器)"
    CONFIG_PATH="$CONFIG_FILE" SHOW_IP="$ip" SHOW_NAME="$NAME_PREFIX" python3 - <<'PYEOF'
import json, os, base64, subprocess, tempfile
cfg = json.load(open(os.environ["CONFIG_PATH"]))
ins = cfg.get("inbounds", [])
hy2 = next(x for x in ins if x.get("type") == "hysteria2")
vl = next(x for x in ins if x.get("type") == "vless")
sni = (hy2.get("tls") or {}).get("server_name", "www.bing.com")
vl_tls = vl.get("tls") or {}
r = vl_tls["reality"]
vl_sni = vl_tls.get("server_name") or (r.get("handshake") or {}).get("server") or sni
def b64d(s): return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def b64e(b): return base64.urlsafe_b64encode(b).decode().rstrip("=")
pub = ""
raw = b64d(r["private_key"])
if len(raw) == 32:
    der = bytes.fromhex("302e020100300506032b656e04220420") + raw
    with tempfile.TemporaryDirectory() as td:
        open(td + "/k.der", "wb").write(der)
        subprocess.run(["openssl", "pkey", "-inform", "DER", "-in", td + "/k.der",
                        "-pubout", "-outform", "DER", "-out", td + "/p.der"],
                       check=True, capture_output=True)
        pub = b64e(open(td + "/p.der", "rb").read()[-32:])
if not pub:
    raise SystemExit("REALITY 公钥推导失败")
ip = os.environ["SHOW_IP"]
nm = os.environ.get("SHOW_NAME", "NODE")
print()
print("==================== 节点链接 (手机/电脑通用) ====================")
print("--- Hysteria2 主节点 (固定速率: 上行 50 / 下行 100) ---")
print(f"hysteria2://{hy2['users'][0]['password']}@{ip}:443?mport=20000-30000&obfs=salamander&obfs-password={hy2['obfs']['password']}&insecure=1&sni={sni}&upmbps=50&downmbps=100#{nm}-HY2")
print()
print("--- Hysteria2 自适应备版 (不带固定速率) ---")
print(f"hysteria2://{hy2['users'][0]['password']}@{ip}:443?mport=20000-30000&obfs=salamander&obfs-password={hy2['obfs']['password']}&insecure=1&sni={sni}#{nm}-HY2-AUTO")
print()
print("--- VLESS+REALITY 备节点 ---")
print(f"vless://{vl['users'][0]['uuid']}@{ip}:443?encryption=none&security=reality&sni={vl_sni}&fp=chrome&pbk={pub}&sid={r['short_id'][0]}&type=tcp#{nm}-VLESS")
print("===================================================================")
print("提醒: REALITY 链接刻意不带 flow 参数, 这是本方案实测可用的口径, 不要自行加回 xtls-rprx-vision。")
PYEOF
    return
  fi
  load_info
  print_links "$ip"
}

do_status() {
  systemctl --no-pager --full status sing-box hy2-hop.service 2>/dev/null | head -n 40 || true
  echo
  ss -tulnp | grep -E ':443' || ss -tulnp | head -n 20
}

do_reinstall() {
  need_root
  if [ ! -f "$INFO_FILE" ]; then
    do_install
    return
  fi
  if [ "${CONFIRM:-}" != "1" ]; then
    printf '将重新生成全部参数, 旧链接立即失效。确认请输入 yes: '
    local ans
    read -r ans
    [ "$ans" = "yes" ] || { echo "已取消。"; exit 0; }
  fi
  do_install force
}

do_uninstall() {
  need_root
  if [ "${CONFIRM:-}" != "1" ]; then
    printf '将停止 sing-box, 删除本脚本配置与端口跳跃服务 (不卸载 sing-box 软件包)。确认请输入 yes: '
    local ans
    read -r ans
    [ "$ans" = "yes" ] || { echo "已取消。"; exit 0; }
  fi
  systemctl disable --now hy2-hop.service 2>/dev/null || true
  systemctl disable --now sing-box 2>/dev/null || true
  rm -f /etc/systemd/system/hy2-hop.service
  systemctl daemon-reload
  rm -rf "$NODE_DIR"
  echo "已卸载本脚本创建的配置。如需彻底删除软件包: apt-get remove -y sing-box"
}

main() {
  case "${1:-install}" in
    install)   do_install ;;
    reinstall) do_reinstall ;;
    show)      do_show "${2:-}" ;;
    status)    do_status ;;
    uninstall) do_uninstall ;;
    *) echo "用法: bash $0 [install|show [IP]|status|reinstall|uninstall]"; exit 1 ;;
  esac
}

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
  main "$@"
fi

将上述文件改为:sb.sh

bash sb.sh # 一键搭建(已搭建则直接显示链接)
bash sb.sh show # 一键查询节点链接
bash sb.sh status # 看运行状态

如需全部重新生成 (旧链接立即失效): bash sb.sh reinstall

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注