dnsmadeeasy 0.4.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ruby.yml +5 -10
- data/.gitignore +2 -0
- data/.rubocop.yml +5 -4
- data/.rubocop_todo.yml +251 -143
- data/CLAUDE.md +67 -0
- data/Gemfile +9 -1
- data/README.md +858 -0
- data/Rakefile +4 -4
- data/dnsmadeeasy.gemspec +42 -26
- data/docs/badges/coverage_badge.svg +21 -0
- data/docs/dry-cli-based-tools.webloc +8 -0
- data/docs/plan-zone-management.md +756 -0
- data/docs/plans/01-plan-ruby4-baseline.md +38 -0
- data/docs/plans/02-plan-dry-cli-launcher.md +44 -0
- data/docs/plans/03-plan-account-command.md +43 -0
- data/docs/plans/04-plan-zone-record-model.md +48 -0
- data/docs/plans/05-plan-zone-parser.md +41 -0
- data/docs/plans/06-plan-zone-formatter.md +43 -0
- data/docs/plans/07-plan-zone-export.md +58 -0
- data/docs/plans/08-plan-zone-diff.md +42 -0
- data/docs/plans/09-plan-zone-apply.md +69 -0
- data/docs/plans/10-plan-docs-cleanup.md +55 -0
- data/docs/spec-zone-management.md +242 -0
- data/exe/dme +13 -2
- data/exe/dmez +6 -0
- data/lib/dme.rb +7 -2
- data/lib/dnsmadeeasy/api/client.rb +32 -26
- data/lib/dnsmadeeasy/cli/box_output.rb +9 -0
- data/lib/dnsmadeeasy/cli/commands/account.rb +222 -0
- data/lib/dnsmadeeasy/cli/commands/base.rb +119 -0
- data/lib/dnsmadeeasy/cli/commands/legacy_operation.rb +30 -0
- data/lib/dnsmadeeasy/cli/commands/version.rb +22 -0
- data/lib/dnsmadeeasy/cli/commands/zone.rb +326 -0
- data/lib/dnsmadeeasy/cli/commands.rb +19 -0
- data/lib/dnsmadeeasy/cli/input.rb +14 -0
- data/lib/dnsmadeeasy/cli/launcher.rb +53 -0
- data/lib/dnsmadeeasy/cli/message_helpers.rb +81 -0
- data/lib/dnsmadeeasy/cli/reported_error.rb +10 -0
- data/lib/dnsmadeeasy/credentials/api_keys.rb +11 -9
- data/lib/dnsmadeeasy/credentials.rb +0 -1
- data/lib/dnsmadeeasy/runner.rb +24 -24
- data/lib/dnsmadeeasy/types.rb +19 -0
- data/lib/dnsmadeeasy/version.rb +2 -1
- data/lib/dnsmadeeasy/zone/aname_flattener.rb +63 -0
- data/lib/dnsmadeeasy/zone/apply_executor.rb +189 -0
- data/lib/dnsmadeeasy/zone/apply_result.rb +22 -0
- data/lib/dnsmadeeasy/zone/diff.rb +152 -0
- data/lib/dnsmadeeasy/zone/file.rb +26 -0
- data/lib/dnsmadeeasy/zone/parser.rb +172 -0
- data/lib/dnsmadeeasy/zone/plan.rb +28 -0
- data/lib/dnsmadeeasy/zone/plan_action.rb +29 -0
- data/lib/dnsmadeeasy/zone/plan_renderer.rb +91 -0
- data/lib/dnsmadeeasy/zone/provider_record.rb +18 -0
- data/lib/dnsmadeeasy/zone/record.rb +44 -0
- data/lib/dnsmadeeasy/zone/record_set.rb +23 -0
- data/lib/dnsmadeeasy/zone/remote_adapter.rb +94 -0
- data/lib/dnsmadeeasy/zone/remote_records.rb +26 -0
- data/lib/dnsmadeeasy/zone/serializer.rb +115 -0
- data/lib/dnsmadeeasy.rb +61 -27
- metadata +184 -25
- data/.dme-help.png +0 -0
- data/README.adoc +0 -690
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dns/zonefile'
|
|
4
|
+
require 'dry/monads'
|
|
5
|
+
require 'dnsmadeeasy/zone/file'
|
|
6
|
+
require 'dnsmadeeasy/zone/record'
|
|
7
|
+
require 'dnsmadeeasy/zone/record_set'
|
|
8
|
+
|
|
9
|
+
module DnsMadeEasy
|
|
10
|
+
module Zone
|
|
11
|
+
# Parses standard zone-file text into provider-neutral records.
|
|
12
|
+
class Parser
|
|
13
|
+
include Dry::Monads[:result]
|
|
14
|
+
|
|
15
|
+
# ANAME is absent from the dns-zonefile grammar; ANAME lines are
|
|
16
|
+
# rewritten to CNAME before parsing and converted back afterwards.
|
|
17
|
+
SUPPORTED_RECORD_CLASSES = {
|
|
18
|
+
'DNS::Zonefile::A' => 'A',
|
|
19
|
+
'DNS::Zonefile::AAAA' => 'AAAA',
|
|
20
|
+
'DNS::Zonefile::CNAME' => 'CNAME',
|
|
21
|
+
'DNS::Zonefile::MX' => 'MX',
|
|
22
|
+
'DNS::Zonefile::NS' => 'NS',
|
|
23
|
+
'DNS::Zonefile::PTR' => 'PTR',
|
|
24
|
+
'DNS::Zonefile::SPF' => 'SPF',
|
|
25
|
+
'DNS::Zonefile::SRV' => 'SRV',
|
|
26
|
+
'DNS::Zonefile::TXT' => 'TXT'
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
ANAME_LINE = /^(?<owner>[^\s;]\S*)\s+(?:\d+\s+)?(?:IN\s+)?ANAME\s+(?<target>\S+)/i
|
|
30
|
+
|
|
31
|
+
def initialize(zone_text)
|
|
32
|
+
@zone_text = zone_text
|
|
33
|
+
@aname_keys = []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call
|
|
37
|
+
zone = DNS::Zonefile.load(parseable_zone_text)
|
|
38
|
+
records, errors = build_records(zone)
|
|
39
|
+
return Failure(errors) if errors.any?
|
|
40
|
+
|
|
41
|
+
Success(File.new(origin: zone_origin(zone), ttl: zone_ttl(zone), record_set: RecordSet.new(records: records)))
|
|
42
|
+
rescue DNS::Zonefile::ParsingError, DNS::Zonefile::UnknownRecordType, Dry::Struct::Error => e
|
|
43
|
+
Failure([e.message])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
attr_reader :zone_text,
|
|
49
|
+
:aname_keys
|
|
50
|
+
|
|
51
|
+
def parseable_zone_text
|
|
52
|
+
zone_lines = rewrite_aname_lines(zone_text.lines)
|
|
53
|
+
insert_synthetic_soa(zone_lines) unless zone_text.match?(/\bSOA\b/)
|
|
54
|
+
zone_lines.join
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def rewrite_aname_lines(zone_lines)
|
|
58
|
+
zone_lines.map do |line|
|
|
59
|
+
match = ANAME_LINE.match(line)
|
|
60
|
+
next line unless match
|
|
61
|
+
|
|
62
|
+
aname_keys << [match[:owner], match[:target]]
|
|
63
|
+
line.sub(/\bANAME\b/i, 'CNAME')
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def insert_synthetic_soa(zone_lines)
|
|
68
|
+
insertion_index = zone_lines.index { |line| !line.strip.start_with?('$') && !line.strip.empty? } || zone_lines.length
|
|
69
|
+
zone_lines.insert(insertion_index, synthetic_soa_line)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def synthetic_soa_line
|
|
73
|
+
"@ IN SOA ns.#{origin_from_text} hostmaster.#{origin_from_text} ( 1 1d 1d 4W #{ttl_from_text} )\n"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def origin_from_text
|
|
77
|
+
zone_text[/^\$ORIGIN\s+(\S+)/, 1] || 'example.invalid.'
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def ttl_from_text
|
|
81
|
+
zone_text[/^\$TTL\s+(\d+)/, 1] || 300
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def build_records(zone)
|
|
85
|
+
origin = zone_origin(zone)
|
|
86
|
+
zone.records.each_with_object([[], []]) do |provider_record, (records, errors)|
|
|
87
|
+
next if provider_record.is_a?(DNS::Zonefile::SOA)
|
|
88
|
+
|
|
89
|
+
record_type = SUPPORTED_RECORD_CLASSES[provider_record.class.name]
|
|
90
|
+
if record_type
|
|
91
|
+
records << build_record(provider_record, record_type, origin)
|
|
92
|
+
else
|
|
93
|
+
errors << "Unsupported DNS record type: #{provider_record.class.name.split('::').last}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def zone_origin(zone)
|
|
99
|
+
zone.soa&.origin || '.'
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def zone_ttl(zone)
|
|
103
|
+
zone.soa&.ttl || 300
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def build_record(provider_record, record_type, origin)
|
|
107
|
+
record_type = 'ANAME' if record_type == 'CNAME' && aname?(provider_record, origin)
|
|
108
|
+
attributes = {
|
|
109
|
+
owner: normalize_owner(provider_record.host, origin),
|
|
110
|
+
type: record_type,
|
|
111
|
+
value: record_value(provider_record, record_type, origin),
|
|
112
|
+
ttl: provider_record.ttl
|
|
113
|
+
}.merge(record_metadata(provider_record, record_type))
|
|
114
|
+
|
|
115
|
+
Record.new(attributes)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def normalize_owner(host, origin)
|
|
119
|
+
return '@' if host == origin
|
|
120
|
+
|
|
121
|
+
host.delete_suffix(".#{origin}").delete_suffix('.')
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def record_value(provider_record, record_type, origin)
|
|
125
|
+
case record_type
|
|
126
|
+
when 'A', 'AAAA'
|
|
127
|
+
provider_record.address
|
|
128
|
+
when 'ANAME', 'CNAME', 'MX', 'NS', 'PTR', 'SRV'
|
|
129
|
+
normalize_target(provider_record.domainname, origin)
|
|
130
|
+
when 'SPF', 'TXT'
|
|
131
|
+
normalize_text_value(provider_record.data)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def normalize_target(target, origin)
|
|
136
|
+
return '@' if target == origin
|
|
137
|
+
|
|
138
|
+
target
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def aname?(provider_record, origin)
|
|
142
|
+
owner = normalize_owner(provider_record.host, origin)
|
|
143
|
+
target = absolute_name(provider_record.domainname, origin)
|
|
144
|
+
aname_keys.any? do |key_owner, key_target|
|
|
145
|
+
normalize_owner(absolute_name(key_owner, origin), origin) == owner &&
|
|
146
|
+
absolute_name(key_target, origin) == target
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def absolute_name(name, origin)
|
|
151
|
+
return origin if name == '@'
|
|
152
|
+
|
|
153
|
+
name.end_with?('.') ? name : "#{name}.#{origin}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def normalize_text_value(value)
|
|
157
|
+
value.delete_prefix('"').delete_suffix('"')
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def record_metadata(provider_record, record_type)
|
|
161
|
+
case record_type
|
|
162
|
+
when 'MX'
|
|
163
|
+
{ priority: provider_record.priority }
|
|
164
|
+
when 'SRV'
|
|
165
|
+
{ priority: provider_record.priority, weight: provider_record.weight, port: provider_record.port }
|
|
166
|
+
else
|
|
167
|
+
{}
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dry/struct'
|
|
4
|
+
require 'dnsmadeeasy/types'
|
|
5
|
+
require 'dnsmadeeasy/zone/plan_action'
|
|
6
|
+
|
|
7
|
+
module DnsMadeEasy
|
|
8
|
+
module Zone
|
|
9
|
+
# Diff result describing intended changes without applying them.
|
|
10
|
+
class Plan < Dry::Struct
|
|
11
|
+
transform_keys(&:to_sym)
|
|
12
|
+
|
|
13
|
+
attribute :creates, Types::Array.of(PlanAction).default([].freeze)
|
|
14
|
+
attribute :updates, Types::Array.of(PlanAction).default([].freeze)
|
|
15
|
+
attribute :skipped_creates, Types::Array.of(PlanAction).default([].freeze)
|
|
16
|
+
attribute :skipped_deletes, Types::Array.of(PlanAction).default([].freeze)
|
|
17
|
+
attribute :ambiguous, Types::Array.of(PlanAction).default([].freeze)
|
|
18
|
+
|
|
19
|
+
def actions
|
|
20
|
+
[creates, updates, skipped_creates, skipped_deletes, ambiguous].flatten.sort_by(&:sort_key)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def empty?
|
|
24
|
+
actions.empty?
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dry/struct'
|
|
4
|
+
require 'dnsmadeeasy/types'
|
|
5
|
+
require 'dnsmadeeasy/zone/record'
|
|
6
|
+
|
|
7
|
+
module DnsMadeEasy
|
|
8
|
+
module Zone
|
|
9
|
+
# A single proposed zone-management action.
|
|
10
|
+
class PlanAction < Dry::Struct
|
|
11
|
+
transform_keys(&:to_sym)
|
|
12
|
+
|
|
13
|
+
ACTION_TYPES = %w[create update skipped_create skipped_delete ambiguous].freeze
|
|
14
|
+
|
|
15
|
+
attribute :action, Types::StrictString.enum(*ACTION_TYPES)
|
|
16
|
+
attribute :record, Record.optional.default(nil)
|
|
17
|
+
attribute :remote_record, Record.optional.default(nil)
|
|
18
|
+
attribute :desired_record, Record.optional.default(nil)
|
|
19
|
+
attribute :message, Types::OptionalString.default(nil)
|
|
20
|
+
|
|
21
|
+
def sort_key
|
|
22
|
+
[
|
|
23
|
+
ACTION_TYPES.index(action),
|
|
24
|
+
(record || desired_record || remote_record)&.sort_key || []
|
|
25
|
+
]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module DnsMadeEasy
|
|
6
|
+
module Zone
|
|
7
|
+
# Renders zone plans for humans and automation.
|
|
8
|
+
class PlanRenderer
|
|
9
|
+
def initialize(plan)
|
|
10
|
+
@plan = plan
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_text
|
|
14
|
+
return "No changes.\n" if plan.empty?
|
|
15
|
+
|
|
16
|
+
[
|
|
17
|
+
section('Create', plan.creates),
|
|
18
|
+
section('Update', plan.updates),
|
|
19
|
+
section('Skipped Creates', plan.skipped_creates),
|
|
20
|
+
section('Skipped Deletes', plan.skipped_deletes),
|
|
21
|
+
section('Manual Review', plan.ambiguous)
|
|
22
|
+
].reject(&:empty?).join("\n")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_json(*)
|
|
26
|
+
JSON.pretty_generate(plan_hash)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :plan
|
|
32
|
+
|
|
33
|
+
def section(title, actions)
|
|
34
|
+
return '' if actions.empty?
|
|
35
|
+
|
|
36
|
+
([title] + actions.sort_by(&:sort_key).map { |action| " - #{action_line(action)}" }).join("\n")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def action_line(action)
|
|
40
|
+
case action.action
|
|
41
|
+
when 'create'
|
|
42
|
+
record_line(action.record)
|
|
43
|
+
when 'update'
|
|
44
|
+
"#{record_line(action.remote_record)} -> #{record_line(action.desired_record)}"
|
|
45
|
+
when 'skipped_create', 'skipped_delete'
|
|
46
|
+
"#{record_line(action.record)} (#{action.message})"
|
|
47
|
+
when 'ambiguous'
|
|
48
|
+
"#{record_line(action.remote_record)} -> #{record_line(action.desired_record)} (#{action.message})"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def record_line(record)
|
|
53
|
+
[record.owner, record.type, record.priority, record.value, "(ttl=#{record.ttl})"].compact.join(' ')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def plan_hash
|
|
57
|
+
{
|
|
58
|
+
creates: plan.creates.map { |action| action_hash(action) },
|
|
59
|
+
updates: plan.updates.map { |action| action_hash(action) },
|
|
60
|
+
skipped_creates: plan.skipped_creates.map { |action| action_hash(action) },
|
|
61
|
+
skipped_deletes: plan.skipped_deletes.map { |action| action_hash(action) },
|
|
62
|
+
ambiguous: plan.ambiguous.map { |action| action_hash(action) }
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def action_hash(action)
|
|
67
|
+
{
|
|
68
|
+
action: action.action,
|
|
69
|
+
record: record_hash(action.record),
|
|
70
|
+
remote_record: record_hash(action.remote_record),
|
|
71
|
+
desired_record: record_hash(action.desired_record),
|
|
72
|
+
message: action.message
|
|
73
|
+
}.compact
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def record_hash(record)
|
|
77
|
+
return unless record
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
owner: record.owner,
|
|
81
|
+
type: record.type,
|
|
82
|
+
value: record.value,
|
|
83
|
+
ttl: record.ttl,
|
|
84
|
+
priority: record.priority,
|
|
85
|
+
weight: record.weight,
|
|
86
|
+
port: record.port
|
|
87
|
+
}.compact
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dry/struct'
|
|
4
|
+
require 'dnsmadeeasy/types'
|
|
5
|
+
require 'dnsmadeeasy/zone/record'
|
|
6
|
+
|
|
7
|
+
module DnsMadeEasy
|
|
8
|
+
module Zone
|
|
9
|
+
# Associates provider metadata with a provider-neutral DNS record.
|
|
10
|
+
class ProviderRecord < Dry::Struct
|
|
11
|
+
transform_keys(&:to_sym)
|
|
12
|
+
|
|
13
|
+
attribute :record, Record
|
|
14
|
+
attribute :provider_id, Types::Coercible::Integer.optional.default(nil)
|
|
15
|
+
attribute :source_id, Types::OptionalString.default(nil)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dry/struct'
|
|
4
|
+
require 'dnsmadeeasy/types'
|
|
5
|
+
|
|
6
|
+
module DnsMadeEasy
|
|
7
|
+
module Zone
|
|
8
|
+
# Provider-neutral DNS record value object.
|
|
9
|
+
class Record < Dry::Struct
|
|
10
|
+
transform_keys(&:to_sym)
|
|
11
|
+
|
|
12
|
+
attribute :owner, Types::NonEmptyString
|
|
13
|
+
attribute :type, Types::RecordType
|
|
14
|
+
attribute :value, Types::NonEmptyString
|
|
15
|
+
attribute :ttl, Types::Ttl.default(300)
|
|
16
|
+
attribute :priority, Types::OptionalInteger.default(nil)
|
|
17
|
+
attribute :weight, Types::OptionalInteger.default(nil)
|
|
18
|
+
attribute :port, Types::OptionalInteger.default(nil)
|
|
19
|
+
|
|
20
|
+
def sort_key
|
|
21
|
+
[
|
|
22
|
+
normalized_owner,
|
|
23
|
+
type_order,
|
|
24
|
+
type,
|
|
25
|
+
priority || -1,
|
|
26
|
+
weight || -1,
|
|
27
|
+
port || -1,
|
|
28
|
+
value,
|
|
29
|
+
ttl
|
|
30
|
+
]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def normalized_owner
|
|
36
|
+
owner == '@' ? '' : owner
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def type_order
|
|
40
|
+
Types::DNS_RECORD_TYPES.index(type) || Types::DNS_RECORD_TYPES.length
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dry/struct'
|
|
4
|
+
require 'dnsmadeeasy/zone/record'
|
|
5
|
+
|
|
6
|
+
module DnsMadeEasy
|
|
7
|
+
module Zone
|
|
8
|
+
# Deterministically ordered collection of zone records.
|
|
9
|
+
class RecordSet < Dry::Struct
|
|
10
|
+
transform_keys(&:to_sym)
|
|
11
|
+
|
|
12
|
+
attribute :records, Types::Array.of(Record).default([].freeze)
|
|
13
|
+
|
|
14
|
+
def sorted
|
|
15
|
+
records.sort_by(&:sort_key)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def include?(record)
|
|
19
|
+
records.include?(record)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dry/monads'
|
|
4
|
+
require 'dnsmadeeasy/zone/provider_record'
|
|
5
|
+
require 'dnsmadeeasy/zone/record'
|
|
6
|
+
require 'dnsmadeeasy/zone/remote_records'
|
|
7
|
+
|
|
8
|
+
module DnsMadeEasy
|
|
9
|
+
module Zone
|
|
10
|
+
# Converts DNS Made Easy API record hashes into provider-neutral zone records.
|
|
11
|
+
class RemoteAdapter
|
|
12
|
+
include Dry::Monads[:result]
|
|
13
|
+
|
|
14
|
+
SUPPORTED_RECORD_TYPES = Types::DNS_RECORD_TYPES.freeze
|
|
15
|
+
SOURCE_ID = 'dns-made-easy'
|
|
16
|
+
|
|
17
|
+
def initialize(response, domain:, default_ttl: 300)
|
|
18
|
+
@response = response
|
|
19
|
+
@domain = domain
|
|
20
|
+
@default_ttl = default_ttl
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
provider_records = []
|
|
25
|
+
warnings = []
|
|
26
|
+
|
|
27
|
+
remote_records.each do |remote_record|
|
|
28
|
+
if remote_record['type'] == 'HTTPRED'
|
|
29
|
+
warnings << omitted_httpred_warning(remote_record)
|
|
30
|
+
elsif SUPPORTED_RECORD_TYPES.include?(remote_record['type'])
|
|
31
|
+
provider_records << provider_record(remote_record)
|
|
32
|
+
else
|
|
33
|
+
return Failure(["Unsupported DNS Made Easy record type: #{remote_record['type']}"])
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Success(RemoteRecords.new(provider_records: provider_records, warnings: warnings))
|
|
38
|
+
rescue Dry::Struct::Error => e
|
|
39
|
+
Failure([e.message])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
attr_reader :response,
|
|
45
|
+
:domain,
|
|
46
|
+
:default_ttl
|
|
47
|
+
|
|
48
|
+
def remote_records
|
|
49
|
+
response.fetch('data', [])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def provider_record(remote_record)
|
|
53
|
+
ProviderRecord.new(
|
|
54
|
+
record: zone_record(remote_record),
|
|
55
|
+
provider_id: remote_record['id'],
|
|
56
|
+
source_id: SOURCE_ID
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def zone_record(remote_record)
|
|
61
|
+
Record.new(
|
|
62
|
+
owner: owner(remote_record),
|
|
63
|
+
type: remote_record['type'],
|
|
64
|
+
value: record_value(remote_record),
|
|
65
|
+
ttl: remote_record['ttl'] || default_ttl,
|
|
66
|
+
priority: remote_record['mxLevel'] || remote_record['priority'],
|
|
67
|
+
weight: remote_record['weight'],
|
|
68
|
+
port: remote_record['port']
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# DME returns TXT/SPF values with the zone-file quotes embedded, while
|
|
73
|
+
# Record holds unquoted text (Parser strips, Serializer re-quotes).
|
|
74
|
+
def record_value(remote_record)
|
|
75
|
+
value = remote_record['value']
|
|
76
|
+
return value unless %w[TXT SPF].include?(remote_record['type'])
|
|
77
|
+
|
|
78
|
+
value.delete_prefix('"').delete_suffix('"')
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def owner(remote_record)
|
|
82
|
+
name = remote_record['name'].to_s
|
|
83
|
+
return '@' if name.empty? || name == domain || name == domain.delete_suffix('.')
|
|
84
|
+
|
|
85
|
+
name.delete_suffix(".#{domain.delete_suffix('.')}")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def omitted_httpred_warning(remote_record)
|
|
89
|
+
record_name = remote_record['name'].to_s.empty? ? '@' : remote_record['name']
|
|
90
|
+
"Omitted HTTPRED record #{record_name} -> #{remote_record['value']}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dry/struct'
|
|
4
|
+
require 'dnsmadeeasy/types'
|
|
5
|
+
require 'dnsmadeeasy/zone/provider_record'
|
|
6
|
+
require 'dnsmadeeasy/zone/record_set'
|
|
7
|
+
|
|
8
|
+
module DnsMadeEasy
|
|
9
|
+
module Zone
|
|
10
|
+
# Provider records plus non-fatal warnings from remote conversion.
|
|
11
|
+
class RemoteRecords < Dry::Struct
|
|
12
|
+
transform_keys(&:to_sym)
|
|
13
|
+
|
|
14
|
+
attribute :provider_records, Types::Array.of(ProviderRecord).default([].freeze)
|
|
15
|
+
attribute :warnings, Types::Array.of(Types::StrictString).default([].freeze)
|
|
16
|
+
|
|
17
|
+
def records
|
|
18
|
+
provider_records.map(&:record)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def record_set
|
|
22
|
+
RecordSet.new(records: records)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dnsmadeeasy/zone/file'
|
|
4
|
+
|
|
5
|
+
module DnsMadeEasy
|
|
6
|
+
module Zone
|
|
7
|
+
# Emits deterministic, normalized zone-file text.
|
|
8
|
+
class Serializer
|
|
9
|
+
def initialize(zone_file, omit_apex_ns: true)
|
|
10
|
+
@zone_file = zone_file
|
|
11
|
+
@omit_apex_ns = omit_apex_ns
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def to_s
|
|
15
|
+
lines = [
|
|
16
|
+
"$ORIGIN #{zone_file.origin}",
|
|
17
|
+
"$TTL #{zone_file.ttl}",
|
|
18
|
+
''
|
|
19
|
+
]
|
|
20
|
+
lines.concat(serialized_records)
|
|
21
|
+
"#{lines.join("\n")}\n"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
attr_reader :zone_file,
|
|
27
|
+
:omit_apex_ns
|
|
28
|
+
|
|
29
|
+
def serialized_records
|
|
30
|
+
grouped_records.flat_map.with_index do |records, index|
|
|
31
|
+
lines = records.map { |record| serialize_record(record) }
|
|
32
|
+
index.zero? ? lines : [''] + lines
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def grouped_records
|
|
37
|
+
serializable_records.chunk { |record| record_group(record) }.map(&:last)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def serializable_records
|
|
41
|
+
zone_file.records
|
|
42
|
+
.reject { |record| omit_apex_ns && record.owner == '@' && record.type == 'NS' }
|
|
43
|
+
.sort_by { |record| serializer_sort_key(record) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def record_group(record)
|
|
47
|
+
case record.type
|
|
48
|
+
when 'MX'
|
|
49
|
+
:mail
|
|
50
|
+
when 'TXT', 'SPF'
|
|
51
|
+
:text
|
|
52
|
+
else
|
|
53
|
+
:address
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def serializer_sort_key(record)
|
|
58
|
+
[
|
|
59
|
+
serializer_type_group(record),
|
|
60
|
+
normalized_owner(record.owner),
|
|
61
|
+
record.type,
|
|
62
|
+
record.priority || -1,
|
|
63
|
+
record.value,
|
|
64
|
+
record.ttl
|
|
65
|
+
]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def serializer_type_group(record)
|
|
69
|
+
case record.type
|
|
70
|
+
when 'A', 'AAAA', 'CNAME', 'ANAME', 'NS', 'PTR'
|
|
71
|
+
0
|
|
72
|
+
when 'MX', 'SRV'
|
|
73
|
+
1
|
|
74
|
+
when 'TXT', 'SPF'
|
|
75
|
+
2
|
|
76
|
+
else
|
|
77
|
+
3
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def normalized_owner(owner)
|
|
82
|
+
owner == '@' ? '' : owner
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def serialize_record(record)
|
|
86
|
+
[record.owner.ljust(8), ttl_token(record), 'IN', record.type.ljust(7), record_payload(record)].compact.join(' ')
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A single $TTL cannot represent a real zone, so records that deviate
|
|
90
|
+
# from the zone default carry an explicit TTL.
|
|
91
|
+
def ttl_token(record)
|
|
92
|
+
record.ttl == zone_file.ttl ? nil : record.ttl.to_s
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def record_payload(record)
|
|
96
|
+
case record.type
|
|
97
|
+
when 'MX'
|
|
98
|
+
"#{record.priority} #{record.value}"
|
|
99
|
+
when 'SRV'
|
|
100
|
+
"#{record.priority} #{record.weight} #{record.port} #{record.value}"
|
|
101
|
+
when 'TXT', 'SPF'
|
|
102
|
+
quote_text(record.value)
|
|
103
|
+
else
|
|
104
|
+
record.value
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def quote_text(value)
|
|
109
|
+
return value if value.start_with?('"') && value.end_with?('"')
|
|
110
|
+
|
|
111
|
+
%("#{value}")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|