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.
This commit is contained in:
rcourtman
2026-06-02 21:04:21 +01:00
parent 68e3a015c0
commit c7e50d5602
27 changed files with 725 additions and 209 deletions
+5 -2
View File
@@ -7,6 +7,7 @@ CF_DNS_API_TOKEN=
# Image pins; use immutable digest refs in production
TRAEFIK_IMAGE=traefik@sha256:<pin>
DOCKER_SOCKET_PROXY_IMAGE=tecnativa/docker-socket-proxy@sha256:<pin>
CONTROL_PLANE_IMAGE=ghcr.io/rcourtman/pulse-control-plane@sha256:<pin>
CP_PULSE_IMAGE=ghcr.io/rcourtman/pulse@sha256:<pin>
@@ -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
+29 -5
View File
@@ -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:
+5 -5
View File
@@ -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
+120 -17
View File
@@ -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:-<none>}; 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}" == *"<pin>"* ]]; 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
+6
View File
@@ -9,3 +9,9 @@ http:
contentTypeNosniff: true
browserXssFilter: true
referrerPolicy: "strict-origin-when-cross-origin"
tls:
options:
default:
minVersion: VersionTLS12
sniStrict: true
+3 -3
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
<CP_PULSE_IMAGE>` 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
@@ -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.
@@ -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
+19
View File
@@ -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)
+7 -1
View File
@@ -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 {
+42
View File
@@ -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()
+7
View File
@@ -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{}{
-5
View File
@@ -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())
+3 -21
View File
@@ -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.
+19 -9
View File
@@ -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",
},
{
+26 -9
View File
@@ -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",
@@ -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)
}
}
+36
View File
@@ -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()
+128
View File
@@ -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
}
+3 -102
View File
@@ -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)
}
+5 -5
View File
@@ -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 ")
@@ -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,
@@ -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,
}
],