#!/bin/bash # Auto-install script for SSH Panel + Xray-core (Ubuntu/Debian/CentOS) # Usage: sudo bash install.sh set -euo pipefail RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' info() { echo -e "${GREEN}[+]${NC} $*"; } warn() { echo -e "${YELLOW}[!]${NC} $*"; } error() { echo -e "${RED}[x]${NC} $*"; exit 1; } # ── config ────────────────────────────────────────────────────────────────── INSTALL_DIR="/opt/sshpanel" SERVICE_NAME="sshpanel" LOG_TMPFS_SIZE="${LOG_TMPFS_SIZE:-15m}" PANEL_LOG_MAX_BYTES="${PANEL_LOG_MAX_BYTES:-1048576}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" GO_VERSION="${GO_VERSION:-$(awk '$1 == "go" {print $2; exit}' "$SCRIPT_DIR/go.mod" 2>/dev/null || echo "1.22.5")}" MKDIR_BIN="$(command -v mkdir 2>/dev/null || true)" [[ -n "$MKDIR_BIN" ]] || MKDIR_BIN="/bin/mkdir" # ──────────────────────────────────────────────────────────────────────────── [[ $EUID -ne 0 ]] && error "Run as root: sudo bash $0" ensure_log_tmpfs_mount() { local log_dir="${INSTALL_DIR}/logs" local opts="rw,nosuid,nodev,noexec,noatime,nofail,size=${LOG_TMPFS_SIZE},mode=0755" local tmp_fstab mkdir -p "$log_dir" if [[ -f /etc/fstab ]]; then cp /etc/fstab "/etc/fstab.sshpanel.bak.$(date +%s)" 2>/dev/null || true tmp_fstab="$(mktemp)" awk -v mp="$log_dir" '!(($1 == "tmpfs") && ($2 == mp) && ($3 == "tmpfs")) {print}' /etc/fstab > "$tmp_fstab" printf 'tmpfs %s tmpfs %s 0 0\n' "$log_dir" "$opts" >> "$tmp_fstab" cat "$tmp_fstab" > /etc/fstab rm -f "$tmp_fstab" info " Log RAM disk automount saved in /etc/fstab: $log_dir (${LOG_TMPFS_SIZE})" else warn " /etc/fstab not found; service startup fallback will mount $log_dir as tmpfs" fi systemctl daemon-reload >/dev/null 2>&1 || true if mountpoint -q "$log_dir"; then mount -o "remount,size=${LOG_TMPFS_SIZE},mode=0755" "$log_dir" >/dev/null 2>&1 || true else mount "$log_dir" >/dev/null 2>&1 || mount -t tmpfs -o "size=${LOG_TMPFS_SIZE},mode=0755" tmpfs "$log_dir" >/dev/null 2>&1 || \ warn " Could not mount $log_dir as tmpfs now; systemd service will try again on start" fi touch "$log_dir/panel.log" >/dev/null 2>&1 || true chmod 0644 "$log_dir/panel.log" >/dev/null 2>&1 || true } echo -e "\n${GREEN}══════════════════════════════════════════${NC}" echo -e "${GREEN} SSH Panel + Xray-core · Installer ${NC}" echo -e "${GREEN}══════════════════════════════════════════${NC}\n" # ── 1. OS detection ────────────────────────────────────────────────────────── info "[1/9] Detecting OS…" if [[ -f /etc/os-release ]]; then # shellcheck disable=SC1091 . /etc/os-release OS_ID="${ID:-unknown}" else OS_ID="unknown" fi case "$OS_ID" in ubuntu|debian|linuxmint) PKG_UPDATE="apt update -qq" PKG_INSTALL="DEBIAN_FRONTEND=noninteractive apt install -y" PKG_DEPS="curl wget git rsync build-essential postgresql postgresql-contrib ca-certificates unzip openssh-client openssl iptables nftables" ;; centos|rhel|rocky|almalinux) PKG_UPDATE="yum makecache -q" PKG_INSTALL="yum install -y" PKG_DEPS="curl wget git rsync gcc make postgresql-server postgresql-contrib ca-certificates unzip openssh-clients openssl iptables nftables" ;; fedora) PKG_UPDATE="dnf makecache -q" PKG_INSTALL="dnf install -y" PKG_DEPS="curl wget git rsync gcc make postgresql-server postgresql-contrib ca-certificates unzip openssh-clients openssl iptables nftables" ;; *) warn "Unknown OS '$OS_ID' — attempting apt…" PKG_UPDATE="apt update -qq" PKG_INSTALL="DEBIAN_FRONTEND=noninteractive apt install -y" PKG_DEPS="curl wget git rsync build-essential postgresql postgresql-contrib ca-certificates unzip openssh-client openssl iptables nftables" ;; esac info " OS: $OS_ID" # ── 2. System dependencies ─────────────────────────────────────────────────── info "[2/9] Installing system packages…" eval "$PKG_UPDATE" eval "$PKG_INSTALL $PKG_DEPS" # ── 3. Go ──────────────────────────────────────────────────────────────────── info "[3/9] Installing Go ${GO_VERSION}…" NEED_GO=true if command -v go &>/dev/null; then CURRENT_GO=$(go version 2>/dev/null | awk '{print $3}' | sed 's/go//') if [[ "$(printf '%s\n' "$GO_VERSION" "$CURRENT_GO" | sort -V | head -1)" == "$GO_VERSION" ]]; then info " Go $CURRENT_GO already installed — skipping" NEED_GO=false fi fi if $NEED_GO; then MACHINE=$(uname -m) case "$MACHINE" in x86_64) GOARCH="amd64" ;; aarch64) GOARCH="arm64" ;; armv7l) GOARCH="armv6l" ;; *) GOARCH="amd64" ;; esac GO_URL="https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz" info " Downloading $GO_URL" wget -q --show-progress -O /tmp/go.tar.gz "$GO_URL" rm -rf /usr/local/go tar -C /usr/local -xzf /tmp/go.tar.gz rm -f /tmp/go.tar.gz echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh chmod +x /etc/profile.d/go.sh fi export PATH=$PATH:/usr/local/go/bin go version # ── 4. Directory layout ────────────────────────────────────────────────────── info "[4/9] Setting up ${INSTALL_DIR}…" mkdir -p "$INSTALL_DIR/admin" "$INSTALL_DIR/keys" "$INSTALL_DIR/logs" ensure_log_tmpfs_mount # ── 5. Build SSH panel binary ──────────────────────────────────────────────── info "[5/9] Building SSH Panel binary…" cd "$SCRIPT_DIR" export GOPATH=/tmp/gopath_sshpanel export GOCACHE=/tmp/gocache_sshpanel go mod download go build -ldflags="-s -w" -o "$INSTALL_DIR/sshpanel" . info " Binary: $INSTALL_DIR/sshpanel" cp -r "$SCRIPT_DIR/admin/"* "$INSTALL_DIR/admin/" info " Admin panel copied" if [[ -f "$SCRIPT_DIR/update.sh" ]]; then cp "$SCRIPT_DIR/update.sh" "$INSTALL_DIR/update.sh" chmod 700 "$INSTALL_DIR/update.sh" info " Git updater copied" fi if [[ -f "$SCRIPT_DIR/change_admin_password.sh" ]]; then cp "$SCRIPT_DIR/change_admin_password.sh" "$INSTALL_DIR/change_admin_password.sh" chmod 700 "$INSTALL_DIR/change_admin_password.sh" info " Admin password recovery script copied" fi # ── 6. Xray binary ────────────────────────────────────────────────────────── info "[6/9] Downloading Xray-core…" XRAY_VER=$(curl -sf "https://api.github.com/repos/XTLS/Xray-core/releases/latest" \ | grep '"tag_name"' | head -1 | cut -d'"' -f4 || echo "v24.11.30") MACHINE=$(uname -m) case "$MACHINE" in x86_64) XRAY_ARCH="64" ;; aarch64) XRAY_ARCH="arm64-v8a" ;; armv7l) XRAY_ARCH="arm32-v7a" ;; *) XRAY_ARCH="64" ;; esac XRAY_URL="https://github.com/XTLS/Xray-core/releases/download/${XRAY_VER}/Xray-linux-${XRAY_ARCH}.zip" info " Xray ${XRAY_VER} (${XRAY_ARCH})" wget -q --show-progress -O /tmp/xray.zip "$XRAY_URL" unzip -o /tmp/xray.zip xray -d "$INSTALL_DIR" > /dev/null 2>&1 || { mkdir -p /tmp/xray_extract unzip -o /tmp/xray.zip -d /tmp/xray_extract > /dev/null 2>&1 mv /tmp/xray_extract/xray "$INSTALL_DIR/xray" } chmod +x "$INSTALL_DIR/xray" rm -f /tmp/xray.zip "$INSTALL_DIR/xray" version # ── 7. PostgreSQL ──────────────────────────────────────────────────────────── info "[7/9] Configuring PostgreSQL…" case "$OS_ID" in centos|rhel|rocky|almalinux|fedora) postgresql-setup --initdb 2>/dev/null || true ;; esac systemctl start postgresql 2>/dev/null || service postgresql start 2>/dev/null || true systemctl enable postgresql 2>/dev/null || true DB_NAME="sshpanel" DB_USER="sshpanel" DB_PASS=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32 || true) if [[ ${#DB_PASS} -lt 32 ]]; then DB_PASS=$(openssl rand -hex 16 2>/dev/null || date +%s%N) fi su -c "psql -tc \"SELECT 1 FROM pg_roles WHERE rolname='${DB_USER}'\" | grep -q 1 || \ psql -c \"CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASS}';\"" postgres # Reinstall-safe: if the role already existed, make the new .env password valid. su -c "psql -c \"ALTER USER ${DB_USER} WITH PASSWORD '${DB_PASS}';\"" postgres su -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'\" | grep -q 1 || \ psql -c \"CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};\"" postgres # Reinstall-safe: if the database already existed, make sshpanel its owner. su -c "psql -c \"ALTER DATABASE ${DB_NAME} OWNER TO ${DB_USER};\"" postgres su -c "psql -d ${DB_NAME} -c \" CREATE TABLE IF NOT EXISTS ssh_users ( username TEXT PRIMARY KEY, password TEXT NOT NULL DEFAULT '', max_connections INT NOT NULL DEFAULT 0, expires_at TEXT, limit_mbps_up INT NOT NULL DEFAULT 0, limit_mbps_down INT NOT NULL DEFAULT 0, totp_secret TEXT NOT NULL DEFAULT '', totp_period INT NOT NULL DEFAULT 60, totp_window INT NOT NULL DEFAULT 1, totp_digits INT NOT NULL DEFAULT 6, allow_static_password BOOLEAN NOT NULL DEFAULT FALSE, owner_username TEXT NOT NULL DEFAULT '' ); ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_secret TEXT NOT NULL DEFAULT ''; ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_period INT NOT NULL DEFAULT 60; ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_window INT NOT NULL DEFAULT 1; ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_digits INT NOT NULL DEFAULT 6; ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS allow_static_password BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS owner_username TEXT NOT NULL DEFAULT ''; ALTER TABLE ssh_users ALTER COLUMN password SET DEFAULT ''; CREATE TABLE IF NOT EXISTS ssh_iface_totals ( iface TEXT PRIMARY KEY, total_rx_bytes BIGINT NOT NULL DEFAULT 0, total_tx_bytes BIGINT NOT NULL DEFAULT 0, last_kernel_rx_bytes BIGINT NOT NULL DEFAULT 0, last_kernel_tx_bytes BIGINT NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ALTER TABLE ssh_iface_totals ADD COLUMN IF NOT EXISTS total_rx_bytes BIGINT NOT NULL DEFAULT 0; ALTER TABLE ssh_iface_totals ADD COLUMN IF NOT EXISTS total_tx_bytes BIGINT NOT NULL DEFAULT 0; ALTER TABLE ssh_iface_totals ADD COLUMN IF NOT EXISTS last_kernel_rx_bytes BIGINT NOT NULL DEFAULT 0; ALTER TABLE ssh_iface_totals ADD COLUMN IF NOT EXISTS last_kernel_tx_bytes BIGINT NOT NULL DEFAULT 0; ALTER TABLE ssh_iface_totals ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); CREATE TABLE IF NOT EXISTS admin_users ( id SERIAL PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'reseller', max_users INT NOT NULL DEFAULT 30, expires_at TIMESTAMPTZ, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS xray_clients ( uuid TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '', inbound_tag TEXT NOT NULL DEFAULT '', expires_at TIMESTAMPTZ, max_conns INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ALTER SCHEMA public OWNER TO ${DB_USER}; ALTER TABLE IF EXISTS ssh_users OWNER TO ${DB_USER}; ALTER TABLE IF EXISTS ssh_iface_totals OWNER TO ${DB_USER}; ALTER TABLE IF EXISTS admin_users OWNER TO ${DB_USER}; ALTER TABLE IF EXISTS xray_clients OWNER TO ${DB_USER}; ALTER SEQUENCE IF EXISTS admin_users_id_seq OWNER TO ${DB_USER}; GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER}; GRANT ALL PRIVILEGES ON SCHEMA public TO ${DB_USER}; GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ${DB_USER}; GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${DB_USER}; \"" postgres info " PostgreSQL database '${DB_NAME}' ready" # ── 8. Config files ────────────────────────────────────────────────────────── info "[8/10] Generating config files…" # Admin token ADMIN_TOKEN=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 48 || true) if [[ ${#ADMIN_TOKEN} -lt 48 ]]; then ADMIN_TOKEN=$(openssl rand -hex 24 2>/dev/null || date +%s%N) fi # Admin panel login password. The web panel login is username/password; # ADMIN_TOKEN is only for bearer-token API access and is not the login password. ADMIN_PASSWORD=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 20 || true) if [[ ${#ADMIN_PASSWORD} -lt 20 ]]; then ADMIN_PASSWORD=$(openssl rand -hex 10 2>/dev/null || date +%s%N) fi ADMIN_PASSWORD_HASH=$(printf '%s' "${ADMIN_PASSWORD}" | sha256sum | awk '{print $1}') su -c "psql -d ${DB_NAME}" postgres < "$INSTALL_DIR/.env" </dev/null \ || curl -sf --max-time 5 https://api.ipify.org 2>/dev/null \ || hostname -I | awk '{print $1}') # config.json cat > "$INSTALL_DIR/config.json" </dev/null \ || python3 -c "import uuid; print(uuid.uuid4())" 2>/dev/null \ || echo "11111111-2222-3333-4444-555555555555") # xray_config.json (default VLESS + SOCKS inbounds — no geoip routing needed) cat > "$INSTALL_DIR/xray_config.json" < 5300)…" cat > /usr/local/sbin/sshpanel-dnstt-redirect.sh <<'EOS' #!/bin/bash set -euo pipefail DNS_UPSTREAM="${DNS_UPSTREAM:-1.1.1.1}" DNSTT_PORT="${DNSTT_PORT:-5300}" # Free port 53 on systemd-resolved based systems and keep outbound DNS working. if command -v systemctl >/dev/null 2>&1; then systemctl disable --now systemd-resolved.service >/dev/null 2>&1 || true fi rm -f /etc/resolv.conf printf 'nameserver %s\n' "$DNS_UPSTREAM" > /etc/resolv.conf # Open DNS/UDP in common Linux firewalls when they are active. if command -v ufw >/dev/null 2>&1; then ufw allow 53/udp >/dev/null 2>&1 || true fi if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then firewall-cmd --permanent --add-port=53/udp >/dev/null 2>&1 || true firewall-cmd --reload >/dev/null 2>&1 || true fi add_iptables_rule() { local bin="$1" chain="$2" "$bin" -t nat -C "$chain" -p udp --dport 53 -j REDIRECT --to-ports "$DNSTT_PORT" 2>/dev/null \ || "$bin" -t nat -A "$chain" -p udp --dport 53 -j REDIRECT --to-ports "$DNSTT_PORT" } if command -v iptables >/dev/null 2>&1; then add_iptables_rule iptables PREROUTING fi if command -v ip6tables >/dev/null 2>&1; then add_iptables_rule ip6tables PREROUTING || true fi # Fallback for minimal systems where only nft is present. if ! command -v iptables >/dev/null 2>&1 && command -v nft >/dev/null 2>&1; then nft add table inet sshpanel_nat 2>/dev/null || true nft 'add chain inet sshpanel_nat prerouting { type nat hook prerouting priority dstnat; policy accept; }' 2>/dev/null || true nft list chain inet sshpanel_nat prerouting 2>/dev/null | grep -q "udp dport 53 redirect to :$DNSTT_PORT" \ || nft add rule inet sshpanel_nat prerouting udp dport 53 redirect to :"$DNSTT_PORT" fi EOS chmod +x /usr/local/sbin/sshpanel-dnstt-redirect.sh cat > /etc/systemd/system/sshpanel-dnstt-redirect.service <<'EOF' [Unit] Description=SSH Panel DNSTT DNS redirect (UDP 53 to 5300) After=network.target Before=sshpanel.service [Service] Type=oneshot ExecStart=/usr/local/sbin/sshpanel-dnstt-redirect.sh RemainAfterExit=yes [Install] WantedBy=multi-user.target EOF systemctl daemon-reload systemctl enable --now sshpanel-dnstt-redirect.service || warn "DNSTT DNS redirect service failed; check: journalctl -u sshpanel-dnstt-redirect -e" info " DNSTT DNS redirect installed: UDP 53 -> 5300" # ── 10. Systemd service ────────────────────────────────────────────────────── info "[10/10] Creating systemd service '${SERVICE_NAME}'…" cat > "/etc/systemd/system/${SERVICE_NAME}.service" <