#!/bin/sh # CONTRACT-DOWNLOAD-URLS / CONTRACT-MANIFEST-SCHEMA # # Focus Layer agent (SDCA) installer. Served verbatim at the root path of # https://get.focus-layer.com (baked into the agent-downloader image, see # services/agent-downloader/cmd — NOT the bucket; an S3 GET / returns a listing). # The canonical command the wizard renders is: # # curl -fsSL https://get.focus-layer.com | sh -s -- --claim # # Note there is NO `sudo` on the pipe. The script runs unprivileged for the # fetch/verify half and prefixes ONLY the privileged steps (claim write, apt # install, systemctl) with sudo — matching the Docker/k3s/Tailscale idiom. This # needs no re-exec of the script: individual commands are wrapped in ${SUDO}, # which is empty when already root. See recommendation 2 in # docs/user-onboarding/install-script-security-review.md. # # Design & contract: docs/user-onboarding/agent-install-page-and-download-site.md # §3.3 (this script), §3.2 (the manifest it reads), and the companion claim doc # claim-code-activation-design.md §4.3 (the claim file the agent reads). # # POSIX sh, not bash: the canonical invocation pipes into `sh`, and the script # must run under dash (Debian/Ubuntu /bin/sh) as well as bash. Keep it clean # under the sh dialect linter (see tools/ci; run with the -s sh option). # # The ordering of "write claim -> install -> start" is load-bearing; each step # documents why. Do not reorder without re-reading §3.3. # # TRUNCATION SAFETY: the entire script is a single main() invoked as `main "$@"` # on the very last line. If the connection drops mid-download, `sh` executes a # partial file that defines an incomplete function and never reaches the call, so # nothing runs — instead of executing a half-written command such as a truncated # `rm -f "${DATA_DIR}"/*.ac`. Keep the `main "$@"` line last, and keep every # statement inside a function. See the security review, recommendation 1. set -eu log() { printf '%s\n' "$*" >&2; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } # Truncate the claim for logging: show only the first 4 chars so a shoulder- # surfer / log scrape cannot recover the code (R7, and the same §8 hygiene the # server logging follows). Never echo the full claim. # Note the `%.4s` precision specifier rather than piping through `cut`: `cut` # terminates its output with a newline, and command substitution only strips # newlines from the END of the whole substitution — so a following `printf '...'` # left the newline stranded mid-string and broke the progress line in two (#309). truncate_code() { _c="$1" if [ -n "${_c}" ]; then printf '%.4s...' "${_c}" fi } main() { # -------------------------------------------------------------------------- # Configuration. Hostnames are baked in here (they are config, not constants, # per CONTRACT-DOWNLOAD-URLS — a rename edits this one line, the wizard copy, # and the k8s ingress, and nothing else). The manifest is served bucket-direct # from the downloads host; this script is served from the get. host. # -------------------------------------------------------------------------- # CONTRACT-DOWNLOAD-URLS / CONTRACT-MANIFEST-SCHEMA: public surfaces read the # RELEASE folder and nothing else. Artifacts live in one folder per branch # (main = release), and the manifest sits at /manifest.json — one level # ABOVE /agent/, NOT inside it. The v1 key was /agent/manifest.json # (inside the agent/ prefix), so this is a change of shape, not just depth: # prepending "main/" to the old key would give /main/agent/manifest.json, which # 404s. There is deliberately no channel selector here — an installer that # could be pointed at a dev branch is a footgun on a public one-liner. MANIFEST_URL="${SDCA_MANIFEST_URL:-https://downloads.focus-layer.com/main/manifest.json}" INSTALL_HOST="get.focus-layer.com" # Where the agent reads its activation code from. Search order in the agent is # /.ac, then /var/lib/sdca/code, then legacy /etc/default/sdca # (claim-code-activation-design.md §4.1). We write the middle file and clear any # stale .ac so a re-run repairs a botched claim (§4.3, R6). DATA_DIR="/var/lib/sdca" CODE_FILE="${DATA_DIR}/code" # -------------------------------------------------------------------------- # 1. Parse args. --claim is optional: claim-less installs still work and the # outcome message then tells the user to activate via the status page. # -------------------------------------------------------------------------- CLAIM="" while [ "$#" -gt 0 ]; do case "$1" in --claim) [ "$#" -ge 2 ] || die "--claim requires a code argument" CLAIM="$2" shift 2 ;; --claim=*) CLAIM="${1#--claim=}" shift ;; -h|--help) cat >&2 <] Options: --claim Bind this agent to your account using the code shown in the onboarding wizard. Optional: without it the agent self-mints a code you enter later on its status page. The script runs unprivileged, then uses sudo only for the install steps. EOF return 0 ;; *) die "unknown argument: $1 (see --help)" ;; esac done # -------------------------------------------------------------------------- # 2. Environment checks: Linux, amd64/arm64, Debian-family. These run # UNPRIVILEGED — no root needed to inspect the machine, fetch the manifest, # download the package, or verify its checksum. Only the install steps (§5-6) # need root, and they are individually prefixed with ${SUDO}. # -------------------------------------------------------------------------- OS="$(uname -s)" [ "${OS}" = "Linux" ] || die "unsupported OS '${OS}'; this installer supports Linux only." MACHINE="$(uname -m)" case "${MACHINE}" in x86_64|amd64) ARCH="amd64" ;; aarch64|arm64) ARCH="arm64" ;; *) die "unsupported architecture '${MACHINE}'; supported: x86_64/amd64, aarch64/arm64." ;; esac command -v apt-get >/dev/null 2>&1 \ || die "this installer supports Debian and Ubuntu (apt) only; apt-get not found." # Decide how to escalate the privileged steps. Empty when already root (the # commands run as-is); "sudo" otherwise. If we are not root and have no sudo, # fail early with the exact rerun command, before downloading anything. if [ "$(id -u)" -eq 0 ]; then SUDO="" elif command -v sudo >/dev/null 2>&1; then SUDO="sudo" else log "This installer needs root for the install step, and sudo is not available." if [ -n "${CLAIM}" ]; then log "Rerun as root: curl -fsSL https://${INSTALL_HOST} | sh -s -- --claim ${CLAIM}" else log "Rerun as root: curl -fsSL https://${INSTALL_HOST} | sh" fi return 1 fi # A downloader is required to fetch the manifest and package. Prefer curl, # fall back to wget. if command -v curl >/dev/null 2>&1; then DL_MANIFEST() { curl -fsSL "$1"; } DL_FILE() { curl -fsSL -o "$2" "$1"; } elif command -v wget >/dev/null 2>&1; then DL_MANIFEST() { wget -qO- "$1"; } DL_FILE() { wget -qO "$2" "$1"; } else die "need curl or wget to download the agent package." fi # -------------------------------------------------------------------------- # 3. Fetch the manifest, pick the package for this arch, download it, verify # its sha256. The manifest is the single source of truth for version + hash # (§3.2) so this script never carries a hard-coded version. All unprivileged. # -------------------------------------------------------------------------- log "Fetching release manifest..." MANIFEST="$(DL_MANIFEST "${MANIFEST_URL}")" \ || die "could not fetch manifest from ${MANIFEST_URL}" # Extract the package entry for this arch. jq if present (robust); otherwise a # portable sed/grep fallback so the script has no hard dependency on jq (a # fresh Debian minimal image has neither jq nor python by default). The # fallback keys off the flat, stable manifest shape in §3.2. PKG_URL="" PKG_SHA="" PKG_FILE="" if command -v jq >/dev/null 2>&1; then PKG_URL="$(printf '%s' "${MANIFEST}" | jq -r --arg a "${ARCH}" '.agent.packages[] | select(.arch==$a) | .url')" PKG_SHA="$(printf '%s' "${MANIFEST}" | jq -r --arg a "${ARCH}" '.agent.packages[] | select(.arch==$a) | .sha256')" PKG_FILE="$(printf '%s' "${MANIFEST}" | jq -r --arg a "${ARCH}" '.agent.packages[] | select(.arch==$a) | .file')" else # Portable extraction: collapse to one line, split into the per-package # objects, keep the one whose "arch" matches, then pull the three fields. # This tolerates whitespace/key order but assumes the contract field names. # # TWO filters are needed to stay correct against manifest v2, and neither is # optional (CONTRACT-MANIFEST-SCHEMA): # # 1. TRUNCATE AT "pins". The manifest's pins[] registry holds frozen # SNAPSHOTS of past releases, and each snapshot contains complete # package entries — same "arch", same "file", same "os". A pinned old # version would therefore satisfy every content test below and could # win on serialization order alone, silently installing the wrong # version. Cutting the text at the first "pins" key makes the whole # registry unreachable regardless of its contents. The publisher pins # `agent` BEFORE `pins` in the key order (asserted by # tools/ci/check-manifest-schema.sh), which is what makes this # provably safe rather than lucky. # # 2. REQUIRE "os". Within the agent section, bundle entries share the # "arch"/"file"/"sha256" shape with package entries; only packages # carry "os". Without this the arm64 bundle could be selected and # installed as if it were a .deb. _head="$(printf '%s' "${MANIFEST}" | tr -d '\n' | sed 's/"pins"[[:space:]]*:.*$//')" _obj="$(printf '%s' "${_head}" | tr '{' '\n' \ | grep "\"arch\"[[:space:]]*:[[:space:]]*\"${ARCH}\"" \ | grep '"file"' \ | grep '"os"[[:space:]]*:' | head -n1)" [ -n "${_obj}" ] || die "manifest has no package for arch ${ARCH}" _field() { printf '%s' "${_obj}" | sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p"; } PKG_URL="$(_field url)" PKG_SHA="$(_field sha256)" PKG_FILE="$(_field file)" fi [ -n "${PKG_URL}" ] && [ "${PKG_URL}" != "null" ] || die "manifest has no package URL for arch ${ARCH}" [ -n "${PKG_SHA}" ] && [ "${PKG_SHA}" != "null" ] || die "manifest has no sha256 for arch ${ARCH}" [ -n "${PKG_FILE}" ] || PKG_FILE="sdca_${ARCH}.deb" TMPDIR="$(mktemp -d)" # Best-effort cleanup of the temp dir on any exit. trap 'rm -rf "${TMPDIR}"' EXIT INT TERM DEB_PATH="${TMPDIR}/${PKG_FILE}" log "Downloading ${PKG_FILE} (${ARCH})..." DL_FILE "${PKG_URL}" "${DEB_PATH}" || die "download failed: ${PKG_URL}" log "Verifying checksum..." if command -v sha256sum >/dev/null 2>&1; then ACTUAL_SHA="$(sha256sum "${DEB_PATH}" | cut -d' ' -f1)" elif command -v shasum >/dev/null 2>&1; then ACTUAL_SHA="$(shasum -a 256 "${DEB_PATH}" | cut -d' ' -f1)" else die "need sha256sum or shasum to verify the download." fi [ "${ACTUAL_SHA}" = "${PKG_SHA}" ] \ || die "checksum mismatch (expected ${PKG_SHA}, got ${ACTUAL_SHA}); aborting." # -------------------------------------------------------------------------- # 4. Announce the plan, THEN escalate. Printing exactly what will happen right # before the first sudo prompt turns that prompt into a meaningful # checkpoint: the user sees the verified package name and the actions before # authorizing. # -------------------------------------------------------------------------- log "" log "Verified ${PKG_FILE} (sha256 OK). About to, as root:" if [ -n "${CLAIM}" ]; then log " - write the activation claim to ${CODE_FILE}" fi log " - install the package with apt (resolves traceroute, fping, irtt, iputils-ping)" log " - enable and start sdca.service" if [ -n "${SUDO}" ]; then log "sudo will ask for your password." fi log "" # -------------------------------------------------------------------------- # 5. Write the claim BEFORE installing. Ordering is load-bearing (§3.3 R4): # the agent runs as user `sdca` and postinst's `chown -R sdca /var/lib/sdca` # fixes the root-written file for free. A file written AFTER install stays # root-owned 0600, unreadable to the agent, and the agent silently # self-mints — the wizard's claim poll would then never match. # # Also delete any stale .ac files: a machine that previously self-minted # wrote .ac, which OUTRANKS /var/lib/sdca/code in the agent's # search order — leaving it would shadow the new claim (§4.3). Clearing it # is what makes a --claim re-run the supported claim-repair path. # # ${SUDO} sh -c '...' runs the whole claim-write block as one privileged # unit; the claim is passed as a positional arg to that inner shell (after # the -c script and a "sh" $0 placeholder), never interpolated into the # script text, so a code with shell metacharacters cannot break out. # -------------------------------------------------------------------------- if [ -n "${CLAIM}" ]; then log "Writing activation claim ($(truncate_code "${CLAIM}"))..." ${SUDO} sh -c ' data_dir="$1"; code_file="$2"; claim="$3" mkdir -p "${data_dir}" ( umask 077; printf "%s\n" "${claim}" > "${code_file}" ) chmod 0600 "${code_file}" rm -f "${data_dir}"/*.ac 2>/dev/null || true ' sh "${DATA_DIR}" "${CODE_FILE}" "${CLAIM}" \ || die "could not write the activation claim." fi # -------------------------------------------------------------------------- # 6. Install the .deb with apt so runtime deps resolve (traceroute, fping, # irtt, iputils-ping). `dpkg -i` would leave them unconfigured. # Idempotent: a re-run reinstalls/upgrades rather than failing (R6). # DEBIAN_FRONTEND is passed through sudo's env explicitly (sudo scrubs the # environment by default), so apt stays non-interactive under sudo too. # -------------------------------------------------------------------------- log "Installing the agent package..." ${SUDO} env DEBIAN_FRONTEND=noninteractive apt-get update -qq || true # apt install ./file.deb resolves dependencies; the ./ prefix is required for # apt to treat the arg as a local file rather than a package name. ${SUDO} env DEBIAN_FRONTEND=noninteractive apt-get install -y "${DEB_PATH}" \ || die "apt install failed." # -------------------------------------------------------------------------- # 7. Start the service EXPLICITLY. postinst only runs `try-restart`, a no-op # on a fresh install (the unit would otherwise start only at next reboot). # Without this the happy path — fresh install, page detects the agent — # does not happen until reboot. On an upgrade this is a harmless second # start. # -------------------------------------------------------------------------- log "Starting the agent..." ${SUDO} systemctl daemon-reload >/dev/null 2>&1 || true ${SUDO} systemctl enable sdca.service >/dev/null 2>&1 || true ${SUDO} systemctl start sdca.service \ || die "could not start sdca.service (check: systemctl status sdca)." # -------------------------------------------------------------------------- # 8. Outcome message. # -------------------------------------------------------------------------- log "" log "Focus Layer agent installed and running." if [ -n "${CLAIM}" ]; then log "Return to the browser tab where you copied this command — it will" log "detect the agent shortly and continue automatically." else # The direction of travel is console -> agent, not agent -> console (#385). # There is no code to read off the page: the agent holds none until a claim # arrives. Re-running with --claim is strictly better than the manual path # (the console then continues automatically), so it is offered first. log "No claim was provided, so this agent is not attached to an organization yet." log "" log "Easiest: re-run this installer with the --claim option shown in the" log "Focus Layer console, and the console will continue automatically." log "" log "Otherwise, get a claim from the Focus Layer console and paste it into" log "this agent's page:" log " http://focuslayer.local" # CONTRACT-STATUS-PAGE / #412: every agent answers to the bare # "focuslayer.local", and mDNS is first-responder-wins. On a network that # already has an agent, that name is a coin flip -- and landing on the OTHER # agent looks like success, so the numeric URL is stated as the correct # choice here rather than as a mere fallback. The host's own address is # printed when we can determine it, since "" is exactly the # thing the user does not know. # # This is MORE dangerous than it was under the old flow, not less: pasting a # claim into the wrong agent CLAIMS that agent, and an agent accepts exactly # one claim ever (first-write-wins). Reading a code off the wrong page merely # wasted a minute; claiming the wrong box is not undoable from the page. log "" log "If this network ALREADY has a Focus Layer agent, do not use that name --" log "it may open the other agent instead, and pasting your claim there would" log "attach the WRONG machine. Use this host's address directly:" _ip="$(hostname -I 2>/dev/null | awk '{print $1}')" if [ -n "${_ip}" ]; then log " http://${_ip}/" else log " http:///" fi fi } # end main # main is the ONLY thing invoked. Keep this the last line (truncation guarantee): # a partial download that stops before here defines main() but never calls it. main "$@"