#!/usr/bin/env bash set -Eeuo pipefail RED='\033[0;31m' GREEN='\033[0;32m' BLUE='\033[0;34m' YELLOW='\033[1;33m' NC='\033[0m' APP_NAME="home-server" INSTALL_DIR="${HOMEIO_INSTALL_DIR:-/opt/home-server}" ENV_FILE="${HOMEIO_ENV_FILE:-${INSTALL_DIR}/.env}" SERVICE_NAME="${HOMEIO_SERVICE_NAME:-home-server}" DBUS_SERVICE_NAME="${HOMEIO_DBUS_SERVICE_NAME:-home-server-dbus}" UPLOAD_SERVICE_NAME="${HOMEIO_UPLOAD_SERVICE_NAME:-home-server-upload}" APP_PORT="${HOMEIO_APP_PORT:-${HOMEIO_PORT:-12026}}" PUBLIC_PORT="${HOMEIO_PUBLIC_PORT:-80}" NGINX_SITE_NAME="${HOMEIO_NGINX_SITE_NAME:-home-server}" REPO_URL="${HOMEIO_REPO_URL:-https://github.com/doctor-io/homeio.git}" REPO_BRANCH="${HOMEIO_REPO_BRANCH:-main}" # The tarball install path is gone; a pinned version is now a git tag. if [[ -n "${HOMEIO_RELEASE_TAG:-}" || -n "${HOMEIO_RELEASE_TARBALL_URL:-}" ]]; then echo "[!] HOMEIO_RELEASE_TAG and HOMEIO_RELEASE_TARBALL_URL are no longer supported." >&2 echo "[!] Homeio installs with git. To pin a version, set HOMEIO_REPO_BRANCH to its tag, for example HOMEIO_REPO_BRANCH=v1.10.0." >&2 exit 1 fi GO_VERSION="${GO_VERSION:-1.23.4}" # SHA-256 of drizzle/0000_slippery_black_queen.sql — used to seed the migration journal # for legacy installs that were bootstrapped with drizzle push (no __drizzle_migrations table). BASELINE_MIGRATION_HASH="e10db77d840d8dc1f42a13ee9de57615a2fb7c46d9525e0d1e7a7f42dee72eaf" BASELINE_MIGRATION_TS="1776413023965" HOMEIO_CREATE_BACKUP="${HOMEIO_CREATE_BACKUP:-true}" HOMEIO_BACKUP_ROOT="${HOMEIO_BACKUP_ROOT:-/var/backups/home-server/releases}" HOMEIO_HEALTHCHECK_URL="${HOMEIO_HEALTHCHECK_URL:-http://127.0.0.1:${APP_PORT}/api/health}" HOMEIO_HEALTHCHECK_RETRIES="${HOMEIO_HEALTHCHECK_RETRIES:-60}" HOMEIO_HEALTHCHECK_DELAY_SEC="${HOMEIO_HEALTHCHECK_DELAY_SEC:-3}" SERVICE_UNIT="${SERVICE_NAME}" if [[ "${SERVICE_UNIT}" != *.service ]]; then SERVICE_UNIT="${SERVICE_UNIT}.service" fi DBUS_SERVICE_UNIT="${DBUS_SERVICE_NAME}" if [[ "${DBUS_SERVICE_UNIT}" != *.service ]]; then DBUS_SERVICE_UNIT="${DBUS_SERVICE_UNIT}.service" fi UPLOAD_SERVICE_UNIT="${UPLOAD_SERVICE_NAME}" if [[ "${UPLOAD_SERVICE_UNIT}" != *.service ]]; then UPLOAD_SERVICE_UNIT="${UPLOAD_SERVICE_UNIT}.service" fi BACKUP_DIR="" PREVIOUS_GIT_REV="" ROLLBACK_READY="false" ROLLBACK_DONE="false" PREVIOUS_LOCK_HASH="" NEW_LOCK_HASH="" print_status() { echo -e "${GREEN}[+]${NC} $1"; } print_error() { echo -e "${RED}[!]${NC} $1" >&2; } print_warn() { echo -e "${YELLOW}[*]${NC} $1"; } command_exists() { command -v "$1" >/dev/null 2>&1 } # The tag of the release GitHub marks as Latest, or nothing when it cannot be # read (offline, rate-limited, no release yet). Never fails. latest_release_tag() { local repo_path="${REPO_URL#https://github.com/}" repo_path="${repo_path%.git}" local json tag json="$(curl -fsSL --max-time 20 -H "Accept: application/vnd.github+json" \ "https://api.github.com/repos/${repo_path}/releases/latest" 2>/dev/null)" || return 0 tag="$(printf '%s\n' "${json}" | sed -n 's/^[[:space:]]*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/p')" if [[ "${tag}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then printf '%s\n' "${tag}" fi } # Without HOMEIO_REPO_BRANCH, install the latest published release rather than # whatever main holds between a merge and its release. git fetches a tag the # same way as a branch. resolve_repo_ref() { [[ -n "${HOMEIO_REPO_BRANCH:-}" ]] && return 0 local tag tag="$(latest_release_tag)" if [[ -n "${tag}" ]]; then REPO_BRANCH="${tag}" print_status "Latest release: ${tag}" else print_warn "Could not read the latest release from GitHub; using the ${REPO_BRANCH} branch." fi } require_root() { [[ "${EUID}" -eq 0 ]] || { print_error "Run this updater as root (for example: sudo bash update.sh)."; exit 1; } } hash_file() { local file="${1}" [[ -f "${file}" ]] || return 0 sha256sum "${file}" | awk '{print $1}' } check_prerequisites() { print_status "Checking prerequisites..." command_exists apt-get || { print_error "apt-get is required."; exit 1; } command_exists systemctl || { print_error "systemd is required but systemctl is not available."; exit 1; } command_exists rsync || { print_error "rsync is required."; exit 1; } command_exists curl || { print_error "curl is required."; exit 1; } command_exists npm || { print_error "npm is required."; exit 1; } [[ -d "${INSTALL_DIR}" ]] || { print_error "Install directory not found: ${INSTALL_DIR}"; exit 1; } [[ -f "${ENV_FILE}" ]] || { print_error "Environment file not found: ${ENV_FILE}"; exit 1; } } # Debian 12+ no longer ships rsyslog, so /var/log/auth.log does not exist and # fail2ban's default backend aborts on the sshd jail. Read the journal instead. # Kept apart from jail.d/homeio.local, which the app rewrites from Settings. configure_fail2ban_backend() { mkdir -p /etc/fail2ban/jail.d cat > /etc/fail2ban/jail.d/00-homeio-backend.local <<'CONF' [DEFAULT] backend = systemd [sshd] backend = systemd CONF } ensure_security_dependencies() { print_status "Ensuring security dependencies (ufw, fail2ban)..." local packages=() command_exists ufw || packages+=("ufw") command_exists fail2ban-client || packages+=("fail2ban") dpkg-query -W -f='${Status}' python3-systemd 2>/dev/null | grep -q "install ok installed" || packages+=("python3-systemd") if (( ${#packages[@]} > 0 )); then apt-get update -qq >/dev/null apt-get install -y -qq "${packages[@]}" >/dev/null fi configure_fail2ban_backend systemctl enable --now fail2ban >/dev/null 2>&1 || true systemctl restart fail2ban >/dev/null 2>&1 || true } capture_current_state() { PREVIOUS_LOCK_HASH="$(hash_file "${INSTALL_DIR}/package-lock.json" || true)" if [[ -d "${INSTALL_DIR}/.git" ]]; then PREVIOUS_GIT_REV="$(git -C "${INSTALL_DIR}" rev-parse HEAD || true)" fi } create_backup() { if [[ "${HOMEIO_CREATE_BACKUP}" != "true" ]]; then print_warn "Skipping code backup (HOMEIO_CREATE_BACKUP=${HOMEIO_CREATE_BACKUP})." return fi local ts ts="$(date '+%Y%m%d-%H%M%S')" BACKUP_DIR="${HOMEIO_BACKUP_ROOT}/${ts}" mkdir -p "${BACKUP_DIR}" print_status "Creating backup at ${BACKUP_DIR}..." rsync -a \ --delete \ --exclude ".git" \ --exclude "node_modules" \ --exclude ".next" \ "${INSTALL_DIR}/" "${BACKUP_DIR}/" } deploy_from_git() { if [[ -d "${INSTALL_DIR}/.git" ]]; then print_status "Updating from git (${REPO_BRANCH})..." git -C "${INSTALL_DIR}" fetch --depth=1 origin "${REPO_BRANCH}" --quiet git -C "${INSTALL_DIR}" checkout --force FETCH_HEAD --quiet else print_warn "No git repository at ${INSTALL_DIR}. Cloning fresh copy from ${REPO_URL}..." local tmp_dir tmp_dir="$(mktemp -d)" local clone_branch="${REPO_BRANCH:-main}" git clone --depth=1 --branch "${clone_branch}" --quiet "${REPO_URL}" "${tmp_dir}/repo" rsync -a \ --delete \ --exclude ".git" \ --exclude "node_modules" \ --exclude ".next" \ --exclude ".env" \ --exclude ".env.local" \ "${tmp_dir}/repo/" "${INSTALL_DIR}/" rm -rf "${tmp_dir}" print_status "Fresh clone deployed." fi } install_dependencies_if_needed() { NEW_LOCK_HASH="$(hash_file "${INSTALL_DIR}/package-lock.json" || true)" if [[ "${PREVIOUS_LOCK_HASH}" != "${NEW_LOCK_HASH}" || ! -d "${INSTALL_DIR}/node_modules" ]]; then print_status "Dependency changes detected. Installing npm dependencies..." cd "${INSTALL_DIR}" && npm ci --silent --no-audit --no-fund 1>/dev/null else print_status "No dependency changes detected. Skipping npm ci." fi } run_database_migrations() { print_status "Syncing database schema..." if [[ ! -f "${ENV_FILE}" ]]; then print_error "Environment file not found: ${ENV_FILE}. Cannot sync schema." exit 1 fi (set -a && source "${ENV_FILE}" && set +a && cd "${INSTALL_DIR}" && npm run db:init) \ || { print_error "Database schema sync failed. Rolling back."; exit 1; } print_status "Database schema synced successfully." } install_go() { local arch go_arch case "$(uname -m)" in x86_64) arch="amd64" ;; aarch64) arch="arm64" ;; *) print_error "Unsupported architecture: $(uname -m)"; exit 1 ;; esac go_arch="${arch}" if command -v go >/dev/null 2>&1; then local current current="$(go version 2>/dev/null | awk '{print $3}' | sed 's/^go//')" if [[ "${current}" == "${GO_VERSION}" ]]; then print_status "Go ${GO_VERSION} already installed." export PATH="/usr/local/go/bin:${PATH}" return fi print_status "Existing Go ${current} detected; installing Go ${GO_VERSION}." fi print_status "Installing Go ${GO_VERSION}..." local tarball="go${GO_VERSION}.linux-${go_arch}.tar.gz" curl -fsSL "https://go.dev/dl/${tarball}" -o "/tmp/${tarball}" rm -rf /usr/local/go tar -C /usr/local -xzf "/tmp/${tarball}" rm -f "/tmp/${tarball}" if [[ ! -f /etc/profile.d/go.sh ]]; then echo 'export PATH="/usr/local/go/bin:$PATH"' > /etc/profile.d/go.sh fi export PATH="/usr/local/go/bin:${PATH}" print_status "Go ${GO_VERSION} installed." } build_upload_server() { print_status "Building upload server (Go)..." export PATH="/usr/local/go/bin:${PATH}" # systemd-run starts the updater in a sparse environment with no $HOME. # `go build` refuses to run without GOCACHE/XDG_CACHE_HOME/HOME, so set # sane defaults here. Keeps build_upload_server safe regardless of how # update.sh was invoked. export HOME="${HOME:-/root}" export GOCACHE="${GOCACHE:-${HOME}/.cache/go-build}" export GOPATH="${GOPATH:-${HOME}/go}" local src="${INSTALL_DIR}/services/upload-server" [[ -d "${src}" ]] || { print_warn "Upload server source not found at ${src}; skipping."; return; } mkdir -p "${INSTALL_DIR}/bin" mkdir -p "${GOCACHE}" "${GOPATH}" local build_log build_log="$(mktemp)" if ! (cd "${src}" && env HOME="${HOME}" GOCACHE="${GOCACHE}" GOPATH="${GOPATH}" go build -o "${INSTALL_DIR}/bin/upload-server" .) >"${build_log}" 2>&1; then print_error "Failed to build upload server." print_error "Last output:" tail -10 "${build_log}" >&2 rm -f "${build_log}" exit 1 fi rm -f "${build_log}" print_status "Upload server built: ${INSTALL_DIR}/bin/upload-server" } stop_upload_server() { if systemctl cat "${UPLOAD_SERVICE_UNIT}" >/dev/null 2>&1; then print_status "Stopping ${UPLOAD_SERVICE_NAME} service..." systemctl stop "${UPLOAD_SERVICE_UNIT}" >/dev/null 2>&1 || true fi } restart_upload_server_service() { local unit_file="/etc/systemd/system/${UPLOAD_SERVICE_UNIT}" local is_new=false [[ ! -f "${unit_file}" ]] && is_new=true print_status "Writing ${UPLOAD_SERVICE_NAME} unit file..." cat >"${unit_file}" <"${build_log}" 2>&1); then print_error "Build failed." print_error "Last output:" tail -20 "${build_log}" >&2 rm -f "${build_log}" exit 1 fi rm -f "${build_log}" } stop_service() { print_status "Stopping ${SERVICE_NAME} service..." systemctl stop "${SERVICE_UNIT}" >/dev/null 2>&1 || true } ensure_service_shutdown_behavior() { local unit_name="${SERVICE_UNIT%.service}" local drop_in_dir="/etc/systemd/system/${unit_name}.service.d" local drop_in_file="${drop_in_dir}/10-shutdown.conf" print_status "Ensuring clean ${SERVICE_NAME} shutdown settings..." mkdir -p "${drop_in_dir}" cat >"${drop_in_file}" <<'EOF' [Service] KillMode=control-group TimeoutStopSec=30s EOF systemctl daemon-reload } start_service() { print_status "Starting ${SERVICE_NAME} service..." systemctl daemon-reload systemctl start "${SERVICE_UNIT}" } restart_dbus_helper_service() { if ! systemctl cat "${DBUS_SERVICE_UNIT}" >/dev/null 2>&1; then print_warn "DBus helper unit ${DBUS_SERVICE_UNIT} not found; skipping helper restart." return fi print_status "Restarting DBus helper service..." systemctl daemon-reload systemctl enable --now "${DBUS_SERVICE_UNIT}" systemctl restart "${DBUS_SERVICE_UNIT}" } # Debian ships nginx with 768 connections per worker. Each open Homeio desktop # holds three live streams, and each stream uses two connections (visitor side # and Homeio side), so the default caps a server at a few hundred desktops. # Only these two limits are raised; the rest of nginx.conf is left alone. tune_nginx_limits() { local main_conf="/etc/nginx/nginx.conf" local worker_connections=4096 local worker_rlimit_nofile=16384 [[ -f "${main_conf}" ]] || return 0 local current raise_connections=false add_rlimit=false current="$(sed -nE 's/^\s*worker_connections\s+([0-9]+);.*/\1/p' "${main_conf}" | head -n1)" if [[ -n "${current}" ]] && (( current < worker_connections )); then raise_connections=true fi if ! grep -qE '^\s*worker_rlimit_nofile\s' "${main_conf}"; then add_rlimit=true fi if [[ "${raise_connections}" == false && "${add_rlimit}" == false ]]; then return 0 fi cp "${main_conf}" "${main_conf}.homeio-bak" if [[ "${raise_connections}" == true ]]; then sed -i -E "s/^(\s*worker_connections\s+)[0-9]+;/\1${worker_connections};/" "${main_conf}" fi if [[ "${add_rlimit}" == true ]]; then sed -i -E "/^\s*worker_processes\s/a worker_rlimit_nofile ${worker_rlimit_nofile};" "${main_conf}" fi if ! nginx -t >/dev/null 2>&1; then print_warn "nginx rejected the raised connection limits; keeping its previous settings." mv "${main_conf}.homeio-bak" "${main_conf}" return 0 fi rm -f "${main_conf}.homeio-bak" print_status "nginx now accepts ${worker_connections} connections per worker." } configure_reverse_proxy() { if ! command_exists nginx; then print_warn "nginx is not installed; skipping reverse proxy update." return fi local nginx_conf="/etc/nginx/sites-available/${NGINX_SITE_NAME}.conf" local nginx_enabled="/etc/nginx/sites-enabled/${NGINX_SITE_NAME}.conf" local maintenance_root="/var/lib/homeio-maintenance" local maintenance_file="${maintenance_root}/__homeio_unavailable.html" print_status "Configuring nginx reverse proxy on :${PUBLIC_PORT} -> 127.0.0.1:${APP_PORT}..." mkdir -p "${maintenance_root}" cat >"${maintenance_file}" <<'EOF' Homeio is restarting

Homeio is restarting

The update is being applied. This page will automatically reload when /api/health is available again.

Waiting for Homeio...
The update is taking longer than expected — it may still be building, or an error may have occurred. Check the update log for details: sudo tail -f /var/log/homeio-self-update.log To manually restart the service: sudo systemctl restart home-server
EOF cat >"${nginx_conf}" </dev/null 2>&1 || true tune_nginx_limits nginx -t systemctl enable --now nginx systemctl reload nginx } healthcheck() { local attempts=0 while (( attempts < HOMEIO_HEALTHCHECK_RETRIES )); do if curl -fsS --max-time 5 "${HOMEIO_HEALTHCHECK_URL}" >/dev/null; then return 0 fi attempts=$((attempts + 1)) sleep "${HOMEIO_HEALTHCHECK_DELAY_SEC}" done return 1 } rollback_release() { trap - ERR ROLLBACK_DONE="true" print_error "Update failed! Attempting rollback..." stop_upload_server stop_service if [[ -n "${BACKUP_DIR}" && -d "${BACKUP_DIR}" ]]; then print_status "Restoring backup from ${BACKUP_DIR}..." rsync -a --delete "${BACKUP_DIR}/" "${INSTALL_DIR}/" elif [[ -n "${PREVIOUS_GIT_REV}" && -d "${INSTALL_DIR}/.git" ]]; then print_status "Restoring previous git revision ${PREVIOUS_GIT_REV}..." git -C "${INSTALL_DIR}" checkout --force "${PREVIOUS_GIT_REV}" --quiet else print_error "No rollback source available."; exit 1; fi print_status "Rebuilding application after rollback..." cd "${INSTALL_DIR}" && npm ci --no-audit --no-fund cd "${INSTALL_DIR}" && npm run build build_upload_server || true start_service restart_upload_server_service || true if healthcheck; then print_status "Rollback succeeded." else print_error "Rollback failed health check. Manual intervention required."; exit 1; fi } on_error() { local line_no="${1}" local exit_code="${2}" if [[ "${ROLLBACK_READY}" == "true" && "${ROLLBACK_DONE}" != "true" ]]; then print_error "Update failed at line ${line_no} (exit ${exit_code})." rollback_release || true fi exit "${exit_code}" } print_summary() { local host primary_ip local_url network_url host="$(hostnamectl --static 2>/dev/null || hostname)" primary_ip="$(hostname -I 2>/dev/null | awk '{print $1}')" if [[ "${PUBLIC_PORT}" == "80" ]]; then local_url="http://${host}.local" else local_url="http://${host}.local:${PUBLIC_PORT}" fi if [[ -n "${primary_ip}" ]]; then if [[ "${PUBLIC_PORT}" == "80" ]]; then network_url="http://${primary_ip}" else network_url="http://${primary_ip}:${PUBLIC_PORT}" fi fi echo "" echo -e "${GREEN}╭────────────────────────────────────────────────────╮${NC}" echo -e "${GREEN}│ Update Complete! │${NC}" echo -e "${GREEN}├────────────────────────────────────────────────────┤${NC}" echo -e "${GREEN}│${NC} ${APP_NAME} has been updated successfully! ${GREEN}│${NC}" echo -e "${GREEN}│${NC} ${GREEN}│${NC}" printf "%b\n" "${GREEN}│${NC} ${BLUE}*${NC} Local: ${BLUE}${local_url}${NC}" if [[ -n "${network_url}" ]]; then printf "%b\n" "${GREEN}│${NC} ${BLUE}*${NC} Network: ${BLUE}${network_url}${NC}" fi echo -e "${GREEN}│${NC} ${GREEN}│${NC}" echo -e "${GREEN}╰────────────────────────────────────────────────────╯${NC}" echo "" echo -e "${BLUE}Manage service:${NC}" echo -e " sudo systemctl [start|stop|restart|status] ${SERVICE_UNIT}" echo "" echo -e "${BLUE}View logs:${NC}" echo -e " sudo journalctl -u ${SERVICE_UNIT} -f" echo -e " sudo journalctl -u ${DBUS_SERVICE_UNIT} -f" echo "" echo -e "${BLUE}Health check:${NC}" echo -e " ${HOMEIO_HEALTHCHECK_URL}" echo "" if [[ -n "${BACKUP_DIR}" && -d "${BACKUP_DIR}" ]]; then echo -e "${BLUE}Backup location:${NC}" echo -e " ${BACKUP_DIR}" echo "" fi } main() { require_root check_prerequisites # Before the rollback trap is armed: a GitHub hiccup only means main is used. resolve_repo_ref capture_current_state create_backup ROLLBACK_READY="true" trap 'on_error ${LINENO} $?' ERR ensure_service_shutdown_behavior stop_upload_server stop_service deploy_from_git ensure_security_dependencies install_go install_dependencies_if_needed run_database_migrations build_app build_upload_server start_service configure_reverse_proxy restart_dbus_helper_service restart_upload_server_service print_status "Running health check..." if ! healthcheck; then print_error "Health check failed at ${HOMEIO_HEALTHCHECK_URL}"; exit 1; fi ROLLBACK_READY="false" print_summary } main "$@"