95c821deb2
Add detailed [DEBUG] logging to every controller module when logging.level is set to "debug". Each module with stateful debug uses SetDebug(bool) wired from main.go. Covers stacks, backup, cloudflare, integrations, system, monitor, settings, scheduler, web handlers, storage, metrics, API, selfupdate, and assets. Also includes the app export/import (.fab bundles) feature from v0.32.0 and its debug page integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
301 lines
8.9 KiB
Go
301 lines
8.9 KiB
Go
package cloudflare
|
|
|
|
import (
|
|
"context"
|
|
"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(ctx context.Context, zoneID string) (string, error) {
|
|
path := fmt.Sprintf("/zones/%s/rulesets", zoneID)
|
|
resp, err := c.do(ctx, "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)
|
|
}
|
|
|
|
if c.debug {
|
|
c.logger.Printf("[CF-DEBUG] GetCustomRulesetID: found %d rulesets for zone %s", len(rulesets), zoneID)
|
|
}
|
|
|
|
for _, rs := range rulesets {
|
|
if rs.Phase == wafPhase {
|
|
if c.debug {
|
|
c.logger.Printf("[CF-DEBUG] GetCustomRulesetID: matched ruleset %s (phase=%s)", rs.ID, wafPhase)
|
|
}
|
|
return rs.ID, nil
|
|
}
|
|
}
|
|
|
|
if c.debug {
|
|
c.logger.Printf("[CF-DEBUG] GetCustomRulesetID: no ruleset with phase %s found", wafPhase)
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
// CreateCustomRuleset creates the http_request_firewall_custom phase entry point ruleset.
|
|
func (c *Client) CreateCustomRuleset(ctx context.Context, 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(ctx, "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(ctx context.Context, zoneID, rulesetID string) ([]rule, error) {
|
|
path := fmt.Sprintf("/zones/%s/rulesets/%s", zoneID, rulesetID)
|
|
resp, err := c.do(ctx, "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)
|
|
}
|
|
|
|
if c.debug {
|
|
c.logger.Printf("[CF-DEBUG] GetRules: %d total rules in ruleset %s", len(rs.Rules), rulesetID)
|
|
}
|
|
|
|
return rs.Rules, nil
|
|
}
|
|
|
|
// GetFelhomRules returns only rules with the [felhom-geo] prefix.
|
|
func (c *Client) GetFelhomRules(ctx context.Context, zoneID, rulesetID string) ([]GeoRule, error) {
|
|
rules, err := c.GetRules(ctx, 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,
|
|
})
|
|
}
|
|
}
|
|
|
|
if c.debug {
|
|
c.logger.Printf("[CF-DEBUG] GetFelhomRules: %d felhom rules out of %d total", len(result), len(rules))
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// CreateRule adds a new rule to the ruleset.
|
|
func (c *Client) CreateRule(ctx context.Context, zoneID, rulesetID string, r rule) (string, error) {
|
|
path := fmt.Sprintf("/zones/%s/rulesets/%s/rules", zoneID, rulesetID)
|
|
resp, err := c.do(ctx, "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)
|
|
if c.debug {
|
|
expr := r.Expression
|
|
if len(expr) > 120 {
|
|
expr = expr[:120] + "..."
|
|
}
|
|
c.logger.Printf("[CF-DEBUG] CreateRule: expression: %s", expr)
|
|
}
|
|
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(ctx context.Context, zoneID, rulesetID, ruleID string, r rule) error {
|
|
path := fmt.Sprintf("/zones/%s/rulesets/%s/rules/%s", zoneID, rulesetID, ruleID)
|
|
_, err := c.do(ctx, "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)
|
|
if c.debug {
|
|
expr := r.Expression
|
|
if len(expr) > 120 {
|
|
expr = expr[:120] + "..."
|
|
}
|
|
c.logger.Printf("[CF-DEBUG] UpdateRule: expression: %s", expr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteRule removes a rule from the ruleset.
|
|
func (c *Client) DeleteRule(ctx context.Context, zoneID, rulesetID, ruleID string) error {
|
|
path := fmt.Sprintf("/zones/%s/rulesets/%s/rules/%s", zoneID, rulesetID, ruleID)
|
|
_, err := c.do(ctx, "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,
|
|
}
|
|
}
|