#!/usr/bin/env bash # Provision and install the cph-hub systemd service on a supported Linux host. # The script is idempotent: existing identities/directories are validated and # never silently rewritten. An absent environment file is seeded completely, # then the script exits EX_CONFIG so no unit is installed with placeholders. # # Usage: # sudo BASE=/srv/curriculum-project-hub \ # HUB_DIR=/srv/curriculum-project-hub/hub \ # bash deploy/install_service.sh set -euo pipefail BASE="${BASE:-/srv/curriculum-project-hub}" HUB_DIR="${HUB_DIR:-$BASE/hub}" INSTANCE_ID="${INSTANCE_ID:?INSTANCE_ID is required (lowercase letters, digits, hyphens)}" if [[ ! "$INSTANCE_ID" =~ ^[a-z0-9]([a-z0-9-]{0,22}[a-z0-9])?$ ]]; then echo "[install] error: invalid INSTANCE_ID: $INSTANCE_ID" >&2 exit 1 fi SERVICE_UNIT="cph-hub-$INSTANCE_ID.service" SERVICE_USER="${SERVICE_USER:-cph-$INSTANCE_ID}" SERVICE_GROUP="${SERVICE_GROUP:-$SERVICE_USER}" SERVICE_HOME="${SERVICE_HOME:-/var/lib/cph-hub/$INSTANCE_ID/home}" STATE_DIR="${STATE_DIR:-/var/lib/cph-hub/$INSTANCE_ID/state}" CACHE_DIR="${CACHE_DIR:-/var/cache/cph-hub/$INSTANCE_ID}" SKILL_STORE_ROOT="${SKILL_STORE_ROOT:-$STATE_DIR/skills}" WORKSPACE_ROOT="${WORKSPACE_ROOT:?WORKSPACE_ROOT required (use a short per-Silo path such as /w/997)}" HOST="${HOST:-127.0.0.1}" PORT="${PORT:?PORT is required and must be unique on the host}" ENV_FILE="${ENV_FILE:-$BASE/.secrets/$INSTANCE_ID/platform.env}" KEYRING_FILE="${KEYRING_FILE:-$BASE/.secrets/$INSTANCE_ID/secret-keyring.json}" MEMORY_MAX="${MEMORY_MAX:?MEMORY_MAX is required, for example 16G}" CPU_QUOTA="${CPU_QUOTA:?CPU_QUOTA is required, for example 400%}" TASKS_MAX="${TASKS_MAX:?TASKS_MAX is required, for example 512}" SYSTEMD_UNIT_DIR="${SYSTEMD_UNIT_DIR:-/etc/systemd/system}" NODE_BIN="${NODE_BIN:-$(command -v node || true)}" BWRAP_BIN="${BWRAP_BIN:-$(command -v bwrap || true)}" SOCAT_BIN="${SOCAT_BIN:-$(command -v socat || true)}" PG_ISREADY_BIN="${PG_ISREADY_BIN:-$(command -v pg_isready || true)}" RUNUSER_BIN="${RUNUSER_BIN:-$(command -v runuser || true)}" SETPRIV_BIN="${SETPRIV_BIN:-$(command -v setpriv || true)}" CPH_BIN_DEFAULT="${CPH_BIN_DEFAULT:-/usr/local/bin/cph}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PREFLIGHT_JS="$HUB_DIR/dist/deployment/preflight-cli.js" RUNTIME_PATH="$(dirname "$NODE_BIN"):$(dirname "$BWRAP_BIN"):$(dirname "$SOCAT_BIN"):$(dirname "$SETPRIV_BIN"):/usr/local/bin:/usr/bin:/bin" fail() { echo "[install] error: $*" >&2 exit 1 } require_command() { command -v "$1" >/dev/null 2>&1 || fail "required command is missing: $1" } require_safe_unit_value() { local label="$1" local value="$2" if [[ ! "$value" =~ ^[A-Za-z0-9_./:@+-]+$ ]]; then fail "$label contains characters that cannot be rendered safely into the systemd unit: $value" fi } if [ "$(id -u)" -ne 0 ]; then fail "must run as root (use sudo)" fi if [[ ! "$SERVICE_USER" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]]; then fail "invalid SERVICE_USER: $SERVICE_USER" fi if [[ ! "$SERVICE_GROUP" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]]; then fail "invalid SERVICE_GROUP: $SERVICE_GROUP" fi if [ "$SERVICE_USER" = "root" ] || [ "$SERVICE_GROUP" = "root" ]; then fail "the Hub service identity must not be root" fi for command in getent groupadd useradd install stat systemctl id openssl date mktemp; do require_command "$command" done for pair in \ "NODE_BIN:$NODE_BIN" \ "BWRAP_BIN:$BWRAP_BIN" \ "SOCAT_BIN:$SOCAT_BIN" \ "PG_ISREADY_BIN:$PG_ISREADY_BIN"; do label="${pair%%:*}" value="${pair#*:}" [ -n "$value" ] || fail "$label is required and its executable was not found on PATH" [ -x "$value" ] || fail "$label is not executable: $value" done [ -n "$RUNUSER_BIN" ] || fail "RUNUSER_BIN is required and runuser was not found on PATH" [ -x "$RUNUSER_BIN" ] || fail "RUNUSER_BIN is not executable: $RUNUSER_BIN" [ -n "$SETPRIV_BIN" ] || fail "SETPRIV_BIN is required and setpriv was not found on PATH" [ -x "$SETPRIV_BIN" ] || fail "SETPRIV_BIN is not executable: $SETPRIV_BIN" [ -d "$HUB_DIR" ] || fail "HUB_DIR not found: $HUB_DIR" [ -f "$HUB_DIR/dist/server.js" ] || fail "Hub build missing: $HUB_DIR/dist/server.js" [ -f "$PREFLIGHT_JS" ] || fail "deployment preflight missing from Hub build: $PREFLIGHT_JS" [ -f "$HUB_DIR/dist/deployment/service-runtime-probe.js" ] || fail "service runtime probe missing from Hub build" [ -f "$HUB_DIR/node_modules/prisma/build/index.js" ] || fail "production dependencies missing: run npm ci in $HUB_DIR" [ -f "$HUB_DIR/prisma/schema.prisma" ] || fail "Prisma schema missing: $HUB_DIR/prisma/schema.prisma" [ -f "$SCRIPT_DIR/cph-hub.service" ] || fail "service template missing: $SCRIPT_DIR/cph-hub.service" for pair in \ "BASE:$BASE" \ "HUB_DIR:$HUB_DIR" \ "SERVICE_HOME:$SERVICE_HOME" \ "STATE_DIR:$STATE_DIR" \ "CACHE_DIR:$CACHE_DIR" \ "SKILL_STORE_ROOT:$SKILL_STORE_ROOT" \ "WORKSPACE_ROOT:$WORKSPACE_ROOT" \ "ENV_FILE:$ENV_FILE" \ "KEYRING_FILE:$KEYRING_FILE" \ "SYSTEMD_UNIT_DIR:$SYSTEMD_UNIT_DIR" \ "NODE_BIN:$NODE_BIN" \ "BWRAP_BIN:$BWRAP_BIN" \ "SOCAT_BIN:$SOCAT_BIN" \ "RUNUSER_BIN:$RUNUSER_BIN" \ "SETPRIV_BIN:$SETPRIV_BIN" \ "RUNTIME_PATH:$RUNTIME_PATH"; do require_safe_unit_value "${pair%%:*}" "${pair#*:}" done [[ "$MEMORY_MAX" =~ ^[1-9][0-9]*[KMGT]$ ]] || fail "MEMORY_MAX must be a systemd byte size such as 16G" [[ "$CPU_QUOTA" =~ ^[1-9][0-9]*%$ ]] || fail "CPU_QUOTA must be a positive percentage such as 400%" [[ "$TASKS_MAX" =~ ^[1-9][0-9]*$ ]] || fail "TASKS_MAX must be a positive integer" [ "${#WORKSPACE_ROOT}" -le 16 ] || fail "WORKSPACE_ROOT must be at most 16 bytes for Agent sandbox sockets: $WORKSPACE_ROOT" KEYRING_CREATED=false if [ -L "$KEYRING_FILE" ]; then fail "local secret keyring must not be a symlink: $KEYRING_FILE" fi if [ ! -e "$KEYRING_FILE" ]; then install -d -o root -g root -m 0700 "$(dirname "$KEYRING_FILE")" key_id="local-$(date -u +%Y%m%dT%H%M%SZ)" key_value="$(openssl rand -base64 32)" tmp_keyring="$(mktemp "$(dirname "$KEYRING_FILE")/.secret-keyring.XXXXXX")" trap 'rm -f "${tmp_keyring:-}"' EXIT chmod 0600 "$tmp_keyring" printf '{"version":1,"activeKeyId":"%s","keys":{"%s":"%s"}}\n' \ "$key_id" "$key_id" "$key_value" >"$tmp_keyring" install -o root -g root -m 0600 "$tmp_keyring" "$KEYRING_FILE" rm -f "$tmp_keyring" tmp_keyring="" trap - EXIT unset key_value KEYRING_CREATED=true echo "[install] generated local secret keyring: $KEYRING_FILE" >&2 fi [ -f "$KEYRING_FILE" ] || fail "local secret keyring is not a regular file: $KEYRING_FILE" keyring_metadata="$(stat -c '%u:%g:%a' -- "$KEYRING_FILE")" [ "$keyring_metadata" = "0:0:600" ] || \ fail "local secret keyring must be owned by root:root with mode 0600; found $keyring_metadata" if [ -L "$ENV_FILE" ]; then fail "environment file must not be a symlink: $ENV_FILE" fi if [ ! -e "$ENV_FILE" ]; then install -d -o root -g root -m 0700 "$(dirname "$ENV_FILE")" umask 077 cat >"$ENV_FILE" <&2 echo "[install] copy $KEYRING_FILE to the separately protected keyring backup before continuing" >&2 echo "[install] fill every required blank, install cph at CPH_BIN, then rerun" >&2 exit 78 fi [ -f "$ENV_FILE" ] || fail "environment file is not a regular file: $ENV_FILE" if [ "$KEYRING_CREATED" = true ]; then echo "[install] copy $KEYRING_FILE to the separately protected keyring backup, then rerun" >&2 exit 78 fi PREFLIGHT_ARGS=( "$NODE_BIN" "$PREFLIGHT_JS" --env-file "$ENV_FILE" --keyring-file "$KEYRING_FILE" --node-bin "$NODE_BIN" --runuser-bin "$RUNUSER_BIN" --setpriv-bin "$SETPRIV_BIN" --service-user "$SERVICE_USER" --base-dir "$BASE" --hub-dir "$HUB_DIR" --service-home "$SERVICE_HOME" --state-dir "$STATE_DIR" --cache-dir "$CACHE_DIR" --workspace-root "$WORKSPACE_ROOT" --service-unit "$SERVICE_UNIT" --bwrap-bin "$BWRAP_BIN" --socat-bin "$SOCAT_BIN" --pg-isready-bin "$PG_ISREADY_BIN" ) # Required configuration, path isolation, binaries and PostgreSQL must pass # before identity/directory provisioning or unit installation begins. "${PREFLIGHT_ARGS[@]}" NOLOGIN_SHELL="" for candidate in /usr/sbin/nologin /sbin/nologin /usr/bin/false /bin/false; do if [ -x "$candidate" ]; then NOLOGIN_SHELL="$candidate" break fi done [ -n "$NOLOGIN_SHELL" ] || fail "no non-login shell found" group_entry="$(getent group "$SERVICE_GROUP" || true)" if [ -z "$group_entry" ]; then groupadd --system "$SERVICE_GROUP" group_entry="$(getent group "$SERVICE_GROUP" || true)" [ -n "$group_entry" ] || fail "created service group cannot be resolved: $SERVICE_GROUP" fi IFS=: read -r resolved_group _ SERVICE_GID _ <<<"$group_entry" [ "$resolved_group" = "$SERVICE_GROUP" ] || fail "service group resolved to unexpected name: $resolved_group" [[ "$SERVICE_GID" =~ ^[0-9]+$ ]] || fail "service group has invalid gid: $SERVICE_GID" [ "$SERVICE_GID" -ne 0 ] || fail "service group must not have gid 0" passwd_entry="$(getent passwd "$SERVICE_USER" || true)" if [ -z "$passwd_entry" ]; then useradd \ --system \ --gid "$SERVICE_GROUP" \ --home-dir "$SERVICE_HOME" \ --shell "$NOLOGIN_SHELL" \ --no-create-home \ "$SERVICE_USER" passwd_entry="$(getent passwd "$SERVICE_USER" || true)" [ -n "$passwd_entry" ] || fail "created service user cannot be resolved: $SERVICE_USER" fi IFS=: read -r resolved_user _ SERVICE_UID user_gid _ user_home user_shell <<<"$passwd_entry" [ "$resolved_user" = "$SERVICE_USER" ] || fail "service user resolved to unexpected name: $resolved_user" [[ "$SERVICE_UID" =~ ^[0-9]+$ ]] || fail "service user has invalid uid: $SERVICE_UID" [ "$SERVICE_UID" -ne 0 ] || fail "service user must not have uid 0" [ "$user_gid" = "$SERVICE_GID" ] || fail "service user primary gid $user_gid does not match $SERVICE_GROUP ($SERVICE_GID)" [ "$user_home" = "$SERVICE_HOME" ] || fail "service user home is $user_home; expected $SERVICE_HOME" case "$user_shell" in /usr/sbin/nologin | /sbin/nologin | /usr/bin/false | /bin/false) ;; *) fail "service user must have a non-login shell; found $user_shell" ;; esac service_gids="$(id -G "$SERVICE_USER")" || fail "cannot resolve groups for service user $SERVICE_USER" for service_gid in $service_gids; do if [ "$service_gid" != "$SERVICE_GID" ]; then fail "service user $SERVICE_USER must not have supplementary groups; found gid $service_gid in: $service_gids" fi done provision_directory() { local path="$1" if [ -L "$path" ]; then fail "service directory must not be a symlink: $path" fi if [ -e "$path" ]; then [ -d "$path" ] || fail "service path exists but is not a directory: $path" actual="$(stat -c '%u:%g:%a' -- "$path")" expected="$SERVICE_UID:$SERVICE_GID:750" [ "$actual" = "$expected" ] || fail "service directory $path has $actual; expected $expected" return fi install -d -o "$SERVICE_UID" -g "$SERVICE_GID" -m 0750 "$path" } provision_directory "$SERVICE_HOME" provision_directory "$STATE_DIR" provision_directory "$CACHE_DIR" provision_directory "$SKILL_STORE_ROOT" provision_directory "$WORKSPACE_ROOT" # Resolve every provisioned path again and verify uid/gid/mode before writing # the unit. This catches symlink or ownership changes between the two phases. "${PREFLIGHT_ARGS[@]}" --service-uid "$SERVICE_UID" --service-gid "$SERVICE_GID" UNIT="$SYSTEMD_UNIT_DIR/$SERVICE_UNIT" install -d -o root -g root -m 0755 "$SYSTEMD_UNIT_DIR" TMP_UNIT="$(mktemp)" trap 'rm -f "$TMP_UNIT"' EXIT sed \ -e "s|__SERVICE_USER__|$SERVICE_USER|g" \ -e "s|__SERVICE_GROUP__|$SERVICE_GROUP|g" \ -e "s|__SERVICE_HOME__|$SERVICE_HOME|g" \ -e "s|__STATE_DIR__|$STATE_DIR|g" \ -e "s|__CACHE_DIR__|$CACHE_DIR|g" \ -e "s|__SKILL_STORE_ROOT__|$SKILL_STORE_ROOT|g" \ -e "s|__WORKSPACE_ROOT__|$WORKSPACE_ROOT|g" \ -e "s|__HUB_DIR__|$HUB_DIR|g" \ -e "s|__ENV_FILE__|$ENV_FILE|g" \ -e "s|__KEYRING_FILE__|$KEYRING_FILE|g" \ -e "s|__NODE_BIN__|$NODE_BIN|g" \ -e "s|__BWRAP_BIN__|$BWRAP_BIN|g" \ -e "s|__SOCAT_BIN__|$SOCAT_BIN|g" \ -e "s|__RUNTIME_PATH__|$RUNTIME_PATH|g" \ -e "s|__INSTANCE_ID__|$INSTANCE_ID|g" \ -e "s|__MEMORY_MAX__|$MEMORY_MAX|g" \ -e "s|__CPU_QUOTA__|$CPU_QUOTA|g" \ -e "s|__TASKS_MAX__|$TASKS_MAX|g" \ "$SCRIPT_DIR/cph-hub.service" >"$TMP_UNIT" install -o root -g root -m 0644 "$TMP_UNIT" "$UNIT" systemctl daemon-reload systemctl enable "$SERVICE_UNIT" echo "[install] installed $SERVICE_UNIT for $SERVICE_USER:$SERVICE_GROUP" echo "[install] home=$SERVICE_HOME state=$STATE_DIR cache=$CACHE_DIR skills=$SKILL_STORE_ROOT workspaces=$WORKSPACE_ROOT" echo "[install] start with: systemctl start $SERVICE_UNIT"