record_store 7.0.1 → 7.1.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5380713a7eb24d7be7ea68e2d6e2691bc929bccc22cf5098ce805665b1f2493b
4
- data.tar.gz: 7a4a1354479436ed184cad12e3eb8d434c0e8fa610bbd734d66ca494bb4c5365
3
+ metadata.gz: e9f48a40d3262e148fff5aecff8c61e5f4f676bdc7f7bcb64e2964dc221ba078
4
+ data.tar.gz: 48a44aa9fc8a431653d09c1c524bcfee73ea09e6148ae9ff0552110b7db51173
5
5
  SHA512:
6
- metadata.gz: 0e5bfe2bf6e97fc9a771c2472bfc4d49d94333bde7bd40c01b67203a674df743be0fc9db80cff14c126aa1d4a9b295d7028c954b372f2b033e0c56feeb1fe6b1
7
- data.tar.gz: b49194a0c1e24f3a3929832c561236c9828c75ee97859233a8ce127cd7f7321526359d43f01f3653962778354b330a095dd94675aeaebb0195afa5bfe60fe1ab
6
+ metadata.gz: cb623045bb1b2db82232eb037adaf05a13c234d9d82e760603075ec590b5528f2daeb6dfe1df28bd6cc1987ba379f8ee070547af3336bb11213651012d527f5b
7
+ data.tar.gz: 73618a9cbae3b16028bcaa7dba64dda74e4324427fda3e364c7df0087ed72828f36965dae239bc0a2c37c1c7aece20950e0a0556a325e4cd10082e74c2da3429
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 7.1.1
4
+ - Bugfix: Cloudflare provider now handles empty record sets properly
5
+
6
+ ## 7.1.0
7
+ - Add Cloudflare as a supported DNS provider
8
+
3
9
  ## 7.0.1
4
10
  - Allow an underscore in the middle of a CNAME record
5
11
 
data/README.md CHANGED
@@ -95,6 +95,12 @@ Regarding the `fingerprint` and `key_content`, you'll need to generate an API Si
95
95
 
96
96
  The `tenancy` and `region` are in the `Profile` menu. The `tenancy` starts with `ocid1.tenancy.oc1..`.
97
97
 
98
+ ### Cloudflare
99
+
100
+ Record_store and the Cloudflare provider do not have any concept of a zone id or account. Zone names must be unique within all accounts the API key has access to, otherwise Cloudflare provider will error.
101
+
102
+ `ALIAS` records in zone files are supported and created in Cloudflare as [per-record flattened](https://developers.cloudflare.com/dns/cname-flattening/set-up-cname-flattening/#per-record) CNAME records.
103
+
98
104
  ----
99
105
 
100
106
  # Architecture
@@ -184,14 +190,14 @@ class Provider
184
190
  #
185
191
  # Arguments:
186
192
  # record - a kind of `Record`
187
- def add(zone, record)
193
+ def add(record, zone)
188
194
  end
189
195
 
190
196
  # Deletes an existing record from the zone. It is expected this call modifies external state.
191
197
  #
192
198
  # Arguments:
193
199
  # record - a kind of `Record`
194
- def remove(zone, record)
200
+ def remove(record, zone)
195
201
  end
196
202
 
197
203
  # Updates an existing record in the zone. It is expected this call modifies external state.
@@ -199,7 +205,7 @@ class Provider
199
205
  # Arguments:
200
206
  # id - provider specific ID of record to update
201
207
  # record - a kind of `Record` which the record with `id` should be updated to
202
- def update(zone, id, record)
208
+ def update(id, record, zone)
203
209
  end
204
210
  end
205
211
  ```
@@ -0,0 +1,97 @@
1
+ require 'net/http'
2
+ require_relative 'response'
3
+
4
+ module Cloudflare
5
+ class Client
6
+ def initialize(api_fqdn, api_token)
7
+ @api_fqdn = api_fqdn
8
+ @api_token = api_token
9
+ end
10
+
11
+ def get(endpoint, params = {})
12
+ uri = build_uri(endpoint, params)
13
+
14
+ response = request(:get, uri)
15
+ Cloudflare::Response.new(response)
16
+ end
17
+
18
+ def post(endpoint, body = nil)
19
+ uri = build_uri(endpoint)
20
+
21
+ response = request(:post, uri, body: body)
22
+ Cloudflare::Response.new(response)
23
+ end
24
+
25
+ def put(endpoint, body = nil)
26
+ uri = build_uri(endpoint)
27
+
28
+ response = request(:put, uri, body: body)
29
+ Cloudflare::Response.new(response)
30
+ end
31
+
32
+ def patch(endpoint, body = nil)
33
+ uri = build_uri(endpoint)
34
+
35
+ response = request(:patch, uri, body: body)
36
+ Cloudflare::Response.new(response)
37
+ end
38
+
39
+ def delete(endpoint)
40
+ uri = build_uri(endpoint)
41
+
42
+ response = request(:delete, uri)
43
+ Cloudflare::Response.new(response)
44
+ end
45
+
46
+ def request(method, uri, body: nil)
47
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |conn|
48
+ request = case method
49
+ when :get
50
+ Net::HTTP::Get.new(uri)
51
+ when :post
52
+ request = Net::HTTP::Post.new(uri)
53
+ request.body = body.to_json if body
54
+ request
55
+ when :put
56
+ request = Net::HTTP::Put.new(uri)
57
+ request.body = body.to_json if body
58
+ request
59
+ when :patch
60
+ request = Net::HTTP::Patch.new(uri)
61
+ request.body = body.to_json if body
62
+ request
63
+ when :delete
64
+ Net::HTTP::Delete.new(uri)
65
+ end
66
+
67
+ cloudflare_headers.each { |k, v| request[k] = v }
68
+
69
+ begin
70
+ response = conn.request(request)
71
+ rescue StandardError => e
72
+ raise "HTTP error: #{e.message}"
73
+ end
74
+ response
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def cloudflare_headers
81
+ @cloudflare_headers ||= {
82
+ 'User-Agent' => "Ruby",
83
+ 'Accept' => 'application/json',
84
+ 'Content-Type' => 'application/json',
85
+ 'Authorization' => "Bearer #{@api_token}"
86
+ }
87
+ end
88
+
89
+ def build_uri(endpoint, params = {})
90
+ URI::HTTPS.build(
91
+ host: @api_fqdn,
92
+ path: endpoint.chomp('/'),
93
+ query: URI.encode_www_form(params),
94
+ )
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,60 @@
1
+ require 'json'
2
+
3
+ module Cloudflare
4
+ class Response
5
+ attr_reader :http_response
6
+
7
+ def initialize(http_response)
8
+ @http_response = http_response
9
+ end
10
+
11
+ def status_code
12
+ @http_response.code.to_i
13
+ end
14
+
15
+ def result_raw
16
+ result = json['result']
17
+
18
+ return if result.nil? || result.empty? || !success
19
+
20
+ result
21
+ end
22
+
23
+ def result
24
+ result = result_raw
25
+ return result.first if result.is_a?(Array)
26
+
27
+ result
28
+ end
29
+
30
+ def success
31
+ json['success']
32
+ end
33
+
34
+ def errors
35
+ json.fetch('errors', [])
36
+ end
37
+
38
+ def messages
39
+ json.fetch('messages', [])
40
+ end
41
+
42
+ def error_messages
43
+ errors.map { |error| error['message'] }
44
+ end
45
+
46
+ private
47
+
48
+ def json_content?
49
+ @http_response['Content-Type']&.match?(%r{application/json})
50
+ end
51
+
52
+ def json
53
+ return {} unless json_content?
54
+
55
+ JSON.parse(@http_response.body)
56
+ rescue JSON::ParserError => e
57
+ raise "JSON parsing error: #{e.message}"
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,192 @@
1
+ require_relative 'cloudflare/client'
2
+
3
+ module RecordStore
4
+ class Provider::Cloudflare < Provider
5
+ class << self
6
+ def record_types
7
+ super | Set.new(%w(PTR))
8
+ end
9
+
10
+ def supports_alias?
11
+ true
12
+ end
13
+
14
+ def supports_spf?
15
+ # New as Cloudflare doesn't support the now deprecated spf type
16
+ false
17
+ end
18
+
19
+ # Returns: an array of `Record` for each record in the provider's zone
20
+ def retrieve_current_records(zone:, stdout: $stdout)
21
+ zone_id = zone_name_to_id(zone)
22
+
23
+ retry_on_connection_errors do
24
+ records = client.get("/client/v4/zones/#{zone_id}/dns_records").result_raw || []
25
+ records.map { |api_body| build_from_api(api_body) }
26
+ end
27
+ end
28
+
29
+ # Returns an array of the zones managed by provider as strings
30
+ # Cloudflare returns zones across all accounts accessible by the API token
31
+ # Can implement filtering in request if needed
32
+ def zones
33
+ retry_on_connection_errors do
34
+ zones = client.get('/client/v4/zones').result_raw || []
35
+ zones.map { |zone| zone['name'] }
36
+ end
37
+ end
38
+
39
+ def apply_changeset(changeset, stdout = $stdout)
40
+ deletes = []
41
+ patches = []
42
+ posts = []
43
+ puts = []
44
+
45
+ changeset.changes.each do |change|
46
+ case change.type
47
+ when :removal
48
+ stdout.puts "Removing #{change.record}..."
49
+ deletes << { id: change.record.id }
50
+ when :addition
51
+ stdout.puts "Creating #{change.record}..."
52
+ posts << build_api_body(change.record)
53
+ when :update
54
+ stdout.puts "Updating record with ID #{change.id} to #{change.record}..."
55
+ patches << build_api_body(change.record).merge(id: change.id)
56
+ else
57
+ raise ArgumentError, "Unknown change type #{change.type.inspect}"
58
+ end
59
+ end
60
+
61
+ zone_id = zone_name_to_id(changeset.zone)
62
+ api_body = {
63
+ deletes: deletes,
64
+ patches: patches,
65
+ posts: posts,
66
+ puts: puts
67
+ }
68
+
69
+ retry_on_connection_errors do
70
+ response = client.post("/client/v4/zones/#{zone_id}/dns_records/batch", api_body)
71
+ unless response.success
72
+ error_message = response.errors.map { |error| error['message'] }.join(', ')
73
+ raise RecordStore::Provider::Error, "Cloudflare API error: #{error_message}"
74
+ end
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def secrets
81
+ super.fetch('cloudflare')
82
+ end
83
+
84
+ def build_api_body(record)
85
+ api_body = {
86
+ name: record.fqdn,
87
+ ttl: record.ttl,
88
+ type: record.type,
89
+ }
90
+ case record
91
+ when Record::A, Record::AAAA
92
+ api_body[:content] = record.rdata_txt
93
+ when Record::CNAME
94
+ api_body[:content] = record.rdata_txt
95
+ when Record::PTR
96
+ api_body[:content] = record.rdata_txt
97
+ when Record::MX
98
+ api_body[:content] = record.exchange
99
+ api_body[:priority] = record.preference
100
+ when Record::TXT, Record::SPF
101
+ api_body[:content] = record.txtdata.gsub('\;', ';')
102
+ when Record::CAA
103
+ api_body[:data] = record.rdata
104
+ when Record::SRV
105
+ api_body[:data] = rdata
106
+ when Record::ALIAS
107
+ api_body[:type] = 'CNAME'
108
+ api_body[:content] = record.rdata_txt
109
+ api_body[:settings] = { flatten_cname: true }
110
+ when Record::NS
111
+ api_body[:content] = record.nsdname
112
+ end
113
+
114
+ api_body
115
+ end
116
+
117
+ def build_from_api(api_response)
118
+ fqdn = Record.ensure_ends_with_dot(api_response['name'])
119
+
120
+ record_type = api_response['type']
121
+
122
+ record = {
123
+ record_id: api_response['id'],
124
+ ttl: api_response['ttl'],
125
+ fqdn: fqdn.downcase,
126
+ }
127
+
128
+ case record_type
129
+ when 'A', 'AAAA'
130
+ record.merge!(address: api_response['content'])
131
+ when 'CNAME'
132
+ if api_response.dig('settings', 'flatten_cname')
133
+ record_type = 'ALIAS'
134
+ record.merge!(alias: api_response['content'])
135
+ else
136
+ record.merge!(cname: api_response['content'])
137
+ end
138
+ when 'TXT'
139
+ record.merge!(txtdata: Record.unescape(api_response['content']).gsub(';', '\;'))
140
+ when 'MX'
141
+ record.merge!(preference: api_response['priority'], exchange: api_response['content'])
142
+ when 'PTR'
143
+ record.merge!(ptrdname: api_response['content'])
144
+ when 'CAA'
145
+ flags, tag, value = api_response['content'].split(' ')
146
+
147
+ record.merge!(
148
+ flags: flags.to_i,
149
+ tag: tag,
150
+ value: Record.unquote(value),
151
+ )
152
+ when 'SRV'
153
+ weight, port, host = api_response['content'].split(' ')
154
+
155
+ record.merge!(
156
+ priority: api_response['priority'].to_i,
157
+ weight: weight.to_i,
158
+ port: port.to_i,
159
+ target: Record.ensure_ends_with_dot(host),
160
+ )
161
+ when 'NS'
162
+ record.merge!(nsdname: api_response['content'])
163
+ end
164
+
165
+ Record.const_get(record_type).new(record)
166
+ end
167
+
168
+ def client
169
+ Cloudflare::Client.new(
170
+ secrets['api_fqdn'],
171
+ secrets['api_token'],
172
+ )
173
+ end
174
+
175
+ def zone_name_to_id(zone_name)
176
+ retry_on_connection_errors do
177
+ matching_zones = client.get('/client/v4/zones').result_raw.select { |zone| zone['name'] == zone_name }
178
+
179
+ case matching_zones.size
180
+ when 0
181
+ raise "Zone not found for #{zone_name}"
182
+ when 1
183
+ matching_zones.first['id']
184
+ else
185
+ raise "Multiple zones found for #{zone_name}. " \
186
+ "API key must only return the zone on which records are to be managed."
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
@@ -36,6 +36,8 @@ module RecordStore
36
36
  'NS1'
37
37
  when /\.oraclecloud\.net\z/
38
38
  'OracleCloudDNS'
39
+ when /\.cloudflare\.com\z/
40
+ 'Cloudflare'
39
41
  end
40
42
  end
41
43
 
@@ -121,15 +123,15 @@ module RecordStore
121
123
 
122
124
  private
123
125
 
124
- def add(record)
126
+ def add(record, zone)
125
127
  raise NotImplementedError
126
128
  end
127
129
 
128
- def remove(record)
130
+ def remove(record, zone)
129
131
  raise NotImplementedError
130
132
  end
131
133
 
132
- def update(id, record)
134
+ def update(id, record, zone)
133
135
  raise NotImplementedError
134
136
  end
135
137
 
@@ -1,3 +1,3 @@
1
1
  module RecordStore
2
- VERSION = '7.0.1'.freeze
2
+ VERSION = '7.1.1'.freeze
3
3
  end
data/lib/record_store.rb CHANGED
@@ -36,6 +36,7 @@ require 'record_store/provider/dnsimple'
36
36
  require 'record_store/provider/google_cloud_dns'
37
37
  require 'record_store/provider/ns1'
38
38
  require 'record_store/provider/oracle_cloud_dns'
39
+ require 'record_store/provider/cloudflare'
39
40
  require 'record_store/cli'
40
41
 
41
42
  module RecordStore
data/record_store.gemspec CHANGED
@@ -35,7 +35,7 @@ Gem::Specification.new do |spec|
35
35
  spec.add_runtime_dependency 'fog-dynect', '>= 0.4', '< 0.6'
36
36
  spec.add_runtime_dependency 'fog-json'
37
37
  spec.add_runtime_dependency 'fog-xml'
38
- spec.add_runtime_dependency 'google-cloud-dns', '~> 0.31'
38
+ spec.add_runtime_dependency 'google-cloud-dns', '>= 0.31', '< 2.0'
39
39
  spec.add_runtime_dependency 'ns1'
40
40
  spec.add_runtime_dependency 'oci', '>= 2.14', '< 2.22'
41
41
  spec.add_runtime_dependency 'ruby-limiter', '>= 1.0.1', '< 3'
@@ -46,7 +46,7 @@ Gem::Specification.new do |spec|
46
46
  spec.add_development_dependency 'mocha'
47
47
  spec.add_development_dependency 'pry'
48
48
  spec.add_development_dependency 'rake'
49
- spec.add_development_dependency 'rubocop', '~> 1.64.0'
49
+ spec.add_development_dependency 'rubocop', '~> 1.67.0'
50
50
  spec.add_development_dependency 'rubocop-shopify', '~> 2.15.1'
51
51
  spec.add_development_dependency 'vcr'
52
52
  spec.add_development_dependency 'webmock'
@@ -23,5 +23,9 @@
23
23
  "token_uri": "https://oauth2.googleapis.com/token",
24
24
  "auth_provider_x509_cert_url": "auth_provider_x509_cert_url",
25
25
  "client_x509_cert_url": "client_x509_cert_url"
26
+ },
27
+ "cloudflare": {
28
+ "api_fqdn": "api.cloudflare.com",
29
+ "api_token": "local-dev-key"
26
30
  }
27
31
  }
metadata CHANGED
@@ -1,15 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: record_store
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.0.1
4
+ version: 7.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Willem van Bergen
8
8
  - Emil Stolarsky
9
- autorequire:
9
+ autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2024-06-19 00:00:00.000000000 Z
12
+ date: 2024-10-22 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activemodel
@@ -145,16 +145,22 @@ dependencies:
145
145
  name: google-cloud-dns
146
146
  requirement: !ruby/object:Gem::Requirement
147
147
  requirements:
148
- - - "~>"
148
+ - - ">="
149
149
  - !ruby/object:Gem::Version
150
150
  version: '0.31'
151
+ - - "<"
152
+ - !ruby/object:Gem::Version
153
+ version: '2.0'
151
154
  type: :runtime
152
155
  prerelease: false
153
156
  version_requirements: !ruby/object:Gem::Requirement
154
157
  requirements:
155
- - - "~>"
158
+ - - ">="
156
159
  - !ruby/object:Gem::Version
157
160
  version: '0.31'
161
+ - - "<"
162
+ - !ruby/object:Gem::Version
163
+ version: '2.0'
158
164
  - !ruby/object:Gem::Dependency
159
165
  name: ns1
160
166
  requirement: !ruby/object:Gem::Requirement
@@ -299,14 +305,14 @@ dependencies:
299
305
  requirements:
300
306
  - - "~>"
301
307
  - !ruby/object:Gem::Version
302
- version: 1.64.0
308
+ version: 1.67.0
303
309
  type: :development
304
310
  prerelease: false
305
311
  version_requirements: !ruby/object:Gem::Requirement
306
312
  requirements:
307
313
  - - "~>"
308
314
  - !ruby/object:Gem::Version
309
- version: 1.64.0
315
+ version: 1.67.0
310
316
  - !ruby/object:Gem::Dependency
311
317
  name: rubocop-shopify
312
318
  requirement: !ruby/object:Gem::Requirement
@@ -383,6 +389,9 @@ files:
383
389
  - lib/record_store/changeset.rb
384
390
  - lib/record_store/cli.rb
385
391
  - lib/record_store/provider.rb
392
+ - lib/record_store/provider/cloudflare.rb
393
+ - lib/record_store/provider/cloudflare/client.rb
394
+ - lib/record_store/provider/cloudflare/response.rb
386
395
  - lib/record_store/provider/dnsimple.rb
387
396
  - lib/record_store/provider/dnsimple/patch_api_header.rb
388
397
  - lib/record_store/provider/dnsimple/patch_request_error_to_include_errors.rb
@@ -431,7 +440,7 @@ licenses:
431
440
  - MIT
432
441
  metadata:
433
442
  allowed_push_host: https://rubygems.org
434
- post_install_message:
443
+ post_install_message:
435
444
  rdoc_options: []
436
445
  require_paths:
437
446
  - lib
@@ -446,8 +455,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
446
455
  - !ruby/object:Gem::Version
447
456
  version: '0'
448
457
  requirements: []
449
- rubygems_version: 3.5.13
450
- signing_key:
458
+ rubygems_version: 3.5.22
459
+ signing_key:
451
460
  specification_version: 4
452
461
  summary: Manage DNS using git
453
462
  test_files: []