Files
homelable/scripts/install-baremetal.sh
T
Pouzor c5a49537ed test(ci): smoke-test the bare-metal installer in a debian:12 container
ShellCheck reads the installer but never runs it, so it cannot catch a
`set -euo pipefail` abort — the failure class that broke this script on a
fresh host. This job executes it for real in debian:12, which carries no
Node and gives the job no TTY, so both the nodesource path and the prompt
fallbacks are exercised on every run.

It asserts what the script promises: SECRET_KEY generated, the bcrypt hash
and the JSON values single-quoted (the systemd EnvironmentFile trap),
.env at mode 600, SQLITE_PATH under the install dir, the venv and the Vite
build present, the service user created, the unit's ExecStart and
EnvironmentFile correct. It then boots the backend by the unit's own
ExecStart and waits on /api/v1/health, which proves the generated .env
actually parses, and re-runs the installer to check the idempotency claim
leaves .env untouched.

systemd is out of reach in a container: systemctl is stubbed, so the unit
is written but never started, and the EnvironmentFile parse itself stays
untested. The job is gated on paths, since it costs roughly six minutes.

Also adds gnupg to the installer's apt list — the nodesource setup script
needs it and a minimal Debian does not have it.

Verified by running the same steps locally in debian:12: all assertions
pass, the backend answers /api/v1/health, and the second run keeps .env.

ha-relevant: no
2026-09-02 02:15:10 +02:00

320 lines
11 KiB
Bash
Executable File

#!/bin/bash
# Install Homelable (backend + frontend) natively on a Debian/Ubuntu host — no Docker.
#
# Run interactively as root on any Debian 12+/Ubuntu 22.04+ host, VM or LXC.
# Installs:
# - a Python venv + systemd unit for the FastAPI backend on 127.0.0.1:8000
# - the built Vite frontend, served by nginx on $HTTP_PORT with /api and /ws
# proxied to the backend
#
# Idempotent: re-running is safe. An existing backend/.env is kept untouched;
# only the venv, the frontend build, the systemd unit and the nginx site are
# refreshed. Use it to upgrade: pull (or re-clone) then re-run.
#
# Optional env vars (override defaults / skip the matching prompt):
# INSTALL_DIR repo root (default: /opt/homelable)
# REPO_URL clone URL if $INSTALL_DIR is empty (default: https://github.com/Pouzor/homelable.git)
# REPO_REF branch/tag/commit when cloning (default: main)
# SERVICE_USER systemd User= (default: homelable)
# BACKEND_PORT uvicorn listen port, loopback only (default: 8000)
# HTTP_PORT nginx listen port (default: 3000)
# SERVER_NAME nginx server_name (default: _)
# ADMIN_PASSWORD initial admin password (default: prompt, "admin" on empty)
# SCANNER_RANGES JSON array of CIDRs to scan (default: prompt, guessed from
# the primary interface)
#
# The two prompts are skipped when stdin is not a TTY (`curl … | sudo bash`);
# set the matching variables to control them there, or take the defaults.
# SKIP_NGINX=1 do not install or touch nginx (bring your own reverse proxy)
set -euo pipefail
INSTALL_DIR="${INSTALL_DIR:-/opt/homelable}"
REPO_URL="${REPO_URL:-https://github.com/Pouzor/homelable.git}"
REPO_REF="${REPO_REF:-main}"
SERVICE_USER="${SERVICE_USER:-homelable}"
SERVICE_NAME="homelable"
BACKEND_PORT="${BACKEND_PORT:-8000}"
HTTP_PORT="${HTTP_PORT:-3000}"
SERVER_NAME="${SERVER_NAME:-_}"
SKIP_NGINX="${SKIP_NGINX:-0}"
NODE_MAJOR=20
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || fail "Run as root (sudo bash $0)."
command -v apt-get >/dev/null || fail "This script targets Debian/Ubuntu (apt-get not found)."
log "Installing OS dependencies"
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
git curl ca-certificates gnupg python3 python3-venv python3-pip \
build-essential nmap iputils-ping iproute2 openssl >/dev/null
if [[ "$SKIP_NGINX" != "1" ]]; then
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nginx >/dev/null
fi
# Node 20+ — Debian 12 ships 18, too old for Vite 7 / React 19.
# `|| true`: with no node installed the substitution exits 127, and under
# `set -euo pipefail` that aborts the script before we get to install it.
node_major="$(node --version 2>/dev/null | sed 's/^v\([0-9]*\).*/\1/' || true)"
node_major="${node_major//[^0-9]/}" # `-lt` errors on anything non-numeric
node_major="${node_major:-0}"
if [[ "$node_major" -lt "$NODE_MAJOR" ]]; then
found="$node_major"
if [[ "$found" == "0" ]]; then found="none"; fi
log "Installing Node.js $NODE_MAJOR (found: $found)"
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - >/dev/null
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nodejs >/dev/null
fi
BACKEND_DIR="$INSTALL_DIR/backend"
FRONTEND_DIR="$INSTALL_DIR/frontend"
if [[ ! -d "$BACKEND_DIR" ]]; then
log "Cloning $REPO_URL ($REPO_REF) → $INSTALL_DIR"
mkdir -p "$(dirname "$INSTALL_DIR")"
git clone --depth 1 --branch "$REPO_REF" "$REPO_URL" "$INSTALL_DIR"
fi
[[ -f "$BACKEND_DIR/requirements.txt" ]] || fail "Missing $BACKEND_DIR/requirements.txt — repo layout unexpected."
[[ -f "$FRONTEND_DIR/package.json" ]] || fail "Missing $FRONTEND_DIR/package.json — repo layout unexpected."
for port in "$BACKEND_PORT" "$HTTP_PORT"; do
if ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${port}$"; then
warn "Port $port already in use. Fine if it's a previous Homelable instance; otherwise abort and free it."
fi
done
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
log "Creating service user '$SERVICE_USER'"
useradd --system --home "$INSTALL_DIR" --shell /usr/sbin/nologin "$SERVICE_USER"
fi
DATA_DIR="$INSTALL_DIR/data"
mkdir -p "$DATA_DIR"
VENV="$BACKEND_DIR/.venv"
if [[ ! -d "$VENV" ]]; then
log "Creating venv at $VENV"
python3 -m venv "$VENV"
fi
log "Installing Python deps (this takes a few minutes)"
"$VENV/bin/pip" install --quiet --upgrade pip
"$VENV/bin/pip" install --quiet -r "$BACKEND_DIR/requirements.txt"
ENV_FILE="$BACKEND_DIR/.env"
if [[ -f "$ENV_FILE" ]]; then
log ".env already present at $ENV_FILE — keeping existing values"
else
[[ -f "$INSTALL_DIR/.env.example" ]] || fail "Missing $INSTALL_DIR/.env.example"
log "No .env found — generating one (press Enter to accept defaults)"
admin_password="${ADMIN_PASSWORD:-}"
if [[ -z "$admin_password" ]]; then
if [[ -t 0 ]]; then
read -rsp "Initial admin password [default: admin]: " admin_password; echo
else
warn "No TTY (piped install) and ADMIN_PASSWORD unset — defaulting to 'admin'."
fi
admin_password="${admin_password:-admin}"
fi
scanner_ranges="${SCANNER_RANGES:-}"
if [[ -z "$scanner_ranges" ]]; then
guess="$(ip -o -f inet addr show scope global 2>/dev/null \
| awk '{print $4}' | head -n1 \
| awk -F/ '{split($1,o,"."); print o[1]"."o[2]"."o[3]".0/"$2}' || true)"
guess="${guess:-192.168.1.0/24}"
if [[ -t 0 ]]; then
read -rp "CIDR range to scan [$guess]: " scanner_ranges
else
warn "No TTY (piped install) and SCANNER_RANGES unset — defaulting to $guess."
fi
scanner_ranges="[\"${scanner_ranges:-$guess}\"]"
fi
secret_key="$(openssl rand -hex 32)"
# Passed through the environment, not argv — argv is world-readable in ps.
password_hash="$(HL_PW="$admin_password" "$VENV/bin/python" -c \
'import os, bcrypt; print(bcrypt.hashpw(os.environ["HL_PW"].encode(), bcrypt.gensalt()).decode())')"
host_ip="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
origins="[\"http://localhost:${HTTP_PORT}\""
if [[ -n "$host_ip" ]]; then
origins="${origins},\"http://${host_ip}:${HTTP_PORT}\""
fi
origins="${origins}]"
umask 077
cat >"$ENV_FILE" <<EOF
# Generated by scripts/install-baremetal.sh
# JSON values are single-quoted: systemd's EnvironmentFile parser strips
# bare double quotes, which would break the JSON before pydantic sees it.
SECRET_KEY=$secret_key
SQLITE_PATH=$DATA_DIR/homelab.db
# Set this to the URL(s) you use to reach Homelable in your browser.
# Behind a TLS reverse proxy, replace these with https://homelable.example.
CORS_ORIGINS='$origins'
AUTH_MODE=local
AUTH_USERNAME=admin
AUTH_PASSWORD_HASH='$password_hash'
SCANNER_RANGES='$scanner_ranges'
SCANNER_HTTP_RANGES='[]'
SCANNER_HTTP_PROBE_ENABLED=false
SCANNER_HTTP_VERIFY_TLS=false
STATUS_CHECKER_INTERVAL=60
EOF
umask 022
log "Wrote $ENV_FILE (mode 600)"
warn "Every other setting (OIDC, MCP, Proxmox, Zigbee, Z-Wave, live view) is documented in .env.example."
fi
log "Building the frontend"
if [[ -f "$FRONTEND_DIR/package-lock.json" ]]; then
( cd "$FRONTEND_DIR" && npm ci --silent )
else
( cd "$FRONTEND_DIR" && npm install --silent )
fi
( cd "$FRONTEND_DIR" && npm run build )
[[ -d "$FRONTEND_DIR/dist" ]] || fail "Frontend build produced no $FRONTEND_DIR/dist."
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
chmod 600 "$ENV_FILE"
# nginx (www-data) needs to traverse the tree down to dist/.
chmod o+x "$INSTALL_DIR" "$FRONTEND_DIR"
UNIT="/etc/systemd/system/${SERVICE_NAME}.service"
log "Writing $UNIT"
cat >"$UNIT" <<EOF
[Unit]
Description=Homelable backend
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$SERVICE_USER
WorkingDirectory=$BACKEND_DIR
EnvironmentFile=$ENV_FILE
ExecStart=$VENV/bin/uvicorn app.main:app --host 127.0.0.1 --port $BACKEND_PORT
Restart=on-failure
RestartSec=5
# The scanner shells out to nmap. Without raw sockets nmap silently falls back
# to a TCP connect scan: hosts and open ports are still found, but OS detection
# (-O) and SYN scan (-sS) do not work. Uncomment to grant them.
#AmbientCapabilities=CAP_NET_RAW CAP_NET_ADMIN
#CapabilityBoundingSet=CAP_NET_RAW CAP_NET_ADMIN
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now "$SERVICE_NAME"
systemctl restart "$SERVICE_NAME"
if [[ "$SKIP_NGINX" != "1" ]]; then
SITE="/etc/nginx/sites-available/homelable"
log "Writing $SITE"
cat >"$SITE" <<EOF
server {
listen $HTTP_PORT;
server_name $SERVER_NAME;
root $FRONTEND_DIR/dist;
index index.html;
client_max_body_size 20M;
# WebSocket (must come before /api/ to take priority)
location /api/v1/status/ws/ {
proxy_pass http://127.0.0.1:$BACKEND_PORT;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
}
location /api/ {
proxy_pass http://127.0.0.1:$BACKEND_PORT;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
}
# Legacy /ws/ path
location /ws/ {
proxy_pass http://127.0.0.1:$BACKEND_PORT;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
}
# SPA fallback
location / {
try_files \$uri \$uri/ /index.html;
}
}
EOF
ln -sf "$SITE" /etc/nginx/sites-enabled/homelable
if [[ "$HTTP_PORT" == "80" ]]; then rm -f /etc/nginx/sites-enabled/default; fi
nginx -t
systemctl reload nginx || systemctl restart nginx
fi
log "Waiting for the backend on :$BACKEND_PORT"
ok=0
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
if curl -fsS "http://127.0.0.1:${BACKEND_PORT}/api/v1/health" >/dev/null 2>&1; then
ok=1; break
fi
sleep 1
done
if [[ "$ok" -ne 1 ]]; then
warn "Backend did not answer /api/v1/health within 15s. Check: journalctl -u $SERVICE_NAME -n 50"
else
log "Backend is up."
fi
HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
cat <<EOF
----------------------------------------------------------------
Homelable installed.
Service: $SERVICE_NAME (systemctl status $SERVICE_NAME)
Backend: http://127.0.0.1:$BACKEND_PORT (loopback only)
Env file: $ENV_FILE
Data: $DATA_DIR (SQLite DB + uploads)
Logs: journalctl -u $SERVICE_NAME -f
EOF
if [[ "$SKIP_NGINX" != "1" ]]; then
cat <<EOF
Web UI: http://${HOST_IP:-<host-ip>}:${HTTP_PORT}
nginx site: /etc/nginx/sites-available/homelable
EOF
else
cat <<EOF
nginx: skipped — proxy your own front end to 127.0.0.1:$BACKEND_PORT
and serve $FRONTEND_DIR/dist. See INSTALLATION.md.
EOF
fi
cat <<EOF
Log in as 'admin' with the password you set. Change it later by regenerating
AUTH_PASSWORD_HASH in $ENV_FILE:
$VENV/bin/python -c "import bcrypt; print(bcrypt.hashpw(b'newpassword', bcrypt.gensalt()).decode())"
systemctl restart $SERVICE_NAME
Upgrade: git -C $INSTALL_DIR pull && bash $INSTALL_DIR/scripts/install-baremetal.sh
----------------------------------------------------------------
EOF