Files
deploy-felhom-compose/controller/internal/cloudflare/waf.go
T
admin 9ed5e78c45 fix: remove custom block response from WAF rules (CF free plan)
Cloudflare Free plan doesn't support custom response body in block
rules. Use plain block action which returns CF's default 403 page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:03:01 +01:00

267 lines
7.8 KiB
Go

package cloudflare
import (
"encoding/json"
"fmt"
"strings"
)
const (
// rulePrefix identifies felhom-managed WAF rules.
rulePrefix = "[felhom-geo]"
// wafPhase is the Cloudflare ruleset phase for custom WAF rules.
wafPhase = "http_request_firewall_custom"
// globalRuleDesc is the description for the global geo-restriction rule.
globalRuleDesc = "[felhom-geo] Global"
// appRuleDescPrefix is the prefix for per-app geo-restriction rules.
appRuleDescPrefix = "[felhom-geo] app:"
)
// ruleset represents a Cloudflare ruleset (minimal fields).
type ruleset struct {
ID string `json:"id"`
Name string `json:"name"`
Phase string `json:"phase"`
Kind string `json:"kind"`
}
// rule represents a Cloudflare custom rule.
type rule struct {
ID string `json:"id,omitempty"`
Description string `json:"description"`
Expression string `json:"expression"`
Action string `json:"action"`
ActionParameters *actionParameters `json:"action_parameters,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
type actionParameters struct {
Response *blockResponse `json:"response,omitempty"`
}
type blockResponse struct {
StatusCode int `json:"status_code"`
Content string `json:"content"`
ContentType string `json:"content_type"`
}
// GeoRule represents a felhom-managed WAF custom rule (for external consumption).
type GeoRule struct {
ID string
Description string
Expression string
Action string
}
// GetCustomRulesetID returns the zone's http_request_firewall_custom ruleset ID.
// Returns empty string if no such ruleset exists yet.
func (c *Client) GetCustomRulesetID(zoneID string) (string, error) {
path := fmt.Sprintf("/zones/%s/rulesets", zoneID)
resp, err := c.do("GET", path, nil)
if err != nil {
return "", fmt.Errorf("list rulesets: %w", err)
}
var rulesets []ruleset
if err := json.Unmarshal(resp.Result, &rulesets); err != nil {
return "", fmt.Errorf("decode rulesets: %w", err)
}
for _, rs := range rulesets {
if rs.Phase == wafPhase {
return rs.ID, nil
}
}
return "", nil
}
// CreateCustomRuleset creates the http_request_firewall_custom phase entry point ruleset.
func (c *Client) CreateCustomRuleset(zoneID string) (string, error) {
path := fmt.Sprintf("/zones/%s/rulesets", zoneID)
body := map[string]interface{}{
"name": "felhom custom rules",
"kind": "zone",
"phase": wafPhase,
"rules": []interface{}{},
}
resp, err := c.do("POST", path, body)
if err != nil {
return "", fmt.Errorf("create ruleset: %w", err)
}
var rs ruleset
if err := json.Unmarshal(resp.Result, &rs); err != nil {
return "", fmt.Errorf("decode created ruleset: %w", err)
}
c.logger.Printf("[CF] Created custom ruleset %s for zone %s", rs.ID, zoneID)
return rs.ID, nil
}
// GetRules returns all rules in a ruleset.
func (c *Client) GetRules(zoneID, rulesetID string) ([]rule, error) {
path := fmt.Sprintf("/zones/%s/rulesets/%s", zoneID, rulesetID)
resp, err := c.do("GET", path, nil)
if err != nil {
return nil, fmt.Errorf("get ruleset: %w", err)
}
var rs struct {
Rules []rule `json:"rules"`
}
if err := json.Unmarshal(resp.Result, &rs); err != nil {
return nil, fmt.Errorf("decode rules: %w", err)
}
return rs.Rules, nil
}
// GetFelhomRules returns only rules with the [felhom-geo] prefix.
func (c *Client) GetFelhomRules(zoneID, rulesetID string) ([]GeoRule, error) {
rules, err := c.GetRules(zoneID, rulesetID)
if err != nil {
return nil, err
}
var result []GeoRule
for _, r := range rules {
if strings.HasPrefix(r.Description, rulePrefix) {
result = append(result, GeoRule{
ID: r.ID,
Description: r.Description,
Expression: r.Expression,
Action: r.Action,
})
}
}
return result, nil
}
// CreateRule adds a new rule to the ruleset.
func (c *Client) CreateRule(zoneID, rulesetID string, r rule) (string, error) {
path := fmt.Sprintf("/zones/%s/rulesets/%s/rules", zoneID, rulesetID)
resp, err := c.do("POST", path, r)
if err != nil {
return "", fmt.Errorf("create rule: %w", err)
}
// The response is the full ruleset; find the new rule by description.
var rs struct {
Rules []rule `json:"rules"`
}
if err := json.Unmarshal(resp.Result, &rs); err != nil {
return "", fmt.Errorf("decode created rule response: %w", err)
}
for _, created := range rs.Rules {
if created.Description == r.Description {
c.logger.Printf("[CF] Created rule %q → %s", r.Description, created.ID)
return created.ID, nil
}
}
return "", fmt.Errorf("created rule not found in response")
}
// UpdateRule updates an existing rule in the ruleset.
func (c *Client) UpdateRule(zoneID, rulesetID, ruleID string, r rule) error {
path := fmt.Sprintf("/zones/%s/rulesets/%s/rules/%s", zoneID, rulesetID, ruleID)
_, err := c.do("PATCH", path, r)
if err != nil {
return fmt.Errorf("update rule %s: %w", ruleID, err)
}
c.logger.Printf("[CF] Updated rule %q (%s)", r.Description, ruleID)
return nil
}
// DeleteRule removes a rule from the ruleset.
func (c *Client) DeleteRule(zoneID, rulesetID, ruleID string) error {
path := fmt.Sprintf("/zones/%s/rulesets/%s/rules/%s", zoneID, rulesetID, ruleID)
_, err := c.do("DELETE", path, nil)
if err != nil {
return fmt.Errorf("delete rule %s: %w", ruleID, err)
}
c.logger.Printf("[CF] Deleted rule %s", ruleID)
return nil
}
// BuildGlobalExpression builds the Cloudflare filter expression for the global geo rule.
// countries: allowed ISO country codes.
// excludeHostnames: app hostnames that have their own rules (excluded from global).
//
// Example output: (not ip.src.country in {"HU" "DE"}) and (http.host ne "app1.example.com")
func BuildGlobalExpression(countries []string, excludeHostnames []string) string {
if len(countries) == 0 {
return "true" // block everything (no countries allowed)
}
// Build country part: (not ip.src.country in {"HU" "DE"})
quoted := make([]string, len(countries))
for i, c := range countries {
quoted[i] = `"` + c + `"`
}
expr := "(not ip.src.country in {" + strings.Join(quoted, " ") + "})"
// Add hostname exclusions for apps with their own rules
for _, host := range excludeHostnames {
expr += ` and (http.host ne "` + host + `")`
}
return expr
}
// BuildAppExpression builds the Cloudflare filter expression for a per-app geo rule.
// hostname: full hostname of the app (e.g., "nextcloud.demo-felhom.eu").
// countries: allowed ISO country codes for this app.
//
// Example output: (http.host eq "nextcloud.demo-felhom.eu" and not ip.src.country in {"HU" "US"})
func BuildAppExpression(hostname string, countries []string) string {
if len(countries) == 0 {
return fmt.Sprintf(`(http.host eq "%s")`, hostname) // block all traffic to this host
}
quoted := make([]string, len(countries))
for i, c := range countries {
quoted[i] = `"` + c + `"`
}
return fmt.Sprintf(`(http.host eq "%s" and not ip.src.country in {%s})`,
hostname, strings.Join(quoted, " "))
}
// AppRuleDescription returns the rule description for a per-app rule.
func AppRuleDescription(appName string) string {
return appRuleDescPrefix + appName
}
// IsGlobalRule checks if a rule description matches the global rule.
func IsGlobalRule(desc string) bool {
return desc == globalRuleDesc
}
// IsAppRule checks if a rule description is a per-app rule and returns the app name.
func IsAppRule(desc string) (string, bool) {
if strings.HasPrefix(desc, appRuleDescPrefix) {
return strings.TrimPrefix(desc, appRuleDescPrefix), true
}
return "", false
}
// newBlockRule creates a rule struct with a block action.
// Custom response body requires a paid CF plan, so we use a plain block (403).
func newBlockRule(description, expression string) rule {
enabled := true
return rule{
Description: description,
Expression: expression,
Action: "block",
Enabled: &enabled,
}
}