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