feat(init): detect CDN and offer Cloudflare edge-enforcer setup — closes #43#44
Open
evertramos wants to merge 1 commit into
Open
feat(init): detect CDN and offer Cloudflare edge-enforcer setup — closes #43#44evertramos wants to merge 1 commit into
evertramos wants to merge 1 commit into
Conversation
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>
Contributor
There was a problem hiding this comment.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Closes #43.
An
ezyshieldinstall behind Cloudflare (or any CDN) is silentlyineffective 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:
VIRTUAL_HOSTdomains and classifiestheir DNS answers against an embedded Cloudflare range table.
enforce.cloudflaretoconfig.yaml, theCF token to
/etc/ezyshield/.envat0600 root:ezyshield, andprints the exact WAF Custom Rule expression to paste (
(ip.src in $<list_name>)).lowercase hex, writes them under
enforce.cloudflare.zone_ids.GET /accounts/{id}(lists) orGET /zones/{id}(rulesets) before config is written; 401/403 refusesto write and prints the required scope + upstream error message.
domain warning to the wizard sink (issue init: detect CDN in front of origin and offer edge-enforcer setup (Cloudflare first) #43 §3 wording).
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.
TestInit_PromptsForToken…,TestInit_ConfigYAMLGetsFixedReference,TestInit_WizardState_ StringRedactsToken).Changes
internal/cdndetect/— new package. Compile-time embeddedranges.yaml(Cloudflare v4+v6 populated from the officialcloudflare.com/ips-v4/ips-v6endpoints; Bunny / Fastly / AWSCloudFront / 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
Resolverinterface for testability. NoI/O at package init beyond the embed decode.
internal/vhostdetect/— new package. Enumeratesdocker ps+docker inspectto find containers withVIRTUAL_HOST=env (thenginx-proxy convention). Best-effort: Docker down / no containers
returns
(nil, nil)so the wizard keeps going. Uses the samedockerCLI shell-out pattern already established in
cmd/ezyshield/init.go(
detectDockerContainers) rather than dialing the Engine API to keepthe 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'smasked-tty path) so the CF token never touches stdout or bufio-on-
stdin. Adds a merge-aware
.envwriter so the CF token and the AI keycoexist in the same file (
writeCloudflareEnvFile).cmd/ezyshield/init.go— three touch-ups:wizardState.cdnfield,
runCDNStepcall inaskQuestions, and theenforce:blocknow opens whenever either nftables or CF is configured (previously
only when nftables was).
askQuestionsnow 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 -lclean (checked viago 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
listsorrulesets.The token env-var NAME is fixed (
CLOUDFLARE_API_TOKEN) — following theissue #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}) withAuthorization: Bearer …andrefuses 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)
operator-typed prompts and Docker-owned env vars (
VIRTUAL_HOST), notlog lines. The one attacker-adjacent surface is the Cloudflare API
response, whose
errors[0].messageis echoed on validation failure;it goes through
sanitizeErrorMessage()which strips ASCII controlchars and caps at 200 bytes to prevent ANSI/terminal-forge injection.
touch the decision engine, policy, allowlist, or the ban path. It
configures an enforcer at init time; the enforcer itself is unchanged.
privileged verbs. Wizard runs as root (same as today), writes the CF
token to
/etc/ezyshield/.envat0600 root:ezyshieldviaapplyDaemonOwnership(already tested elsewhere). Config yaml keepsthe
SecretRefenv:VARNAMEshape soLoadConfigrefuses an inlinevalue if a maintainer later hand-edits it.
error message. Prompt is served via
tokenReader(the /dev/ttymasked-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.cfTokenbetween prompt and file write, mirroringwizardState.aiToken.cdnStep.String()masks it.writeCloudflare EnvFileprintswrote/kept— never the value.dryValidateCFTokenbuilds error messages from the CF endpoint URL (which contains only
the account/zone ID, both validated to
[a-f0-9]{32}) and the sanitizedupstream message — never the token, never wrapped with
%von therequest. CF token is documented as least-privilege: lists mode
needs
Account:Account Filter Lists:Editon one account; rulesetsmode needs
Zone:Firewall:Editon each listed zone — the wizardrefuses to proceed if the scope check (
GET /accounts/{id}orGET /zones/{id}) returns 401/403 and prints the exact required scope.anything to any AI provider.
dashboard change.
init run (dry validation). Uses
http.NewRequestWithContext+http.Client{Timeout: 8s}(stdlib defaults so cert verification is on;no
InsecureSkipVerify); context cancellation is honored. Responsebody is bounded to 4 KiB before JSON-decoding. 4xx/5xx are caught
explicitly and refuse to write config (fail-safe). No
SSRFrisk —the URL is built from
api.cloudflare.com/client/v4+ operator-typedID that has already been validated to
[a-f0-9]{32}; the base URL isa compile-time constant except in tests.
added (
internal/cdndetectuses only stdlib + already-presentgopkg.in/yaml.v3).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()oncdnStep(safe under%+v), (c)the
0600 root:ezyshieldfile write, and (d) the wizard refusing towrite 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
SecretRefinline value, dry-run default unchanged, allowlist untouched).
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/vhostdetectpackagescaffolding + 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 thepackages 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