diff --git a/INSTALLATION.md b/INSTALLATION.md index 9251aea..5ba6f00 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -89,6 +89,152 @@ bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/Proxmo --- +## Bare metal — no Docker + +`scripts/install-baremetal.sh` installs Homelable natively on a Debian 12+ / +Ubuntu 22.04+ host (physical, VM or LXC): a Python venv and a `homelable` +systemd unit for the backend on `127.0.0.1:8000`, the built frontend served by +nginx on port 3000. + +```bash +git clone https://github.com/Pouzor/homelable.git /opt/homelable +sudo bash /opt/homelable/scripts/install-baremetal.sh +``` + +The script can also clone for you — run it from anywhere and it fetches the repo +into `INSTALL_DIR` if that directory is empty: + +```bash +curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-baremetal.sh \ + | sudo bash +``` + +It prompts for the admin password and the CIDR range to scan, then writes +`backend/.env` with a generated `SECRET_KEY` and bcrypt hash. Open +**http://\:3000**. + +Re-running is safe and is how you upgrade — an existing `backend/.env` is kept +untouched, everything else is rebuilt: + +```bash +cd /opt/homelable && git pull +sudo bash scripts/install-baremetal.sh +``` + +### Options + +Every setting is an environment variable, so a non-interactive install is one line: + +```bash +sudo HTTP_PORT=8080 ADMIN_PASSWORD=hunter2 SCANNER_RANGES='["10.0.0.0/24"]' \ + bash scripts/install-baremetal.sh +``` + +| Variable | Default | What | +|---|---|---| +| `INSTALL_DIR` | `/opt/homelable` | Repo root | +| `REPO_URL` / `REPO_REF` | upstream / `main` | Used only when `INSTALL_DIR` is empty | +| `SERVICE_USER` | `homelable` | systemd `User=` | +| `BACKEND_PORT` | `8000` | uvicorn port, bound to loopback | +| `HTTP_PORT` | `3000` | nginx port | +| `SERVER_NAME` | `_` | nginx `server_name` | +| `ADMIN_PASSWORD` | prompt (`admin`) | Initial password for user `admin` | +| `SCANNER_RANGES` | prompt (guessed) | JSON array of CIDRs | +| `SKIP_NGINX=1` | off | Do not install or touch nginx | + +### Afterwards + +```bash +systemctl status homelable +journalctl -u homelable -f +``` + +- Config: `/opt/homelable/backend/.env` — every other option (OIDC, MCP, + Proxmox, Zigbee, Z-Wave, live view) is documented in `.env.example`. + `systemctl restart homelable` after an edit. +- Data: `/opt/homelable/data` — SQLite DB and uploads. Back up this folder. +- nginx site: `/etc/nginx/sites-available/homelable`. + +Change the password later: + +```bash +/opt/homelable/backend/.venv/bin/python -c \ + 'import bcrypt; print(bcrypt.hashpw(b"newpassword", bcrypt.gensalt()).decode())' +# put it in backend/.env as AUTH_PASSWORD_HASH='$2b$12$...' (keep the single quotes) +systemctl restart homelable +``` + +⚠️ Keep JSON values in `backend/.env` single-quoted — `CORS_ORIGINS='["http://…"]'`. +systemd's `EnvironmentFile` parser strips bare double quotes, which breaks the +JSON before the backend parses it. This does not apply to the Docker install. + +### Scanning as a non-root service + +The unit runs as `homelable`, not root, so nmap has no raw sockets and silently +falls back to a TCP connect scan: hosts and open ports are still found, OS +detection (`-O`) and SYN scan (`-sS`) are not. To grant them, uncomment in +`/etc/systemd/system/homelable.service`: + +```ini +AmbientCapabilities=CAP_NET_RAW CAP_NET_ADMIN +CapabilityBoundingSet=CAP_NET_RAW CAP_NET_ADMIN +``` + +then `systemctl daemon-reload && systemctl restart homelable`. + +### Your own reverse proxy + +With `SKIP_NGINX=1` the script leaves the front end to you: serve +`/opt/homelable/frontend/dist` as a static SPA and proxy the API to the backend. +The nginx translation of `docker/nginx.conf` — what the script writes, with +`backend:8000` replaced by `127.0.0.1:8000`: + +```nginx +server { + listen 3000; + server_name _; + root /opt/homelable/frontend/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:8000; + 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:8000; + 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:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } + + location / { + try_files $uri $uri/ /index.html; + } +} +``` + +Terminating TLS in front of it means adding your own hostname to +`CORS_ORIGINS` in `backend/.env` (`https://homelable.example`) and restarting +the service. + +--- + ## Configuration All configuration is done via `.env` (copied from `.env.example`): diff --git a/README.md b/README.md index 2c8be6d..44c9884 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Every feature, with how to turn it on and use it, is described in **[FEATURES.md ## Installation -Docker (from the **[pre-built GHCR images](./INSTALLATION.md#pre-built-docker-images)**), Proxmox LXC, build from source, configuration, and development setup are all covered in **[INSTALLATION.md](./INSTALLATION.md)**. +Docker (from the **[pre-built GHCR images](./INSTALLATION.md#pre-built-docker-images)**), Proxmox LXC, **[bare metal without Docker](./INSTALLATION.md#bare-metal--no-docker)** (`sudo bash scripts/install-baremetal.sh` — systemd unit plus nginx on a Debian/Ubuntu host), build from source, configuration, and development setup are all covered in **[INSTALLATION.md](./INSTALLATION.md)**. --- diff --git a/scripts/install-baremetal.sh b/scripts/install-baremetal.sh new file mode 100755 index 0000000..310f56f --- /dev/null +++ b/scripts/install-baremetal.sh @@ -0,0 +1,300 @@ +#!/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) +# 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 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. +node_major="$(node --version 2>/dev/null | sed 's/^v\([0-9]*\).*/\1/')" +if [[ -z "$node_major" || "$node_major" -lt "$NODE_MAJOR" ]]; then + log "Installing Node.js $NODE_MAJOR (found: ${node_major:-none})" + 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 + read -rsp "Initial admin password [default: admin]: " admin_password; echo + 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}')" + guess="${guess:-192.168.1.0/24}" + read -rp "CIDR range to scan [$guess]: " scanner_ranges + 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}')" + origins="[\"http://localhost:${HTTP_PORT}\"" + [[ -n "$host_ip" ]] && origins="${origins},\"http://${host_ip}:${HTTP_PORT}\"" + origins="${origins}]" + + umask 077 + cat >"$ENV_FILE" <"$UNIT" <"$SITE" </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}')" + +cat <}:${HTTP_PORT} + nginx site: /etc/nginx/sites-available/homelable +EOF +else + cat <