Class: CloudflareRulesService

Inherits:
Object
  • Object
show all
Includes:
Singleton
Defined in:
app/services/cloudflare_rules_service.rb

Overview

Service for managing Cloudflare rules across different environments
Supports exporting, comparing, and syncing rules between production and staging

Usage:
service = CloudflareRulesService.new
service.export_rules(:production)
service.compare_rules(:production, :staging)
service.sync_rules(:production, :staging, ruleset_id: 'abc123')

REQUIRED API TOKEN PERMISSIONS:
The Cloudflare API token must have these permissions:

  • Zone.Zone (Read) - Basic zone access
  • Zone.Zone Settings (Read) - Page rules
  • Zone.Firewall Services (Read/Edit) - Firewall rules
  • Zone.Zone Rulesets (Read/Edit) - Modern rulesets (cache, transform, WAF)
  • Zone.Rate Limiting (Read/Edit) - Rate limiting rules

Create/update the token at: https://dash.cloudflare.com/79b7f58cf035093b5ad11747df30369a/api-tokens
Store it in credentials at: cloudflare.account_api_token

Constant Summary collapse

BASE_URL =

URL for base.

'https://api.cloudflare.com/client/v4'
RULES_DATA_PATH =

Filesystem/URL path for rules data.

Rails.root.join("data/cloudflare_rules")
ACCOUNT_ID =

Cloudflare Account ID (same as used in CloudflareStreamApi)

'79b7f58cf035093b5ad11747df30369a'
ORIGIN_RATE_LIMIT_DESCRIPTION =

Description used to identify the origin flood-protection rate limit rule.
Must be unique across all rules in the http_ratelimit phase so upsert works.

'Origin flood protection — limit cache-miss requests per IP'
CRM_GENERAL_DESCRIPTION =

Descriptions used to match CRM-specific rules for fix_crm_rate_limits

'CRM General Rate Limit'
CRM_JOB_POLLING_DESCRIPTION =

Crm job polling description.

'CRM Job Polling Throttle'
CRM_TRIGGER_DESCRIPTION =

Crm trigger description.

'CRM Trigger'
WARMLYYOURS_USERS_LIST_NAME =

Cloudflare IP list name used in the "Always Allow WY Users" skip rule.
The list ID is auto-discovered from this name via the Lists API.

'warmlyyours_users'
RULE_TYPES =

Rule types we support syncing
Note: Page Rules are NOT compatible with Account API tokens per Cloudflare docs
https://developers.cloudflare.com/fundamentals/api/get-started/account-owned-tokens/

%i[
  firewall_rules
  rulesets
  rate_limits
].freeze
DOMAIN_MAP =

Domain mapping for rule adaptation

{
  production: 'warmlyyours.com',
  staging: 'warmlyyours.ws',
  development: 'warmlyyours.me'
}.freeze

Instance Method Summary collapse

Constructor Details

#initializeCloudflareRulesService

Returns a new instance of CloudflareRulesService.



65
66
67
68
69
70
71
72
73
74
75
# File 'app/services/cloudflare_rules_service.rb', line 65

def initialize
  # Use account_api_token for rules management (has zone rulesets permissions)
  # Falls back to api_token if account_api_token not available
  @token = begin
    Heatwave::Configuration.fetch(:cloudflare, :account_api_token)
  rescue StandardError
    Heatwave::Configuration.fetch(:cloudflare, :api_token)
  end
  @zone_map = Cache::EdgeCacheUtility::ZONE_MAP
  ensure_data_directory
end

Instance Method Details

#available_environmentsObject

Get available zones/environments



194
195
196
# File 'app/services/cloudflare_rules_service.rb', line 194

def available_environments
  @zone_map.keys
end

#check_permissions(environment = :production) ⇒ Object

Check what API permissions the current token has
Returns a hash with each permission type and whether it's available
Note: Page Rules are NOT checked as they're incompatible with Account API tokens
https://developers.cloudflare.com/fundamentals/api/get-started/account-owned-tokens/



202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
# File 'app/services/cloudflare_rules_service.rb', line 202

def check_permissions(environment = :production)
  zone_id = zone_id_for(environment)
  results = {}

  # Test zone read
  results[:zone_read] = test_endpoint("/zones/#{zone_id}")

  # Test firewall rules
  results[:firewall_rules] = test_endpoint("/zones/#{zone_id}/firewall/rules")

  # Test rulesets
  results[:rulesets] = test_endpoint("/zones/#{zone_id}/rulesets")

  # Test rate limits
  results[:rate_limits] = test_endpoint("/zones/#{zone_id}/rate_limits")

  # NOTE: Page Rules (/pagerules) are NOT compatible with Account API tokens

  results
end

#compare_rules(source, target) ⇒ Hash

Compare rules between two environments

Parameters:

  • source (Symbol)

    Source environment (usually :production)

  • target (Symbol)

    Target environment (usually :staging)

Returns:

  • (Hash)

    Comparison results with differences



124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'app/services/cloudflare_rules_service.rb', line 124

def compare_rules(source, target)
  source_rules = load_latest_rules(source)
  target_rules = load_latest_rules(target)

  comparison = {}

  RULE_TYPES.each do |rule_type|
    next unless source_rules[rule_type] && target_rules[rule_type]

    comparison[rule_type] = compare_rule_type(
      source_rules[rule_type],
      target_rules[rule_type],
      rule_type
    )
  end

  comparison
end

#expand_crm_trigger_to_all_methods(environment: :production, dry_run: false) ⇒ Object

Expand the CRM Trigger skip rule to cover ALL HTTP methods (not just writes).
The CRM requires authentication so WAF/SBFM checks add friction with no value.

Changes the expression from:
(http.request.method in "PUT" "PATCH" "DELETE" and http.host eq "crm.warmlyyours.com")
To:
(http.host eq "crm.warmlyyours.com")

Parameters:

  • environment (Symbol) (defaults to: :production)

    :production or :staging

  • dry_run (Boolean) (defaults to: false)

    when true, prints changes without applying



396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
# File 'app/services/cloudflare_rules_service.rb', line 396

def expand_crm_trigger_to_all_methods(environment: :production, dry_run: false)
  zone_id = zone_id_for(environment)
  domain = DOMAIN_MAP[environment.to_sym]
  crm_host = "crm.#{domain}"
  target_expression = "(http.host eq \"#{crm_host}\")"

  existing = api_get("/zones/#{zone_id}/rulesets/phases/http_request_firewall_custom/entrypoint")
  if existing.is_a?(Hash) && existing['success'] == false
    errors = existing['errors'] || existing[:errors] || []
    return { error: "Failed to fetch custom firewall rules: #{errors.inspect}" }
  end

  existing_rules = if existing.is_a?(Array)
                     existing
                   else
                     (existing.is_a?(Hash) ? existing['rules'] || [] : [])
                   end
  changed = false

  updated_rules = existing_rules.map do |rule|
    next rule unless rule['description'] == CRM_TRIGGER_DESCRIPTION

    old_expression = rule['expression']
    next rule if old_expression == target_expression

    rule['expression'] = target_expression
    changed = true
    rule
  end

  return { skipped: true, reason: "CRM Trigger already covers all methods or was not found" } unless changed

  if dry_run
    Rails.logger.info("[CloudflareRulesService] DRY RUN — CRM Trigger: expanding to all HTTP methods for #{crm_host}")
    return { dry_run: true, changes: ["CRM Trigger expression → #{target_expression}"] }
  end

  result = api_put(
    "/zones/#{zone_id}/rulesets/phases/http_request_firewall_custom/entrypoint",
    { rules: updated_rules }
  )

  if result.is_a?(Hash) && result['success'] == false
    errors = result['errors'] || result[:errors] || []
    return { error: "Failed to update custom firewall rules: #{errors.inspect}" }
  end

  { applied: true, changes: ["CRM Trigger expression → #{target_expression}"], result: result }
end

#export_rules(environment) ⇒ Hash

Export all rules from an environment to YAML files

Parameters:

  • environment (Symbol)

    :production, :staging, or :development

Returns:

  • (Hash)

    Summary of exported rules



84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'app/services/cloudflare_rules_service.rb', line 84

def export_rules(environment)
  environment = environment.to_sym
  zone_id = zone_id_for(environment)
  timestamp = Time.current.strftime('%Y%m%d_%H%M%S')
  export_dir = RULES_DATA_PATH.join(environment.to_s, timestamp)
  FileUtils.mkdir_p(export_dir)

  results = {}

  # Export each rule type
  # Note: Page Rules are NOT compatible with Account API tokens
  results[:firewall_rules] = export_firewall_rules(zone_id, export_dir)
  results[:rulesets] = export_rulesets(zone_id, export_dir)
  results[:rate_limits] = export_rate_limits(zone_id, export_dir)

  # Create a combined summary file
  summary = {
    environment: environment,
    zone_id: zone_id,
    domain: DOMAIN_MAP[environment],
    exported_at: Time.current.iso8601,
    rule_counts: results.transform_values do |v|
      v[:count]
    rescue StandardError
      0
    end
  }

  File.write(export_dir.join('_summary.yml'), summary.to_yaml)

  # Also update the "latest" symlink
  update_latest_symlink(environment, export_dir)

  results.merge(export_dir: export_dir.to_s)
end

#fetch_current_rules(environment) ⇒ Object

Fetch current rules directly from API (without saving)



241
242
243
244
245
246
247
248
249
250
# File 'app/services/cloudflare_rules_service.rb', line 241

def fetch_current_rules(environment)
  zone_id = zone_id_for(environment)

  {
    firewall_rules: fetch_firewall_rules(zone_id),
    page_rules: fetch_page_rules(zone_id),
    rulesets: fetch_rulesets(zone_id),
    rate_limits: fetch_rate_limits(zone_id)
  }
end

#fetch_ip_list_items(list_id) ⇒ Array<Hash>

Fetch current items from a Cloudflare IP list (paginated).

Parameters:

  • list_id (String)

    the list UUID

Returns:

  • (Array<Hash>)

    all items across all pages



360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
# File 'app/services/cloudflare_rules_service.rb', line 360

def fetch_ip_list_items(list_id)
  all_items = []
  cursor = nil

  loop do
    params = "per_page=500"
    params += "&cursor=#{cursor}" if cursor

    full_url = "#{BASE_URL}/accounts/#{ACCOUNT_ID}/rules/lists/#{list_id}/items?#{params}"
    response = connection.get(full_url)
    page = handle_response_with_pagination(response)
    return page if page.is_a?(Hash) && page[:error]

    all_items.concat(Array(page[:result]))

    cursor = page[:cursor_after]
    break if cursor.blank?
  end

  all_items
end

#fetch_ip_listsArray<Hash>

Fetch all IP lists on the account.

Returns:

  • (Array<Hash>)

    list metadata (id, name, kind, num_items, etc.)



324
325
326
# File 'app/services/cloudflare_rules_service.rb', line 324

def fetch_ip_lists
  api_get("/accounts/#{ACCOUNT_ID}/rules/lists")
end

#find_list_id_by_name(name) ⇒ String

Find a list's UUID by its name.
Caches the result for the lifetime of this singleton instance.

Parameters:

  • name (String)

    the Cloudflare list name (e.g. "warmlyyours_users")

Returns:

  • (String)

    the list UUID

Raises:

  • (RuntimeError)

    if the list is not found



333
334
335
336
337
338
339
340
341
342
343
344
# File 'app/services/cloudflare_rules_service.rb', line 333

def find_list_id_by_name(name)
  @list_id_cache ||= {}
  return @list_id_cache[name] if @list_id_cache.key?(name)

  lists = fetch_ip_lists
  raise "Cloudflare API error fetching lists: #{lists[:error]}" if lists.is_a?(Hash) && lists[:error]

  match = Array(lists).find { |l| l['name'] == name }
  raise "Cloudflare IP list '#{name}' not found. Available: #{Array(lists).pluck('name').join(', ')}" unless match

  @list_id_cache[name] = match['id']
end

#fix_crm_rate_limits(environment: :production, requests_per_period: 600, job_requests_per_period: 120, dry_run: false) ⇒ Object

Fix CRM rate limiting rules that are too aggressive for multi-user offices.

Changes applied:

  1. CRM General Rate Limit: 300 req/60s → requests_per_period (default 600)
  2. CRM Job Polling Throttle: 30 req/60s → job_requests_per_period (default 120)
  3. CRM Trigger skip rule: adds http_ratelimit phase skip so write ops bypass rate limiting

The CRM rate limits bucket by ip.src + cf.colo.id, meaning all users behind a
shared office IP count against the same limit. These higher thresholds accommodate
5-10 concurrent CRM users on the same network.

Parameters:

  • environment (Symbol) (defaults to: :production)

    :production or :staging

  • requests_per_period (Integer) (defaults to: 600)

    new CRM general limit per 60s (default 600)

  • job_requests_per_period (Integer) (defaults to: 120)

    new /jobs limit per 60s (default 120)

  • dry_run (Boolean) (defaults to: false)

    when true, prints changes without applying



461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
# File 'app/services/cloudflare_rules_service.rb', line 461

def fix_crm_rate_limits(environment: :production, requests_per_period: 600,
                        job_requests_per_period: 120, dry_run: false)
  zone_id = zone_id_for(environment)
  results = {}

  # --- Phase 1: Update http_ratelimit rules ---
  results[:rate_limits] = update_crm_rate_limit_rules(
    zone_id, requests_per_period, job_requests_per_period, dry_run: dry_run
  )

  # --- Phase 2: Update http_request_firewall_custom CRM Trigger rule ---
  results[:firewall_custom] = update_crm_trigger_skip_rule(zone_id, dry_run: dry_run)

  results
end

#replace_ip_list_items(list_id, items) ⇒ Hash

Full-replace all items in a Cloudflare IP list.
Uses PUT which atomically replaces the entire list contents.

Parameters:

  • list_id (String)

    the list UUID

  • items (Array<Hash>)

    items with :ip and optional :comment keys

Returns:

  • (Hash)

    API result containing operation_id for async tracking



352
353
354
355
# File 'app/services/cloudflare_rules_service.rb', line 352

def replace_ip_list_items(list_id, items)
  payload = items.map { |item| { ip: item[:ip], comment: item[:comment].to_s.truncate(500) } }
  api_put("/accounts/#{ACCOUNT_ID}/rules/lists/#{list_id}/items", payload)
end

#sync_all_rules(source, target, dry_run: true) ⇒ Hash

Sync all rules from source to target environment
Note: Page Rules are NOT compatible with Account API tokens

Parameters:

  • source (Symbol)

    Source environment

  • target (Symbol)

    Target environment

  • dry_run (Boolean) (defaults to: true)

    If true, only shows what would change

Returns:

  • (Hash)

    Results of sync operation



149
150
151
152
153
154
155
156
157
# File 'app/services/cloudflare_rules_service.rb', line 149

def sync_all_rules(source, target, dry_run: true)
  results = {}

  results[:firewall_rules] = sync_firewall_rules(source, target, dry_run: dry_run)
  results[:rulesets] = sync_rulesets(source, target, dry_run: dry_run)
  results[:rate_limits] = sync_rate_limits(source, target, dry_run: dry_run)

  results
end

#sync_ruleset(source, target, ruleset_id:, dry_run: true) ⇒ Object

Sync a specific ruleset by ID

Parameters:

  • source (Symbol)

    Source environment

  • target (Symbol)

    Target environment

  • ruleset_id (String)

    The ruleset ID to sync

  • dry_run (Boolean) (defaults to: true)

    If true, only shows what would change



164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'app/services/cloudflare_rules_service.rb', line 164

def sync_ruleset(source, target, ruleset_id:, dry_run: true)
  source_zone_id = zone_id_for(source)
  target_zone_id = zone_id_for(target)

  # Fetch the specific ruleset from source
  ruleset = fetch_ruleset(source_zone_id, ruleset_id)
  return { error: "Ruleset #{ruleset_id} not found in #{source}" } unless ruleset

  # Adapt rules for target environment
  adapted_ruleset = adapt_rules_for_environment(ruleset, source, target)

  if dry_run
    {
      action: :would_sync,
      ruleset_id: ruleset_id,
      ruleset_name: ruleset['name'],
      rules_count: ruleset.dig('rules')&.size || 0,
      adapted_ruleset: adapted_ruleset
    }
  else
    result = push_ruleset(target_zone_id, adapted_ruleset)
    {
      action: :synced,
      ruleset_id: ruleset_id,
      result: result
    }
  end
end

#upsert_origin_rate_limit(environment: :production, requests_per_period: 120, mitigation_timeout: 60, dry_run: false) ⇒ Object

Upsert the origin flood-protection rate limiting rule in the http_ratelimit phase.

Uses Cloudflare Advanced Rate Limiting (WAF rulesets API):

  • Only counts requests that reach the origin (cache misses).
  • Limits any single IP to requests_per_period origin requests per 60 seconds.
  • Blocks the IP for mitigation_timeout seconds when the threshold is exceeded.
  • Scoped to www.warmlyyours.com only so CRM/API subdomains are unaffected.

Idempotent: an existing rule with RULE_DESCRIPTION is updated, not duplicated.

Parameters:

  • environment (Symbol) (defaults to: :production)

    :production or :staging

  • requests_per_period (Integer) (defaults to: 120)

    Cache-miss requests per 60s per IP (default 120)

  • mitigation_timeout (Integer) (defaults to: 60)

    Block duration in seconds (default 60)

  • dry_run (Boolean) (defaults to: false)

    When true, prints the rule payload without applying it



266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
# File 'app/services/cloudflare_rules_service.rb', line 266

def upsert_origin_rate_limit(environment: :production, requests_per_period: 120,
                             mitigation_timeout: 60, dry_run: false)
  zone_id = zone_id_for(environment)
  domain  = DOMAIN_MAP[environment.to_sym]

  new_rule = {
    'description' => ORIGIN_RATE_LIMIT_DESCRIPTION,
    'expression'  => "http.host eq \"#{domain}\"",
    'action'      => 'block',
    'ratelimit'   => {
      'characteristics'    => ['ip.src'],
      'period'             => 60,
      'requests_per_period' => requests_per_period,
      'counting_expression' => "http.host eq \"#{domain}\" and not cf.cache.status in {\"HIT\" \"REVALIDATED\" \"UPDATING\" \"STALE\" \"BYPASS\"}",
      'mitigation_timeout' => mitigation_timeout
    }
  }

  if dry_run
    Rails.logger.info("[CloudflareRulesService] DRY RUN — would upsert rate limit rule for #{environment} (#{domain}):")
    Rails.logger.info(JSON.pretty_generate(new_rule))
    return { dry_run: true, rule: new_rule }
  end

  existing = api_get("/zones/#{zone_id}/rulesets/phases/http_ratelimit/entrypoint")
  if existing.is_a?(Hash) && existing['success'] == false
    errors = existing['errors'] || existing[:errors] || []
    raise "Cloudflare API error fetching rate limit rules for #{environment}: #{errors.inspect}"
  end

  existing_rules = if existing.is_a?(Array)
                     existing
                   else
                     (existing.is_a?(Hash) ? existing['rules'] || [] : [])
                   end

  updated_rules = existing_rules.reject { |r| r['description'] == ORIGIN_RATE_LIMIT_DESCRIPTION }
  updated_rules << new_rule

  result = api_put(
    "/zones/#{zone_id}/rulesets/phases/http_ratelimit/entrypoint",
    { rules: updated_rules }
  )

  if result.is_a?(Hash) && result['success'] == false
    errors = result['errors'] || result[:errors] || []
    raise "Cloudflare API error upserting rate limit rule for #{environment}: #{errors.inspect}"
  end

  { environment: environment, domain: domain, rule: new_rule, result: result }
end

#verify_tokenObject

Verify token is valid
Uses account-owned token endpoint per:
https://developers.cloudflare.com/fundamentals/api/get-started/account-owned-tokens/



226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'app/services/cloudflare_rules_service.rb', line 226

def verify_token
  # Try account token verification first (for account-owned tokens)
  response = api_get("/accounts/#{ACCOUNT_ID}/tokens/verify")
  return { valid: true, status: response['status'], expires_on: response['expires_on'], type: :account } if response.is_a?(Hash) && response['status'] == 'active'

  # Fall back to user token verification
  response = api_get('/user/tokens/verify')
  if response.is_a?(Hash) && response['status'] == 'active'
    { valid: true, status: response['status'], expires_on: response['expires_on'], type: :user }
  else
    { valid: false, error: response[:error] || 'Unknown error' }
  end
end