#!/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}" SERVICE_USER="${SERVICE_USER:-cph-hub}" SERVICE_GROUP="${SERVICE_GROUP:-$SERVICE_USER}" SERVICE_HOME="${SERVICE_HOME:-/var/lib/cph-hub/home}" STATE_DIR="${STATE_DIR:-/var/lib/cph-hub/state}" CACHE_DIR="${CACHE_DIR:-/var/cache/cph-hub}" WORKSPACE_ROOT="${WORKSPACE_ROOT:-/var/lib/cph-hub/workspaces}" HOST="${HOST:-127.0.0.1}" PORT="${PORT:-8788}" ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}" KEYRING_FILE="${KEYRING_FILE:-$BASE/.secrets/secret-keyring.json}" 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" \ "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 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" --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 "$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/cph-hub.service" 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|__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" \ "$SCRIPT_DIR/cph-hub.service" >"$TMP_UNIT" install -o root -g root -m 0644 "$TMP_UNIT" "$UNIT" systemctl daemon-reload systemctl enable cph-hub.service echo "[install] installed cph-hub.service for $SERVICE_USER:$SERVICE_GROUP" echo "[install] home=$SERVICE_HOME state=$STATE_DIR cache=$CACHE_DIR workspaces=$WORKSPACE_ROOT" echo "[install] start with: systemctl start cph-hub.service"