VPS one-click scripts
All scripts / wireguard

wireguard WireGuard VPN

To set up WireGuard on a VPS, run the command below as root. It installs wireguard-tools, creates the server and first client configs, enables IP forwarding, starts wg-quick@wg0, and shows a QR code to import.

Command
$ curl -fsSL xyz.sh/wireguard | bash
Last updated:
Declared OS support:Debian 11+, Ubuntu 20.04+, Rocky 9, AlmaLinux 9
Aliases:wg
Root required
Upstream:https://www.wireguard.com/install/
SHA256:cb3681ebfa3e3568d53eca19cbbc6bd773592a2507eb2e40aa0f750dc3f96a28 · Matches the script curl receives. You can also fetch /wireguard.sh.sha256
Read the tutorial:WireGuard Server Setup Tutorial

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

A small script written by xyz.sh: installs wireguard-tools (plus qrencode when available) from the distro, creates /etc/wireguard/wg0.conf (10.66.66.1/24, UDP 51820 by default, NAT with iptables MASQUERADE on the default interface), enables IPv4 forwarding in /etc/sysctl.d/99-xyz-wireguard.conf, creates the first client /etc/wireguard/clients/<name>.conf and prints it with a QR code, then enables wg-quick@wg0 with systemd. The public endpoint is the default route's source address; if that is private, api.ipify.org is asked once after a notice (or pass --endpoint). UFW / firewalld, when active, get the port allowed. --add-client adds peers; --uninstall removes the configs. Needs a 5.6+ kernel or a distro WireGuard module; CentOS 7 is not supported.

Supported OS

  • Debian 11+
  • Ubuntu 20.04+
  • Rocky 9
  • AlmaLinux 9

Root required

Flags and examples

$ curl -fsSL xyz.sh/wireguard | bash -s -- -y
$ curl -fsSL xyz.sh/wireguard | bash -s -- --port 51820 --endpoint vpn.example.com -y
$ curl -fsSL xyz.sh/wireguard | bash -s -- --add-client phone -y
$ curl -fsSL xyz.sh/wireguard | bash -s -- --status
$ curl -fsSL xyz.sh/wireguard | bash -s -- --uninstall -y

Upstream: https://www.wireguard.com/install/

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. Private keys are generated locally and stored in /etc/wireguard (mode 600); nothing is uploaded. If the default route's source address is private, api.ipify.org is queried once after a notice; use --endpoint to avoid it.
Which operating systems are supported?
Declared support: Debian 11+, Ubuntu 20.04+, Rocky 9, and AlmaLinux 9. 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.
Does it work on OpenVZ / LXC?
WireGuard needs a kernel module. OpenVZ and most LXC containers cannot load one, so wg-quick fails; use a KVM (full virtualisation) VPS.

Full source

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

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

# xyz.sh/wireguard — set up a WireGuard VPN server and client configs.
#
# Self-written, small and readable:
#   - installs wireguard-tools (and qrencode when available) from the distro
#   - creates /etc/wireguard/wg0.conf (10.66.66.1/24, UDP 51820 by default)
#     with NAT (iptables MASQUERADE on the default interface) via PostUp/PostDown
#   - enables IPv4 forwarding in /etc/sysctl.d/99-xyz-wireguard.conf
#   - creates the first client in /etc/wireguard/clients/<name>.conf and
#     prints it (plus a QR code if qrencode is installed)
#   - enables wg-quick@wg0 with systemd
# The public endpoint is detected from the default route's source address; if
# that is a private address, api.ipify.org is asked once (announced) unless
# you pass --endpoint.
#
# Usage:
#   curl -fsSL xyz.sh/wireguard | bash -s -- -y
#   curl -fsSL xyz.sh/wireguard | bash -s -- --port 51820 --endpoint vpn.example.com -y
#   curl -fsSL xyz.sh/wireguard | bash -s -- --add-client phone -y
#   curl -fsSL xyz.sh/wireguard | bash -s -- --status
#   curl -fsSL xyz.sh/wireguard | bash -s -- --uninstall -y
#   curl -fsSL xyz.sh/wireguard | bash -s -- --dry-run
#
# Supported: Debian 11+, Ubuntu 20.04+, Rocky / AlmaLinux 9 (kernel 5.6+ or
# a distro WireGuard module). CentOS 7 is not supported. 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:-wireguard}"
case "${XYZ_NAME}" in wireguard|wg) : ;; *) XYZ_NAME="wireguard" ;; esac
XYZ_TITLE="$(xyz_t "WireGuard VPN server" "WireGuard VPN 服务端")"
XYZ_UPSTREAM="https://www.wireguard.com/install/"
WG_DIR="/etc/wireguard"
WG_IF="wg0"
WG_CONF="${WG_DIR}/${WG_IF}.conf"
SYSCTL_FILE="/etc/sysctl.d/99-xyz-wireguard.conf"
xyz_init "$@"

PORT="51820"; ENDPOINT=""; DNS="1.1.1.1, 8.8.8.8"; CLIENT="client1"
DO_STATUS=0; DO_UNINSTALL=0; ADD_CLIENT=""; NO_START=0
i=0
while [[ $i -lt ${#XYZ_ARGS[@]} ]]; do
  arg="${XYZ_ARGS[$i]}"
  case "${arg}" in
    --port) i=$((i + 1)); PORT="${XYZ_ARGS[$i]:-}" ;;
    --endpoint) i=$((i + 1)); ENDPOINT="${XYZ_ARGS[$i]:-}" ;;
    --dns) i=$((i + 1)); DNS="${XYZ_ARGS[$i]:-}" ;;
    --client) i=$((i + 1)); CLIENT="${XYZ_ARGS[$i]:-}" ;;
    --add-client) i=$((i + 1)); ADD_CLIENT="${XYZ_ARGS[$i]:-}" ;;
    --no-start) NO_START=1 ;;
    --status) DO_STATUS=1 ;;
    --uninstall|--remove) DO_UNINSTALL=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/wireguard | bash -s -- -y
  curl -fsSL xyz.sh/wireguard | bash -s -- --port 51820 --endpoint vpn.example.com -y
  curl -fsSL xyz.sh/wireguard | bash -s -- --add-client phone -y
  curl -fsSL xyz.sh/wireguard | bash -s -- --status
  curl -fsSL xyz.sh/wireguard | bash -s -- --uninstall -y

$(xyz_t "Client configs are saved in ${WG_DIR}/clients/. Allow UDP ${PORT} in your firewall / security group." "客户端配置保存在 ${WG_DIR}/clients/。请在防火墙和安全组放行 UDP ${PORT}。")
HINT
  exit 0
fi

if ! [[ "${PORT}" =~ ^[0-9]+$ ]] || (( PORT < 1 || PORT > 65535 )); then
  xyz_error "--port 1-65535"
  exit 1
fi
for n in "${CLIENT}" "${ADD_CLIENT}"; do
  [[ -z "${n}" || "${n}" =~ ^[A-Za-z0-9_-]{1,32}$ ]] || { xyz_error "$(xyz_t "Client names: letters, digits, _ and -" "客户端名称只能包含字母、数字、_ 和 -")"; exit 1; }
done
DNS_RE='^[0-9A-Fa-f:., ]+$'
[[ "${DNS}" =~ ${DNS_RE} ]] || { xyz_error "--dns takes IP addresses"; exit 1; }

if [[ "${DO_STATUS}" == "1" ]]; then
  if xyz_need_cmd wg; then wg show 2>/dev/null || xyz_info "$(xyz_t "No WireGuard interface is up." "没有运行中的 WireGuard 接口。")"; fi
  if [[ -f "${WG_CONF}" ]]; then
    xyz_info "${WG_CONF} $(xyz_t "exists" "已存在")"
  else
    xyz_info "$(xyz_t "Not configured." "尚未配置。")"
  fi
  for c in "${WG_DIR}"/clients/*.conf; do
    [[ -f "${c}" ]] && printf '  client: %s\n' "$(basename "${c}" .conf)"
  done
  exit 0
fi

xyz_require_root
xyz_require_supported_os
if [[ "${XYZ_OS_ID}" == "centos" && "${XYZ_OS_VERSION%%.*}" == "7" ]]; then
  xyz_error "$(xyz_t "CentOS 7 is not supported (no in-kernel WireGuard). Use Debian 12, Ubuntu 22.04+ or Rocky/Alma 9." "不支持 CentOS 7(内核不含 WireGuard)。请使用 Debian 12、Ubuntu 22.04+ 或 Rocky/Alma 9。")"
  exit 1
fi

svc() {
  # svc ACTION -> systemctl ACTION wg-quick@wg0 if systemd exists
  if xyz_has_systemd; then systemctl "$@" "wg-quick@${WG_IF}"; else return 1; fi
}

if [[ "${DO_UNINSTALL}" == "1" ]]; then
  xyz_info "$(xyz_t "Will stop wg-quick@wg0 and remove" "将停止 wg-quick@wg0 并删除") ${WG_CONF} ${WG_DIR}/clients ${SYSCTL_FILE} $(xyz_t "(packages are kept)" "(保留软件包)")"
  if [[ "${XYZ_DRY_RUN:-0}" == "1" ]]; then xyz_info "$(xyz_t "Dry-run: nothing removed." "演练模式:未删除。")"; exit 0; fi
  xyz_confirm "$(xyz_t "Remove the WireGuard server config and all client configs?" "删除 WireGuard 服务端与全部客户端配置?")" || exit 1
  svc disable --now 2>/dev/null || wg-quick down "${WG_IF}" 2>/dev/null || true
  rm -rf "${WG_CONF}" "${WG_DIR}/clients" "${WG_DIR}/server.key" "${WG_DIR}/server.pub" "${SYSCTL_FILE}"
  xyz_info "$(xyz_t "Removed." "已删除。")"
  exit 0
fi

default_if() { ip -4 route show default 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}'; }
default_src() { ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}'; }
is_private() { [[ "$1" =~ ^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|127\.) ]]; }

detect_endpoint() {
  local ip
  ip="$(default_src || true)"
  if [[ -n "${ip}" ]] && ! is_private "${ip}"; then
    printf '%s' "${ip}"
    return 0
  fi
  xyz_info "$(xyz_t "Default route uses a private address; asking https://api.ipify.org for the public IPv4 (skip with --endpoint)." "默认路由是内网地址;将向 https://api.ipify.org 查询公网 IPv4(可用 --endpoint 跳过)。")" >&2
  curl -4 -fsS --max-time 10 https://api.ipify.org 2>/dev/null || true
}

next_ip() {
  local n=2
  while grep -q "AllowedIPs = 10.66.66.${n}/32" "${WG_CONF}" 2>/dev/null; do n=$((n + 1)); done
  (( n > 254 )) && { xyz_error "subnet full"; exit 1; }
  printf '10.66.66.%s' "${n}"
}

make_client() {
  local name="$1" cip ckey cpub psk spub ep port
  [[ -f "${WG_DIR}/clients/${name}.conf" ]] && { xyz_error "$(xyz_t "Client exists:" "客户端已存在:") ${name}"; exit 1; }
  cip="$(next_ip)"
  ckey="$(wg genkey)"; cpub="$(printf '%s' "${ckey}" | wg pubkey)"; psk="$(wg genpsk)"
  spub="$(cat "${WG_DIR}/server.pub")"
  ep="$(sed -n 's/^# xyz-endpoint = //p' "${WG_CONF}")"
  port="$(awk -F' = ' '$1=="ListenPort"{print $2}' "${WG_CONF}")"
  cat >> "${WG_CONF}" <<PEER

[Peer]
# ${name}
PublicKey = ${cpub}
PresharedKey = ${psk}
AllowedIPs = ${cip}/32
PEER
  mkdir -p "${WG_DIR}/clients"
  umask 077
  cat > "${WG_DIR}/clients/${name}.conf" <<CLIENT
[Interface]
PrivateKey = ${ckey}
Address = ${cip}/24
DNS = ${DNS}

[Peer]
PublicKey = ${spub}
PresharedKey = ${psk}
Endpoint = ${ep}:${port}
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25
CLIENT
  chmod 600 "${WG_DIR}/clients/${name}.conf"
  xyz_info "$(xyz_t "Client config:" "客户端配置:") ${WG_DIR}/clients/${name}.conf"
  echo "----- ${name}.conf -----"
  cat "${WG_DIR}/clients/${name}.conf"
  echo "------------------------"
  if xyz_need_cmd qrencode; then qrencode -t ansiutf8 < "${WG_DIR}/clients/${name}.conf" || true; fi
  if wg show "${WG_IF}" >/dev/null 2>&1; then
    wg set "${WG_IF}" peer "${cpub}" preshared-key <(printf '%s' "${psk}") allowed-ips "${cip}/32" || true
  fi
}

install_tools() {
  if xyz_need_cmd wg && xyz_need_cmd wg-quick; then return 0; fi
  case "${XYZ_OS_FAMILY}" in
    debian) xyz_pkg_install wireguard-tools iptables iproute2; xyz_pkg_install qrencode || true ;;
    rhel)
      if ! rpm -q epel-release >/dev/null 2>&1; then xyz_pkg_install epel-release || true; fi
      xyz_pkg_install wireguard-tools iptables iproute; xyz_pkg_install qrencode || true ;;
    *) xyz_error "unsupported OS"; exit 1 ;;
  esac
}

if [[ -n "${ADD_CLIENT}" ]]; then
  [[ -f "${WG_CONF}" ]] || { xyz_error "$(xyz_t "No server config yet; run without --add-client first." "尚无服务端配置,请先不带 --add-client 运行一次。")"; exit 1; }
  if [[ "${XYZ_DRY_RUN:-0}" == "1" ]]; then xyz_info "$(xyz_t "Dry-run: would add client" "演练模式:将添加客户端") ${ADD_CLIENT} ($(next_ip))"; exit 0; fi
  xyz_confirm "$(xyz_t "Add client ${ADD_CLIENT}?" "添加客户端 ${ADD_CLIENT}?")" || exit 1
  make_client "${ADD_CLIENT}"
  exit 0
fi

if [[ -f "${WG_CONF}" ]]; then
  xyz_error "$(xyz_t "${WG_CONF} already exists. Use --add-client NAME, --status, or --uninstall." "${WG_CONF} 已存在。可用 --add-client NAME、--status 或 --uninstall。")"
  exit 1
fi

IFACE="$(default_if || true)"
[[ -z "${IFACE}" ]] && IFACE="eth0"
if [[ -z "${ENDPOINT}" && "${XYZ_DRY_RUN:-0}" != "1" ]]; then
  ENDPOINT="$(detect_endpoint)"
fi

xyz_step "$(xyz_t "Planned changes" "计划变更")"
printf '  %s: wireguard-tools iptables (+ qrencode)\n' "$(xyz_t "Packages" "软件包")"
printf '  %s: %s  (10.66.66.1/24, UDP %s, NAT via %s)\n' "$(xyz_t "Server" "服务端")" "${WG_CONF}" "${PORT}" "${IFACE}"
printf '  %s: %s  (net.ipv4.ip_forward=1)\n' "sysctl" "${SYSCTL_FILE}"
printf '  %s: %s/clients/%s.conf  Endpoint=%s:%s  DNS=%s\n' "$(xyz_t "Client" "客户端")" "${WG_DIR}" "${CLIENT}" "${ENDPOINT:-<auto>}" "${PORT}" "${DNS}"
printf '  %s\n' "$(xyz_t "Firewall: UFW / firewalld (if active) will allow UDP ${PORT}. Also open it in your cloud security group." "防火墙:若启用了 UFW / firewalld 会放行 UDP ${PORT}。云安全组也需放行。")"

if [[ "${XYZ_DRY_RUN:-0}" == "1" ]]; then
  xyz_info "$(xyz_t "Dry-run: nothing installed or written." "演练模式:未安装、未写入任何内容。")"
  exit 0
fi
[[ -n "${ENDPOINT}" ]] || { xyz_error "$(xyz_t "Could not detect a public endpoint; pass --endpoint IP_OR_HOST." "无法检测公网地址,请用 --endpoint 指定 IP 或域名。")"; exit 1; }
xyz_confirm "$(xyz_t "Set up the WireGuard server?" "配置 WireGuard 服务端?")" || { xyz_error "$(xyz_t "Aborted by user." "已取消。")"; exit 1; }

install_tools
mkdir -p "${WG_DIR}"
chmod 700 "${WG_DIR}"
umask 077
wg genkey > "${WG_DIR}/server.key"
wg pubkey < "${WG_DIR}/server.key" > "${WG_DIR}/server.pub"
cat > "${WG_CONF}" <<CONF
# Written by xyz.sh/wireguard ${XYZ_VERSION}
# xyz-endpoint = ${ENDPOINT}
[Interface]
Address = 10.66.66.1/24
ListenPort = ${PORT}
PrivateKey = $(cat "${WG_DIR}/server.key")
PostUp = iptables -t nat -A POSTROUTING -s 10.66.66.0/24 -o ${IFACE} -j MASQUERADE; iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT
PostDown = iptables -t nat -D POSTROUTING -s 10.66.66.0/24 -o ${IFACE} -j MASQUERADE; iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT
CONF
chmod 600 "${WG_CONF}"
(umask 022; mkdir -p "$(dirname "${SYSCTL_FILE}")"; printf 'net.ipv4.ip_forward = 1\n' > "${SYSCTL_FILE}")
sysctl -p "${SYSCTL_FILE}" >/dev/null 2>&1 || xyz_warn "$(xyz_t "Could not apply ip_forward now (container?)." "暂时无法应用 ip_forward(可能是容器)。")"

if xyz_need_cmd ufw && ufw status 2>/dev/null | grep -q '^Status: active'; then ufw allow "${PORT}/udp" || true; fi
if xyz_need_cmd firewall-cmd && firewall-cmd --state >/dev/null 2>&1; then
  firewall-cmd --permanent --add-port="${PORT}/udp" && firewall-cmd --permanent --add-masquerade && firewall-cmd --reload || true
fi

make_client "${CLIENT}"

if [[ "${NO_START}" == "1" ]]; then
  xyz_info "$(xyz_t "--no-start: configs written; start later with: systemctl enable --now wg-quick@wg0" "--no-start:配置已写入;稍后用 systemctl enable --now wg-quick@wg0 启动。")"
  exit 0
fi
if svc enable --now; then
  wg show "${WG_IF}" || true
  xyz_info "$(xyz_t "WireGuard is running on UDP ${PORT}." "WireGuard 已在 UDP ${PORT} 运行。")"
elif wg-quick up "${WG_IF}"; then
  xyz_warn "$(xyz_t "Started with wg-quick (no systemd); it will not start on boot." "已用 wg-quick 启动(无 systemd),开机不会自动启动。")"
else
  xyz_error "$(xyz_t "Could not bring up wg0 (kernel module missing or no permission). Configs are kept." "无法启动 wg0(缺少内核模块或权限不足),配置已保留。")"
  exit 1
fi

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.