my-homelab-configs/infra/rpi-services/bootstrap.sh

322 lines
11 KiB
Bash
Executable File

#!/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_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 <<ENV_EOT
TZ=${TZ:-America/Mexico_City}
PIHOLE_IMAGE=${PIHOLE_IMAGE:-pihole/pihole:latest}
PIHOLE_CONTAINER_NAME=${PIHOLE_CONTAINER_NAME:-homelab-pihole}
PIHOLE_WEB_PORT=${PIHOLE_WEB_PORT:-8081}
PIHOLE_DNS_BIND_IP=${PIHOLE_DNS_BIND_IP:-0.0.0.0}
PIHOLE_WEB_BIND_IP=${PIHOLE_WEB_BIND_IP:-0.0.0.0}
PIHOLE_WEBPASSWORD=${PIHOLE_WEBPASSWORD}
PIHOLE_UPSTREAMS=${PIHOLE_UPSTREAMS:-unbound#53;1.1.1.1;9.9.9.9}
PIHOLE_DATA_DIR=${PIHOLE_DATA_DIR:-${DATA_DIR}/pihole/etc-pihole}
PIHOLE_DNSMASQ_DIR=${PIHOLE_DNSMASQ_DIR:-${DATA_DIR}/pihole/etc-dnsmasq.d}
PIHOLE_REV_SERVER=${PIHOLE_REV_SERVER:-false}
PIHOLE_REV_SERVER_TARGET=${PIHOLE_REV_SERVER_TARGET:-}
PIHOLE_REV_SERVER_DOMAIN=${PIHOLE_REV_SERVER_DOMAIN:-}
PIHOLE_REV_SERVER_CIDR=${PIHOLE_REV_SERVER_CIDR:-}
UNBOUND_IMAGE=${UNBOUND_IMAGE:-mvance/unbound-rpi:latest}
UNBOUND_CONTAINER_NAME=${UNBOUND_CONTAINER_NAME:-homelab-unbound}
UPTIME_KUMA_IMAGE=${UPTIME_KUMA_IMAGE:-louislam/uptime-kuma:1}
UPTIME_KUMA_CONTAINER_NAME=${UPTIME_KUMA_CONTAINER_NAME:-homelab-uptime-kuma}
UPTIME_KUMA_BIND_IP=${UPTIME_KUMA_BIND_IP:-0.0.0.0}
UPTIME_KUMA_PORT=${UPTIME_KUMA_PORT:-3001}
UPTIME_KUMA_DATA_DIR=${UPTIME_KUMA_DATA_DIR:-${DATA_DIR}/uptime-kuma}
RPI_SERVICES_NETWORK=${RPI_SERVICES_NETWORK:-homelab-rpi-services}
ENV_EOT
}
sync_pihole_adlists() {
local pihole_container="${PIHOLE_CONTAINER_NAME:-homelab-pihole}"
local adlists_file="${INSTALL_DIR}/adlists.txt"
local sql_file
local elapsed=0
if [[ ! -s "${adlists_file}" ]]; then
return 0
fi
until sudo docker exec "${pihole_container}" test -s /etc/pihole/gravity.db >/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}"
if ! sudo docker exec "${pihole_container}" pihole restartdns reload; then
sudo docker restart "${pihole_container}" >/dev/null
fi
}
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
curl -fsSL https://get.docker.com | sudo sh
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