#!/usr/bin/env bash # Portalis one-command install. # # curl -fsSL https://get.portalis.sh | sudo bash # # Needs Docker + Compose v2; when missing it offers to install them # (PORTALIS_INSTALL_DOCKER=1 does so without asking, for unattended runs). # # 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; use `sudo -E`). They are # remembered in /.env, so a re-run keeps them unless you pass the variable # again: explicit env > .env > default. # PORTALIS_DIR install directory (default /opt/portalis) # PORTALIS_IMAGE image repo, no tag (default ghcr.io/solutionmax/portalis) # PORTALIS_VERSION image tag; pins updates (default latest) # PORTALIS_PORT web UI port (default 8090) # PORTALIS_SSH_PORT SSH gateway port (default 2222) # PORTALIS_BIND address to publish on (default 0.0.0.0 = all) # set to 127.0.0.1 when a reverse proxy terminates TLS # PORTALIS_TRUST_PROXY 1 = trust X-Forwarded-For from private/loopback peers # (default 1 when bound to loopback, else 0) # PORTALIS_TRUSTED_PROXIES CIDRs allowed to set X-Forwarded-For, comma-separated # (default 127.0.0.1/32,::1/128 when bound to loopback) # PORTALIS_INSTALL_DOCKER 1 = install Docker without asking when it is missing # # Subcommands: # curl -fsSL https://get.portalis.sh | sudo bash -s -- uninstall [--purge] # stop the containers and remove the portalis-ota-* systemd units; # --purge also deletes the Docker volumes and the install directory. # curl -fsSL https://get.portalis.sh | sudo bash -s -- refresh-ota # rewrite only the host OTA scripts (ota/*.sh) and their systemd units # from this installer: no image pull, no container restart, no .env # change. An OTA update calls this so the host side of a new release # arrives with it. set -Eeuo pipefail 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() { STEP=$*; printf "${CYAN}▸${RST} ${BOLD}%s${RST}\n" "$*"; } info() { printf " ${DIM}%s${RST}\n" "$*"; } die() { printf "\n${RED}✖ %s${RST}\n" "$*" >&2; exit 1; } LICENSE_PUBKEY_DEFAULT=aad11a2287ba0abba47d349c3e99c80adfde46b9ac00b09a98a7a5fa34cb3731 LICENSE_SERVER_DEFAULT=https://my.portalis.sh DIR=${PORTALIS_DIR:-/opt/portalis} # Where the portalis-ota-* systemd units live. Overridable so the smoke test can # point it at a scratch directory instead of the host's real unit directory. UNITS=${PORTALIS_SYSTEMD_DIR:-/etc/systemd/system} # Settings remembered from an earlier run (B4): a re-run is the documented # update path, and silently falling back to defaults would re-expose a # loopback-bound install to the network. Explicit env still wins. SETTINGS=(PORTALIS_IMAGE PORTALIS_VERSION PORTALIS_PORT PORTALIS_SSH_PORT PORTALIS_BIND PORTALIS_TRUST_PROXY PORTALIS_TRUSTED_PROXIES PORTALIS_INSTANCE_NAME) for k in "${SETTINGS[@]}"; do [[ -z "${!k:-}" && -f "$DIR/.env" ]] || continue v=$(sed -n "s/^$k=//p" "$DIR/.env" | tail -1) [[ -n "$v" ]] && declare "$k=$v" done IMAGE_REPO=${PORTALIS_IMAGE:-ghcr.io/solutionmax/portalis} VERSION=${PORTALIS_VERSION:-latest} [[ "$VERSION" =~ ^[A-Za-z0-9._-]+$ ]] || die "PORTALIS_VERSION '$VERSION' is not a valid image tag (e.g. 1.0.9 or latest)" PORT=${PORTALIS_PORT:-8090} SSH_PORT=${PORTALIS_SSH_PORT:-2222} # Publishing on all interfaces is the sane default for a first install, but it # means plain HTTP is reachable from the network. Behind a TLS proxy you want # 127.0.0.1 so the only way in is through the proxy. BIND=${PORTALIS_BIND:-0.0.0.0} [[ "$BIND" == localhost ]] && BIND=127.0.0.1 # Docker's port mapping wants an IP # ponytail: IPv6 is accepted unvalidated — Docker rejects garbage itself. [[ "$BIND" =~ ^[0-9]+(\.[0-9]+){3}$ || "$BIND" == *:* ]] || die "PORTALIS_BIND must be an IP address, got '$BIND'" # Trusting X-Forwarded-For means whoever connects decides what IP we record. That # is right behind a reverse proxy and wrong everywhere else: without one, the # client is the peer, so it can name any address it likes — which forges the # audit trail, walks past the IP allowlist and defeats the login rate limit. # Default off; on automatically when the app is bound to loopback, because then a # proxy is the only way in. TRUSTED_PROXIES is the precise form (CIDR list, wins # in the app when set); TRUST_PROXY=1 stays as the legacy "any private peer". case "$BIND" in 127.0.0.1|::1) TRUST_DEFAULT=1; TRUSTED_DEFAULT=127.0.0.1/32,::1/128 ;; *) TRUST_DEFAULT=0; TRUSTED_DEFAULT= ;; esac TRUST_PROXY=${PORTALIS_TRUST_PROXY:-$TRUST_DEFAULT} TRUSTED_PROXIES=${PORTALIS_TRUSTED_PROXIES:-$TRUSTED_DEFAULT} # Where this host can reach the web UI for the health probe. case "$BIND" in 0.0.0.0) PROBE=127.0.0.1 ;; *:*) PROBE="[$BIND]" ;; *) PROBE=$BIND ;; esac # Anything that trips set -e: say which step, and that a re-run resumes. on_err() { printf "\n${RED}✖ failed during: %s${RST}\n" "${STEP:-startup}" >&2 [[ -f "$DIR/.env" ]] && printf " ${DIM}Configuration is kept in %s/.env — fix the cause and re-run the same command; it picks up where it left off.${RST}\n" "$DIR" >&2 true } trap on_err ERR # uninstall [--purge]: undo everything the installer wired up on the host. uninstall() { step "Uninstalling Portalis from $DIR" if [[ -d /run/systemd/system ]]; then systemctl disable --now portalis-ota-check.timer portalis-ota-apply.path portalis-ota-recheck.path >/dev/null 2>&1 || true rm -f "$UNITS"/portalis-ota-* systemctl daemon-reload info "removed the portalis-ota-* systemd units" fi if [[ -f "$DIR/docker-compose.yml" ]]; then if [[ "$1" == --purge ]]; then (cd "$DIR" && docker compose down -v) && rm -rf "$DIR" info "removed containers, volumes (database, host keys, recordings) and $DIR" else (cd "$DIR" && docker compose down) info "removed containers; kept $DIR (.env, compose, ota-state) and the Docker volumes" info "re-run the installer to bring it back, or 'uninstall --purge' to delete everything" fi fi exit 0 } # need_docker: Docker + Compose v2 must be present; offer to install them via # the official convenience script. We are usually run as `curl | sudo bash`, so # stdin is this script — the question is asked on /dev/tty. No terminal (CI, # cloud-init): PORTALIS_INSTALL_DOCKER=1 installs unattended, otherwise we stop. need_docker() { command -v docker >/dev/null && docker compose version >/dev/null 2>&1 && return 0 local ans=y tmp if [[ "${PORTALIS_INSTALL_DOCKER:-}" != 1 ]]; then # read -p prints its prompt on stderr; write it to the tty ourselves so it # is visible even when stderr is redirected (curl ... | sudo bash 2>log). { : /dev/null \ || die "Docker is not installed and there is no terminal to ask. Either re-run with PORTALIS_INSTALL_DOCKER=1 to install it unattended, or install Docker manually (https://docs.docker.com/engine/install/) and re-run." printf 'Docker is not installed. Would you like to install Docker now? [Y/n] ' >/dev/tty read -r ans /dev/null && echo "${ID:-}")" in almalinux|rocky|rhel|ol) dnf -y -q install dnf-plugins-core \ && dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo \ && dnf -y -q install docker-ce docker-ce-cli containerd.io docker-compose-plugin \ || { rm -f "$tmp"; die "could not install Docker from the CentOS repo — install it manually (https://docs.docker.com/engine/install/) and re-run."; } ;; *) rm -f "$tmp"; die "could not install Docker automatically — install it manually (https://docs.docker.com/engine/install/) and re-run." ;; esac fi rm -f "$tmp" [[ -d /run/systemd/system ]] && systemctl enable --now docker >/dev/null 2>&1 || true docker compose version >/dev/null 2>&1 \ || die "Docker was installed but 'docker compose version' does not work — check the Docker install and re-run." } # write_ota_files (re)writes the host side of the 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. # # Split out of setup_ota so `refresh-ota` can refresh these on their own: the # scripts ship with the installer, not with the image, so an update would # otherwise keep running the host scripts of whichever installer first ran here. # # The scripts are written to a .tmp file and renamed into place, never truncated # in place: ota/update.sh calls refresh-ota while it is still executing, and # bash re-reads a running script from its file offset — overwriting that inode # would make it resume in the middle of the new text. write_ota_files() { mkdir -p "$DIR/ota" cat > "$DIR/ota/check.sh.tmp" <<'SH' #!/usr/bin/env bash # OTA check (image-based): compare the running image digest to the registry's # and record the result the app reads. Never exits non-zero and always writes # update-status.json: a failed check is a status the UI must be able to show, # not a reason for the "Check for updates" button to vanish. Anonymous token # works for a public image. Image repo and tag come from the installer's .env. set -uo pipefail DIR="${PORTALIS_DIR:-/opt/portalis}" STATE="$DIR/ota-state" cfg() { sed -n "s/^$1=//p" "$DIR/.env" 2>/dev/null | tail -1; } IMAGE_REPO=$(cfg PORTALIS_IMAGE); IMAGE_REPO=${IMAGE_REPO:-ghcr.io/solutionmax/portalis} TAG=$(cfg PORTALIS_VERSION); TAG=${TAG:-latest} REPO_PATH="${IMAGE_REPO#ghcr.io/}" mkdir -p "$STATE" # JSON string escaping without python: backslash and quote escaped, control # characters dropped. ponytail: dropped rather than \u-escaped — the inputs are # one-line curl/docker messages, never user text. escq() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; } esc() { escq "$1" | tr -d '\000-\037'; } # Same escaping, but line breaks survive as \n. Release notes are markdown, and # esc's control-character purge would run all the lines together. escn() { escq "$1" | tr -d '\000-\011\013-\037' | sed -e ':a;N;$!ba;s/\n/\\n/g'; } # jsonfield : the value of a top-level string field of the FIRST object in # releases.json, with the JSON escapes decoded. Used when jq is absent — a bare # Debian or Alma has neither jq nor python, and this file must run anywhere. # It is a real (small) JSON scanner rather than a regex: notes contain quotes, # backslashes and \n, all of which a `sed -n 's/.*"notes":"\([^"]*\)".*/\1/p'` # would cut in the wrong place. LC_ALL=C so substr() walks bytes, not runes. jsonfield() { LC_ALL=C awk -v want="$1" ' function uchar(h, v, i, c, d) { # \uXXXX -> UTF-8 bytes (BMP only) v = 0 for (i = 1; i <= 4; i++) { c = tolower(substr(h, i, 1)); d = index("0123456789abcdef", c) - 1 if (d < 0) return "" v = v * 16 + d } if (v < 128) return sprintf("%c", v) if (v < 2048) return sprintf("%c%c", 192 + int(v / 64), 128 + v % 64) return sprintf("%c%c%c", 224 + int(v / 4096), 128 + int(v / 64) % 64, 128 + v % 64) } function readstr( out, ch) { # p sits just past the opening quote out = "" while (p <= n) { ch = substr(S, p, 1); p++ if (ch == "\\") { ch = substr(S, p, 1); p++ if (ch == "n") out = out "\n" else if (ch == "t") out = out "\t" else if (ch == "r") out = out "\r" else if (ch == "b" || ch == "f") out = out " " else if (ch == "u") { out = out uchar(substr(S, p, 4)); p += 4 } else out = out ch # \" \\ \/ and anything unknown } else if (ch == "\"") return out else out = out ch } return out } function skipval( depth, ch) { # a value we were not asked for ch = substr(S, p, 1) if (ch != "{" && ch != "[") { while (p <= n && index(",}", substr(S, p, 1)) == 0) p++; return } depth = 0 while (p <= n) { ch = substr(S, p, 1); p++ if (ch == "\"") readstr() else if (ch == "{" || ch == "[") depth++ else if (ch == "}" || ch == "]") { depth--; if (depth == 0) return } } } function ws() { while (p <= n && index(" \t\r\n", substr(S, p, 1)) > 0) p++ } { S = S $0 "\n" } END { n = length(S); p = index(S, "{") if (p == 0) exit p++ while (p <= n) { ws(); if (substr(S, p, 1) == ",") { p++; ws() } if (substr(S, p, 1) != "\"") exit p++; key = readstr() ws(); if (substr(S, p, 1) != ":") exit p++; ws() if (substr(S, p, 1) == "\"") { p++; val = readstr(); if (key == want) { printf "%s", val; exit } } else skipval() } }' } err=""; remote="" if out=$(curl -fsS "https://ghcr.io/token?scope=repository:${REPO_PATH}:pull&service=ghcr.io" 2>&1); then tok=$(printf '%s' "$out" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p') if out=$(curl -fsSI \ -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>&1); then remote=$(printf '%s' "$out" | tr -d '\r' | awk 'tolower($1)=="docker-content-digest:"{print $2}') [[ -n "$remote" ]] || err="registry returned no digest for ${IMAGE_REPO}:${TAG}" else err="registry: ${out}"; fi else err="registry token: ${out}"; fi running=$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "${IMAGE_REPO}:${TAG}" 2>/dev/null \ | sed -n 's#.*@##p' | head -1) avail=false; pinned=false [[ -n "$remote" && -n "$running" && "$remote" != "$running" ]] && avail=true # A pinned tag still gets checked (so the UI can say "pinned to X"); it only # flips to available if that same tag was re-pushed. [[ "$TAG" != latest ]] && pinned=true [[ -n "$err" ]] && echo "check failed: $err" >&2 # Release notes and the human version number come from get.portalis.sh: a # registry digest carries no changelog and the configured tag is usually # "latest", which tells a user nothing. Best effort with a short timeout — a # missing, unreachable or malformed releases.json leaves the tag-derived version # in place, empty notes, and is explicitly NOT a check error: the registry # comparison above is what decides whether an update exists. # OTA_JSON=awk forces the jq-free reader (used by the smoke test). version="$TAG"; notes="" if rel=$(curl -fsS --max-time 10 "${OTA_RELEASES_URL:-https://get.portalis.sh/releases.json}" 2>/dev/null); then if [[ "${OTA_JSON:-}" != awk ]] && command -v jq >/dev/null 2>&1; then v=$(printf '%s' "$rel" | jq -r 'if type=="array" and length>0 then (.[0].version // "") else "" end' 2>/dev/null) || v="" notes=$(printf '%s' "$rel" | jq -r 'if type=="array" and length>0 then (.[0].notes // "") else "" end' 2>/dev/null) || notes="" else v=$(printf '%s' "$rel" | jsonfield version) notes=$(printf '%s' "$rel" | jsonfield notes) fi # Only a plausible tag replaces the configured one; junk is ignored. [[ "$v" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$ ]] && version="$v" fi notes=${notes:0:2000} # 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. updateError is what update.sh passes in # after a failed apply; it clears on the next scheduled check. now=$(date -Iseconds) cat > "$STATE/update-status.json" < "$DIR/ota/update.sh.tmp" <<'SH' #!/usr/bin/env bash # OTA apply: pull the newest image, recreate the stack, probe the web UI; if it # does not come back, re-tag the previously running image and bring that up. # The trigger is removed whatever happens, so the UI never sticks on "Updating". set -uo pipefail DIR="${PORTALIS_DIR:-/opt/portalis}" STATE="$DIR/ota-state"; LOG="$STATE/update.log"; TRIGGER="$STATE/update.trigger" trap 'rm -f "$TRIGGER" "$STATE/update-stage"' EXIT # The app shows these as steps while the update runs (pull → restart → health, # rollback when the new image does not come up). stage() { echo "$1" > "$STATE/update-stage"; chmod 0644 "$STATE/update-stage"; echo "--- stage: $1 $(date -Iseconds)"; } cd "$DIR" || exit 1 cfg() { sed -n "s/^$1=//p" .env 2>/dev/null | tail -1; } IMAGE_REPO=$(cfg PORTALIS_IMAGE); IMAGE_REPO=${IMAGE_REPO:-ghcr.io/solutionmax/portalis} TAG=$(cfg PORTALIS_VERSION); TAG=${TAG:-latest} PORT=$(cfg PORTALIS_PORT); PORT=${PORT:-8090} BIND=$(cfg PORTALIS_BIND); BIND=${BIND:-0.0.0.0} case "$BIND" in 0.0.0.0) PROBE=127.0.0.1 ;; *:*) PROBE="[$BIND]" ;; *) PROBE=$BIND ;; esac healthy() { local t=0; until curl -fsS -o /dev/null "http://${PROBE}:${PORT}/"; do (( t++ >= 60 )) && return 1; sleep 1; done; } finish() { # $1 = error message, empty on success echo "=== done $(date -Iseconds) ${1:+FAILED: $1 }===" OTA_UPDATE_ERROR="$1" PORTALIS_DIR="$DIR" bash "$DIR/ota/check.sh" [[ -z "$1" ]] && exit 0; exit 1 } exec >> "$LOG" 2>&1 echo "=== update $(date -Iseconds) ===" old=$(docker image inspect --format '{{.Id}}' "${IMAGE_REPO}:${TAG}" 2>/dev/null || true) stage pull docker compose pull || finish "image pull failed (see ota-state/update.log)" stage restart if docker compose up -d && { stage health; healthy; }; then # The host scripts (this one included) ship with the installer, not with the # image, so a release that changes them only lands if we fetch them here. # refresh-ota rewrites ota/*.sh + the units and nothing else — no pull, no # restart, so it cannot recurse back into this update — and it renames the # new files into place, so this running script keeps its own inode. curl -fsSL https://get.portalis.sh | bash -s -- refresh-ota >>"$LOG" 2>&1 || echo "--- refresh-ota failed (non-fatal)" finish "" fi echo "--- new image did not come online, rolling back to ${old:-}" stage rollback # ponytail: rollback = point the tag back at the old image ID; no separate # rollback tag, no compose edits. The next check still reports the update. [[ -n "$old" ]] && docker tag "$old" "${IMAGE_REPO}:${TAG}" && docker compose up -d finish "update failed, rolled back to the previous image (see ota-state/update.log)" SH cat > "$DIR/ota/recheck.sh.tmp" <<'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 for f in check.sh update.sh recheck.sh; do chmod +x "$DIR/ota/$f.tmp" mv -f "$DIR/ota/$f.tmp" "$DIR/ota/$f" done mkdir -p "$UNITS" cat > "$UNITS/portalis-ota-check.service" < "$UNITS/portalis-ota-check.timer" < "$UNITS/portalis-ota-apply.service" < "$UNITS/portalis-ota-apply.path" < "$UNITS/portalis-ota-recheck.service" < "$UNITS/portalis-ota-recheck.path" </dev/null 2>&1 || true } # setup_ota: what a full install/update run does — write the files, seed a first # status, arm the units. 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 write_ota_files # Seed an initial status so the app reports "managed" right away. PORTALIS_DIR="$DIR" "$DIR/ota/check.sh" 2>/dev/null || true ota_units_reload info "auto-updater installed (checks every 30 min; one-click update in Settings → About)" } # refresh-ota: rewrite the host OTA scripts and units, and nothing else. # ota/update.sh calls this after a successful update so a new release can ship # a new checker/updater. Deliberately does NOT pull an image, restart the stack, # touch .env or run update.sh — the last of those would recurse straight back # into the update that called it. refresh_ota() { step "Refreshing the Portalis OTA host scripts in $DIR" [[ $(id -u) -eq 0 ]] || die "run as root (sudo)" [[ -d "$DIR" ]] || die "$DIR does not exist — run the installer first" write_ota_files if command -v systemctl >/dev/null 2>&1 && [[ -d /run/systemd/system ]]; then ota_units_reload info "rewrote ota/check.sh, ota/update.sh, ota/recheck.sh and the portalis-ota-* units" else info "rewrote ota/check.sh, ota/update.sh, ota/recheck.sh (no systemd — units not armed)" fi exit 0 } # Every run is appended to a log, so support can ask for one file instead of # a screenshot. The file copy has colours and spinner frames stripped and the # bootstrap password masked; the terminal output is unchanged. LOGFILE=${PORTALIS_LOG:-/var/log/portalis-install.log} if touch "$LOGFILE" 2>/dev/null; then chmod 0600 "$LOGFILE" printf -- '── %s args: %s (%s)\n' "$(date -Is)" "${*:-install}" "$(. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-unknown os}")" >> "$LOGFILE" exec > >(tee >(sed -u 's/.*\r//; s/\x1b\[[0-9;]*m//g; s/\(Password *: \).*/\1********/' >> "$LOGFILE")) 2>&1 fi # refresh-ota is dispatched here, before the banner: ota/update.sh calls it, so # it must print log lines only — no ASCII art and no bootstrap-password box. [[ "${1:-}" == refresh-ota ]] && refresh_ota 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" [[ $(id -u) -eq 0 ]] || die "run as root (sudo)" [[ "${1:-}" == uninstall ]] && uninstall "${2:-}" command -v openssl >/dev/null || die "openssl not found" command -v curl >/dev/null || die "curl not found" need_docker 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 || hostname)} EOF elif [[ -f .installed ]]; then info "existing installation — keeping .env, updating image" # Installs from before 1.0.23 have no instance name yet; add the host's. grep -q '^PORTALIS_INSTANCE_NAME=' .env || echo "PORTALIS_INSTANCE_NAME=${PORTALIS_INSTANCE_NAME:-$(hostname -f 2>/dev/null || hostname)}" >> .env else info "previous run did not finish — resuming with the existing .env" fi BOOTSTRAP_PW=$(grep '^PORTALIS_BOOTSTRAP_PASSWORD=' .env | cut -d= -f2-) # .installed is written only once the stack is healthy (B2): until then the # bootstrap password is still "new" and must be printed again on re-run. FRESH=1; [[ -f .installed ]] && FRESH=0 # Remember the deployment settings for re-runs and the OTA scripts. Derived # values (TRUST_PROXY, TRUSTED_PROXIES) are stored only when set explicitly, so # they keep following PORTALIS_BIND. sed -i '/^# Deployment settings/d;/^PORTALIS_\(IMAGE\|VERSION\|PORT\|SSH_PORT\|BIND\|TRUST_PROXY\|TRUSTED_PROXIES\)=/d' .env cat >> .env </dev/null || true # ── 3/4 pull & start ──────────────────────────────────────────────── step "[3/4] Pulling image and starting containers" info "image: ${IMAGE_REPO}:${VERSION}" docker compose pull -q docker compose up -d # ── 4/4 wait for health ───────────────────────────────────────────── step "[4/4] Waiting for Portalis to come online" UP=0; start=$SECONDS; frames='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'; i=0 while (( SECONDS - start < 120 )); do if curl -fsS -o /dev/null "http://${PROBE}:${PORT}/" 2>/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 — configuration is kept in ${DIR}/.env." exit 1 fi touch .installed # 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} [[ "$BIND" == 0.0.0.0 ]] || HOST=$BIND 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 not shown again.${RST}\n" echo " (It stays in ${DIR}/.env as PORTALIS_BOOTSTRAP_PASSWORD until first login replaces it.)" 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 # Portalis carries SSH credentials and records full terminal sessions, and it # listens on plain HTTP. Saying nothing here leaves people to discover that the # hard way, so spell it out at the one moment they are certain to be reading. if [[ "$BIND" == "127.0.0.1" || "$BIND" == "::1" ]]; then printf " ${GREEN}✔ Web UI bound to %s — only reachable through your proxy.${RST}\n" "$BIND" echo else printf " ${YEL}⚠ This install serves plain HTTP — there is no TLS.${RST}\n" echo " Passwords, 2FA codes and everything typed in a terminal session" echo " travel unencrypted over your network." echo echo " Fine on a trusted, isolated network. For anything else, put TLS in" echo " front of it. With Caddy that is two lines:" echo printf " ${BOLD}portalis.your-domain.com {${RST}\n" printf " ${BOLD} reverse_proxy 127.0.0.1:%s${RST}\n" "$PORT" printf " ${BOLD}}${RST}\n" echo echo " Then re-run this installer with PORTALIS_BIND=127.0.0.1 so the app is" echo " no longer reachable directly from the network." echo fi