#!/usr/bin/env bash # # ZiraCP installer — single self-contained script. # # curl -fsSL https://get.ziracp.com | bash # # Installs the ZiraCP hosting control panel on AlmaLinux 9 (RHEL 9 family). # Idempotent: re-running upgrades/repairs an existing install in place. # # This v0 installs the panel core (agent + zCenter + zPanel) and the # systemd/firewall wiring. The full hosting stack (nginx, PHP, MariaDB, mail, # DNS, …) is layered by later phases. # # Environment overrides (all optional): # ZIRACP_HUB_PORT admin panel port (default 7443) # ZIRACP_PANEL_PORT user panel port (default 7444) # ZIRACP_HOSTNAME panel hostname (default: system hostname) # ADMIN_USER initial admin username (default admin) # ADMIN_PASSWORD initial admin password (default: random) # ZIRACP_VERSION version/channel to fetch (default stable) # ZIRACP_BASE_URL artifact base URL (default https://get.ziracp.com) # ZIRACP_LOCAL_BINARY use a local ziracpd binary instead of downloading (testing) # ZIRACP_LOCAL_CLI use a local zira CLI binary (testing) # ZIRACP_SKIP_OS_CHECK set to 1 to bypass the AlmaLinux 9 requirement (testing) # INTERACTIVE set to "no" for fully unattended install # ZIRACP_SELINUX "permissive" to set SELinux permissive during install # set -euo pipefail # ------------------------------------------------------------------ constants ZIRACP_USER="ziracp" ZIRACP_GROUP="ziracp" BIN_DIR="/usr/local/bin" CONFIG_DIR="/etc/ziracp" CONFIG_FILE="${CONFIG_DIR}/config.yaml" DATA_DIR="/var/lib/ziracp" UNIT_DIR="/etc/systemd/system" LOG_FILE="/root/ziracp-install-$(date -u +%Y%m%d-%H%M%S).log" HUB_PORT="${ZIRACP_HUB_PORT:-7443}" PANEL_PORT="${ZIRACP_PANEL_PORT:-7444}" VERSION="${ZIRACP_VERSION:-stable}" BASE_URL="${ZIRACP_BASE_URL:-https://get.ziracp.com}" ADMIN_USER="${ADMIN_USER:-admin}" INTERACTIVE="${INTERACTIVE:-yes}" RED=$'\033[31m'; GREEN=$'\033[32m'; YELLOW=$'\033[33m'; BLUE=$'\033[34m'; BOLD=$'\033[1m'; NC=$'\033[0m' # Temp workdir for downloads; removed by the EXIT trap set inside main() (main # runs in a pipeline subshell via `main | tee`, so the trap must live there). WORKTMP="" cleanup() { [ -n "${WORKTMP:-}" ] && rm -rf "$WORKTMP" 2>/dev/null || true; } info() { printf "%s[ziracp]%s %s\n" "$BLUE" "$NC" "$*"; } ok() { printf "%s[ok]%s %s\n" "$GREEN" "$NC" "$*"; } warn() { printf "%s[warn]%s %s\n" "$YELLOW" "$NC" "$*"; } die() { printf "%s[error]%s %s\n" "$RED" "$NC" "$*" >&2; exit 1; } # ------------------------------------------------------------------ preflight require_root() { [ "$(id -u)" -eq 0 ] || die "This installer must be run as root (not sudo-wrapped in a subshell)." } detect_os() { [ -r /etc/os-release ] || die "Cannot read /etc/os-release; unsupported system." # shellcheck disable=SC1091 . /etc/os-release OS_ID="${ID:-unknown}" OS_VER="${VERSION_ID:-0}" if [ "${ZIRACP_SKIP_OS_CHECK:-0}" = "1" ]; then warn "OS check skipped (ZIRACP_SKIP_OS_CHECK=1): ${OS_ID} ${OS_VER}" return fi case "$OS_ID" in almalinux|rhel|rocky|centos) ;; *) die "Unsupported OS '${OS_ID}'. ZiraCP requires AlmaLinux 9 (RHEL 9 family)." ;; esac case "$OS_VER" in 9|9.*) ;; *) die "Unsupported version '${OS_VER}'. ZiraCP requires major version 9." ;; esac ok "Detected ${PRETTY_NAME:-$OS_ID $OS_VER}" } detect_arch() { local m; m="$(uname -m)" case "$m" in x86_64) ARCH="amd64" ;; aarch64) ARCH="arm64" ;; *) die "Unsupported architecture '${m}'. ZiraCP supports x86_64 and aarch64." ;; esac ok "Architecture: ${m} (${ARCH})" } check_ports() { local busy="" for p in "$HUB_PORT" "$PANEL_PORT"; do if ss -ltn "( sport = :$p )" 2>/dev/null | grep -q LISTEN; then busy="$busy $p" fi done if [ -n "$busy" ] && [ "$UPGRADE" != "yes" ]; then die "Port(s)$busy already in use. Set ZIRACP_HUB_PORT / ZIRACP_PANEL_PORT to free ports." fi } detect_existing() { UPGRADE="no" if [ -f "$CONFIG_FILE" ] && [ -x "${BIN_DIR}/ziracpd" ]; then UPGRADE="yes" info "Existing installation detected — running in upgrade/repair mode." fi } # ------------------------------------------------------------------ helpers have() { command -v "$1" >/dev/null 2>&1; } randb64() { head -c 32 /dev/urandom | base64 | tr -d '\n'; } randpw() { head -c 18 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 20; } download() { # download local url="$1" dest="$2" case "$url" in https://*) ;; *) die "Refusing non-HTTPS download URL: ${url}" ;; esac if have curl; then curl -fsSL --proto '=https' --tlsv1.2 "$url" -o "$dest" elif have wget; then wget --https-only -qO "$dest" "$url" else die "Neither curl nor wget is available to download components." fi } # ------------------------------------------------------------------ install steps create_user() { if ! getent group "$ZIRACP_GROUP" >/dev/null; then groupadd --system "$ZIRACP_GROUP" ok "Created group ${ZIRACP_GROUP}" fi if ! getent passwd "$ZIRACP_USER" >/dev/null; then useradd --system --gid "$ZIRACP_GROUP" --home-dir "$DATA_DIR" \ --shell /sbin/nologin --comment "ZiraCP service account" "$ZIRACP_USER" ok "Created user ${ZIRACP_USER}" fi } create_dirs() { # Config dir: root-owned, group ziracp may traverse to read the config file. mkdir -p "$CONFIG_DIR" chown "root:${ZIRACP_GROUP}" "$CONFIG_DIR" chmod 0750 "$CONFIG_DIR" # Data dirs: owned by the unprivileged service account. for d in "$DATA_DIR" "${DATA_DIR}/tls" "${DATA_DIR}/snapshots"; do mkdir -p "$d" chown "${ZIRACP_USER}:${ZIRACP_GROUP}" "$d" chmod 0750 "$d" done } install_binary() { # Cleaned by the script-level EXIT trap. A RETURN trap would leak into later # functions and, under `set -u`, fire on an unset var — so we avoid it. WORKTMP="$(mktemp -d)" local tmp="$WORKTMP" if [ -n "${ZIRACP_LOCAL_BINARY:-}" ]; then [ -f "$ZIRACP_LOCAL_BINARY" ] || die "ZIRACP_LOCAL_BINARY not found: $ZIRACP_LOCAL_BINARY" install -m 0755 "$ZIRACP_LOCAL_BINARY" "${BIN_DIR}/ziracpd" ok "Installed ziracpd from local binary" else local url="${BASE_URL}/${VERSION}/ziracpd-linux-${ARCH}" info "Downloading ziracpd from ${url}" download "$url" "${tmp}/ziracpd" # Fail closed: a missing checksum file must never turn an unverified # download into an executable installation. Local-binary mode above is the # explicit escape hatch for development and offline smoke tests. download "${BASE_URL}/${VERSION}/SHA256SUMS" "${tmp}/SHA256SUMS" \ || die "SHA256SUMS is unavailable; refusing to install an unverified binary" local want; want="$(grep "ziracpd-linux-${ARCH}" "${tmp}/SHA256SUMS" | awk '{print $1}')" local got; got="$(sha256sum "${tmp}/ziracpd" | awk '{print $1}')" [ -n "$want" ] && [ "$want" = "$got" ] || die "SHA256 mismatch for ziracpd (want=$want got=$got)" ok "Verified ziracpd checksum" install -m 0755 "${tmp}/ziracpd" "${BIN_DIR}/ziracpd" ok "Installed ziracpd" fi # Optional CLI. if [ -n "${ZIRACP_LOCAL_CLI:-}" ] && [ -f "$ZIRACP_LOCAL_CLI" ]; then install -m 0755 "$ZIRACP_LOCAL_CLI" "${BIN_DIR}/zira" ok "Installed zira CLI" fi } write_config() { if [ "$UPGRADE" = "yes" ]; then info "Keeping existing config at ${CONFIG_FILE}" return fi local hostname session_key audit_key hostname="${ZIRACP_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}" session_key="$(randb64)" audit_key="$(randb64)" umask 027 cat > "$CONFIG_FILE" < "${UNIT_DIR}/ziracp-agent.service" <<'EOF' [Unit] Description=ZiraCP privileged root agent (ziracp-agent) After=network-online.target Wants=network-online.target [Service] Type=simple ExecStart=/usr/local/bin/ziracpd agent --config /etc/ziracp/config.yaml Restart=always RestartSec=3 RuntimeDirectory=ziracp RuntimeDirectoryMode=0755 LimitNOFILE=65536 OOMScoreAdjust=-500 [Install] WantedBy=multi-user.target EOF for plane in hub panel; do cat > "${UNIT_DIR}/ziracp-${plane}.service" </dev/null; then firewall-cmd --quiet --permanent --add-port="${HUB_PORT}/tcp" || true firewall-cmd --quiet --permanent --add-port="${PANEL_PORT}/tcp" || true firewall-cmd --quiet --reload || true ok "Opened firewall ports ${HUB_PORT}, ${PANEL_PORT}" else warn "firewalld not active; ensure ports ${HUB_PORT}/${PANEL_PORT} are reachable" fi } apply_selinux() { have getenforce || return 0 local mode; mode="$(getenforce 2>/dev/null || echo Disabled)" if [ "${ZIRACP_SELINUX:-}" = "permissive" ] && [ "$mode" = "Enforcing" ]; then setenforce 0 || true warn "SELinux set to permissive for this session (ZIRACP_SELINUX=permissive)" else info "SELinux mode: ${mode} (policy module ships in a later phase)" fi } run_migrations() { runuser -u "$ZIRACP_USER" -- "${BIN_DIR}/ziracpd" migrate --config "$CONFIG_FILE" ok "Applied database migrations" } bootstrap_admin() { if [ "$UPGRADE" = "yes" ]; then info "Skipping admin bootstrap (existing install)" ADMIN_PASSWORD="" return fi if [ -z "${ADMIN_PASSWORD:-}" ]; then ADMIN_PASSWORD="$(randpw)" ADMIN_GENERATED="yes" fi ZIRACP_ADMIN_PASSWORD="$ADMIN_PASSWORD" runuser -u "$ZIRACP_USER" -- \ "${BIN_DIR}/ziracpd" bootstrap --config "$CONFIG_FILE" --username "$ADMIN_USER" >/dev/null ok "Created admin user '${ADMIN_USER}'" } start_services() { systemctl enable --now ziracp-agent.service >/dev/null 2>&1 || systemctl restart ziracp-agent.service systemctl enable --now ziracp-hub.service >/dev/null 2>&1 || systemctl restart ziracp-hub.service systemctl enable --now ziracp-panel.service >/dev/null 2>&1 || systemctl restart ziracp-panel.service # On upgrade, force a restart to pick up the new binary. if [ "$UPGRADE" = "yes" ]; then systemctl restart ziracp-agent.service ziracp-hub.service ziracp-panel.service fi ok "Started services" } health_check() { local url="https://127.0.0.1:${HUB_PORT}/api/v1/health" for _ in $(seq 1 30); do if curl -fsSk "$url" >/dev/null 2>&1; then ok "Health check passed (${url})" return 0 fi sleep 1 done warn "Health check did not pass within 30s; check: journalctl -u ziracp-hub -n 50" return 1 } write_uninstaller() { cat > "${BIN_DIR}/ziracp-uninstall.sh" </dev/null || true rm -f ${UNIT_DIR}/ziracp-agent.service ${UNIT_DIR}/ziracp-hub.service ${UNIT_DIR}/ziracp-panel.service systemctl daemon-reload rm -f ${BIN_DIR}/ziracpd ${BIN_DIR}/zira if command -v firewall-cmd >/dev/null 2>&1; then firewall-cmd --permanent --remove-port=${HUB_PORT}/tcp 2>/dev/null || true firewall-cmd --permanent --remove-port=${PANEL_PORT}/tcp 2>/dev/null || true firewall-cmd --reload 2>/dev/null || true fi if [ "\$PURGE" = "yes" ]; then rm -rf ${CONFIG_DIR} ${DATA_DIR} userdel ${ZIRACP_USER} 2>/dev/null || true groupdel ${ZIRACP_GROUP} 2>/dev/null || true echo "ZiraCP fully purged." else echo "ZiraCP removed. Config and data kept in ${CONFIG_DIR} and ${DATA_DIR} (use --purge to remove)." fi rm -f ${BIN_DIR}/ziracp-uninstall.sh EOF chmod 0755 "${BIN_DIR}/ziracp-uninstall.sh" ok "Wrote uninstaller to ${BIN_DIR}/ziracp-uninstall.sh" } print_summary() { local ip; ip="$(hostname -I 2>/dev/null | awk '{print $1}')"; ip="${ip:-}" echo printf "%s========================================================%s\n" "$BOLD" "$NC" printf "%s ZiraCP installed successfully%s\n" "$GREEN$BOLD" "$NC" printf "%s========================================================%s\n" "$BOLD" "$NC" echo printf " zCenter (admin): %shttps://%s:%s%s\n" "$BOLD" "$ip" "$HUB_PORT" "$NC" printf " zPanel (user): %shttps://%s:%s%s\n" "$BOLD" "$ip" "$PANEL_PORT" "$NC" echo if [ "$UPGRADE" != "yes" ]; then printf " Admin username: %s%s%s\n" "$BOLD" "$ADMIN_USER" "$NC" if [ "${ADMIN_GENERATED:-no}" = "yes" ]; then printf " Admin password: %s%s%s\n" "$BOLD" "$ADMIN_PASSWORD" "$NC" printf " %s(store this now — it is not shown again)%s\n" "$YELLOW" "$NC" else printf " Admin password: %s(as provided)%s\n" "$YELLOW" "$NC" fi else printf " Upgrade complete. Existing credentials unchanged.\n" fi echo printf " The panel uses a self-signed certificate; your browser will warn\n" printf " until an ACME certificate is issued for the hostname.\n" echo printf " Uninstall: %sziracp-uninstall.sh%s Log: %s\n" "$BOLD" "$NC" "$LOG_FILE" echo } # ------------------------------------------------------------------ main main() { trap cleanup EXIT info "Starting ZiraCP installation" require_root detect_os detect_arch detect_existing check_ports apply_selinux create_user create_dirs install_binary write_config install_units open_firewall run_migrations bootstrap_admin start_services write_uninstaller health_check || true print_summary } # Run everything, teeing a full transcript to the log file. main "$@" 2>&1 | tee "$LOG_FILE"