VPS 一键脚本

ssh SSH 安全加固

要安全地修改 SSH 端口或改用密钥登录,以 root 运行下面的命令。脚本会备份 sshd_config、用 sshd -t 校验,只做 reload,现有连接不会断开;新端口默认与旧端口同时开放,确认新端口能登录后再关闭旧端口。

命令
$ curl -fsSL xyz.sh/ssh | bash
最后更新:
声明支持的系统:Debian, Ubuntu, CentOS, Rocky, AlmaLinux
别名:sshd ssh-harden
需要 root
SHA256:d02da250297e5b480fb735a7aa7d6dd2dbb21d9eb789c5191ac4de4201512d66 · 与 curl 拿到的脚本一致,也可访问 /ssh.sh.sha256
阅读教程:SSH 安全加固教程:改端口与密钥登录

所有脚本都通过 bash -n 与 ShellCheck 检查,并在 Debian 12/11、Ubuntu 24.04/22.04/20.04/18.04、Rocky 9、AlmaLinux 9、CentOS 7 的官方 Docker 镜像中以 root 运行 curl | bash 测试(结果见各页“容器测试结果”)。容器里没有 systemd、内核模块和真实公网线路,相关部分需要在真实 VPS 上确认。

它会做什么

修改前会备份 /etc/ssh/sshd_config,并用 sshd -t 校验,校验失败自动恢复。新端口默认与旧端口同时监听,当前会话和旧端口都不受影响;在新终端确认能登录后,再加 --close-old 关闭旧端口。只有目标用户的 authorized_keys 里至少有一个公钥(或本次用 --key / --github 添加)时才允许 --disable-password,否则直接拒绝,避免把自己锁在外面。若 UFW 或 firewalld 已启用,会先放行新端口;SELinux 强制模式下会给端口打 ssh_port_t 标签;Ubuntu 22.10+ 的 ssh.socket 也会正确处理。配置以带标记的块写在 sshd_config 顶部(sshd 采用最先读到的值,因此会覆盖 50-cloud-init.conf 之类的设置),sshd 只做 reload,现有连接不会断开。--rollback 一键撤销。云服务商的安全组需要你自己放行新端口。

支持的系统

  • Debian
  • Ubuntu
  • CentOS
  • Rocky
  • AlmaLinux

需要 root

参数与示例

$ curl -fsSL xyz.sh/ssh | bash -s -- --status
$ curl -fsSL xyz.sh/ssh | bash -s -- --port 2222 -y
$ curl -fsSL xyz.sh/ssh | bash -s -- --github 你的用户名 --disable-password -y
$ curl -fsSL xyz.sh/ssh | bash -s -- --key "ssh-ed25519 AAAA..." --disable-password -y
$ curl -fsSL xyz.sh/ssh | bash -s -- --port 2222 --close-old -y
$ curl -fsSL xyz.sh/ssh | bash -s -- --rollback -y

容器测试结果

在各发行版官方 Docker 镜像中以 root、无终端方式运行 curl | bash 的结果。partial 表示容器内无法完整验证(例如没有 systemd、内核模块或测速协议被测试环境的代理拦截),需要在真实 VPS 上确认。 (2026-11-11)

OS结果
debian:12pass
debian:11pass
ubuntu:24.04pass
ubuntu:22.04pass
ubuntu:20.04pass
ubuntu:18.04pass
rockylinux:9pass
almalinux:9pass
centos:7pass

常见问题

这个脚本安全吗?
xyz.sh 不埋点、不偷偷执行未说明的操作,完整源码可在页面上查看,也可以用 curl -fsSL xyz.sh/<name>.sh 下载后先读再运行;每个脚本都有 /<name>.sh.sha256 校验值。把远程脚本管道给 bash 仍有风险,请先阅读源码。脚本有多重防锁死保护:sshd -t 校验失败自动恢复备份;新端口默认与旧端口并存;没有公钥时拒绝 --disable-password;只 reload 不 restart。云服务商安全组仍需你自己放行新端口。
支持哪些系统?
声明支持 Debian、Ubuntu、CentOS、Rocky、AlmaLinux。脚本会检测发行版,无法识别时会提示。已在这些系统的 Docker 容器里做过语法、--dry-run 与可运行部分的测试(结果见 GitHub 上的 tests/RESULTS.md);依赖 systemd、内核模块或公网的部分需要在真实 VPS 上验证。
需要 root 吗?
需要。请用 root 或 sudo bash 运行;非 root 时脚本会退出并提示。--dry-run 和 --status 可以先看会做什么。
改完端口连不上怎么办?
旧端口默认仍然开放,用旧端口登录后检查云服务商安全组/防火墙是否放行了新端口,或运行 --rollback 撤销。如果已经关闭了旧端口,请通过服务商的 VNC/控制台登录执行 --rollback。

完整源码

以下就是 curl 实际拿到的内容(公共库 + 脚本正文)。运行前请先读一遍。

xyz.sh/ssh.sh
#!/usr/bin/env bash
# Served by xyz.sh/ssh
# Human-readable page: https://xyz.sh/ssh
# This file is the common library followed by the script body.
# No telemetry is added by xyz.sh.
# =============================================================================
# xyz.sh common library
# Shared helpers for every script served by xyz.sh.
#
# Promises:
#   - No telemetry, analytics, or background "phone-home" calls
#   - No hidden installs or config changes beyond what the script announces
#   - Official upstream URLs are printed before any remote code is executed
#   - Destructive or system-changing steps ask for confirmation (or YES=1 / -y)
# =============================================================================

if [[ -n "${XYZ_COMMON_LOADED:-}" ]]; then
  # When sourced a second time, return. When executed as a file, return fails.
  # shellcheck disable=SC2317
  return 0 2>/dev/null || true
fi
# shellcheck disable=SC2034
XYZ_COMMON_LOADED=1

set -euo pipefail

XYZ_VERSION="1.3.0"
XYZ_BASE="${XYZ_BASE:-https://xyz.sh}"
XYZ_NAME="${XYZ_NAME:-unknown}"

# --- language ---------------------------------------------------------------
# Override with XYZ_LANG=zh or XYZ_LANG=en.
if [[ -z "${XYZ_LANG:-}" ]]; then
  case "${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}" in
    zh*|ZH*|cmn*|CMN*) XYZ_LANG="zh" ;;
    *) XYZ_LANG="en" ;;
  esac
fi

xyz_t() {
  # xyz_t "English text" "中文文本"
  if [[ "${XYZ_LANG}" == "zh" ]]; then
    printf '%s' "${2:-$1}"
  else
    printf '%s' "$1"
  fi
}

# --- colors (TTY only) ------------------------------------------------------
# XYZ_COLOR=always|never|auto (default auto: TTY and no NO_COLOR); --no-color.
_xyz_color=0
case "${XYZ_COLOR:-auto}" in
  always) _xyz_color=1 ;;
  never) _xyz_color=0 ;;
  *) [[ -t 1 && -z "${NO_COLOR:-}" ]] && _xyz_color=1 ;;
esac
[[ "${XYZ_NO_COLOR:-0}" == "1" ]] && _xyz_color=0
if [[ "${_xyz_color}" == "1" ]]; then
  XYZ_C_RESET=$'\033[0m'
  XYZ_C_BOLD=$'\033[1m'
  XYZ_C_DIM=$'\033[2m'
  XYZ_C_RED=$'\033[31m'
  XYZ_C_GREEN=$'\033[32m'
  XYZ_C_YELLOW=$'\033[33m'
  XYZ_C_CYAN=$'\033[36m'
else
  XYZ_C_RESET=""
  XYZ_C_BOLD=""
  XYZ_C_DIM=""
  XYZ_C_RED=""
  XYZ_C_GREEN=""
  XYZ_C_YELLOW=""
  XYZ_C_CYAN=""
fi

xyz_info()  { printf '%s[INFO]%s %s\n'  "${XYZ_C_GREEN}"  "${XYZ_C_RESET}" "$*"; }
xyz_warn()  { printf '%s[WARN]%s %s\n'  "${XYZ_C_YELLOW}" "${XYZ_C_RESET}" "$*" >&2; }
xyz_error() { printf '%s[ERROR]%s %s\n' "${XYZ_C_RED}"    "${XYZ_C_RESET}" "$*" >&2; }
xyz_step()  { printf '%s==>%s %s\n'     "${XYZ_C_CYAN}"   "${XYZ_C_RESET}" "$*"; }

# --- args / init ------------------------------------------------------------
XYZ_ARGS=()
XYZ_YES=0
XYZ_DRY_RUN=0

xyz_parse_global_args() {
  local filtered=()
  local arg
  for arg in "$@"; do
    case "${arg}" in
      -y|--yes)
        XYZ_YES=1
        ;;
      --lang=zh|zh)
        XYZ_LANG="zh"
        ;;
      --lang=en|en)
        XYZ_LANG="en"
        ;;
      --dry-run)
        XYZ_DRY_RUN=1
        ;;
      --no-color)
        XYZ_NO_COLOR=1
        XYZ_C_RESET=""; XYZ_C_BOLD=""; XYZ_C_DIM=""; XYZ_C_RED=""
        XYZ_C_GREEN=""; XYZ_C_YELLOW=""; XYZ_C_CYAN=""
        ;;
      --help|-h)
        XYZ_HELP=1
        filtered+=("${arg}")
        ;;
      *)
        filtered+=("${arg}")
        ;;
    esac
  done
  XYZ_ARGS=("${filtered[@]+"${filtered[@]}"}")
  if [[ "${YES:-}" =~ ^(1|yes|true|YES)$ || "${XYZ_ASSUME_YES:-}" == "1" ]]; then
    XYZ_YES=1
  fi
  # Exported so individual scripts can read flags set here.
  export XYZ_ARGS XYZ_YES XYZ_LANG XYZ_HELP="${XYZ_HELP:-}" XYZ_DRY_RUN="${XYZ_DRY_RUN:-0}"
  export XYZ_NO_COLOR="${XYZ_NO_COLOR:-0}"
  if declare -F xyz_ui_init >/dev/null; then xyz_ui_init; fi
}

xyz_banner() {
  local title="${XYZ_TITLE:-$XYZ_NAME}"
  printf '\n%s%sxyz.sh/%s%s  v%s\n' "${XYZ_C_BOLD}" "${XYZ_C_CYAN}" "${XYZ_NAME}" "${XYZ_C_RESET}" "${XYZ_VERSION}"
  printf '%s\n' "${XYZ_C_DIM}----------------------------------------${XYZ_C_RESET}"
  printf '  %s: %s/%s\n' "$(xyz_t "Page" "页面")" "${XYZ_BASE}" "${XYZ_NAME}"
  printf '  %s: %s/%s.sh\n' "$(xyz_t "Raw" "源码")" "${XYZ_BASE}" "${XYZ_NAME}"
  if [[ -n "${XYZ_UPSTREAM:-}" ]]; then
    printf '  %s: %s\n' "$(xyz_t "Upstream" "上游来源")" "${XYZ_UPSTREAM}"
  fi
  printf '  %s\n' "$(xyz_t "No telemetry, no hidden actions." "无遥测、无隐藏操作。")"
  printf '%s\n\n' "${XYZ_C_DIM}----------------------------------------${XYZ_C_RESET}"
  if [[ -n "${title}" && "${title}" != "${XYZ_NAME}" ]]; then
    xyz_step "${title}"
  fi
}

xyz_init() {
  xyz_parse_global_args "$@"
  xyz_detect_os
  xyz_banner
}

# --- root / OS --------------------------------------------------------------
xyz_is_root() {
  [[ "${EUID:-$(id -u)}" -eq 0 ]]
}

xyz_require_root() {
  if ! xyz_is_root; then
    xyz_error "$(xyz_t \
      "This script must be run as root. Try: sudo bash" \
      "请使用 root 运行。可尝试:sudo bash")"
    exit 1
  fi
}

xyz_prefer_root() {
  if ! xyz_is_root; then
    xyz_warn "$(xyz_t \
      "Not running as root; some information or tests may be limited." \
      "当前不是 root,部分信息或测试可能不完整。")"
  fi
}

XYZ_OS_ID=""
XYZ_OS_LIKE=""
XYZ_OS_VERSION=""
XYZ_OS_FAMILY=""   # debian | rhel | unknown
XYZ_OS_PRETTY=""

xyz_detect_os() {
  XYZ_OS_ID=""
  XYZ_OS_LIKE=""
  XYZ_OS_VERSION=""
  XYZ_OS_FAMILY="unknown"
  XYZ_OS_PRETTY="unknown"
  if [[ -r /etc/os-release ]]; then
    # shellcheck disable=SC1091
    . /etc/os-release
    XYZ_OS_ID="${ID:-}"
    XYZ_OS_LIKE="${ID_LIKE:-}"
    XYZ_OS_VERSION="${VERSION_ID:-}"
    XYZ_OS_PRETTY="${PRETTY_NAME:-$XYZ_OS_ID}"
    export XYZ_OS_ID XYZ_OS_LIKE XYZ_OS_VERSION XYZ_OS_FAMILY XYZ_OS_PRETTY
  fi
  case "${XYZ_OS_ID}" in
    debian|ubuntu)
      XYZ_OS_FAMILY="debian"
      ;;
    centos|rhel|rocky|almalinux|alma|fedora)
      XYZ_OS_FAMILY="rhel"
      ;;
    *)
      case " ${XYZ_OS_ID} ${XYZ_OS_LIKE} " in
        *debian*|*ubuntu*) XYZ_OS_FAMILY="debian" ;;
        *rhel*|*centos*|*fedora*|*rocky*|*alma*) XYZ_OS_FAMILY="rhel" ;;
        *) : ;;
      esac
      ;;
  esac
}

xyz_os_supported() {
  case "${XYZ_OS_ID}" in
    debian|ubuntu|centos|rocky|almalinux) return 0 ;;
    *) : ;;
  esac
  case " ${XYZ_OS_LIKE} " in
    *debian*|*ubuntu*|*rhel*|*centos*|*rocky*|*alma*) return 0 ;;
    *) : ;;
  esac
  return 1
}

xyz_require_supported_os() {
  xyz_detect_os
  if ! xyz_os_supported; then
    xyz_warn "$(xyz_t \
      "Unrecognized distribution: ${XYZ_OS_ID:-unknown} (${XYZ_OS_PRETTY}). Officially supported: Debian, Ubuntu, CentOS, Rocky, AlmaLinux." \
      "未能识别的发行版:${XYZ_OS_ID:-unknown}(${XYZ_OS_PRETTY})。官方支持:Debian、Ubuntu、CentOS、Rocky、AlmaLinux。")"
    if ! xyz_confirm "$(xyz_t "Continue anyway?" "仍然继续?")"; then
      exit 1
    fi
  else
    xyz_info "$(xyz_t "Detected OS:" "检测到系统:") ${XYZ_OS_PRETTY} (${XYZ_OS_FAMILY})"
  fi
}

# --- packages / commands ----------------------------------------------------
xyz_need_cmd() {
  command -v "$1" >/dev/null 2>&1
}

xyz_pkg_install() {
  xyz_detect_os
  if (( $# == 0 )); then
    return 0
  fi
  case "${XYZ_OS_FAMILY}" in
    debian)
      export DEBIAN_FRONTEND=noninteractive
      apt-get update -y || xyz_warn "$(xyz_t "apt-get update reported errors (a repository may be unreachable); trying to install anyway." "apt-get update 报错(可能有软件源无法访问),仍尝试安装。")"
      apt-get install -y --no-install-recommends "$@"
      ;;
    rhel)
      if xyz_need_cmd dnf; then
        dnf install -y "$@"
      elif xyz_need_cmd yum; then
        yum install -y "$@"
      else
        xyz_error "$(xyz_t "Neither dnf nor yum is available." "找不到 dnf 或 yum。")"
        exit 1
      fi
      ;;
    *)
      xyz_error "$(xyz_t "Cannot install packages on this OS automatically." "无法在此系统上自动安装软件包。")"
      exit 1
      ;;
  esac
}

xyz_ensure_cmds() {
  local pkg
  local missing=()
  for pkg in "$@"; do
    if ! xyz_need_cmd "${pkg}"; then
      missing+=("${pkg}")
    fi
  done
  if ((${#missing[@]} > 0)); then
    xyz_info "$(xyz_t "Installing missing commands:" "正在安装缺失命令:") ${missing[*]}"
    xyz_pkg_install "${missing[@]}"
  fi
}

# --- confirmation (safe with curl | bash) -----------------------------------
# A readable /dev/tty node is not enough: in containers or curl|bash without a
# controlling terminal, open() fails with ENXIO ("No such device or address").
# [[ -r /dev/tty ]] is true in that case and must not be used as the check.
xyz_open_tty() {
  # Opens /dev/tty into XYZ_TTY_FD if it is a real terminal. Caller must close.
  local fd
  XYZ_TTY_FD=""
  # Brace group so a failed open cannot leak "No such device or address".
  if { exec {fd}<>/dev/tty; } 2>/dev/null && [[ -t "${fd}" ]]; then
    XYZ_TTY_FD="${fd}"
    return 0
  fi
  if [[ -n "${fd:-}" ]]; then
    exec {fd}>&- 2>/dev/null || true
  fi
  return 1
}

xyz_close_tty() {
  if [[ -n "${XYZ_TTY_FD:-}" ]]; then
    exec {XYZ_TTY_FD}>&- 2>/dev/null || true
    XYZ_TTY_FD=""
  fi
}

xyz_confirm() {
  local prompt="$1"
  if [[ "${XYZ_YES}" == "1" ]]; then
    xyz_info "$(xyz_t "Auto-confirmed (-y / YES=1):" "已自动确认(-y / YES=1):") ${prompt}"
    return 0
  fi
  local reply=""
  if xyz_open_tty; then
    printf '%s [y/N] ' "${prompt}" >&"${XYZ_TTY_FD}"
    # shellcheck disable=SC2162
    read -r reply <&"${XYZ_TTY_FD}" || true
    xyz_close_tty
  else
    xyz_warn "$(xyz_t \
      "No TTY available for confirmation. Re-run with -y or YES=1 if you accept." \
      "没有可用的终端来确认。若你已了解并同意,请加 -y 或 YES=1 重试。")"
    return 1
  fi
  case "${reply}" in
    y|Y|yes|YES|Yes) return 0 ;;
    *) return 1 ;;
  esac
}

# --- download helpers -------------------------------------------------------
# Create the temp dir in the main shell. xyz_tmpdir is usually called inside
# $(...), a subshell, so creating it (and its EXIT trap) there would delete the
# directory as soon as the subshell returned.
xyz_init_tmpdir() {
  if [[ -z "${XYZ_TMPDIR:-}" || ! -d "${XYZ_TMPDIR}" ]]; then
    XYZ_TMPDIR="$(mktemp -d "${TMPDIR:-/tmp}/xyzsh.XXXXXX")"
    # shellcheck disable=SC2064
    trap "rm -rf '${XYZ_TMPDIR}'" EXIT
  fi
}

xyz_tmpdir() {
  if [[ -z "${XYZ_TMPDIR:-}" || ! -d "${XYZ_TMPDIR}" ]]; then
    xyz_error "internal: temp dir not initialised"
    exit 1
  fi
  printf '%s' "${XYZ_TMPDIR}"
}

xyz_fetch() {
  # xyz_fetch URL DEST
  local url="$1"
  local dest="$2"
  xyz_info "$(xyz_t "Downloading" "正在下载"): ${url}"
  xyz_info "$(xyz_t "Saving to" "保存到"): ${dest}"
  if xyz_need_cmd curl; then
    curl -fsSL --connect-timeout 20 --max-time 180 --retry 2 --retry-delay 1 \
      -o "${dest}" "${url}"
  elif xyz_need_cmd wget; then
    wget -q --timeout=60 -O "${dest}" "${url}"
  else
    xyz_error "$(xyz_t "Need curl or wget to download files." "需要 curl 或 wget 才能下载文件。")"
    exit 1
  fi
  if [[ ! -s "${dest}" ]]; then
    xyz_error "$(xyz_t "Download failed or file is empty:" "下载失败或文件为空:") ${url}"
    exit 1
  fi
  if xyz_need_cmd sha256sum; then
    xyz_info "SHA256: $(sha256sum "${dest}" | awk '{print $1}')"
  elif xyz_need_cmd shasum; then
    xyz_info "SHA256: $(shasum -a 256 "${dest}" | awk '{print $1}')"
  fi
}

xyz_run_upstream() {
  # xyz_run_upstream URL [args...]
  # Downloads an official installer, shows the URL and a short preview, then
  # runs it only after confirmation (unless -y / YES=1).
  local url="$1"
  shift
  local dest
  xyz_init_tmpdir
  dest="$(xyz_tmpdir)/upstream.sh"
  xyz_fetch "${url}" "${dest}"
  chmod +x "${dest}"
  xyz_info "$(xyz_t "Preview (first 20 lines):" "预览(前 20 行):")"
  sed -n '1,20p' "${dest}" || true
  printf '%s\n' "${XYZ_C_DIM}...${XYZ_C_RESET}"
  # Default --dry-run: verify the download and preview, do not execute.
  # Scripts that forward --dry-run to upstream (e.g. docker) set
  # XYZ_FORWARD_DRY_RUN=1 so the official script still runs with that flag.
  if [[ "${XYZ_DRY_RUN:-0}" == "1" && "${XYZ_FORWARD_DRY_RUN:-0}" != "1" ]]; then
    xyz_info "$(xyz_t \
      "Dry-run: official script downloaded and previewed; not executing it." \
      "演练模式:已下载并预览官方脚本,不会执行。")"
    return 0
  fi
  if ! xyz_confirm "$(xyz_t \
    "Run this official upstream script?" \
    "运行这份官方上游脚本?")"; then
    xyz_error "$(xyz_t "Aborted by user." "已取消。")"
    exit 1
  fi
  xyz_step "$(xyz_t "Executing upstream script" "正在执行上游脚本")"
  # Never let the upstream script inherit our stdin: under `curl | bash` that
  # is the rest of this very script. Give it the terminal if there is one,
  # otherwise /dev/null. Interactive installers set XYZ_UPSTREAM_NEEDS_TTY=1.
  if xyz_open_tty; then
    local tty_fd="${XYZ_TTY_FD}"
    XYZ_TTY_FD=""
    bash "${dest}" "$@" <&"${tty_fd}"
    local rc=$?
    exec {tty_fd}>&- 2>/dev/null || true
    return "${rc}"
  fi
  if [[ "${XYZ_UPSTREAM_NEEDS_TTY:-0}" == "1" ]]; then
    xyz_error "$(xyz_t \
      "This official installer is interactive and needs a terminal. Log in with SSH and run: bash <(curl -fsSL ${XYZ_BASE}/${XYZ_NAME})" \
      "这个官方安装脚本需要交互,必须在终端里运行。请用 SSH 登录后执行:bash <(curl -fsSL ${XYZ_BASE}/${XYZ_NAME})")"
    exit 1
  fi
  bash "${dest}" "$@" </dev/null
}

# --- small shared helpers ----------------------------------------------------
xyz_strip_ansi() {
  sed -e 's/\x1b\[[0-9;?]*[A-Za-z]//g' -e 's/\r//g'
}

# Effective SSH port: sshd -T (what sshd really uses) > config files > env > 22.
xyz_effective_ssh_port() {
  local port=""
  if xyz_need_cmd sshd; then
    port="$(sshd -T 2>/dev/null | awk '$1=="port" {print $2; exit}' || true)"
  fi
  if [[ -z "${port}" && -d /etc/ssh/sshd_config.d ]]; then
    port="$(cat /etc/ssh/sshd_config.d/*.conf 2>/dev/null | awk 'tolower($1)=="port" {print $2; exit}' || true)"
  fi
  if [[ -z "${port}" && -f /etc/ssh/sshd_config ]]; then
    port="$(awk 'tolower($1)=="port" {print $2; exit}' /etc/ssh/sshd_config || true)"
  fi
  if [[ -z "${port}" && -n "${SSH_CONNECTION:-}" ]]; then
    port="$(printf '%s' "${SSH_CONNECTION}" | awk '{print $4}')"
  fi
  printf '%s' "${port:-22}"
}

xyz_has_systemd() {
  [[ -d /run/systemd/system ]] && xyz_need_cmd systemctl
}

# Back up a file once per run as FILE.xyz-bak-YYYYmmddHHMMSS and print the path.
xyz_backup_file() {
  local f="$1"
  local bak
  bak="${f}.xyz-bak-$(date +%Y%m%d%H%M%S)"
  if [[ -e "${f}" ]]; then
    cp -a "${f}" "${bak}"
    printf '%s' "${bak}"
  fi
}

# --- misc -------------------------------------------------------------------
xyz_usage_exit() {
  if [[ "${1:-}" != "" ]]; then
    printf '%s\n' "$1"
  fi
  exit 0
}

XYZ_NAME="ssh"

# xyz.sh/ssh — SSH hardening: change port, add keys, disable passwords.
#
# Safety rules this script follows:
#   - Backs up /etc/ssh/sshd_config before any change and validates the new
#     config with `sshd -t`; a failed validation restores the backup.
#   - A new --port is ADDED next to the current port by default, so this
#     session and the old port keep working. Close the old port in a second
#     run with --close-old after you have logged in on the new one.
#   - --disable-password is refused unless the target user has at least one
#     public key in authorized_keys (or you pass --key / --github).
#   - sshd is reloaded (not restarted); existing sessions stay connected.
#   - If UFW or firewalld is active, the new port is allowed first; on
#     SELinux-enforcing systems the port is labelled ssh_port_t.
#
# The settings are written as one marked block at the top of
# /etc/ssh/sshd_config (sshd uses the first value it reads, so this wins
# over later lines and sshd_config.d drop-ins such as 50-cloud-init.conf).
#
# Usage:
#   curl -fsSL xyz.sh/ssh | bash -s -- --status
#   curl -fsSL xyz.sh/ssh | bash -s -- --port 2222 -y
#   curl -fsSL xyz.sh/ssh | bash -s -- --github yourname --disable-password -y
#   curl -fsSL xyz.sh/ssh | bash -s -- --key "ssh-ed25519 AAAA... me@pc" --disable-password -y
#   curl -fsSL xyz.sh/ssh | bash -s -- --port 2222 --close-old -y
#   curl -fsSL xyz.sh/ssh | bash -s -- --rollback -y
#   curl -fsSL xyz.sh/ssh | bash -s -- --port 2222 --dry-run
#
# Supported: Debian, Ubuntu, CentOS, Rocky, AlmaLinux. Root required.
# No telemetry is added by this wrapper.

set -euo pipefail

if [[ -z "${XYZ_COMMON_LOADED:-}" ]]; then
  _xyz_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  if [[ -f "${_xyz_dir}/lib/common.sh" ]]; then
    # shellcheck source=lib/common.sh
    source "${_xyz_dir}/lib/common.sh"
  else
    echo "xyz.sh: missing lib/common.sh" >&2
    exit 1
  fi
fi

XYZ_NAME="${XYZ_NAME:-ssh}"
case "${XYZ_NAME}" in ssh|sshd|ssh-harden) : ;; *) XYZ_NAME="ssh" ;; esac
XYZ_TITLE="$(xyz_t "SSH hardening" "SSH 安全加固")"
SSHD_CONFIG="${XYZ_SSHD_CONFIG:-/etc/ssh/sshd_config}"
BEGIN_MARK="# BEGIN xyz.sh/ssh"
END_MARK="# END xyz.sh/ssh"
xyz_init "$@"

NEW_PORT=""; CLOSE_OLD=0; DISABLE_PW=0; ROOT_LOGIN=""; TARGET_USER="root"
KEYS=(); KEY_URLS=(); DO_STATUS=0; DO_ROLLBACK=0; NO_RELOAD=0
i=0
while [[ $i -lt ${#XYZ_ARGS[@]} ]]; do
  arg="${XYZ_ARGS[$i]}"
  case "${arg}" in
    --port) i=$((i + 1)); NEW_PORT="${XYZ_ARGS[$i]:-}" ;;
    --port=*) NEW_PORT="${arg#--port=}" ;;
    --close-old) CLOSE_OLD=1 ;;
    --disable-password|--no-password) DISABLE_PW=1 ;;
    --root-login) i=$((i + 1)); ROOT_LOGIN="${XYZ_ARGS[$i]:-}" ;;
    --user) i=$((i + 1)); TARGET_USER="${XYZ_ARGS[$i]:-}" ;;
    --key) i=$((i + 1)); KEYS+=("${XYZ_ARGS[$i]:-}") ;;
    --key-url) i=$((i + 1)); KEY_URLS+=("${XYZ_ARGS[$i]:-}") ;;
    --github) i=$((i + 1)); KEY_URLS+=("https://github.com/${XYZ_ARGS[$i]:-}.keys") ;;
    --status) DO_STATUS=1 ;;
    --rollback|--restore) DO_ROLLBACK=1 ;;
    --no-reload) NO_RELOAD=1 ;;
    --help|-h) XYZ_HELP=1 ;;
    *)
      xyz_error "$(xyz_t "Unknown argument:" "未知参数:") ${arg}"
      exit 1
      ;;
  esac
  i=$((i + 1))
done

if [[ "${XYZ_HELP:-}" == "1" ]]; then
  cat <<HINT
$(xyz_t "Usage:" "用法:")
  curl -fsSL xyz.sh/ssh | bash -s -- --status
  curl -fsSL xyz.sh/ssh | bash -s -- --port 2222 -y
  curl -fsSL xyz.sh/ssh | bash -s -- --github yourname --disable-password -y
  curl -fsSL xyz.sh/ssh | bash -s -- --key "ssh-ed25519 AAAA..." --disable-password -y
  curl -fsSL xyz.sh/ssh | bash -s -- --port 2222 --close-old -y
  curl -fsSL xyz.sh/ssh | bash -s -- --root-login prohibit-password -y
  curl -fsSL xyz.sh/ssh | bash -s -- --rollback -y

$(xyz_t "A new port is added next to the old one; run again with --close-old after testing." "新端口会与旧端口同时监听;测试新端口能登录后,再加 --close-old 关闭旧端口。")
$(xyz_t "Keep this session open and test from a NEW terminal before logging out." "请保持当前会话不要断开,先在新终端里测试登录。")
HINT
  exit 0
fi

if [[ -n "${NEW_PORT}" ]] && { ! [[ "${NEW_PORT}" =~ ^[0-9]+$ ]] || (( NEW_PORT < 1 || NEW_PORT > 65535 )); }; then
  xyz_error "$(xyz_t "--port must be 1-65535." "--port 必须在 1-65535 之间。")"
  exit 1
fi
case "${ROOT_LOGIN}" in ""|yes|no|prohibit-password) : ;; *)
  xyz_error "--root-login yes|no|prohibit-password"; exit 1 ;;
esac
[[ "${TARGET_USER}" =~ ^[a-z_][a-z0-9_.-]*$ ]] || { xyz_error "--user: invalid name"; exit 1; }

xyz_require_root
xyz_require_supported_os
if ! xyz_need_cmd sshd && [[ ! -x /usr/sbin/sshd ]]; then
  xyz_error "$(xyz_t "OpenSSH server (sshd) is not installed." "未安装 OpenSSH 服务端(sshd)。")"
  exit 1
fi
export PATH="${PATH}:/usr/sbin:/sbin"

ensure_host_keys() {
  if ! ls /etc/ssh/ssh_host_*_key >/dev/null 2>&1; then
    xyz_info "$(xyz_t "No SSH host keys found; generating them (ssh-keygen -A)." "未找到 SSH 主机密钥,正在生成(ssh-keygen -A)。")"
    ssh-keygen -A >/dev/null
  fi
  mkdir -p /run/sshd 2>/dev/null || true
}

effective() {
  # effective KEY -> values from sshd -T (lowercase keys)
  sshd -T 2>/dev/null | awk -v k="$1" '$1==k {print $2}' | tr '\n' ' ' | sed 's/ $//'
}

user_home() {
  getent passwd "$1" 2>/dev/null | cut -d: -f6
}

auth_keys_file() {
  local home
  home="$(user_home "${TARGET_USER}")"
  [[ -z "${home}" ]] && return 1
  printf '%s/.ssh/authorized_keys' "${home}"
}

count_keys() {
  local f="$1"
  [[ -f "${f}" ]] || { printf '0'; return 0; }
  grep -c -E '^(sk-)?(ssh-(ed25519|rsa|dss)|ecdsa-sha2-[a-z0-9]+)(@openssh\.com)? [A-Za-z0-9+/=]+' "${f}" 2>/dev/null || printf '0'
}

show_status() {
  ensure_host_keys
  xyz_step "$(xyz_t "Effective sshd settings (sshd -T)" "sshd 当前生效配置(sshd -T)")"
  printf '  %-26s %s\n' "port" "$(effective port)"
  printf '  %-26s %s\n' "permitrootlogin" "$(effective permitrootlogin)"
  printf '  %-26s %s\n' "passwordauthentication" "$(effective passwordauthentication)"
  printf '  %-26s %s\n' "kbdinteractiveauthentication" "$(effective kbdinteractiveauthentication)$(effective challengeresponseauthentication)"
  printf '  %-26s %s\n' "pubkeyauthentication" "$(effective pubkeyauthentication)"
  local akf
  akf="$(auth_keys_file || true)"
  printf '  %-26s %s (%s)\n' "authorized_keys" "$(count_keys "${akf:-/nonexistent}")" "${akf:-n/a}"
  if grep -q "^${BEGIN_MARK}" "${SSHD_CONFIG}" 2>/dev/null; then
    xyz_info "$(xyz_t "xyz.sh block present in" "已存在 xyz.sh 配置块:") ${SSHD_CONFIG}"
    sed -n "\\|^${BEGIN_MARK}|,\\|^${END_MARK}|p" "${SSHD_CONFIG}" | sed 's/^/  /'
  fi
}

reload_sshd() {
  if [[ "${NO_RELOAD}" == "1" ]]; then
    xyz_info "$(xyz_t "--no-reload: config written and validated; sshd not reloaded." "--no-reload:配置已写入并校验,未重载 sshd。")"
    return 0
  fi
  if xyz_has_systemd; then
    if systemctl is-active --quiet ssh.socket 2>/dev/null; then
      # Ubuntu 22.10+ socket activation reads Port via a generator.
      systemctl daemon-reload
      systemctl restart ssh.socket
      xyz_info "$(xyz_t "ssh.socket restarted (existing sessions are kept)." "已重启 ssh.socket(现有会话不受影响)。")"
      return 0
    fi
    local unit
    for unit in ssh sshd; do
      if systemctl is-active --quiet "${unit}" 2>/dev/null; then
        systemctl reload "${unit}"
        xyz_info "$(xyz_t "Reloaded" "已重载") ${unit} $(xyz_t "(existing sessions are kept)." "(现有会话不受影响)。")"
        return 0
      fi
    done
  fi
  if [[ -s /run/sshd.pid ]] && kill -0 "$(cat /run/sshd.pid)" 2>/dev/null; then
    kill -HUP "$(cat /run/sshd.pid)"
    xyz_info "$(xyz_t "Sent SIGHUP to sshd." "已向 sshd 发送 SIGHUP。")"
    return 0
  fi
  xyz_warn "$(xyz_t "sshd does not seem to be running; config will apply when it starts." "sshd 似乎没有运行;配置会在其启动时生效。")"
}

if [[ "${DO_STATUS}" == "1" ]]; then
  show_status
  exit 0
fi

ensure_host_keys

if [[ "${DO_ROLLBACK}" == "1" ]]; then
  if ! grep -q "^${BEGIN_MARK}" "${SSHD_CONFIG}"; then
    xyz_info "$(xyz_t "No xyz.sh block in ${SSHD_CONFIG}; nothing to roll back." "${SSHD_CONFIG} 中没有 xyz.sh 配置块,无需回滚。")"
    exit 0
  fi
  if [[ "${XYZ_DRY_RUN:-0}" == "1" ]]; then
    xyz_info "$(xyz_t "Dry-run: would remove this block:" "演练模式:将删除以下配置块:")"
    sed -n "\\|^${BEGIN_MARK}|,\\|^${END_MARK}|p" "${SSHD_CONFIG}" | sed 's/^/  /'
    exit 0
  fi
  xyz_confirm "$(xyz_t "Remove the xyz.sh block from ${SSHD_CONFIG}?" "从 ${SSHD_CONFIG} 删除 xyz.sh 配置块?")" || exit 1
  bak="$(xyz_backup_file "${SSHD_CONFIG}")"
  sed -i "\\|^${BEGIN_MARK}|,\\|^${END_MARK}|d" "${SSHD_CONFIG}"
  if ! sshd -t; then
    cp -a "${bak}" "${SSHD_CONFIG}"
    xyz_error "$(xyz_t "sshd -t failed after rollback; restored" "回滚后 sshd -t 校验失败,已恢复") ${bak}"
    exit 1
  fi
  reload_sshd
  xyz_info "$(xyz_t "Rolled back. Backup:" "已回滚。备份:") ${bak}"
  xyz_info "$(xyz_t "Note: --close-old edits are commented with '# xyz.sh:' and kept in the backups (*.xyz-bak-*)." "注意:--close-old 注释掉的行带有 '# xyz.sh:' 前缀,原文件保存在 *.xyz-bak-* 备份中。")"
  exit 0
fi

if [[ -z "${NEW_PORT}" && "${DISABLE_PW}" == "0" && -z "${ROOT_LOGIN}" && ${#KEYS[@]} -eq 0 && ${#KEY_URLS[@]} -eq 0 ]]; then
  show_status
  echo
  xyz_info "$(xyz_t "Nothing to change. See --help for --port, --key, --github, --disable-password." "未指定要修改的内容。参见 --help:--port、--key、--github、--disable-password。")"
  exit 0
fi

# ---- collect keys -------------------------------------------------------------
AKF="$(auth_keys_file)" || { xyz_error "$(xyz_t "User not found:" "找不到用户:") ${TARGET_USER}"; exit 1; }
NEW_KEYS=()
for k in "${KEYS[@]+"${KEYS[@]}"}"; do
  NEW_KEYS+=("${k}")
done
for u in "${KEY_URLS[@]+"${KEY_URLS[@]}"}"; do
  xyz_info "$(xyz_t "Fetching public keys from" "正在获取公钥:") ${u}"
  body="$(curl -fsSL --max-time 20 "${u}" || true)"
  while IFS= read -r line; do
    [[ -n "${line}" ]] && NEW_KEYS+=("${line}")
  done <<< "${body}"
done
VALID_NEW=()
for k in "${NEW_KEYS[@]+"${NEW_KEYS[@]}"}"; do
  if printf '%s\n' "${k}" | grep -q -E '^(sk-)?(ssh-(ed25519|rsa|dss)|ecdsa-sha2-[a-z0-9]+)(@openssh\.com)? [A-Za-z0-9+/=]{20,}'; then
    VALID_NEW+=("${k}")
  else
    xyz_warn "$(xyz_t "Ignoring something that is not an SSH public key:" "忽略不是 SSH 公钥的内容:") ${k:0:40}"
  fi
done
if [[ ${#NEW_KEYS[@]} -gt 0 && ${#VALID_NEW[@]} -eq 0 ]]; then
  xyz_error "$(xyz_t "No valid public key was provided." "没有提供有效的公钥。")"
  exit 1
fi
EXISTING_KEYS="$(count_keys "${AKF}")"
TOTAL_KEYS=$(( EXISTING_KEYS + ${#VALID_NEW[@]} ))
if [[ "${DISABLE_PW}" == "1" && "${TOTAL_KEYS}" -eq 0 ]]; then
  xyz_error "$(xyz_t \
    "Refusing --disable-password: ${TARGET_USER} has no key in ${AKF}. Add one with --key or --github first, or you would be locked out." \
    "拒绝执行 --disable-password:${TARGET_USER} 在 ${AKF} 中没有任何公钥。请先用 --key 或 --github 添加公钥,否则会无法登录。")"
  exit 1
fi
if [[ "${DISABLE_PW}" == "1" && "${TARGET_USER}" == "root" && -z "${ROOT_LOGIN}" ]]; then
  ROOT_LOGIN="prohibit-password"
fi

# ---- plan --------------------------------------------------------------------------
CUR_PORTS="$(effective port)"
[[ -z "${CUR_PORTS}" ]] && CUR_PORTS="$(xyz_effective_ssh_port)"
PORTS=()
if [[ -n "${NEW_PORT}" ]]; then
  PORTS+=("${NEW_PORT}")
  if [[ "${CLOSE_OLD}" != "1" ]]; then
    for p in ${CUR_PORTS}; do
      [[ "${p}" != "${NEW_PORT}" ]] && PORTS+=("${p}")
    done
  fi
fi

BLOCK="${BEGIN_MARK} ${XYZ_VERSION} $(date -u +%Y-%m-%dT%H:%MZ) — remove with: curl -fsSL xyz.sh/ssh | bash -s -- --rollback"$'\n'
# Settings from an earlier xyz.sh block are kept unless overridden now.
OLD_BLOCK="$(sed -n "\\|^${BEGIN_MARK}|,\\|^${END_MARK}|p" "${SSHD_CONFIG}" 2>/dev/null | grep -v '^#' || true)"
old_val() { printf '%s\n' "${OLD_BLOCK}" | awk -v k="$1" 'tolower($1)==tolower(k) {print $2}'; }
if [[ ${#PORTS[@]} -eq 0 ]]; then
  while IFS= read -r p; do [[ -n "${p}" ]] && PORTS+=("${p}"); done < <(old_val Port)
fi
[[ -z "${ROOT_LOGIN}" ]] && ROOT_LOGIN="$(old_val PermitRootLogin | head -1)"
if [[ "${DISABLE_PW}" == "0" && "$(old_val PasswordAuthentication | head -1)" == "no" ]]; then
  DISABLE_PW=1
fi
for p in "${PORTS[@]+"${PORTS[@]}"}"; do BLOCK+="Port ${p}"$'\n'; done
[[ -n "${ROOT_LOGIN}" ]] && BLOCK+="PermitRootLogin ${ROOT_LOGIN}"$'\n'
if [[ "${DISABLE_PW}" == "1" ]]; then
  BLOCK+="PubkeyAuthentication yes"$'\n'
  BLOCK+="PasswordAuthentication no"$'\n'
  if sshd -T 2>/dev/null | grep -q '^kbdinteractiveauthentication'; then
    BLOCK+="KbdInteractiveAuthentication no"$'\n'
  else
    BLOCK+="ChallengeResponseAuthentication no"$'\n'
  fi
fi
BLOCK+="${END_MARK}"

xyz_step "$(xyz_t "Planned changes" "计划变更")"
printf '  %s: %s\n' "$(xyz_t "Current port(s)" "当前端口")" "${CUR_PORTS}"
[[ ${#VALID_NEW[@]} -gt 0 ]] && printf '  %s: %s -> %s\n' "$(xyz_t "Add keys" "添加公钥")" "${#VALID_NEW[@]}" "${AKF}"
printf '  %s %s:\n' "$(xyz_t "Block at the top of" "写入配置块到文件顶部:")" "${SSHD_CONFIG}"
printf '%s\n' "${BLOCK}" | sed 's/^/    /'
if [[ "${CLOSE_OLD}" == "1" && -n "${NEW_PORT}" ]]; then
  printf '  %s\n' "$(xyz_t "Other active 'Port' lines in sshd_config and sshd_config.d/*.conf will be commented out." "sshd_config 与 sshd_config.d/*.conf 中其它生效的 Port 行会被注释掉。")"
fi

FW=""
if [[ -n "${NEW_PORT}" ]]; then
  if xyz_need_cmd ufw && ufw status 2>/dev/null | grep -q '^Status: active'; then FW="ufw"
  elif xyz_need_cmd firewall-cmd && firewall-cmd --state >/dev/null 2>&1; then FW="firewalld"; fi
  [[ -n "${FW}" ]] && printf '  %s\n' "$(xyz_t "Firewall (${FW}) is active: port ${NEW_PORT}/tcp will be allowed first." "防火墙(${FW})已启用:会先放行 ${NEW_PORT}/tcp。")"
  printf '  %s\n' "$(xyz_t "Also allow ${NEW_PORT}/tcp in your cloud provider's security group if it has one." "如果云服务商有安全组,也请放行 ${NEW_PORT}/tcp。")"
  if xyz_need_cmd ss && ss -ltn 2>/dev/null | awk '{print $4}' | grep -q -E "[:.]${NEW_PORT}\$"; then
    if [[ " ${CUR_PORTS} " != *" ${NEW_PORT} "* ]]; then
      xyz_error "$(xyz_t "Port ${NEW_PORT} is already used by another program." "端口 ${NEW_PORT} 已被其它程序占用。")"
      exit 1
    fi
  fi
fi

if [[ "${XYZ_DRY_RUN:-0}" == "1" ]]; then
  xyz_info "$(xyz_t "Dry-run: nothing was changed." "演练模式:未做任何修改。")"
  exit 0
fi
xyz_warn "$(xyz_t "Keep THIS session open until you have logged in from a new terminal." "在新终端成功登录之前,请不要关闭当前会话。")"
xyz_confirm "$(xyz_t "Apply these SSH changes?" "应用以上 SSH 修改?")" || { xyz_error "$(xyz_t "Aborted by user." "已取消。")"; exit 1; }

# ---- apply ----------------------------------------------------------------------------
if [[ ${#VALID_NEW[@]} -gt 0 ]]; then
  home="$(user_home "${TARGET_USER}")"
  install -d -m 700 -o "${TARGET_USER}" "${home}/.ssh" 2>/dev/null || mkdir -p "${home}/.ssh"
  touch "${AKF}"
  added=0
  for k in "${VALID_NEW[@]}"; do
    blob="$(printf '%s' "${k}" | awk '{print $2}')"
    if ! grep -q -F "${blob}" "${AKF}"; then
      printf '%s\n' "${k}" >> "${AKF}"
      added=$((added + 1))
    fi
  done
  chmod 600 "${AKF}"
  chown "${TARGET_USER}:" "${AKF}" "${home}/.ssh" 2>/dev/null || true
  command -v restorecon >/dev/null 2>&1 && restorecon -R "${home}/.ssh" 2>/dev/null || true
  xyz_info "$(xyz_t "Added ${added} new key(s) to" "新增 ${added} 个公钥到") ${AKF}"
fi

if [[ -n "${NEW_PORT}" ]]; then
  case "${FW}" in
    ufw) ufw allow "${NEW_PORT}/tcp" ;;
    firewalld) firewall-cmd --permanent --add-port="${NEW_PORT}/tcp" && firewall-cmd --reload ;;
    *) : ;;
  esac
  if xyz_need_cmd getenforce && [[ "$(getenforce 2>/dev/null)" == "Enforcing" ]]; then
    xyz_need_cmd semanage || xyz_pkg_install policycoreutils-python-utils || xyz_pkg_install policycoreutils-python || true
    semanage port -a -t ssh_port_t -p tcp "${NEW_PORT}" 2>/dev/null || semanage port -m -t ssh_port_t -p tcp "${NEW_PORT}" || true
  fi
fi

BAKS=()
BAKS+=("$(xyz_backup_file "${SSHD_CONFIG}")")
tmpcfg="$(mktemp)"
{
  printf '%s\n' "${BLOCK}"
  sed "\\|^${BEGIN_MARK}|,\\|^${END_MARK}|d" "${SSHD_CONFIG}"
} > "${tmpcfg}"
cat "${tmpcfg}" > "${SSHD_CONFIG}"
rm -f "${tmpcfg}"
if [[ "${CLOSE_OLD}" == "1" && -n "${NEW_PORT}" ]]; then
  # Comment out every other active Port line outside our block.
  awk -v b="${BEGIN_MARK}" -v e="${END_MARK}" '
    index($0, b) == 1 { inb = 1 } index($0, e) == 1 { inb = 0; print; next }
    !inb && tolower($1) == "port" { print "# xyz.sh: " $0; next } { print }' "${SSHD_CONFIG}" > "${SSHD_CONFIG}.xyz-new"
  cat "${SSHD_CONFIG}.xyz-new" > "${SSHD_CONFIG}"; rm -f "${SSHD_CONFIG}.xyz-new"
  for f in /etc/ssh/sshd_config.d/*.conf; do
    [[ -f "${f}" ]] || continue
    if grep -q -i -E '^[[:space:]]*port[[:space:]]' "${f}"; then
      BAKS+=("$(xyz_backup_file "${f}")")
      sed -i -E 's/^([[:space:]]*[Pp][Oo][Rr][Tt][[:space:]].*)$/# xyz.sh: \1/' "${f}"
    fi
  done
fi

if ! sshd -t; then
  xyz_error "$(xyz_t "sshd -t rejected the new config. Restoring backups." "sshd -t 校验失败,正在恢复备份。")"
  cp -a "${BAKS[0]}" "${SSHD_CONFIG}"
  for b in "${BAKS[@]:1}"; do cp -a "${b}" "${b%.xyz-bak-*}"; done
  exit 1
fi
xyz_info "$(xyz_t "sshd -t: configuration is valid. Backup:" "sshd -t:配置有效。备份:") ${BAKS[*]}"

reload_sshd
sleep 1
show_status
echo
if [[ -n "${NEW_PORT}" ]]; then
  xyz_info "$(xyz_t "Now test from a NEW terminal:" "现在请在新终端测试:") ssh -p ${NEW_PORT} ${TARGET_USER}@<server-ip>"
  [[ "${CLOSE_OLD}" != "1" ]] && xyz_info "$(xyz_t "When it works, close the old port:" "确认可用后关闭旧端口:") curl -fsSL xyz.sh/ssh | bash -s -- --port ${NEW_PORT} --close-old -y"
fi
xyz_info "$(xyz_t "Undo:" "撤销:") curl -fsSL xyz.sh/ssh | bash -s -- --rollback -y"

推荐 VPS

以下为占位展示,尚未接入任何商家或联盟链接,也没有可点击的购买地址。上线推广前会再填入真实套餐与合规的返佣链接。

占位 · 高性价比

商家待定

机房、配置与价格将在接入后填写。当前不是购买入口。

占位 · 大内存 / 大硬盘

商家待定

机房、配置与价格将在接入后填写。当前不是购买入口。