#!/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 # curl -fsSL https://get.keenrig.com | sh -s -- --upgrade (an installed node) # # 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 UPGRADE=0 FORCE=0 # AUTO: "" = leave the auto-upgrade timer as it is, on | off = install | remove. AUTO="" # SWARM: "" = plain Docker, init = first node of a Keenrig-created cluster, # node = any other node (Docker + pair + join, no Keenrig services). SWARM="" # 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). On a node that is ALREADY installed it only pairs it - nothing is reinstalled, domain and gateway are kept. --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 --upgrade upgrade an INSTALLED node to the channel's release: swap the binaries, restart, health-check, roll back if it fails. Leaves configuration, data and the cloud pairing alone. --force with --upgrade: reinstall, or go back to an older release --auto-upgrade check the channel every 5 minutes and run --upgrade when it has a newer release (systemd timer keenrig-upgrade). ON by default for a new install and for --join. Releases are checked by sha256 only, NOT signed yet. --no-auto-upgrade remove that timer (both work alone on an installed node, or with --upgrade) --swarm-init with --join: first node of a cluster Keenrig created - install, create the Docker Swarm, run apps on it --swarm-node with --join: another node of that cluster - install Docker, pair, join the swarm (no Keenrig services on this machine) -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 ;; --upgrade) UPGRADE=1; shift ;; --force) FORCE=1; shift ;; --auto-upgrade) AUTO=on; shift ;; --no-auto-upgrade) AUTO=off; shift ;; --swarm-init) SWARM=init; shift ;; --swarm-node) SWARM=node; shift ;; -h | --help) usage; exit 0 ;; *) echo "unknown option: $1" >&2; usage >&2; exit 2 ;; esac done # The copy of this installer that the auto-upgrade timer runs. Set AFTER the # flags, because --base moves it. AUTO_SCRIPT="$BASE/scripts/keenrig-upgrade.sh" 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 } # fetch_text prints a small remote file on stdout (empty + non-zero on error). fetch_text() { if command -v curl >/dev/null 2>&1; then curl -fsSL --max-time 20 "$1" 2>/dev/null else wget -q -T 20 -O- "$1" 2>/dev/null fi } # ver_gt A B — true when semver A is strictly newer than B. Only # MAJOR.MINOR.PATCH counts; a pre-release suffix is dropped, so a hand-built # "0.0.0-dev" sorts below every release. awk rather than `sort -V`: busybox # sort (Alpine) has no -V, and this script promises to run there. ver_gt() { awk -v a="$1" -v b="$2" 'BEGIN { sub(/-.*/, "", a); sub(/-.*/, "", b) split(a, x, "."); split(b, y, ".") for (i = 1; i <= 3; i++) { if ((x[i] + 0) > (y[i] + 0)) exit 0 if ((x[i] + 0) < (y[i] + 0)) exit 1 } exit 1 }' } # --- ports 80/443 --------------------------------------------------------------- # # The gateway has to OWN 80 and 443: the Admin link printed at the end is port # 80 of this machine. Measured on a VPS that already ran Cloudron (2026-10-10): # Cloudron's nginx kept both ports, keenrig-caddy could not bind, the warning # scrolled past mid-install, and "Done. Admin: http:///" opened Cloudron's # "Not Found" page. Refuse BEFORE touching anything, and say who holds the port. # port_owner prints the name of the process listening on TCP port $1, "" when # the port is free or `ss` is missing (no check is better than a wrong one). port_owner() { command -v ss >/dev/null 2>&1 || return 0 ss -ltnp "sport = :$1" 2>/dev/null | sed -n 's/.*users:(("\([^"]*\)".*/\1/p' | head -n 1 } check_ports() { # Cloudron first: its nginx would pass the "nginx" allowance below when the # gateway is nginx, and the two platforms fight over Docker and the ports. if [ -d /home/yellowtent/box ] || systemctl list-unit-files box.service 2>/dev/null | grep -q '^box\.service'; then die "Cloudron is installed on this machine and holds ports 80/443. Keenrig needs a machine of its own - install on a clean VPS (Ubuntu 22.04/24.04/26.04)." fi for p in 80 443; do owner="$(port_owner "$p")" case "$owner" in "") continue ;; # Our own gateway on a re-run, or the distro's caddy.service, which # caddy-bootstrap.sh disables before starting keenrig-caddy. caddy) [ "$GATEWAY" = caddy ] && continue ;; nginx) [ "$GATEWAY" != caddy ] && continue ;; esac die "port $p is already in use by '$owner'. The Keenrig gateway ($GATEWAY) must listen on 80 and 443 - otherwise the Admin link opens whatever '$owner' serves. Stop and disable it, then re-run: ss -ltnp 'sport = :$p' # see exactly what is listening systemctl disable --now " done } # --- containers (Proxmox LXC, OpenVZ) ------------------------------------------- # # Reported 2026-10-27 from a Proxmox LXC: the install died in tar (fixed with # --no-same-owner), and an LXC has more traps after that one. Docker inside an # unprivileged CT needs `nesting` and `keyctl`; without them dockerd does not # start or every container fails, and systemd cannot set up the units' sandbox # (status=226/NAMESPACE). What to tell the user lives here, once. in_container() { virt="$(systemd-detect-virt -c 2>/dev/null || true)" [ -n "$virt" ] && [ "$virt" != "none" ] } in_lxc() { in_container || return 1 case "$virt" in lxc*) return 0 ;; esac return 1 } container_hint() { in_container || return 0 case "$virt" in lxc*) echo >&2 echo " This machine is an LXC container." >&2 echo " On the Proxmox HOST (not inside the container), enable nesting and keyctl:" >&2 echo " web UI: -> Options -> Features -> tick 'keyctl' and 'Nesting'" >&2 echo " or: pct set --features nesting=1,keyctl=1 (replaces any other features)" >&2 echo " then restart the container (pct reboot ) and re-run this installer." >&2 ;; openvz) echo >&2 echo " This machine is an OpenVZ container: Docker needs nested containers," >&2 echo " which only the host provider can enable." >&2 ;; esac return 0 } # check_resources only WARNS: the numbers are what one app next to the box needs, # not a hard floor. A Proxmox CT is created with 512 MB RAM and 8 GB disk by # default - enough to install, not enough to keep an app alive - and the OOM # kills that follow show up nowhere near this installer. check_resources() { mem_kb="$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || true)" if [ -n "$mem_kb" ] && [ "$mem_kb" -lt 1800000 ]; then echo "WARNING: this machine has $((mem_kb / 1024)) MB of RAM; Keenrig needs 2 GB to run apps." >&2 in_lxc && echo " (LXC: raise its memory on the host, e.g. pct set --memory 2048)" >&2 fi # Preflight touches nothing: measure the nearest directory that exists. d="$BASE" while [ ! -d "$d" ]; do d="$(dirname "$d")"; done free_kb="$(df -Pk "$d" 2>/dev/null | awk 'NR == 2 {print $4}')" if [ -n "$free_kb" ] && [ "$free_kb" -lt 10000000 ]; then echo "WARNING: only $((free_kb / 1024 / 1024)) GB free under $BASE; app images and data need 10 GB or more." >&2 in_lxc && echo " (LXC: grow its disk on the host, e.g. pct resize rootfs +20G)" >&2 fi return 0 } # --- apt lock ------------------------------------------------------------------- # # Measured on a fresh Contabo Ubuntu 26.04 (2026-10-14): right after first boot, # cloud-init and unattended-upgrades hold the dpkg lock for several minutes, and # get.docker.com died on "Could not get lock /var/lib/dpkg/lock-frontend". The # user had done nothing wrong - they just ran the command too early. So WAIT # for the lock instead of failing, and say what is being waited on. APT_LOCKS="/var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock /var/cache/apt/archives/lock" # apt_busy is true while some process holds an apt/dpkg lock. apt takes fcntl # locks, which `flock` cannot see - fuser can. Without fuser (psmisc missing), # fall back to looking for the processes themselves. apt_busy() { if command -v fuser >/dev/null 2>&1; then # shellcheck disable=SC2086 fuser $APT_LOCKS >/dev/null 2>&1 else pgrep -x 'apt|apt-get|dpkg|aptitude' >/dev/null 2>&1 fi } wait_for_apt() { command -v apt-get >/dev/null 2>&1 || return 0 # cloud-init on first boot runs apt itself; `status --wait` blocks until it # is done. Bounded, so a cloud-init stuck in an error never hangs us. if command -v cloud-init >/dev/null 2>&1 && cloud-init status 2>/dev/null | grep -q 'running'; then log "waiting for cloud-init to finish first-boot setup" timeout 600 cloud-init status --wait >/dev/null 2>&1 || true fi i=0 while apt_busy; do if [ "$i" = "0" ]; then log "another package manager is running (usually unattended-upgrades on a new server) - waiting for it" fi i=$((i + 1)) [ "$i" -lt 120 ] || die "apt/dpkg is still locked after 10 minutes. See who holds it: ps aux | grep -E 'apt|dpkg|unattended' Then re-run this installer - every step is idempotent." sleep 5 done } # --- 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" wait_for_apt log "installing Docker via get.docker.com" tmp="$(mktemp)" fetch https://get.docker.com "$tmp" # get.docker.com runs apt-get without a lock timeout, so unattended-upgrades # can grab the lock again between our wait and its first apt-get. One retry # after waiting again covers that window. if ! sh "$tmp"; then log "Docker install failed - waiting for apt and trying once more" sleep 5 wait_for_apt if ! sh "$tmp"; then # A brand-new Ubuntu release can land before Docker publishes a # repository for it. Ubuntu's own docker.io package is older but # runs everything Keenrig needs. if command -v apt-get >/dev/null 2>&1; then log "get.docker.com failed - falling back to Ubuntu's docker.io package" pkg_install docker.io || { rm -f "$tmp"; die "could not install Docker"; } else rm -f "$tmp" die "could not install Docker" fi fi fi 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 # --no-same-owner: chạy dưới root, tar mặc định giữ uid/gid ghi trong archive # (vd. 197609 của máy build Windows) và chết với "Cannot change ownership ... # Invalid argument" trên host có user namespace hạn chế (LXC, OpenVZ). tar --no-same-owner -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 # node/VERSION = semver TRẦN ("0.2.0"), thứ keenrig-ota so với manifest. # Gói có file VERSION (build-release.sh, 2026-10-03) thì lấy nó; gói cũ # thì hỏi binary — trước 2026-10-03 box không có --version, nên chỗ này # từng ghi file rỗng mà `|| true` che mất. if [ -f "$tmp/VERSION" ]; then tr -d '\r\n' <"$tmp/VERSION" >"$BASE/node/VERSION" else "$BASE/bin/keenrig-box" --version 2>/dev/null | awk '{print $NF}' >"$BASE/node/VERSION" || true fi chown "$KEENRIG_USER":"$KEENRIG_USER" "$BASE/node/VERSION" 2>/dev/null || true } # check_docker_runs proves Docker can START a container, not just that the # binary exists ("Docker already installed" said nothing on the LXC that could # not run one). Without it the box installs, /healthz answers, and the first app # fails with an error nobody ties to the machine. The probe image is keenrig-box # itself (static, CGO off) imported as a one-file image: no registry pull, so no # Docker Hub rate limit and no network needed beyond the default bridge. check_docker_runs() { log "checking that Docker can run a container" if ! out="$(docker info 2>&1)"; then printf '%s\n' "$out" | tail -n 5 >&2 echo "ERROR: the Docker daemon is not running. See: journalctl -u docker -n 50" >&2 container_hint exit 1 fi img=keenrig-probe:latest ok=1 out="$(tar -C "$BASE/bin" --mode=0755 -cf - keenrig-box | docker import - "$img" 2>&1)" || ok=0 if [ "$ok" = "1" ]; then out="$(docker run --rm "$img" /keenrig-box --version 2>&1)" || ok=0 fi docker rmi -f "$img" >/dev/null 2>&1 || true [ "$ok" = "1" ] && return 0 printf '%s\n' "$out" | tail -n 5 >&2 echo "ERROR: Docker is installed but cannot run a container - every app would fail." >&2 case "$out" in *ip_unprivileged_port_start*) # runc's Nov 2025 security fix vs the AppArmor profile of older # Proxmox LXC packages; fixed on the Proxmox side. echo " This is the runc/AppArmor clash in Proxmox LXC. On the Proxmox HOST, either" >&2 echo " apt update && apt full-upgrade (newer lxc-pve fixes it), or" >&2 echo " add 'lxc.apparmor.profile: unconfined' to /etc/pve/lxc/.conf" >&2 echo " then restart the container and re-run this installer." >&2 ;; *) container_hint ;; esac exit 1 } # --- gateway ------------------------------------------------------------------ pkg_install() { if command -v apt-get >/dev/null 2>&1; then wait_for_apt # Lock::Timeout as well: unattended-upgrades can wake up between # wait_for_apt and this line (apt >= 1.9.11; older apt ignores it). # apt's own message is kept and shown on failure: hiding it once turned # "Unable to locate package caddy" (Ubuntu 22.04) into a bare WARNING # that said nothing about why. aptlog="$(mktemp)" if DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=300 update -qq >"$aptlog" 2>&1 && DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=300 install -y -qq "$@" >>"$aptlog" 2>&1; then rm -f "$aptlog" return 0 fi grep -E '^(E|W):' "$aptlog" | tail -n 5 >&2 || true rm -f "$aptlog" return 1 elif command -v dnf >/dev/null 2>&1; then dnf install -y -q "$@" else return 1 fi } # add_caddy_repo adds Caddy's official apt repository (Cloudsmith, GPG-signed). # # Measured 2026-10-14 on a fresh Ubuntu 22.04: `apt-get install caddy` -> # "Unable to locate package caddy". Ubuntu only ships caddy from 23.04 on, and # 22.04 is still a supported target, so the distro archive alone is not # enough. These are the steps from caddyserver.com/docs/install, nothing more: # the key goes in its own keyring file and the list pins `signed-by` to it, so # it is trusted for this one repository only. add_caddy_repo() { command -v apt-get >/dev/null 2>&1 || return 1 log "caddy is not in this distro's archive - adding Caddy's official apt repository" command -v gpg >/dev/null 2>&1 || pkg_install gnupg || return 1 keyring=/usr/share/keyrings/caddy-stable-archive-keyring.gpg tmp="$(mktemp)" fetch https://dl.cloudsmith.io/public/caddy/stable/gpg.key "$tmp" || { rm -f "$tmp"; return 1; } gpg --batch --yes --dearmor -o "$keyring" "$tmp" || { rm -f "$tmp"; return 1; } chmod 0644 "$keyring" fetch https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt "$tmp" || { rm -f "$tmp"; return 1; } # Only accept what that file is supposed to be: deb lines for this repo, # signed by the keyring above. Anything else (an HTML error page) is refused. grep -q "signed-by=$keyring" "$tmp" || { rm -f "$tmp"; return 1; } install -m 0644 "$tmp" /etc/apt/sources.list.d/caddy-stable.list rm -f "$tmp" } 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)" if ! pkg_install caddy; then add_caddy_repo && pkg_install caddy || die "could not install Caddy - the gateway is what makes the Admin reachable. Install it by hand (https://caddyserver.com/docs/install) and re-run this installer." fi ;; 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 pair.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_unit_files writes the unit files ONLY — not the 10-local.conf drop-in, # which holds this machine's own choices (gateway, domain). --upgrade calls this # alone: rewriting the drop-in from the flags of an upgrade run would silently # reset a box installed with --domain or --gateway nginx to the defaults. install_unit_files() { 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 } install_units() { install_unit_files 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. # # restart, NOT `enable --now`: --now only STARTS a unit that is stopped, so # re-running the installer on a machine that already runs a box swaps the # binary and the unit file on disk while the OLD process keeps running with # the OLD unit's sandbox. Measured on a VPS 2026-10-14: box process from # 09:00 running a `(deleted)` binary, the 16:38 fix on disk never loaded. # restart also starts a unit that is stopped, so a first install is the same. systemctl enable keenrig-box.service keenrig-agent.service keenrig-ota.service systemctl restart 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 if [ "$(systemctl show -p ExecMainStatus --value keenrig-box.service 2>/dev/null)" = "226" ]; then echo " keenrig-box could not set up its systemd sandbox (status=226/NAMESPACE)." >&2 container_hint fi return 1 } # check_box_privileges proves the RUNNING box can use its sudo scripts # (internal/privops). Without them a box looks healthy - /healthz answers - but # every privileged step fails: an app running as a non-root user (n8n, uid 1000) # never gets its volume chowned and crash-loops with EACCES, deleting an app # leaves its data behind. Measured on a VPS 2026-10-14. # # Two separate checks, because they fail for different reasons: # - NoNewPrivs of the box PROCESS: a property of the unit's sandbox, not of # the unit text. systemd turns it on implicitly for any seccomp-based option # (RestrictSUIDSGID=, ProtectKernelModules=, SystemCallFilter=, ...) when # User= is not root, and then the kernel ignores sudo's setuid bit. Only # /proc//status tells the truth. # - `sudo -n