From c7e50d5602827cdffa1db9eeace128222897da78 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 2 Jun 2026 21:04:21 +0100 Subject: [PATCH] Harden provider-hosted MSP isolation Broker provider control-plane Docker access through a socket proxy, remove broad host mounts, align audit and rate-limit proxy trust, harden tenant runtime containers, restrict workspace report logo paths, and update provider deploy guardrails. --- deploy/provider-msp/.env.example | 7 +- deploy/provider-msp/docker-compose.yml | 34 +++- deploy/provider-msp/run-install-proof.sh | 10 +- deploy/provider-msp/setup.sh | 137 +++++++++++++-- deploy/provider-msp/traefik-dynamic.yml | 6 + deploy/provider-msp/upgrade.sh | 6 +- .../v6/internal/subsystems/agent-lifecycle.md | 7 + .../v6/internal/subsystems/api-contracts.md | 5 +- .../v6/internal/subsystems/cloud-paid.md | 15 ++ .../subsystems/deployment-installability.md | 37 ++-- .../internal/subsystems/security-privacy.md | 4 +- .../internal/subsystems/storage-recovery.md | 7 + internal/api/contract_test.go | 19 ++ internal/api/metrics_reporting_handlers.go | 8 +- internal/api/reporting_handlers_test.go | 42 +++++ internal/api/security_regression_test.go | 7 + internal/api/system_settings.go | 5 - internal/cloudcp/auditlog/auditlog.go | 24 +-- internal/cloudcp/auditlog/auditlog_test.go | 28 ++- internal/cloudcp/docker/manager.go | 35 +++- .../docker/manager_integration_test.go | 164 ++++++++++++++++++ internal/cloudcp/docker/manager_test.go | 36 ++++ internal/cloudcp/proxytrust/client_ip.go | 128 ++++++++++++++ internal/cloudcp/ratelimit.go | 105 +---------- internal/cloudcp/ratelimit_test.go | 10 +- .../installtests/provider_msp_deploy_test.go | 46 +++-- .../release_control/subsystem_lookup_test.py | 2 +- 27 files changed, 725 insertions(+), 209 deletions(-) create mode 100644 internal/cloudcp/docker/manager_integration_test.go create mode 100644 internal/cloudcp/proxytrust/client_ip.go diff --git a/deploy/provider-msp/.env.example b/deploy/provider-msp/.env.example index 08622980c..1fb654eb0 100644 --- a/deploy/provider-msp/.env.example +++ b/deploy/provider-msp/.env.example @@ -7,6 +7,7 @@ CF_DNS_API_TOKEN= # Image pins; use immutable digest refs in production TRAEFIK_IMAGE=traefik@sha256: +DOCKER_SOCKET_PROXY_IMAGE=tecnativa/docker-socket-proxy@sha256: CONTROL_PLANE_IMAGE=ghcr.io/rcourtman/pulse-control-plane@sha256: CP_PULSE_IMAGE=ghcr.io/rcourtman/pulse@sha256: @@ -15,9 +16,11 @@ CP_ENV=production CP_ADMIN_KEY= PULSE_PROVIDER_MSP_DATA_DIR=/data PULSE_PROVIDER_MSP_DOCKER_NETWORK=pulse-provider-msp +PULSE_PROVIDER_MSP_DOCKER_SUBNET=172.30.0.0/24 PULSE_PROVIDER_MSP_DOCKER_SOCKET=/var/run/docker.sock -PULSE_PROVIDER_MSP_HOST_ROOT=/ -PULSE_PROVIDER_MSP_DOCKER_DATA_DIR=/var/lib/docker +PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR=/var/lib/pulse-provider-msp/spacecheck/root +PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR=/var/lib/docker/.pulse-provider-msp-spacecheck +CP_TRUSTED_PROXY_CIDRS=172.30.0.0/24 CP_PROVIDER_MSP_LICENSE_FILE=./provider-msp-license.jwt CP_TRIAL_ACTIVATION_PRIVATE_KEY= CP_TENANT_MEMORY_LIMIT=536870912 diff --git a/deploy/provider-msp/docker-compose.yml b/deploy/provider-msp/docker-compose.yml index 995089f9c..19679b942 100644 --- a/deploy/provider-msp/docker-compose.yml +++ b/deploy/provider-msp/docker-compose.yml @@ -21,13 +21,31 @@ services: labels: - pulse.provider-msp.role=traefik + docker-socket-proxy: + image: ${DOCKER_SOCKET_PROXY_IMAGE:?DOCKER_SOCKET_PROXY_IMAGE must be set to a digest-pinned image} + volumes: + - ${PULSE_PROVIDER_MSP_DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:ro + environment: + - LOG_LEVEL=warning + - PING=1 + - VERSION=1 + - INFO=1 + - CONTAINERS=1 + - IMAGES=1 + - NETWORKS=1 + - SYSTEM=1 + - VOLUMES=1 + - POST=1 + networks: + - pulse-provider-msp + restart: unless-stopped + control-plane: image: ${CONTROL_PLANE_IMAGE:?CONTROL_PLANE_IMAGE must be set to a digest-pinned image} volumes: - ${PULSE_PROVIDER_MSP_DATA_DIR:-/data}:${PULSE_PROVIDER_MSP_DATA_DIR:-/data} - - ${PULSE_PROVIDER_MSP_HOST_ROOT:-/}:/host-root:ro - - ${PULSE_PROVIDER_MSP_DOCKER_DATA_DIR:-/var/lib/docker}:/host-var-lib-docker:ro - - ${PULSE_PROVIDER_MSP_DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock + - ${PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR:-/var/lib/pulse-provider-msp/spacecheck/root}:/storage-root-spacecheck:ro + - ${PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR:-/var/lib/docker/.pulse-provider-msp-spacecheck}:/storage-docker-spacecheck:ro secrets: - provider_msp_license env_file: @@ -41,14 +59,16 @@ services: - CP_ADMIN_KEY=${CP_ADMIN_KEY} - CP_BASE_URL=https://${DOMAIN} - CP_PULSE_IMAGE=${CP_PULSE_IMAGE} + - DOCKER_HOST=tcp://docker-socket-proxy:2375 - CP_DOCKER_NETWORK=${PULSE_PROVIDER_MSP_DOCKER_NETWORK:-pulse-provider-msp} + - CP_TRUSTED_PROXY_CIDRS=${CP_TRUSTED_PROXY_CIDRS} - CP_PROVIDER_MSP_LICENSE_FILE=/run/secrets/provider_msp_license - CP_TENANT_MEMORY_LIMIT=${CP_TENANT_MEMORY_LIMIT:-536870912} - CP_ALLOW_DOCKERLESS_PROVISIONING=${CP_ALLOW_DOCKERLESS_PROVISIONING:-false} - CP_STORAGE_GUARDRAILS_ENABLED=${CP_STORAGE_GUARDRAILS_ENABLED:-true} - - CP_STORAGE_ROOT_PATH=/host-root + - CP_STORAGE_ROOT_PATH=/storage-root-spacecheck - CP_STORAGE_DATA_PATH=${PULSE_PROVIDER_MSP_DATA_DIR:-/data} - - CP_STORAGE_DOCKER_PATH=/host-var-lib-docker + - CP_STORAGE_DOCKER_PATH=/storage-docker-spacecheck - CP_STORAGE_MIN_ROOT_AVAILABLE=${CP_STORAGE_MIN_ROOT_AVAILABLE:-10GiB} - CP_STORAGE_MIN_DATA_AVAILABLE=${CP_STORAGE_MIN_DATA_AVAILABLE:-5GiB} - CP_STORAGE_MIN_DOCKER_AVAILABLE=${CP_STORAGE_MIN_DOCKER_AVAILABLE:-10GiB} @@ -64,6 +84,7 @@ services: - pulse-provider-msp depends_on: - traefik + - docker-socket-proxy restart: unless-stopped labels: - pulse.provider-msp.role=control-plane @@ -84,6 +105,9 @@ secrets: networks: pulse-provider-msp: name: ${PULSE_PROVIDER_MSP_DOCKER_NETWORK:-pulse-provider-msp} + ipam: + config: + - subnet: ${PULSE_PROVIDER_MSP_DOCKER_SUBNET:-172.30.0.0/24} volumes: acme-data: diff --git a/deploy/provider-msp/run-install-proof.sh b/deploy/provider-msp/run-install-proof.sh index e46307b1d..3cc70402b 100755 --- a/deploy/provider-msp/run-install-proof.sh +++ b/deploy/provider-msp/run-install-proof.sh @@ -8,8 +8,8 @@ Usage: Runs the provider-hosted MSP compose install proof on a Docker host: 1. validates .env and docker-compose.yml - 2. optionally pulls the pinned Traefik and control-plane images - 3. starts Traefik so proof workspaces can attach isolated tenant networks + 2. optionally pulls the pinned Traefik, Docker socket proxy, and control-plane images + 3. starts Traefik and the Docker socket proxy so proof workspaces can attach isolated tenant networks 4. runs provider-msp install-proof through the compose control-plane service 5. starts the provider stack 6. runs provider-msp status as a final operator check @@ -119,10 +119,10 @@ docker version >/dev/null docker compose config --quiet if [[ "$skip_compose_pull" != "1" ]]; then - docker compose pull traefik control-plane + docker compose pull traefik docker-socket-proxy control-plane fi -docker compose up -d traefik +docker compose up -d traefik docker-socket-proxy install_args=( provider-msp install-proof @@ -142,7 +142,7 @@ fi docker compose run --rm --no-deps control-plane "${install_args[@]}" if [[ "$start_after_proof" != "0" ]]; then - docker compose up -d traefik control-plane + docker compose up -d traefik docker-socket-proxy control-plane docker compose run --rm --no-deps control-plane provider-msp status docker compose ps fi diff --git a/deploy/provider-msp/setup.sh b/deploy/provider-msp/setup.sh index a72c634eb..4fb4a6942 100755 --- a/deploy/provider-msp/setup.sh +++ b/deploy/provider-msp/setup.sh @@ -8,9 +8,10 @@ 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_HOST_ROOT="${PULSE_PROVIDER_MSP_HOST_ROOT:-/}" -PULSE_PROVIDER_MSP_DOCKER_DATA_DIR="${PULSE_PROVIDER_MSP_DOCKER_DATA_DIR:-/var/lib/docker}" +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}" @@ -73,7 +74,7 @@ EOF install_ops_tools() { log "installing ops tools" - apt_install jq rsync sqlite3 rclone s3cmd + apt_install jq openssl rsync sqlite3 rclone s3cmd } create_data_dirs() { @@ -85,14 +86,40 @@ create_data_dirs() { install -d -m 0700 "${data_dir}/control-plane" install -d -m 0700 "${data_dir}/backups" install -d -m 0700 "${data_dir}/backups/provider-msp" + + local root_spacecheck docker_spacecheck + root_spacecheck="$(provider_root_spacecheck_dir)" + docker_spacecheck="$(provider_docker_spacecheck_dir)" + log "creating storage space-check marker directories" + install -d -m 0700 "${root_spacecheck}" + install -d -m 0700 "${docker_spacecheck}" } ensure_docker_network() { - local network + local network subnet existing_subnets network="$(provider_docker_network)" - log "ensuring Docker network ${network} exists" + subnet="$(provider_docker_subnet)" + log "checking Docker network ${network}" if ! docker network inspect "${network}" >/dev/null 2>&1; then - docker network create "${network}" >/dev/null + 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 } @@ -211,6 +238,65 @@ provider_docker_network() { 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_TRIAL_ACTIVATION_PRIVATE_KEY "${env_path}")" ]]; then + log "generating CP_TRIAL_ACTIVATION_PRIVATE_KEY" + set_env_value CP_TRIAL_ACTIVATION_PRIVATE_KEY "$(openssl rand -base64 32 | tr -d '\n')" "${env_path}" + fi + chmod 0600 "${env_path}" +} + truthy() { case "$(echo "$1" | tr '[:upper:]' '[:lower:]')" in true|1|yes|on) return 0 ;; @@ -245,14 +331,20 @@ Edit it now and set required values: - ACME_EMAIL - CF_DNS_API_TOKEN - TRAEFIK_IMAGE (digest pinned) + - DOCKER_SOCKET_PROXY_IMAGE (digest pinned) - CONTROL_PLANE_IMAGE (digest pinned) - CP_PULSE_IMAGE (digest pinned) - - CP_ADMIN_KEY - PULSE_PROVIDER_MSP_DATA_DIR - PULSE_PROVIDER_MSP_DOCKER_NETWORK + - PULSE_PROVIDER_MSP_DOCKER_SUBNET - PULSE_PROVIDER_MSP_DOCKER_SOCKET + - PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR + - PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR + - CP_TRUSTED_PROXY_CIDRS - CP_PROVIDER_MSP_LICENSE_FILE - - CP_TRIAL_ACTIVATION_PRIVATE_KEY + +setup.sh will generate CP_ADMIN_KEY and CP_TRIAL_ACTIVATION_PRIVATE_KEY if they +are still blank. EOF @@ -276,7 +368,7 @@ validate_env_file() { local missing=() local k v - for k in DOMAIN ACME_EMAIL CF_DNS_API_TOKEN CP_ENV TRAEFIK_IMAGE CONTROL_PLANE_IMAGE CP_ADMIN_KEY CP_PULSE_IMAGE PULSE_PROVIDER_MSP_DATA_DIR PULSE_PROVIDER_MSP_DOCKER_NETWORK PULSE_PROVIDER_MSP_DOCKER_SOCKET PULSE_PROVIDER_MSP_HOST_ROOT PULSE_PROVIDER_MSP_DOCKER_DATA_DIR CP_PROVIDER_MSP_LICENSE_FILE CP_TRIAL_ACTIVATION_PRIVATE_KEY CP_TENANT_MEMORY_LIMIT CP_ALLOW_DOCKERLESS_PROVISIONING CP_STORAGE_GUARDRAILS_ENABLED CP_STORAGE_MIN_ROOT_AVAILABLE CP_STORAGE_MIN_DATA_AVAILABLE CP_STORAGE_MIN_DOCKER_AVAILABLE CP_STORAGE_MAX_DOCKER_BUILD_CACHE CP_PROOF_TENANT_MAX_AGE CP_PROOF_TENANT_MATCHERS CP_REQUIRE_EMAIL_PROVIDER PULSE_EMAIL_FROM PULSE_EMAIL_REPLY_TO; do + for k in DOMAIN ACME_EMAIL CF_DNS_API_TOKEN CP_ENV TRAEFIK_IMAGE DOCKER_SOCKET_PROXY_IMAGE CONTROL_PLANE_IMAGE CP_ADMIN_KEY CP_PULSE_IMAGE PULSE_PROVIDER_MSP_DATA_DIR PULSE_PROVIDER_MSP_DOCKER_NETWORK PULSE_PROVIDER_MSP_DOCKER_SUBNET PULSE_PROVIDER_MSP_DOCKER_SOCKET PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR CP_TRUSTED_PROXY_CIDRS CP_PROVIDER_MSP_LICENSE_FILE CP_TRIAL_ACTIVATION_PRIVATE_KEY CP_TENANT_MEMORY_LIMIT CP_ALLOW_DOCKERLESS_PROVISIONING CP_STORAGE_GUARDRAILS_ENABLED CP_STORAGE_MIN_ROOT_AVAILABLE CP_STORAGE_MIN_DATA_AVAILABLE CP_STORAGE_MIN_DOCKER_AVAILABLE CP_STORAGE_MAX_DOCKER_BUILD_CACHE CP_PROOF_TENANT_MAX_AGE CP_PROOF_TENANT_MATCHERS CP_REQUIRE_EMAIL_PROVIDER PULSE_EMAIL_FROM PULSE_EMAIL_REPLY_TO; do v="$(env_value "${k}" "${env_path}")" if [[ -z "${v}" ]]; then missing+=("${k}") @@ -292,7 +384,7 @@ validate_env_file() { fi local path_var path_value - for path_var in PULSE_PROVIDER_MSP_DATA_DIR PULSE_PROVIDER_MSP_DOCKER_SOCKET PULSE_PROVIDER_MSP_HOST_ROOT PULSE_PROVIDER_MSP_DOCKER_DATA_DIR; do + for path_var in PULSE_PROVIDER_MSP_DATA_DIR PULSE_PROVIDER_MSP_DOCKER_SOCKET PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR; do path_value="$(env_value "${path_var}" "${env_path}")" if [[ "${path_value}" != /* ]]; then die "${path_var} must be an absolute path" @@ -301,12 +393,9 @@ validate_env_file() { if [[ ! -S "$(env_value PULSE_PROVIDER_MSP_DOCKER_SOCKET "${env_path}")" ]]; then die "PULSE_PROVIDER_MSP_DOCKER_SOCKET must point to a reachable Docker socket" fi - if [[ ! -d "$(env_value PULSE_PROVIDER_MSP_HOST_ROOT "${env_path}")" ]]; then - die "PULSE_PROVIDER_MSP_HOST_ROOT must point to an existing directory" - fi local image_ref - for k in TRAEFIK_IMAGE CONTROL_PLANE_IMAGE CP_PULSE_IMAGE; do + for k in TRAEFIK_IMAGE DOCKER_SOCKET_PROXY_IMAGE CONTROL_PLANE_IMAGE CP_PULSE_IMAGE; do image_ref="$(env_value "${k}" "${env_path}")" if [[ "${image_ref}" != *@sha256:* || "${image_ref}" == *""* ]]; then die "${k} must be an immutable digest ref (expected ...@sha256:...)" @@ -327,8 +416,20 @@ validate_env_file() { 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 - if [[ ! -d "$(env_value PULSE_PROVIDER_MSP_DOCKER_DATA_DIR "${env_path}")" ]]; then - die "PULSE_PROVIDER_MSP_DOCKER_DATA_DIR must point to the host Docker data directory" + + 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_TRIAL_ACTIVATION_PRIVATE_KEY "${env_path}")" + if ! printf '%s' "${trial_key}" | base64 -d >/dev/null 2>&1; then + die "CP_TRIAL_ACTIVATION_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 @@ -367,7 +468,7 @@ pull_provider_images() { return 0 fi log "pulling provider MSP images" - (cd "${PULSE_PROVIDER_MSP_INSTALL_DIR}" && docker compose pull traefik control-plane) + (cd "${PULSE_PROVIDER_MSP_INSTALL_DIR}" && docker compose pull traefik docker-socket-proxy control-plane) } run_install_proof_if_requested() { @@ -432,9 +533,11 @@ main() { install_ops_tools install_deploy_bundle ensure_env_file + ensure_generated_secrets validate_env_file create_data_dirs ensure_docker_network + block_container_metadata_service validate_compose_config pull_provider_images run_install_proof_if_requested diff --git a/deploy/provider-msp/traefik-dynamic.yml b/deploy/provider-msp/traefik-dynamic.yml index b30c36eb6..dba7c2f7a 100644 --- a/deploy/provider-msp/traefik-dynamic.yml +++ b/deploy/provider-msp/traefik-dynamic.yml @@ -9,3 +9,9 @@ http: contentTypeNosniff: true browserXssFilter: true referrerPolicy: "strict-origin-when-cross-origin" + +tls: + options: + default: + minVersion: VersionTLS12 + sniStrict: true diff --git a/deploy/provider-msp/upgrade.sh b/deploy/provider-msp/upgrade.sh index 4b0cfb811..2544a7e29 100755 --- a/deploy/provider-msp/upgrade.sh +++ b/deploy/provider-msp/upgrade.sh @@ -12,7 +12,7 @@ Runs the provider-hosted MSP pre-upgrade and upgrade flow: 2. checks provider status and install preflight 3. creates and verifies a fresh provider MSP backup 4. dry-runs restore into a separate target data directory - 5. pulls and starts the provider control-plane and Traefik services + 5. pulls and starts the provider Traefik, Docker socket proxy, and control-plane services 6. prints the tenant runtime rollout plan for CP_PULSE_IMAGE 7. optionally rolls all tenant runtimes onto CP_PULSE_IMAGE @@ -196,9 +196,9 @@ run_control provider-msp backup restore "${archive_path}" --target-data-dir "${r run_control provider-msp status --require-backup if ! truthy "${skip_compose_pull}"; then - docker compose pull traefik control-plane + docker compose pull traefik docker-socket-proxy control-plane fi -docker compose up -d traefik control-plane +docker compose up -d traefik docker-socket-proxy control-plane run_control provider-msp status --require-backup run_control tenant-runtime rollout --all --image "${tenant_runtime_image}" --dry-run diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index f39e7f6ec..c29838279 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -621,6 +621,13 @@ profile and assignment columns, but embedded table framing must route through 5. Keep release-grade updater trust fail-closed across `internal/agentupdate/`, `internal/dockeragent/`, and the shared `internal/api/unified_agent.go` download helpers. When release builds embed trusted update signing keys, published agent binaries and installer assets must carry detached `.sig` plus `.sshsig` sidecars; updater/runtime paths must require `X-Signature-Ed25519` in addition to `X-Checksum-Sha256`, and installer-owned download flows must require the matching base64-encoded `X-Signature-SSHSIG`, instead of silently downgrading to checksum-only trust. 6. Keep shared `internal/api/` helper edits isolated from agent lifecycle semantics: Patrol-specific status transport or alert-trigger wiring changes in shared handlers must not bleed into auto-register, installer, or fleet-control behavior unless this contract moves in the same slice. The same isolation rule applies to AI settings payload work in `internal/api/ai_handlers.go`: provider auth fields, masked-secret echoes, and provider-test model selection remain AI/runtime plus API-contract ownership and must not be reinterpreted as lifecycle setup or registration semantics just because they share backend helper layers. + The same isolation rule applies to report branding validation and rendering + request assembly in `internal/api/system_settings.go` and + `internal/api/metrics_reporting_handlers.go`: lifecycle-owned install, + enrollment, and reporting freshness flows may coexist with generated + reports, but workspace logo material remains API/security/reporting + ownership and must not become agent credential, install-token, or fleet + lifecycle state. The same isolation rule applies to Patrol investigation-record propagation through shared AI intelligence handlers and `internal/api/router.go`: lifecycle surfaces may observe the resulting resource context, but they must diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 2de00cecc..dc815c540 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -189,7 +189,10 @@ reporting layer, and the reporting layer remains the final gate: an unentitled tenant runtime must render the normal Pulse report identity even if branding configuration is present. Provider-hosted MSP proofs may exercise branding through tenant runtimes, but the control plane must not gain MSP-specific report -generation plumbing or cross-client report content. +generation plumbing or cross-client report content. Tenant-local workspace +branding overrides may carry display names and bounded inline logo data only; +local filesystem `logoPath` input is reserved for provider-default runtime +configuration and must not be accepted from persisted workspace settings. Pulse Account workspace summaries carry setup state as a backend-owned payload contract. Browser bootstrap and `/api/portal/dashboard` workspace entries may diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index ae6a8338a..e1fe56497 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -42,6 +42,13 @@ contract. `CP_DOCKER_NETWORK` names the provider ingress and support network, not the client runtime boundary: each client workspace runtime must be created on a derived per-tenant Docker bridge, and support containers must be explicitly attached to that bridge before the tenant runtime is started. +Provider-hosted MSP control-plane host access is part of the same runtime +contract. The internet-facing control plane must not receive broad host-root or +Docker-data read mounts for storage admission checks; it may stat only narrow +operator-owned marker directories on the target filesystems. Docker daemon +access must be routed through the packaged socket proxy rather than a raw +read-write socket mount, and audit/rate-limit client IP recovery must honor +forwarded headers only from configured trusted provider proxy CIDRs. Provider-hosted MSP report branding is a tenant runtime configuration contract, not control-plane report generation. The control plane may accept provider-default report brand environment values and pass them into each tenant @@ -123,6 +130,7 @@ Stripe-free and avoids a cloud-control-plane report data path across clients. 68. `frontend-modern/src/utils/selfHostedFeatureCatalog.generated.ts` 80. `internal/cloudcp/server.go`, `internal/cloudcp/authz.go`, `internal/cloudcp/commercial_identity.go`, `internal/cloudcp/security.go` 81. `internal/cloudcp/health_monitor.go`, `internal/cloudcp/health_stuck_provisioning.go`, `internal/cloudcp/tenant_state_metrics.go`, `internal/cloudcp/ratelimit.go` +81a. `internal/cloudcp/proxytrust/client_ip.go` 82. `internal/cloudcp/hosted_entitlement_handlers.go`, `internal/cloudcp/url_helpers.go` 83. `internal/cloudcp/admin/handlers.go`, `internal/cloudcp/admin/status.go`, `internal/cloudcp/auditlog/auditlog.go` 84. `internal/cloudcp/cpmetrics/metrics.go`, `internal/cloudcp/cpsec/nonce.go`, `internal/cloudcp/static_assets.go`, `internal/cloudcp/favicon.svg` @@ -211,6 +219,13 @@ Stripe-free and avoids a cloud-control-plane report data path across clients. selected by provider MSP labels, start the tenant runtime only after those support attachments succeed, prefer the tenant bridge for health checks, and remove tenant bridges when the owning runtime is removed. + Tenant runtime containers must also start with the provider-hosted escape + hardening defaults owned by the Docker manager: no-new-privileges, dropped + Linux capabilities unless explicitly reintroduced by a governed need, + Docker's default seccomp profile still active, a read-only root filesystem, + and bounded writable tmpfs mounts only for runtime scratch paths. These + defaults are part of the client isolation contract, not optional compose + decoration. Client-bound proof must cover the boundary itself: workspace limits must not be raceable past the licensed cap, handoff tokens must not be replayed or retargeted across workspaces, org-bound agent install/report tokens must not diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index c34ba39bc..d9a5f096c 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -19,6 +19,14 @@ Own server installation, deployment bootstrap behavior, provider-hosted MSP deployment artifacts, update planning, and server-side update execution surfaces. +Provider-hosted MSP deploy artifacts must package the provider control plane as +a least-privilege Docker provisioner. The packaged compose/setup path must avoid +whole-host and Docker-data read mounts, expose storage admission only through +narrow marker directories, broker Docker daemon access through the socket proxy, +pin trusted proxy CIDRs to the provider network, block tenant bridge access to +cloud metadata endpoints at the host firewall when possible, and pin the Traefik +TLS floor in the dynamic config. + ## Canonical Files 1. `internal/updates/` @@ -175,16 +183,25 @@ surfaces. ` when the operator explicitly asks for tenant rollout. `deploy/provider-msp/setup.sh` is the first-time provider host setup artifact. It must install the Docker/compose host prerequisites, create the - provider data, backup, and Docker-network layout, copy the provider MSP - deploy bundle into a stable operator directory, create a private `.env` from - the provider template when needed, fail closed on placeholder image refs, - missing signed MSP license files, Dockerless production provisioning, disabled - storage guardrails, or Stripe/cloud-signup variables, validate compose, and - optionally hand off to `run-install-proof.sh` when the provider account name - and owner email are supplied. Because provider-hosted MSP provisions tenant - containers through the host Docker socket, the provider data directory must - be mounted at the same absolute path inside the control-plane container that - the host Docker daemon will later use for tenant runtime bind mounts. + provider data and backup layout, validate the provider Docker network when + it already exists and otherwise let compose create it with the configured + subnet, copy the provider MSP deploy bundle into a stable operator + directory, create a private `.env` from the provider template when needed, + fail closed on placeholder image refs, missing signed MSP license files, + Dockerless production provisioning, disabled storage guardrails, or + Stripe/cloud-signup variables, validate compose, and optionally hand off to + `run-install-proof.sh` when the provider account name and owner email are + supplied. Because provider-hosted MSP provisions tenant containers through + the host Docker socket, the provider data directory must be mounted at the + same absolute path inside the control-plane container that the host Docker + daemon will later use for tenant runtime bind mounts. + The setup artifact must also generate strong provider secrets when the + template leaves `CP_ADMIN_KEY` or `CP_TRIAL_ACTIVATION_PRIVATE_KEY` blank, + enforce minimum admin-key strength and a valid activation signing key before + compose starts, require `CP_TRUSTED_PROXY_CIDRS` to include the provider + Docker subnet, create the storage-admission marker directories, and install a + host-level `DOCKER-USER` rule blocking `169.254.169.254` from tenant + containers when iptables is available. Provider-hosted MSP installability must also pass provider-default report branding through the packaged tenant environment rather than requiring report-specific operator provisioning. The deployable control-plane config diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 62e40d336..e8a2f1e93 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -114,7 +114,9 @@ controls as normal product settings. can carry operator-authored names and logo material into generated PDFs. `reportBranding` updates must validate object shape, supported keys, string types, bounded lengths, newline-free values, supported logo formats, - and valid bounded base64 before persistence. Rendering custom branding + and valid bounded base64 before persistence. Workspace settings must not + accept local filesystem `logoPath` values; file-backed logo paths are + provider-default runtime configuration only. Rendering custom branding remains gated by the `white_label` entitlement in the reporting layer, so storing a brand setting never becomes a free branding bypass. 16. `internal/cloudcp/auth/magiclink.go` shared with `cloud-paid`: control-plane magic-link HMAC handling is both a Pulse Cloud account-access boundary and a security/privacy token-secrecy boundary. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 5fd3bf01f..fc85410c8 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -227,6 +227,13 @@ recovery scope, or a storage/recovery-owned secret source. 1. Add or change recovery-point persistence, rollups, or series derivation through `internal/recovery/` 4. Route transport changes for storage and recovery endpoints through `internal/api/` and the owning `api-contracts` proof routes + Report branding validation and reporting request assembly in + `internal/api/system_settings.go` and + `internal/api/metrics_reporting_handlers.go` remain adjacent + API/security/reporting ownership. Storage and recovery workflows may consume + generated report output when a separate reporting surface exposes it, but + workspace logo settings are not backup artifacts, recovery-point metadata, + restore evidence, or storage-provider credentials. Update-plan readiness payloads and apply-route readiness enforcement are adjacent shared API context only. Storage and recovery surfaces may observe the resulting update state if a future settings flow links to recovery diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index e834b3fc4..a89287358 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -3805,6 +3805,25 @@ func TestContract_ReportingRequestCarriesEntitledReportBranding(t *testing.T) { } } +func TestContract_ReportBrandingSettingsRejectWorkspaceLogoPath(t *testing.T) { + err := validateReportBrandingSettings(map[string]interface{}{ + "displayName": "Client One", + "logoPath": "/etc/pulse/secrets/handoff.key", + }) + if err == nil || !strings.Contains(err.Error(), "reportBranding.logoPath is not supported") { + t.Fatalf("expected workspace logoPath to be rejected, got %v", err) + } + + err = validateReportBrandingSettings(map[string]interface{}{ + "displayName": "Client One", + "logoBase64": "iVBORw0KGgo=", + "logoFormat": "png", + }) + if err != nil { + t.Fatalf("expected inline report logo settings to remain valid, got %v", err) + } +} + func TestContract_ReportingCatalogRouteAccessibleWithoutReportingFeature(t *testing.T) { rawToken := "reporting-catalog-contract-token-123.12345678" record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil) diff --git a/internal/api/metrics_reporting_handlers.go b/internal/api/metrics_reporting_handlers.go index 9c56556d7..65dd1d95d 100644 --- a/internal/api/metrics_reporting_handlers.go +++ b/internal/api/metrics_reporting_handlers.go @@ -144,7 +144,7 @@ func (h *ReportingHandlers) resolveReportBranding(ctx context.Context) reporting return branding } if settings.ReportBranding != nil { - branding.WorkspaceOverride = reportBrandFromSettings(*settings.ReportBranding) + branding.WorkspaceOverride = reportBrandFromWorkspaceSettings(*settings.ReportBranding) } return branding } @@ -158,6 +158,12 @@ func reportBrandFromEnv() reporting.ReportBrand { }) } +func reportBrandFromWorkspaceSettings(settings config.ReportBrandSettings) reporting.ReportBrand { + brand := reportBrandFromSettings(settings) + brand.LogoPath = "" + return brand +} + func reportBrandFromSettings(settings config.ReportBrandSettings) reporting.ReportBrand { logoData, err := config.DecodeReportBrandLogoBase64(settings.LogoBase64) if err != nil { diff --git a/internal/api/reporting_handlers_test.go b/internal/api/reporting_handlers_test.go index bd99e1a48..e38ec1ac8 100644 --- a/internal/api/reporting_handlers_test.go +++ b/internal/api/reporting_handlers_test.go @@ -224,6 +224,48 @@ func TestReportingHandlers_AttachesEntitledReportBranding(t *testing.T) { } } +func TestReportingHandlers_StripsWorkspaceReportBrandLogoPath(t *testing.T) { + engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"} + original := reporting.GetEngine() + reporting.SetEngine(engine) + t.Cleanup(func() { reporting.SetEngine(original) }) + + service := pkglicensing.NewService() + service.SetCurrentForTesting(&pkglicensing.License{ + Claims: pkglicensing.Claims{ + LicenseID: "lic_report_branding_path", + Email: "brand-path@example.test", + Tier: pkglicensing.TierEnterprise, + }, + ValidatedAt: time.Now(), + }) + SetLicenseServiceProvider(reportBrandLicenseProvider{service: service}) + t.Cleanup(func() { SetLicenseServiceProvider(nil) }) + + handler := NewReportingHandlers(nil, nil) + handler.SetSystemSettingsStore(reportBrandSettingsStore{settings: &config.SystemSettings{ + ReportBranding: &config.ReportBrandSettings{ + DisplayName: "Client Path", + LogoPath: "/etc/pulse/secrets/handoff.key", + }, + }}) + t.Setenv("PULSE_REPORT_PROVIDER_BRAND_LOGO_PATH", "/etc/pulse/provider-brand.png") + + req := httptest.NewRequest(http.MethodGet, "/api/reporting?format=pdf&resourceType=node&resourceId=node-1", nil) + rr := httptest.NewRecorder() + handler.HandleGenerateReport(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String()) + } + if got := engine.lastReq.Branding.ProviderDefault.LogoPath; got != "/etc/pulse/provider-brand.png" { + t.Fatalf("provider logo path = %q, want provider env path", got) + } + if got := engine.lastReq.Branding.WorkspaceOverride.LogoPath; got != "" { + t.Fatalf("workspace logo path must be stripped, got %q", got) + } +} + func TestReportingHandlers_GenerateReport_TrimsOptionalFields(t *testing.T) { engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"} original := reporting.GetEngine() diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index ad658365c..e269ddf49 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -1901,6 +1901,13 @@ func TestSystemSettingsReportBrandingValidationRejectsUnsafePayload(t *testing.T }}, want: "reportBranding.tenantID is not supported", }, + { + name: "workspace_logo_path_unsupported", + raw: map[string]interface{}{"reportBranding": map[string]interface{}{ + "logoPath": "/etc/pulse/secrets/handoff.key", + }}, + want: "reportBranding.logoPath is not supported", + }, { name: "newline", raw: map[string]interface{}{"reportBranding": map[string]interface{}{ diff --git a/internal/api/system_settings.go b/internal/api/system_settings.go index d4f03994e..add5e60b8 100644 --- a/internal/api/system_settings.go +++ b/internal/api/system_settings.go @@ -604,7 +604,6 @@ func validateSystemSettings(_ *config.SystemSettings, rawRequest map[string]inte func validateReportBrandingSettings(settings map[string]interface{}) error { allowed := map[string]struct{}{ "displayName": {}, - "logoPath": {}, "logoBase64": {}, "logoFormat": {}, } @@ -624,10 +623,6 @@ func validateReportBrandingSettings(settings map[string]interface{}) error { if len(strings.TrimSpace(str)) > config.ReportBrandDisplayNameMaxLength { return fmt.Errorf("reportBranding.displayName must be <= %d characters", config.ReportBrandDisplayNameMaxLength) } - case "logoPath": - if len(strings.TrimSpace(str)) > config.ReportBrandLogoPathMaxLength { - return fmt.Errorf("reportBranding.logoPath must be <= %d characters", config.ReportBrandLogoPathMaxLength) - } case "logoBase64": if _, err := config.DecodeReportBrandLogoBase64(str); err != nil { return fmt.Errorf("reportBranding.%s", err.Error()) diff --git a/internal/cloudcp/auditlog/auditlog.go b/internal/cloudcp/auditlog/auditlog.go index 4a07c0dd8..762f47977 100644 --- a/internal/cloudcp/auditlog/auditlog.go +++ b/internal/cloudcp/auditlog/auditlog.go @@ -1,33 +1,15 @@ package auditlog import ( - "net" "net/http" "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/proxytrust" ) // ClientIP resolves the best-effort client IP for audit metadata. func ClientIP(r *http.Request) string { - if r == nil { - return "" - } - - if xff := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xff != "" { - if i := strings.IndexByte(xff, ','); i >= 0 { - return strings.TrimSpace(xff[:i]) - } - return xff - } - - if rip := strings.TrimSpace(r.Header.Get("X-Real-IP")); rip != "" { - return strings.Trim(rip, "[]") - } - - host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) - if err != nil { - return strings.TrimSpace(r.RemoteAddr) - } - return strings.TrimSpace(host) + return proxytrust.ClientIP(r) } // ActorID returns the request actor identifier from common headers. diff --git a/internal/cloudcp/auditlog/auditlog_test.go b/internal/cloudcp/auditlog/auditlog_test.go index ae3a988f3..1c686facf 100644 --- a/internal/cloudcp/auditlog/auditlog_test.go +++ b/internal/cloudcp/auditlog/auditlog_test.go @@ -4,9 +4,14 @@ import ( "net/http" "net/url" "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/proxytrust" ) func TestClientIP(t *testing.T) { + t.Setenv("CP_TRUSTED_PROXY_CIDRS", "127.0.0.1/32,10.0.0.0/8") + proxytrust.ResetForTesting() + tests := []struct { name string request *http.Request @@ -18,33 +23,38 @@ func TestClientIP(t *testing.T) { expectedIP: "", }, { - name: "X-Forwarded-For single IP", - request: newRequestWithHeaders(t, "", map[string]string{"X-Forwarded-For": "192.168.1.100"}), + name: "X-Forwarded-For single IP from trusted proxy", + request: newRequestWithHeaders(t, "127.0.0.1:1234", map[string]string{"X-Forwarded-For": "192.168.1.100"}), expectedIP: "192.168.1.100", }, { - name: "X-Forwarded-For multiple IPs", - request: newRequestWithHeaders(t, "", map[string]string{"X-Forwarded-For": "192.168.1.100, 10.0.0.1, 172.16.0.1"}), + name: "X-Forwarded-For multiple IPs selects right-most untrusted hop", + request: newRequestWithHeaders(t, "127.0.0.1:1234", map[string]string{"X-Forwarded-For": "192.168.1.100, 10.0.0.1"}), expectedIP: "192.168.1.100", }, { name: "X-Forwarded-For with spaces", - request: newRequestWithHeaders(t, "", map[string]string{"X-Forwarded-For": " 192.168.1.100 , 10.0.0.1"}), + request: newRequestWithHeaders(t, "127.0.0.1:1234", map[string]string{"X-Forwarded-For": " 192.168.1.100 , 10.0.0.1"}), expectedIP: "192.168.1.100", }, { - name: "X-Real-IP when no XFF", - request: newRequestWithHeaders(t, "10.0.0.5:1234", map[string]string{"X-Real-IP": "10.0.0.5"}), + name: "X-Forwarded-For ignored from untrusted remote", + request: newRequestWithHeaders(t, "198.51.100.10:1234", map[string]string{"X-Forwarded-For": "192.168.1.100"}), + expectedIP: "198.51.100.10", + }, + { + name: "X-Real-IP when no XFF from trusted proxy", + request: newRequestWithHeaders(t, "127.0.0.1:1234", map[string]string{"X-Real-IP": "10.0.0.5"}), expectedIP: "10.0.0.5", }, { name: "X-Real-IP takes precedence when XFF is empty", - request: newRequestWithHeaders(t, "10.0.0.5:1234", map[string]string{"X-Forwarded-For": "", "X-Real-IP": "10.0.0.5"}), + request: newRequestWithHeaders(t, "127.0.0.1:1234", map[string]string{"X-Forwarded-For": "", "X-Real-IP": "10.0.0.5"}), expectedIP: "10.0.0.5", }, { name: "X-Real-IP with brackets stripped", - request: newRequestWithHeaders(t, "10.0.0.5:1234", map[string]string{"X-Real-IP": "[::1]"}), + request: newRequestWithHeaders(t, "127.0.0.1:1234", map[string]string{"X-Real-IP": "[::1]"}), expectedIP: "::1", }, { diff --git a/internal/cloudcp/docker/manager.go b/internal/cloudcp/docker/manager.go index a2a51ca1b..411d6e366 100644 --- a/internal/cloudcp/docker/manager.go +++ b/internal/cloudcp/docker/manager.go @@ -514,15 +514,7 @@ func (m *Manager) CreateAndStart(ctx context.Context, tenantID, tenantDataDir st Labels: labels, Env: tenantEnv(tenantID, m.cfg.BaseDomain, m.cfg.TrialActivationPublicKey, m.tenantTrustedProxyCIDRs(ctx, tenantNetworkName), m.cfg.TenantReportBrand), }, - HostConfig: &container.HostConfig{ - RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, - LogConfig: tenantRuntimeLogConfig(m.cfg.TenantLogMaxSize, m.cfg.TenantLogMaxFile), - Resources: container.Resources{ - Memory: m.cfg.MemoryLimit, - CPUShares: m.cfg.CPUShares, - }, - Mounts: tenantMounts(tenantDataDir), - }, + HostConfig: tenantRuntimeHostConfig(tenantDataDir, m.cfg), NetworkingConfig: &network.NetworkingConfig{ EndpointsConfig: map[string]*network.EndpointSettings{ tenantNetworkName: {}, @@ -569,6 +561,31 @@ func tenantRuntimeLogConfig(maxSize string, maxFile int) container.LogConfig { } } +func tenantRuntimeHostConfig(tenantDataDir string, cfg ManagerConfig) *container.HostConfig { + return &container.HostConfig{ + RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, + LogConfig: tenantRuntimeLogConfig(cfg.TenantLogMaxSize, cfg.TenantLogMaxFile), + Resources: container.Resources{ + Memory: cfg.MemoryLimit, + CPUShares: cfg.CPUShares, + }, + Mounts: tenantMounts(tenantDataDir), + SecurityOpt: tenantRuntimeSecurityOptions(), + CapDrop: []string{"ALL"}, + ReadonlyRootfs: true, + Tmpfs: map[string]string{ + "/run": "rw,noexec,nosuid,nodev,size=16m", + "/tmp": "rw,noexec,nosuid,nodev,size=64m", + }, + } +} + +func tenantRuntimeSecurityOptions() []string { + // Do not set seccomp=unconfined. Docker's default seccomp profile remains + // active unless the daemon has been explicitly weakened outside Pulse. + return []string{"no-new-privileges:true"} +} + func tenantImmutableOwnershipPaths() []string { return []string{ "/etc/pulse/secrets/handoff.key", diff --git a/internal/cloudcp/docker/manager_integration_test.go b/internal/cloudcp/docker/manager_integration_test.go new file mode 100644 index 000000000..47742bcc2 --- /dev/null +++ b/internal/cloudcp/docker/manager_integration_test.go @@ -0,0 +1,164 @@ +package docker + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/network" + "github.com/moby/moby/client" +) + +func TestIntegrationTenantNetworksAreNotMutuallyReachable(t *testing.T) { + if os.Getenv("PULSE_RUN_DOCKER_INTEGRATION") != "1" { + t.Skip("set PULSE_RUN_DOCKER_INTEGRATION=1 to run live Docker tenant-network proof") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + suffix := dockerIntegrationSuffix(t) + providerNetwork := "pulse-it-provider-" + suffix + tenantNetworkPrefix := "pulse-it-tenant-" + suffix + + mgr, err := NewManager(ManagerConfig{ + Image: "busybox:1.36", + Network: providerNetwork, + IsolateTenantNetworks: true, + TenantNetworkPrefix: tenantNetworkPrefix, + }) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + t.Cleanup(func() { _ = mgr.Close() }) + + if _, err := mgr.cli.NetworkCreate(ctx, providerNetwork, client.NetworkCreateOptions{ + Driver: "bridge", + Labels: map[string]string{"pulse.integration": "tenant-network-proof"}, + }); err != nil { + t.Fatalf("create provider network: %v", err) + } + t.Cleanup(func() { + _, _ = mgr.cli.NetworkRemove(context.Background(), providerNetwork, client.NetworkRemoveOptions{}) + }) + + if _, _, err := mgr.ensureRuntimeImageAvailable(ctx, true); err != nil { + t.Fatalf("prepare busybox image: %v", err) + } + + tenantA, err := mgr.ensureTenantNetwork(ctx, "t-A") + if err != nil { + t.Fatalf("create tenant A network: %v", err) + } + t.Cleanup(func() { _, _ = mgr.cli.NetworkRemove(context.Background(), tenantA, client.NetworkRemoveOptions{}) }) + + tenantB, err := mgr.ensureTenantNetwork(ctx, "t-B") + if err != nil { + t.Fatalf("create tenant B network: %v", err) + } + t.Cleanup(func() { _, _ = mgr.cli.NetworkRemove(context.Background(), tenantB, client.NetworkRemoveOptions{}) }) + + targetID := dockerIntegrationCreateContainer(t, ctx, mgr, tenantB, []string{"sleep", "300"}) + targetIP := dockerIntegrationContainerIP(t, ctx, mgr, targetID, tenantB) + if targetIP == "" { + t.Fatal("tenant B target container has no IP on its tenant network") + } + dockerIntegrationAssertNotAttached(t, ctx, mgr, targetID, providerNetwork) + + if code := dockerIntegrationRunContainer(t, ctx, mgr, tenantB, []string{"ping", "-c", "1", "-W", "1", targetIP}); code != 0 { + t.Fatalf("same-tenant probe exit status = %d, want 0", code) + } + if code := dockerIntegrationRunContainer(t, ctx, mgr, tenantA, []string{"ping", "-c", "1", "-W", "1", targetIP}); code == 0 { + t.Fatal("cross-tenant probe unexpectedly reached tenant B container") + } +} + +func dockerIntegrationSuffix(t *testing.T) string { + t.Helper() + var raw [4]byte + if _, err := rand.Read(raw[:]); err != nil { + t.Fatalf("random suffix: %v", err) + } + return strings.ToLower(hex.EncodeToString(raw[:])) +} + +func dockerIntegrationCreateContainer(t *testing.T, ctx context.Context, mgr *Manager, networkName string, cmd []string) string { + t.Helper() + name := fmt.Sprintf("pulse-it-%s-%s", dockerIntegrationSuffix(t), strings.ReplaceAll(networkName, "_", "-")) + resp, err := mgr.cli.ContainerCreate(ctx, client.ContainerCreateOptions{ + Config: &container.Config{ + Image: mgr.cfg.Image, + Cmd: cmd, + Labels: map[string]string{"pulse.integration": "tenant-network-proof"}, + }, + NetworkingConfig: &network.NetworkingConfig{ + EndpointsConfig: map[string]*network.EndpointSettings{ + networkName: {}, + }, + }, + Name: name, + }) + if err != nil { + t.Fatalf("create container on %s: %v", networkName, err) + } + t.Cleanup(func() { + _, _ = mgr.cli.ContainerRemove(context.Background(), resp.ID, client.ContainerRemoveOptions{Force: true}) + }) + if _, err := mgr.cli.ContainerStart(ctx, resp.ID, client.ContainerStartOptions{}); err != nil { + t.Fatalf("start container on %s: %v", networkName, err) + } + return resp.ID +} + +func dockerIntegrationRunContainer(t *testing.T, ctx context.Context, mgr *Manager, networkName string, cmd []string) int64 { + t.Helper() + id := dockerIntegrationCreateContainer(t, ctx, mgr, networkName, cmd) + wait := mgr.cli.ContainerWait(ctx, id, client.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}) + select { + case err := <-wait.Error: + if err != nil { + t.Fatalf("wait for probe on %s: %v", networkName, err) + } + case result := <-wait.Result: + return result.StatusCode + case <-ctx.Done(): + t.Fatalf("probe on %s did not finish: %v", networkName, ctx.Err()) + } + return 1 +} + +func dockerIntegrationContainerIP(t *testing.T, ctx context.Context, mgr *Manager, containerID, networkName string) string { + t.Helper() + inspect, err := mgr.cli.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{}) + if err != nil { + t.Fatalf("inspect container %s: %v", containerID, err) + } + if inspect.Container.NetworkSettings == nil { + return "" + } + endpoint := inspect.Container.NetworkSettings.Networks[networkName] + if endpoint == nil { + return "" + } + return strings.TrimSpace(endpoint.IPAddress.String()) +} + +func dockerIntegrationAssertNotAttached(t *testing.T, ctx context.Context, mgr *Manager, containerID, networkName string) { + t.Helper() + inspect, err := mgr.cli.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{}) + if err != nil { + t.Fatalf("inspect container %s: %v", containerID, err) + } + if inspect.Container.NetworkSettings == nil { + return + } + if endpoint := inspect.Container.NetworkSettings.Networks[networkName]; endpoint != nil { + t.Fatalf("container %s is unexpectedly attached to provider network %s", containerID[:12], networkName) + } +} diff --git a/internal/cloudcp/docker/manager_test.go b/internal/cloudcp/docker/manager_test.go index 1d8f8917c..ebfd1a689 100644 --- a/internal/cloudcp/docker/manager_test.go +++ b/internal/cloudcp/docker/manager_test.go @@ -253,6 +253,42 @@ func TestTenantRuntimeLogConfigBoundsJSONLogs(t *testing.T) { } } +func TestTenantRuntimeHostConfigAppliesEscapeHardening(t *testing.T) { + t.Parallel() + + cfg := ManagerConfig{ + MemoryLimit: 512 * 1024 * 1024, + CPUShares: 512, + TenantLogMaxSize: "20m", + TenantLogMaxFile: 2, + } + hostCfg := tenantRuntimeHostConfig("/data/tenants/t-acme", cfg) + if hostCfg == nil { + t.Fatal("tenantRuntimeHostConfig returned nil") + } + if !hostCfg.ReadonlyRootfs { + t.Fatal("tenant runtime root filesystem must be read-only") + } + if got := hostCfg.SecurityOpt; len(got) != 1 || got[0] != "no-new-privileges:true" { + t.Fatalf("SecurityOpt = %v, want no-new-privileges", got) + } + if got := hostCfg.CapDrop; len(got) != 1 || got[0] != "ALL" { + t.Fatalf("CapDrop = %v, want [ALL]", got) + } + if _, ok := hostCfg.Tmpfs["/tmp"]; !ok { + t.Fatalf("Tmpfs missing /tmp: %v", hostCfg.Tmpfs) + } + if _, ok := hostCfg.Tmpfs["/run"]; !ok { + t.Fatalf("Tmpfs missing /run: %v", hostCfg.Tmpfs) + } + if hostCfg.LogConfig.Type != "json-file" || hostCfg.LogConfig.Config["max-size"] != "20m" || hostCfg.LogConfig.Config["max-file"] != "2" { + t.Fatalf("LogConfig not preserved: %+v", hostCfg.LogConfig) + } + if hostCfg.Resources.Memory != cfg.MemoryLimit || hostCfg.Resources.CPUShares != cfg.CPUShares { + t.Fatalf("Resources not preserved: %+v", hostCfg.Resources) + } +} + func TestCanonicalTrustedProxyCIDR(t *testing.T) { t.Parallel() diff --git a/internal/cloudcp/proxytrust/client_ip.go b/internal/cloudcp/proxytrust/client_ip.go new file mode 100644 index 000000000..ddd3c4c91 --- /dev/null +++ b/internal/cloudcp/proxytrust/client_ip.go @@ -0,0 +1,128 @@ +package proxytrust + +import ( + "net" + "net/http" + "os" + "strings" + "sync" +) + +var ( + trustedProxyOnce sync.Once + trustedProxyCIDRs []*net.IPNet +) + +// ClientIP resolves the client address only through explicitly trusted proxy +// hops. Untrusted peers cannot spoof audit or rate-limit identity with XFF. +func ClientIP(r *http.Request) string { + if r == nil { + return "" + } + + remote := ExtractRemoteIP(r.RemoteAddr) + if remote == "" { + return "" + } + + if IsTrustedProxyIP(remote) { + if xff := rightMostUntrustedForwardedIP(r.Header.Get("X-Forwarded-For")); xff != "" { + return xff + } + + if realIP := strings.TrimSpace(strings.Trim(r.Header.Get("X-Real-IP"), "[]")); net.ParseIP(realIP) != nil { + return realIP + } + } + + return remote +} + +func ExtractRemoteIP(remoteAddr string) string { + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + return strings.Trim(remoteAddr, "[]") + } + return strings.Trim(host, "[]") +} + +func rightMostUntrustedForwardedIP(header string) string { + parts := strings.Split(header, ",") + var leftMostValid string + for i := len(parts) - 1; i >= 0; i-- { + candidate := strings.TrimSpace(strings.Trim(parts[i], "[]")) + if net.ParseIP(candidate) == nil { + continue + } + leftMostValid = candidate + if !IsTrustedProxyIP(candidate) { + return candidate + } + } + return leftMostValid +} + +func IsTrustedProxyIP(rawIP string) bool { + ip := net.ParseIP(strings.Trim(rawIP, "[]")) + if ip == nil { + return false + } + + trustedProxyOnce.Do(loadTrustedProxyCIDRs) + if len(trustedProxyCIDRs) == 0 { + return false + } + for _, network := range trustedProxyCIDRs { + if network.Contains(ip) { + return true + } + } + return false +} + +func loadTrustedProxyCIDRs() { + raw := strings.TrimSpace(os.Getenv("CP_TRUSTED_PROXY_CIDRS")) + if raw == "" { + // Backward-compatible fallback to the shared setting used by the app server. + raw = strings.TrimSpace(os.Getenv("PULSE_TRUSTED_PROXY_CIDRS")) + } + if raw == "" { + return + } + + for _, entry := range strings.Split(raw, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + + if strings.Contains(entry, "/") { + _, network, err := net.ParseCIDR(entry) + if err != nil { + continue + } + network.IP = network.IP.Mask(network.Mask) + trustedProxyCIDRs = append(trustedProxyCIDRs, network) + continue + } + + ip := net.ParseIP(entry) + if ip == nil { + continue + } + bits := 32 + if ip.To4() == nil { + bits = 128 + } + mask := net.CIDRMask(bits, bits) + trustedProxyCIDRs = append(trustedProxyCIDRs, &net.IPNet{ + IP: ip.Mask(mask), + Mask: mask, + }) + } +} + +func ResetForTesting() { + trustedProxyOnce = sync.Once{} + trustedProxyCIDRs = nil +} diff --git a/internal/cloudcp/ratelimit.go b/internal/cloudcp/ratelimit.go index 44b62cf5d..d562f1a3d 100644 --- a/internal/cloudcp/ratelimit.go +++ b/internal/cloudcp/ratelimit.go @@ -2,13 +2,12 @@ package cloudcp import ( "math" - "net" "net/http" - "os" "strconv" - "strings" "sync" "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/proxytrust" ) const ( @@ -16,11 +15,6 @@ const ( defaultCPRateWindow = time.Minute ) -var ( - trustedProxyOnce sync.Once - trustedProxyCIDRs []*net.IPNet -) - // CPRateLimiter provides simple IP-based rate limiting for control plane endpoints. type CPRateLimiter struct { mu sync.Mutex @@ -90,98 +84,5 @@ func (rl *CPRateLimiter) Middleware(next http.Handler) http.Handler { } func clientIP(r *http.Request) string { - remote := extractRemoteIP(r.RemoteAddr) - if remote == "" { - return "" - } - - if isTrustedProxyIP(remote) { - if xff := firstValidForwardedIP(r.Header.Get("X-Forwarded-For")); xff != "" { - return xff - } - - if realIP := strings.TrimSpace(strings.Trim(r.Header.Get("X-Real-IP"), "[]")); net.ParseIP(realIP) != nil { - return realIP - } - } - - return remote -} - -func extractRemoteIP(remoteAddr string) string { - host, _, err := net.SplitHostPort(remoteAddr) - if err != nil { - return strings.Trim(remoteAddr, "[]") - } - return strings.Trim(host, "[]") -} - -func firstValidForwardedIP(header string) string { - for _, part := range strings.Split(header, ",") { - candidate := strings.TrimSpace(strings.Trim(part, "[]")) - if net.ParseIP(candidate) != nil { - return candidate - } - } - return "" -} - -func isTrustedProxyIP(rawIP string) bool { - ip := net.ParseIP(strings.Trim(rawIP, "[]")) - if ip == nil { - return false - } - - trustedProxyOnce.Do(loadTrustedProxyCIDRs) - if len(trustedProxyCIDRs) == 0 { - return false - } - for _, network := range trustedProxyCIDRs { - if network.Contains(ip) { - return true - } - } - return false -} - -func loadTrustedProxyCIDRs() { - raw := strings.TrimSpace(os.Getenv("CP_TRUSTED_PROXY_CIDRS")) - if raw == "" { - // Backward-compatible fallback to the shared setting used by the app server. - raw = strings.TrimSpace(os.Getenv("PULSE_TRUSTED_PROXY_CIDRS")) - } - if raw == "" { - return - } - - for _, entry := range strings.Split(raw, ",") { - entry = strings.TrimSpace(entry) - if entry == "" { - continue - } - - if strings.Contains(entry, "/") { - _, network, err := net.ParseCIDR(entry) - if err != nil { - continue - } - network.IP = network.IP.Mask(network.Mask) - trustedProxyCIDRs = append(trustedProxyCIDRs, network) - continue - } - - ip := net.ParseIP(entry) - if ip == nil { - continue - } - bits := 32 - if ip.To4() == nil { - bits = 128 - } - mask := net.CIDRMask(bits, bits) - trustedProxyCIDRs = append(trustedProxyCIDRs, &net.IPNet{ - IP: ip.Mask(mask), - Mask: mask, - }) - } + return proxytrust.ClientIP(r) } diff --git a/internal/cloudcp/ratelimit_test.go b/internal/cloudcp/ratelimit_test.go index fc5c0c4f5..a75cdebc4 100644 --- a/internal/cloudcp/ratelimit_test.go +++ b/internal/cloudcp/ratelimit_test.go @@ -3,14 +3,14 @@ package cloudcp import ( "net/http" "net/http/httptest" - "sync" "testing" "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/proxytrust" ) func resetTrustedProxyConfig() { - trustedProxyOnce = sync.Once{} - trustedProxyCIDRs = nil + proxytrust.ResetForTesting() } func TestCPRateLimiterAllow_WithinLimitThenRejects(t *testing.T) { @@ -88,8 +88,8 @@ func TestClientIP(t *testing.T) { } }) - t.Run("x-forwarded-for-first-value", func(t *testing.T) { - t.Setenv("CP_TRUSTED_PROXY_CIDRS", "127.0.0.1/32") + t.Run("x-forwarded-for-right-most-untrusted-hop", func(t *testing.T) { + t.Setenv("CP_TRUSTED_PROXY_CIDRS", "127.0.0.1/32,10.0.0.0/8") resetTrustedProxyConfig() req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("X-Forwarded-For", " 203.0.113.1 , 10.0.0.1 ") diff --git a/scripts/installtests/provider_msp_deploy_test.go b/scripts/installtests/provider_msp_deploy_test.go index 739c492c9..627d4b538 100644 --- a/scripts/installtests/provider_msp_deploy_test.go +++ b/scripts/installtests/provider_msp_deploy_test.go @@ -25,15 +25,28 @@ func TestProviderMSPDeployComposeIsProviderModeAndStripeFree(t *testing.T) { "CP_DATA_DIR=${PULSE_PROVIDER_MSP_DATA_DIR:-/data}", "CP_PROVIDER_MSP_LICENSE_FILE=/run/secrets/provider_msp_license", "CP_DOCKER_NETWORK=${PULSE_PROVIDER_MSP_DOCKER_NETWORK:-pulse-provider-msp}", + "CP_TRUSTED_PROXY_CIDRS=${CP_TRUSTED_PROXY_CIDRS}", + "DOCKER_HOST=tcp://docker-socket-proxy:2375", "CP_STORAGE_DATA_PATH=${PULSE_PROVIDER_MSP_DATA_DIR:-/data}", + "CP_STORAGE_ROOT_PATH=/storage-root-spacecheck", + "CP_STORAGE_DOCKER_PATH=/storage-docker-spacecheck", "${PULSE_PROVIDER_MSP_DATA_DIR:-/data}:${PULSE_PROVIDER_MSP_DATA_DIR:-/data}", - "${PULSE_PROVIDER_MSP_DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock", + "DOCKER_SOCKET_PROXY_IMAGE", + "PING=1", + "VERSION=1", + "INFO=1", + "${PULSE_PROVIDER_MSP_DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:ro", + "${PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR:-/var/lib/pulse-provider-msp/spacecheck/root}:/storage-root-spacecheck:ro", + "${PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR:-/var/lib/docker/.pulse-provider-msp-spacecheck}:/storage-docker-spacecheck:ro", "pulse.provider-msp.role=traefik", "pulse.provider-msp.role=control-plane", "provider_msp_license:", "name: ${PULSE_PROVIDER_MSP_DOCKER_NETWORK:-pulse-provider-msp}", + "subnet: ${PULSE_PROVIDER_MSP_DOCKER_SUBNET:-172.30.0.0/24}", ) assertNotContainsAny(t, text, + ":/host-root", + ":/host-var-lib-docker", "STRIPE_", "CP_TRIAL_SIGNUP_PRICE_ID", "CP_MSP_STARTER_PRICE_ID", @@ -53,9 +66,11 @@ func TestProviderMSPDeployEnvExampleMatchesBootstrapPath(t *testing.T) { "CP_ENV=production", "PULSE_PROVIDER_MSP_DATA_DIR=/data", "PULSE_PROVIDER_MSP_DOCKER_NETWORK=pulse-provider-msp", + "PULSE_PROVIDER_MSP_DOCKER_SUBNET=172.30.0.0/24", "PULSE_PROVIDER_MSP_DOCKER_SOCKET=/var/run/docker.sock", - "PULSE_PROVIDER_MSP_HOST_ROOT=/", - "PULSE_PROVIDER_MSP_DOCKER_DATA_DIR=/var/lib/docker", + "PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR=/var/lib/pulse-provider-msp/spacecheck/root", + "PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR=/var/lib/docker/.pulse-provider-msp-spacecheck", + "CP_TRUSTED_PROXY_CIDRS=172.30.0.0/24", "CP_PROVIDER_MSP_LICENSE_FILE=./provider-msp-license.jwt", "CP_TRIAL_ACTIVATION_PRIVATE_KEY=", "sudo -E ./setup.sh", @@ -117,21 +132,28 @@ func TestProviderMSPSetupScriptMatchesProviderContract(t *testing.T) { "CP_TRIAL_ACTIVATION_PRIVATE_KEY", "PULSE_PROVIDER_MSP_DATA_DIR", "PULSE_PROVIDER_MSP_DOCKER_NETWORK", + "PULSE_PROVIDER_MSP_DOCKER_SUBNET", "PULSE_PROVIDER_MSP_DOCKER_SOCKET", - "PULSE_PROVIDER_MSP_HOST_ROOT", - "PULSE_PROVIDER_MSP_DOCKER_DATA_DIR", + "PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR", + "PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR", + "CP_TRUSTED_PROXY_CIDRS", + "DOCKER_SOCKET_PROXY_IMAGE", "must be an absolute path", "must point to a reachable Docker socket", - "must point to the host Docker data directory", + "CP_ADMIN_KEY must be at least 32 characters", + "CP_TRUSTED_PROXY_CIDRS must include PULSE_PROVIDER_MSP_DOCKER_SUBNET", + "169.254.169.254/32", + "will be created by compose with subnet", "must not be configured in provider-hosted MSP mode", "CP_ALLOW_DOCKERLESS_PROVISIONING must be false", "CP_STORAGE_GUARDRAILS_ENABLED must be true", "docker compose config --quiet", - "docker compose pull traefik control-plane", + "docker compose pull traefik docker-socket-proxy control-plane", "PULSE_PROVIDER_MSP_ACCOUNT_NAME", "PULSE_PROVIDER_MSP_OWNER_EMAIL", "./run-install-proof.sh", ) + assertNotContainsAny(t, text, "docker network create --subnet") } func TestProviderMSPUpgradeRunnerMatchesComposeContract(t *testing.T) { @@ -169,8 +191,8 @@ func TestProviderMSPUpgradeRunnerMatchesComposeContract(t *testing.T) { "--run-id", "--health-timeout", "--prune-previous", - "docker compose pull traefik control-plane", - "docker compose up -d traefik control-plane", + "docker compose pull traefik docker-socket-proxy control-plane", + "docker compose up -d traefik docker-socket-proxy control-plane", "docker compose run --rm --no-deps control-plane", "provider_msp_upgrade_ok=true", "tenant_runtime_rollout_applied=true", @@ -196,8 +218,8 @@ func TestProviderMSPInstallProofRunnerMatchesComposeContract(t *testing.T) { assertContainsAll(t, text, "docker compose config --quiet", "docker version >/dev/null", - "docker compose pull traefik control-plane", - "docker compose up -d traefik", + "docker compose pull traefik docker-socket-proxy control-plane", + "docker compose up -d traefik docker-socket-proxy", "provider-msp install-proof", "--account-name", "--owner-email", @@ -207,7 +229,7 @@ func TestProviderMSPInstallProofRunnerMatchesComposeContract(t *testing.T) { "--skip-image-pull", "${#extra_install_args[@]}", "docker compose run --rm --no-deps control-plane", - "docker compose up -d traefik control-plane", + "docker compose up -d traefik docker-socket-proxy control-plane", "provider-msp status", ) assertNotContainsAny(t, text, diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 69bd9c340..7ff6bedbe 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -2860,7 +2860,7 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 346, + "line": 349, "heading_line": 113, } ],