#!/bin/sh # Keenrig — install a node. # # curl -fsSL https://get.keenrig.com | sh # curl -fsSL https://get.keenrig.com | sh -s -- --domain box.example.com # curl -fsSL https://get.keenrig.com | sh -s -- --join # # POSIX sh, not bash: this script runs on any clean VM, including Alpine or a # minimal image without bash. A bashism here breaks exactly where nobody can # debug it — all the user sees is "not found". # # READ BEFORE EDITING: every path below must match # keenrig-node/internal/paths/paths.go. A mismatch is a SILENT install bug — # the box creates its directories, systemd points at different ones, and # nothing looks wrong until the first update deletes the wrong place. set -eu BASE="${KEENRIG_BASE:-/var/lib/keenrig}" # The release channel lives under the same host that serves this script: # one name, one certificate, and the file you are reading proves the host is # reachable before anything is downloaded from it. RELEASES="${KEENRIG_RELEASES:-https://get.keenrig.com/dl}" CHANNEL="${KEENRIG_CHANNEL:-stable}" KEENRIG_USER="${KEENRIG_USER:-keenrig}" # The control plane that --join pairs against. Override for self-hosted clouds. CLOUD="${KEENRIG_CLOUD_URL:-https://console.keenrig.com}" DOMAIN="" JOIN="" SKIP_DOCKER=0 # GATEWAY decides which proxy gets installed + bootstrapped. Must match the # driver names registered in keenrig-node/internal/proxy (caddy | nginx | # nginx-owasp) — a mismatch means the installer sets up a gateway the box # cannot drive. GATEWAY="${KEENRIG_PROXY:-caddy}" usage() { cat <<'USAGE' keenrig installer --domain domain for the Admin UI (optional; you can set it later) --join join a control plane (issue the token in the Console) --cloud control plane URL (default https://console.keenrig.com) --base root directory (default /var/lib/keenrig) --channel release channel (default stable) --gateway caddy (default) | nginx | nginx-owasp --skip-docker do not install Docker automatically -h, --help USAGE } while [ $# -gt 0 ]; do case "$1" in --domain) DOMAIN="${2:-}"; shift 2 ;; --join) JOIN="${2:-}"; shift 2 ;; --cloud) CLOUD="${2:-}"; shift 2 ;; --base) BASE="${2:-}"; shift 2 ;; --channel) CHANNEL="${2:-}"; shift 2 ;; --gateway) GATEWAY="${2:-}"; shift 2 ;; --skip-docker) SKIP_DOCKER=1; shift ;; -h | --help) usage; exit 0 ;; *) echo "unknown option: $1" >&2; usage >&2; exit 2 ;; esac done log() { echo "==> $*"; } die() { echo "ERROR: $*" >&2; exit 1; } # --- preflight checks ----------------------------------------------------------- # # Check EVERYTHING before touching anything: a machine that got a new user and # new directories before hearing "systemd is missing" is a machine in a # half-done state the user has to clean up by hand. [ "$(id -u)" = "0" ] || die "must run as root (try: sudo)" case "$(uname -s)" in Linux) ;; *) die "Linux only (got $(uname -s))" ;; esac case "$(uname -m)" in x86_64 | amd64) ARCH=amd64 ;; aarch64 | arm64) ARCH=arm64 ;; *) die "unsupported architecture: $(uname -m)" ;; esac case "$GATEWAY" in caddy | nginx | nginx-owasp) ;; *) die "invalid gateway: $GATEWAY (expected caddy | nginx | nginx-owasp)" ;; esac command -v systemctl >/dev/null 2>&1 || die "systemd is required" command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 || die "curl or wget is required" command -v tar >/dev/null 2>&1 || die "tar is required" fetch() { # $1 = url, $2 = destination if command -v curl >/dev/null 2>&1; then curl -fsSL --retry 3 --retry-delay 2 -o "$2" "$1" else wget -q -O "$2" "$1" fi } # --- Docker ------------------------------------------------------------------- install_docker() { if command -v docker >/dev/null 2>&1; then log "Docker already installed: $(docker --version 2>/dev/null || echo '?')" return fi [ "$SKIP_DOCKER" = "0" ] || die "Docker is missing and --skip-docker was given" log "installing Docker via get.docker.com" tmp="$(mktemp)" fetch https://get.docker.com "$tmp" sh "$tmp" rm -f "$tmp" systemctl enable --now docker } # --- user + directories --------------------------------------------------------- # # Run under a dedicated user, NOT root: the box only needs rights on and # the ability to talk to dockerd. Running as root turns any hole in the control # API into root on the machine. # # The trade-off, stated plainly: this user is in the docker group, and the # docker group is effectively root (anyone who can call dockerd can mount / # into a container). It is still better than running straight root because it # narrows the surface of EVERYTHING else (reading files, writing outside # , ptracing other processes) — just do not mistake it for real # isolation. setup_user() { if ! id "$KEENRIG_USER" >/dev/null 2>&1; then log "creating user $KEENRIG_USER" useradd --system --home-dir "$BASE" --shell /usr/sbin/nologin "$KEENRIG_USER" 2>/dev/null || adduser --system --home "$BASE" --shell /usr/sbin/nologin "$KEENRIG_USER" fi if getent group docker >/dev/null 2>&1; then usermod -aG docker "$KEENRIG_USER" 2>/dev/null || true fi } setup_dirs() { log "creating the directory layout under $BASE" # Must match paths.go — the four planes. mkdir -p \ "$BASE/bin" \ "$BASE/instance" \ "$BASE/node/proxy/sites" \ "$BASE/node/proxy/cert" \ "$BASE/node/acme" \ "$BASE/node/addons" \ "$BASE/node/logs" \ "$BASE/node/backup" \ "$BASE/node/update" \ "$BASE/node/run" \ "$BASE/node/agent" \ "$BASE/apps" chown -R "$KEENRIG_USER":"$KEENRIG_USER" "$BASE" # instance holds master.key — losing it means losing every secret of the # instance. 0700 matches paths.PrivateDirPerm. chmod 0700 "$BASE/instance" chmod 0755 "$BASE" "$BASE/bin" "$BASE/node" "$BASE/apps" } # --- download + verify ---------------------------------------------------------- install_binaries() { url="$RELEASES/$CHANNEL/keenrig-linux-$ARCH.tar.gz" log "downloading $url" tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT fetch "$url" "$tmp/keenrig.tar.gz" # The checksum guards against a CORRUPT FILE, not a compromised server — # whoever controls the release server also sets the sha256. An Ed25519 # signature is what guards against that, and it belongs to keenrig-ota # (OT-01). The checksum stays because it is cheap and catches truncated # downloads, and this comment states the limit instead of letting the # reader assume more protection than exists. if fetch "$url.sha256" "$tmp/keenrig.tar.gz.sha256" 2>/dev/null; then if command -v sha256sum >/dev/null 2>&1; then (cd "$tmp" && sha256sum -c keenrig.tar.gz.sha256) || die "checksum mismatch - the download is corrupt" fi else echo "WARNING: could not fetch the .sha256 file - skipping the integrity check" >&2 fi tar -xzf "$tmp/keenrig.tar.gz" -C "$tmp" for b in keenrig-box keenrig-agent keenrig-ota; do [ -f "$tmp/$b" ] || die "the archive is missing $b" install -m 0755 -o "$KEENRIG_USER" -g "$KEENRIG_USER" "$tmp/$b" "$BASE/bin/$b" done "$BASE/bin/keenrig-box" --version >"$BASE/node/VERSION" 2>/dev/null || true chown "$KEENRIG_USER":"$KEENRIG_USER" "$BASE/node/VERSION" 2>/dev/null || true } # --- gateway ------------------------------------------------------------------ pkg_install() { if command -v apt-get >/dev/null 2>&1; then apt-get update -qq && apt-get install -y -qq "$@" 2>/dev/null elif command -v dnf >/dev/null 2>&1; then dnf install -y -q "$@" 2>/dev/null else return 1 fi } install_gateway() { case "$GATEWAY" in caddy) if command -v caddy >/dev/null 2>&1; then log "Caddy already installed"; return; fi log "installing Caddy (it obtains and renews certificates on its own)" pkg_install caddy || echo "WARNING: could not install Caddy automatically; install it by hand and re-run" >&2 ;; nginx) if command -v nginx >/dev/null 2>&1; then log "nginx already installed"; return; fi log "installing nginx" pkg_install nginx openssl || echo "WARNING: could not install nginx automatically" >&2 ;; nginx-owasp) log "installing nginx + ModSecurity" # Do NOT install silently and hope: bootstrap REFUSES if the module is # absent, and refusing is the right answer — enabling the WAF flag # without the module loaded means the user believes their apps are # protected while they are not. pkg_install nginx openssl libnginx-mod-http-modsecurity modsecurity-crs || echo "WARNING: could not install nginx+ModSecurity automatically" >&2 ;; esac } # --- privileged scripts + sudoers ------------------------------------------------- # # The box runs as user `keenrig`, NOT root: a hole in the control API then does # not become root on the machine. But a few operations still need root # (starting the gateway, deleting app data written by containers under other # UIDs, reading disk usage). They go through the scripts below, each of which # validates its own arguments. # # sudoers CANNOT check arguments — it only checks PATHS. So every guard lives # inside the script itself, and the paths here must match sudoers EXACTLY. install_privops() { log "installing the privileged scripts" dst="$BASE/scripts" mkdir -p "$dst" src="$(dirname "$0")/scripts" for f in caddy-bootstrap.sh nginx-bootstrap.sh nginx-ctl.sh restartservice.sh appdata.sh du.sh reboot.sh; do if [ -f "$src/$f" ]; then install -m 0755 -o root -g root "$src/$f" "$dst/$f" else fetch "$RELEASES/$CHANNEL/scripts/$f" "$dst/$f" chmod 0755 "$dst/$f" chown root:root "$dst/$f" fi done # Owned by root, 0755: user `keenrig` must NOT be able to write the very # scripts it runs via sudo. If it could, every guard inside them is # meaningless — whoever takes the box just edits the script and calls it. chown root:root "$dst" chmod 0755 "$dst" log "installing sudoers" sudosrc="$(dirname "$0")/sudoers/keenrig" tmp="$(mktemp)" if [ -f "$sudosrc" ]; then cp "$sudosrc" "$tmp" else fetch "$RELEASES/$CHANNEL/sudoers/keenrig" "$tmp" fi # visudo -c BEFORE placing it in /etc/sudoers.d: a sudoers file with a # syntax error BREAKS SUDO FOR THE WHOLE MACHINE, including for the real # administrator. This is one of the few places where "install now, fix # later" is not an option. if command -v visudo >/dev/null 2>&1; then visudo -cqf "$tmp" || die "the sudoers file has a syntax error - NOT installing it (it would break sudo for the whole machine)" fi install -m 0440 -o root -g root "$tmp" /etc/sudoers.d/keenrig rm -f "$tmp" } install_units() { log "installing the systemd units" src="$(dirname "$0")/systemd" for u in keenrig-box.service keenrig-agent.service keenrig-ota.service keenrig-caddy.service; do if [ -f "$src/$u" ]; then install -m 0644 "$src/$u" "/etc/systemd/system/$u" else # Run via `curl | sh` and there is no source directory next to the # script — fetch the unit from the release channel. fetch "$RELEASES/$CHANNEL/systemd/$u" "/etc/systemd/system/$u" fi done mkdir -p /etc/systemd/system/keenrig-box.service.d cat >/etc/systemd/system/keenrig-box.service.d/10-local.conf <>/etc/systemd/system/keenrig-box.service.d/10-local.conf ;; esac [ -z "$DOMAIN" ] || echo "Environment=KEENRIG_DOMAIN=$DOMAIN" >>/etc/systemd/system/keenrig-box.service.d/10-local.conf systemctl daemon-reload # All three units start together, NOT in order: the call graph is a DAG # (agent→box, ota→box) so each process retries on its own. Forcing a start # order here would hide an ND-06 bug until the first real reboot. systemctl enable --now keenrig-box.service keenrig-agent.service keenrig-ota.service } # bootstrap_gateway writes the base configuration and starts the gateway. # # Runs AFTER install_units (the unit must exist) and BEFORE wait_healthy: a # healthy box with no gateway up means the user can only reach it on loopback # port 8080 — that is, from nowhere. # # A failure here does NOT stop the install: the box keeps running, and the # operator can fix the gateway afterwards. Aborting the whole install over the # gateway would turn a fixable incident into a half-done machine. bootstrap_gateway() { log "writing the base configuration for $GATEWAY" env_common="KEENRIG_BASE=$BASE KEENRIG_USER=$KEENRIG_USER" case "$GATEWAY" in caddy) if ! env $env_common "$BASE/scripts/caddy-bootstrap.sh"; then echo "WARNING: Caddy did not start. The box is still running on 127.0.0.1:8080." >&2 echo " See: journalctl -u keenrig-caddy -n 50" >&2 fi ;; nginx | nginx-owasp) waf="" [ "$GATEWAY" = "nginx-owasp" ] && waf="--waf" if ! env $env_common "$BASE/scripts/nginx-bootstrap.sh" $waf; then echo "WARNING: nginx did not start. The box is still running on 127.0.0.1:8080." >&2 echo " See: journalctl -u nginx -n 50" >&2 fi ;; esac } wait_healthy() { # Probes 127.0.0.1 because the box listens on LOOPBACK (SEC-01) — which is # also why this step runs ON the machine and cannot be checked remotely. log "waiting for keenrig-box to become ready" i=0 while [ "$i" -lt 60 ]; do if command -v curl >/dev/null 2>&1 && curl -fsS --max-time 2 http://127.0.0.1:8080/healthz >/dev/null 2>&1; then log "keenrig-box is up" return 0 fi i=$((i + 1)) sleep 1 done echo "WARNING: /healthz did not answer within 60s. See: journalctl -u keenrig-box -n 50" >&2 return 1 } # join_cloud exchanges the one-time join token for agent credentials. # # It runs LAST, after every failure-prone step (Docker, download, gateway): # the token is single-use with a 30-minute lifetime, and burning it on a # machine that then dies at the download would force the user back to the # Console for a new token just to retry. If pairing itself fails, the box is # already installed and standalone — re-running this installer with a fresh # token is cheap because every earlier step is idempotent. join_cloud() { [ -n "$JOIN" ] || return 0 log "joining the control plane at $CLOUD" ver="$("$BASE/bin/keenrig-agent" --version 2>/dev/null | awk '{print $2}')" body="{\"token\":\"$JOIN\",\"host_id\":\"$(hostname 2>/dev/null || uname -n)\",\"version\":\"${ver:-unknown}\"}" # No --retry on purpose: the token is consumed server-side on first use, so # a blind retry can never succeed and only muddies the error. if command -v curl >/dev/null 2>&1; then res="$(curl -sS --max-time 30 -H 'Content-Type: application/json' \ -d "$body" "$CLOUD/agent/v1/pair" 2>&1)" || true else res="$(wget -q -O- --header='Content-Type: application/json' \ --post-data="$body" "$CLOUD/agent/v1/pair" 2>&1)" || true fi env_id="$(printf '%s' "$res" | sed -n 's/.*"env_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" api_key="$(printf '%s' "$res" | sed -n 's/.*"api_key"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" if [ -z "$env_id" ] || [ -z "$api_key" ]; then echo "ERROR: pairing with $CLOUD failed." >&2 echo " Response: ${res:-}" >&2 echo >&2 echo " The box itself is installed and running standalone. Join tokens are" >&2 echo " single-use and expire after 30 minutes - issue a fresh one in the" >&2 echo " Console and re-run this installer with the new --join token." >&2 exit 1 fi # The API key is a secret: a root-owned 0600 EnvironmentFile, NOT a # command-line flag (visible in ps aux) and NOT the unit file itself # (0644 - readable by every user on the machine). mkdir -p /etc/keenrig old_umask="$(umask)" umask 077 cat >/etc/keenrig/agent.env <}/" [ -z "$DOMAIN" ] || echo " Domain: https://$DOMAIN/" echo " First-run setup token: journalctl -u keenrig-box | grep 'setup token'" echo if [ -n "$JOIN" ]; then echo " This node is connected to $CLOUD - it appears in the Console shortly." else echo " No cloud account needed: create the instance owner on the very first screen." fi echo echo " Note: reaching the box by IP means no HTTPS yet. Point a domain at it and use" echo " that domain - Caddy obtains the certificate on its own, nothing else to do." } main "$@"