#!/usr/bin/env bash # Portalis one-command install. # # curl -fsSL https://raw.githubusercontent.com/raym0nz82/portalis-install/main/install.sh | bash # # Pulls the prebuilt container image (no source, no compilation), brings up the # app + PostgreSQL + Redis, generates all secrets and a random first-login # superadmin password. Re-running is safe — it keeps the existing configuration # and simply updates to the newest image. # # Optional overrides (set as env vars before running): # PORTALIS_DIR install directory (default /opt/portalis) # PORTALIS_VERSION image tag to install (default latest) # PORTALIS_PORT web UI port (default 8090) # PORTALIS_SSH_PORT SSH gateway port (default 2222) set -euo pipefail IMAGE_REPO=ghcr.io/raym0nz82/portalis LICENSE_PUBKEY_DEFAULT=aad11a2287ba0abba47d349c3e99c80adfde46b9ac00b09a98a7a5fa34cb3731 LICENSE_SERVER_DEFAULT=https://my.portalis.sh DIR=${PORTALIS_DIR:-/opt/portalis} VERSION=${PORTALIS_VERSION:-latest} PORT=${PORTALIS_PORT:-8090} SSH_PORT=${PORTALIS_SSH_PORT:-2222} BOLD=$'\033[1m'; DIM=$'\033[2m'; RST=$'\033[0m' CYAN=$'\033[1;36m'; GREEN=$'\033[1;32m'; RED=$'\033[1;31m'; YEL=$'\033[1;33m'; BLUE=$'\033[1;34m' step() { printf "${CYAN}▸${RST} ${BOLD}%s${RST}\n" "$*"; } info() { printf " ${DIM}%s${RST}\n" "$*"; } die() { printf "\n${RED}✖ %s${RST}\n" "$*" >&2; exit 1; } # setup_ota wires the host-side over-the-air updater: three small scripts plus # systemd units that (a) check ghcr for a newer image every 30 min, (b) apply an # update when the app drops a trigger, (c) re-check on demand. Image-based — it # compares the running image digest to the registry's; no source repo needed. # Skips cleanly (leaving the app in "manual update" mode) when systemd is absent. setup_ota() { if ! command -v systemctl >/dev/null 2>&1 || [[ ! -d /run/systemd/system ]]; then info "systemd not detected — skipping auto-updater (manual: re-run this installer)" return 0 fi mkdir -p "$DIR/ota" cat > "$DIR/ota/check.sh" <<'SH' #!/usr/bin/env bash # OTA check (image-based): compare the running image digest to ghcr's and record # the result the app reads. Anonymous token works for a public image. set -euo pipefail DIR="${PORTALIS_DIR:-/opt/portalis}" IMAGE_REPO="${IMAGE_REPO:-ghcr.io/raym0nz82/portalis}" TAG="${PORTALIS_VERSION:-latest}" STATE="$DIR/ota-state" REPO_PATH="${IMAGE_REPO#ghcr.io/}" mkdir -p "$STATE" tok=$(curl -fsSL "https://ghcr.io/token?scope=repository:${REPO_PATH}:pull&service=ghcr.io" 2>/dev/null \ | sed -n 's/.*"token":"\([^"]*\)".*/\1/p') remote=$(curl -fsSLI \ -H "Authorization: Bearer ${tok}" \ -H "Accept: application/vnd.oci.image.index.v1+json" \ -H "Accept: application/vnd.docker.distribution.manifest.list.v2+json" \ -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ "https://ghcr.io/v2/${REPO_PATH}/manifests/${TAG}" 2>/dev/null \ | tr -d '\r' | awk 'tolower($1)=="docker-content-digest:"{print $2}') local=$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "${IMAGE_REPO}:${TAG}" 2>/dev/null \ | sed -n 's#.*@##p' | head -1) avail=false [[ -n "$remote" && -n "$local" && "$remote" != "$local" ]] && avail=true # latestSha is left empty on purpose: it's the git-commit field the app's legacy # updater compares against BuildSHA. An image digest there would never match the # commit and would trip the fallback into a permanent "update available". The # image checker is authoritative via updateAvailable; the digest lives in a # separate field for debugging only. cat > "$STATE/update-status.json" < "$DIR/ota/update.sh" <<'SH' #!/usr/bin/env bash # OTA apply: pull the newest image and recreate the stack, then refresh status. set -euo pipefail DIR="${PORTALIS_DIR:-/opt/portalis}" STATE="$DIR/ota-state"; LOG="$STATE/update.log" cd "$DIR" { echo "=== update $(date -Iseconds) ===" docker compose pull docker compose up -d echo "=== done $(date -Iseconds) ===" } >> "$LOG" 2>&1 PORTALIS_DIR="$DIR" bash "$DIR/ota/check.sh" >> "$LOG" 2>&1 || true rm -f "$STATE/update.trigger" SH cat > "$DIR/ota/recheck.sh" <<'SH' #!/usr/bin/env bash set -euo pipefail DIR="${PORTALIS_DIR:-/opt/portalis}" PORTALIS_DIR="$DIR" bash "$DIR/ota/check.sh" || true rm -f "$DIR/ota-state/check.trigger" SH chmod +x "$DIR/ota/"*.sh cat > /etc/systemd/system/portalis-ota-check.service < /etc/systemd/system/portalis-ota-check.timer < /etc/systemd/system/portalis-ota-apply.service < /etc/systemd/system/portalis-ota-apply.path < /etc/systemd/system/portalis-ota-recheck.service < /etc/systemd/system/portalis-ota-recheck.path </dev/null || true systemctl daemon-reload systemctl enable --now portalis-ota-check.timer portalis-ota-apply.path portalis-ota-recheck.path >/dev/null 2>&1 || true info "auto-updater installed (checks every 30 min; one-click update in Settings → About)" } printf "\n${BLUE}" cat <<'ART' ____ _ _ _ | _ \ ___ _ __| |_ __ _| (_)___ | |_) / _ \| '__| __/ _` | | / __| | __/ (_) | | | || (_| | | \__ \ |_| \___/|_| \__\__,_|_|_|___/ ART printf "${RST}${DIM} self-hosted SSH gateway · session recording · JIT access${RST}\n\n" # ── 1/4 prerequisites ─────────────────────────────────────────────── step "[1/4] Checking prerequisites" command -v docker >/dev/null || die "docker not found — install Docker first (https://get.docker.com)" docker compose version >/dev/null 2>&1 || die "docker compose v2 required" command -v openssl >/dev/null || die "openssl not found" command -v curl >/dev/null || die "curl not found" info "docker, compose, openssl, curl present" # ── 2/4 configuration ─────────────────────────────────────────────── step "[2/4] Preparing configuration in $DIR" mkdir -p "$DIR" cd "$DIR" cat > docker-compose.yml < .env </dev/null; then UP=1; break; fi i=$(( (i + 1) % 10 )) printf "\r ${CYAN}%s${RST} starting services… ${DIM}%ds${RST} " "${frames:$i:1}" "$(( SECONDS - start ))" sleep 0.25 done printf "\r\033[K" if [[ $UP -ne 1 ]]; then printf "${RED}✖ Portalis did not come online within 120s.${RST}\n\n" echo "Most recent app logs:" docker compose logs --tail 25 portalis 2>&1 | sed 's/^/ /' echo echo "Common causes: port ${PORT} already in use, or the database not ready." echo "Fix it and re-run this installer." exit 1 fi # Wire the host-side auto-updater (best-effort; manual re-run still works). setup_ota HOST=$(hostname -I 2>/dev/null | awk '{print $1}'); HOST=${HOST:-localhost} printf "\n${GREEN}" cat <<'ART' ✔ PORTALIS IS LIVE ┌──────────────────────────────────────────────────────────┐ ART printf "${RST}" printf "${GREEN} │${RST} Web UI : ${BOLD}http://%s:%s${RST}\n" "$HOST" "$PORT" printf "${GREEN} │${RST} SSH gate : ${BOLD}%s${RST} port ${BOLD}%s${RST}\n" "$HOST" "$SSH_PORT" printf "${GREEN} │${RST} Username : ${BOLD}superadmin${RST}\n" if [[ $FRESH -eq 1 ]]; then printf "${GREEN} │${RST} Password : ${YEL}%s${RST}\n" "$BOOTSTRAP_PW" printf "${GREEN} └──────────────────────────────────────────────────────────┘${RST}\n" echo printf " ${YEL}⚠ Write this password down now — it is shown only here.${RST}\n" echo " First login forces a password change + 2FA setup." echo " Need a paid tier? Settings → License → Request or upgrade a license." else printf "${GREEN} │${RST} Password : ${DIM}(unchanged — set at first install)${RST}\n" printf "${GREEN} └──────────────────────────────────────────────────────────┘${RST}\n" echo printf " ${GREEN}✔ Updated to the newest image.${RST}\n" fi echo