Last active
February 27, 2026 15:50
-
-
Save DavidOsipov/ee38483fbdb10913346e3ef526980430 to your computer and use it in GitHub Desktop.
Monolithic installation script of Xray-Core for OpenBSD
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/ksh | |
| # | |
| # SPDX-License-Identifier: MIT | |
| # Copyright (c) 2026 David Osipov <personal@david-osipov.vision> | |
| # Website: https://david-osipov.vision/ | |
| # | |
| # $OpenBSD$ | |
| # | |
| # Xray-core installer for OpenBSD. | |
| # Strictly follows hier(7), bsd.own.mk, rc.d(8) and login.conf(5) standards. | |
| # Designed for doas/sudo + cron usage. | |
| set -eu | |
| umask 022 | |
| # ==================== CONFIGURATION (hier(7) compliant) ==================== | |
| BIN_PATH="/usr/local/bin/xray" | |
| SHARE_PATH="/usr/local/share/xray" # Architecture-independent geo assets | |
| CONF_DIR="/etc/xray" | |
| LOG_DIR="/var/log/xray" | |
| RC_SCRIPT="/etc/rc.d/xray" | |
| DAEMON_USER="_xray" | |
| # daemon_class is READ-ONLY in rc.subr — it is automatically derived from | |
| # the rc.d script name by searching login.conf(5) for a matching class. | |
| DAEMON_CLASS="xray" | |
| GITHUB_REPO="XTLS/Xray-core" | |
| # Script operation flags | |
| QUIET=0 | |
| FORCE=0 | |
| # ======================================================================== | |
| # Sanity check: Ensure script is running with root privileges | |
| if [ "$(id -u)" -ne 0 ]; then | |
| echo "error: This script requires root privileges. Please run using doas or sudo." >&2 | |
| exit 1 | |
| fi | |
| # OpenBSD native architecture detection for downloading the correct binary | |
| case $(uname -m) in | |
| amd64) XRAY_ARCH="64" ;; | |
| i386) XRAY_ARCH="32" ;; | |
| arm64) XRAY_ARCH="arm64-v8a" ;; | |
| *) echo "error: Architecture $(uname -m) is currently not supported by Xray releases." >&2; exit 1 ;; | |
| esac | |
| # Cleanup function to remove temporary files upon script exit or interruption | |
| cleanup() { | |
| [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ] && rm -rf "$TMP_DIR" | |
| [ -n "${CLIENT_INFO_FILE:-}" ] && [ -f "$CLIENT_INFO_FILE" ] && rm -f "$CLIENT_INFO_FILE" | |
| } | |
| trap cleanup EXIT INT TERM | |
| # Logging utilities | |
| log() { [ "$QUIET" -eq 1 ] && logger -t xray-install -p user.info "$1" || echo "info: $1"; } | |
| error() { [ "$QUIET" -eq 1 ] && logger -t xray-install -p user.err "$1" || echo "error: $1" >&2; } | |
| # POSIX/OpenBSD compliant version comparison (workaround since there is no GNU sort -V) | |
| version_gt() { | |
| local top | |
| top=$(printf '%s\n%s\n' "$1" "$2" | sort -t. -k1,1n -k2,2n -k3,3n | head -n1) | |
| [ "$top" != "$1" ] | |
| } | |
| # Extracts the version number of the currently installed binary | |
| get_current_version() { | |
| [ -x "$BIN_PATH" ] || { echo ""; return; } | |
| "$BIN_PATH" version 2>/dev/null | head -n1 | awk '{print $2}' | tr -d 'v' | |
| } | |
| # Queries GitHub API for the latest release tag | |
| get_latest_version() { | |
| ftp -V -o - "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" 2>/dev/null | | |
| tr ',' '\n' | awk -F'"' '/"tag_name":/ {print $4}' | tr -d 'v' | |
| } | |
| # Creates a dedicated, unprivileged user for running the daemon securely | |
| create_daemon_user() { | |
| if ! id -u "$DAEMON_USER" >/dev/null 2>&1; then | |
| log "Creating dedicated system user '$DAEMON_USER' to isolate the daemon..." | |
| useradd -s /sbin/nologin -d /var/empty -c "Xray Proxy Service" "$DAEMON_USER" | |
| else | |
| log "System user '$DAEMON_USER' already exists, proceeding..." | |
| fi | |
| } | |
| # Adds the xray login class to /etc/login.conf. | |
| setup_login_class() { | |
| if grep -q "^${DAEMON_CLASS}:" /etc/login.conf 2>/dev/null; then | |
| log "Login class '${DAEMON_CLASS}' already exists in /etc/login.conf." | |
| if ! grep -q "setenv=XRAY_LOCATION_ASSET" /etc/login.conf 2>/dev/null; then | |
| echo "warning: Login class '${DAEMON_CLASS}' exists but lacks XRAY_LOCATION_ASSET." >&2 | |
| echo " Please manually add: :setenv=XRAY_LOCATION_ASSET=${SHARE_PATH}:" >&2 | |
| fi | |
| return | |
| fi | |
| log "Appending '${DAEMON_CLASS}' login class to /etc/login.conf..." | |
| cat >> /etc/login.conf << EOF | |
| ${DAEMON_CLASS}:\\ | |
| :setenv=XRAY_LOCATION_ASSET=${SHARE_PATH}:\\ | |
| :openfiles-cur=4096:\\ | |
| :openfiles-max=8192:\\ | |
| :tc=daemon: | |
| EOF | |
| cap_mkdb /etc/login.conf | |
| log "Rebuilt /etc/login.conf database via cap_mkdb(8)." | |
| } | |
| # Generates and installs a canonical, pristine rc.d(8) service script. | |
| install_rc_script() { | |
| log "Generating and installing OpenBSD rc.d(8) service script at $RC_SCRIPT..." | |
| cat > "$RC_SCRIPT" << EOF | |
| #!/bin/ksh | |
| # | |
| # \$OpenBSD\$ | |
| # | |
| # Xray-core daemon control script. | |
| # Auto-generated by install-xray.sh | |
| daemon="${BIN_PATH}" | |
| daemon_flags="run -confdir ${CONF_DIR}" | |
| daemon_user="${DAEMON_USER}" | |
| daemon_timeout="60" | |
| . /etc/rc.d/rc.subr | |
| rc_bg=YES | |
| rc_reload=NO | |
| rc_cmd \$1 | |
| EOF | |
| chmod 555 "$RC_SCRIPT" | |
| chown root:wheel "$RC_SCRIPT" | |
| } | |
| # Generates a default configuration with best practices for DPI evasion | |
| generate_default_config() { | |
| local conf_file="${CONF_DIR}/config.json" | |
| if [ -f "$conf_file" ]; then | |
| log "Configuration file already exists at $conf_file. Skipping default config generation." | |
| return | |
| fi | |
| log "Generating default XHTTP + REALITY + Vision configuration..." | |
| # Generate cryptographic materials using Xray | |
| local uuid=$("$BIN_PATH" uuid) | |
| local path_uuid=$("$BIN_PATH" uuid) | |
| local keys=$("$BIN_PATH" x25519) | |
| local priv_key=$(echo "$keys" | awk '/Private key:/ {print $3}') | |
| local pub_key=$(echo "$keys" | awk '/Public key:/ {print $3}') | |
| local short_id=$(openssl rand -hex 8) | |
| local vlessenc=$("$BIN_PATH" vlessenc) | |
| local dec_key=$(echo "$vlessenc" | awk '/Decryption:/ {print $2}') | |
| local enc_key=$(echo "$vlessenc" | awk '/Encryption:/ {print $2}') | |
| cat > "$conf_file" << EOF | |
| { | |
| "log": { | |
| "loglevel": "warning", | |
| "access": "${LOG_DIR}/access.log", | |
| "error": "${LOG_DIR}/error.log", | |
| "dnsLog": false | |
| }, | |
| "routing": { | |
| "domainStrategy": "IPIfNonMatch", | |
| "rules": [ | |
| { | |
| "type": "field", | |
| "ip": ["geoip:private", "geoip:ru"], | |
| "outboundTag": "block" | |
| }, | |
| { | |
| "type": "field", | |
| "domain": ["geosite:ru", "geosite:category-ads-all"], | |
| "outboundTag": "block" | |
| }, | |
| { | |
| "type": "field", | |
| "protocol": ["bittorrent"], | |
| "outboundTag": "block" | |
| } | |
| ] | |
| }, | |
| "dns": { | |
| "servers": [ | |
| "https://8.8.8.8/dns-query", | |
| "https://1.1.1.1/dns-query", | |
| "localhost" | |
| ] | |
| }, | |
| "inbounds": [ | |
| { | |
| "listen": "127.0.0.1", | |
| "port": 10443, | |
| "protocol": "vless", | |
| "tag": "vless_reality_xhttp", | |
| "settings": { | |
| "clients": [ | |
| { | |
| "id": "${uuid}", | |
| "email": "user@example.com", | |
| "flow": "xtls-rprx-vision" | |
| } | |
| ], | |
| "decryption": "${dec_key}" | |
| }, | |
| "streamSettings": { | |
| "network": "xhttp", | |
| "security": "reality", | |
| "xhttpSettings": { | |
| "host": "YOUR_DOMAIN_HERE", | |
| "path": "/${path_uuid}", | |
| "mode": "auto", | |
| "extra": { | |
| "xPaddingBytes": "100-5000", | |
| "noSSEHeader": false, | |
| "scMaxEachPostBytes": 1000000, | |
| "scMaxBufferedPosts": 30, | |
| "scStreamUpServerSecs": "20-80", | |
| "xmux": { | |
| "maxConcurrency": "16-32", | |
| "hMaxRequestTimes": "600-900", | |
| "hMaxReusableSecs": "1800-3000" | |
| } | |
| } | |
| }, | |
| "realitySettings": { | |
| "show": false, | |
| "target": "127.0.0.1:8443", | |
| "xver": 1, | |
| "serverNames": [ | |
| "YOUR_DOMAIN_HERE" | |
| ], | |
| "privateKey": "${priv_key}", | |
| "shortIds": [ | |
| "${short_id}" | |
| ] | |
| } | |
| }, | |
| "sniffing": { | |
| "enabled": true, | |
| "destOverride": ["http", "tls", "quic"] | |
| } | |
| } | |
| ], | |
| "outbounds": [ | |
| { | |
| "protocol": "freedom", | |
| "tag": "direct", | |
| "streamSettings": { | |
| "sockopt": { | |
| "tcpKeepAliveIdle": 300 | |
| } | |
| } | |
| }, | |
| { | |
| "protocol": "blackhole", | |
| "tag": "block" | |
| } | |
| ] | |
| } | |
| EOF | |
| chown root:"$DAEMON_USER" "$conf_file" | |
| chmod 640 "$conf_file" | |
| # Save client details to a temporary file to display at the end | |
| CLIENT_INFO_FILE=$(mktemp /tmp/xray_client_info.XXXXXX) | |
| cat > "$CLIENT_INFO_FILE" << EOF | |
| [CLIENT CONFIGURATION DETAILS] | |
| UUID : ${uuid} | |
| Path : /${path_uuid} | |
| Public Key : ${pub_key} | |
| Short ID : ${short_id} | |
| Encryption : ${enc_key} | |
| (Use 'Encryption' value in the client's 'Decryption' or 'Fingerprint' field depending on the app) | |
| EOF | |
| } | |
| install_xray() { | |
| local version="$1" | |
| log "Starting Xray-core deployment process..." | |
| if [ "$version" = "latest" ]; then | |
| log "Querying GitHub for the latest release version..." | |
| version=$(get_latest_version) | |
| [ -z "$version" ] && { error "Failed to fetch the latest version tag from GitHub API."; exit 1; } | |
| version="v${version}" | |
| elif [ "${version#v}" = "$version" ]; then | |
| version="v${version}" | |
| fi | |
| local current | |
| current=$(get_current_version) | |
| local target=${version#v} | |
| if [ -n "$current" ] && [ "$current" = "$target" ] && [ "$FORCE" -eq 0 ]; then | |
| log "Xray v${current} is already installed and matches the requested version." | |
| [ "$QUIET" -eq 0 ] && echo "Hint: Use '$0 install --force' if you need to cleanly reinstall." | |
| exit 0 | |
| fi | |
| # ==================== DOWNLOAD & VERIFICATION ==================== | |
| TMP_DIR=$(mktemp -d /tmp/xray.XXXXXX) | |
| local zip="${TMP_DIR}/xray.zip" | |
| local dgst="${TMP_DIR}/xray.zip.dgst" | |
| log "Downloading Xray release ${version} for architecture ${XRAY_ARCH}..." | |
| ftp -V -o "$zip" "https://github.com/${GITHUB_REPO}/releases/download/${version}/Xray-openbsd-${XRAY_ARCH}.zip" \ | |
| || { error "Failed to download the Xray zip archive."; exit 1; } | |
| log "Downloading SHA256 signature file..." | |
| ftp -V -o "$dgst" "https://github.com/${GITHUB_REPO}/releases/download/${version}/Xray-openbsd-${XRAY_ARCH}.zip.dgst" \ | |
| || { error "Failed to download the .dgst signature file."; exit 1; } | |
| log "Verifying package integrity via SHA256 checksum..." | |
| local expected actual | |
| expected=$(sed -n 's/.*256= *//p' "$dgst" | head -n1) | |
| actual=$(sha256 -q "$zip") | |
| if [ -z "$expected" ] || [ "$expected" != "$actual" ]; then | |
| error "CRITICAL: SHA256 checksum verification failed! Archive might be corrupted or compromised." | |
| exit 1 | |
| fi | |
| log "Checksum verification passed successfully." | |
| log "Extracting archive contents..." | |
| unzip -q -o "$zip" -d "$TMP_DIR" | |
| # ==================== DEPLOYMENT ==================== | |
| create_daemon_user | |
| setup_login_class | |
| log "Installing binary to $BIN_PATH..." | |
| install -m 555 -o root -g bin "${TMP_DIR}/xray" "$BIN_PATH" | |
| log "Deploying geo-assets to $SHARE_PATH..." | |
| install -d -m 755 -o root -g bin "$SHARE_PATH" | |
| install -m 644 -o root -g bin "${TMP_DIR}/geoip.dat" "${TMP_DIR}/geosite.dat" "$SHARE_PATH/" | |
| log "Configuring directory permissions..." | |
| # Config directory: root:_xray 750 — daemon reads, only root modifies | |
| install -d -m 750 -o root -g "$DAEMON_USER" "$CONF_DIR" | |
| # Generate default config if it doesn't exist | |
| generate_default_config | |
| chown -R root:"$DAEMON_USER" "$CONF_DIR" | |
| find "$CONF_DIR" -name '*.json' -exec chmod 640 {} + 2>/dev/null || true | |
| # Log directory: _xray:_xray 750 — daemon has full control over its logs | |
| install -d -m 750 -o "$DAEMON_USER" -g "$DAEMON_USER" "$LOG_DIR" | |
| install_rc_script | |
| # ==================== FINAL SYSTEM MESSAGES ==================== | |
| if [ "$QUIET" -eq 0 ]; then | |
| echo "" | |
| echo "================================================================================" | |
| echo " Xray-core ${version} has been deployed successfully!" | |
| echo "--------------------------------------------------------------------------------" | |
| echo " Executable : $BIN_PATH" | |
| echo " Geo Assets : $SHARE_PATH/ (geoip.dat, geosite.dat)" | |
| echo " Configs : $CONF_DIR/ (root:_xray, 640 on *.json)" | |
| echo " Logs : $LOG_DIR/ (_xray:_xray 750)" | |
| echo " rc.d script : $RC_SCRIPT" | |
| echo " login class : ${DAEMON_CLASS} (env injected, openfiles tuned)" | |
| if [ -f "${CLIENT_INFO_FILE:-}" ]; then | |
| echo "" | |
| echo " [!] A DEFAULT CONFIGURATION HAS BEEN GENERATED" | |
| echo " File: $CONF_DIR/config.json" | |
| echo " It uses the ultimate anti-DPI stack: VLESS + XHTTP + REALITY + Vision." | |
| echo "" | |
| cat "$CLIENT_INFO_FILE" | |
| fi | |
| echo "" | |
| echo "================================================================================" | |
| echo " NEXT STEPS & INSTRUCTIONS:" | |
| echo "================================================================================" | |
| echo " 1. EDIT YOUR CONFIGURATION" | |
| echo " Open $CONF_DIR/config.json and replace 'YOUR_DOMAIN_HERE'" | |
| echo " with your actual domain (e.g., game.example.com)." | |
| echo "" | |
| echo " 2. SETUP YOUR LOCAL WEB SERVER (REALITY Fallback)" | |
| echo " Xray expects a valid HTTPS server running on 127.0.0.1:8443." | |
| echo " - It MUST have a valid TLS certificate for your domain." | |
| echo " - It MUST be configured to accept PROXY protocol v1 (xver: 1)." | |
| echo "" | |
| echo " 3. CONFIGURE PF (PORT FORWARDING & ISOLATION)" | |
| echo " OpenBSD strictly prohibits unprivileged users from binding to ports < 1024." | |
| echo " Add the following to your /etc/pf.conf:" | |
| echo "" | |
| echo " # Redirect incoming port 443 to Xray's unprivileged port 10443" | |
| echo " pass in on egress inet proto tcp to port 443 rdr-to 127.0.0.1 port 10443" | |
| echo "" | |
| echo " # Allow Xray daemon to make outbound connections" | |
| echo " pass out on egress inet proto tcp from any to any port { 80, 443 } user ${DAEMON_USER}" | |
| echo "" | |
| echo " Apply changes: doas pfctl -f /etc/pf.conf" | |
| echo "" | |
| echo " 4. START THE SERVICE" | |
| echo " doas rcctl enable xray" | |
| echo " doas rcctl start xray" | |
| echo "================================================================================" | |
| else | |
| logger -t xray-install -p user.notice "Successfully installed/updated Xray to ${version}" | |
| fi | |
| } | |
| check_update() { | |
| local current latest | |
| current=$(get_current_version) | |
| latest=$(get_latest_version) | |
| [ -z "$latest" ] && { error "Failed to fetch latest version data."; exit 1; } | |
| if [ -z "$current" ]; then | |
| [ "$QUIET" -eq 0 ] && echo "Xray is currently not installed. The latest available release is: v${latest}" | |
| exit 0 | |
| fi | |
| if version_gt "$latest" "$current"; then | |
| [ "$QUIET" -eq 0 ] && echo "Update available! v${current} -> v${latest}" | |
| return 0 # update available | |
| else | |
| [ "$QUIET" -eq 0 ] && echo "Your Xray installation is fully up to date (v${current})." | |
| return 1 # already up to date | |
| fi | |
| } | |
| uninstall_xray() { | |
| local purge=${1:-0} | |
| log "Initiating Xray-core removal procedure..." | |
| # Gracefully stop and disable the service before removing files | |
| rcctl stop xray 2>/dev/null || true | |
| rcctl disable xray 2>/dev/null || true | |
| for p in "$BIN_PATH" "$SHARE_PATH" "$RC_SCRIPT"; do | |
| [ -e "$p" ] && rm -rf "$p" && log "Deleted system path: $p" | |
| done | |
| if [ "$purge" = 1 ]; then | |
| rm -rf "$CONF_DIR" "$LOG_DIR" 2>/dev/null || true | |
| log "Purged configuration directory: $CONF_DIR" | |
| log "Purged log directory: $LOG_DIR" | |
| if id -u "$DAEMON_USER" >/dev/null 2>&1; then | |
| userdel "$DAEMON_USER" 2>/dev/null || true | |
| log "Deleted system user: $DAEMON_USER" | |
| fi | |
| echo "warning: The login class '${DAEMON_CLASS}' was left in /etc/login.conf." | |
| echo " It is generally unsafe to automate its removal. Please remove it manually." | |
| else | |
| echo "warning: User configurations ($CONF_DIR) and logs ($LOG_DIR) were preserved." | |
| echo " To completely obliterate all traces, run: '$0 remove --purge'" | |
| fi | |
| log "Xray-core removal completed." | |
| } | |
| print_help() { | |
| cat << EOF | |
| Usage: $0 <command> [options] | |
| Commands: | |
| install [version] Install or upgrade Xray-core (defaults to the latest release) | |
| check Check GitHub for available updates | |
| update Silent auto-update if a newer version is available | |
| remove [--purge] Uninstall Xray-core | |
| help Display this comprehensive help message | |
| Options: | |
| --force, -f Force reinstallation even if the exact version is already installed | |
| --quiet, -q Enable quiet mode (suppresses stdout, logs to syslog; ideal for cron jobs) | |
| --purge Obliterate configs, logs, and the dedicated user (only valid with 'remove') | |
| Examples: | |
| doas $0 install | |
| doas $0 install v1.8.4 | |
| 0 4 * * * doas $0 update --quiet # Recommended cron job schedule for automatic updates | |
| EOF | |
| } | |
| # ====================== ARGUMENT PARSING & MAIN EXECUTION ====================== | |
| COMMAND="install" | |
| VERSION="latest" | |
| PURGE=0 | |
| # Determine primary command | |
| if [ $# -gt 0 ] && [ "${1#-}" = "$1" ]; then | |
| COMMAND="$1" | |
| shift | |
| fi | |
| # Parse additional runtime options | |
| while [ $# -gt 0 ]; do | |
| case "$1" in | |
| --quiet|-q) QUIET=1; shift ;; | |
| --force|-f) FORCE=1; shift ;; | |
| --purge) PURGE=1; shift ;; | |
| -*) error "Unrecognized option '$1' provided."; print_help >&2; exit 1 ;; | |
| *) VERSION="$1"; shift ;; | |
| esac | |
| done | |
| # Route to the appropriate function based on the requested command | |
| case "$COMMAND" in | |
| install) | |
| if ! command -v unzip >/dev/null 2>&1; then | |
| log "Dependency 'unzip' is missing. Installing via pkg_add..." | |
| pkg_add -I unzip || { error "Failed to install required dependency: unzip."; exit 1; } | |
| fi | |
| install_xray "$VERSION" | |
| ;; | |
| check) | |
| check_update | |
| ;; | |
| update) | |
| QUIET=1 | |
| if check_update; then | |
| log "Auto-update triggered via cron/manual execution." | |
| install_xray latest | |
| rcctl restart xray 2>/dev/null || true | |
| fi | |
| ;; | |
| remove) | |
| uninstall_xray "$PURGE" | |
| ;; | |
| help) | |
| print_help | |
| ;; | |
| *) | |
| error "Unknown command '$COMMAND' requested." | |
| print_help >&2 | |
| exit 1 | |
| ;; | |
| esac |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment