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 =
Note:

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

Rule types we support syncing

%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.



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

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



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

def available_environments
  @zone_map.keys
end

#check_permissions(environment = :production) ⇒ Object

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/

Check what API permissions the current token has
Returns a hash with each permission type and whether it's available



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

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



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

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



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
445
446
# File 'app/services/cloudflare_rules_service.rb', line 398

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



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
119
# File 'app/services/cloudflare_rules_service.rb', line 85

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)



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

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



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

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.)



326
327
328
# File 'app/services/cloudflare_rules_service.rb', line 326

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



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

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



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

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



354
355
356
357
# File 'app/services/cloudflare_rules_service.rb', line 354

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

Note:

Page Rules are NOT compatible with Account API tokens

Sync all rules from source to target environment

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



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

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



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
192
# File 'app/services/cloudflare_rules_service.rb', line 165

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



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
317
318
# File 'app/services/cloudflare_rules_service.rb', line 268

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/



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

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