#!/bin/sh # Pharos installer — https://pharos.solutionmax.net/get # # curl -fsSL https://pharos.solutionmax.net/get | sh # curl -fsSL https://pharos.solutionmax.net/get | sh -s -- --docker --port 8080 # curl -fsSL https://pharos.solutionmax.net/get | sh -s -- --php --dir ~/pharos-app --url https://status.example.com # curl -fsSL https://pharos.solutionmax.net/get | sh -s -- --version 0.5.0 (a specific release) # # What it does, in this order, and nothing else: # 1. looks at the machine: OS, whether you are root, whether docker / php are there # 2. fetches the signed release manifest and checks its Ed25519 signature # 3. downloads the release and checks its SHA-256 against the signed manifest # 4. installs: Docker (pulls ghcr.io/solutionmax/pharos, builds it from the archive only when # the pull fails) or as a PHP application # 5. prints where to open it # Run again later to update. Read it first if you like: curl -fsSL https://pharos.solutionmax.net/get | less set -eu RELEASES="${PHAROS_RELEASES:-https://pharos.solutionmax.net/releases}" IMAGE="${PHAROS_IMAGE:-ghcr.io/solutionmax/pharos}" PUBKEY_HEX="68d158ba363853e3b64efa7c2082015d198db31a4a039f05a24d3bbd93308ff2" MODE=""; DIR=""; PORT="8080"; URL=""; YES=0; WANT="" while [ $# -gt 0 ]; do case "$1" in --docker) MODE=docker; shift ;; --php) MODE=php; shift ;; --dir) DIR="$2"; shift 2 ;; --dir=*) DIR="${1#*=}"; shift ;; --port) PORT="$2"; shift 2 ;; --port=*) PORT="${1#*=}"; shift ;; --url) URL="$2"; shift 2 ;; --url=*) URL="${1#*=}"; shift ;; --version) WANT="$2"; shift 2 ;; --version=*) WANT="${1#*=}"; shift ;; -y|--yes) YES=1; shift ;; -h|--help) sed -n '2,15p' "$0" 2>/dev/null || true; exit 0 ;; *) echo "unknown option: $1" >&2; exit 2 ;; esac done # ---------- output helpers ---------- if [ -t 1 ]; then B="$(printf '\033[1m')"; D="$(printf '\033[2m')"; G="$(printf '\033[32m')"; Y="$(printf '\033[33m')"; R="$(printf '\033[31m')"; N="$(printf '\033[0m')"; else B=""; D=""; G=""; Y=""; R=""; N=""; fi ok() { printf '%s✓%s %s\n' "$G" "$N" "$1"; } warn() { printf '%s!%s %s\n' "$Y" "$N" "$1"; } die() { printf '%s✗ %s%s\n' "$R" "$1" "$N" >&2; exit 1; } dim() { printf '%s %s%s\n' "$D" "$1" "$N"; } ask() { # ask "question" -> 0 for yes; honours --yes; needs a terminal if [ "$YES" = 1 ]; then return 0; fi if [ ! -r /dev/tty ]; then die "$1: no terminal to answer on. Re-run with --yes to accept, or run the script from a shell."; fi printf '%s %s[Y/n]%s ' "$1" "$B" "$N" > /dev/tty # EOF (no one at the terminal) is not a yes: only Enter or y/yes is. read -r a < /dev/tty || die "No answer: not running interactively. Re-run with --yes to accept, or run the script from a shell." case "$a" in ""|y|Y|yes|YES) return 0 ;; *) return 1 ;; esac } need() { command -v "$1" >/dev/null 2>&1; } printf '\n%sPharos installer%s\n' "$B" "$N" dim "Reads: OS, docker, php. Writes: only the install directory. Source: $RELEASES" echo # ---------- 1. the machine ---------- OS="$(uname -s)"; ARCH="$(uname -m)" [ "$OS" = "Linux" ] || die "This installer supports Linux only (found $OS). On macOS or Windows, use Docker Desktop and the Docker path from the docs." DISTRO="unknown"; [ -r /etc/os-release ] && DISTRO="$(. /etc/os-release && printf '%s %s' "${PRETTY_NAME:-$ID}" "")" ROOT=0; [ "$(id -u)" = 0 ] && ROOT=1 SUDO=""; [ "$ROOT" = 0 ] && need sudo && sudo -n true 2>/dev/null && SUDO="sudo" HAVE_DOCKER=0; need docker && docker info >/dev/null 2>&1 && HAVE_DOCKER=1 HAVE_PHP=0; PHPV=""; if need php; then PHPV="$(php -r 'echo PHP_VERSION;' 2>/dev/null || true)"; php -r 'exit(version_compare(PHP_VERSION, "8.3.0", ">=") ? 0 : 1);' 2>/dev/null && HAVE_PHP=1; fi ok "$DISTRO $ARCH, $( [ "$ROOT" = 1 ] && echo 'running as root' || echo "running as $(id -un)$( [ -n "$SUDO" ] && echo ' (sudo available)')" )" for t in curl; do need "$t" || die "$t is required. Install it and run again."; done # choose the path if [ -z "$MODE" ]; then if [ "$HAVE_DOCKER" = 1 ]; then MODE=docker elif [ "$ROOT" = 1 ] || [ -n "$SUDO" ]; then warn "Docker is not installed." dim "Pharos runs best in Docker on a server you own. The alternative is a plain PHP install." if ask "Install Docker now via get.docker.com (the official script)?"; then MODE=docker; INSTALL_DOCKER=1 elif [ "$HAVE_PHP" = 1 ]; then MODE=php else die "Neither Docker nor PHP 8.3 is available. Install one of them, or answer Y to install Docker."; fi elif [ "$HAVE_PHP" = 1 ]; then MODE=php; dim "No Docker and no root → installing as a PHP application" else die "No Docker, no root and no PHP 8.3 (found ${PHPV:-none}). Ask your host to enable PHP 8.3, or use a server where you can install Docker." fi fi [ "$MODE" = php ] && [ "$HAVE_PHP" = 0 ] && die "PHP 8.3 or later is required for the PHP path (found ${PHPV:-none})." # ---------- 2. the signed manifest ---------- TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT MANIFEST_URL="$RELEASES/latest.json"; [ -n "$WANT" ] && MANIFEST_URL="$RELEASES/pharos-${WANT}.json" curl -fsSL "$MANIFEST_URL" -o "$TMP/latest.json" || die "Cannot reach $MANIFEST_URL$( [ -n "$WANT" ] && echo ". Is $WANT a published version? See $RELEASES/")" MANIFEST="$(tr -d '[:space:]' < "$TMP/latest.json")" PAYLOAD_B64="${MANIFEST%%.*}"; SIG_B64="${MANIFEST#*.}" b64url_decode() { printf '%s' "$1" | tr '_-' '/+' | awk '{ l = length($0) % 4; if (l == 2) print $0 "=="; else if (l == 3) print $0 "="; else print $0 }' | base64 -d 2>/dev/null; } b64url_decode "$PAYLOAD_B64" > "$TMP/payload.json" || die "Manifest is not readable" b64url_decode "$SIG_B64" > "$TMP/sig.bin" || die "Manifest signature is not readable" # Ed25519 verify: php+sodium (exact, always there on the PHP path), else openssl with one-shot -rawin # (RHEL/CloudLinux ship 1.1.1k without -rawin), else say so — the SHA-256 is still checked either way. verify_manifest() { if need php && php -r 'exit(extension_loaded("sodium") ? 0 : 1);' 2>/dev/null; then php -r 'exit(sodium_crypto_sign_verify_detached(file_get_contents($argv[2]), file_get_contents($argv[1]), hex2bin($argv[3])) ? 0 : 1);' \ "$TMP/payload.json" "$TMP/sig.bin" "$PUBKEY_HEX" >/dev/null 2>&1; return $? fi if need openssl && openssl pkeyutl -help 2>&1 | grep -q -- '-rawin'; then # raw Ed25519 public key → DER (SubjectPublicKeyInfo) → PEM, then a detached verify { printf '302a300506032b6570032100'; printf '%s' "$PUBKEY_HEX"; } | xxd -r -p > "$TMP/pub.der" 2>/dev/null || \ { printf '302a300506032b6570032100%s' "$PUBKEY_HEX" | sed 's/../\\x&/g' | xargs -0 printf '%b' > "$TMP/pub.der"; } { echo "-----BEGIN PUBLIC KEY-----"; base64 < "$TMP/pub.der"; echo "-----END PUBLIC KEY-----"; } > "$TMP/pub.pem" openssl pkeyutl -verify -pubin -inkey "$TMP/pub.pem" -rawin -in "$TMP/payload.json" -sigfile "$TMP/sig.bin" >/dev/null 2>&1; return $? fi return 2 } VERIFY_STATUS=0 verify_manifest || VERIFY_STATUS=$? case "$VERIFY_STATUS" in 0) ok "Release manifest: Ed25519 signature valid" ;; 2) die "Cannot verify the release signature. Install PHP with sodium or OpenSSL with Ed25519 support before retrying." ;; *) die "The release manifest does not carry a valid SolutionMAX signature. Not installing." ;; esac field() { sed -n 's/.*"'"$1"'":"\([^"]*\)".*/\1/p' "$TMP/payload.json" | head -1; } [ "$(field purpose)" = "pharos-release" ] || die "Manifest is not a Pharos release manifest." VERSION="$(field version)"; ZIP_URL="$(field url)"; SHA="$(field sha256)"; NOTES="$(field notes)" [ -n "$WANT" ] && [ "$WANT" != "$VERSION" ] && die "Requested $WANT but the manifest says $VERSION, refusing." dim "Release $VERSION: $NOTES" # ---------- 3. the archive (fetched when a path needs it: PHP always, Docker only as a fallback) ---------- fetch_zip() { [ -f "$TMP/pharos.zip" ] && return 0 curl -fsSL "$ZIP_URL" -o "$TMP/pharos.zip" || die "Download failed: $ZIP_URL" GOT="$(sha256sum "$TMP/pharos.zip" | cut -d' ' -f1)" [ "$GOT" = "$SHA" ] || die "SHA-256 mismatch: expected $SHA, got $GOT. Not installing." ok "pharos-$VERSION.zip downloaded, SHA-256 matches the signed manifest" } unpack() { # unpack zip into $1 (contents of the single top-level folder land in $1) fetch_zip mkdir -p "$1"; rm -rf "$TMP/x"; mkdir -p "$TMP/x" if need unzip; then unzip -q "$TMP/pharos.zip" -d "$TMP/x" elif need python3; then python3 -c "import zipfile,sys; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])" "$TMP/pharos.zip" "$TMP/x" else die "Need unzip or python3 to unpack the release."; fi top="$(find "$TMP/x" -mindepth 1 -maxdepth 1 -type d | head -1)" cp -a "$top"/. "$1"/ } # ---------- 4a. Docker ---------- if [ "$MODE" = docker ]; then if [ "${INSTALL_DOCKER:-0}" = 1 ]; then dim "Installing Docker (this runs https://get.docker.com as root)…" curl -fsSL https://get.docker.com -o "$TMP/get-docker.sh" || die "Cannot download the Docker install script" $SUDO sh "$TMP/get-docker.sh" >/dev/null 2>&1 || die "Docker installation failed. See https://docs.docker.com/engine/install/" $SUDO systemctl enable --now docker >/dev/null 2>&1 || true docker info >/dev/null 2>&1 || $SUDO docker info >/dev/null 2>&1 || die "Docker installed but not usable from this account. Log out and in (docker group), then run again." ok "Docker $(docker version --format '{{.Server.Version}}' 2>/dev/null || $SUDO docker version --format '{{.Server.Version}}') installed" fi DK="docker"; docker info >/dev/null 2>&1 || DK="$SUDO docker" $DK compose version >/dev/null 2>&1 || die "docker compose (plugin) is missing. Install docker-compose-plugin and run again." [ -n "$DIR" ] || DIR="$( [ "$ROOT" = 1 ] && echo /opt/pharos || echo "$HOME/pharos" )" APP="$DIR/app"; UPDATE=0; CUR="" if [ -f "$DIR/compose.yaml" ] || [ -f "$APP/artisan" ]; then UPDATE=1 # this script's own compose files carry the version; installs older than the GHCR image only have it in the unpacked app CUR="$(sed -n 's/^# pharos-version: //p' "$DIR/compose.yaml" 2>/dev/null | head -1)" [ -n "$CUR" ] || CUR="$(sed -n "s/.*'version' => env('PHAROS_VERSION', '\([^']*\)').*/\1/p" "$APP/config/pharos.php" 2>/dev/null || true)" if [ "$CUR" = "$VERSION" ]; then [ -n "$(cd "$DIR" && $DK compose ps -q app 2>/dev/null)" ] && { ok "Already on $VERSION at $DIR, nothing to do."; exit 0; } warn "Already on $VERSION at $DIR but not running, starting it." else dim "Updating $DIR from ${CUR:-?} to $VERSION (your .env and data volumes are kept)"; fi fi if [ ! -f "$DIR/.env" ]; then mkdir -p "$DIR" KEY="base64:$( (need openssl && openssl rand -base64 32) || head -c 32 /dev/urandom | base64 )" IP="$(hostname -I 2>/dev/null | awk '{print $1}')"; [ -n "$URL" ] || URL="http://${IP:-localhost}:$PORT" printf 'APP_KEY=%s\nAPP_URL=%s\nPHAROS_PORT=%s\nPHAROS_LICENSE_PUBLIC_KEY=\n' "$KEY" "$URL" "$PORT" > "$DIR/.env" chmod 600 "$DIR/.env"; ok ".env written: APP_KEY generated, APP_URL $URL" fi # The image is built by GitHub Actions from the same tag the manifest names. `build:` stays in # the file so `docker compose up -d --build` can make it from ./app when the registry is out of reach. cat > "$DIR/compose.yaml" </dev/null 2>&1 ); then ok "Image $IMAGE:$VERSION pulled" UP="up -d" # an install that was built from the archive before keeps nothing useful in ./app once the image is pulled [ -d "$APP" ] && { rm -rf "$APP" "$APP.old" "$APP.new"; dim "Removed $APP, the image replaces the unpacked source"; } else warn "Could not pull $IMAGE:$VERSION (no route to ghcr.io, or the package is not public yet), building it from the release archive instead." if [ "$UPDATE" = 1 ] && [ -d "$APP" ]; then rm -rf "$APP.new"; unpack "$APP.new"; rm -rf "$APP.old"; mv "$APP" "$APP.old"; mv "$APP.new" "$APP" else rm -rf "$APP"; unpack "$APP" fi ok "Release $VERSION unpacked into $APP" dim "Building the image (first time takes a minute)…" UP="up -d --build" fi dim "Starting app + scheduler…" ( cd "$DIR" && $DK compose $UP --quiet-pull >/dev/null 2>&1 ) || { ( cd "$DIR" && $DK compose logs --tail=20 app ) 2>/dev/null; die "docker compose failed. Logs above; retry with: cd $DIR && docker compose $UP"; } i=0; until curl -fsS -o /dev/null "http://127.0.0.1:$PORT/" 2>/dev/null; do i=$((i+1)); [ $i -gt 60 ] && { warn "Containers are up but http://127.0.0.1:$PORT/ does not answer yet."; break; }; sleep 2; done [ $i -le 60 ] && ok "http://127.0.0.1:$PORT answers" [ "$UPDATE" = 1 ] && rm -rf "$APP.old" echo; printf '%sPharos %s is running.%s\n' "$B" "$VERSION" "$N" printf ' Open %s%s%s and create your administrator.\n' "$B" "$(sed -n 's/^APP_URL=//p' "$DIR/.env")" "$N" dim "Later put a domain in front (Caddy/nginx → port $PORT) and set APP_URL in $DIR/.env" dim "Logs cd $DIR && docker compose logs -f" dim "Update curl -fsSL https://pharos.solutionmax.net/get | sh (same command, or: cd $DIR && docker compose pull && docker compose up -d)" exit 0 fi # ---------- 4b. PHP application ---------- missing=""; for e in pdo_sqlite mbstring openssl sodium zip curl fileinfo tokenizer xml ctype json; do php -m 2>/dev/null | grep -qi "^$e$" || missing="$missing $e"; done [ -z "$missing" ] || die "PHP $PHPV is missing extensions:$missing. Enable them (cPanel: Select PHP Version) and run again." ok "PHP $PHPV with the required extensions" [ -n "$DIR" ] || DIR="$HOME/pharos-app" if [ -f "$DIR/artisan" ] && [ -z "$WANT" ]; then dim "Existing install at $DIR, letting Pharos update itself" OUT="$( cd "$DIR" && php artisan pharos:update --no-interaction 2>&1 )" || { printf '%s\n' "$OUT" | tail -3; die "Update failed; see $DIR/storage/logs/laravel.log"; } # say what actually happened: "Already up to date." is not an update ok "$(printf '%s\n' "$OUT" | grep -E 'Already up to date|Updated to|No verified release' | tail -1 | sed 's/\x1b\[[0-9;]*m//g')"; exit 0 fi if [ -f "$DIR/artisan" ]; then die "Pinned-version updates cannot safely overwrite an existing installation. Use Admin → Updates (with backup/rollback), or omit --version to use the verified self-updater." fi if [ -d "$DIR" ] && [ -n "$(find "$DIR" -mindepth 1 -maxdepth 1 -print -quit)" ]; then die "Install directory is not empty. Choose a new directory; existing files will not be overwritten." fi unpack "$DIR"; ok "Unpacked into $DIR" cd "$DIR" [ -n "$URL" ] || URL="http://localhost" cp .env.example .env sed -i "s|^APP_ENV=.*|APP_ENV=production|; s|^APP_DEBUG=.*|APP_DEBUG=false|; s|^APP_URL=.*|APP_URL=$URL|; s|^DB_CONNECTION=.*|DB_CONNECTION=sqlite|" .env php artisan key:generate --force -q SETUP_KEY="$(php -r 'echo bin2hex(random_bytes(32));')" printf '\nPHAROS_SETUP_KEY=%s\n' "$SETUP_KEY" >> .env chmod 600 .env printf ' Installation key (needed to create the first administrator): %s\n' "$SETUP_KEY" touch database/database.sqlite; chmod -R u+rwX storage bootstrap/cache database php artisan migrate --force -q || die "Database migration failed. Correct the database settings before continuing." php artisan storage:link -q 2>/dev/null || warn "Storage symlink unavailable; Pharos can serve public uploads itself." php artisan optimize:clear -q ok ".env written, APP_KEY generated, database migrated" # The CLI that successfully migrated the database is the scheduler runtime. # The application reads first and refuses to overwrite an unreadable crontab. if php artisan list --raw | grep -q '^pharos:cron '; then if php artisan pharos:cron --install; then ok "Cron task configured" else warn "Automatic cron setup is unavailable. Add the following task in the panel:" php artisan pharos:cron || true fi else warn "This release predates automatic cron setup. Add a task using your versioned CLI PHP and the installation directory, running artisan schedule:run every minute." fi echo; printf '%sPharos %s is installed in %s.%s\n' "$B" "$VERSION" "$DIR" "$N" dim "Point the document root of your domain at: $DIR/public" dim "cPanel: Domains → Manage → Document Root · DirectAdmin: Domain Setup · Plesk: Hosting Settings" dim "cPanel over SSH, subdomain: uapi SubDomain changedocroot domain= docroot=${DIR#"$HOME/"}/public" dim "DirectAdmin without a document-root option: cd ~/domains/ && mv public_html public_html.orig && ln -s $DIR/public public_html" printf ' Then open %s%s%s and create your administrator.\n' "$B" "$URL" "$N" dim "Update curl -fsSL https://pharos.solutionmax.net/get | sh (same command)"