Files
coreci-chat/scripts/install.sh
T
CIAgent 86f7dcc10b feat(P4): Wave D relay agent — Go binary, install script, WebSocket, SSH whitelist hook
REQ-010: modular install script (detect_os/install_binary/write_systemd_unit/register_target)
REQ-011: outbound WebSocket from relay agent to SaaS
REQ-012: register with tenant + target metadata
REQ-013: heartbeat + exponential backoff reconnect (max 5 → alert)
REQ-026 partial: SSH whitelist file + CheckCommand hook (G-004 contract lock,
  G-007 shadow exec.Cmd test, G-008 scope statement)
G-009: unsupported-OS abort with actionable error

---ci---
phase: 4
milestone: v0.1
status: execute
---/ci---
2026-08-25 02:03:04 +00:00

269 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
# CoreCI Chat Relay Agent — install script (Wave D Task 4, REQ-010, G-009).
#
# Modular: detect_os → install_binary → write_systemd_unit → register_target.
# Idempotent (re-run upgrades). Aborts cleanly on an unsupported OS with an
# actionable error listing the supported OSes (Edge 16).
#
# Usage:
# CORECI_TENANT_TOKEN=<jwt> CORECI_SAAS_URL=https://chat.coreci.dev \
# curl -fsSL https://chat.coreci.dev/install.sh | sh
#
# Or with the token pre-embedded by the dashboard's /dashboard/relay page:
# curl -fsSL "https://chat.coreci.dev/install.sh?t=<token>" | sh
#
# The script is designed to be readable + auditable: it never pipes an
# unverified binary into a shell. The downloaded binary's SHA256 is verified
# against a checksum file before install (R-005 pitfall guard).
set -euo pipefail
# ─── Defaults / constants ────────────────────────────────────────────────────
readonly BIN_INSTALL_PATH="/usr/local/bin/coreci-relay-agent"
readonly UNIT_PATH="/etc/systemd/system/coreci-relay-agent.service"
readonly ENV_DIR="/etc/coreci"
readonly ENV_FILE="${ENV_DIR}/relay.env"
readonly SERVICE_NAME="coreci-relay-agent"
# Released by the Gitea release for v0.0.5. Override with CORECI_RELEASE_URL for
# self-hosted deploys. The per-arch binary URL pattern is:
# ${RELEASE_URL}/coreci-relay-agent-${OS}-${ARCH}
readonly DEFAULT_RELEASE_URL="https://git.cloudinit.dev/coreci/coreci-chat/releases/download/v0.0.5"
# Agent version this script installs (must match the Go binary's AgentVersion).
readonly AGENT_VERSION="0.0.5"
# Supported OSes (REQ-010 Edge 16 / G-009). Used in the abort message.
readonly SUPPORTED_OS_MSG="Supported: Ubuntu 24.04 LTS, Debian 12+. Detected: ${ID:-unknown} ${VERSION_ID:-unknown}"
# Minimum supported VERSION_ID per distro (parsed from /etc/os-release).
declare -A MIN_VERSIONS=(
["ubuntu"]="24.04"
["debian"]="12"
)
# ─── Logging ─────────────────────────────────────────────────────────────────
log() { printf '[coreci-install] %s\n' "$*" >&2; }
ok() { printf '[coreci-install] \033[32mok\033[0m %s\n' "$*" >&2; }
warn() { printf '[coreci-install] \033[33mwarn\033[0m %s\n' "$*" >&2; }
die() { printf '[coreci-install] \033[31merror\033[0m %s\n' "$*" >&2; exit 1; }
# ─── detect_os ───────────────────────────────────────────────────────────────
# Parses /etc/os-release (ID + VERSION_ID). Supported: ubuntu >= 24.04,
# debian >= 12. Returns 0 + sets OS_NAME/OS_VERSION if supported; returns 1 +
# prints the actionable error if not (Edge 16, G-009).
#
# Exposed for testing: the function only reads OS_NAME/OS_VERSION/ID/
# VERSION_ID globals; tests can source this script and call detect_os with a
# fake /etc/os-release by overriding the OS_RELEASE_PATH variable.
OS_RELEASE_PATH="${OS_RELEASE_PATH:-/etc/os-release}"
OS_NAME=""
OS_VERSION=""
detect_os() {
local release_file="$OS_RELEASE_PATH"
if [[ ! -f "$release_file" ]]; then
log "Unsupported OS. $SUPPORTED_OS_MSG (no /etc/os-release found)"
return 1
fi
# Parse ID and VERSION_ID (and PRETTY_NAME for the abort message).
# shellcheck disable=SC1090
ID="" VERSION_ID="" PRETTY_NAME=""
set +u
# Source the file in a subshell so its KEY=value lines populate our locals
# without polluting the caller's environment.
eval "$(grep -E '^(ID|VERSION_ID|PRETTY_NAME)=' "$release_file" || true)"
set -u
ID="${ID:-}"
VERSION_ID="${VERSION_ID:-}"
OS_NAME="$ID"
OS_VERSION="$VERSION_ID"
local min="${MIN_VERSIONS[$ID]:-}"
if [[ -z "$min" ]]; then
log "Unsupported OS. $SUPPORTED_OS_MSG"
return 1
fi
# Version compare: Debian's VERSION_ID is "12" or "12.1"; Ubuntu's is "24.04".
# We compare as floating-ish by splitting on '.' and comparing major then minor.
if ! version_ge "$VERSION_ID" "$min"; then
log "Unsupported OS. $SUPPORTED_OS_MSG"
return 1
fi
ok "detected supported OS: $ID $VERSION_ID"
return 0
}
# version_ge returns 0 if $1 >= $2, comparing dotted versions numerically
# (24.04 >= 24.04 → true; 24.10 >= 24.04 → true; 22.04 >= 24.04 → false).
version_ge() {
local a="$1" b="$2"
local IFS=.
local a_parts=($a) b_parts=($b)
local i
for i in 0 1 2 3; do
local ai="${a_parts[i]:-0}"
local bi="${b_parts[i]:-0}"
# Strip non-numeric suffixes (e.g., 12.1 → 12, 24.04 LTS → 24.04).
ai="${ai//[^0-9]/}"
bi="${bi//[^0-9]/}"
if (( 10#$ai > 10#$bi )); then return 0; fi
if (( 10#$ai < 10#$bi )); then return 1; fi
done
return 0
}
# ─── install_binary ──────────────────────────────────────────────────────────
# Detects arch (uname -m → amd64/arm64), downloads the Go binary from the
# SaaS release URL, verifies the SHA256 checksum, and installs to
# /usr/local/bin/coreci-relay-agent. Idempotent (overwrites on re-run).
install_binary() {
local arch release_url bin_url checksum_url tmpdir tmp_bin tmp_sum
arch="$(uname -m)"
case "$arch" in
x86_64|amd64) arch="amd64" ;;
aarch64|arm64) arch="arm64" ;;
*) die "Unsupported architecture: $arch (supported: amd64, arm64)" ;;
esac
release_url="${CORECI_RELEASE_URL:-$DEFAULT_RELEASE_URL}"
bin_url="${release_url}/coreci-relay-agent-linux-${arch}"
checksum_url="${release_url}/checksums.txt"
log "downloading binary: $bin_url"
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' RETURN
tmp_bin="$tmpdir/coreci-relay-agent"
tmp_sum="$tmpdir/checksums.txt"
if ! curl -fsSL "$bin_url" -o "$tmp_bin"; then
die "failed to download binary from $bin_url"
fi
log "downloading checksum: $checksum_url"
if ! curl -fsSL "$checksum_url" -o "$tmp_sum"; then
die "failed to download checksum from $checksum_url"
fi
# Verify: the checksum file has lines like
# <sha256> coreci-relay-agent-linux-amd64
local expected_sha actual_sha match_line
match_line="$(grep -E "coreci-relay-agent-linux-${arch}\$" "$tmp_sum" || true)"
if [[ -z "$match_line" ]]; then
die "no checksum entry for linux-${arch} in $checksum_url"
fi
expected_sha="$(awk '{print $1}' <<<"$match_line")"
actual_sha="$(sha256sum "$tmp_bin" | awk '{print $1}')"
if [[ "$expected_sha" != "$actual_sha" ]]; then
die "checksum mismatch for $bin_url: expected $expected_sha, got $actual_sha"
fi
ok "checksum verified ($expected_sha)"
install -m 0755 "$tmp_bin" "$BIN_INSTALL_PATH"
ok "installed binary to $BIN_INSTALL_PATH"
}
# ─── write_systemd_unit ──────────────────────────────────────────────────────
# Writes /etc/systemd/system/coreci-relay-agent.service and enables + starts it.
# Idempotent: overwrites + daemon-reload + restart-if-running on re-run.
write_systemd_unit() {
cat >"$UNIT_PATH" <<UNIT
[Unit]
Description=CoreCI Chat Relay Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=${BIN_INSTALL_PATH}
Restart=on-failure
RestartSec=5
Environment=CORECI_CONFIG=${ENV_FILE}
EnvironmentFile=-${ENV_FILE}
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=${ENV_DIR}
[Install]
WantedBy=multi-user.target
UNIT
ok "wrote systemd unit to $UNIT_PATH"
systemctl daemon-reload
systemctl enable "$SERVICE_NAME" >/dev/null 2>&1 || true
ok "enabled $SERVICE_NAME (will start after register_target sets the token)"
}
# ─── register_target ─────────────────────────────────────────────────────────
# Writes /etc/coreci/relay.env with CORECI_TENANT_TOKEN + CORECI_SAAS_URL.
# Idempotent: preserves an existing token if the env vars aren't set on this
# run (re-run for a binary upgrade should not wipe the registration).
register_target() {
local token saas_url
token="${CORECI_TENANT_TOKEN:-}"
saas_url="${CORECI_SAAS_URL:-}"
mkdir -p "$ENV_DIR"
chmod 0750 "$ENV_DIR"
# Preserve existing values if the env var isn't set on this run.
if [[ -f "$ENV_FILE" ]]; then
if [[ -z "$token" ]]; then
token="$(grep -E '^CORECI_TENANT_TOKEN=' "$ENV_FILE" | cut -d= -f2- || true)"
fi
if [[ -z "$saas_url" ]]; then
saas_url="$(grep -E '^CORECI_SAAS_URL=' "$ENV_FILE" | cut -d= -f2- || true)"
fi
fi
if [[ -z "$token" ]]; then
die "CORECI_TENANT_TOKEN is required. Get it from the dashboard's /dashboard/relay page (the curl|bash command embeds it)."
fi
if [[ -z "$saas_url" ]]; then
die "CORECI_SAAS_URL is required (e.g. https://chat.coreci.dev)."
fi
# Write atomically (write to a temp + mv so a crash mid-write doesn't leave a
# half-written env file).
local tmp_env="${ENV_FILE}.tmp"
{
echo "CORECI_TENANT_TOKEN=${token}"
echo "CORECI_SAAS_URL=${saas_url}"
} >"$tmp_env"
chmod 0600 "$tmp_env"
mv "$tmp_env" "$ENV_FILE"
ok "wrote registration to $ENV_FILE (token preserved if already set)"
}
# ─── main ────────────────────────────────────────────────────────────────────
# Orchestrates: detect_os → install_binary → write_systemd_unit → register_target.
# On any failure, exits non-zero with an actionable error (set -e + die()).
main() {
# Root required: we install to /usr/local/bin + /etc/systemd + /etc/coreci.
if [[ $EUID -ne 0 ]]; then
die "must run as root (use sudo). The install writes to /usr/local/bin, /etc/systemd, /etc/coreci."
fi
log "CoreCI Chat Relay Agent installer v$AGENT_VERSION"
detect_os || exit 1
install_binary
write_systemd_unit
register_target
# Now that the env file exists, start (or restart) the service.
systemctl restart "$SERVICE_NAME" 2>/dev/null || systemctl start "$SERVICE_NAME"
ok "$SERVICE_NAME started. Check status: systemctl status $SERVICE_NAME"
ok "install complete. The agent will connect to the SaaS within 60s."
}
# Only run main when executed (not when sourced for testing).
if [[ "${BASH_SOURCE[0]:-$0}" == "${0}" ]]; then
main "$@"
fi