dnsmadeeasy 0.4.0 → 1.0.5
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 +854 -0
- data/Rakefile +4 -4
- data/dnsmadeeasy.gemspec +56 -32
- 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/plans/11-plan-zone-cli-arguments.md +114 -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 +407 -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 +93 -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 +219 -0
- data/lib/dnsmadeeasy/zone/apply_result.rb +22 -0
- data/lib/dnsmadeeasy/zone/diff.rb +174 -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 +119 -0
- data/lib/dnsmadeeasy.rb +61 -27
- metadata +212 -39
- data/.dme-help.png +0 -0
- data/README.adoc +0 -690
|
@@ -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,119 @@
|
|
|
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
|
+
# Fixed columns: owner (30), TTL (5, blank when it matches $TTL),
|
|
10
|
+
# "IN " (3), record type (7), then the payload.
|
|
11
|
+
RECORD_FORMAT = '%-30s %5s %-3s%-7s %s'
|
|
12
|
+
|
|
13
|
+
def initialize(zone_file, omit_apex_ns: true)
|
|
14
|
+
@zone_file = zone_file
|
|
15
|
+
@omit_apex_ns = omit_apex_ns
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_s
|
|
19
|
+
lines = [
|
|
20
|
+
"$ORIGIN #{zone_file.origin}",
|
|
21
|
+
"$TTL #{zone_file.ttl}",
|
|
22
|
+
''
|
|
23
|
+
]
|
|
24
|
+
lines.concat(serialized_records)
|
|
25
|
+
"#{lines.join("\n")}\n"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
attr_reader :zone_file,
|
|
31
|
+
:omit_apex_ns
|
|
32
|
+
|
|
33
|
+
def serialized_records
|
|
34
|
+
grouped_records.flat_map.with_index do |records, index|
|
|
35
|
+
lines = records.map { |record| serialize_record(record) }
|
|
36
|
+
index.zero? ? lines : [''] + lines
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def grouped_records
|
|
41
|
+
serializable_records.chunk { |record| record_group(record) }.map(&:last)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def serializable_records
|
|
45
|
+
zone_file.records
|
|
46
|
+
.reject { |record| omit_apex_ns && record.owner == '@' && record.type == 'NS' }
|
|
47
|
+
.sort_by { |record| serializer_sort_key(record) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def record_group(record)
|
|
51
|
+
case record.type
|
|
52
|
+
when 'MX'
|
|
53
|
+
:mail
|
|
54
|
+
when 'TXT', 'SPF'
|
|
55
|
+
:text
|
|
56
|
+
else
|
|
57
|
+
:address
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def serializer_sort_key(record)
|
|
62
|
+
[
|
|
63
|
+
serializer_type_group(record),
|
|
64
|
+
normalized_owner(record.owner),
|
|
65
|
+
record.type,
|
|
66
|
+
record.priority || -1,
|
|
67
|
+
record.value,
|
|
68
|
+
record.ttl
|
|
69
|
+
]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def serializer_type_group(record)
|
|
73
|
+
case record.type
|
|
74
|
+
when 'A', 'AAAA', 'CNAME', 'ANAME', 'NS', 'PTR'
|
|
75
|
+
0
|
|
76
|
+
when 'MX', 'SRV'
|
|
77
|
+
1
|
|
78
|
+
when 'TXT', 'SPF'
|
|
79
|
+
2
|
|
80
|
+
else
|
|
81
|
+
3
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def normalized_owner(owner)
|
|
86
|
+
owner == '@' ? '' : owner
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def serialize_record(record)
|
|
90
|
+
format(RECORD_FORMAT, record.owner, ttl_token(record), 'IN', record.type, record_payload(record))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A single $TTL cannot represent a real zone, so records that deviate
|
|
94
|
+
# from the zone default carry an explicit right-aligned TTL.
|
|
95
|
+
def ttl_token(record)
|
|
96
|
+
record.ttl == zone_file.ttl ? '' : record.ttl.to_s
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def record_payload(record)
|
|
100
|
+
case record.type
|
|
101
|
+
when 'MX'
|
|
102
|
+
"#{record.priority} #{record.value}"
|
|
103
|
+
when 'SRV'
|
|
104
|
+
"#{record.priority} #{record.weight} #{record.port} #{record.value}"
|
|
105
|
+
when 'TXT', 'SPF'
|
|
106
|
+
quote_text(record.value)
|
|
107
|
+
else
|
|
108
|
+
record.value
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def quote_text(value)
|
|
113
|
+
return value if value.start_with?('"') && value.end_with?('"')
|
|
114
|
+
|
|
115
|
+
%("#{value}")
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
data/lib/dnsmadeeasy.rb
CHANGED
|
@@ -6,25 +6,49 @@ module DnsMadeEasy
|
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
require 'dnsmadeeasy/version'
|
|
9
|
+
require 'dnsmadeeasy/types'
|
|
9
10
|
require 'dnsmadeeasy/credentials'
|
|
10
11
|
require 'dnsmadeeasy/api/client'
|
|
12
|
+
require 'dnsmadeeasy/cli/launcher'
|
|
13
|
+
require 'dnsmadeeasy/zone/record'
|
|
14
|
+
require 'dnsmadeeasy/zone/provider_record'
|
|
15
|
+
require 'dnsmadeeasy/zone/record_set'
|
|
16
|
+
require 'dnsmadeeasy/zone/file'
|
|
17
|
+
require 'dnsmadeeasy/zone/parser'
|
|
18
|
+
require 'dnsmadeeasy/zone/aname_flattener'
|
|
19
|
+
require 'dnsmadeeasy/zone/apply_executor'
|
|
20
|
+
require 'dnsmadeeasy/zone/apply_result'
|
|
21
|
+
require 'dnsmadeeasy/zone/diff'
|
|
22
|
+
require 'dnsmadeeasy/zone/plan'
|
|
23
|
+
require 'dnsmadeeasy/zone/plan_action'
|
|
24
|
+
require 'dnsmadeeasy/zone/plan_renderer'
|
|
25
|
+
require 'dnsmadeeasy/zone/remote_adapter'
|
|
26
|
+
require 'dnsmadeeasy/zone/remote_records'
|
|
27
|
+
require 'dnsmadeeasy/zone/serializer'
|
|
11
28
|
|
|
12
29
|
module DnsMadeEasy
|
|
13
|
-
class Error < StandardError
|
|
30
|
+
class Error < StandardError
|
|
14
31
|
end
|
|
15
|
-
|
|
32
|
+
|
|
33
|
+
class AuthenticationError < Error
|
|
16
34
|
end
|
|
17
|
-
|
|
35
|
+
|
|
36
|
+
class APIKeyAndSecretMissingError < Error
|
|
18
37
|
end
|
|
19
|
-
|
|
38
|
+
|
|
39
|
+
class InvalidCredentialKeys < Error
|
|
20
40
|
end
|
|
21
|
-
|
|
41
|
+
|
|
42
|
+
class AbstractMethodError < Error
|
|
22
43
|
end
|
|
23
|
-
|
|
44
|
+
|
|
45
|
+
class InvalidCredentialsFormatError < Error
|
|
24
46
|
end
|
|
25
|
-
|
|
47
|
+
|
|
48
|
+
class NoSuchAccountError < Error
|
|
26
49
|
end
|
|
27
|
-
|
|
50
|
+
|
|
51
|
+
class NoDomainError < Error
|
|
28
52
|
end
|
|
29
53
|
|
|
30
54
|
class << self
|
|
@@ -38,26 +62,22 @@ module DnsMadeEasy
|
|
|
38
62
|
def configure_from_file(file = nil,
|
|
39
63
|
account = nil,
|
|
40
64
|
encryption_key = nil)
|
|
41
|
-
|
|
42
65
|
credentials = ::DnsMadeEasy::Credentials.keys_from_file(
|
|
43
|
-
file: file || ::DnsMadeEasy::Credentials.default_credentials_path(user: ENV
|
|
66
|
+
file: file || ::DnsMadeEasy::Credentials.default_credentials_path(user: ENV.fetch('USER', nil)),
|
|
44
67
|
account: account,
|
|
45
68
|
encryption_key: encryption_key
|
|
46
69
|
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
else
|
|
53
|
-
raise APIKeyAndSecretMissingError, "Unable to load valid api keys from #{file}!"
|
|
70
|
+
raise APIKeyAndSecretMissingError, "Unable to load valid api keys from #{file}!" unless credentials
|
|
71
|
+
|
|
72
|
+
configure do |config|
|
|
73
|
+
config.api_key = credentials.api_key
|
|
74
|
+
config.api_secret = credentials.api_secret
|
|
54
75
|
end
|
|
55
76
|
end
|
|
56
77
|
|
|
57
78
|
def credentials_from_file(file: DnsMadeEasy::Credentials.default_credentials_path,
|
|
58
79
|
account: nil,
|
|
59
80
|
encryption_key: nil)
|
|
60
|
-
|
|
61
81
|
DnsMadeEasy::Credentials.keys_from_file file: file,
|
|
62
82
|
account: account,
|
|
63
83
|
encryption_key: encryption_key
|
|
@@ -67,37 +87,51 @@ module DnsMadeEasy
|
|
|
67
87
|
self.default_api_key = value
|
|
68
88
|
end
|
|
69
89
|
|
|
90
|
+
def api_key
|
|
91
|
+
default_api_key
|
|
92
|
+
end
|
|
93
|
+
|
|
70
94
|
def api_secret=(value)
|
|
71
95
|
self.default_api_secret = value
|
|
72
96
|
end
|
|
73
97
|
|
|
74
|
-
def
|
|
75
|
-
|
|
98
|
+
def api_secret
|
|
99
|
+
default_api_secret
|
|
76
100
|
end
|
|
77
101
|
|
|
78
|
-
def
|
|
79
|
-
@
|
|
102
|
+
def client(**)
|
|
103
|
+
@client ||= create_client(false, **)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def sandbox_client(**)
|
|
107
|
+
@sandbox_client ||= create_client(true, **)
|
|
80
108
|
end
|
|
81
109
|
|
|
82
110
|
def create_client(sandbox = false,
|
|
83
111
|
api_key: default_api_key,
|
|
84
112
|
api_secret: default_api_secret,
|
|
85
113
|
|
|
86
|
-
**
|
|
114
|
+
**)
|
|
87
115
|
raise APIKeyAndSecretMissingError, 'Please set #api_key and #api_secret' unless api_key && api_secret
|
|
88
116
|
|
|
89
|
-
::DnsMadeEasy::Api::Client.new(api_key, api_secret, sandbox, **
|
|
117
|
+
::DnsMadeEasy::Api::Client.new(api_key, api_secret, sandbox, **)
|
|
90
118
|
end
|
|
91
119
|
|
|
92
120
|
# Basically delegate it all to the Client instance
|
|
93
121
|
# if the method call is supported.
|
|
94
122
|
#
|
|
95
|
-
def method_missing(method,
|
|
123
|
+
def method_missing(method, ...)
|
|
96
124
|
if client.respond_to?(method)
|
|
97
|
-
client.send(method,
|
|
125
|
+
client.send(method, ...)
|
|
98
126
|
else
|
|
99
|
-
super
|
|
127
|
+
super
|
|
100
128
|
end
|
|
101
129
|
end
|
|
130
|
+
|
|
131
|
+
def respond_to_missing?(method, include_private = false)
|
|
132
|
+
client.respond_to?(method, include_private) || super
|
|
133
|
+
rescue APIKeyAndSecretMissingError
|
|
134
|
+
super
|
|
135
|
+
end
|
|
102
136
|
end
|
|
103
137
|
end
|