Skip to content

feat(init): detect CDN and offer Cloudflare edge-enforcer setup — closes #43#44

Open
evertramos wants to merge 1 commit into
devfrom
feat/issue-43-init-cdn-detect
Open

feat(init): detect CDN and offer Cloudflare edge-enforcer setup — closes #43#44
evertramos wants to merge 1 commit into
devfrom
feat/issue-43-init-cdn-detect

Conversation

@evertramos

Copy link
Copy Markdown
Owner

What & why

Closes #43.

An ezyshield install behind Cloudflare (or any CDN) is silently
ineffective for CDN-fronted domains — nftables sees only CDN IPs at the
TCP layer, so bans on the client IP never match. The wizard used to say
nothing about this; the operator discovered the gap after issuing a ban
and watching the browser continue to load the site (typical live triage
on the dogfood host, 2026-07-05).

This PR wires detection + the Cloudflare subflow into ezyshield init,
placed between the SSH/admin block and the AI block (so the loud-skip
warning is visible before the operator commits to any downstream config).

Acceptance criteria from the issue:

  • Wizard detects nginx-proxy VIRTUAL_HOST domains and classifies
    their DNS answers against an embedded Cloudflare range table.
  • Choosing "lists" writes enforce.cloudflare to config.yaml, the
    CF token to /etc/ezyshield/.env at 0600 root:ezyshield, and
    prints the exact WAF Custom Rule expression to paste ((ip.src in $<list_name>)).
  • Choosing "rulesets" prompts for zone IDs, validates each is 32
    lowercase hex, writes them under enforce.cloudflare.zone_ids.
  • Token is dry-validated via GET /accounts/{id} (lists) or GET /zones/{id} (rulesets) before config is written; 401/403 refuses
    to write and prints the required scope + upstream error message.
  • Selecting "skip" when a CDN was detected prints the loud per-
    domain warning to the wizard sink (issue init: detect CDN in front of origin and offer edge-enforcer setup (Cloudflare first) #43 §3 wording).
  • Unit tests: detection (mocked resolver), vhost enumeration (mocked
    Docker CLI), wizard step (mocked prompt + CF HTTP client) covering
    happy path lists, happy path rulesets, scope mismatch (401),
    transient CF error, loud skip on detected CDN.
  • Existing init tests still green (TestInit_PromptsForToken…,
    TestInit_ConfigYAMLGetsFixedReference, TestInit_WizardState_ StringRedactsToken).

Changes

  • internal/cdndetect/ — new package. Compile-time embedded
    ranges.yaml (Cloudflare v4+v6 populated from the official
    cloudflare.com/ips-v4 / ips-v6 endpoints; Bunny / Fastly / AWS
    CloudFront / Akamai / GCP CDN / Azure Front Door shipped as empty
    stubs with source-URL comments so a maintainer can populate them in a
    data-only PR later). Pure Resolver interface for testability. No
    I/O at package init beyond the embed decode.
  • internal/vhostdetect/ — new package. Enumerates docker ps +
    docker inspect to find containers with VIRTUAL_HOST= env (the
    nginx-proxy convention). Best-effort: Docker down / no containers
    returns (nil, nil) so the wizard keeps going. Uses the same docker
    CLI shell-out pattern already established in cmd/ezyshield/init.go
    (detectDockerContainers) rather than dialing the Engine API to keep
    the wizard footprint identical.
  • cmd/ezyshield/init_cdn.go — the wizard step. Detects, prompts,
    optionally runs the Cloudflare subflow, dry-validates the token, and
    prints the loud-skip warning. Reuses tokenReader (issue config: ask user for AI API token directly, store in /etc/ezyshield/.env (supersedes #22, #27) #13's
    masked-tty path) so the CF token never touches stdout or bufio-on-
    stdin. Adds a merge-aware .env writer so the CF token and the AI key
    coexist in the same file (writeCloudflareEnvFile).
  • cmd/ezyshield/init.go — three touch-ups: wizardState.cdn
    field, runCDNStep call in askQuestions, and the enforce: block
    now opens whenever either nftables or CF is configured (previously
    only when nftables was).
  • Test updates — the two existing AI-key tests that drive
    askQuestions now feed one extra blank line for the new
    "Does this server sit behind a CDN?" question (no vhosts detected in
    test mode → falls through to the generic prompt).

Test plan

  • go test -race -count=1 ./... — all packages green (see CI).
  • go vet ./... clean.
  • gofmt -l clean (checked via go fmt ./...).

Cloudflare subflow shape (one-paragraph summary)

The subflow defaults to lists mode when the operator presses ENTER,
matching the issue's recommendation for "multi-zone / high volume". Both
modes are supported; the operator picks by typing lists or rulesets.
The token env-var NAME is fixed (CLOUDFLARE_API_TOKEN) — following the
issue #13 precedent — so we never prompt for the NAME and never risk the
paste-mistake failure mode. Dry validation issues one HTTP GET
(/accounts/{id} or /zones/{id}) with Authorization: Bearer … and
refuses to write config on 401/403; the scope-mismatch message names the
exact CF scope the operator needs. On success, lists mode also prints
the ready-to-paste WAF rule expression (ip.src in $<list_name>).

Deferred to follow-up issues

The detection table has stubs for Bunny, Fastly, AWS CloudFront, Akamai,
Google Cloud CDN, and Azure Front Door — populated ranges + wiring the
init subflow for each will land in per-provider issues once the
respective enforcer exists (Bunny is closest — see
internal/enforce/bunny*.go; the others need enforcer work first).
Per-domain routing of bans to specific enforcers remains an explicit
non-goal of this PR.

Security review (per docs/SECURITY-REVIEW.md)

  • §1 Input handling (hostile logs): N/A — this PR reads
    operator-typed prompts and Docker-owned env vars (VIRTUAL_HOST), not
    log lines. The one attacker-adjacent surface is the Cloudflare API
    response, whose errors[0].message is echoed on validation failure;
    it goes through sanitizeErrorMessage() which strips ASCII control
    chars and caps at 200 bytes to prevent ANSI/terminal-forge injection.
  • §2 Decision engine (lock-out / false-ban): N/A — this PR does not
    touch the decision engine, policy, allowlist, or the ban path. It
    configures an enforcer at init time; the enforcer itself is unchanged.
  • §3 Privilege separation / enforcer: OK — no new listeners, no new
    privileged verbs. Wizard runs as root (same as today), writes the CF
    token to /etc/ezyshield/.env at 0600 root:ezyshield via
    applyDaemonOwnership (already tested elsewhere). Config yaml keeps
    the SecretRef env:VARNAME shape so LoadConfig refuses an inline
    value if a maintainer later hand-edits it.
  • §4 Secrets: OK — token never appears in stdout, stderr, log, or
    error message. Prompt is served via tokenReader (the /dev/tty
    masked-read path from issue config: ask user for AI API token directly, store in /etc/ezyshield/.env (supersedes #22, #27) #13); the raw token lives in
    cdnStep.cfToken between prompt and file write, mirroring
    wizardState.aiToken. cdnStep.String() masks it. writeCloudflare EnvFile prints wrote/kept — never the value. dryValidateCFToken
    builds error messages from the CF endpoint URL (which contains only
    the account/zone ID, both validated to [a-f0-9]{32}) and the sanitized
    upstream message — never the token, never wrapped with %v on the
    request. CF token is documented as least-privilege: lists mode
    needs Account:Account Filter Lists:Edit on one account; rulesets
    mode needs Zone:Firewall:Edit on each listed zone — the wizard
    refuses to proceed if the scope check (GET /accounts/{id} or GET /zones/{id}) returns 401/403 and prints the exact required scope.
  • §5 AI / prompt-injection boundary: N/A — this PR does not send
    anything to any AI provider.
  • §6 Control surfaces (socket/dashboard): N/A — no new sockets, no
    dashboard change.
  • §7 Plugins: N/A.
  • §8 Edge / external APIs: OK — one outbound HTTPS request per
    init run (dry validation). Uses http.NewRequestWithContext +
    http.Client{Timeout: 8s} (stdlib defaults so cert verification is on;
    no InsecureSkipVerify); context cancellation is honored. Response
    body is bounded to 4 KiB before JSON-decoding. 4xx/5xx are caught
    explicitly and refuse to write config (fail-safe). No SSRF risk —
    the URL is built from api.cloudflare.com/client/v4 + operator-typed
    ID that has already been validated to [a-f0-9]{32}; the base URL is
    a compile-time constant except in tests.
  • §9 Dependencies / supply chain: OK — no new module dependency
    added (internal/cdndetect uses only stdlib + already-present
    gopkg.in/yaml.v3).
  • §10 Logging / audit / fail-safe: OK — init is not a state-changing
    daemon action (no audit table involved). Any failure in detection,
    vhost enumeration, or Cloudflare validation is non-fatal for the
    wizard: the CDN step returns and the operator sees the loud-skip
    warning if there was a CDN match. §10 code-quality self-review walked
    on every new function; the dead-code check caught one unreachable
    branch (a name != "" regex validation on a hardcoded-"" variable)
    which is removed in this PR.

Self-assessment: does this change give an attacker a step toward
lock-out / injection / privilege-escalation / secret-exfil / evasion?
No. The largest new surface is the operator-typed Cloudflare token,
which is bounded by (a) the masked /dev/tty read (never on stdout /
stdin), (b) the redacted String() on cdnStep (safe under %+v), (c)
the 0600 root:ezyshield file write, and (d) the wizard refusing to
write config if the token doesn't scope-validate. Detection input
(domain names, DNS answers, CF API JSON) is treated as untrusted and
either regex-validated at prompt time or sanitized before display.

Checklist

  • Follows AGENTS.md Hard Rules (no new listeners, no SecretRef
    inline value, dry-run default unchanged, allowlist untouched).
  • No hardening systemd directive removed.
  • New dependency justified: none added.
  • Docs: this file — the wizard prints its own guidance to the
    operator; PLAN/ARCHITECTURE unchanged (no interface change).

Note on PR size

At ~2.7k LOC this is above the ~400-line target in AGENTS.md. About
1.4k of that is the internal/cdndetect + internal/vhostdetect package
scaffolding + tests (both freshly-created packages with table-driven
coverage, per the issue's explicit ask), 866 LOC is the wizard step
itself (again with tests separated), and the actual diff into existing
files is 55 lines in init.go + 2 lines in the AI test. Splitting the
packages into a separate infra PR was considered and discarded — the
packages have no consumer outside the init wizard yet, so they would
merge as dead code.

🤖 Generated with Claude Code

Adds a CDN-detection + edge-enforcer subflow to the init wizard so an
operator running behind a CDN discovers the ineffectiveness of local
nftables bans at install time — not 30 minutes into a live incident.

- internal/cdndetect: embedded ranges.yaml (Cloudflare v4+v6 populated;
  Bunny/Fastly/AWS/Akamai/GCP/Azure stubs to keep the table shape stable),
  Resolver interface, MatchDomains + tests.
- internal/vhostdetect: nginx-proxy VIRTUAL_HOST enumerator via the
  `docker` CLI, best-effort (Docker down != wizard failure) + tests.
- cmd/ezyshield: wizard step between the SSH/admin block and AI, offering
  the Cloudflare enforcer (lists / rulesets), dry-validating the token
  against CF API, writing `env:CLOUDFLARE_API_TOKEN` to /etc/ezyshield/.env
  at 0600 root:ezyshield (merged with the AI key so both survive), and
  printing a loud per-domain warning on skip.

Closes #43

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 6, 2026 00:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@amazon-q-developer amazon-q-developer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR successfully implements CDN detection and Cloudflare edge enforcer configuration for the init wizard. The implementation is comprehensive, well-tested, and follows security best practices. The code correctly handles token masking, input validation, and fail-safe error handling. All acceptance criteria from issue #43 are met, and the security review is thorough.


You can now have the agent implement changes and create commits directly on your pull request's source branch. Simply comment with /q followed by your request in natural language to ask the agent to make changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants