VPS one-click scripts
All scripts / ssh-harden

ssh-harden SSH hardening

To change the SSH port or switch to key-only login safely, run the command below as root. It backs up sshd_config, validates with sshd -t and only reloads sshd, so existing sessions stay connected; the new port is added alongside the old one until you confirm it works.

Alias of ssh

Command
$ curl -fsSL xyz.sh/ssh-harden | bash
Last updated:
Declared OS support:Debian, Ubuntu, CentOS, Rocky, AlmaLinux
Aliases:sshd ssh-harden
Root required
SHA256:c891924aa27e01ce09e79f46f1faeaed42d5807338abd1081599756e93642999 · Matches the script curl receives. You can also fetch /ssh-harden.sh.sha256
Read the tutorial:SSH Hardening Tutorial: Port Change and Key Login

Every script passes bash -n and ShellCheck and was tested with curl | bash as root in the official Docker images of Debian 12/11, Ubuntu 24.04/22.04/20.04/18.04, Rocky 9, AlmaLinux 9 and CentOS 7 (see “Container test results” on each page). Containers lack systemd, kernel modules and real public routes, so those parts still need a real VPS.

What it does

Backs up /etc/ssh/sshd_config and validates every change with sshd -t, restoring the backup if validation fails. A new port is added next to the old one by default, so this session and the old port keep working; after logging in on the new port from a new terminal, run again with --close-old. --disable-password is refused unless the target user has at least one key in authorized_keys (or you add one now with --key / --github), so you cannot lock yourself out that way. If UFW or firewalld is active the new port is allowed first; SELinux-enforcing systems get an ssh_port_t label; Ubuntu 22.10+ ssh.socket activation is handled. Settings are written as a marked block at the top of sshd_config (sshd uses the first value it reads, so it beats drop-ins such as 50-cloud-init.conf) and sshd is only reloaded, so existing sessions stay connected. --rollback undoes it. Open the new port in your cloud security group yourself.

Supported OS

  • Debian
  • Ubuntu
  • CentOS
  • Rocky
  • AlmaLinux

Root required

Flags and examples

$ 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 -- --rollback -y

Container test results

Results of curl | bash as root without a terminal in each distribution's official Docker image. partial means the container could not verify everything (for example no systemd, no kernel module, or the test protocol blocked by the test environment's proxy) and a real VPS is needed to confirm. (2026-11-11)

OSResult
debian:12pass
debian:11pass
ubuntu:24.04pass
ubuntu:22.04pass
ubuntu:20.04pass
ubuntu:18.04pass
rockylinux:9pass
almalinux:9pass
centos:7pass

FAQ

Is this script safe?
xyz.sh adds no telemetry and performs no hidden actions. Read the full source on the page or download it with curl -fsSL xyz.sh/<name>.sh first; every script has a /<name>.sh.sha256 checksum. Piping a remote script to bash is still a risk — review the source first. Lock-out protection: a failed sshd -t restores the backup; the new port is added next to the old one; --disable-password is refused without a key; sshd is reloaded, not restarted. You still need to open the new port in your cloud security group.
Which operating systems are supported?
Declared support: Debian, Ubuntu, CentOS, Rocky Linux, and AlmaLinux. The script detects the distribution and warns on an unknown one. It was tested in Docker containers of these systems (syntax, --dry-run, and whatever can run in a container; see tests/RESULTS.md); parts that need systemd, kernel modules, or the public internet still need a real VPS.
Do I need root?
Yes. Run it as root or via sudo bash; it exits with a message otherwise. --dry-run and --status show what it would do first.
I can't connect on the new port — what now?
The old port is still open by default: log in on it, check that your cloud security group / firewall allows the new port, or run --rollback. If you already closed the old port, use your provider's VNC/console to run --rollback.

Full source

This is exactly what curl receives (common library + script body). Read it before running.

xyz.sh/ssh-harden.sh
#!/usr/bin/env bash
# Served by xyz.sh/ssh-harden
# Human-readable page: https://xyz.sh/ssh-harden
# 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-harden"

# 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"

Recommended VPS

These slots are placeholders. No vendor or affiliate link has been added, and nothing here is a purchase button. Real plans and compliant referral links will be filled in later.

Placeholder · Value

Vendor TBD

Region, specs, and price will be filled in after onboarding. This is not a checkout link.

Placeholder · High RAM / storage

Vendor TBD

Region, specs, and price will be filled in after onboarding. This is not a checkout link.