#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" CONFIG_DIR="${PROJECT_ROOT}/config" KEY_DIR="${CONFIG_DIR}/keys" CLIENT_ROOT="${CONFIG_DIR}/clients" CLIENT_ID="" INSTALLATION_ID="" CLOUD_BASE_URL="" TOKEN_URL="" CLIENT_DIR="" CLIENT_PRIVATE_KEY="" CLIENT_PUBLIC_KEY="" MACHINE_ASSERTION="" CLIENT_NAME="" DB_HOST_VALUE="" DB_PORT_VALUE="" DB_NAME_VALUE="" DB_USER_VALUE="" DB_PASSWORD_VALUE="" LICENSE_TYPE="" PACKAGE_CODE="" LICENSE_MONTHS="" PSQL_BIN="" SKIP_BUILD_CONFIGURED="${CYGNUS_SETUP_SKIP_BUILD+x}" SKIP_BUILD="${CYGNUS_SETUP_SKIP_BUILD:-false}" FORCE_ASSERTION="${CYGNUS_SETUP_FORCE_ASSERTION:-false}" NON_INTERACTIVE="${CYGNUS_SETUP_NON_INTERACTIVE:-false}" log() { printf '\n==> %s\n' "$1" } fail() { printf '\nERROR: %s\n' "$1" >&2 exit 1 } require_command() { command -v "$1" >/dev/null 2>&1 || fail "Required command '$1' was not found." } prompt_value() { local prompt="$1" local default_value="$2" local value="" if [[ "${NON_INTERACTIVE}" == "true" ]] || [[ ! -t 0 ]]; then printf '%s' "${default_value}" return fi read -r -p "${prompt} [${default_value}]: " value printf '%s' "${value:-${default_value}}" } prompt_yes_no() { local prompt="$1" local default_value="$2" local answer="" local hint="y/N" [[ "${default_value}" == "true" ]] && hint="Y/n" if [[ "${NON_INTERACTIVE}" == "true" ]] || [[ ! -t 0 ]]; then printf '%s' "${default_value}" return fi read -r -p "${prompt} [${hint}]: " answer case "${answer}" in y|Y|yes|YES) printf '%s' "true" ;; n|N|no|NO) printf '%s' "false" ;; "") printf '%s' "${default_value}" ;; *) fail "Please answer yes or no." ;; esac } validate_identifier() { local label="$1" local value="$2" [[ "${value}" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || fail \ "${label} must contain no spaces and use only letters, numbers, '-' or '_'." } validate_client_slug() { [[ "$1" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] || fail \ "Customer identifier must use lowercase letters, numbers and single hyphens only." } collect_client_inputs() { local default_client="${CYGNUS_SETUP_CLIENT_ID:-customer-a}" local default_installation="${CYGNUS_SETUP_INSTALLATION_ID:-site-01}" local default_url="${CYGNUS_SETUP_CLOUD_URL:-http://localhost:8090}" log "Client provisioning details" CLIENT_ID="$(prompt_value \ "Customer identifier (no spaces; used in key and directory names)" \ "${default_client}")" validate_client_slug "${CLIENT_ID}" CLIENT_NAME="$(prompt_value \ "Customer display name" \ "${CYGNUS_SETUP_CLIENT_NAME:-${CLIENT_ID}}")" [[ -n "${CLIENT_NAME}" ]] || fail "Customer display name is required." INSTALLATION_ID="$(prompt_value \ "Installation identifier (no spaces)" \ "${default_installation}")" validate_identifier "Installation identifier" "${INSTALLATION_ID}" CLOUD_BASE_URL="$(prompt_value "Cloud service base URL" "${default_url}")" [[ "${CLOUD_BASE_URL}" =~ ^https?://[^[:space:]]+$ ]] || fail \ "Cloud service base URL must be a valid http:// or https:// URL." if [[ -z "${SKIP_BUILD_CONFIGURED}" ]]; then if [[ "$(prompt_yes_no "Run the complete Maven verification" "true")" == "true" ]]; then SKIP_BUILD="false" else SKIP_BUILD="true" fi fi TOKEN_URL="${CLOUD_BASE_URL%/}/oauth2/token" CLIENT_DIR="${CLIENT_ROOT}/${CLIENT_ID}" CLIENT_PRIVATE_KEY="${CLIENT_DIR}/${CLIENT_ID}-signing-private.pem" CLIENT_PUBLIC_KEY="${CLIENT_DIR}/${CLIENT_ID}-signing-public.pem" MACHINE_ASSERTION="${CLIENT_DIR}/${CLIENT_ID}-${INSTALLATION_ID}-assertion.jwt" DB_HOST_VALUE="$(prompt_value \ "Cloud PostgreSQL host" "${CYGNUS_SETUP_DB_HOST:-${DB_HOST:-localhost}}")" DB_PORT_VALUE="$(prompt_value \ "Cloud PostgreSQL port" "${CYGNUS_SETUP_DB_PORT:-${DB_PORT:-5432}}")" DB_NAME_VALUE="$(prompt_value \ "Cloud PostgreSQL database" "${CYGNUS_SETUP_DB_NAME:-${DB_NAME:-matrix}}")" DB_USER_VALUE="$(prompt_value \ "Cloud PostgreSQL user" "${CYGNUS_SETUP_DB_USER:-${DB_USER:-postgres}}")" DB_PASSWORD_VALUE="${CYGNUS_SETUP_DB_PASSWORD:-${DB_PASSWORD:-}}" if [[ -z "${DB_PASSWORD_VALUE}" ]] && [[ "${NON_INTERACTIVE}" != "true" ]] && [[ -t 0 ]]; then read -r -s -p "Cloud PostgreSQL password: " DB_PASSWORD_VALUE printf '\n' fi [[ -n "${DB_PASSWORD_VALUE}" ]] || fail \ "Cloud PostgreSQL password is required through the prompt or CYGNUS_SETUP_DB_PASSWORD." LICENSE_TYPE="$(prompt_value \ "License type" "${CYGNUS_SETUP_LICENSE_TYPE:-ANNUAL}")" PACKAGE_CODE="$(prompt_value \ "License package code" "${CYGNUS_SETUP_PACKAGE_CODE:-FULL}")" LICENSE_MONTHS="$(prompt_value \ "License validity in months" "${CYGNUS_SETUP_LICENSE_MONTHS:-12}")" validate_identifier "License type" "${LICENSE_TYPE}" validate_identifier "Package code" "${PACKAGE_CODE}" [[ "${LICENSE_MONTHS}" =~ ^[1-9][0-9]*$ ]] || fail \ "License validity must be a positive whole number of months." printf '\nProvisioning summary:\n' printf ' Customer: %s\n' "${CLIENT_ID}" printf ' Customer name: %s\n' "${CLIENT_NAME}" printf ' Installation: %s\n' "${INSTALLATION_ID}" printf ' Cloud URL: %s\n' "${CLOUD_BASE_URL}" printf ' Token audience: %s\n' "${TOKEN_URL}" printf ' Database: %s@%s:%s/%s\n' \ "${DB_USER_VALUE}" "${DB_HOST_VALUE}" "${DB_PORT_VALUE}" "${DB_NAME_VALUE}" printf ' License: %s / %s / %s month(s)\n' \ "${LICENSE_TYPE}" "${PACKAGE_CODE}" "${LICENSE_MONTHS}" printf ' Verify build: %s\n' "$([[ "${SKIP_BUILD}" == "true" ]] && printf no || printf yes)" if [[ "$(prompt_yes_no "Continue with these values" "true")" != "true" ]]; then fail "Provisioning cancelled." fi } generate_private_key() { local path="$1" if [[ -f "${path}" ]]; then printf 'Keeping existing private key: %s\n' "${path}" return fi openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out "${path}" chmod 600 "${path}" } generate_public_key() { local private_path="$1" local public_path="$2" if [[ -f "${public_path}" ]]; then printf 'Keeping existing public key: %s\n' "${public_path}" return fi openssl pkey -in "${private_path}" -pubout -out "${public_path}" chmod 644 "${public_path}" } configure_java_21() { local java_major if [[ "$(uname -s)" == "Darwin" ]] && [[ -x /usr/libexec/java_home ]]; then if java_home_21="$(/usr/libexec/java_home -v 21 2>/dev/null)"; then export JAVA_HOME="${java_home_21}" export PATH="${JAVA_HOME}/bin:${PATH}" fi fi require_command java java_major="$(java -version 2>&1 | awk -F '[".]' '/version/ {print $2; exit}')" [[ "${java_major}" == "21" ]] || fail \ "JDK 21 is required. Current java is: $(java -version 2>&1 | head -n 1)" } register_client_in_database() { local client_id_b64 client_name_b64 installation_b64 public_key_b64 local license_type_b64 package_code_b64 client_id_b64="$(printf '%s' "${CLIENT_ID}" | openssl base64 -A)" client_name_b64="$(printf '%s' "${CLIENT_NAME}" | openssl base64 -A)" installation_b64="$(printf '%s' "${INSTALLATION_ID}" | openssl base64 -A)" public_key_b64="$(openssl base64 -A -in "${CLIENT_PUBLIC_KEY}")" license_type_b64="$(printf '%s' "${LICENSE_TYPE}" | openssl base64 -A)" package_code_b64="$(printf '%s' "${PACKAGE_CODE}" | openssl base64 -A)" PGPASSWORD="${DB_PASSWORD_VALUE}" "${PSQL_BIN}" \ -h "${DB_HOST_VALUE}" \ -p "${DB_PORT_VALUE}" \ -U "${DB_USER_VALUE}" \ -d "${DB_NAME_VALUE}" \ -X -v ON_ERROR_STOP=1 \ -c " WITH registration AS ( SELECT registration_id FROM identity.client_registration_details WHERE lower(client_code) = lower( convert_from(decode('${client_id_b64}', 'base64'), 'UTF8')) AND status = 'ACTIVE' ORDER BY created_at LIMIT 1 ), account AS ( INSERT INTO identity.client_account (tenant_id, registration_id, client_slug, client_name, status) SELECT gen_random_uuid(), registration_id, convert_from(decode('${client_id_b64}', 'base64'), 'UTF8'), convert_from(decode('${client_name_b64}', 'base64'), 'UTF8'), 'ACTIVE' FROM registration ON CONFLICT (client_slug) DO UPDATE SET registration_id = EXCLUDED.registration_id, client_name = EXCLUDED.client_name, status = 'ACTIVE', updated_at = now() RETURNING tenant_id ), new_license AS ( INSERT INTO identity.client_license (license_id, tenant_id, license_type, package_code, valid_from, valid_until, status) SELECT gen_random_uuid(), tenant_id, convert_from(decode('${license_type_b64}', 'base64'), 'UTF8'), convert_from(decode('${package_code_b64}', 'base64'), 'UTF8'), now(), now() + make_interval(months => ${LICENSE_MONTHS}), 'ACTIVE' FROM account WHERE NOT EXISTS ( SELECT 1 FROM identity.client_license existing WHERE existing.tenant_id = account.tenant_id AND existing.status = 'ACTIVE' AND existing.valid_until > now() ) RETURNING license_id, tenant_id ), selected_license AS ( SELECT existing.license_id, existing.tenant_id FROM identity.client_license existing JOIN account ON account.tenant_id = existing.tenant_id WHERE existing.status = 'ACTIVE' AND existing.valid_until > now() UNION ALL SELECT license_id, tenant_id FROM new_license ORDER BY license_id LIMIT 1 ), installation AS ( INSERT INTO identity.client_installation (installation_id, tenant_id, client_id, installation_code, assertion_public_key, allowed_scopes, enabled, license_id, installation_uuid, installation_name, status, software_version, environment) SELECT gen_random_uuid(), account.tenant_id, convert_from(decode('${client_id_b64}', 'base64'), 'UTF8'), convert_from(decode('${installation_b64}', 'base64'), 'UTF8'), convert_from(decode('${public_key_b64}', 'base64'), 'UTF8'), ARRAY['identity.login']::text[], true, selected_license.license_id, gen_random_uuid(), convert_from(decode('${client_name_b64}', 'base64'), 'UTF8') || ' ' || convert_from(decode('${installation_b64}', 'base64'), 'UTF8'), 'ACTIVE', 'development', 'development' FROM account JOIN selected_license ON selected_license.tenant_id = account.tenant_id ON CONFLICT (client_id, installation_code) DO UPDATE SET tenant_id = EXCLUDED.tenant_id, assertion_public_key = EXCLUDED.assertion_public_key, allowed_scopes = EXCLUDED.allowed_scopes, enabled = true, license_id = EXCLUDED.license_id, status = 'ACTIVE', security_version = identity.client_installation.security_version + 1, updated_at = now() RETURNING tenant_id ) SELECT count(*) AS provisioned_installations FROM installation;" local installation_count installation_count="$( PGPASSWORD="${DB_PASSWORD_VALUE}" "${PSQL_BIN}" \ -h "${DB_HOST_VALUE}" \ -p "${DB_PORT_VALUE}" \ -U "${DB_USER_VALUE}" \ -d "${DB_NAME_VALUE}" \ -X -A -t \ -c " SELECT count(*) FROM identity.client_installation WHERE client_id = convert_from(decode('${client_id_b64}', 'base64'), 'UTF8') AND installation_code = convert_from(decode('${installation_b64}', 'base64'), 'UTF8') AND enabled = true;" )" [[ "${installation_count//[[:space:]]/}" == "1" ]] || fail \ "No active registration/account was found for '${CLIENT_ID}'. Create or activate client_registration_details first." } generate_machine_assertion() { if [[ -f "${MACHINE_ASSERTION}" ]] \ && [[ "${FORCE_ASSERTION}" != "true" ]]; then if [[ "$(prompt_yes_no \ "Machine assertion already exists. Regenerate it" \ "false")" == "true" ]]; then FORCE_ASSERTION="true" else printf 'Keeping existing machine assertion: %s\n' "${MACHINE_ASSERTION}" return fi fi [[ -f "${CLIENT_PRIVATE_KEY}" ]] || fail \ "Customer signing key was not found: ${CLIENT_PRIVATE_KEY}" [[ -f "${KEY_DIR}/assertion-decryption-public.pem" ]] || fail \ "Cloud assertion public key was not found: ${KEY_DIR}/assertion-decryption-public.pem" rm -f "${MACHINE_ASSERTION}" mvn -q -pl cygnus-cloud-client exec:java \ -Dexec.mainClass=com.cygnus.client.provisioning.MachineAssertionGenerator \ -Dexec.args="${CLIENT_ID} ${INSTALLATION_ID} ${TOKEN_URL} ${CLIENT_PRIVATE_KEY} ${KEY_DIR}/assertion-decryption-public.pem ${MACHINE_ASSERTION}" [[ -s "${MACHINE_ASSERTION}" ]] || fail \ "Machine assertion generation did not create: ${MACHINE_ASSERTION}" chmod 600 "${MACHINE_ASSERTION}" } main() { cd "${PROJECT_ROOT}" collect_client_inputs log "Checking JDK 21, Maven, and OpenSSL" require_command awk require_command openssl require_command mvn if command -v psql >/dev/null 2>&1; then PSQL_BIN="$(command -v psql)" elif [[ -x /Library/PostgreSQL/17/bin/psql ]]; then PSQL_BIN="/Library/PostgreSQL/17/bin/psql" else fail "PostgreSQL psql was not found in PATH or /Library/PostgreSQL/17/bin." fi configure_java_21 java -version mvn -version openssl version if [[ "${SKIP_BUILD}" != "true" ]]; then log "Building and verifying all modules" mvn clean verify else log "Skipping Maven verification because CYGNUS_SETUP_SKIP_BUILD=true" mvn -q -pl cygnus-cloud-client -am compile fi log "Creating protected local configuration directories" mkdir -p "${KEY_DIR}" "${CLIENT_DIR}" chmod 700 "${CONFIG_DIR}" "${KEY_DIR}" "${CLIENT_ROOT}" "${CLIENT_DIR}" log "Generating cloud assertion-encryption keys" generate_private_key "${KEY_DIR}/assertion-decryption-private.pem" generate_public_key \ "${KEY_DIR}/assertion-decryption-private.pem" \ "${KEY_DIR}/assertion-decryption-public.pem" log "Generating cloud access-token signing keys" generate_private_key "${KEY_DIR}/access-token-private.pem" generate_public_key \ "${KEY_DIR}/access-token-private.pem" \ "${KEY_DIR}/access-token-public.pem" log "Generating cloud login-payload encryption keys" generate_private_key "${KEY_DIR}/login-private.pem" generate_public_key \ "${KEY_DIR}/login-private.pem" \ "${KEY_DIR}/login-public.pem" log "Generating on-premises installation signing keys" generate_private_key "${CLIENT_PRIVATE_KEY}" generate_public_key \ "${CLIENT_PRIVATE_KEY}" \ "${CLIENT_PUBLIC_KEY}" log "Registering tenant, installation, and license in the cloud database" register_client_in_database log "Generating encrypted machine assertion" generate_machine_assertion log "Local communication security setup completed" printf '%s\n' \ "Client ID: ${CLIENT_ID}" \ "Installation ID: ${INSTALLATION_ID}" \ "Token audience: ${TOKEN_URL}" \ "Registration: PostgreSQL identity schema" \ "Assertion file: ${MACHINE_ASSERTION}" \ "Login public key: ${KEY_DIR}/login-public.pem" \ "" \ "Next: configure the cloud and on-prem environment variables, then start" \ "the cloud service on ${CLOUD_BASE_URL} before starting the on-prem app." } main "$@"