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 =
'https://api.cloudflare.com/client/v4'
RULES_DATA_PATH =
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 Throttle'
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.



61
62
63
64
65
66
67
# File 'app/services/cloudflare_rules_service.rb', line 61

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 = Heatwave::Configuration.fetch(:cloudflare, :account_api_token) rescue Heatwave::Configuration.fetch(:cloudflare, :api_token)
  @zone_map = Cache::EdgeCacheUtility::ZONE_MAP
  ensure_data_directory
end

Instance Method Details

#available_environmentsObject

Get available zones/environments



182
183
184
# File 'app/services/cloudflare_rules_service.rb', line 182

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/



190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'app/services/cloudflare_rules_service.rb', line 190

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



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'app/services/cloudflare_rules_service.rb', line 112

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



384
385
386
387
388
389
390
391
392
393
394
395
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
# File 'app/services/cloudflare_rules_service.rb', line 384

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 = existing.is_a?(Array) ? existing : (existing.is_a?(Hash) ? existing['rules'] || [] : [])
  changed = false

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

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

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

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

  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



76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'app/services/cloudflare_rules_service.rb', line 76

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 { |v| v[:count] rescue 0 }
  }

  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)



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

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



348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
# File 'app/services/cloudflare_rules_service.rb', line 348

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



310
311
312
# File 'app/services/cloudflare_rules_service.rb', line 310

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



319
320
321
322
323
324
325
326
327
328
329
330
331
332
# File 'app/services/cloudflare_rules_service.rb', line 319

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
  if lists.is_a?(Hash) && lists[:error]
    raise "Cloudflare API error fetching lists: #{lists[:error]}"
  end

  match = Array(lists).find { |l| l['name'] == name }
  raise "Cloudflare IP list '#{name}' not found. Available: #{Array(lists).map { |l| l['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



449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
# File 'app/services/cloudflare_rules_service.rb', line 449

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



340
341
342
343
# File 'app/services/cloudflare_rules_service.rb', line 340

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



137
138
139
140
141
142
143
144
145
# File 'app/services/cloudflare_rules_service.rb', line 137

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



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'app/services/cloudflare_rules_service.rb', line 152

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



256
257
258
259
260
261
262
263
264
265
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
# File 'app/services/cloudflare_rules_service.rb', line 256

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 = existing.is_a?(Array) ? existing : (existing.is_a?(Hash) ? existing['rules'] || [] : [])

  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/



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'app/services/cloudflare_rules_service.rb', line 214

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

  # 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