mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -9,3 +9,9 @@ http:
|
||||
contentTypeNosniff: true
|
||||
browserXssFilter: true
|
||||
referrerPolicy: "strict-origin-when-cross-origin"
|
||||
|
||||
tls:
|
||||
options:
|
||||
default:
|
||||
minVersion: VersionTLS12
|
||||
sniStrict: true
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user