2d842abe5b
Unit Tests / test (push) Successful in 15m39s
Reverts 8d1ef39. The installer must collect cell name, domain mode, and
provider tokens before 'make install' so that DDNS registration,
availability checks, and Caddy TLS can be configured at first boot.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
562 lines
21 KiB
Bash
Executable File
562 lines
21 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:
|
|
# sudo bash install.sh # Standard install
|
|
# sudo bash install.sh --force # Bypass idempotency check
|
|
# sudo 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; }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Interactive prompt helpers (use /dev/tty so they work even with piped stdin)
|
|
# ---------------------------------------------------------------------------
|
|
prompt() {
|
|
# prompt <label> <default> <var>
|
|
local _label="$1" _default="$2" _var="$3" _inp=''
|
|
if [ -n "$_default" ]; then
|
|
printf " %s [%s]: " "$_label" "$_default" >/dev/tty
|
|
else
|
|
printf " %s: " "$_label" >/dev/tty
|
|
fi
|
|
read -r _inp </dev/tty || true
|
|
[ -z "$_inp" ] && _inp="$_default"
|
|
eval "${_var}=\${_inp}"
|
|
}
|
|
|
|
prompt_secret() {
|
|
# prompt_secret <label> <var>
|
|
local _label="$1" _var="$2" _inp=''
|
|
printf " %s: " "$_label" >/dev/tty
|
|
stty -echo </dev/tty 2>/dev/null || true
|
|
read -r _inp </dev/tty || true
|
|
stty echo </dev/tty 2>/dev/null || true
|
|
printf "\n" >/dev/tty
|
|
eval "${_var}=\${_inp}"
|
|
}
|
|
|
|
verify_cf_token() {
|
|
local _token="$1" _result=''
|
|
_result=$(curl -fsSm 10 \
|
|
-H "Authorization: Bearer ${_token}" \
|
|
"https://api.cloudflare.com/client/v4/user/tokens/verify" 2>/dev/null) || true
|
|
echo "$_result" | grep -q '"success":true'
|
|
}
|
|
|
|
verify_duckdns() {
|
|
local _sub="$1" _token="$2" _result=''
|
|
_result=$(curl -fsSm 10 \
|
|
"https://www.duckdns.org/update?domains=${_sub}&token=${_token}&ip=" 2>/dev/null) || true
|
|
[ "$_result" = "OK" ]
|
|
}
|
|
|
|
check_pic_ngo_available() {
|
|
local _name="$1" _result=''
|
|
_result=$(curl -fsSm 10 \
|
|
"https://ddns.pic.ngo/api/v1/check/${_name}" 2>/dev/null) || true
|
|
echo "$_result" | grep -q '"available":true'
|
|
}
|
|
|
|
TOTAL_STEPS=8
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Must run as root
|
|
# ---------------------------------------------------------------------------
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
die "This installer must be run as root (use sudo)."
|
|
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
|
|
apt-get update -qq
|
|
apt-get install -y -qq git curl make docker.io docker-compose-plugin 2>&1 \
|
|
| grep -v "^$" | sed 's/^/ /' || true
|
|
|
|
# Verify docker compose plugin installed
|
|
if ! docker compose version >/dev/null 2>&1; then
|
|
log_warn "docker-compose-plugin not available; falling back to standalone docker-compose"
|
|
apt-get install -y -qq docker-compose 2>&1 | grep -v "^$" | sed 's/^/ /' || true
|
|
fi
|
|
;;
|
|
|
|
dnf)
|
|
dnf install -y -q git curl make docker 2>&1 | sed 's/^/ /' || true
|
|
|
|
# Enable and start Docker (dnf installs but doesn't enable it)
|
|
systemctl enable --now docker >/dev/null 2>&1 || true
|
|
|
|
# Docker Compose plugin comes bundled with the Docker CE package on Fedora/RHEL.
|
|
# If not present, install via the docker-compose-plugin package (Docker CE repo).
|
|
if ! docker compose version >/dev/null 2>&1; then
|
|
log_warn "docker compose plugin not found; installing docker-compose-plugin..."
|
|
dnf install -y -q docker-compose-plugin 2>&1 | sed 's/^/ /' || true
|
|
fi
|
|
;;
|
|
|
|
apk)
|
|
apk add --quiet git curl make docker docker-cli-compose 2>&1 | sed 's/^/ /' || true
|
|
|
|
# Enable Docker on Alpine (OpenRC)
|
|
rc-update add docker default >/dev/null 2>&1 || true
|
|
service docker start >/dev/null 2>&1 || true
|
|
;;
|
|
|
|
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)
|
|
adduser -S -D -H -s /sbin/nologin "$PIC_USER"
|
|
;;
|
|
*)
|
|
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 user is in it
|
|
if ! getent group docker >/dev/null 2>&1; then
|
|
groupadd docker
|
|
log_ok "Created docker group"
|
|
fi
|
|
|
|
if ! id -nG "$PIC_USER" | grep -qw docker; then
|
|
usermod -aG docker "$PIC_USER"
|
|
log_ok "Added ${PIC_USER} to docker group"
|
|
else
|
|
log_ok "${PIC_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
|
|
|
|
# Give the invoking user (or pic if run directly as root) ownership of the repo
|
|
# so they can run `make update` and other git commands without sudo.
|
|
REPO_OWNER="${SUDO_USER:-${PIC_USER}}"
|
|
chown -R "${REPO_OWNER}:${REPO_OWNER}" "$PIC_DIR"
|
|
# Allow all users to run git commands here regardless of who owns the files
|
|
git config --system --add safe.directory "$PIC_DIR" 2>/dev/null || true
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Step 5 — Configure cell identity
|
|
# ---------------------------------------------------------------------------
|
|
log_step 5 "Configuring cell identity..."
|
|
|
|
if [ ! -c /dev/tty ]; then
|
|
die "No interactive terminal available. Re-run with a real terminal (not piped)."
|
|
fi
|
|
|
|
printf "\n" >/dev/tty
|
|
|
|
# ── Cell name ──────────────────────────────────────────────────────────────
|
|
PIC_CELL_NAME=''
|
|
while true; do
|
|
prompt "Cell name (e.g. myhome, alice, lab)" "" PIC_CELL_NAME
|
|
if echo "$PIC_CELL_NAME" | grep -qE '^[a-z][a-z0-9-]{1,30}$'; then
|
|
break
|
|
fi
|
|
log_warn "Must start with a letter, use only lowercase letters/digits/hyphens, 2–31 chars."
|
|
done
|
|
|
|
# ── Domain / DDNS choice ───────────────────────────────────────────────────
|
|
printf "\n" >/dev/tty
|
|
printf " How will your cell be publicly reachable?\n" >/dev/tty
|
|
printf " 1) pic.ngo subdomain — free, %s.pic.ngo, fully automatic HTTPS\n" "$PIC_CELL_NAME" >/dev/tty
|
|
printf " 2) Cloudflare DNS-01 — your own domain (must use Cloudflare nameservers)\n" >/dev/tty
|
|
printf " 3) DuckDNS — free *.duckdns.org subdomain\n" >/dev/tty
|
|
printf " 4) HTTP-01 (any) — any domain, port 80 must be publicly reachable\n" >/dev/tty
|
|
printf " 5) Local only — no public domain, LAN/VPN access only\n" >/dev/tty
|
|
printf "\n" >/dev/tty
|
|
|
|
PIC_DOMAIN_MODE=''
|
|
_choice=''
|
|
while true; do
|
|
prompt "Choice" "1" _choice
|
|
case "$_choice" in
|
|
1) PIC_DOMAIN_MODE="pic_ngo"; break ;;
|
|
2) PIC_DOMAIN_MODE="cloudflare"; break ;;
|
|
3) PIC_DOMAIN_MODE="duckdns"; break ;;
|
|
4) PIC_DOMAIN_MODE="http01"; break ;;
|
|
5) PIC_DOMAIN_MODE="lan"; break ;;
|
|
*) log_warn "Enter a number from 1 to 5." ;;
|
|
esac
|
|
done
|
|
|
|
PIC_DOMAIN_NAME=''
|
|
PIC_CF_TOKEN=''
|
|
PIC_DDK_TOKEN=''
|
|
PIC_DDK_SUB=''
|
|
|
|
# ── pic.ngo ────────────────────────────────────────────────────────────────
|
|
if [ "$PIC_DOMAIN_MODE" = "pic_ngo" ]; then
|
|
PIC_DOMAIN_NAME="${PIC_CELL_NAME}.pic.ngo"
|
|
printf " Checking name availability at pic.ngo..." >/dev/tty
|
|
if check_pic_ngo_available "$PIC_CELL_NAME"; then
|
|
printf " available\n" >/dev/tty
|
|
log_ok "Will register: ${PIC_DOMAIN_NAME}"
|
|
else
|
|
printf "\n" >/dev/tty
|
|
log_warn "${PIC_DOMAIN_NAME} may already be taken or the server is unreachable."
|
|
log_warn "Registration will be retried at first boot."
|
|
fi
|
|
fi
|
|
|
|
# ── Cloudflare ─────────────────────────────────────────────────────────────
|
|
if [ "$PIC_DOMAIN_MODE" = "cloudflare" ]; then
|
|
printf "\n" >/dev/tty
|
|
while true; do
|
|
prompt "Your domain name (e.g. home.example.com)" "" PIC_DOMAIN_NAME
|
|
if echo "$PIC_DOMAIN_NAME" | grep -qiE '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z]{2,})+$'; then
|
|
break
|
|
fi
|
|
log_warn "Enter a valid fully-qualified domain name (e.g. home.example.com)."
|
|
done
|
|
printf "\n" >/dev/tty
|
|
printf " Create an API token at: Cloudflare Dashboard → My Profile → API Tokens\n" >/dev/tty
|
|
printf " Required permission: Zone / DNS / Edit (set to all zones or your specific zone)\n" >/dev/tty
|
|
printf "\n" >/dev/tty
|
|
_attempts=0
|
|
while true; do
|
|
prompt_secret "Cloudflare API token" PIC_CF_TOKEN
|
|
if [ -z "$PIC_CF_TOKEN" ]; then
|
|
log_warn "Token cannot be empty."
|
|
continue
|
|
fi
|
|
printf " Verifying token with Cloudflare..." >/dev/tty
|
|
if verify_cf_token "$PIC_CF_TOKEN"; then
|
|
printf " valid\n" >/dev/tty
|
|
log_ok "Cloudflare token verified"
|
|
break
|
|
fi
|
|
printf " invalid\n" >/dev/tty
|
|
_attempts=$((_attempts + 1))
|
|
log_warn "Verification failed — check the token has Zone / DNS / Edit permission."
|
|
if [ "$_attempts" -ge 2 ]; then
|
|
log_warn "Token failed twice. You can still continue — it will be tested again at first boot."
|
|
prompt "Press Enter to continue with this token, or Ctrl-C to abort and re-run" "" _dummy
|
|
break
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# ── DuckDNS ────────────────────────────────────────────────────────────────
|
|
if [ "$PIC_DOMAIN_MODE" = "duckdns" ]; then
|
|
printf "\n" >/dev/tty
|
|
printf " First create a subdomain at duckdns.org, then enter the details below.\n" >/dev/tty
|
|
printf "\n" >/dev/tty
|
|
while true; do
|
|
prompt "DuckDNS subdomain (e.g. myhome → myhome.duckdns.org)" "" PIC_DDK_SUB
|
|
if echo "$PIC_DDK_SUB" | grep -qE '^[a-z0-9][a-z0-9-]*$'; then
|
|
break
|
|
fi
|
|
log_warn "Subdomain must be lowercase letters, digits, and hyphens only."
|
|
done
|
|
PIC_DOMAIN_NAME="${PIC_DDK_SUB}.duckdns.org"
|
|
printf "\n" >/dev/tty
|
|
_attempts=0
|
|
while true; do
|
|
prompt_secret "DuckDNS token (from duckdns.org account page)" PIC_DDK_TOKEN
|
|
if [ -z "$PIC_DDK_TOKEN" ]; then
|
|
log_warn "Token cannot be empty."
|
|
continue
|
|
fi
|
|
printf " Verifying token with DuckDNS..." >/dev/tty
|
|
if verify_duckdns "$PIC_DDK_SUB" "$PIC_DDK_TOKEN"; then
|
|
printf " valid\n" >/dev/tty
|
|
log_ok "DuckDNS token verified (${PIC_DOMAIN_NAME})"
|
|
break
|
|
fi
|
|
printf " invalid\n" >/dev/tty
|
|
_attempts=$((_attempts + 1))
|
|
log_warn "Verification failed — make sure the subdomain exists at duckdns.org and the token is correct."
|
|
if [ "$_attempts" -ge 2 ]; then
|
|
log_warn "Token failed twice. You can still continue — it will be tested again at first boot."
|
|
prompt "Press Enter to continue with this token, or Ctrl-C to abort and re-run" "" _dummy
|
|
break
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# ── HTTP-01 ────────────────────────────────────────────────────────────────
|
|
if [ "$PIC_DOMAIN_MODE" = "http01" ]; then
|
|
printf "\n" >/dev/tty
|
|
while true; do
|
|
prompt "Your domain name (e.g. home.example.com)" "" PIC_DOMAIN_NAME
|
|
if echo "$PIC_DOMAIN_NAME" | grep -qiE '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z]{2,})+$'; then
|
|
break
|
|
fi
|
|
log_warn "Enter a valid fully-qualified domain name."
|
|
done
|
|
printf "\n" >/dev/tty
|
|
log_warn "HTTP-01 requires port 80 to be publicly reachable from the internet."
|
|
log_warn "Make sure your router forwards port 80 to this machine before completing setup."
|
|
fi
|
|
|
|
# ── Local only ─────────────────────────────────────────────────────────────
|
|
if [ "$PIC_DOMAIN_MODE" = "lan" ]; then
|
|
log_ok "Local-only mode — no public domain or DDNS will be configured."
|
|
fi
|
|
|
|
printf "\n" >/dev/tty
|
|
log_ok "Identity configured: cell=${PIC_CELL_NAME} mode=${PIC_DOMAIN_MODE}${PIC_DOMAIN_NAME:+ domain=${PIC_DOMAIN_NAME}}"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Step 6 — Run make install
|
|
# ---------------------------------------------------------------------------
|
|
log_step 6 "Running 'make install'..."
|
|
|
|
cd "$PIC_DIR"
|
|
|
|
if ! CELL_NAME="$PIC_CELL_NAME" \
|
|
DOMAIN_MODE="$PIC_DOMAIN_MODE" \
|
|
CELL_DOMAIN_NAME="${PIC_DOMAIN_NAME:-}" \
|
|
CLOUDFLARE_API_TOKEN="${PIC_CF_TOKEN:-}" \
|
|
DUCKDNS_TOKEN="${PIC_DDK_TOKEN:-}" \
|
|
DUCKDNS_SUBDOMAIN="${PIC_DDK_SUB:-}" \
|
|
make install 2>&1 | sed 's/^/ /'; then
|
|
die "'make install' failed. Check the output above."
|
|
fi
|
|
|
|
# make install runs as root so config/ and data/ get created root-owned.
|
|
# Re-apply ownership to the invoking user so they can manage files without sudo.
|
|
chown -R "${REPO_OWNER}:${REPO_OWNER}" "$PIC_DIR"
|
|
|
|
log_ok "'make install' complete"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Step 7 — Start core services
|
|
# ---------------------------------------------------------------------------
|
|
log_step 7 "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"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Step 8 — Health check + print wizard URL
|
|
# ---------------------------------------------------------------------------
|
|
log_step 8 "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 " Cell: ${BOLD}%s${RESET}\n" "$PIC_CELL_NAME"
|
|
if [ -n "$PIC_DOMAIN_NAME" ]; then
|
|
printf " Domain: ${BOLD}%s${RESET} (%s)\n" "$PIC_DOMAIN_NAME" "$PIC_DOMAIN_MODE"
|
|
else
|
|
printf " Domain: %s\n" "local only (LAN/VPN)"
|
|
fi
|
|
printf "\n"
|
|
printf " Open the setup wizard to set your admin password and choose services:\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"
|