mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Keep the operator .env out of the Traefik container and unhardcode the DNS-01 provider
Traefik took the whole operator .env via env_file, so the internet-facing container's environment carried CP_ADMIN_KEY and the entitlement signing private key, neither of which it needs. It now receives only ACME/DNS material: the CF token by explicit passthrough plus an optional dns-credentials.env that setup.sh creates 0600. The same wiring removes the silent Cloudflare-only narrowing: the resolver provider is now ACME_DNS_PROVIDER (default cloudflare) via Traefik's env override, the CF token is required only for the default provider, and any other Traefik dnsChallenge provider supplies its credential variables through dns-credentials.env. Pinned by TestProviderMSPTraefikEnvIsMinimalAndDNSProviderOverridable; contract delta in deployment-installability.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user