#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" INSTALL_DIR="${LAB_RPI_SERVICES_INSTALL_DIR:-/opt/homelab-rpi-services}" DATA_DIR="${LAB_RPI_SERVICES_DATA_DIR:-${INSTALL_DIR}/data}" DOCKER_NVME_ROOT="${LAB_RPI_DOCKER_ROOT:-${LAB_RPI_DOCKER_NVME_ROOT:-/nvme-storage/docker}}" DOCKER_FALLBACK_ROOT="${LAB_RPI_DOCKER_FALLBACK_ROOT:-/var/lib/docker}" STOP_LEGACY_PIHOLE="${LAB_RPI_STOP_LEGACY_PIHOLE:-true}" PIHOLE_WEBPASSWORD="${PIHOLE_WEBPASSWORD:-}" install_missing_packages() { local missing_packages=() local package for package in "$@"; do if ! dpkg-query -W -f='${Status}' "${package}" 2>/dev/null | grep -q 'install ok installed'; then missing_packages+=("${package}") fi done if ((${#missing_packages[@]} > 0)); then sudo apt-get update sudo apt-get install -y --no-install-recommends "${missing_packages[@]}" fi } docker_root_target() { if mountpoint -q "${DOCKER_NVME_ROOT}"; then sudo mkdir -p "${DOCKER_NVME_ROOT}" if sudo test -w "${DOCKER_NVME_ROOT}"; then printf '%s\n' "${DOCKER_NVME_ROOT}" return 0 fi fi printf '%s\n' "${DOCKER_FALLBACK_ROOT}" } configure_docker_root() { local target_root="$1" local current_root="" local configured_root="" local daemon_file="/etc/docker/daemon.json" sudo mkdir -p "${target_root}" /etc/docker if sudo systemctl is-active --quiet docker; then current_root="$(sudo docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)" fi if [[ -s "${daemon_file}" ]]; then configured_root="$(python3 - "${daemon_file}" <<'PY' import json import sys try: with open(sys.argv[1], encoding="utf-8") as handle: document = json.load(handle) except Exception: document = {} print(document.get("data-root", "")) PY )" fi if [[ "${current_root}" == "${target_root}" && "${configured_root}" == "${target_root}" ]]; then return 0 fi echo "Configuring Docker data-root: ${target_root}" sudo python3 - "${daemon_file}" "${target_root}" <<'PY' import json import os import sys path, target_root = sys.argv[1:3] document = {} if os.path.exists(path) and os.path.getsize(path) > 0: with open(path, encoding="utf-8") as handle: document = json.load(handle) document["data-root"] = target_root with open(path, "w", encoding="utf-8") as handle: json.dump(document, handle, indent=2, sort_keys=True) handle.write("\n") PY sudo systemctl restart docker } stop_legacy_pihole() { local container if [[ "${STOP_LEGACY_PIHOLE}" =~ ^(0|false|no|off|disabled)$ ]]; then return 0 fi for container in pihole pi-hole; do if sudo docker ps -a --format '{{.Names}}' | grep -qx "${container}"; then echo "Stopping legacy Pi-hole container ${container}; it is left on disk for manual rollback." sudo docker stop "${container}" >/dev/null 2>&1 || true fi done } resolve_pihole_webpassword() { local env_file="${INSTALL_DIR}/.env" local existing_password="" if [[ -n "${PIHOLE_WEBPASSWORD}" ]]; then printf '%s\n' "${PIHOLE_WEBPASSWORD}" return 0 fi if [[ -s "${env_file}" ]]; then existing_password="$(grep -E '^PIHOLE_WEBPASSWORD=' "${env_file}" | tail -n 1 | cut -d= -f2- || true)" if [[ -n "${existing_password}" ]]; then printf '%s\n' "${existing_password}" return 0 fi fi if command -v openssl >/dev/null 2>&1; then openssl rand -hex 18 return 0 fi python3 - <<'PY' import secrets print(secrets.token_hex(18)) PY } write_env_file() { PIHOLE_WEBPASSWORD="$(resolve_pihole_webpassword)" sudo tee "${INSTALL_DIR}/.env" >/dev/null </dev/null 2>&1; do if ((elapsed >= 120)); then echo "Timed out waiting for Pi-hole gravity database." >&2 return 1 fi sleep 5 elapsed=$((elapsed + 5)) done sql_file="$(mktemp)" python3 - "${adlists_file}" >"${sql_file}" <<'PY' import sys adlists_file = sys.argv[1] with open(adlists_file, encoding="utf-8") as handle: addresses = [ line.strip() for line in handle if line.strip() and not line.lstrip().startswith("#") ] if not addresses: raise SystemExit(0) def sql_string(value): return "'" + value.replace("'", "''") + "'" for address in addresses: quoted = sql_string(address) print(f"insert or ignore into adlist (address, enabled, comment) values ({quoted}, 1, 'homelab managed');") print(f"update adlist set enabled = 1, comment = coalesce(nullif(comment, ''), 'homelab managed') where address = {quoted};") PY # shellcheck disable=SC2024 sudo docker exec -i "${pihole_container}" pihole-FTL sqlite3 /etc/pihole/gravity.db <"${sql_file}" rm -f "${sql_file}" sudo docker exec "${pihole_container}" pihole -g } sync_pihole_managed_dns() { local pihole_container="${PIHOLE_CONTAINER_NAME:-homelab-pihole}" local local_records_file="${INSTALL_DIR}/local-dns-records.txt" local cname_records_file="${INSTALL_DIR}/cname-records.txt" local static_dhcp_hosts_file="${INSTALL_DIR}/static-dhcp-hosts.txt" local dnsmasq_file dnsmasq_file="$(mktemp)" python3 - "${local_records_file}" "${cname_records_file}" "${static_dhcp_hosts_file}" >"${dnsmasq_file}" <<'PY' import sys local_records_file, cname_records_file, static_dhcp_hosts_file = sys.argv[1:4] print("# Generated by homelab rpi-services bootstrap. Edit repo files, not this file.") def useful_lines(path): try: with open(path, encoding="utf-8") as handle: for raw_line in handle: line = raw_line.strip() if line and not line.startswith("#"): yield line except FileNotFoundError: return for line in useful_lines(local_records_file): fields = line.split() if len(fields) != 2: raise SystemExit(f"Invalid local DNS record: {line}") hostname, address = fields print(f"address=/{hostname}/{address}") for line in useful_lines(cname_records_file): fields = line.split() if len(fields) != 2: raise SystemExit(f"Invalid CNAME record: {line}") alias, target = fields print(f"cname={alias},{target}") for line in useful_lines(static_dhcp_hosts_file): if not line.startswith("dhcp-host="): raise SystemExit(f"Invalid static DHCP entry: {line}") print(line) PY sudo docker cp "${dnsmasq_file}" "${pihole_container}:/etc/dnsmasq.d/02-homelab-managed-dns.conf" rm -f "${dnsmasq_file}" sudo docker restart "${pihole_container}" >/dev/null } seed_uptime_kuma_monitors() { local uptime_kuma_container="${UPTIME_KUMA_CONTAINER_NAME:-homelab-uptime-kuma}" local monitor_file="${INSTALL_DIR}/uptime-kuma-monitors.json" local seed_script="${INSTALL_DIR}/seed-uptime-kuma.js" local elapsed=0 if [[ ! -s "${monitor_file}" || ! -s "${seed_script}" ]]; then echo "Uptime Kuma monitor seed files are missing; skipping monitor seed." return 0 fi until sudo docker exec "${uptime_kuma_container}" test -s /app/data/kuma.db >/dev/null 2>&1; do if ((elapsed >= 180)); then echo "Timed out waiting for Uptime Kuma database; skipping monitor seed." >&2 return 0 fi sleep 5 elapsed=$((elapsed + 5)) done sudo docker cp "${monitor_file}" "${uptime_kuma_container}:/tmp/homelab-kuma-monitors.json" sudo docker cp "${seed_script}" "${uptime_kuma_container}:/tmp/homelab-seed-kuma.js" if sudo docker exec -w /app "${uptime_kuma_container}" node /tmp/homelab-seed-kuma.js /tmp/homelab-kuma-monitors.json; then sudo docker restart "${uptime_kuma_container}" >/dev/null else echo "Uptime Kuma monitor seed failed; leaving the running service unchanged." >&2 fi } install_missing_packages ca-certificates curl iptables python3 rsync if ! command -v docker >/dev/null 2>&1; then if [[ "${LAB_RPI_INSTALL_DOCKER:-false}" == "true" ]]; then curl -fsSL https://get.docker.com | sudo sh else echo "Docker is not installed on the RPi host. Install Docker first, or rerun with LAB_RPI_INSTALL_DOCKER=true to allow get.docker.com installation." >&2 exit 1 fi fi sudo systemctl enable docker >/dev/null configure_docker_root "$(docker_root_target)" if ! sudo docker compose version >/dev/null 2>&1; then install_missing_packages docker-compose-plugin fi sudo mkdir -p "${INSTALL_DIR}" "${DATA_DIR}/pihole/etc-pihole" "${DATA_DIR}/pihole/etc-dnsmasq.d" "${DATA_DIR}/uptime-kuma" sudo cp "${SCRIPT_DIR}/docker-compose.yml" "${INSTALL_DIR}/docker-compose.yml" sudo cp "${SCRIPT_DIR}/adlists.txt" "${INSTALL_DIR}/adlists.txt" sudo cp "${SCRIPT_DIR}/local-dns-records.txt" "${INSTALL_DIR}/local-dns-records.txt" sudo cp "${SCRIPT_DIR}/cname-records.txt" "${INSTALL_DIR}/cname-records.txt" sudo cp "${SCRIPT_DIR}/static-dhcp-hosts.txt" "${INSTALL_DIR}/static-dhcp-hosts.txt" sudo cp "${SCRIPT_DIR}/uptime-kuma-monitors.json" "${INSTALL_DIR}/uptime-kuma-monitors.json" sudo cp "${SCRIPT_DIR}/seed-uptime-kuma.js" "${INSTALL_DIR}/seed-uptime-kuma.js" write_env_file stop_legacy_pihole cd "${INSTALL_DIR}" sudo docker compose pull sudo docker compose up -d --remove-orphans sync_pihole_adlists sync_pihole_managed_dns seed_uptime_kuma_monitors sudo docker compose ps