#!/bin/sh
# qbox installer — brings the single-host stack up on a Linux box with KVM.
#
#   curl -fsSL https://qbox.sh/install.sh | sh
#
# Idempotent: re-running re-downloads the latest docker-compose.yml, pulls new
# images, and recreates changed services. Your .env and data are left untouched.
#
# Override anything via environment, e.g.:
#   curl -fsSL https://qbox.sh/install.sh | QBOX_SITE_ADDRESS=qbox.acme.com QBOX_DATA_DIR=/mnt/data/qbox sh
set -eu

# ---- configuration (override via env) -------------------------------------
QBOX_HOME="${QBOX_HOME:-$HOME/qbox}"           # install dir (compose + .env + assets)
QBOX_DATA_DIR="${QBOX_DATA_DIR:-/var/lib/qbox}" # persistent data (put on xfs/btrfs)
ASSETS_DIR="${ASSETS_DIR:-$QBOX_HOME/assets}"
QBOX_SITE_ADDRESS="${QBOX_SITE_ADDRESS:-}"     # domain / IP for TLS; auto-detected if empty
QBOX_IMAGE_REPO="${QBOX_IMAGE_REPO:-ghcr.io/utibeabasi6}"
QBOX_VERSION="${QBOX_VERSION:-latest}"

COMPOSE_URL="${QBOX_COMPOSE_URL:-https://qbox.sh/install/docker-compose.yml}"
FC_VERSION="${QBOX_FIRECRACKER_VERSION:-v1.12.1}"   # must be >= 1.12.0

# Pinned SHA-256 of the firecracker release tarball (firecracker-<ver>-<arch>.tgz).
# PUBLISHER: fill this in for the pinned FC_VERSION/ARCH (firecracker publishes a
# SHA256SUMS with each GitHub release). Left empty → the tarball is downloaded
# over HTTPS but not checksum-verified (a warning is printed). Override per-arch
# via QBOX_FIRECRACKER_SHA256.
FC_SHA256="${QBOX_FIRECRACKER_SHA256:-}"

# Where the guest assets (vmlinux/qboxd/dropbear) are served from: a Cloudflare
# CDN in front of the private qbox-assets bucket. Override QBOX_ASSET_BASE_URL
# to pin a version (e.g. .../v1.2.3) or to point at your own mirror.
QBOX_ASSET_BASE_URL="${QBOX_ASSET_BASE_URL:-https://assets.qbox.sh/latest}"

# Pinned SHA-256 of each guest asset for this release. Same-origin .sha256
# sidecars are NOT trusted: an attacker who replaces the asset on the CDN can
# replace the sidecar too. PUBLISHER: fill these in at release time (mirrors
# FC_SHA256). Override per-host via QBOX_<NAME>_SHA256.
QBOX_VMLINUX_SHA256="${QBOX_VMLINUX_SHA256:-}"
QBOX_QBOXD_SHA256="${QBOX_QBOXD_SHA256:-}"
QBOX_DROPBEAR_SHA256="${QBOX_DROPBEAR_SHA256:-}"

# ---- output helpers -------------------------------------------------------
if [ -t 1 ]; then B="$(printf '\033[1m')"; G="$(printf '\033[32m')"; Y="$(printf '\033[33m')"; R="$(printf '\033[31m')"; X="$(printf '\033[0m')"; else B=; G=; Y=; R=; X=; fi
say()  { printf '%s==>%s %s\n' "$G$B" "$X" "$*"; }
warn() { printf '%s!! %s%s\n' "$Y" "$*" "$X"; }
die()  { printf '%sxx %s%s\n' "$R" "$*" "$X" >&2; exit 1; }

# sudo only when not already root.
if [ "$(id -u)" -eq 0 ]; then SUDO=""; else SUDO="sudo"; fi
need_sudo() {
  if [ -n "$SUDO" ] && ! command -v sudo >/dev/null 2>&1; then
    die "this step needs root; run as root or install sudo"
  fi
}

have() { command -v "$1" >/dev/null 2>&1; }

# ---- 0. host checks -------------------------------------------------------
[ "$(uname -s)" = "Linux" ] || die "qbox hosts must be Linux (got $(uname -s))."
case "$(uname -m)" in
  x86_64|amd64) ARCH=x86_64 ;;
  *) die "unsupported architecture $(uname -m); qbox requires x86_64." ;;
esac
have curl || have wget || die "need curl or wget."

dl() { # dl <url> <dest>
  if have curl; then curl -fsSL -o "$2" "$1"; else wget -qO "$2" "$1"; fi
}

# sha256_of <file> → lowercase hex digest, or "" if no tool available.
sha256_of() {
  if have sha256sum; then sha256sum "$1" | awk '{print $1}'
  elif have shasum; then shasum -a 256 "$1" | awk '{print $1}'
  else echo ""; fi
}

# verify_sha256 <file> <expected-hex> — dies on mismatch; dies if no hashing tool.
verify_sha256() {
  _got="$(sha256_of "$1")"
  [ -n "$_got" ] || die "no sha256sum/shasum available to verify $1"
  # normalize to lowercase for comparison
  _want="$(printf '%s' "$2" | tr 'A-Z' 'a-z')"
  _got="$(printf '%s' "$_got" | tr 'A-Z' 'a-z')"
  if [ "$_got" != "$_want" ]; then
    rm -f "$1"
    die "checksum mismatch for $1: got $_got want $_want (refusing to use a tampered/corrupt asset)"
  fi
}

say "qbox installer — home=$QBOX_HOME data=$QBOX_DATA_DIR"

# ---- 1. Docker ------------------------------------------------------------
if have docker && docker compose version >/dev/null 2>&1; then
  say "Docker + compose plugin present."
else
  warn "Docker (with the compose plugin) not found — installing via get.docker.com."
  need_sudo
  tmp="$(mktemp)"; dl https://get.docker.com "$tmp"; $SUDO sh "$tmp" </dev/null; rm -f "$tmp"
  have docker || die "Docker install failed."
  docker compose version >/dev/null 2>&1 || die "Docker installed but the compose plugin is missing."
  $SUDO systemctl enable --now docker 2>/dev/null || true
fi

# ---- 2. KVM ---------------------------------------------------------------
if [ ! -e /dev/kvm ]; then
  warn "/dev/kvm is missing — qbox needs hardware virtualization."
  warn "Use bare metal, a DigitalOcean Droplet, an AWS .metal instance, or another"
  warn "VM with nested virtualization enabled. See https://qbox.sh/docs/requirements"
  die "no /dev/kvm on this host."
fi
say "/dev/kvm present."

# ---- 3. kernel modules: TUN/TAP + vsock -----------------------------------
if [ -e /dev/net/tun ]; then
  say "TUN/TAP present."
else
  warn "TUN module not loaded — loading it."
  need_sudo
  $SUDO modprobe tun || die "failed to load the tun module."
  echo tun | $SUDO tee /etc/modules-load.d/tun.conf >/dev/null   # persist across reboots
  [ -e /dev/net/tun ] || die "TUN still unavailable after modprobe."
fi

# vsock host support — qboxd (PID 1 in each microVM) talks to the host over
# vsock; without vhost_vsock the qboxd READY handshake times out and template
# builds / spawns fail at "qboxd_not_ready". Best-effort: warn, don't abort (some
# kernels build it in, and the guest-side virtio-vsock is what ultimately matters).
if $SUDO modprobe vhost_vsock 2>/dev/null; then
  echo vhost_vsock | $SUDO tee /etc/modules-load.d/vhost_vsock.conf >/dev/null
  say "vsock host support loaded (vhost_vsock)."
else
  warn "could not load vhost_vsock — if builds/spawns fail at 'qboxd_not_ready', your kernel needs vsock host support."
fi

# ---- 4. layout ------------------------------------------------------------
mkdir -p "$QBOX_HOME" "$ASSETS_DIR"
$SUDO mkdir -p "$QBOX_DATA_DIR"/pg "$QBOX_DATA_DIR"/nats "$QBOX_DATA_DIR"/rustfs \
  "$QBOX_DATA_DIR"/builder-workspace "$QBOX_DATA_DIR"/host-agent-data \
  "$QBOX_DATA_DIR"/caddy "$QBOX_DATA_DIR"/caddy-config "$QBOX_DATA_DIR"/cp-metrics
# The control plane runs as uid 10001 (non-root) and writes its metrics TSDB
# here; a bind mount keeps the host dir's ownership, so make it writable.
$SUDO chown 10001:10001 "$QBOX_DATA_DIR"/cp-metrics
cd "$QBOX_HOME"

# ---- 5. host assets -------------------------------------------------------
# Always (re)pull every asset and atomically swap it into place. Replacing a
# file via rename is safe for running sandboxes: the jailer copies firecracker
# into each VM's chroot at spawn, and the kernel/qboxd are baked into template
# snapshots — so running VMs keep their own copies; new assets only affect future
# spawns/builds (rebuild templates + restart the builder to adopt a new kernel).
say "fetching firecracker + jailer $FC_VERSION"
tgz="$(mktemp)"; tmpd="$(mktemp -d)"
dl "https://github.com/firecracker-microvm/firecracker/releases/download/${FC_VERSION}/firecracker-${FC_VERSION}-${ARCH}.tgz" "$tgz"
if [ -n "$FC_SHA256" ]; then
  verify_sha256 "$tgz" "$FC_SHA256"
else
  die "QBOX_FIRECRACKER_SHA256 is not set — refusing to install an unverified microVM monitor that runs as root via jailer. Set QBOX_FIRECRACKER_SHA256 to the published GitHub release sha256, or bake one into install.sh at release time."
fi
tar -xzf "$tgz" -C "$tmpd"
chmod +x "$tmpd/release-${FC_VERSION}-${ARCH}/firecracker-${FC_VERSION}-${ARCH}" "$tmpd/release-${FC_VERSION}-${ARCH}/jailer-${FC_VERSION}-${ARCH}"
mv "$tmpd/release-${FC_VERSION}-${ARCH}/firecracker-${FC_VERSION}-${ARCH}" "$ASSETS_DIR/firecracker"
mv "$tmpd/release-${FC_VERSION}-${ARCH}/jailer-${FC_VERSION}-${ARCH}"      "$ASSETS_DIR/jailer"
rm -rf "$tgz" "$tmpd"

fetch_asset() { # fetch_asset <name> <pinned-sha256> <executable?>
  _name="$1"
  _pinned="$2"
  _envvar="QBOX_$(printf '%s' "$_name" | tr 'a-z.-' 'A-Z__')_SHA256"
  [ -n "$_pinned" ] || die "$_envvar is not set — refusing to install an unverified guest asset. Set the pinned sha256 from the release notes, or bake one into install.sh at release time."
  say "fetching $_name"
  _tmp="$ASSETS_DIR/$_name.tmp"
  dl "$QBOX_ASSET_BASE_URL/$_name" "$_tmp" || { rm -f "$_tmp"; die "failed to download $_name from $QBOX_ASSET_BASE_URL"; }
  verify_sha256 "$_tmp" "$_pinned"
  [ "${3:-}" = "x" ] && chmod +x "$_tmp"
  mv "$_tmp" "$ASSETS_DIR/$_name"   # atomic swap; already-open fds keep the old inode
  return 0
}
fetch_asset vmlinux  "$QBOX_VMLINUX_SHA256"
fetch_asset qboxd    "$QBOX_QBOXD_SHA256"    x
fetch_asset dropbear "$QBOX_DROPBEAR_SHA256" x

# verify the required binaries exist
for f in firecracker jailer vmlinux qboxd; do
  [ -s "$ASSETS_DIR/$f" ] || die "required asset missing: $ASSETS_DIR/$f"
done
[ -x "$ASSETS_DIR/firecracker" ] && [ -x "$ASSETS_DIR/jailer" ] || die "firecracker/jailer are not executable."
say "host assets ready."

# ---- 6. compose (always refreshed) ----------------------------------------
# docker-compose.yml is the most security-critical artifact in this install:
# a compromised compose can mount /:/host or set privileged:true and own the
# host on the next `docker compose up`. Reject any compose URL whose sha256
# isn't pinned out-of-band (env, or baked here at release time).
say "downloading latest docker-compose.yml"
_compose_tmp="$(mktemp)"
dl "$COMPOSE_URL" "$_compose_tmp" || { rm -f "$_compose_tmp"; die "failed to download compose from $COMPOSE_URL"; }
if [ -n "${QBOX_COMPOSE_SHA256:-}" ]; then
  verify_sha256 "$_compose_tmp" "$QBOX_COMPOSE_SHA256"
else
  die "QBOX_COMPOSE_SHA256 is not set — refusing to install an unverified docker-compose.yml. Either pin a release-specific sha256 via env or copy the file in by hand."
fi
mv "$_compose_tmp" "$QBOX_HOME/docker-compose.yml"

# ---- 7. .env (created once; never overwritten) ----------------------------
gen_secret() { if have openssl; then openssl rand -hex "${1:-24}"; else head -c "${1:-24}" /dev/urandom | od -An -tx1 | tr -d ' \n'; fi; }
detect_ip() { ip route get 1.1.1.1 2>/dev/null | sed -n 's/.*src \([0-9.]*\).*/\1/p' | head -1; }

if [ -z "$QBOX_SITE_ADDRESS" ]; then
  QBOX_SITE_ADDRESS="$(detect_ip)"
  [ -n "$QBOX_SITE_ADDRESS" ] || QBOX_SITE_ADDRESS=":443"
  warn "QBOX_SITE_ADDRESS not set — defaulting to $QBOX_SITE_ADDRESS (self-signed cert). Set a domain to get automatic HTTPS."
fi
# public origin for cookies/CORS
case "$QBOX_SITE_ADDRESS" in
  :*) QBOX_PUBLIC_URL="https://localhost" ;;
  http*://*) QBOX_PUBLIC_URL="$QBOX_SITE_ADDRESS" ;;
  *) QBOX_PUBLIC_URL="https://${QBOX_SITE_ADDRESS%%:*}" ;;
esac

# When a real domain is configured, Caddy obtains a Let's Encrypt cert — which
# requires the domain to ALREADY point at this host (A/AAAA) with ports 80 + 443
# reachable. Make that explicit (a missing/incorrect record is the #1 cause of
# "cert won't issue").
site_host="${QBOX_SITE_ADDRESS#http://}"; site_host="${site_host#https://}"; site_host="${site_host%%:*}"
site_is_domain=false
if [ -n "$site_host" ] && ! printf '%s' "$site_host" | grep -Eq '^([0-9]{1,3}\.){3}[0-9]{1,3}$'; then
  site_is_domain=true
  warn "TLS for '$site_host' uses Let's Encrypt — point $site_host at this host's PUBLIC IP (A/AAAA record) and open ports 80 + 443 first, or the certificate won't issue."
  if have getent && [ -z "$(getent hosts "$site_host" 2>/dev/null)" ]; then
    warn "Heads-up: '$site_host' does not resolve yet — add the DNS record before the cert can be obtained."
  fi
fi

# Port-forward previews live at *.preview.<QBOX_PROXY_DOMAIN>. Default it to the
# site domain so previews work once *.preview.<domain> DNS points here; leave it
# empty for an IP / bare-port deploy (previews need a real domain).
QBOX_PROXY_DOMAIN="${QBOX_PROXY_DOMAIN:-}"
if [ -z "$QBOX_PROXY_DOMAIN" ] && [ "$site_is_domain" = true ]; then
  QBOX_PROXY_DOMAIN="$site_host"
fi
if [ -n "$QBOX_PROXY_DOMAIN" ]; then
  warn "Previews use *.preview.$QBOX_PROXY_DOMAIN — add a wildcard DNS record (*.preview.$QBOX_PROXY_DOMAIN -> this host's public IP) to reach exposed sandbox ports."
fi

# Scope the session cookie to the domain so PRIVATE previews on
# *.preview.<domain> share the dashboard's login. Host-only (empty) cookies never
# reach the preview subdomain, so private previews just bounce to the dashboard.
QBOX_COOKIE_DOMAIN="${QBOX_COOKIE_DOMAIN:-$site_host}"
if [ "$site_is_domain" != true ]; then QBOX_COOKIE_DOMAIN=""; fi  # IP/bare-port: no shared cookie

# Browser sessions stream their WebRTC live view through the bundled coturn TURN
# relay (media can't traverse the host NAT otherwise). The viewer's browser is
# told where coturn is, so it must be a publicly reachable address — we reuse the
# SAME host qbox already runs on (QBOX_SITE_ADDRESS: your domain or IP), not a
# separately detected one. Only fall back to a detected IP if the site is bound
# without a host (e.g. ":443"). A NAT'd host can override QBOX_TURN_EXTERNAL_IP.
QBOX_TURN_HOST="$site_host"
[ -n "$QBOX_TURN_HOST" ] || QBOX_TURN_HOST="$(detect_ip)"
[ -n "$QBOX_TURN_HOST" ] || QBOX_TURN_HOST="127.0.0.1"
QBOX_TURN_PASSWORD_VAL="$(gen_secret 24)"
warn "Browser live view uses the bundled coturn TURN relay — open UDP 3478 and UDP 49160-49200 on this host's firewall (and any cloud firewall) or the WebRTC stream won't connect."

if [ -f "$QBOX_HOME/.env" ]; then
  say ".env already exists — keeping your secrets. (Edit it and re-run to change config.)"
else
  say "generating .env with fresh secrets"
  cat > "$QBOX_HOME/.env" <<EOF
# qbox config — generated by install.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ).
# Full reference: https://qbox.sh/docs/configuration

# --- public access / TLS ---
QBOX_SITE_ADDRESS=$QBOX_SITE_ADDRESS
QBOX_PUBLIC_URL=$QBOX_PUBLIC_URL
# false because previews are HTTP (QBOX_PROXY_SCHEME=http): a Secure cookie can't
# reach *.preview.<domain>, breaking PRIVATE previews. Set true once all HTTPS.
QBOX_COOKIE_SECURE=false
# Shares the dashboard login with private previews on *.preview.<domain>.
QBOX_COOKIE_DOMAIN=$QBOX_COOKIE_DOMAIN
# Bring your own cert: drop tls.crt + tls.key into this dir.
QBOX_TLS_DIR=./tls

# --- images ---
QBOX_IMAGE_REPO=$QBOX_IMAGE_REPO
QBOX_VERSION=$QBOX_VERSION

# --- data + assets ---
QBOX_DATA_DIR=$QBOX_DATA_DIR
ASSETS_DIR=$ASSETS_DIR

# --- secrets (rotate by editing here) ---
POSTGRES_PASSWORD=$(gen_secret 24)
QBOX_SESSION_SECRET=$(gen_secret 32)
QBOX_S3_ACCESS_KEY=qboxadmin
QBOX_S3_SECRET_KEY=$(gen_secret 24)
# Encrypts env vars + volume configs at rest. Do NOT rotate after storing
# secrets — existing encrypted data becomes undecryptable.
QBOX_ENV_MASTER_KEY=$(gen_secret 32)

# --- port forwarding / previews ---
# Previews are served at http://sb_<id>-<port>.preview.\$QBOX_PROXY_DOMAIN.
# Empty domain = previews disabled (needs a real domain + *.preview wildcard DNS).
# Scheme is http for now (HTTPS previews are deferred).
QBOX_PROXY_DOMAIN=$QBOX_PROXY_DOMAIN
QBOX_PROXY_SIGNING_KEY=$(gen_secret 32)
QBOX_PROXY_SCHEME=http
# Guards the control plane's /internal routing endpoint (shared CP <-> proxy).
QBOX_INTERNAL_SECRET=$(gen_secret 32)

# --- optional ---
QBOX_HA_LABEL=host-1
QBOX_S3_BUCKET=qbox-artifacts
# Data plane (control plane <-> host agent) runs without mTLS on this single
# trusted host; 9100 + the SSH-proxy range MUST stay firewalled off the internet
# and the sandbox subnet. The _ACK is required because production refuses
# insecure mTLS by default. For multi-host, provide mTLS certs and set both false.
QBOX_HOST_GRPC_INSECURE=true
QBOX_HOST_GRPC_INSECURE_ACK=true
QBOX_BUILDER_CONCURRENCY=2

# --- browser sessions: WebRTC live view via the bundled coturn TURN relay ---
# coturn is part of the default stack. The viewer's browser reaches it at
# QBOX_TURN_HOST (reused from your site address). A host behind 1:1 NAT (public IP
# != the address coturn binds) should set QBOX_TURN_EXTERNAL_IP to its public IP
# and uncomment the --external-ip line in the coturn service.
QBOX_TURN_HOST=$QBOX_TURN_HOST
QBOX_TURN_USER=qbox
QBOX_TURN_PASSWORD=$QBOX_TURN_PASSWORD_VAL
QBOX_BROWSER_ICESERVERS=[{"urls":["turn:$QBOX_TURN_HOST:3478?transport=udp"],"username":"qbox","credential":"$QBOX_TURN_PASSWORD_VAL"}]
EOF
  chmod 600 "$QBOX_HOME/.env"
fi
mkdir -p "$QBOX_HOME/tls"

# ---- 7b. backfill new settings on upgrade ---------------------------------
# Re-running after an upgrade: append any key this version expects that an older
# .env doesn't have, with a sensible default. Existing keys are never touched, so
# secrets and customizations are preserved. New installs already wrote these, so
# every call here is a no-op for them.
ensure_env() { # ensure_env KEY DEFAULT
  if ! grep -q "^$1=" "$QBOX_HOME/.env" 2>/dev/null; then
    printf '%s=%s\n' "$1" "$2" >> "$QBOX_HOME/.env"
    say "added missing $1 to .env (upgrade default)"
  fi
}
ensure_env QBOX_ENV_MASTER_KEY "$(gen_secret 32)"
ensure_env QBOX_COOKIE_DOMAIN "$QBOX_COOKIE_DOMAIN"
ensure_env QBOX_PROXY_DOMAIN "$QBOX_PROXY_DOMAIN"
ensure_env QBOX_PROXY_SIGNING_KEY "$(gen_secret 32)"
ensure_env QBOX_PROXY_SCHEME http
ensure_env QBOX_INTERNAL_SECRET "$(gen_secret 32)"
# Browser-session TURN relay (added in the WebRTC live-view release). On upgrade,
# add the relay creds so existing installs get working browser live view too.
ensure_env QBOX_TURN_HOST "$QBOX_TURN_HOST"
ensure_env QBOX_TURN_USER qbox
ensure_env QBOX_TURN_PASSWORD "$QBOX_TURN_PASSWORD_VAL"
ensure_env QBOX_BROWSER_ICESERVERS "[{\"urls\":[\"turn:$QBOX_TURN_HOST:3478?transport=udp\"],\"username\":\"qbox\",\"credential\":\"$QBOX_TURN_PASSWORD_VAL\"}]"

# ---- 8. bring it up -------------------------------------------------------
say "pulling images + starting the stack"
docker compose pull
docker compose up -d

echo
say "qbox is up."
case "$QBOX_SITE_ADDRESS" in
  :*) printf '   Open %shttps://<this-host>%s and complete the install flow (self-signed cert — expect a browser warning).\n' "$B" "$X" ;;
  *) printf '   Open %shttps://%s%s and complete the install flow.\n' "$B" "${QBOX_SITE_ADDRESS%%:*}" "$X"
     printf '   First load fetches a Let'\''s Encrypt cert (a few seconds). A cert error means %s isn'\''t pointing at this host yet — fix DNS / open 80+443.\n' "${QBOX_SITE_ADDRESS%%:*}" ;;
esac
printf '   Logs:    %sdocker compose -f %s/docker-compose.yml logs -f%s\n' "$B" "$QBOX_HOME" "$X"
printf '   Docs:    %shttps://qbox.sh/docs/self-hosting%s\n' "$B" "$X"
