diff --git a/contracts/ripple/backend/backend.yml.tftpl b/contracts/ripple/backend/backend.yml.tftpl index c4df6a7..c1b7fbe 100644 --- a/contracts/ripple/backend/backend.yml.tftpl +++ b/contracts/ripple/backend/backend.yml.tftpl @@ -1,3 +1,51 @@ +%{ for i, vault in tpl.vaults ~} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: vault-${i}-supervisord-config +data: + supervisord.conf: | + [supervisord] + nodaemon=true + logfile=/dev/null + logfile_maxbytes=0 + pidfile=/tmp/supervisord.pid + + [program:vault-core] + command=/opt/vault-core/bin/vault-core-omnibus-s390x-g++ -p ${vault.platform} -u 0.0.0.0:${vault.grpc_port} + directory=/opt/vault-core + autostart=true + autorestart=true + autorestart_delay=2 + startretries=5 + stdout_logfile=/dev/fd/1 + stdout_logfile_maxbytes=0 + stderr_logfile=/dev/fd/2 + stderr_logfile_maxbytes=0 + + [program:vault-bridge] + command=/opt/vault-bridge/bin/vault-bridge + directory=/opt/vault-bridge + priority=3 + stdout_logfile=/dev/fd/1 + stdout_logfile_maxbytes=0 + stderr_logfile=/dev/fd/2 + stderr_logfile_maxbytes=0 + startsecs=0 + stopasgroup=true + + [program:healthcheck] + command=/opt/vault-bridge-health-check/bin/vault-bridge-health-check + directory=/opt/vault-bridge-health-check + priority=4 + stdout_logfile=/dev/fd/1 + stdout_logfile_maxbytes=0 + stderr_logfile=/dev/fd/2 + stderr_logfile_maxbytes=0 + startsecs=0 + stopasgroup=true +%{ endfor ~} --- apiVersion: v1 kind: Pod @@ -11,12 +59,14 @@ spec: - name: cold-bridge image: ${tpl.cold_bridge_image} env: - # This must be same as VAULT_ID below - - name: Vault__Ids__0 - value: "${tpl.vault_id}" + # Dynamic vault IDs - supports 1 to N vaults +%{ for i, vault in tpl.vaults ~} + - name: Vault__Ids__${i} + value: "${vault.vault_id}" +%{ endfor ~} # Passphrase: "{{EMPTY}}" is needed so that the bridge data is not cyphered and can be managed by OSO - name: Passphrase - value: "${tpl.passphrase}" + value: "{{EMPTY}}" volumeMounts: - name: app-data mountPath: /App_Data @@ -26,15 +76,32 @@ spec: volumeMounts: - name: ibm-cfg mountPath: /opt/kms/cfg/ibm.cfg + - name: ibm-cfg + mountPath: /app/cfg/ibm.cfg - name: cert mountPath: /data/cert readOnly: true - - name: cold-vault + # KMS vault containers - one per vault + # supervisord.conf is mounted from a ConfigMap per vault with unique -u
+ # so all vault instances can coexist in the shared pod network namespace +%{ for i, vault in tpl.vaults ~} + - name: vault-${i} image: ${tpl.cold_vault_image} + command: + - /bin/bash + - -c + args: + - | + until (echo > /dev/tcp/localhost/10000) 2>/dev/null; do + sleep 2 + done + exec /opt/entrypoint.sh env: + - name: VAULT_CORE_ADDRESS + value: "0.0.0.0:${vault.grpc_port}" - name: PLATFORM - value: "kms" + value: "${vault.platform}" - name: VAULT_BRIDGE_LOGLEVEL value: "7" - name: VAULT_CORE_LOGLEVEL @@ -44,10 +111,24 @@ spec: - name: VAULT_TRUSTED_SIG value: "pem:${tpl.notary_messaging_public_key}" - name: VAULT_ID - value: "${tpl.vault_id}" - # This comes from the docker container that is spun up above. If it's normal vault, then it would be API end point. + value: "${vault.vault_id}" - name: HARMONIZE_CORE_ENDPOINT value: "http://localhost:8080/internal/v1" + - name: HMZ_FEATURE_OPTIONAL_MAXIMUM_FEE + value: "true" +%{ if vault.log_level != "" ~} + - name: HMZ_LOG_LEVEL + value: "${vault.log_level}" +%{ endif ~} +%{ if vault.vault_log_level != "" ~} + - name: VAULT_LOG_LEVEL + value: "${vault.vault_log_level}" +%{ endif ~} + volumeMounts: + - name: vault-${i}-supervisord-config + mountPath: /opt/supervisord/supervisord.conf + subPath: supervisord.conf +%{ endfor ~} - name: backend-plugin image: ${tpl.backend_plugin_image} @@ -58,16 +139,34 @@ spec: env: - name: COMPONENT value: "backend_plugin" - - name: SEED + - name: OSOENCRYPTIONPASS value: "${tpl.seed}" + - name: COLD_BRIDGE_ENDPOINT + value: "http://localhost:8080" - name: PORT value: "4000" - - name: COLD_BRIDGE_ENDPOINT - value: "${tpl.cold_bridge_endpoint}" + - name: DEBUG + value: "${tpl.debug}" +%{ if tpl.debug == "true" ~} + - name: SSH_PORT + value: "${tpl.ssh_port}" + - name: OSO_SSH_PUBKEY + value: "${tpl.ssh_pubkey}" + - name: OSO_SSH_PASSWORD + value: "${tpl.ssh_password}" +%{ endif ~} +%{ if tpl.debug == "true" ~} + volumeMounts: + - name: debug + mountPath: /debug +%{ endif ~} ports: - containerPort: 4000 hostPort: 4000 - +%{ if tpl.debug == "true" ~} + - containerPort: ${tpl.ssh_port} + hostPort: ${tpl.ssh_port} +%{ endif ~} %{ if tpl.enable_ep11server } - name: ep11server @@ -135,3 +234,13 @@ spec: type: CharDevice %{~ endif ~} +%{ for i, vault in tpl.vaults ~} + - name: vault-${i}-supervisord-config + configMap: + name: vault-${i}-supervisord-config +%{ endfor ~} + +%{ if tpl.debug == "true" ~} + - name: debug + emptyDir: {} +%{ endif ~} \ No newline at end of file diff --git a/contracts/ripple/backend/terraform.tfvars.template b/contracts/ripple/backend/terraform.tfvars.template index b99bd05..16dfbf4 100644 --- a/contracts/ripple/backend/terraform.tfvars.template +++ b/contracts/ripple/backend/terraform.tfvars.template @@ -9,6 +9,13 @@ COLD_BRIDGE_IMAGE="registry.control23.dap.local/metaco-ripple/vault-cold-bridge@ COLD_VAULT_IMAGE="registry.control23.dap.local/metaco-ripple/vault-releases@sha256:" KMSCONNECT_IMAGE="registry.control23.dap.local/metaco-ripple/kms-ibm@sha256:" +MOCK_VAULTS = [ + { + vault_id = "" + mock_phrase = "" + } +] + # Vault Configuration VAULT_ID="" NOTARY_MESSAGING_PUBLIC_KEY="" @@ -19,6 +26,13 @@ WORKLOAD_VOLUME_PREV_SEED="" # Default value points to the cold-bridge service running on localhost # COLD_BRIDGE_ENDPOINT="http://localhost:8080" +# Multi-Vault Configuration (use VAULTS instead of VAULT_ID for multiple vaults) +# Each vault container will be assigned KMS_URL="0.0.0.0:10001", "0.0.0.0:10002", etc. +# VAULTS = [ +# { vault_id = "", log_level = "", vault_log_level = "" }, +# { vault_id = "", log_level = "", vault_log_level = "" }, +# ] + # Update volume name if it is different from default value vault_vol # VOLUME_NAME = "" diff --git a/contracts/ripple/backend/user_data_backend.tf b/contracts/ripple/backend/user_data_backend.tf index 8b7d1c3..1bcf0b6 100644 --- a/contracts/ripple/backend/user_data_backend.tf +++ b/contracts/ripple/backend/user_data_backend.tf @@ -38,6 +38,30 @@ resource "local_file" "grep_client_cert" { } +# Local variable to handle both single vault (VAULT_ID) and multi-vault (VAULTS) configurations +# Normalizes all vault configurations to KMS-only platform with required runtime settings +locals { + # If VAULT_ID is provided, create a single-vault list with default KMS values + # Otherwise, use the VAULTS list (which is already KMS-only) + resolved_vaults_raw = var.VAULT_ID != "" ? [ + { + vault_id = var.VAULT_ID + log_level = "" + vault_log_level = "" + } + ] : var.VAULTS + + # Inject grpc_port per vault (10001, 10002, 10003, ...) so the template + # can reference vault.grpc_port without inline arithmetic. + # KMS_URL is set to "0.0.0.0:" on each vault container so that + # each vault instance binds its gRPC listener on a unique port within the + # shared pod network namespace. cold-bridge is told the matching endpoint + # via Vault__GrpcEndpoints__N. See: DAPCS-1965. + resolved_vaults = [ + for i, v in local.resolved_vaults_raw : merge(v, { grpc_port = 10001 + i, platform = "kms" }) + ] +} + resource "local_file" "podman-play" { content = templatefile( "${path.module}/backend.yml.tftpl", @@ -46,14 +70,18 @@ resource "local_file" "podman-play" { cold_bridge_image = var.COLD_BRIDGE_IMAGE, cold_vault_image = var.COLD_VAULT_IMAGE, kmsconnect_image = var.KMSCONNECT_IMAGE, - vault_id = var.VAULT_ID, + vaults = local.resolved_vaults, passphrase = var.PASSPHRASE, notary_messaging_public_key = var.NOTARY_MESSAGING_PUBLIC_KEY, - seed = var.SEED, cold_bridge_endpoint = var.COLD_BRIDGE_ENDPOINT, + seed = var.OSOENCRYPTIONPASS, enable_ep11server = var.INTERNAL_GREP11, crypto_pass_enable = var.CRYPTO_PASSTHROUGH_ENABLEMENT, grep11_image = var.GREP11_IMAGE, + debug = var.DEBUG ? "true" : "false", + ssh_pubkey = var.SSH_PUBKEY, + ssh_port = var.SSH_PORT, + ssh_password = var.SSH_PASSWORD, } }, ) filename = "podman-play/play.yml" @@ -81,8 +109,8 @@ resource "null_resource" "crypto_deps" { ] } -# archive of the folder containing docker-compose file. This folder could create additional resources such as files -# to be mounted into containers, environment files etc. This is why all of these files get bundled in a tgz file (base64 encoded) +# archive of the folder containing the podman-play pod YAML and supporting files (ibm.cfg, certs, etc.) +# All of these files get bundled into a tgz (base64 encoded) for the HPCR workload contract. resource "hpcr_tgz" "workload" { depends_on = [local_file.podman-play] folder = "podman-play" diff --git a/contracts/ripple/backend/variables.tf b/contracts/ripple/backend/variables.tf index 74f42d2..af93171 100644 --- a/contracts/ripple/backend/variables.tf +++ b/contracts/ripple/backend/variables.tf @@ -20,10 +20,10 @@ variable "PREFIX" { variable "DEBUG" { type = bool description = "Create debug contracts, plaintext" - default = false + default = true } -variable "SEED" { +variable "OSOENCRYPTIONPASS" { type = string description = "Encrypt data through the iteration pipeline (should be the same value as frontend plugin)" default = "" @@ -34,6 +34,12 @@ variable "BACKEND_PLUGIN_IMAGE" { description = "Backend plugin image containing registry" } +variable "BACKEND_ENDPOINT" { + type = string + description = "Backend plugin endpoint URL (required by backend_plugin_manager.py)" + default = "http://localhost:4000" +} + variable "COLD_BRIDGE_ENDPOINT" { type = string description = "Cold bridge endpoint URL for the cold bridge service" @@ -56,16 +62,28 @@ variable "KMSCONNECT_IMAGE" { } variable "VAULT_ID" { - type = string - description = "Vault ID" + type = string + description = "Vault ID (single vault, use VAULTS for multi-vault)" + default = "" +} + +variable "VAULTS" { + type = list(object({ + vault_id = string + log_level = optional(string, "") + vault_log_level = optional(string, "") + })) + description = "List of KMS vault configurations (supports 1 to N vaults). Use instead of VAULT_ID for multi-vault setups." + default = [] } variable "PASSPHRASE" { - type = string - default = "{{EMPTY}}" - description = "Required to enable plugin to view content within a JSON format" + type = string + description = "Passphrase for cold-bridge. Use '{{EMPTY}}' so that the bridge data is not cyphered and can be managed by OSO" + default = "{{EMPTY}}" } + variable "NOTARY_MESSAGING_PUBLIC_KEY" { type = string description = "Notary messaging public key after performing genesis" @@ -199,3 +217,22 @@ variable "CRYPTO_PASSTHROUGH_ENABLEMENT" { default = true description = "Crypto passthrough enablement configuration" } + +variable "SSH_PUBKEY" { + type = string + description = "SSH public key for debug access" + default = "" +} + +variable "SSH_PORT" { + type = string + description = "SSH port for debug access" + default = "5000" +} + +variable "SSH_PASSWORD" { + type = string + description = "SSH password for debug access (fallback when publickey auth fails). Only active when DEBUG=true." + default = "" + sensitive = true +} diff --git a/contracts/ripple/frontend_plugin/frontend_plugin.yml.tftpl b/contracts/ripple/frontend_plugin/frontend_plugin.yml.tftpl index 33477e2..c61d983 100644 --- a/contracts/ripple/frontend_plugin/frontend_plugin.yml.tftpl +++ b/contracts/ripple/frontend_plugin/frontend_plugin.yml.tftpl @@ -1,10 +1,10 @@ apiVersion: v1 kind: Pod metadata: - name: frontend_plugin + name: frontend-plugin-pod spec: containers: - - name: frontend_plugin + - name: frontend-plugin image: ${tpl.image} envFrom: - configMapRef: @@ -25,10 +25,12 @@ spec: value: "${tpl.HMZ_API_HOSTNAME}" - name: ROOTCERT value: "${tpl.ROOTCERT}" - - name: SEED + - name: SEED value: "${tpl.SEED}" - name: TOKEN_EXP value: "${tpl.TOKEN_EXP}" ports: - - containerPort: 4000 + - containerPort: 4000 hostPort: 4000 + + diff --git a/contracts/ripple/frontend_plugin/terraform.tfvars.template b/contracts/ripple/frontend_plugin/terraform.tfvars.template index 49984d2..41b02d6 100644 --- a/contracts/ripple/frontend_plugin/terraform.tfvars.template +++ b/contracts/ripple/frontend_plugin/terraform.tfvars.template @@ -6,3 +6,4 @@ HMZ_AUTH_HOSTNAME="" HMZ_API_HOSTNAME="" VAULT_ID="" SK="" +HMZ_USER_SK="" diff --git a/contracts/ripple/frontend_plugin/user_data_frontend_plugin.tf b/contracts/ripple/frontend_plugin/user_data_frontend_plugin.tf index 84a1039..3d2b906 100644 --- a/contracts/ripple/frontend_plugin/user_data_frontend_plugin.tf +++ b/contracts/ripple/frontend_plugin/user_data_frontend_plugin.tf @@ -19,9 +19,11 @@ resource "local_file" "frontend_plugin_podman_play" { { tpl = { image = var.FRONTEND_PLUGIN_IMAGE, SK = var.SK, - VAULTID = var.VAULT_ID, + VAULTID = join(" ", var.VAULT_IDS), HMZ_AUTH_HOSTNAME = var.HMZ_AUTH_HOSTNAME, HMZ_API_HOSTNAME = var.HMZ_API_HOSTNAME, + //HMZ_AUTH_PATH = var.HMZ_AUTH_PATH, + //HMZ_AUTH_CUSTOMERID = var.HMZ_AUTH_CUSTOMERID, ROOTCERT = var.ROOTCERT, SEED = var.SEED, TOKEN_EXP = var.TOKEN_EXP diff --git a/contracts/ripple/frontend_plugin/variables.tf b/contracts/ripple/frontend_plugin/variables.tf index 1ec565e..70979e9 100644 --- a/contracts/ripple/frontend_plugin/variables.tf +++ b/contracts/ripple/frontend_plugin/variables.tf @@ -31,21 +31,9 @@ variable "FRONTEND_PLUGIN_IMAGE" { description = "Frontend plugin image name" } -variable "SEED" { - type = string - description = "Encrypt data through the iteration pipeline (should be same value as backend plugin)" - default = "" -} - -# Ripple -variable "SK" { - type = string - description = "Private (secret) key of a registered user used to login to Ripple" -} - -variable "VAULT_ID" { - type = string - description = "Ripple vault id" +variable "VAULT_IDS" { + type = list(string) + description = "List of Ripple vault IDs (supports 1 to N vaults)" } variable "HMZ_AUTH_HOSTNAME" { @@ -69,3 +57,26 @@ variable "TOKEN_EXP" { description = "Ripple configured bearer token expiration (#h#m#s format)" default = "4h0m0s" } + +variable "SEED" { + type = string + description = "Encrypt data through the iteration pipeline (should be same value as backend plugin)" + default = "" + sensitive = true +} + +variable "SK" { + type = string + description = "Private (secret) key of a registered user used to login to Ripple" +} + +#variable "HMZ_AUTH_PATH" { +# type = string +# description = "Harmonize path to get auth token" +#} + +#variable "HMZ_AUTH_CUSTOMERID" { +# type = string +# description = "Harmonize customer id used to authenticate" +# default = "customer_api" +#} diff --git a/contracts/ripple/get_workloads.sh b/contracts/ripple/get_workloads.sh index 588ad49..6ba0c54 100755 --- a/contracts/ripple/get_workloads.sh +++ b/contracts/ripple/get_workloads.sh @@ -44,10 +44,10 @@ BACKEND_WORKLOADS=[ hipersocket34: false, workload: "$BACKEND", persistent_vol: { - volume_name = "vault_vol", - env_seed = "vaultseed2", + volume_name = "vault_vol_lpar3", + env_seed = "vaultseed1I23456780", prev_seed = "", - volume_path = "/var/lib/libvirt/images/oso/vault-data.qcow2" + volume_path = "/var/lib/libvirt/images/oso/vault_vol_lpar3.qcow2" } } ] diff --git a/ripple-plugin/Dockerfile b/ripple-plugin/Dockerfile index ac3512c..2562643 100644 --- a/ripple-plugin/Dockerfile +++ b/ripple-plugin/Dockerfile @@ -17,6 +17,7 @@ FROM registry.access.redhat.com/ubi9/ubi-minimal:latest AS live ENV HOME=/app-root + RUN microdnf --assumeyes module enable nginx:1.24 \ && microdnf --assumeyes \ --setopt=install_weak_deps=0 \ @@ -25,12 +26,27 @@ RUN microdnf --assumeyes module enable nginx:1.24 \ --enablerepo=ubi-9-appstream-rpms \ install \ python3.12 \ + openssh-server \ + shadow-utils \ gettext nginx findutils \ - && microdnf clean all + procps-ng \ + && microdnf clean all \ + && useradd --uid 1001 --gid 0 --home-dir ${HOME} --no-create-home appuser + +# SSH_PASSWORD is injected via --secret at build time (debug builds only). +# Using a secret avoids baking the password into image layers or docker history. +RUN --mount=type=secret,id=ssh_password,required=false \ + if [ -f /run/secrets/ssh_password ]; then \ + echo "appuser:$(cat /run/secrets/ssh_password)" | chpasswd ; \ + fi RUN install --directory --mode 0700 --owner 1001 --group 0 \ "${HOME}" \ "${HOME}/.ssh" \ + && ssh-keygen -A \ + && chown -R 1001:0 /etc/ssh \ + && chmod 0755 /etc/ssh/sshd_config \ + && install --mode 0644 --owner 1001 --group 0 $(mktemp) ${HOME}/.ssh/authorized_keys \ && chown -R 1001:0 /var/run \ && chmod -R ug+rwX /var/run \ && chown -R 1001:0 /var/lib/nginx \ @@ -51,6 +67,7 @@ RUN microdnf --assumeyes \ --enablerepo=ubi-9-baseos-rpms \ --enablerepo=ubi-9-appstream-rpms \ install \ + openssh-server \ openssl-devel \ gcc cargo rustc \ python3.12-pip python3.12-devel \ @@ -94,8 +111,10 @@ RUN microdnf --assumeyes \ make \ python3.12-devel \ openssl-devel \ + openssh-server \ && microdnf clean all COPY --from=compile /opt/venv /opt/venv COPY --chown=1001:0 /unit-tests /pyproject.toml /tests/ RUN pip3.12 --python /opt/venv install --requirement /tmp/requirements.constraints.txt -CMD ["pytest", "-svvv"] \ No newline at end of file +CMD ["pytest", "-svvv"] + diff --git a/ripple-plugin/Makefile b/ripple-plugin/Makefile index 0d891ba..8924220 100644 --- a/ripple-plugin/Makefile +++ b/ripple-plugin/Makefile @@ -16,6 +16,8 @@ .PHONY : build debug test +SSH_PASSWORD ?= + REGISTRY ?= us.icr.io NAMESPACE ?= dap-osc-dev TAG ?= latest @@ -60,6 +62,18 @@ build : docker build \ . --target release -t oso-ripple-plugins:latest -t $(REGISTRY)/$(NAMESPACE)/oso-ripple-plugins:$(TAG) -f Dockerfile --platform linux/s390x --provenance=false +debug : + @if [ -z "$(SSH_PASSWORD)" ]; then \ + echo "ERROR: SSH_PASSWORD is required for debug build. Use: make debug SSH_PASSWORD="; \ + exit 1; \ + fi + @printf '%s' "$(SSH_PASSWORD)" > /tmp/.ssh_password_secret + docker build \ + --secret id=ssh_password,src=/tmp/.ssh_password_secret \ + --label debug=true \ + . --target release -t oso-ripple-plugins:latest -t $(REGISTRY)/$(NAMESPACE)/oso-ripple-plugins:$(TAG) -f Dockerfile --platform linux/s390x --provenance=false + @rm -f /tmp/.ssh_password_secret + ifdef RIPPLE_PLUGINS_TEST_RESULTS VOL_OPTS ::= -v $(RIPPLE_PLUGINS_TEST_RESULTS):/tests/results:rw,z endif @@ -76,3 +90,4 @@ test: test-build --platform linux/s390x \ --entrypoint pytest \ $(REGISTRY)/$(NAMESPACE)/oso-ripple-plugins-test:$(TAG) + diff --git a/ripple-plugin/src/app-root/backend_plugin/entrypoints/supervisord-backend_plugin.conf b/ripple-plugin/src/app-root/backend_plugin/entrypoints/supervisord-backend_plugin.conf index de18949..c3c342e 100644 --- a/ripple-plugin/src/app-root/backend_plugin/entrypoints/supervisord-backend_plugin.conf +++ b/ripple-plugin/src/app-root/backend_plugin/entrypoints/supervisord-backend_plugin.conf @@ -37,7 +37,7 @@ redirect_stderr=true stopsignal=QUIT [program:sshd] -command=sh -c '/usr/sbin/sshd -D' +command=sh -c '/usr/sbin/sshd -D -f %(ENV_SSHD_CONFIG)s -E %(ENV_SSHD_LOG)s' stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 redirect_stderr=true diff --git a/ripple-plugin/src/app-root/common/entrypoints/entrypoint.sh b/ripple-plugin/src/app-root/common/entrypoints/entrypoint.sh index f7fb706..e85f077 100755 --- a/ripple-plugin/src/app-root/common/entrypoints/entrypoint.sh +++ b/ripple-plugin/src/app-root/common/entrypoints/entrypoint.sh @@ -18,11 +18,88 @@ cp -r /oso-root/"${COMPONENT}"/* /app-root if [ "${DEBUG}" == "true" ]; then - echo "${SSH_PUBKEY}" >"${HOME}/.ssh/authorized_keys" - sed -ie 's/#Port 22/Port '"$SSH_PORT"'/g' /etc/ssh/sshd_config + if [ -z "${SSH_PORT}" ]; then + echo "ERROR: DEBUG=true but SSH_PORT is not set" >&2 + exit 1 + fi + # Support both OSO_SSH_PUBKEY (new) and SSH_PUBKEY (legacy) + _SSH_PUBKEY="${OSO_SSH_PUBKEY:-${SSH_PUBKEY}}" + # Support both OSO_SSH_PASSWORD (new) and SSH_PASSWORD (legacy) + _SSH_PASSWORD="${OSO_SSH_PASSWORD:-${SSH_PASSWORD}}" + + if [ -z "${_SSH_PUBKEY}" ] && [ -z "${_SSH_PASSWORD}" ]; then + echo "ERROR: DEBUG=true but neither OSO_SSH_PUBKEY nor OSO_SSH_PASSWORD is set" >&2 + exit 1 + fi + + # ── authorized_keys ──────────────────────────────────────────────────────── + if [ -n "${_SSH_PUBKEY}" ]; then + echo "[DEBUG] Writing OSO_SSH_PUBKEY to ${HOME}/.ssh/authorized_keys" + echo "${_SSH_PUBKEY}" > "${HOME}/.ssh/authorized_keys" + chmod 0600 "${HOME}/.ssh/authorized_keys" + else + echo "[DEBUG] OSO_SSH_PUBKEY not set — publickey auth will not work" + > "${HOME}/.ssh/authorized_keys" + fi + + # ── optional password auth ────────────────────────────────────────────────── + # Password is set at image build time (Dockerfile ARG SSH_PASSWORD) since + # the container runs as UID 1001 which cannot write /etc/passwd at runtime. + if [ -n "${_SSH_PASSWORD}" ]; then + PASSWORD_AUTH="yes" + else + PASSWORD_AUTH="no" + fi + + # ── container-local sshd config — never touch host /etc/ssh/sshd_config ─── + # Write a minimal self-contained config from scratch. + # Avoids inheriting the base config's "Include" drop-ins or conflicting + # directives (e.g. PasswordAuthentication no from sshd_config.d/). + SSHD_CONFIG="${HOME}/.ssh/sshd_config" + SSHD_LOG="${HOME}/.ssh/sshd.log" + cat > "${SSHD_CONFIG}" <<-EOF + Port ${SSH_PORT} + ListenAddress 0.0.0.0 + HostKey /etc/ssh/ssh_host_rsa_key + HostKey /etc/ssh/ssh_host_ecdsa_key + HostKey /etc/ssh/ssh_host_ed25519_key + AuthorizedKeysFile ${HOME}/.ssh/authorized_keys + PasswordAuthentication ${PASSWORD_AUTH} + PubkeyAuthentication yes + StrictModes no + ChallengeResponseAuthentication no + KbdInteractiveAuthentication no + UsePAM yes + LogLevel DEBUG3 + SyslogFacility AUTH + Subsystem sftp /usr/libexec/openssh/sftp-server + EOF + chmod 0644 "${SSHD_CONFIG}" + touch "${SSHD_LOG}" + chmod 0644 "${SSHD_LOG}" + export SSHD_CONFIG + export SSHD_LOG + + # ── diagnostics printed at container startup ──────────────────────────────── + echo "[DEBUG] ======== SSH debug info ========" + echo "[DEBUG] SSH_PORT = ${SSH_PORT}" + echo "[DEBUG] SSHD_CONFIG = ${SSHD_CONFIG}" + echo "[DEBUG] SSHD_LOG = ${SSHD_LOG}" + echo "[DEBUG] authorized_keys = $(cat "${HOME}/.ssh/authorized_keys")" + echo "[DEBUG] authorized_keys perms = $(stat -c '%a %U:%G' "${HOME}/.ssh/authorized_keys")" + echo "[DEBUG] .ssh dir perms = $(stat -c '%a %U:%G' "${HOME}/.ssh")" + echo "[DEBUG] HOME = ${HOME}" + echo "[DEBUG] whoami = $(whoami)" + echo "[DEBUG] id = $(id)" + echo "[DEBUG] sshd_config contents:" + cat "${SSHD_CONFIG}" + echo "[DEBUG] Validating sshd config:" + /usr/sbin/sshd -t -f "${SSHD_CONFIG}" && echo "[DEBUG] sshd config OK" || echo "[DEBUG] sshd config INVALID" + echo "[DEBUG] ================================" else export DEBUG="false" fi umask 0007 /app-root/entrypoints/entrypoint.sh + diff --git a/ripple-plugin/src/app-root/frontend_plugin/entrypoints/supervisord-frontend_plugin.conf b/ripple-plugin/src/app-root/frontend_plugin/entrypoints/supervisord-frontend_plugin.conf index 0f401fb..962a2a4 100644 --- a/ripple-plugin/src/app-root/frontend_plugin/entrypoints/supervisord-frontend_plugin.conf +++ b/ripple-plugin/src/app-root/frontend_plugin/entrypoints/supervisord-frontend_plugin.conf @@ -37,7 +37,7 @@ redirect_stderr=true stopsignal=QUIT [program:sshd] -command=sh -c '/usr/sbin/sshd -D' +command=sh -c '/usr/sbin/sshd -D -f %(ENV_SSHD_CONFIG)s' stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 redirect_stderr=true diff --git a/ripple-plugin/src/oso_ripple_plugins/backend_plugin/api/v1alpha1.py b/ripple-plugin/src/oso_ripple_plugins/backend_plugin/api/v1alpha1.py index 834f59f..7065ce9 100644 --- a/ripple-plugin/src/oso_ripple_plugins/backend_plugin/api/v1alpha1.py +++ b/ripple-plugin/src/oso_ripple_plugins/backend_plugin/api/v1alpha1.py @@ -110,17 +110,42 @@ def get(self): @api.route("/status", methods=["GET"]) class Status(Resource): + # Define the Error model + error_model = api.model( + "Error", + { + "code": fields.String(description="Error code"), + "message": fields.String(description="Error message") + } + ) + component_status_model = api.model( - "ComponentStatus", {"status": fields.String(), "error": fields.String()} + "ComponentStatus", + { + "status_code": fields.Integer(description="HTTP status code"), + "status": fields.String(description="Human readable message"), + "errors": fields.List(fields.Nested(error_model), default=[], description="List of errors") + } ) @api.response(code=200, description="Success", model=component_status_model) @api.response(code=503, description="Unavailable", model=component_status_model) def get(self): + """Return the BPM component status""" try: - current_app.bpm.backend_status() + # Capture backend status if needed + backend_result = current_app.bpm.backend_status() except Exception as e: - logger.exception(e) - abort(503) - - return {"status": "OK"}, 200 + logger.exception("BPM backend status check failed") + return { + "status_code": 503, + "status": "Unavailable", + "errors": [{"code": "BACKEND_ERROR", "message": str(e)}] + }, 503 + + # Return a successful status + return { + "status_code": 200, + "status": "OK", + "errors": [] + }, 200 diff --git a/ripple-plugin/src/oso_ripple_plugins/backend_plugin/backend_plugin_manager.py b/ripple-plugin/src/oso_ripple_plugins/backend_plugin/backend_plugin_manager.py index 3b2eea2..efc70c8 100644 --- a/ripple-plugin/src/oso_ripple_plugins/backend_plugin/backend_plugin_manager.py +++ b/ripple-plugin/src/oso_ripple_plugins/backend_plugin/backend_plugin_manager.py @@ -14,7 +14,6 @@ # limitations under the License. -import copy import json import logging import os @@ -35,7 +34,7 @@ class BackendPluginManager: def __init__(self): self.cold_bridge_endpoint = os.environ.get("COLD_BRIDGE_ENDPOINT", "http://localhost:8080") - self.seed = os.environ.get("SEED", "") + self.seed = os.environ.get("OSOENCRYPTIONPASS", "") logging.basicConfig(stream=sys.stdout, level=logging.INFO) self.logger = logging.getLogger(__name__) @@ -55,110 +54,99 @@ def bulk_download(self) -> List[Dict]: self.logger.info("Bulk download finished successfully") - empty_content = { - "accounts": [], - "transactions": [], - "manifests": [], - "vaults": [], - } - - def write_document_set(documents, content_key: str, id_key: str): - for item in response_json.get(content_key, []): - self.logger.info( - f"Saving document from {content_key} for bulk download" - ) - - try: - document_id = item.get(id_key) - self.logger.info(f"Saving document {document_id} for bulk download") - - content = copy.deepcopy(empty_content) - content.setdefault(content_key, []).append(item) - - # Encrypt content - if len(self.seed) > 0: - data = crypt.encrypt(json.dumps(content), self.seed) - else: - data = json.dumps(content) - - documents.append( - {"id": item.get(id_key), "content": data, "metadata": ""} - ) - - self.logger.info( - f"Successfully saved document {document_id} for bulk download" - ) - except Exception as err: - self.logger.exception(err) - continue - documents = [] - for content_key, id_key in [ - ("transactions", "transactionId"), - ("accounts", "accountId"), - ("manifests", "manifestId"), - ]: - write_document_set(documents, content_key, id_key) + + sections = [ + ("transactions", "transactionId", "transaction"), + ("accounts", "accountId", "account"), + ("manifests", "manifestId", "manifest"), + ] + for section, id_key, type_name in sections: + for item in response_json.get(section, []): + # Encrypt if seed is set + if self.seed and "signedPayload" in item: + item["signedPayloadCiphered"] = crypt.encrypt(item["signedPayload"], self.seed) + del item["signedPayload"] + + # Build content and metadata + content = { + "accounts": [item] if section == "accounts" else [], + "transactions": [item] if section == "transactions" else [], + "manifests": [item] if section == "manifests" else [], + "vaults": [], + } + meta = {"source": item["vaultId"], "type": type_name} + + documents.append({ + "id": item[id_key], + "content": json.dumps(content), + "metadata": json.dumps(meta) + }) return documents def bulk_upload(self, documents): - vault_id = None - transactions = [] - accounts = [] - manifests = [] + v_tx= {} + v_ac= {} + v_ma= {} self.logger.info("Saving documents for bulk upload") for document in documents: try: - document_id = document["id"] - self.logger.info(f"Saving document {document_id} for bulk upload") - - # Decrypt content - if len(self.seed) > 0: - contents = json.loads(crypt.decrypt(document["content"], self.seed)) - else: - contents = json.loads(document["content"]) + contents = json.loads(document["content"]) + vaultid= contents.get("vaultId") + + if vaultid not in v_tx: + v_tx[vaultid]=[] + v_ac[vaultid]=[] + v_ma[vaultid]=[] + # Map sections to their storage dict + section_map = { + "transactions": v_tx[vaultid], + "accounts": v_ac[vaultid], + "manifests": v_ma[vaultid], + } + + for section, storage in section_map.items(): + for item in contents.get(section, []): + if self.seed and "signedPayloadCiphered" in item: + item["signedPayload"] = crypt.decrypt(item["signedPayloadCiphered"], self.seed) + del item["signedPayloadCiphered"] + storage.append(item) + + self.logger.info(f"Saving document {document['id']} for bulk upload") - transactions.extend(contents.get("transactions", [])) - accounts.extend(contents.get("accounts", [])) - manifests.extend(contents.get("manifests", [])) - - if vault_id is None: - vault_id = contents.get("vaultId") - - self.logger.info( - f"Successfully saved document {document_id} for bulk upload" - ) except Exception as e: self.logger.exception(e) continue - if not vault_id: - return Exception("Could not get vault id") - - content = { - "vaultId": vault_id, - "accounts": accounts, - "transactions": transactions, - "manifests": manifests, - } - self.logger.info("Performing bulk upload to backend") - - try: - with tempfile.NamedTemporaryFile(mode="w", delete=False) as vault_file: - json.dump(content, vault_file) - - files = {"files": (vault_id, open(vault_file.name, "rb"))} - response = requests.post( - url=f"{self.cold_bridge_endpoint}/v1/feed/upload", - files=files, - ) - response.raise_for_status() - except Exception as e: - raise e - finally: - os.remove(vault_file.name) + for vaultid in v_tx.keys(): + content = { + "vaultId": vaultid, + "accounts": v_ac[vaultid], + "transactions": v_tx[vaultid], + "manifests": v_ma[vaultid], + } + vault_file_name = None + try: + with tempfile.NamedTemporaryFile(mode="w", delete=False) as vault_file: + vault_file_name = vault_file.name + json.dump(content, vault_file) + + files = {"files": (vaultid, open(vault_file.name, "rb"))} + response = requests.post( + url=f"{self.cold_bridge_endpoint}/v1/feed/upload", + files=files, + ) + response.raise_for_status() + self.logger.info(f"Successfully uploaded vault {vaultid}") + except requests.HTTPError as http_err: + self.logger.error(f"HTTP error uploading vault {vaultid}: {http_err}") + except Exception as err: + self.logger.error(f"Unexpected error uploading vault {vaultid}: {err}") + finally: + if vault_file_name: + os.remove(vault_file_name) self.logger.info("Bulk upload finished successfully") diff --git a/ripple-plugin/src/oso_ripple_plugins/common/pre_request.py b/ripple-plugin/src/oso_ripple_plugins/common/pre_request.py index 9e95b6e..807f78e 100644 --- a/ripple-plugin/src/oso_ripple_plugins/common/pre_request.py +++ b/ripple-plugin/src/oso_ripple_plugins/common/pre_request.py @@ -72,7 +72,7 @@ def set_cert(): def bind_flask_before_request(sender: Flask, **extras) -> None: logger.info(f"HTTP Method: {request.method} URL Path: {request.path}") - + client_verify = request.headers.get("X-SSL-CLIENT-VERIFY", "FAILED") if client_verify != "SUCCESS": logger.info(f"Could not verify certificate, client verify: {client_verify}") diff --git a/ripple-plugin/src/oso_ripple_plugins/frontend_plugin/api/v1alpha1.py b/ripple-plugin/src/oso_ripple_plugins/frontend_plugin/api/v1alpha1.py index b692d51..f2026ef 100644 --- a/ripple-plugin/src/oso_ripple_plugins/frontend_plugin/api/v1alpha1.py +++ b/ripple-plugin/src/oso_ripple_plugins/frontend_plugin/api/v1alpha1.py @@ -1,17 +1,12 @@ -# Copyright (c) 2025 IBM Corp. -# All rights reserved. # -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at +# Licensed Materials - Property of IBM # -# http://www.apache.org/licenses/LICENSE-2.0 +# (c) Copyright IBM Corp. 2024 +# +# The source code for this program is not published or otherwise +# divested of its trade secrets, irrespective of what has been +# deposited with the U.S. Copyright Office # -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. import logging import sys @@ -93,17 +88,43 @@ def post(self): @api.route("/status", methods=["GET"]) class Status(Resource): + # Define the error model + error_model = api.model( + "Error", + { + "code": fields.String(description="Error code"), + "message": fields.String(description="Error message") + } + ) + + # Define the component status model component_status_model = api.model( - "ComponentStatus", {"status": fields.String(), "error": fields.String()} + "ComponentStatus", + { + "status_code": fields.Integer(description="HTTP status code"), + "status": fields.String(description="Human readable message"), + "errors": fields.List(fields.Nested(error_model), default=[], description="List of errors") + } ) @api.response(code=200, description="Success", model=component_status_model) @api.response(code=503, description="Unavailable", model=component_status_model) def get(self): + """Return the component status""" try: - current_app.fpm.backend_status() + # Capture backend status if needed + backend_result = current_app.fpm.backend_status() except Exception as e: - logger.exception(e) - abort(503) - - return {"status": "OK"}, 200 + logger.exception("Backend status check failed") + return { + "status_code": 503, + "status": "Unavailable", + "errors": [{"code": "BACKEND_ERROR", "message": str(e)}] + }, 503 + + # Return a successful status + return { + "status_code": 200, + "status": "OK", + "errors": [] + }, 200 \ No newline at end of file diff --git a/ripple-plugin/src/oso_ripple_plugins/frontend_plugin/frontend_plugin_manager.py b/ripple-plugin/src/oso_ripple_plugins/frontend_plugin/frontend_plugin_manager.py index 5bff2bf..d7a49f5 100644 --- a/ripple-plugin/src/oso_ripple_plugins/frontend_plugin/frontend_plugin_manager.py +++ b/ripple-plugin/src/oso_ripple_plugins/frontend_plugin/frontend_plugin_manager.py @@ -37,7 +37,7 @@ class FrontendPluginManager: def __init__(self): if "SK" not in os.environ: - raise errors.ConfigError("SK not found") + raise errors.ConfigError("Harmonize OSO user server key not found") private_key_b64 = os.environ["SK"] private_key_decoded = base64.b64decode(private_key_b64) self.private_key = load_pem_private_key(private_key_decoded, password=None) @@ -52,13 +52,21 @@ def __init__(self): raise errors.ConfigError("HMZ_AUTH_HOSTNAME not found") self.hmz_auth_hostname = os.environ["HMZ_AUTH_HOSTNAME"] + if "HMZ_AUTH_PATH" not in os.environ: + raise errors.ConfigError("HMZ_AUTH_PATH not found") + self.hmz_auth_path = os.environ["HMZ_AUTH_PATH"] + + if "HMZ_AUTH_CUSTOMERID" not in os.environ: + raise errors.ConfigError("HMZ_AUTH_CUSTOMERID not found") + self.hmz_auth_customerid = os.environ["HMZ_AUTH_CUSTOMERID"] + if "HMZ_API_HOSTNAME" not in os.environ: raise errors.ConfigError("HMZ_API_HOSTNAME not found") self.hmz_api_hostname = os.environ["HMZ_API_HOSTNAME"] if "VAULTID" not in os.environ: raise errors.ConfigError("VAULTID not found") - self.vault_id = os.environ["VAULTID"] + self.vaultids = os.environ["VAULTID"].split() self.seed = os.environ.get("SEED", "") @@ -127,7 +135,7 @@ def _get_token(self) -> Tuple[str, float]: challenge = str(uuid.uuid4()) signature = self._sign(challenge) data = { - "client_id": "customer_api", + "client_id": self.hmz_auth_customerid, "grant_type": "password", "challenge": challenge, "public_key": self.public_key, @@ -135,7 +143,7 @@ def _get_token(self) -> Tuple[str, float]: } response = requests.post( - f"https://{self.hmz_auth_hostname}/token", + f"https://{self.hmz_auth_hostname}{self.hmz_auth_path}", data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}, verify=self.verify, @@ -173,119 +181,141 @@ def get_token(self) -> str: def bulk_download(self) -> list: self.logger.info("Performing bulk download from frontend") token = self.get_token() - url = f"https://{self.hmz_api_hostname}/v1/vaults/{self.vault_id}/operations/prepared" - response = requests.get( - url=url, - headers={"Authorization": "Bearer " + token}, - stream=True, - verify=self.verify, - ) - response.raise_for_status() - vault_json = response.json() - self.logger.info("Bulk download finished successfully") - - empty_content = { - "vaultId": "", - "accounts": [], - "transactions": [], - "manifests": [], - } + documents = [] + for vaultid in self.vaultids: - def write_document_set(documents, content_key: str, id_key: str): - for item in vault_json.get(content_key, []): - self.logger.info( - f"Saving document from {content_key} for bulk download" - ) + url = f"https://{self.hmz_api_hostname}/v1/vaults/{vaultid}/operations/prepared" + response = requests.get( + url=url, + headers={"Authorization": "Bearer " + token}, + stream=True, + verify=self.verify, + ) + response.raise_for_status() + vault_json = response.json() + self.logger.info(f"Bulk download finished successfully for vault {vaultid}") + empty_content = { + "vaultId": vaultid, + "accounts": [], + "transactions": [], + "manifests": [], + } + + def write_document_set(documents, content_key: str, id_key: str): + for item in vault_json.get(content_key, []): + self.logger.info( + f"Saving document from {content_key} for bulk download" + ) + try: + document_id = item.get(id_key) + self.logger.info(f"Saving document {document_id} for bulk download") - try: - document_id = item.get(id_key) - self.logger.info(f"Saving document {document_id} for bulk download") + content = copy.deepcopy(empty_content) + content.setdefault(content_key, []).append(item) - content = copy.deepcopy(empty_content) - content["vaultId"] = vault_json["vaultId"] - content.setdefault(content_key, []).append(item) + # Encrypt content + if self.seed: + for section in ["transactions", "manifests", "accounts"]: + for item in content.get(section, []): + if "signedPayload" in item: + item["signedPayloadCiphered"] = crypt.encrypt(item["signedPayload"], self.seed) + del item["signedPayload"] - # Encrypt content - if len(self.seed) > 0: - data = crypt.encrypt(json.dumps(content), self.seed) - else: data = json.dumps(content) - - documents.append( - {"id": document_id, "content": data, "metadata": ""} - ) - - self.logger.info( - f"Successfully saved document {document_id} for bulk download" - ) - except Exception as e: - self.logger.exception(e) - continue - - documents = [] - for content_key, id_key in [ - ("transactions", "transactionId"), - ("accounts", "accountId"), - ("manifests", "manifestId"), - ]: - write_document_set(documents, content_key, id_key) + meta = { "source" : vaultid, "type": content_key} + documents.append( + {"id": document_id, "content": data, "metadata": json.dumps(meta) } + ) + + self.logger.info( + f"Successfully saved document {document_id} for bulk download" + ) + except Exception as e: + self.logger.exception(e) + continue + + + for content_key, id_key in [ + ("transactions", "transactionId"), + ("accounts", "accountId"), + ("manifests", "manifestId"), + ]: + write_document_set(documents, content_key, id_key) return documents def bulk_upload(self, documents): - vaults = [] - transactions = [] - accounts = [] - manifests = [] + v_tx = {} + v_ac = {} + v_ma = {} + v_vaults = {} self.logger.info("Saving documents for bulk upload") for document in documents: try: + contents = json.loads(document["content"]) + vaultid = contents.get("vaultId") + + if vaultid not in v_tx: + v_tx[vaultid] = [] + v_ac[vaultid] = [] + v_ma[vaultid] = [] + v_vaults[vaultid] = [] + document_id = document["id"] self.logger.info(f"Saving document {document_id} for bulk upload") + # Decrypt content - if len(self.seed) > 0: - contents = json.loads(crypt.decrypt(document["content"], self.seed)) - else: - contents = json.loads(document["content"]) - - transactions.extend(contents.get("transactions", [])) - accounts.extend(contents.get("accounts", [])) - manifests.extend(contents.get("manifests", [])) - vaults.extend(contents.get("vaults", [])) - - self.logger.info( - f"Successfully saved document {document_id} for bulk upload" - ) + if self.seed: + for section in ("transactions", "accounts", "manifests"): + for item in contents.get(section, []): + if "signedPayloadCiphered" in item: + item["signedPayload"] = crypt.decrypt(item["signedPayloadCiphered"], self.seed) + del item["signedPayloadCiphered"] + + v_tx[vaultid].extend(contents.get("transactions", [])) + v_ac[vaultid].extend(contents.get("accounts", [])) + v_ma[vaultid].extend(contents.get("manifests", [])) + v_vaults[vaultid].extend(contents.get("vaults", [])) + + self.logger.info(f"Successfully saved document {document_id} for bulk upload") except Exception as e: self.logger.exception(e) continue - content = { - "accounts": accounts, - "transactions": transactions, - "manifests": manifests, - "vaults": vaults, - } - self.logger.info("Performing bulk upload to frontend") token = self.get_token() - try: - with tempfile.NamedTemporaryFile(mode="w", delete=False) as vault_file: - json.dump(content, vault_file) - - files = {"files": open(vault_file.name, "rb")} - response = requests.post( - url=f"https://{self.hmz_api_hostname}/v1/vaults/operations/signed", - headers={"Authorization": "Bearer " + token}, - files=files, - verify=self.verify, - ) - response.raise_for_status() - except Exception as e: - raise e - finally: - os.remove(vault_file.name) + + # Upload each vault separately + for vaultid in v_tx.keys(): + content = { + "vaultId": vaultid, + "accounts": v_ac[vaultid], + "transactions": v_tx[vaultid], + "manifests": v_ma[vaultid], + "vaults": v_vaults[vaultid], + } + + try: + with tempfile.NamedTemporaryFile(mode="w", delete=False) as vault_file: + json.dump(content, vault_file) + + files = {"files": open(vault_file.name, "rb")} + response = requests.post( + url=f"https://{self.hmz_api_hostname}/v1/vaults/operations/signed", + headers={"Authorization": "Bearer " + token}, + files=files, + verify=self.verify, + ) + response.raise_for_status() + self.logger.info(f"Successfully uploaded vault {vaultid}") + except requests.HTTPError as http_err: + self.logger.error(f"HTTP error uploading vault {vaultid}: {http_err} - {response.text}") + except Exception as err: + self.logger.error(f"Unexpected error uploading vault {vaultid}: {err}") + finally: + os.remove(vault_file.name) + self.logger.info("Bulk upload finished successfully") def backend_status(self):