#!/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 PIC_DEBUG="${PIC_DEBUG:-0}" # Parse flags for arg in "$@"; do case "$arg" in --force) FORCE=1 ;; --debug) PIC_DEBUG=1 ;; *) echo "Unknown argument: $arg" >&2 echo "Usage: $0 [--force] [--debug]" >&2 exit 1 ;; esac done # --------------------------------------------------------------------------- # Log file — /var/log/pic-install.log when writable (root via sudo), else /tmp # --------------------------------------------------------------------------- if touch /var/log/pic-install.log 2>/dev/null; then LOGFILE="/var/log/pic-install.log" else LOGFILE="${TMPDIR:-/tmp}/pic-install.log" fi : > "$LOGFILE" # truncate / create # --------------------------------------------------------------------------- # 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" if [ "$PIC_DEBUG" -eq 0 ]; then printf "\n${YELLOW}Last output (full log: %s):${RESET}\n" "$LOGFILE" >&2 tail -n 30 "$LOGFILE" | sed 's/^/ /' >&2 fi exit 1 } # --------------------------------------------------------------------------- # run_step [args...] # # Default mode: redirect stdout+stderr to LOGFILE; print a single "in # progress" line then overwrite it with a checkmark on success. On failure # print the last 30 log lines and die. # # Debug mode (PIC_DEBUG=1): tee output to LOGFILE AND stdout (indented), # print the done line at the end. # # TERM safety: when stdout is not a TTY the \r trick does not work, so we # fall back to a plain two-line "... / done" style. # --------------------------------------------------------------------------- _IS_TTY=0 [ -t 1 ] && _IS_TTY=1 run_step() { local label_running="$1" local label_done="$2" shift 2 # "$@" is the command to run if [ "$PIC_DEBUG" -eq 1 ]; then printf " → %s\n" "$label_running" # set -o pipefail: the pipeline below fails if "$@" fails, regardless # of tee's or sed's exit code. { "$@" 2>&1 | tee -a "$LOGFILE" | sed 's/^/ /'; } || \ die "Command failed. See $LOGFILE for details." log_ok "$label_done" return fi # Default (quiet) mode if [ "$_IS_TTY" -eq 1 ]; then printf " → %s..." "$label_running" else printf " → %s...\n" "$label_running" fi local exit_code=0 "$@" >> "$LOGFILE" 2>&1 || exit_code=$? if [ "$exit_code" -ne 0 ]; then [ "$_IS_TTY" -eq 1 ] && printf "\n" die "Step failed: $label_running" fi if [ "$_IS_TTY" -eq 1 ]; then printf "\r ${GREEN}✓${RESET} %-60s\n" "$label_done" else printf " ${GREEN}✓${RESET} %s\n" "$label_done" fi } 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 printf " Full log: %s\n" "$LOGFILE" [ "$PIC_DEBUG" -eq 1 ] && printf " ${YELLOW}Debug mode enabled — verbose output active${RESET}\n" # --------------------------------------------------------------------------- # 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..." _install_deps() { 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 || true if ! docker compose version >/dev/null 2>&1; then sudo apt-get install -y -qq docker-compose || true fi # Ensure host clock is synchronised before DDNS/TOTP registration. sudo apt-get install -y -qq chrony || true if sudo systemctl enable --now chrony >/dev/null 2>&1; then : # NTP enabled elif sudo systemctl enable --now chronyd >/dev/null 2>&1; then : # NTP enabled fi ;; dnf) sudo dnf install -y -q git curl make docker || true sudo systemctl enable --now docker >/dev/null 2>&1 || true if ! docker compose version >/dev/null 2>&1; then sudo dnf install -y -q docker-compose-plugin || true fi sudo dnf install -y -q chrony || true sudo systemctl enable --now chronyd >/dev/null 2>&1 || true ;; apk) sudo apk add --quiet git curl make docker docker-cli-compose || 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 || true sudo rc-update add chronyd default >/dev/null 2>&1 || true sudo service chronyd start >/dev/null 2>&1 || true ;; esac } run_step "Installing system packages" "System packages installed" _install_deps # Report NTP status (informational, outside the noisy run_step) case "$PKG_MANAGER" in apt) if sudo systemctl is-active --quiet chrony 2>/dev/null || \ sudo systemctl is-active --quiet chronyd 2>/dev/null; then log_ok "Host NTP (chrony) is running" else log_warn "Could not start chrony — verify host clock is accurate before running the setup wizard" fi ;; dnf|apk) if sudo systemctl is-active --quiet chronyd 2>/dev/null || \ sudo service chronyd status >/dev/null 2>&1; then log_ok "Host NTP (chronyd) is running" else log_warn "Could not start chronyd — verify host clock is accurate before running the setup wizard" fi ;; 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" run_step "Updating repository" "Repository updated" \ git -C "$PIC_DIR" pull --ff-only 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")" run_step "Cloning repository" "Repository cloned to ${PIC_DIR}" \ git clone "$PIC_REPO" "$PIC_DIR" fi sudo git config --system --add safe.directory "$PIC_DIR" 2>/dev/null || true # The cosign public key ships in the repo and is bind-mounted into cell-api so # store-service image signatures can be verified offline. It is checked in # (config/cosign/cosign.pub), so the clone above should already provide it; # warn loudly if it is somehow missing rather than silently skipping verify. COSIGN_PUBKEY="${PIC_DIR}/config/cosign/cosign.pub" if [ -f "$COSIGN_PUBKEY" ]; then log_ok "cosign public key present at ${COSIGN_PUBKEY}" else log_warn "cosign public key missing at ${COSIGN_PUBKEY} — image signature verification will be unavailable" fi # --------------------------------------------------------------------------- # Step 5 — Run make install # --------------------------------------------------------------------------- log_step 5 "Generating configuration..." cd "$PIC_DIR" # run_step routes all output to LOGFILE. After it returns we scan LOGFILE # for the admin password banner (printed once by setup_cell.py) and relay it # to the user — it must never be silently buried in the log. # We record the log byte-offset before the step so we only scan new output. _LOG_OFFSET_BEFORE="$(wc -c < "$LOGFILE" 2>/dev/null || echo 0)" run_step "Generating configuration" "Configuration generated" make install # Extract only the lines added by this step. _NEW_LOG="$(tail -c +"$(( _LOG_OFFSET_BEFORE + 1 ))" "$LOGFILE" 2>/dev/null || true)" # Relay admin password banner if present. if printf '%s\n' "$_NEW_LOG" | grep -qiE "(ADMIN PASSWORD|shown once)"; then printf "\n" printf '%s\n' "$_NEW_LOG" \ | awk '/ADMIN PASSWORD|shown once|={6}/{found=1} found{print} found && /^[[:space:]]*$/{exit}' \ | sed 's/^/ /' printf "\n" fi log_ok "'make install' complete" # --------------------------------------------------------------------------- # Step 6 — Start core services # --------------------------------------------------------------------------- log_step 6 "Starting core services..." cd "$PIC_DIR" run_step \ "Downloading container images (first run can take a few minutes)" \ "Container images ready" \ make start-core 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:-}" # --------------------------------------------------------------------------- # 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"