484 lines
12 KiB
Bash
484 lines
12 KiB
Bash
#!/usr/bin/env bash
|
||
set -Eeuo pipefail
|
||
trap 'echo "[错误] 第 $LINENO 行执行失败:$BASH_COMMAND"' ERR
|
||
|
||
CONFIG_FILE="/etc/hysteria/config.yaml"
|
||
SERVICE_NAME="hysteria-server.service"
|
||
MASQUERADE_DEFAULT_URL="https://news.ycombinator.com/"
|
||
|
||
red() { printf '\033[31m%s\033[0m\n' "$*"; }
|
||
green() { printf '\033[32m%s\033[0m\n' "$*"; }
|
||
yellow() { printf '\033[33m%s\033[0m\n' "$*"; }
|
||
blue() { printf '\033[36m%s\033[0m\n' "$*"; }
|
||
|
||
require_root() {
|
||
if [[ "${EUID}" -ne 0 ]]; then
|
||
red "请使用 root 运行此脚本。"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
require_cmd() {
|
||
command -v "$1" >/dev/null 2>&1 || {
|
||
red "缺少命令: $1"
|
||
exit 1
|
||
}
|
||
}
|
||
|
||
prompt_nonempty() {
|
||
local prompt="$1"
|
||
local value=""
|
||
while true; do
|
||
read -r -p "$prompt" value
|
||
if [[ -n "${value// }" ]]; then
|
||
printf '%s' "$value"
|
||
return 0
|
||
fi
|
||
yellow "输入不能为空,请重新输入。"
|
||
done
|
||
}
|
||
|
||
confirm_yn() {
|
||
local prompt="${1:-是否继续?}"
|
||
local answer
|
||
read -r -p "${prompt} [Y/n]: " answer
|
||
case "${answer:-Y}" in
|
||
Y|y|yes|YES|"") return 0 ;;
|
||
N|n|no|NO) return 1 ;;
|
||
*) yellow "输入无效,默认按 Y 处理。"; return 0 ;;
|
||
esac
|
||
}
|
||
|
||
generate_password() {
|
||
openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 24
|
||
echo
|
||
}
|
||
|
||
generate_sub_prefix() {
|
||
openssl rand -base64 32 | tr -dc 'a-z0-9' | head -c 8
|
||
echo
|
||
}
|
||
|
||
get_public_ips() {
|
||
local ipv4="" ipv6=""
|
||
ipv4="$(curl -4 -fsSL https://api.ipify.org || true)"
|
||
ipv6="$(curl -6 -fsSL https://api64.ipify.org || true)"
|
||
printf '%s|%s\n' "$ipv4" "$ipv6"
|
||
}
|
||
|
||
wait_for_apt_lock() {
|
||
local timeout="${1:-300}"
|
||
local waited=0
|
||
|
||
while pgrep -x apt >/dev/null 2>&1 \
|
||
|| pgrep -x apt-get >/dev/null 2>&1 \
|
||
|| pgrep -x dpkg >/dev/null 2>&1 \
|
||
|| pgrep -f unattended-upgrades >/dev/null 2>&1; do
|
||
if (( waited == 0 )); then
|
||
yellow "检测到 APT/DPKG 正被其他进程占用,正在等待..."
|
||
ps -ef | grep -E 'apt|apt-get|dpkg|unattended' | grep -v grep || true
|
||
fi
|
||
|
||
if (( waited >= timeout )); then
|
||
red "等待 APT 锁超时(${timeout} 秒)。"
|
||
if confirm_yn "是否继续等待?"; then
|
||
waited=0
|
||
else
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
sleep 2
|
||
waited=$((waited + 2))
|
||
done
|
||
|
||
return 0
|
||
}
|
||
|
||
apt_update_and_install_base() {
|
||
if ! confirm_yn "即将执行 apt update 并安装基础依赖 curl sed ufw openssl,是否继续?"; then
|
||
red "已取消。"
|
||
exit 1
|
||
fi
|
||
|
||
export DEBIAN_FRONTEND=noninteractive
|
||
|
||
blue "==> 更新 APT 软件库"
|
||
wait_for_apt_lock 300 || { red "APT 被占用,无法继续。"; exit 1; }
|
||
apt update -y
|
||
|
||
blue "==> 安装基础依赖"
|
||
wait_for_apt_lock 300 || { red "APT 被占用,无法继续。"; exit 1; }
|
||
apt install -y curl sed ufw openssl
|
||
}
|
||
|
||
disable_existing_firewalls() {
|
||
if ! confirm_yn "即将关闭系统现有防火墙并清理规则,是否继续?"; then
|
||
red "已取消关闭现有防火墙。"
|
||
exit 1
|
||
fi
|
||
|
||
blue "==> 自动检测并关闭当前系统防火墙"
|
||
|
||
if command -v ufw >/dev/null 2>&1; then
|
||
yellow "检测到 UFW,正在关闭并重置"
|
||
ufw disable || true
|
||
yes | ufw reset || true
|
||
fi
|
||
|
||
if systemctl list-unit-files 2>/dev/null | grep -q '^firewalld\.service'; then
|
||
yellow "检测到 firewalld,正在停止并禁用"
|
||
systemctl stop firewalld || true
|
||
systemctl disable firewalld || true
|
||
systemctl mask firewalld || true
|
||
fi
|
||
|
||
if systemctl list-unit-files 2>/dev/null | grep -q '^nftables\.service'; then
|
||
yellow "检测到 nftables,正在停止并禁用"
|
||
systemctl stop nftables || true
|
||
systemctl disable nftables || true
|
||
fi
|
||
|
||
if command -v nft >/dev/null 2>&1; then
|
||
yellow "清空 nftables 规则"
|
||
nft flush ruleset || true
|
||
fi
|
||
|
||
if command -v iptables >/dev/null 2>&1; then
|
||
yellow "清空 iptables 规则"
|
||
iptables -I INPUT 1 -p tcp --dport 22 -j ACCEPT || true
|
||
iptables -F || true
|
||
iptables -X || true
|
||
iptables -Z || true
|
||
iptables -P INPUT ACCEPT || true
|
||
iptables -P FORWARD ACCEPT || true
|
||
iptables -P OUTPUT ACCEPT || true
|
||
else
|
||
yellow "未检测到 iptables,跳过。"
|
||
fi
|
||
|
||
if command -v ip6tables >/dev/null 2>&1; then
|
||
yellow "清空 ip6tables 规则"
|
||
ip6tables -I INPUT 1 -p tcp --dport 22 -j ACCEPT || true
|
||
ip6tables -F || true
|
||
ip6tables -X || true
|
||
ip6tables -Z || true
|
||
ip6tables -P INPUT ACCEPT || true
|
||
ip6tables -P FORWARD ACCEPT || true
|
||
ip6tables -P OUTPUT ACCEPT || true
|
||
else
|
||
yellow "未检测到 ip6tables,跳过。"
|
||
fi
|
||
|
||
green "现有防火墙处理完成。"
|
||
}
|
||
|
||
configure_ufw() {
|
||
if ! confirm_yn "即将配置并启用 UFW(开放 22/80/443 TCP 和 443 UDP),是否继续?"; then
|
||
red "已取消 UFW 配置。"
|
||
exit 1
|
||
fi
|
||
|
||
blue "==> 配置 UFW"
|
||
|
||
if [[ -f /etc/default/ufw ]]; then
|
||
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw || true
|
||
fi
|
||
|
||
ufw default deny incoming || true
|
||
ufw default allow outgoing || true
|
||
ufw allow 22/tcp || true
|
||
ufw allow 80/tcp || true
|
||
ufw allow 443/tcp || true
|
||
ufw allow 443/udp || true
|
||
yes | ufw enable || true
|
||
|
||
green "UFW 配置完成。"
|
||
}
|
||
|
||
install_hysteria2() {
|
||
if ! confirm_yn "即将安装 Hysteria 2,是否继续?"; then
|
||
red "已取消 Hysteria 2 安装。"
|
||
exit 1
|
||
fi
|
||
|
||
blue "==> 安装 Hysteria 2"
|
||
bash <(curl -fsSL https://get.hy2.sh/)
|
||
green "Hysteria 2 安装完成。"
|
||
}
|
||
|
||
cf_api() {
|
||
local method="$1"
|
||
local endpoint="$2"
|
||
local token="$3"
|
||
local data="${4:-}"
|
||
|
||
if [[ -n "$data" ]]; then
|
||
curl -fsSL -X "$method" "https://api.cloudflare.com/client/v4${endpoint}" \
|
||
-H "Authorization: Bearer ${token}" \
|
||
-H "Content-Type: application/json" \
|
||
--data "$data"
|
||
else
|
||
curl -fsSL -X "$method" "https://api.cloudflare.com/client/v4${endpoint}" \
|
||
-H "Authorization: Bearer ${token}" \
|
||
-H "Content-Type: application/json"
|
||
fi
|
||
}
|
||
|
||
get_cf_zone_id() {
|
||
local main_domain="$1"
|
||
local token="$2"
|
||
local resp zone_id
|
||
resp="$(cf_api GET "/zones?name=${main_domain}&status=active" "$token")"
|
||
zone_id="$(printf '%s' "$resp" | jq -r '.result[0].id // empty')"
|
||
|
||
if [[ -z "$zone_id" ]]; then
|
||
red "未找到 Cloudflare Zone:${main_domain}"
|
||
red "请确认主域名已接入 Cloudflare,且 API Token 对该 Zone 具有 DNS 编辑权限。"
|
||
exit 1
|
||
fi
|
||
|
||
printf '%s\n' "$zone_id"
|
||
}
|
||
|
||
delete_existing_dns_record_if_needed() {
|
||
local zone_id="$1"
|
||
local full_domain="$2"
|
||
local type="$3"
|
||
local token="$4"
|
||
local resp record_ids rid
|
||
|
||
resp="$(cf_api GET "/zones/${zone_id}/dns_records?type=${type}&name=${full_domain}" "$token")"
|
||
record_ids="$(printf '%s' "$resp" | jq -r '.result[].id // empty')"
|
||
|
||
if [[ -n "$record_ids" ]]; then
|
||
while IFS= read -r rid; do
|
||
[[ -z "$rid" ]] && continue
|
||
cf_api DELETE "/zones/${zone_id}/dns_records/${rid}" "$token" >/dev/null
|
||
done <<< "$record_ids"
|
||
fi
|
||
}
|
||
|
||
create_cf_dns_record() {
|
||
local zone_id="$1"
|
||
local full_domain="$2"
|
||
local type="$3"
|
||
local content="$4"
|
||
local token="$5"
|
||
local payload resp success
|
||
|
||
payload="$(jq -nc \
|
||
--arg type "$type" \
|
||
--arg name "$full_domain" \
|
||
--arg content "$content" \
|
||
'{type:$type,name:$name,content:$content,ttl:120,proxied:false}')"
|
||
|
||
resp="$(cf_api POST "/zones/${zone_id}/dns_records" "$token" "$payload")"
|
||
success="$(printf '%s' "$resp" | jq -r '.success')"
|
||
|
||
if [[ "$success" != "true" ]]; then
|
||
red "创建 Cloudflare DNS 记录失败:"
|
||
printf '%s\n' "$resp" | jq .
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
create_cloudflare_dns_records() {
|
||
local main_domain="$1"
|
||
local sub_prefix="$2"
|
||
local token="$3"
|
||
local ip_info="$4"
|
||
local ipv4="${ip_info%%|*}"
|
||
local ipv6="${ip_info##*|}"
|
||
local full_domain="${sub_prefix}.${main_domain}"
|
||
local zone_id
|
||
|
||
zone_id="$(get_cf_zone_id "$main_domain" "$token")"
|
||
|
||
blue "==> 在 Cloudflare 中创建 DNS 记录"
|
||
echo "Zone: ${main_domain}"
|
||
echo "完整域名: ${full_domain}"
|
||
|
||
if [[ -n "$ipv4" ]]; then
|
||
delete_existing_dns_record_if_needed "$zone_id" "$full_domain" "A" "$token"
|
||
create_cf_dns_record "$zone_id" "$full_domain" "A" "$ipv4" "$token"
|
||
green "已创建 A 记录 -> ${ipv4}"
|
||
fi
|
||
|
||
if [[ -n "$ipv6" ]]; then
|
||
delete_existing_dns_record_if_needed "$zone_id" "$full_domain" "AAAA" "$token"
|
||
create_cf_dns_record "$zone_id" "$full_domain" "AAAA" "$ipv6" "$token"
|
||
green "已创建 AAAA 记录 -> ${ipv6}"
|
||
fi
|
||
|
||
if [[ -z "$ipv4" && -z "$ipv6" ]]; then
|
||
red "未获取到当前机器公网 IPv4/IPv6,无法创建 DNS 记录。"
|
||
exit 1
|
||
fi
|
||
|
||
printf '%s\n' "$full_domain"
|
||
}
|
||
|
||
backup_existing_config() {
|
||
if [[ -f "${CONFIG_FILE}" ]]; then
|
||
local backup_file="${CONFIG_FILE}.bak.$(date +%Y%m%d_%H%M%S)"
|
||
cp -a "${CONFIG_FILE}" "${backup_file}"
|
||
yellow "检测到已有配置,已备份到: ${backup_file}"
|
||
fi
|
||
}
|
||
|
||
write_config() {
|
||
local full_domain="$1"
|
||
local email="$2"
|
||
local cf_token="$3"
|
||
local password="$4"
|
||
local proxy_url="$5"
|
||
|
||
echo
|
||
blue "==> 即将写入如下配置到 ${CONFIG_FILE}"
|
||
cat <<EOF
|
||
listen: :443
|
||
|
||
acme:
|
||
domains:
|
||
- ${full_domain}
|
||
email: ${email}
|
||
type: dns
|
||
dns:
|
||
name: cloudflare
|
||
config:
|
||
cloudflare_api_token: ${cf_token}
|
||
|
||
auth:
|
||
type: password
|
||
password: ${password}
|
||
|
||
masquerade:
|
||
type: proxy
|
||
proxy:
|
||
url: ${proxy_url}
|
||
rewriteHost: true
|
||
EOF
|
||
echo
|
||
|
||
if ! confirm_yn "是否确认写入配置文件?"; then
|
||
red "已取消写入配置。"
|
||
exit 1
|
||
fi
|
||
|
||
mkdir -p /etc/hysteria
|
||
backup_existing_config
|
||
|
||
cat > "${CONFIG_FILE}" <<EOF
|
||
listen: :443
|
||
|
||
acme:
|
||
domains:
|
||
- ${full_domain}
|
||
email: ${email}
|
||
type: dns
|
||
dns:
|
||
name: cloudflare
|
||
config:
|
||
cloudflare_api_token: ${cf_token}
|
||
|
||
auth:
|
||
type: password
|
||
password: ${password}
|
||
|
||
masquerade:
|
||
type: proxy
|
||
proxy:
|
||
url: ${proxy_url}
|
||
rewriteHost: true
|
||
EOF
|
||
|
||
chown root:root "${CONFIG_FILE}"
|
||
chmod 644 "${CONFIG_FILE}"
|
||
green "配置已写入 ${CONFIG_FILE}"
|
||
}
|
||
|
||
start_service() {
|
||
if ! confirm_yn "是否启动并设置 Hysteria 服务开机自启?"; then
|
||
red "已取消启动服务。"
|
||
exit 1
|
||
fi
|
||
|
||
blue "==> 启动并设置开机自启"
|
||
systemctl daemon-reload || true
|
||
systemctl enable --now "${SERVICE_NAME}"
|
||
systemctl restart "${SERVICE_NAME}"
|
||
green "服务已启动。"
|
||
}
|
||
|
||
show_result() {
|
||
local full_domain="$1"
|
||
local password="$2"
|
||
local proxy_url="$3"
|
||
local ip_info="$4"
|
||
local ipv4="${ip_info%%|*}"
|
||
local ipv6="${ip_info##*|}"
|
||
local share_link="hysteria2://${password}@${full_domain}:443/?sni=${full_domain}&insecure=0"
|
||
|
||
echo
|
||
green "================= HY2 节点信息 ================="
|
||
echo "域名: ${full_domain}"
|
||
echo "端口: 443"
|
||
echo "密码: ${password}"
|
||
echo "伪装站点: ${proxy_url}"
|
||
[[ -n "${ipv4}" ]] && echo "IPv4: ${ipv4}"
|
||
[[ -n "${ipv6}" ]] && echo "IPv6: ${ipv6}"
|
||
echo
|
||
echo "代理链接:"
|
||
echo "${share_link}"
|
||
echo
|
||
echo "hysteria 状态:"
|
||
systemctl status "${SERVICE_NAME}" --no-pager -l | sed -n '1,8p'
|
||
echo "================================================"
|
||
}
|
||
|
||
main() {
|
||
require_root
|
||
|
||
if ! confirm_yn "本脚本将更新软件源、关闭现有防火墙、配置 UFW、安装并配置 Hysteria 2、自动创建 Cloudflare DNS,是否继续?"; then
|
||
red "用户取消执行。"
|
||
exit 1
|
||
fi
|
||
|
||
apt_update_and_install_base
|
||
|
||
require_cmd curl
|
||
require_cmd sed
|
||
require_cmd systemctl
|
||
require_cmd ufw
|
||
require_cmd jq
|
||
require_cmd openssl
|
||
|
||
local main_domain email cf_token password sub_prefix full_domain ip_info proxy_url
|
||
|
||
main_domain="$(prompt_nonempty '请输入主域名(例如 example.com): ')"
|
||
email="$(prompt_nonempty '请输入 ACME 邮箱: ')"
|
||
cf_token="$(prompt_nonempty '请输入 Cloudflare API Token: ')"
|
||
|
||
password="$(generate_password)"
|
||
sub_prefix="$(generate_sub_prefix)"
|
||
ip_info="$(get_public_ips)"
|
||
|
||
full_domain="$(create_cloudflare_dns_records "$main_domain" "$sub_prefix" "$cf_token" "$ip_info")"
|
||
proxy_url="$MASQUERADE_DEFAULT_URL"
|
||
|
||
echo
|
||
green "自动生成的 Hysteria 域名: ${full_domain}"
|
||
echo "默认伪装站点: ${proxy_url}"
|
||
echo "随机密码: ${password}"
|
||
echo
|
||
|
||
disable_existing_firewalls
|
||
configure_ufw
|
||
install_hysteria2
|
||
write_config "${full_domain}" "${email}" "${cf_token}" "${password}" "${proxy_url}"
|
||
start_service
|
||
show_result "${full_domain}" "${password}" "${proxy_url}" "${ip_info}"
|
||
}
|
||
|
||
main "$@" |