Ctrl K
ComunidadGuías…

install.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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 <cert> <key>` 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" <<SUDOERS
# Allow AppCrane runtime user to reload/restart Caddy without a password
${RUN_USER} ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload caddy
${RUN_USER} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart caddy
SUDOERS
chmod 440 "$SUDOERS_FILE"
visudo -cf "$SUDOERS_FILE" || { rm -f "$SUDOERS_FILE"; warn "sudoers file invalid — Caddy reload will require manual intervention"; }
log "Sudoers written: $SUDOERS_FILE"

# polkit rule: AppCrane's code calls bare `systemctl reload caddy` (not `sudo`).
# When AppCrane runs as a non-root user (User=${RUN_USER} in the unit), that hits
# polkit → "Interactive authentication required" and the reload fails silently,
# so config changes never reach the running proxy (apps then get no identity
# headers). This grants ${RUN_USER} manage rights on caddy.service only. Ubuntu's
# polkit (>=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" <<POLKIT
// AppCrane: allow ${RUN_USER} to manage caddy.service without interactive auth.
polkit.addRule(function(action, subject) {
  if (action.id == "org.freedesktop.systemd1.manage-units" &&
      action.lookup("unit") == "caddy.service" &&
      subject.user == "${RUN_USER}") {
    return polkit.Result.YES;
  }
});
POLKIT
  chmod 644 "$POLKIT_RULE"
  systemctl restart polkit 2>/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" <<EOF
PORT=5001
HOST=0.0.0.0

# DO NOT lose this key — env vars cannot be decrypted without it.
ENCRYPTION_KEY=${ENCRYPTION_KEY}

SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=appcrane@${CRANE_DOMAIN}

CRANE_DOMAIN=${CRANE_DOMAIN}

# Optional: provide manual TLS cert/key to skip ACME (required for HSTS-preloaded domains)
TLS_CERT_FILE=${TLS_CERT_FILE:-}
TLS_KEY_FILE=${TLS_KEY_FILE:-}

CADDY_ADMIN_URL=http://localhost:2019
DATA_DIR=${REPO_DIR}/data
EOF
  chown "$RUN_USER":"$RUN_USER" "$ENV_FILE"
  chmod 600 "$ENV_FILE"
  log ".env written (chmod 600)"
else
  log ".env already exists — leaving untouched"
fi

# ---------- Docker + systemd setup (idempotent) --------------------------

# Docker — apps run as containers. Install if missing, add the run user to the
# docker group, and — critically — make sure the daemon is actually RUNNING, not
# just present. AppCrane's unit has Requires=docker.service, so a wedged Docker
# means AppCrane never starts. Idempotent.
if ! command -v docker >/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 <<UNIT
[Unit]
Description=AppCrane — self-hosted deployment manager
After=network.target docker.service
Requires=docker.service

[Service]
Type=simple
User=${RUN_USER}
WorkingDirectory=${REPO_DIR}
ExecStart=${REPO_DIR}/scripts/safe-boot.sh
Restart=always
RestartSec=3
# v2.58.0: stop an unfixable crash from restarting forever. A Node major upgrade
# that broke better-sqlite3's ABI produced 10,394 restarts before anyone noticed
# — eight hours of a 3-second loop, filling the journal while the service was
# down the whole time. safe-boot.sh now repairs that specific case on the way
# up, but a genuinely broken release should stop and stay stopped rather than
# churn: the service is equally unavailable either way, and a stopped unit is
# visible where a looping one hides in the logs.
StartLimitIntervalSec=300
StartLimitBurst=10
StandardOutput=journal
StandardError=journal
KillSignal=SIGTERM
TimeoutStopSec=20
LimitNOFILE=65535
# Under whole-host memory pressure the kernel kills app containers first: they
# run with --oom-score-adj=500 (databases 300), AppCrane with -500.
OOMScoreAdjust=-500
# Under CPU contention AppCrane, Caddy and sshd outweigh containers: apps run
# with --cpu-shares=128, databases 256, builds 2 (cgroup weight 1 to 35), where an
# unflagged container and an unconfigured service both weigh 100.
CPUWeight=1000

[Install]
WantedBy=multi-user.target
UNIT
chmod 644 /etc/systemd/system/appcrane.service
for unit in caddy.service ssh.service sshd.service; do
  if [[ -f "/lib/systemd/system/$unit" || -f "/usr/lib/systemd/system/$unit" || -f "/etc/systemd/system/$unit" ]]; then
    mkdir -p "/etc/systemd/system/$unit.d"
    printf '%s\n' '# Written by AppCrane. Under CPU contention this service outweighs app containers' \
      '# (--cpu-shares 128 to 256) so the site and SSH stay reachable.' '[Service]' 'CPUWeight=1000' \
      > "/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 <<EOF

$(printf '\033[1;32mAppCrane is up.\033[0m')

  API:        http://localhost:5001
  Domain:     https://${CRANE_DOMAIN:-<set in .env>}
  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