#!/usr/bin/env bash # Pulse Provider MSP - first-time host setup # Run on a fresh Ubuntu 24.04 host as root. set -euo pipefail IFS=$'\n\t' PULSE_PROVIDER_MSP_INSTALL_DIR="${PULSE_PROVIDER_MSP_INSTALL_DIR:-/opt/pulse-provider-msp}" PULSE_PROVIDER_MSP_DATA_DIR="${PULSE_PROVIDER_MSP_DATA_DIR:-/data}" PULSE_PROVIDER_MSP_DOCKER_NETWORK="${PULSE_PROVIDER_MSP_DOCKER_NETWORK:-pulse-provider-msp}" PULSE_PROVIDER_MSP_DOCKER_SUBNET="${PULSE_PROVIDER_MSP_DOCKER_SUBNET:-172.30.0.0/24}" PULSE_PROVIDER_MSP_DOCKER_SOCKET="${PULSE_PROVIDER_MSP_DOCKER_SOCKET:-/var/run/docker.sock}" PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR="${PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR:-/var/lib/pulse-provider-msp/spacecheck/root}" PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR="${PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR:-/var/lib/docker/.pulse-provider-msp-spacecheck}" PULSE_PROVIDER_MSP_BUNDLE_URL="${PULSE_PROVIDER_MSP_BUNDLE_URL:-}" PULSE_PROVIDER_MSP_EXPECT_ENV="${PULSE_PROVIDER_MSP_EXPECT_ENV:-production}" PULSE_PROVIDER_MSP_SKIP_PULL="${PULSE_PROVIDER_MSP_SKIP_PULL:-0}" PULSE_PROVIDER_MSP_RUN_INSTALL_PROOF="${PULSE_PROVIDER_MSP_RUN_INSTALL_PROOF:-auto}" PULSE_PROVIDER_MSP_ACCOUNT_NAME="${PULSE_PROVIDER_MSP_ACCOUNT_NAME:-}" PULSE_PROVIDER_MSP_OWNER_EMAIL="${PULSE_PROVIDER_MSP_OWNER_EMAIL:-}" log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*" } die() { echo "error: $*" >&2 exit 1 } have() { command -v "$1" >/dev/null 2>&1; } need_root() { if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then die "run as root (or: sudo -E bash setup.sh)" fi } apt_install() { export DEBIAN_FRONTEND=noninteractive apt-get update -y apt-get install -y --no-install-recommends "$@" } install_docker_ce() { if have docker && docker --version >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then log "docker and compose already installed" return 0 fi log "installing Docker CE and compose plugin" apt_install ca-certificates curl gnupg lsb-release install -m 0755 -d /etc/apt/keyrings if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg chmod a+r /etc/apt/keyrings/docker.gpg fi local arch codename arch="$(dpkg --print-architecture)" codename="$(. /etc/os-release && echo "${VERSION_CODENAME}")" cat >/etc/apt/sources.list.d/docker.list </dev/null 2>&1; then log "Docker network ${network} will be created by compose with subnet ${subnet}" return 0 fi existing_subnets="$(docker network inspect -f '{{range .IPAM.Config}}{{println .Subnet}}{{end}}' "${network}" 2>/dev/null | tr '\n' ',' | sed 's/,$//' || true)" if [[ ",${existing_subnets}," != *",${subnet},"* ]]; then die "Docker network ${network} exists with subnet(s) ${existing_subnets:-}; expected ${subnet} so CP_TRUSTED_PROXY_CIDRS can trust Traefik without trusting every peer" fi } block_container_metadata_service() { if ! have iptables; then log "iptables not found; skipping container metadata-service block" return 0 fi log "ensuring containers cannot reach cloud metadata service" iptables -N DOCKER-USER 2>/dev/null || true if ! iptables -C DOCKER-USER -d 169.254.169.254/32 -j REJECT >/dev/null 2>&1; then iptables -I DOCKER-USER -d 169.254.169.254/32 -j REJECT fi } script_dir_best_effort() { if [[ -n "${BASH_SOURCE[0]:-}" && -e "${BASH_SOURCE[0]}" ]]; then (cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) return 0 fi return 1 } install_deploy_bundle() { log "installing provider MSP deploy bundle to ${PULSE_PROVIDER_MSP_INSTALL_DIR}" install -d -m 0755 "${PULSE_PROVIDER_MSP_INSTALL_DIR}" local src_dir="" if src_dir="$(script_dir_best_effort)"; then : else src_dir="" fi local required=( "docker-compose.yml" "traefik.yml" "traefik-dynamic.yml" ".env.example" "run-install-proof.sh" "upgrade.sh" ) if [[ -n "${src_dir}" ]]; then local f for f in "${required[@]}"; do [[ -f "${src_dir}/${f}" ]] || src_dir="" done fi if [[ -z "${src_dir}" && -n "${PULSE_PROVIDER_MSP_BUNDLE_URL}" ]]; then log "deploy bundle not found locally; downloading PULSE_PROVIDER_MSP_BUNDLE_URL" local tmp tmp="$(mktemp -d)" # shellcheck disable=SC2064 trap "rm -rf \"${tmp}\"" EXIT curl -fsSL "${PULSE_PROVIDER_MSP_BUNDLE_URL}" -o "${tmp}/bundle.tgz" tar -xzf "${tmp}/bundle.tgz" -C "${tmp}" local cand ok f while IFS= read -r cand; do [[ -n "${cand}" ]] || continue local d d="$(dirname "${cand}")" ok="1" for f in "${required[@]}"; do [[ -f "${d}/${f}" ]] || ok="0" done if [[ "${ok}" == "1" ]]; then src_dir="${d}" break fi done < <(find "${tmp}" -type f -name docker-compose.yml -print 2>/dev/null || true) fi if [[ -z "${src_dir}" ]]; then cat >&2 <<'EOF' error: missing deploy bundle files next to setup.sh. This script needs these files present on disk: - docker-compose.yml - traefik.yml - traefik-dynamic.yml - .env.example - run-install-proof.sh - upgrade.sh Run it from deploy/provider-msp/, or set PULSE_PROVIDER_MSP_BUNDLE_URL to a tar.gz containing those files. EOF exit 1 fi install -m 0644 "${src_dir}/docker-compose.yml" "${PULSE_PROVIDER_MSP_INSTALL_DIR}/docker-compose.yml" install -m 0644 "${src_dir}/traefik.yml" "${PULSE_PROVIDER_MSP_INSTALL_DIR}/traefik.yml" install -m 0644 "${src_dir}/traefik-dynamic.yml" "${PULSE_PROVIDER_MSP_INSTALL_DIR}/traefik-dynamic.yml" install -m 0644 "${src_dir}/.env.example" "${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env.example" install -m 0755 "${src_dir}/run-install-proof.sh" "${PULSE_PROVIDER_MSP_INSTALL_DIR}/run-install-proof.sh" install -m 0755 "${src_dir}/upgrade.sh" "${PULSE_PROVIDER_MSP_INSTALL_DIR}/upgrade.sh" } env_value() { local key="$1" local env_path="${2:-${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env}" local value value="$(grep -E "^${key}=" "${env_path}" | tail -n 1 | cut -d= -f2- || true)" value="${value%\"}"; value="${value#\"}" value="${value%\'}"; value="${value#\'}" echo "${value}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' } provider_data_dir() { local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env" local configured="" if [[ -f "${env_path}" ]]; then configured="$(env_value PULSE_PROVIDER_MSP_DATA_DIR "${env_path}")" fi echo "${configured:-${PULSE_PROVIDER_MSP_DATA_DIR}}" } provider_docker_network() { local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env" local configured="" if [[ -f "${env_path}" ]]; then configured="$(env_value PULSE_PROVIDER_MSP_DOCKER_NETWORK "${env_path}")" fi echo "${configured:-${PULSE_PROVIDER_MSP_DOCKER_NETWORK}}" } provider_docker_subnet() { local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env" local configured="" if [[ -f "${env_path}" ]]; then configured="$(env_value PULSE_PROVIDER_MSP_DOCKER_SUBNET "${env_path}")" fi echo "${configured:-${PULSE_PROVIDER_MSP_DOCKER_SUBNET}}" } provider_root_spacecheck_dir() { local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env" local configured="" if [[ -f "${env_path}" ]]; then configured="$(env_value PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR "${env_path}")" fi echo "${configured:-${PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR}}" } provider_docker_spacecheck_dir() { local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env" local configured="" if [[ -f "${env_path}" ]]; then configured="$(env_value PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR "${env_path}")" fi echo "${configured:-${PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR}}" } set_env_value() { local key="$1" local value="$2" local env_path="${3:-${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env}" local tmp tmp="$(mktemp)" if grep -q -E "^${key}=" "${env_path}"; then awk -v key="${key}" -v value="${value}" 'BEGIN{done=0} $0 ~ "^" key "=" && done==0 { print key "=" value; done=1; next } { print }' "${env_path}" >"${tmp}" else cat "${env_path}" >"${tmp}" printf '%s=%s\n' "${key}" "${value}" >>"${tmp}" fi cat "${tmp}" >"${env_path}" rm -f "${tmp}" } ensure_generated_secrets() { local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env" [[ -f "${env_path}" ]] || die "missing ${env_path}" have openssl || die "openssl is required to generate provider MSP secrets" if [[ -z "$(env_value CP_ADMIN_KEY "${env_path}")" ]]; then log "generating CP_ADMIN_KEY" set_env_value CP_ADMIN_KEY "$(openssl rand -hex 32)" "${env_path}" fi if [[ -z "$(env_value CP_ENTITLEMENT_SIGNING_PRIVATE_KEY "${env_path}")" ]]; then log "generating CP_ENTITLEMENT_SIGNING_PRIVATE_KEY" set_env_value CP_ENTITLEMENT_SIGNING_PRIVATE_KEY "$(openssl rand -base64 32 | tr -d '\n')" "${env_path}" fi chmod 0600 "${env_path}" } # derive_lease_signing_public_key prints the base64 Ed25519 public key for # CP_ENTITLEMENT_SIGNING_PRIVATE_KEY. The provider MSP license must bind this # exact key (entitlement_signing_public_key) or the control plane will refuse # to start; include it when requesting your license. The private key never # leaves this host. derive_lease_signing_public_key() { local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env" [[ -f "${env_path}" ]] || die "missing ${env_path}" have openssl || die "openssl is required to derive the lease signing public key" local key_b64 key_len tmp_der key_b64="$(env_value CP_ENTITLEMENT_SIGNING_PRIVATE_KEY "${env_path}")" [[ -n "${key_b64}" ]] || die "CP_ENTITLEMENT_SIGNING_PRIVATE_KEY is not set; run setup.sh first" key_len="$(printf '%s' "${key_b64}" | base64 -d 2>/dev/null | wc -c | tr -d ' ')" case "${key_len}" in 64) # 64-byte Ed25519 private key: the public key is the trailing 32 bytes. printf '%s' "${key_b64}" | base64 -d | tail -c 32 | base64 | tr -d '\n' ;; 32) # 32-byte seed: wrap in a PKCS#8 DER envelope and let openssl derive # the public key (raw key = trailing 32 bytes of the SPKI DER). tmp_der="$(mktemp)" { printf '\x30\x2e\x02\x01\x00\x30\x05\x06\x03\x2b\x65\x70\x04\x22\x04\x20' printf '%s' "${key_b64}" | base64 -d } >"${tmp_der}" openssl pkey -inform DER -in "${tmp_der}" -pubout -outform DER 2>/dev/null | tail -c 32 | base64 | tr -d '\n' rm -f "${tmp_der}" ;; *) die "CP_ENTITLEMENT_SIGNING_PRIVATE_KEY must decode to a 32-byte seed or 64-byte Ed25519 key (got ${key_len} bytes)" ;; esac } truthy() { case "$(echo "$1" | tr '[:upper:]' '[:lower:]')" in true|1|yes|on) return 0 ;; *) return 1 ;; esac } falsy() { case "$(echo "$1" | tr '[:upper:]' '[:lower:]')" in false|0|no|off) return 0 ;; *) return 1 ;; esac } ensure_env_file() { local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env" if [[ -f "${env_path}" ]]; then chmod 0600 "${env_path}" || true return 0 fi log "no ${env_path}; creating from .env.example" cp -n "${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env.example" "${env_path}" chmod 0600 "${env_path}" cat <"* ]]; then die "${k} must be an immutable digest ref (expected ...@sha256:...)" fi done local forbidden forbidden_value for forbidden in STRIPE_API_KEY STRIPE_WEBHOOK_SECRET CP_TRIAL_SIGNUP_PRICE_ID CP_PUBLIC_CLOUD_SIGNUP_ENABLED CP_MSP_STARTER_PRICE_ID CP_MSP_GROWTH_PRICE_ID CP_MSP_SCALE_PRICE_ID; do forbidden_value="$(env_value "${forbidden}" "${env_path}")" if [[ -n "${forbidden_value}" ]]; then die "${forbidden} must not be configured in provider-hosted MSP mode" fi done if ! falsy "$(env_value CP_ALLOW_DOCKERLESS_PROVISIONING "${env_path}")"; then die "CP_ALLOW_DOCKERLESS_PROVISIONING must be false for provider-hosted MSP deploys" fi if ! truthy "$(env_value CP_STORAGE_GUARDRAILS_ENABLED "${env_path}")"; then die "CP_STORAGE_GUARDRAILS_ENABLED must be true for provider-hosted MSP deploys" fi local admin_key trial_key trusted_cidrs docker_subnet admin_key="$(env_value CP_ADMIN_KEY "${env_path}")" if [[ "${#admin_key}" -lt 32 ]]; then die "CP_ADMIN_KEY must be at least 32 characters" fi trial_key="$(env_value CP_ENTITLEMENT_SIGNING_PRIVATE_KEY "${env_path}")" if ! printf '%s' "${trial_key}" | base64 -d >/dev/null 2>&1; then die "CP_ENTITLEMENT_SIGNING_PRIVATE_KEY must be valid base64" fi docker_subnet="$(env_value PULSE_PROVIDER_MSP_DOCKER_SUBNET "${env_path}")" trusted_cidrs="$(env_value CP_TRUSTED_PROXY_CIDRS "${env_path}" | tr -d '[:space:]')" if [[ ",${trusted_cidrs}," != *",${docker_subnet},"* ]]; then die "CP_TRUSTED_PROXY_CIDRS must include PULSE_PROVIDER_MSP_DOCKER_SUBNET (${docker_subnet})" fi local proof_matchers required_matcher proof_matchers="$(env_value CP_PROOF_TENANT_MATCHERS "${env_path}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" for required_matcher in proof canary rehearsal msp_prod ownerseed owner_seed; do if [[ ",${proof_matchers}," != *",${required_matcher},"* ]]; then die "CP_PROOF_TENANT_MATCHERS must include '${required_matcher}'" fi done local require_email require_email="$(env_value CP_REQUIRE_EMAIL_PROVIDER "${env_path}")" if ! truthy "${require_email}" && ! falsy "${require_email}"; then die "CP_REQUIRE_EMAIL_PROVIDER must be an explicit boolean value" fi if truthy "${require_email}" && [[ -z "$(env_value RESEND_API_KEY "${env_path}")" ]]; then die "RESEND_API_KEY is required when CP_REQUIRE_EMAIL_PROVIDER=true" fi local license_file license_file="$(env_value CP_PROVIDER_MSP_LICENSE_FILE "${env_path}")" if [[ "${license_file}" != /* ]]; then license_file="${PULSE_PROVIDER_MSP_INSTALL_DIR}/${license_file}" fi if [[ ! -f "${license_file}" ]]; then die "CP_PROVIDER_MSP_LICENSE_FILE does not exist: ${license_file} Request your provider MSP license with this lease signing public key (./setup.sh --print-lease-signing-public-key): $(derive_lease_signing_public_key) The license must bind this key or the control plane will refuse to start." fi } validate_compose_config() { log "validating compose config" (cd "${PULSE_PROVIDER_MSP_INSTALL_DIR}" && docker compose config --quiet) } pull_provider_images() { if truthy "${PULSE_PROVIDER_MSP_SKIP_PULL}"; then log "skipping image pull because PULSE_PROVIDER_MSP_SKIP_PULL=${PULSE_PROVIDER_MSP_SKIP_PULL}" return 0 fi log "pulling provider MSP images" (cd "${PULSE_PROVIDER_MSP_INSTALL_DIR}" && docker compose pull traefik docker-socket-proxy control-plane) } run_install_proof_if_requested() { local mode mode="$(echo "${PULSE_PROVIDER_MSP_RUN_INSTALL_PROOF}" | tr '[:upper:]' '[:lower:]')" case "${mode}" in auto|true|1|yes|on|false|0|no|off) ;; *) die "PULSE_PROVIDER_MSP_RUN_INSTALL_PROOF must be auto, true, or false" ;; esac if falsy "${mode}"; then return 0 fi if [[ -z "${PULSE_PROVIDER_MSP_ACCOUNT_NAME}" || -z "${PULSE_PROVIDER_MSP_OWNER_EMAIL}" ]]; then if truthy "${mode}"; then die "PULSE_PROVIDER_MSP_ACCOUNT_NAME and PULSE_PROVIDER_MSP_OWNER_EMAIL are required when PULSE_PROVIDER_MSP_RUN_INSTALL_PROOF=true" fi return 0 fi log "running provider MSP install proof" ( cd "${PULSE_PROVIDER_MSP_INSTALL_DIR}" PROVIDER_MSP_ACCOUNT_NAME="${PULSE_PROVIDER_MSP_ACCOUNT_NAME}" \ PROVIDER_MSP_OWNER_EMAIL="${PULSE_PROVIDER_MSP_OWNER_EMAIL}" \ ./run-install-proof.sh ) } print_summary() { local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env" local domain data_dir network domain="$(env_value DOMAIN "${env_path}")" data_dir="$(provider_data_dir)" network="$(provider_docker_network)" cat <