diff --git a/deploy/provider-msp/.env.example b/deploy/provider-msp/.env.example index 62c92b49a..d095e97c6 100644 --- a/deploy/provider-msp/.env.example +++ b/deploy/provider-msp/.env.example @@ -8,6 +8,13 @@ ACME_EMAIL=admin@example.com # Cloudflare DNS-01 wildcard TLS CF_DNS_API_TOKEN= +# DNS-01 provider for the wildcard certificate. cloudflare is the default and +# uses CF_DNS_API_TOKEN above. Any Traefik dnsChallenge provider name works; +# for a non-Cloudflare provider, set it here and put that provider's +# credential variables in dns-credentials.env, which reaches only the traefik +# container. +ACME_DNS_PROVIDER=cloudflare + # Image pins. Leave blank and setup.sh resolves each one to an immutable # digest from its published tag, then writes the digest back here. All four # images are public, so this needs no registry credentials. diff --git a/deploy/provider-msp/docker-compose.yml b/deploy/provider-msp/docker-compose.yml index 5bbf13bde..f35718eaf 100644 --- a/deploy/provider-msp/docker-compose.yml +++ b/deploy/provider-msp/docker-compose.yml @@ -9,10 +9,16 @@ services: - ./traefik.yml:/etc/traefik/traefik.yml:ro - ./traefik-dynamic.yml:/etc/traefik/traefik-dynamic.yml:ro - acme-data:/etc/traefik/acme + # The edge container gets only ACME/DNS material, never the operator .env: + # CP_ADMIN_KEY and the entitlement signing key must not sit in the + # internet-facing container's environment. env_file: - - .env + - path: ./dns-credentials.env + required: false environment: - - CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN} + - CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN:-} + - TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_DNSCHALLENGE_PROVIDER=${ACME_DNS_PROVIDER:-cloudflare} + - TRAEFIK_CERTIFICATESRESOLVERS_LE_ACME_DNSCHALLENGE_PROVIDER=${ACME_DNS_PROVIDER:-cloudflare} - TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_EMAIL=${ACME_EMAIL} - TRAEFIK_CERTIFICATESRESOLVERS_LE_ACME_EMAIL=${ACME_EMAIL} networks: diff --git a/deploy/provider-msp/setup.sh b/deploy/provider-msp/setup.sh index 7e0546227..9ea8edf59 100755 --- a/deploy/provider-msp/setup.sh +++ b/deploy/provider-msp/setup.sh @@ -504,7 +504,9 @@ Created ${env_path} from .env.example. Edit it now and set required values: - DOMAIN - ACME_EMAIL - - CF_DNS_API_TOKEN + - CF_DNS_API_TOKEN (with the default ACME_DNS_PROVIDER=cloudflare; for any + other Traefik dnsChallenge provider, set ACME_DNS_PROVIDER and put that + provider's credential variables in dns-credentials.env) - TRAEFIK_IMAGE (digest pinned) - DOCKER_SOCKET_PROXY_IMAGE (digest pinned) - CONTROL_PLANE_IMAGE (digest pinned) @@ -529,6 +531,29 @@ EOF fi } +# Traefik is the only container that needs DNS-01 credentials, and it must not +# receive the operator .env (that holds CP_ADMIN_KEY and the entitlement +# signing private key). Non-Cloudflare providers put their credential +# variables here; compose injects the file into the traefik container alone. +ensure_dns_credentials_file() { + local creds_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/dns-credentials.env" + if [[ -f "${creds_path}" ]]; then + chmod 0600 "${creds_path}" || true + return 0 + fi + cat > "${creds_path}" <<'EOF' +# Credential variables for the ACME DNS-01 provider, injected only into the +# traefik container. With the default ACME_DNS_PROVIDER=cloudflare this file +# stays empty; CF_DNS_API_TOKEN in .env is passed through directly. For any +# other provider, set ACME_DNS_PROVIDER in .env to the Traefik dnsChallenge +# provider name and put that provider's variables here, e.g. for route53: +# AWS_ACCESS_KEY_ID=... +# AWS_SECRET_ACCESS_KEY=... +# AWS_REGION=... +EOF + chmod 0600 "${creds_path}" +} + validate_env_file() { local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env" [[ -f "${env_path}" ]] || die "missing ${env_path}" @@ -542,7 +567,7 @@ validate_env_file() { local missing=() local k v - 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_ENTITLEMENT_SIGNING_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 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_ENTITLEMENT_SIGNING_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}") @@ -552,6 +577,20 @@ validate_env_file() { die "missing required values in ${env_path}: ${missing[*]}" fi + local dns_provider creds_path + dns_provider="$(env_value ACME_DNS_PROVIDER "${env_path}")" + dns_provider="${dns_provider:-cloudflare}" + if [[ "${dns_provider}" == "cloudflare" ]]; then + if [[ -z "$(env_value CF_DNS_API_TOKEN "${env_path}")" ]]; then + die "CF_DNS_API_TOKEN is required with the default ACME_DNS_PROVIDER=cloudflare; for another provider set ACME_DNS_PROVIDER to a Traefik dnsChallenge provider name and put its credential variables in dns-credentials.env" + fi + else + creds_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/dns-credentials.env" + if [[ ! -f "${creds_path}" ]] || ! grep -Eq '^[A-Za-z_][A-Za-z0-9_]*=.+' "${creds_path}"; then + die "ACME_DNS_PROVIDER=${dns_provider}: put that provider's credential variables in ${creds_path} (see Traefik's dnsChallenge provider table for the variable names)" + fi + fi + cp_env="$(env_value CP_ENV "${env_path}" | tr '[:upper:]' '[:lower:]')" if [[ "${cp_env}" != "${expected_env}" ]]; then die "CP_ENV must be '${expected_env}' for this setup run (got '${cp_env}')" @@ -752,6 +791,7 @@ main() { install_ops_tools install_deploy_bundle ensure_env_file + ensure_dns_credentials_file ensure_generated_secrets # After the signing key exists, since the license binds its public half. ensure_eval_license diff --git a/docs/MSP.md b/docs/MSP.md index 45fa5e594..a03816092 100644 --- a/docs/MSP.md +++ b/docs/MSP.md @@ -274,9 +274,11 @@ cd Pulse-main/deploy/provider-msp sudo -E ./setup.sh ``` -The host needs Ubuntu 24.04 or similar, a domain whose DNS is managed through -Cloudflare (the wildcard certificate uses a Cloudflare DNS token), and ports -80 and 443 free. +The host needs Ubuntu 24.04 or similar, a domain you can point at it, and +ports 80 and 443 free. The wildcard certificate is issued over DNS-01 with +Cloudflare as the default provider (`CF_DNS_API_TOKEN`); any other Traefik +dnsChallenge provider works by setting `ACME_DNS_PROVIDER` in `.env` and +putting that provider's credential variables in `dns-credentials.env`. Leave `CP_PROVIDER_MSP_LICENSE_FILE` blank and `setup.sh` self-issues a 2-client evaluation licence for you. It sends only the public half of the diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index fc92ff2e1..17bd00001 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -247,6 +247,15 @@ upgrade, update, release, or artifact-selection behavior. 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. + The packaged edge wiring must keep the operator `.env` out of the Traefik + container: Traefik's environment carries only ACME/DNS material (the + Cloudflare token passthrough plus the `dns-credentials.env` file that + `setup.sh` creates with 0600 permissions), never `CP_ADMIN_KEY` or + `CP_ENTITLEMENT_SIGNING_PRIVATE_KEY`. The wildcard-TLS DNS-01 provider is + operator-configurable through `ACME_DNS_PROVIDER` (default `cloudflare`): + with the default provider `CF_DNS_API_TOKEN` is required; with any other + Traefik dnsChallenge provider, setup must fail closed until that provider's + credential variables are present in `dns-credentials.env`. The setup summary must leave the operator on a working next step, not a dead end: it must print the `provider-msp bootstrap` command that creates the operator account and portal sign-in link, and the day-2 sign-in diff --git a/scripts/installtests/provider_msp_deploy_test.go b/scripts/installtests/provider_msp_deploy_test.go index 931c01bae..e91b034a8 100644 --- a/scripts/installtests/provider_msp_deploy_test.go +++ b/scripts/installtests/provider_msp_deploy_test.go @@ -78,6 +78,7 @@ func TestProviderMSPDeployEnvExampleMatchesBootstrapPath(t *testing.T) { // asserted in TestProviderMSPSetupScriptSupportsUnlicensedEvaluation. "CP_PROVIDER_MSP_LICENSE_FILE=", "CP_ENTITLEMENT_SIGNING_PRIVATE_KEY=", + "ACME_DNS_PROVIDER=cloudflare", "sudo -E ./setup.sh", "docker compose run --rm control-plane provider-msp bootstrap", "docker compose run --rm control-plane provider-msp portal-link", @@ -395,3 +396,73 @@ func TestProviderMSPSetupScriptSupportsUnlicensedEvaluation(t *testing.T) { } } } + +// Traefik terminates TLS at the internet edge. Handing it the whole operator +// .env put CP_ADMIN_KEY and the entitlement signing private key one edge CVE +// away from disclosure, so the compose contract pins the minimal wiring: only +// ACME/DNS material reaches the traefik container, and the DNS-01 provider is +// overridable for operators whose DNS is not on Cloudflare. +func TestProviderMSPTraefikEnvIsMinimalAndDNSProviderOverridable(t *testing.T) { + composeBytes, err := os.ReadFile(repoFile("deploy", "provider-msp", "docker-compose.yml")) + if err != nil { + t.Fatalf("read provider MSP compose: %v", err) + } + var compose struct { + Services map[string]struct { + EnvFile any `yaml:"env_file"` + } `yaml:"services"` + } + if err := yaml.Unmarshal(composeBytes, &compose); err != nil { + t.Fatalf("provider MSP compose must be valid YAML: %v", err) + } + traefik, ok := compose.Services["traefik"] + if !ok { + t.Fatal("compose must define a traefik service") + } + var envFiles []string + switch v := traefik.EnvFile.(type) { + case nil: + case string: + envFiles = append(envFiles, v) + case []any: + for _, entry := range v { + switch e := entry.(type) { + case string: + envFiles = append(envFiles, e) + case map[string]any: + if p, _ := e["path"].(string); p != "" { + envFiles = append(envFiles, p) + } + } + } + } + for _, f := range envFiles { + if strings.HasSuffix(f, ".env") && !strings.HasSuffix(f, "dns-credentials.env") { + t.Fatalf("traefik env_file %q would leak the operator .env (CP_ADMIN_KEY, entitlement signing key) into the edge container", f) + } + } + + text := string(composeBytes) + assertContainsAll(t, text, + "TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_DNSCHALLENGE_PROVIDER=${ACME_DNS_PROVIDER:-cloudflare}", + "TRAEFIK_CERTIFICATESRESOLVERS_LE_ACME_DNSCHALLENGE_PROVIDER=${ACME_DNS_PROVIDER:-cloudflare}", + "CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN:-}", + "path: ./dns-credentials.env", + "required: false", + ) + + scriptBytes, err := os.ReadFile(repoFile("deploy", "provider-msp", "setup.sh")) + if err != nil { + t.Fatalf("read provider MSP setup: %v", err) + } + assertContainsAll(t, string(scriptBytes), + "ensure_dns_credentials_file", + "CF_DNS_API_TOKEN is required with the default ACME_DNS_PROVIDER=cloudflare", + "put that provider's credential variables in", + ) + // CF_DNS_API_TOKEN must not return to the unconditional required list; it + // is only required when ACME_DNS_PROVIDER resolves to cloudflare. + if strings.Contains(string(scriptBytes), "ACME_EMAIL CF_DNS_API_TOKEN CP_ENV") { + t.Fatal("CF_DNS_API_TOKEN is back in the unconditional required-env list; it must be required only when ACME_DNS_PROVIDER is cloudflare") + } +}