#!/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 } # Kept for the "must run as root" message below, which reprints the command the # user actually typed. Word-splitting is fine here: every option this script # takes (token, domain, URL, directory name) is a single shell word. ARGV="$*" 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. # "try: sudo" is not enough here, and the reason is the pipe. The user is # running `curl … | bash`, so there is no script path to prepend sudo to, and # the two obvious guesses are both wrong: `sudo curl … | bash` elevates curl # and leaves the shell unprivileged, while re-execing ourselves under sudo # would mean fetching this script a SECOND time — a different download than # the one that was reviewed. So print the exact command, with their own # arguments in it, and let them run it. if [ "$(id -u)" != "0" ]; then echo "ERROR: must run as root." >&2 echo >&2 echo " Re-run with sudo on the SHELL, not on curl:" >&2 echo >&2 echo " curl -fsSL https://get.keenrig.com | sudo sh -s --${ARGV:+ $ARGV}" >&2 echo >&2 echo " (\`sudo curl … | sh\` does not work: that elevates the download, while the" >&2 echo " shell that runs the script stays unprivileged.)" >&2 exit 1 fi 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" # Two files, and the split is not cosmetic. `keenrig` carries the grants and # MUST install; `keenrig-quiet` carries one journalctl-formatting tweak and # is allowed to be dropped. # # Measured on a real Ubuntu 26.04: since 25.10 Ubuntu ships **sudo-rs**, which # rejects the whole file over one setting it does not implement — # "unknown setting: 'syslog'". A rejected file loses EVERY line in it, so # keeping that tweak next to the grants would cost the box its gateway on # every current Ubuntu. install_sudoers_file() { # $1 = basename under sudoers/, $2 = "required" | "optional" sudosrc="$(dirname "$0")/sudoers/$1" tmp="$(mktemp)" if [ -f "$sudosrc" ]; then cp "$sudosrc" "$tmp" elif ! fetch "$RELEASES/$CHANNEL/sudoers/$1" "$tmp" 2>/dev/null; then rm -f "$tmp" [ "$2" = optional ] || die "cannot fetch sudoers/$1" return 0 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 && ! visudo -cf "$tmp" >/dev/null 2>&1; then rm -f "$tmp" [ "$2" = optional ] || die "sudoers/$1 was rejected by visudo - NOT installing it (it would break sudo for the whole machine)" log "skipping the optional sudoers/$1 (this sudo does not accept it)" return 0 fi install -m 0440 -o root -g root "$tmp" "/etc/sudoers.d/$1" rm -f "$tmp" } install_sudoers_file keenrig required install_sudoers_file keenrig-quiet optional } 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 < "Done" -> open the Admin link -> error page, with # nothing anywhere saying why. Four steps, and the last one happens after they # already believe it worked. This turns that into one line, here, while they # are still looking at the terminal. check_network() { [ -n "${JOINED_KEY:-}" ] || return 0 log "asking $CLOUD whether this machine is reachable from the Internet" res="" if command -v curl >/dev/null 2>&1; then res="$(curl -sS --max-time 25 -H "Authorization: Bearer $JOINED_KEY" "$CLOUD/agent/v1/network" 2>&1)" || true else res="$(wget -q -O- --header="Authorization: Bearer $JOINED_KEY" "$CLOUD/agent/v1/network" 2>&1)" || true fi NET_IP="$(printf '%s' "$res" | sed -n 's/.*"public_ip"[[:space:]]*:[[:space:]]*"\([^"]*\)".*//p')" NET_ADMIN="$(printf '%s' "$res" | sed -n 's/.*"admin_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*//p')" case "$res" in *'"reachable":true'* | *'"reachable": true'*) NET_OK=1 ;; *) NET_OK=0 ;; esac } main() { install_docker setup_user setup_dirs install_binaries install_gateway install_privops install_units bootstrap_gateway wait_healthy || true join_cloud check_network ip="$(hostname -I 2>/dev/null | awk '{print $1}')" echo log "Done." # The address printed is port 80 of the GATEWAY, not 8080 of the box: the # box listens on loopback (SEC-01) so 8080 is not reachable from outside. # The gateway accepts requests aimed straight at the IP and forwards them # to the Admin UI via the catch-all (PX-03) — no domain needed. echo " Admin: http://${ip:-}/" [ -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 # The reachability verdict, stated plainly. A machine that cannot be reached # is not a broken install - everything here is running - but it is also not # a machine anyone can visit, and the user has to hear that NOW. if [ "${NET_OK:-}" = "1" ]; then echo " Reachable from the Internet at ${NET_IP:-?}." [ -z "${NET_ADMIN:-}" ] || echo " Admin (DNS is being pointed here now): $NET_ADMIN" elif [ -n "${NET_IP:-}" ]; then echo " THIS MACHINE CANNOT BE REACHED FROM THE INTERNET." >&2 echo >&2 echo " The agent reached $CLOUD from $NET_IP, but nothing answers on port 80 or" >&2 echo " 443 at that address. The box is installed and running - it just has no way" >&2 echo " for a browser to get to it, and it cannot obtain a certificate either." >&2 echo >&2 echo " Usually one of:" >&2 echo " - ports 80/443 are not forwarded to this machine" >&2 echo " - a firewall or security group drops them" >&2 echo " - the connection is behind carrier-grade NAT (common on home ISPs)," >&2 echo " where no port forwarding is possible at all" >&2 echo >&2 echo " Fix that and it starts working on its own - nothing here to re-run." >&2 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 "$@"