a9c7235347
Unit Tests / test (push) Successful in 12m0s
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>
357 lines
13 KiB
Bash
Executable File
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"
|