Files
pic/install.sh
T
roof a9c7235347
Unit Tests / test (push) Successful in 12m0s
fix: install chrony for host NTP and enable pic.service on cold install
Root-cause fix for ACME failures caused by clock drift breaking TOTP
during DDNS registration: install and start chrony (all supported package
managers) before the setup wizard runs, so the host clock is accurate from
day one.

Also enables and starts the pic systemd unit at the end of a cold install —
previously the unit file was written but never activated, so the stack would
not survive a reboot without a manual `systemctl enable --now pic`.

Makefile uninstall hardened: `disable --now` instead of bare `disable` so the
running unit is stopped before the unit file is removed; daemon-reload called
afterwards to flush the stale unit; and all lingering cell-* containers
(tor/sshuttle/redsocks/store services) are now force-removed so subsequent
reinstalls start from a clean Docker state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:38:03 -04:00

357 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# =============================================================================
# Personal Internet Cell (PIC) — Bash Installer
# =============================================================================
#
# SECURITY NOTICE
# ---------------
# You are about to execute this script with elevated privileges.
# ALWAYS review a script before running it:
#
# curl -fsSL https://git.pic.ngo/roof/pic/raw/branch/main/install.sh | less
#
# SHA256 checksum (verify before running):
# PLACEHOLDER — updated when script is published at git.pic.ngo
#
# Verify with:
# sha256sum install.sh
# # or, via curl before piping:
# curl -fsSL https://git.pic.ngo/roof/pic/raw/branch/main/install.sh \
# | sha256sum
#
# =============================================================================
#
# Usage:
# bash install.sh # Standard install (uses sudo internally for packages)
# bash install.sh --force # Bypass idempotency check
# PIC_DIR=/srv/pic bash install.sh # Custom install directory
#
# Supported OS: Debian/Ubuntu (apt), Fedora/RHEL (dnf), Alpine Linux (apk)
#
# =============================================================================
set -euo pipefail
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
PIC_DIR="${PIC_DIR:-/opt/pic}"
PIC_REPO="${PIC_REPO:-https://git.pic.ngo/roof/pic.git}"
PIC_USER="${PIC_USER:-pic}"
API_HEALTH_URL="http://127.0.0.1:3000/health"
API_HEALTH_TIMEOUT=60
WEBUI_PORT=8081
FORCE=0
# Parse flags
for arg in "$@"; do
case "$arg" in
--force) FORCE=1 ;;
*)
echo "Unknown argument: $arg" >&2
echo "Usage: $0 [--force]" >&2
exit 1
;;
esac
done
# ---------------------------------------------------------------------------
# Color output
# ---------------------------------------------------------------------------
if [ -t 1 ] && command -v tput >/dev/null 2>&1 && tput setaf 1 >/dev/null 2>&1; then
RED="$(tput setaf 1)"
GREEN="$(tput setaf 2)"
YELLOW="$(tput setaf 3)"
BOLD="$(tput bold)"
RESET="$(tput sgr0)"
else
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BOLD='\033[1m'
RESET='\033[0m'
fi
log_step() { printf "\n${BOLD}[%s/%s] %s${RESET}\n" "$1" "$TOTAL_STEPS" "$2"; }
log_ok() { printf " ${GREEN}${RESET} %s\n" "$1"; }
log_warn() { printf " ${YELLOW}${RESET} %s\n" "$1"; }
log_error() { printf "\n${RED}${BOLD}ERROR:${RESET}${RED} %s${RESET}\n" "$1" >&2; }
die() { log_error "$1"; exit 1; }
TOTAL_STEPS=7
# ---------------------------------------------------------------------------
# Sudo check — we need it for package installs and system user creation
# ---------------------------------------------------------------------------
if ! command -v sudo >/dev/null 2>&1; then
die "sudo is required. Install it and ensure your user has sudo access."
fi
# ---------------------------------------------------------------------------
# Idempotency guard
# ---------------------------------------------------------------------------
if [ -f "${PIC_DIR}/.installed" ] && [ "$FORCE" -eq 0 ]; then
printf "${YELLOW}Already installed.${RESET} Run ${BOLD}'make update'${RESET} to update.\n"
printf "To force a full reinstall, run: ${BOLD}$0 --force${RESET}\n"
exit 0
fi
# ---------------------------------------------------------------------------
# Step 1 — Detect OS / package manager
# ---------------------------------------------------------------------------
log_step 1 "Detecting operating system..."
PKG_MANAGER=""
OS_ID=""
if [ -f /etc/os-release ]; then
# shellcheck source=/dev/null
. /etc/os-release
OS_ID="${ID:-unknown}"
fi
case "$OS_ID" in
ubuntu|debian|raspbian)
PKG_MANAGER="apt"
;;
fedora)
PKG_MANAGER="dnf"
;;
rhel|centos|almalinux|rocky)
PKG_MANAGER="dnf"
;;
alpine)
PKG_MANAGER="apk"
;;
*)
# Last-resort detection
if command -v apt-get >/dev/null 2>&1; then
PKG_MANAGER="apt"
elif command -v dnf >/dev/null 2>&1; then
PKG_MANAGER="dnf"
elif command -v apk >/dev/null 2>&1; then
PKG_MANAGER="apk"
else
die "Unsupported OS '${OS_ID}'. PIC requires Debian/Ubuntu, Fedora/RHEL, or Alpine Linux."
fi
;;
esac
log_ok "Detected OS: ${OS_ID} (package manager: ${PKG_MANAGER})"
# ---------------------------------------------------------------------------
# Step 2 — Install required packages
# ---------------------------------------------------------------------------
log_step 2 "Installing dependencies..."
case "$PKG_MANAGER" in
apt)
export DEBIAN_FRONTEND=noninteractive
sudo apt-get update -qq
sudo apt-get install -y -qq git curl make docker.io docker-compose-plugin 2>&1 \
| grep -v "^$" | sed 's/^/ /' || true
if ! docker compose version >/dev/null 2>&1; then
log_warn "docker-compose-plugin not available; falling back to standalone docker-compose"
sudo apt-get install -y -qq docker-compose 2>&1 | grep -v "^$" | sed 's/^/ /' || true
fi
# Ensure host clock is synchronised before DDNS/TOTP registration.
# chrony is preferred; the service name differs by distro (chrony on Debian, chronyd on some Ubuntu).
sudo apt-get install -y -qq chrony 2>&1 | grep -v "^$" | sed 's/^/ /' || true
if sudo systemctl enable --now chrony >/dev/null 2>&1; then
log_ok "Host NTP (chrony) enabled and started"
elif sudo systemctl enable --now chronyd >/dev/null 2>&1; then
log_ok "Host NTP (chronyd) enabled and started"
else
log_warn "Could not start chrony — verify host clock is accurate before running the setup wizard"
fi
;;
dnf)
sudo dnf install -y -q git curl make docker 2>&1 | sed 's/^/ /' || true
sudo systemctl enable --now docker >/dev/null 2>&1 || true
if ! docker compose version >/dev/null 2>&1; then
log_warn "docker compose plugin not found; installing docker-compose-plugin..."
sudo dnf install -y -q docker-compose-plugin 2>&1 | sed 's/^/ /' || true
fi
sudo dnf install -y -q chrony 2>&1 | sed 's/^/ /' || true
sudo systemctl enable --now chronyd >/dev/null 2>&1 \
&& log_ok "Host NTP (chronyd) enabled and started" \
|| log_warn "Could not start chronyd — verify host clock is accurate before running the setup wizard"
;;
apk)
sudo apk add --quiet git curl make docker docker-cli-compose 2>&1 | sed 's/^/ /' || true
sudo rc-update add docker default >/dev/null 2>&1 || true
sudo service docker start >/dev/null 2>&1 || true
sudo apk add --quiet chrony 2>&1 | sed 's/^/ /' || true
sudo rc-update add chronyd default >/dev/null 2>&1 || true
sudo service chronyd start >/dev/null 2>&1 \
&& log_ok "Host NTP (chronyd) enabled and started" \
|| log_warn "Could not start chronyd — verify host clock is accurate before running the setup wizard"
;;
esac
# Final sanity checks
command -v git >/dev/null 2>&1 || die "git could not be installed. Aborting."
command -v curl >/dev/null 2>&1 || die "curl could not be installed. Aborting."
command -v make >/dev/null 2>&1 || die "make could not be installed. Aborting."
command -v docker >/dev/null 2>&1 || die "docker could not be installed. Aborting."
docker compose version >/dev/null 2>&1 || \
docker-compose version >/dev/null 2>&1 || \
die "Neither 'docker compose' (plugin) nor 'docker-compose' is available. Aborting."
log_ok "All dependencies satisfied"
# ---------------------------------------------------------------------------
# Step 3 — Create system user
# ---------------------------------------------------------------------------
log_step 3 "Configuring system user..."
if ! id "$PIC_USER" >/dev/null 2>&1; then
case "$PKG_MANAGER" in
apk)
sudo adduser -S -D -H -s /sbin/nologin "$PIC_USER"
;;
*)
sudo useradd --system --no-create-home --shell /usr/sbin/nologin "$PIC_USER"
;;
esac
log_ok "Created system user: ${PIC_USER}"
else
log_ok "System user already exists: ${PIC_USER}"
fi
# Ensure docker group exists and invoking user is in it
if ! getent group docker >/dev/null 2>&1; then
sudo groupadd docker
log_ok "Created docker group"
fi
CURRENT_USER="${USER:-$(id -un)}"
if ! id -nG "$CURRENT_USER" | grep -qw docker; then
sudo usermod -aG docker "$CURRENT_USER"
log_ok "Added ${CURRENT_USER} to docker group (re-login or newgrp docker to apply)"
else
log_ok "${CURRENT_USER} is already in docker group"
fi
# ---------------------------------------------------------------------------
# Step 4 — Clone or update repository
# ---------------------------------------------------------------------------
log_step 4 "Setting up repository at ${PIC_DIR}..."
if [ -d "${PIC_DIR}/.git" ]; then
log_warn "Repository already cloned — running git pull"
git -C "$PIC_DIR" pull --ff-only 2>&1 | sed 's/^/ /'
log_ok "Repository updated"
elif [ -d "$PIC_DIR" ] && [ "$(ls -A "$PIC_DIR" 2>/dev/null)" ]; then
die "${PIC_DIR} exists and is not empty and is not a git repo. Aborting to avoid data loss."
else
mkdir -p "$(dirname "$PIC_DIR")"
git clone "$PIC_REPO" "$PIC_DIR" 2>&1 | sed 's/^/ /'
log_ok "Repository cloned to ${PIC_DIR}"
fi
sudo git config --system --add safe.directory "$PIC_DIR" 2>/dev/null || true
# ---------------------------------------------------------------------------
# Step 5 — Run make install
# ---------------------------------------------------------------------------
log_step 5 "Running 'make install'..."
cd "$PIC_DIR"
if ! make install 2>&1 | sed 's/^/ /'; then
die "'make install' failed. Check the output above."
fi
log_ok "'make install' complete"
# ---------------------------------------------------------------------------
# Step 6 — Start core services
# ---------------------------------------------------------------------------
log_step 6 "Starting core services..."
cd "$PIC_DIR"
if ! make start-core 2>&1 | sed 's/^/ /'; then
die "'make start-core' failed. Check the output above."
fi
log_ok "Core services started"
# Enable and start the pic systemd unit so the stack survives a reboot.
# Skipped on Alpine (OpenRC) and on systems without systemd.
if command -v systemctl >/dev/null 2>&1; then
sudo systemctl daemon-reload 2>/dev/null || true
if sudo systemctl enable --now pic 2>/dev/null; then
log_ok "systemd unit pic.service enabled and started"
else
log_warn "Could not enable pic.service — run: sudo systemctl enable --now pic"
fi
fi
# ---------------------------------------------------------------------------
# Step 7 — Health check + print wizard URL
# ---------------------------------------------------------------------------
log_step 7 "Waiting for API health check (up to ${API_HEALTH_TIMEOUT}s)..."
ELAPSED=0
HEALTHY=0
while [ "$ELAPSED" -lt "$API_HEALTH_TIMEOUT" ]; do
if curl -fsS "$API_HEALTH_URL" >/dev/null 2>&1; then
HEALTHY=1
break
fi
sleep 2
ELAPSED=$((ELAPSED + 2))
printf " Waiting... (%ds)\r" "$ELAPSED"
done
printf "\n"
if [ "$HEALTHY" -ne 1 ]; then
log_warn "API did not respond within ${API_HEALTH_TIMEOUT}s at ${API_HEALTH_URL}"
log_warn "The stack may still be starting up. Check with: make -C ${PIC_DIR} status"
log_warn "Or follow logs with: make -C ${PIC_DIR} logs"
else
log_ok "API is healthy"
fi
# Detect the host's primary outbound IP address
HOST_IP="$(ip route get 1.1.1.1 2>/dev/null | awk '/src/{print $7; exit}' || true)"
if [ -z "$HOST_IP" ]; then
# Fallback: first non-loopback IPv4
HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
fi
HOST_IP="${HOST_IP:-<host-ip>}"
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
printf "\n${GREEN}${BOLD}============================================================${RESET}\n"
printf "${GREEN}${BOLD} PIC installed successfully!${RESET}\n"
printf "${GREEN}${BOLD}============================================================${RESET}\n"
printf "\n"
printf " Open the setup wizard to configure your cell:\n"
printf "\n"
printf " ${BOLD}http://${HOST_IP}:${WEBUI_PORT}/setup${RESET}\n"
printf "\n"
printf " Useful commands:\n"
printf " make -C ${PIC_DIR} status — check service status\n"
printf " make -C ${PIC_DIR} logs — follow all service logs\n"
printf " make -C ${PIC_DIR} start — start all services\n"
printf " make -C ${PIC_DIR} stop — stop all services\n"
printf " make -C ${PIC_DIR} update — pull latest code and restart\n"
printf "\n"