#!/usr/bin/env bash # # AppCrane installer for fresh Ubuntu servers. # # Interactive: # curl -fsSL https://raw.githubusercontent.com/gitayg/appCrane/main/install.sh | sudo bash # sudo bash install.sh # # Non-interactive (CI / automation): # sudo CRANE_DOMAIN=crane.example.com \ # ADMIN_EMAIL=admin@example.com \ # bash install.sh # # Or via flags: # sudo bash install.sh --domain crane.example.com \ # --admin-email admin@example.com \ # --admin-name "Admin" \ # --tls-cert /etc/caddy/certs/fullchain.pem \ # --tls-key /etc/caddy/certs/privkey.pem # # --tls-cert / --tls-key: skip ACME and use a pre-provisioned cert (required for # HSTS-preloaded domains where browsers block HTTP challenges). # # Flags override env vars. ADMIN_NAME defaults to "admin" when not set. # Re-running is safe — every step is idempotent. set -euo pipefail NODE_MAJOR=22 REPO_URL="https://github.com/gitayg/appCrane.git" log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; } # --- flag parsing (override env vars when provided) ---------------------- while [[ $# -gt 0 ]]; do case "$1" in --domain) CRANE_DOMAIN="${2:?--domain requires a value}"; shift 2 ;; --admin-email) ADMIN_EMAIL="${2:?--admin-email requires a value}"; shift 2 ;; --admin-name) ADMIN_NAME="${2:?--admin-name requires a value}"; shift 2 ;; --tls-cert) TLS_CERT_FILE="${2:?--tls-cert requires a value}"; shift 2 ;; --tls-key) TLS_KEY_FILE="${2:?--tls-key requires a value}"; shift 2 ;; -h|--help) sed -n '2,/^$/p' "$0" | grep '^#' | sed 's/^# \?//' exit 0 ;; *) die "Unknown flag: $1 (supported: --domain, --admin-email, --admin-name, --tls-cert, --tls-key)" ;; esac done # ---------- pre-flight --------------------------------------------------- [[ "$(uname -s)" == "Linux" ]] || die "This installer targets Linux. Detected: $(uname -s)" [[ $EUID -eq 0 ]] || die "Run as root: sudo bash install.sh" RUN_USER="${SUDO_USER:-root}" RUN_HOME="$(getent passwd "$RUN_USER" | cut -d: -f6)" [[ -n "$RUN_HOME" ]] || die "Could not resolve home directory for $RUN_USER" # If we're inside an existing checkout, install from here. Otherwise clone. if [[ -f "$(dirname "${BASH_SOURCE[0]}")/package.json" ]]; then REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" else REPO_DIR="$RUN_HOME/appCrane" fi log "Install user: $RUN_USER ($RUN_HOME)" log "Repo dir: $REPO_DIR" # ---------- TLS cert pre-flight ------------------------------------------ # Manual TLS certs are an operator prerequisite — the installer references the # paths you pass, it does not fetch certs. Validate early so a missing/typo'd # path fails here with a clear message instead of silently proceeding and # leaving Caddy to fail later trying to load a nonexistent cert. Both come as a # pair. (No flags = ACME, nothing to check.) if [[ -n "${TLS_CERT_FILE:-}" || -n "${TLS_KEY_FILE:-}" ]]; then [[ -n "${TLS_CERT_FILE:-}" ]] || die "TLS_KEY_FILE is set but TLS_CERT_FILE is not — provide both (or neither, to use automatic HTTPS)." [[ -n "${TLS_KEY_FILE:-}" ]] || die "TLS_CERT_FILE is set but TLS_KEY_FILE is not — provide both (or neither, to use automatic HTTPS)." [[ -f "$TLS_CERT_FILE" ]] || die "TLS_CERT_FILE '$TLS_CERT_FILE' does not exist. Place the certificate there first, then re-run." [[ -f "$TLS_KEY_FILE" ]] || die "TLS_KEY_FILE '$TLS_KEY_FILE' does not exist. Place the private key there first, then re-run." log "TLS certs found: $TLS_CERT_FILE / $TLS_KEY_FILE" fi # ---------- system packages ---------------------------------------------- log "Installing base packages" export DEBIAN_FRONTEND=noninteractive apt-get update -y apt-get install -y curl ca-certificates gnupg lsb-release git build-essential \ debian-keyring debian-archive-keyring apt-transport-https jq sudo # ---------- Node.js ------------------------------------------------------ if ! command -v node >/dev/null 2>&1 || [[ "$(node -v | sed 's/v//;s/\..*//')" -lt $NODE_MAJOR ]]; then log "Installing Node.js ${NODE_MAJOR}.x" curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - apt-get install -y nodejs else log "Node $(node -v) already installed" fi # ---------- Caddy -------------------------------------------------------- if ! command -v caddy >/dev/null 2>&1; then log "Installing Caddy" # --batch --yes: non-interactive + auto-overwrite. Without them, a keyring # left by an earlier/partial run makes gpg prompt "overwrite?" on /dev/tty, # which fails with no TTY (piped SSH / CI) and breaks the curl pipe. curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/gpg.key \ | gpg --dearmor --batch --yes -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt \ > /etc/apt/sources.list.d/caddy-stable.list apt-get update -y apt-get install -y caddy else log "Caddy already installed" fi systemctl enable --now caddy if ! curl -fsS http://localhost:2019/config/ >/dev/null 2>&1; then warn "Caddy admin API not reachable at :2019 — AppCrane needs it for routing." warn "Check 'systemctl status caddy'." fi # ENH-002: Caddy file permissions + sudoers so AppCrane can manage Caddy log "Configuring Caddy permissions for $RUN_USER" mkdir -p /etc/caddy/sites if ! getent group caddy >/dev/null 2>&1; then groupadd --system caddy fi usermod -aG caddy "$RUN_USER" 2>/dev/null || true chown root:caddy /etc/caddy /etc/caddy/sites chmod 775 /etc/caddy /etc/caddy/sites [[ -f /etc/caddy/Caddyfile ]] && chown root:caddy /etc/caddy/Caddyfile && chmod 664 /etc/caddy/Caddyfile # Manual TLS certs (--tls-cert/--tls-key) must be readable by the `caddy` user # that runs the proxy — AppCrane emits `tls ` into the Caddyfile and # Caddy reads those paths at load. A root-owned 600 cert/key (the usual way they # land) fails with a permission error. Make them caddy-group-readable (the key # stays non-world-readable) and ensure the containing dir is traversable. for _crt in "${TLS_CERT_FILE:-}" "${TLS_KEY_FILE:-}"; do [[ -n "$_crt" && -f "$_crt" ]] || continue chown root:caddy "$_crt" 2>/dev/null || true chmod 640 "$_crt" _cdir="$(dirname "$_crt")" chgrp caddy "$_cdir" 2>/dev/null || true chmod g+rx "$_cdir" 2>/dev/null || true log "Made TLS file caddy-readable: $_crt" done SUDOERS_FILE="/etc/sudoers.d/appcrane-caddy" mkdir -p /etc/sudoers.d # created by the sudo package, but be defensive on minimal images cat > "$SUDOERS_FILE" <=0.106) reads JS rules from /etc/polkit-1/rules.d/. Root installs don't # need it, but it's harmless there. POLKIT_RULE="/etc/polkit-1/rules.d/49-appcrane-caddy.rules" if [[ -d /etc/polkit-1/rules.d ]]; then log "Writing polkit rule so $RUN_USER can reload/restart caddy.service" cat > "$POLKIT_RULE" </dev/null || systemctl restart polkit.service 2>/dev/null || true log "polkit rule written: $POLKIT_RULE" else warn "/etc/polkit-1/rules.d not found — if AppCrane runs non-root, Caddy reloads may need manual polkit/sudo setup." fi # ---------- clone / install repo ----------------------------------------- if [[ ! -d "$REPO_DIR/.git" ]]; then log "Cloning AppCrane to $REPO_DIR" sudo -u "$RUN_USER" -H git clone "$REPO_URL" "$REPO_DIR" fi log "Installing npm dependencies" sudo -u "$RUN_USER" -H bash -c "cd '$REPO_DIR' && npm install" log "Linking 'crane' CLI globally" ( cd "$REPO_DIR" && npm link ) # ---------- .env --------------------------------------------------------- ENV_FILE="$REPO_DIR/.env" # ask VAR "Prompt text" [default] # Uses the value already in $VAR (set via env or flag) without prompting. # Falls back to default without prompting when no TTY is available. # Only opens /dev/tty when a value is genuinely needed interactively. ask() { local var="$1" prompt="$2" default="${3:-}" val # Already provided via env var or flag — use it as-is. val="${!var:-}" if [[ -n "$val" ]]; then log " $prompt: $val" printf -v "$var" '%s' "$val" return fi # No value — use default silently if no TTY (non-interactive mode). if [[ -n "$default" ]] && [[ ! -e /dev/tty ]]; then printf -v "$var" '%s' "$default" return fi # Interactive prompt — requires a TTY. [[ -e /dev/tty ]] || die "No TTY and \$$var not set. Pass via env or flag: $var=... bash install.sh or --$(echo "$var" | tr '[:upper:]_' '[:lower:]-') VALUE" while :; do if [[ -n "$default" ]]; then printf '%s [%s]: ' "$prompt" "$default" > /dev/tty else printf '%s: ' "$prompt" > /dev/tty fi IFS= read -r val < /dev/tty || val="" val="${val:-$default}" [[ -n "$val" ]] && { printf -v "$var" '%s' "$val"; return; } printf ' (required)\n' > /dev/tty done } if [[ ! -f "$ENV_FILE" ]]; then log "Creating .env" ask CRANE_DOMAIN "AppCrane domain (e.g. crane.example.com)" ENCRYPTION_KEY="$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')" cat > "$ENV_FILE" </dev/null 2>&1; then log "Installing Docker" apt-get install -y docker.io else log "Docker already installed: $(docker --version)" fi if ! id -nG "$RUN_USER" | tr ' ' '\n' | grep -qx docker; then log "Adding $RUN_USER to docker group" usermod -aG docker "$RUN_USER" fi # Repair the #1 auto-fixable cause of "no sockets found via socket activation": # a "hosts" key in /etc/docker/daemon.json fixes dockerd's listener, which then # conflicts with the -H fd:// docker.socket passes → the daemon won't start. # Remove that key (back the file up first) so socket activation works. Also flag # a daemon.json that isn't valid JSON, since that breaks the daemon on its own. DJSON=/etc/docker/daemon.json if [[ -f "$DJSON" ]]; then if ! jq empty "$DJSON" >/dev/null 2>&1; then warn "$DJSON is not valid JSON — Docker won't start until it's fixed or removed." elif jq -e 'has("hosts")' "$DJSON" >/dev/null 2>&1; then warn "Removing conflicting \"hosts\" from $DJSON (backed up) — it breaks socket activation" cp "$DJSON" "${DJSON}.appcrane-bak.$(date +%s)" _tmp="$(mktemp)"; jq 'del(.hosts)' "$DJSON" > "$_tmp" && mv "$_tmp" "$DJSON" fi fi # A docker.io + Docker-CE mix is a mixed-source Docker that can wedge the socket. # apt refuses docker.io + docker-ce together, but an earlier get.docker.com run # leaves CE add-ons (docker-ce-rootless-extras, docker-ce-cli, …) that don't # conflict at the package level yet still muddy the runtime — so match any # docker-ce* package, not just the exact `docker-ce`. _installed="$(dpkg -l 2>/dev/null | awk '$1=="ii"{print $2}')" if grep -qx docker.io <<<"$_installed" && grep -qE '^docker-ce' <<<"$_installed"; then warn "docker.io and Docker-CE package(s) are both installed — a mixed-source Docker:" warn " $(grep -E '^docker-ce' <<<"$_installed" | paste -sd' ')" warn " Standardize on docker.io (what this installer uses) by removing the CE bits:" warn " apt-get remove -y '^docker-ce.*'" fi # Cap journald, syslog and Docker logs and keep a disk reserve — a full disk # once took a box down. Runs before the Docker restart below so a fresh install's # default log-opts take effect now. A failure here must not abort the install. log "Hardening host logs and disk headroom" bash "$REPO_DIR/scripts/host-hardening.sh" \ || warn "scripts/host-hardening.sh failed — re-run it: sudo bash $REPO_DIR/scripts/host-hardening.sh" # A root-owned copy plus a sudoers rule, so a non-root AppCrane can re-apply it # from Settings > Job Health > Host limits without sudoing the repo copy. bash "$REPO_DIR/scripts/install-host-hardening.sh" "$RUN_USER" \ || warn "scripts/install-host-hardening.sh failed — Host limits can only be applied by hand: sudo bash $REPO_DIR/scripts/install-host-hardening.sh" # Bring Docker up robustly. dockerd runs with -H fd:// and gets its listener from # docker.socket, so the socket must come up FIRST — starting docker.service alone # yields "no sockets found via socket activation". Repeated failed starts also # leave systemd rate-limiting further attempts, so clear failed state first. log "Ensuring Docker daemon is running" systemctl reset-failed docker.socket docker.service 2>/dev/null || true systemctl enable docker.socket docker.service 2>/dev/null || true systemctl start docker.socket 2>/dev/null || true systemctl restart docker.service 2>/dev/null || systemctl start docker.service 2>/dev/null || true # Verify the daemon actually answers before proceeding — a half-up Docker would # only surface later as AppCrane's dependency-failed start. if ! timeout 45 bash -c 'until docker version >/dev/null 2>&1; do sleep 1; done'; then warn "Docker daemon still not responding after repair attempts." warn " systemctl status docker.socket docker.service # see the underlying error" warn " journalctl -u docker -n 30 --no-pager" die "Docker must be running before AppCrane can start (its unit Requires=docker.service)." fi log "Docker is running: $(docker --version)" # systemd service. Restart=always survives crashes/reboots and lets self-update # work (the updater exits; systemd re-execs on the new code). ExecStart is # scripts/safe-boot.sh — an out-of-process wrapper around `node server/index.js` # that git-resets to the previous SHA if a self-update crashes on boot (a # migration can kill node before the in-process rollback runs). The app reads # its .env from WorkingDirectory; safe-boot.sh needs `node` on PATH (nodesource # installs /usr/bin/node) and jq (installed above). log "Writing systemd service" cat > /etc/systemd/system/appcrane.service < "/etc/systemd/system/$unit.d/50-appcrane-cpu-priority.conf" fi done systemctl daemon-reload for unit in caddy.service ssh.service sshd.service; do [[ -f "/etc/systemd/system/$unit.d/50-appcrane-cpu-priority.conf" ]] && systemctl set-property --runtime "$unit" CPUWeight=1000 2>/dev/null || true done systemctl enable --now appcrane.service # Wait for the API to come up. for _ in {1..20}; do curl -fsS http://localhost:5001/api/info >/dev/null 2>&1 && break sleep 0.5 done # ---------- admin init --------------------------------------------------- CONFIG_FILE="$RUN_HOME/.appcrane/config.json" if [[ -f "$CONFIG_FILE" ]] && jq -e .key "$CONFIG_FILE" >/dev/null 2>&1; then log "Admin already initialized — skipping crane init" else ask ADMIN_NAME "Admin name" "admin" ask ADMIN_EMAIL "Admin email" log "Initializing admin user" sudo -u "$RUN_USER" -H crane init --name "$ADMIN_NAME" --email "$ADMIN_EMAIL" fi # ---------- done --------------------------------------------------------- cat <} Admin key: $CONFIG_FILE systemd: systemctl status appcrane | journalctl -u appcrane -f Docker: docker ps --filter label=appcrane=true Next: 1. Point DNS for ${CRANE_DOMAIN:-your crane domain} at this server. 2. crane app create --name MyApp --slug myapp --repo https://github.com/... → reachable at https://${CRANE_DOMAIN:-your crane domain}/myapp 3. Back up ${ENV_FILE} — losing ENCRYPTION_KEY bricks every stored env var. EOF