328 lines
11 KiB
Bash
Executable File
328 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_DNS_INTERFACE=${PIHOLE_DNS_INTERFACE:-ALL}
|
|
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
|
|
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
|