875 lines
22 KiB
Bash
875 lines
22 KiB
Bash
#!/usr/bin/env bash
|
||
set -Eeuo pipefail
|
||
trap 'echo "[错误] 第 $LINENO 行执行失败:$BASH_COMMAND" >&2' ERR
|
||
|
||
CONFIG_FILE="/etc/hysteria/config.yaml"
|
||
HYSTERIA_BIN="/usr/local/bin/hysteria"
|
||
HYSTERIA_WORK_DIR="/var/lib/hysteria"
|
||
SYSTEMD_SERVICE_NAME="hysteria-server.service"
|
||
OPENRC_SERVICE_NAME="hysteria-server"
|
||
OPENRC_SERVICE_FILE="/etc/init.d/${OPENRC_SERVICE_NAME}"
|
||
OPENRC_LOG_FILE="/var/log/${OPENRC_SERVICE_NAME}.log"
|
||
SERVICE_NAME="${SYSTEMD_SERVICE_NAME}"
|
||
DEVICE_USERS=(macminim4 iphone12p ipadmini5 pve_debian other)
|
||
USERPASS_ENTRIES=()
|
||
TRAFFIC_STATS_SECRET=""
|
||
PACKAGE_MANAGER=""
|
||
INIT_SYSTEM=""
|
||
OS_ID=""
|
||
OS_ID_LIKE=""
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
require_python3_json() {
|
||
python3 - <<'PY' >/dev/null 2>&1
|
||
import json
|
||
PY
|
||
}
|
||
|
||
is_systemd() {
|
||
[[ "${INIT_SYSTEM}" == "systemd" ]]
|
||
}
|
||
|
||
is_openrc() {
|
||
[[ "${INIT_SYSTEM}" == "openrc" ]]
|
||
}
|
||
|
||
load_os_release() {
|
||
if [[ -r /etc/os-release ]]; then
|
||
OS_ID="$(awk -F= '/^ID=/{gsub(/"/, "", $2); print $2; exit}' /etc/os-release)"
|
||
OS_ID_LIKE="$(awk -F= '/^ID_LIKE=/{gsub(/"/, "", $2); print $2; exit}' /etc/os-release)"
|
||
fi
|
||
}
|
||
|
||
platform_label() {
|
||
printf '%s/%s/%s' "${OS_ID:-unknown}" "${PACKAGE_MANAGER:-unknown}" "${INIT_SYSTEM:-unknown}"
|
||
}
|
||
|
||
detect_platform() {
|
||
load_os_release
|
||
|
||
if command -v apk >/dev/null 2>&1 || [[ "${OS_ID}" == "alpine" ]] || [[ " ${OS_ID_LIKE} " == *" alpine "* ]]; then
|
||
PACKAGE_MANAGER="apk"
|
||
INIT_SYSTEM="openrc"
|
||
SERVICE_NAME="${OPENRC_SERVICE_NAME}"
|
||
if ! command -v rc-service >/dev/null 2>&1 || ! command -v rc-update >/dev/null 2>&1; then
|
||
red "检测到 Alpine/apk,但未发现 OpenRC(rc-service/rc-update),无法继续。"
|
||
exit 1
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
if command -v apt >/dev/null 2>&1; then
|
||
PACKAGE_MANAGER="apt"
|
||
INIT_SYSTEM="systemd"
|
||
SERVICE_NAME="${SYSTEMD_SERVICE_NAME}"
|
||
if ! command -v systemctl >/dev/null 2>&1; then
|
||
red "检测到 APT 环境,但未发现 systemctl,当前脚本暂不支持该初始化系统。"
|
||
exit 1
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
red "当前仅支持 Debian/Ubuntu(apt + systemd)和 Alpine(apk + OpenRC)。"
|
||
exit 1
|
||
}
|
||
|
||
detect_hysteria_arch() {
|
||
case "$(uname -m)" in
|
||
x86_64|amd64) echo "amd64" ;;
|
||
i386|i686) echo "386" ;;
|
||
armv5*|arm5*) echo "armv5" ;;
|
||
armv6*|armv7*|arm) echo "arm" ;;
|
||
aarch64|arm64) echo "arm64" ;;
|
||
s390x) echo "s390x" ;;
|
||
mips64le|mipsle|mips) echo "mipsle" ;;
|
||
riscv64) echo "riscv64" ;;
|
||
*)
|
||
return 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
random_subdomain_prefix() {
|
||
openssl rand -hex 8 | cut -c1-8
|
||
}
|
||
|
||
prompt_nonempty() {
|
||
local prompt="$1"
|
||
local value=""
|
||
while true; do
|
||
read -r -p "$prompt" value < /dev/tty
|
||
if [[ -n "${value// }" ]]; then
|
||
printf '%s' "$value"
|
||
return 0
|
||
fi
|
||
yellow "输入不能为空,请重新输入。" >&2
|
||
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
|
||
}
|
||
|
||
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
|
||
|
||
green "APT 锁已释放。"
|
||
return 0
|
||
}
|
||
|
||
generate_password() {
|
||
local length="${1:-36}"
|
||
local password=""
|
||
|
||
while ((${#password} < length)); do
|
||
password+="$(
|
||
openssl rand -base64 48 | tr -dc 'A-Za-z0-9'
|
||
)"
|
||
done
|
||
|
||
printf '%s\n' "${password:0:length}"
|
||
}
|
||
|
||
mask_secret() {
|
||
local secret="$1"
|
||
local len="${#secret}"
|
||
if (( len <= 8 )); then
|
||
printf '********'
|
||
else
|
||
printf '%s****%s' "${secret:0:4}" "${secret: -4}"
|
||
fi
|
||
}
|
||
|
||
yaml_quote() {
|
||
local value="$1"
|
||
value="${value//\\/\\\\}"
|
||
value="${value//\"/\\\"}"
|
||
printf '"%s"' "$value"
|
||
}
|
||
|
||
validate_domain() {
|
||
local domain="$1"
|
||
[[ -n "$domain" ]] || return 1
|
||
[[ "$domain" != *$'\n'* ]] || return 1
|
||
[[ "$domain" != *$'\r'* ]] || return 1
|
||
[[ "$domain" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$ ]]
|
||
}
|
||
|
||
get_server_ip() {
|
||
local ipv4 ipv6
|
||
ipv4="$(curl -4 -fsSL https://api.ipify.org || true)"
|
||
ipv6="$(curl -6 -fsSL https://api64.ipify.org || true)"
|
||
echo "${ipv4}|${ipv6}"
|
||
}
|
||
|
||
apt_update_and_install_base() {
|
||
if ! confirm_yn "即将执行 apt update 并安装基础依赖 curl sed ufw openssl python3 ca-certificates,是否继续?"; 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 python3 ca-certificates
|
||
}
|
||
|
||
apk_update_and_install_base() {
|
||
if ! confirm_yn "即将执行 apk update 并安装基础依赖 bash curl sed ufw ufw-openrc openssl python3 iptables ca-certificates,是否继续?"; then
|
||
red "已取消。"
|
||
exit 1
|
||
fi
|
||
|
||
blue "==> 更新 APK 软件索引"
|
||
apk update
|
||
|
||
blue "==> 安装基础依赖"
|
||
apk add --no-cache bash curl sed ufw ufw-openrc openssl python3 iptables ca-certificates
|
||
}
|
||
|
||
install_base_dependencies() {
|
||
case "${PACKAGE_MANAGER}" in
|
||
apt) apt_update_and_install_base ;;
|
||
apk) apk_update_and_install_base ;;
|
||
*)
|
||
red "未知包管理器:${PACKAGE_MANAGER}"
|
||
exit 1
|
||
;;
|
||
esac
|
||
}
|
||
|
||
service_exists() {
|
||
local service="$1"
|
||
|
||
if is_systemd; then
|
||
systemctl list-unit-files 2>/dev/null | grep -Eq "^${service}(\\.service)?"
|
||
return $?
|
||
fi
|
||
|
||
if is_openrc; then
|
||
[[ -x "/etc/init.d/${service}" ]]
|
||
return $?
|
||
fi
|
||
|
||
return 1
|
||
}
|
||
|
||
stop_and_disable_service() {
|
||
local service="$1"
|
||
|
||
if is_systemd; then
|
||
systemctl stop "${service}" || true
|
||
systemctl disable "${service}" || true
|
||
return 0
|
||
fi
|
||
|
||
if is_openrc; then
|
||
rc-service "${service}" stop || true
|
||
rc-update del "${service}" default || true
|
||
return 0
|
||
fi
|
||
|
||
return 1
|
||
}
|
||
|
||
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
|
||
if is_openrc && service_exists ufw; then
|
||
rc-service ufw stop || true
|
||
fi
|
||
fi
|
||
|
||
if service_exists firewalld; then
|
||
yellow "检测到 firewalld,正在停止并禁用"
|
||
stop_and_disable_service firewalld || true
|
||
if is_systemd; then
|
||
systemctl mask firewalld || true
|
||
fi
|
||
fi
|
||
|
||
if service_exists nftables; then
|
||
yellow "检测到 nftables,正在停止并禁用"
|
||
stop_and_disable_service 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
|
||
|
||
if is_openrc && service_exists ufw; then
|
||
rc-update add ufw default || true
|
||
rc-service ufw restart || rc-service ufw start || true
|
||
fi
|
||
|
||
green "UFW 配置完成。"
|
||
}
|
||
|
||
write_openrc_service_file() {
|
||
mkdir -p "${HYSTERIA_WORK_DIR}"
|
||
|
||
cat > "${OPENRC_SERVICE_FILE}" <<EOF
|
||
#!/sbin/openrc-run
|
||
|
||
description="Hysteria 2 server"
|
||
command="${HYSTERIA_BIN}"
|
||
command_args="server -c ${CONFIG_FILE}"
|
||
command_background="yes"
|
||
pidfile="/run/\${RC_SVCNAME}.pid"
|
||
start_stop_daemon_args="--chdir ${HYSTERIA_WORK_DIR} --stdout ${OPENRC_LOG_FILE} --stderr ${OPENRC_LOG_FILE}"
|
||
|
||
depend() {
|
||
need net
|
||
use dns logger firewall
|
||
}
|
||
|
||
start_pre() {
|
||
checkpath --directory --owner root:root --mode 0755 ${HYSTERIA_WORK_DIR}
|
||
checkpath --file --owner root:root --mode 0644 ${OPENRC_LOG_FILE}
|
||
}
|
||
EOF
|
||
|
||
chmod 755 "${OPENRC_SERVICE_FILE}"
|
||
}
|
||
|
||
install_hysteria2_alpine() {
|
||
local arch download_url tmp_bin
|
||
|
||
arch="$(detect_hysteria_arch)" || {
|
||
red "当前架构 $(uname -m) 暂无对应的 Alpine 安装分支。"
|
||
exit 1
|
||
}
|
||
|
||
download_url="https://github.com/apernet/hysteria/releases/latest/download/hysteria-linux-${arch}"
|
||
tmp_bin="$(mktemp)"
|
||
|
||
blue "==> 下载 Hysteria 2 二进制 (${arch})"
|
||
curl -fsSL "${download_url}" -o "${tmp_bin}"
|
||
|
||
blue "==> 安装 Hysteria 2 二进制"
|
||
install -m 755 "${tmp_bin}" "${HYSTERIA_BIN}"
|
||
rm -f "${tmp_bin}"
|
||
|
||
write_openrc_service_file
|
||
}
|
||
|
||
install_hysteria2() {
|
||
if ! confirm_yn "即将安装 Hysteria 2,是否继续?"; then
|
||
red "已取消 Hysteria 2 安装。"
|
||
exit 1
|
||
fi
|
||
|
||
blue "==> 安装 Hysteria 2"
|
||
|
||
if is_openrc; then
|
||
install_hysteria2_alpine
|
||
else
|
||
bash <(curl -fsSL https://get.hy2.sh/)
|
||
fi
|
||
|
||
green "Hysteria 2 安装完成。"
|
||
}
|
||
|
||
run_domain_selector() {
|
||
if confirm_yn "是否执行外部域名筛选脚本?"; then
|
||
blue "==> 执行域名筛选脚本"
|
||
bash <(curl -sL https://raw.githubusercontent.com/ccxkai233/Domain_Selector/main/domain_check.sh) || true
|
||
else
|
||
yellow "已跳过域名筛选脚本。"
|
||
fi
|
||
}
|
||
|
||
cf_api_request() {
|
||
local method="$1"
|
||
local url="$2"
|
||
local data="${3:-}"
|
||
|
||
if [[ -n "$data" ]]; then
|
||
curl -fsSL -X "$method" "$url" \
|
||
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
||
-H "Content-Type: application/json" \
|
||
--data "$data"
|
||
else
|
||
curl -fsSL -X "$method" "$url" \
|
||
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
||
-H "Content-Type: application/json"
|
||
fi
|
||
}
|
||
|
||
cf_extract_zone_id() {
|
||
python3 -c '
|
||
import json, sys
|
||
data = json.load(sys.stdin)
|
||
result = data.get("result") or []
|
||
if result and isinstance(result[0], dict):
|
||
print(result[0].get("id", ""), end="")
|
||
'
|
||
}
|
||
|
||
cf_extract_dns_record_id() {
|
||
python3 -c '
|
||
import json, sys
|
||
data = json.load(sys.stdin)
|
||
result = data.get("result") or []
|
||
if result and isinstance(result[0], dict):
|
||
print(result[0].get("id", ""), end="")
|
||
'
|
||
}
|
||
|
||
get_cf_zone_id() {
|
||
local zone="$1"
|
||
cf_api_request GET "https://api.cloudflare.com/client/v4/zones?name=${zone}" \
|
||
| cf_extract_zone_id
|
||
}
|
||
|
||
delete_existing_cf_dns_record() {
|
||
local zone_id="$1"
|
||
local full_domain="$2"
|
||
local type="$3"
|
||
local record_id
|
||
|
||
record_id="$(
|
||
cf_api_request GET "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records?name=${full_domain}&type=${type}" \
|
||
| cf_extract_dns_record_id
|
||
)"
|
||
|
||
if [[ -n "$record_id" ]]; then
|
||
cf_api_request DELETE "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records/${record_id}" >/dev/null
|
||
fi
|
||
}
|
||
|
||
create_cf_dns_record_type() {
|
||
local zone_id="$1"
|
||
local full_domain="$2"
|
||
local type="$3"
|
||
local content="$4"
|
||
|
||
[[ -z "$content" ]] && return 0
|
||
|
||
local payload
|
||
payload=$(
|
||
cat <<EOF
|
||
{"type":"${type}","name":"${full_domain}","content":"${content}","ttl":120,"proxied":false}
|
||
EOF
|
||
)
|
||
|
||
if ! cf_api_request POST "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records" "$payload" >/dev/null; then
|
||
red "创建 Cloudflare ${type} 记录失败,请检查 Token 权限和 Zone 配置。" >&2
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
create_cloudflare_dns_record() {
|
||
local zone="$1"
|
||
local subdomain="$2"
|
||
local ipv4="$3"
|
||
local ipv6="$4"
|
||
local full_domain="${subdomain}.${zone}"
|
||
local zone_id
|
||
|
||
echo "==> 在 Cloudflare 中创建 DNS 记录" >&2
|
||
echo "Zone: ${zone}" >&2
|
||
echo "完整域名: ${full_domain}" >&2
|
||
|
||
if ! zone_id="$(get_cf_zone_id "$zone")"; then
|
||
red "调用 Cloudflare API 获取 Zone ID 失败,请检查网络和 Token 权限。" >&2
|
||
return 1
|
||
fi
|
||
if [[ -z "$zone_id" ]]; then
|
||
red "无法获取 Cloudflare Zone ID,请检查 Token 权限或 Zone 名称。" >&2
|
||
return 1
|
||
fi
|
||
|
||
delete_existing_cf_dns_record "$zone_id" "$full_domain" "A" || true
|
||
delete_existing_cf_dns_record "$zone_id" "$full_domain" "AAAA" || true
|
||
|
||
if [[ -n "$ipv4" ]]; then
|
||
create_cf_dns_record_type "$zone_id" "$full_domain" "A" "$ipv4" || return 1
|
||
echo "已创建 A 记录 -> ${ipv4}" >&2
|
||
fi
|
||
|
||
if [[ -n "$ipv6" ]]; then
|
||
create_cf_dns_record_type "$zone_id" "$full_domain" "AAAA" "$ipv6" || return 1
|
||
echo "已创建 AAAA 记录 -> ${ipv6}" >&2
|
||
fi
|
||
|
||
if ! validate_domain "$full_domain"; then
|
||
red "生成的域名格式无效:${full_domain}" >&2
|
||
return 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
|
||
}
|
||
|
||
set_config_permissions() {
|
||
local service_user service_group
|
||
|
||
if is_openrc; then
|
||
chown root:root "${CONFIG_FILE}"
|
||
chmod 600 "${CONFIG_FILE}"
|
||
green "已按 OpenRC 服务配置将配置文件权限设置为 root:root 600"
|
||
return 0
|
||
fi
|
||
|
||
service_user="$(systemctl show -p User --value "${SERVICE_NAME}" 2>/dev/null || true)"
|
||
service_user="${service_user//$'\n'/}"
|
||
|
||
if [[ -n "${service_user}" && "${service_user}" != "root" ]]; then
|
||
service_group="$(id -gn "${service_user}" 2>/dev/null || true)"
|
||
if [[ -n "${service_group}" ]]; then
|
||
chown root:"${service_group}" "${CONFIG_FILE}"
|
||
chmod 640 "${CONFIG_FILE}"
|
||
green "已按服务账户 ${service_user}:${service_group} 设置配置文件权限为 640"
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
chown root:root "${CONFIG_FILE}"
|
||
chmod 644 "${CONFIG_FILE}"
|
||
yellow "未识别到可用的服务账户组,已回退为 root:root 且权限 644"
|
||
}
|
||
|
||
render_userpass_block() {
|
||
local indent="${1:-4}"
|
||
local prefix="" entry username password
|
||
|
||
prefix="$(printf '%*s' "${indent}" '')"
|
||
for entry in "${USERPASS_ENTRIES[@]}"; do
|
||
username="${entry%%:*}"
|
||
password="${entry#*:}"
|
||
printf '%s%s: %s\n' "${prefix}" "${username}" "$(yaml_quote "$password")"
|
||
done
|
||
}
|
||
|
||
render_config() {
|
||
local domain="$1"
|
||
local email="$2"
|
||
local cf_token="$3"
|
||
local proxy_url="$4"
|
||
|
||
cat <<EOF
|
||
listen: :443
|
||
|
||
acme:
|
||
domains:
|
||
- $(yaml_quote "$domain")
|
||
email: $(yaml_quote "$email")
|
||
type: dns
|
||
dns:
|
||
name: cloudflare
|
||
config:
|
||
cloudflare_api_token: $(yaml_quote "$cf_token")
|
||
|
||
auth:
|
||
type: userpass
|
||
userpass:
|
||
EOF
|
||
render_userpass_block 4
|
||
cat <<EOF
|
||
|
||
trafficStats:
|
||
listen: 127.0.0.1:9999
|
||
secret: $(yaml_quote "$TRAFFIC_STATS_SECRET")
|
||
|
||
disableUDP: false
|
||
udpIdleTimeout: 90s
|
||
|
||
resolver:
|
||
type: https
|
||
https:
|
||
addr: 1.1.1.1:443
|
||
timeout: 10s
|
||
sni: cloudflare-dns.com
|
||
insecure: false
|
||
|
||
speedTest: true
|
||
|
||
masquerade:
|
||
type: proxy
|
||
proxy:
|
||
url: $(yaml_quote "$proxy_url")
|
||
rewriteHost: true
|
||
insecure: false
|
||
EOF
|
||
}
|
||
|
||
render_preview_config() {
|
||
local domain="$1"
|
||
local email="$2"
|
||
local cf_token="$3"
|
||
local proxy_url="$4"
|
||
local masked_token
|
||
|
||
if ! validate_domain "$domain"; then
|
||
red "域名无效,拒绝写入配置:${domain@Q}"
|
||
exit 1
|
||
fi
|
||
|
||
masked_token="$(mask_secret "$cf_token")"
|
||
render_config "$domain" "$email" "$masked_token" "$proxy_url"
|
||
}
|
||
|
||
write_config() {
|
||
local domain="$1"
|
||
local email="$2"
|
||
local cf_token="$3"
|
||
local proxy_url="$4"
|
||
|
||
if ! validate_domain "$domain"; then
|
||
red "域名无效,拒绝写入配置:${domain@Q}"
|
||
exit 1
|
||
fi
|
||
|
||
echo
|
||
blue "==> 即将写入如下配置到 ${CONFIG_FILE}"
|
||
render_preview_config "$domain" "$email" "$cf_token" "$proxy_url"
|
||
echo
|
||
|
||
if ! confirm_yn "是否确认写入配置文件?"; then
|
||
red "已取消写入配置。"
|
||
exit 1
|
||
fi
|
||
|
||
mkdir -p /etc/hysteria
|
||
backup_existing_config
|
||
|
||
render_config "$domain" "$email" "$cf_token" "$proxy_url" > "${CONFIG_FILE}"
|
||
|
||
set_config_permissions
|
||
green "配置已写入 ${CONFIG_FILE}"
|
||
}
|
||
|
||
start_service() {
|
||
if ! confirm_yn "是否启动并设置 Hysteria 服务开机自启?"; then
|
||
red "已取消启动服务。"
|
||
exit 1
|
||
fi
|
||
|
||
blue "==> 启动并设置开机自启"
|
||
if is_systemd; then
|
||
systemctl daemon-reload || true
|
||
systemctl enable --now "${SERVICE_NAME}"
|
||
systemctl restart "${SERVICE_NAME}"
|
||
else
|
||
rc-update add "${SERVICE_NAME}" default || true
|
||
rc-service "${SERVICE_NAME}" restart || rc-service "${SERVICE_NAME}" start
|
||
fi
|
||
green "服务已启动。"
|
||
}
|
||
|
||
show_service_status() {
|
||
if is_systemd; then
|
||
systemctl --no-pager --full status "${SERVICE_NAME}" || true
|
||
else
|
||
rc-service "${SERVICE_NAME}" status || true
|
||
pgrep -af "${HYSTERIA_BIN##*/}" || true
|
||
fi
|
||
}
|
||
|
||
show_recent_logs() {
|
||
if is_systemd; then
|
||
journalctl --no-pager -n 30 -u "${SERVICE_NAME}" || true
|
||
else
|
||
if [[ -f "${OPENRC_LOG_FILE}" ]]; then
|
||
tail -n 30 "${OPENRC_LOG_FILE}" || true
|
||
else
|
||
yellow "暂未找到 OpenRC 日志文件:${OPENRC_LOG_FILE}"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
show_result() {
|
||
local domain="$1"
|
||
local proxy_url="$2"
|
||
local ip_info="$3"
|
||
local ipv4="${ip_info%%|*}"
|
||
local ipv6="${ip_info##*|}"
|
||
local entry username password
|
||
|
||
echo
|
||
green "================= HY2 节点信息 ================="
|
||
echo "域名: ${domain}"
|
||
echo "端口: 443"
|
||
echo "认证方式: userpass"
|
||
echo "服务管理器: ${INIT_SYSTEM}"
|
||
echo "伪装站点: ${proxy_url}"
|
||
echo "流量统计监听: 127.0.0.1:9999"
|
||
echo "流量统计密钥: ${TRAFFIC_STATS_SECRET}"
|
||
[[ -n "${ipv4}" ]] && echo "IPv4: ${ipv4}"
|
||
[[ -n "${ipv6}" ]] && echo "IPv6: ${ipv6}"
|
||
echo
|
||
echo "客户端连接参数:"
|
||
echo " server: ${domain}:443"
|
||
echo " sni: ${domain}"
|
||
echo " auth: userpass"
|
||
echo
|
||
echo "设备账号:"
|
||
for entry in "${USERPASS_ENTRIES[@]}"; do
|
||
username="${entry%%:*}"
|
||
password="${entry#*:}"
|
||
echo " ${username}: ${password}"
|
||
done
|
||
echo
|
||
echo "hysteria 状态:"
|
||
show_service_status
|
||
echo
|
||
echo "最近日志:"
|
||
show_recent_logs
|
||
echo "================================================"
|
||
}
|
||
|
||
main() {
|
||
require_root
|
||
detect_platform
|
||
|
||
if ! confirm_yn "本脚本将更新软件源、关闭现有防火墙、配置 UFW、安装并配置 Hysteria 2(检测到: $(platform_label)),是否继续?"; then
|
||
red "用户取消执行。"
|
||
exit 1
|
||
fi
|
||
|
||
install_base_dependencies
|
||
|
||
require_cmd curl
|
||
require_cmd sed
|
||
require_cmd ufw
|
||
require_cmd openssl
|
||
require_cmd python3
|
||
if is_systemd; then
|
||
require_cmd systemctl
|
||
else
|
||
require_cmd rc-service
|
||
require_cmd rc-update
|
||
fi
|
||
require_python3_json || { red "python3 缺少 json 模块,无法解析 Cloudflare API 返回值。"; exit 1; }
|
||
|
||
local email zone subdomain default_subdomain proxy_url ip_info ipv4 ipv6 domain username
|
||
|
||
email="$(prompt_nonempty '请输入 ACME 邮箱: ')"
|
||
zone="$(prompt_nonempty '请输入 Cloudflare Zone(例如 example.com): ')"
|
||
|
||
default_subdomain="$(random_subdomain_prefix)"
|
||
read -r -p "请输入要创建的子域名前缀(例如 hy2,直接回车使用默认值 ${default_subdomain}): " subdomain
|
||
subdomain="${subdomain:-$default_subdomain}"
|
||
subdomain="$(printf '%s' "$subdomain" | tr 'A-Z' 'a-z' | tr -cd 'a-z0-9-')"
|
||
|
||
CF_API_TOKEN="$(prompt_nonempty '请输入 Cloudflare API Token: ')"
|
||
export CF_API_TOKEN
|
||
|
||
USERPASS_ENTRIES=()
|
||
for username in "${DEVICE_USERS[@]}"; do
|
||
USERPASS_ENTRIES+=("${username}:$(generate_password 36)")
|
||
done
|
||
TRAFFIC_STATS_SECRET="$(generate_password 36)"
|
||
|
||
ip_info="$(get_server_ip)"
|
||
ipv4="${ip_info%%|*}"
|
||
ipv6="${ip_info##*|}"
|
||
|
||
if [[ -z "$ipv4" && -z "$ipv6" ]]; then
|
||
red "无法获取服务器公网 IP。"
|
||
exit 1
|
||
fi
|
||
|
||
disable_existing_firewalls
|
||
configure_ufw
|
||
install_hysteria2
|
||
run_domain_selector
|
||
|
||
proxy_url="$(prompt_nonempty '请输入最终用于 masquerade 的完整 URL(例如 https://example.com/): ')"
|
||
|
||
if confirm_yn "是否在 Cloudflare 中自动创建 DNS 记录 ${subdomain}.${zone}?"; then
|
||
if ! domain="$(create_cloudflare_dns_record "$zone" "$subdomain" "$ipv4" "$ipv6")"; then
|
||
red "自动创建 Cloudflare DNS 记录失败,脚本已中止,未写入 Hysteria 配置。"
|
||
exit 1
|
||
fi
|
||
else
|
||
domain="$(prompt_nonempty '请输入已存在并已解析到本机的完整域名: ')"
|
||
fi
|
||
|
||
write_config "${domain}" "${email}" "${CF_API_TOKEN}" "${proxy_url}"
|
||
start_service
|
||
show_result "${domain}" "${proxy_url}" "${ip_info}"
|
||
}
|
||
|
||
main "$@"
|