#!/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 | sudo sh -s -- --claim # # 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. set -eu # -------------------------------------------------------------------------- # 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. # -------------------------------------------------------------------------- MANIFEST_URL="${SDCA_MANIFEST_URL:-https://downloads.focus-layer.com/agent/manifest.json}" # 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" 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. truncate_code() { # First 4 chars followed by an ellipsis. Empty stays empty. _c="$1" if [ -n "${_c}" ]; then printf '%s' "${_c}" | cut -c1-4 printf '...' fi } # -------------------------------------------------------------------------- # 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 <<'EOF' Focus Layer agent installer. Usage: curl -fsSL https://get.focus-layer.com | sudo sh -s -- [--claim ] 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. EOF exit 0 ;; *) die "unknown argument: $1 (see --help)" ;; esac done # -------------------------------------------------------------------------- # 2. Environment checks: Linux, amd64/arm64, Debian-family, root. # A piped script cannot re-exec itself ($0 is `sh`), so we cannot sudo # ourselves — fail fast and print the exact command to rerun. # -------------------------------------------------------------------------- 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." if [ "$(id -u)" -ne 0 ]; then log "This installer must run as root." if [ -n "${CLAIM}" ]; then log "Rerun with: curl -fsSL https://get.focus-layer.com | sudo sh -s -- --claim ${CLAIM}" else log "Rerun with: curl -fsSL https://get.focus-layer.com | sudo sh" fi exit 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. # -------------------------------------------------------------------------- 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 §3.2 field names. _obj="$(printf '%s' "${MANIFEST}" | tr -d '\n' | tr '{' '\n' \ | grep "\"arch\"[[:space:]]*:[[:space:]]*\"${ARCH}\"" \ | grep '"file"' | 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. 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. # -------------------------------------------------------------------------- if [ -n "${CLAIM}" ]; then log "Writing activation claim ($(truncate_code "${CLAIM}"))..." mkdir -p "${DATA_DIR}" # umask so the create is 0600 even before the explicit chmod. ( umask 077; printf '%s\n' "${CLAIM}" > "${CODE_FILE}" ) chmod 0600 "${CODE_FILE}" # Clear stale self-minted .ac files so the new claim is not shadowed. # (No-op on a first install; the repair path on a re-run.) rm -f "${DATA_DIR}"/*.ac 2>/dev/null || true fi # -------------------------------------------------------------------------- # 5. 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). # -------------------------------------------------------------------------- log "Installing the agent package..." export 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. apt-get install -y "${DEB_PATH}" \ || die "apt install failed." # -------------------------------------------------------------------------- # 6. 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..." systemctl daemon-reload >/dev/null 2>&1 || true systemctl enable sdca.service >/dev/null 2>&1 || true systemctl start sdca.service || die "could not start sdca.service (check: systemctl status sdca)." # -------------------------------------------------------------------------- # 7. 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 log "No activation code was provided. Open the agent's status page to get one:" log " http://focuslayer.local (fallback: http:/// )" log "then enter it in the onboarding wizard under \"I have an activation code\"." fi