mythic-beasts 0.2.0 → 1.0.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b734cfcdd42c515ebf9d1c11d0ccc95067f0da9912fbbe62742de3865265f426
4
- data.tar.gz: a53d53c0eaf241086e37cf25bad093156e5777edacabf90b291ffadfb43187d9
3
+ metadata.gz: d494ca87fec6261eef7a30bb03ca7fdab2d34c1b589de1d5cc19caaf543dfb77
4
+ data.tar.gz: ed5f8c6e915764a76e71d502e7210b850d78ce672699d34b04a33610605c1bef
5
5
  SHA512:
6
- metadata.gz: e464d4d61ac8a68ae5c2615c235e6a0357e1dcf4253a8f6bb961b690b0b97164f8dde32f486f637c29ce77893b6d83f3a0b6e314dd55a8e82a956d4567e5ebf0
7
- data.tar.gz: 580d8c412f04e39a4a8a3f25604beed9d540655ab83ce7766f60df2dd05752655003105f399b8cd6b1b79c44ba7c9bf6bef1424a76032dd773f386a6f237622b
6
+ metadata.gz: 583a0776da3cdff5fabd1ba06f2b3eab45ca6c458564c546323e8f077f4e42eb356304e9d43c0ce601914cd33d1b4366f46ba99cdb1e995f0ec80f348e2a1ac0
7
+ data.tar.gz: ac6d3815a942466222b0d536e82c7d378b1fc020a58a9892b0ba6053e2434353f62b2822b149e0cb17f69efa2802440243fac450a7074c4892cf2af1b2926757
data/CHANGELOG.md CHANGED
@@ -7,7 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [0.2.0] - 2025-11-09
10
+ ## [1.0.0] - 2026-03-12
11
+
12
+ ### Added in 1.0.0
13
+
14
+ - Email authentication verification (`verify_email_auth`, `verify_spf`, `verify_dmarc`, `verify_dkim`)
15
+ - Validates SPF records: syntax, lookup count (RFC 7208 limit of 10), multiple record detection
16
+ - Validates DMARC records: version tag, policy tag, aggregate report configuration
17
+ - Validates DKIM records: version tag, public key presence and base64 encoding
18
+ - Returns structured `Result` and `CheckResult` objects for programmatic use
19
+ - New example scripts:
20
+ - `examples/list_dns.rb` - List all DNS records for a zone
21
+ - `examples/sync_dns.rb` - Generic DNS sync from YAML with dry run/apply
22
+ - `examples/sync_from_octodns.rb` - OctoDNS zone file sync with conflict detection
23
+ - `examples/verify_email_auth.rb` - CLI for email authentication verification
24
+ - README documentation for email authentication verification and DNS examples
25
+
26
+ ## [0.2.0] - 2025-11-23
11
27
 
12
28
  ### Added in 0.2.0
13
29
 
@@ -19,7 +35,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
19
35
  - Convenience method `create_simple` for quick proxy setup
20
36
  - Full CRUD operations on proxy endpoints by domain/hostname/address
21
37
  - DNS record verification after proxy configuration
22
- - DNS management integration in manage-vps tool
38
+ - DNS management integration in `manage-vps` tool
39
+ - Comprehensive test coverage improvements:
40
+ - Added tests for VPS newer API endpoints: `servers()`, `server()`, `reboot()`, `update()`, `unprovision()`, `iso_images()`, `create_with_identifier()`
41
+ - Added test for Client PATCH method
42
+ - Added test for Proxy client initialization
43
+ - Test suite now contains 73 examples with 100% pass rate
23
44
 
24
45
  ### Changed in 0.2.0
25
46
 
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Otaina Limited
3
+ Copyright (c) 2025-2026 James Inman, Otaina Limited
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -84,6 +84,34 @@ MythicBeasts.client.dns.delete_records(
84
84
  MythicBeasts.client.dns.dynamic_update('home.example.com')
85
85
  ```
86
86
 
87
+ ### Email Authentication Verification
88
+
89
+ Verify SPF, DKIM, and DMARC records are correctly configured for a domain:
90
+
91
+ ```ruby
92
+ dns = MythicBeasts.client.dns
93
+
94
+ # Verify all email auth records at once
95
+ result = dns.verify_email_auth('example.com', dkim_selectors: ['google', 'mailchimp'])
96
+
97
+ result.valid? # => true if no errors across all checks
98
+ result.errors # => ["No DKIM record found for selector 'mailchimp'"]
99
+ result.warnings # => ["DMARC record missing 'rua' tag (no aggregate reports)"]
100
+
101
+ # Access individual check results
102
+ result.spf.record # => "v=spf1 include:_spf.google.com ~all"
103
+ result.spf.details[:lookup_count] # => 1
104
+ result.dmarc.details[:policy] # => "reject"
105
+ result.dkim['google'].valid? # => true
106
+
107
+ # Or verify individual record types
108
+ spf = dns.verify_spf('example.com')
109
+ dmarc = dns.verify_dmarc('example.com')
110
+ dkim = dns.verify_dkim('example.com', 'google')
111
+ ```
112
+
113
+ See `examples/verify_email_auth.rb` for a complete CLI script.
114
+
87
115
  ### VPS Management
88
116
 
89
117
  #### Interactive CLI Tool (Recommended)
@@ -100,7 +128,12 @@ export MYTHIC_BEASTS_API_SECRET="your_secret"
100
128
  mythic-beasts-provision
101
129
  ```
102
130
 
103
- The CLI will guide you through selecting options by number:
131
+ The gem ships two interactive CLI tools:
132
+
133
+ - `mythic-beasts-provision` - provision a new VPS with guided setup
134
+ - `mythic-beasts-manage-vps` - manage existing servers (DNS, proxy, controls)
135
+
136
+ The provisioning CLI guides you through selecting options by number:
104
137
 
105
138
  - Selecting product (VPS size)
106
139
  - Choosing datacenter/zone
@@ -210,7 +243,17 @@ Available error classes:
210
243
 
211
244
  See the `examples/` directory for complete working examples:
212
245
 
246
+ **DNS:**
247
+
248
+ - `examples/list_dns.rb` - List all DNS records for a zone
249
+ - `examples/sync_dns.rb` - Sync DNS records from a YAML file (with dry run/apply)
250
+ - `examples/sync_from_octodns.rb` - Sync DNS from OctoDNS zone files (with conflict detection)
251
+ - `examples/verify_email_auth.rb` - Verify SPF, DKIM, and DMARC records
252
+
253
+ **VPS:**
254
+
213
255
  - `examples/list_zones_and_types.rb` - List available zones and VPS types
256
+ - `examples/list_vps_options.rb` - List all VPS configuration options
214
257
  - `examples/provision_vps.rb` - Provision a new VPS server
215
258
  - `examples/ipv6_only_vps.rb` - Provision an IPv6-only VPS with proxy setup guide
216
259
 
@@ -219,7 +262,8 @@ Run examples with:
219
262
  ```bash
220
263
  export MYTHIC_BEASTS_API_KEY=your_key
221
264
  export MYTHIC_BEASTS_API_SECRET=your_secret
222
- ruby examples/list_zones_and_types.rb
265
+ ruby examples/list_dns.rb example.com
266
+ ruby examples/verify_email_auth.rb example.com google,mailchimp
223
267
  ```
224
268
 
225
269
  ## Development
@@ -253,7 +297,18 @@ bundle exec bundler-audit check --update # Run security audit
253
297
 
254
298
  ## Contributing
255
299
 
256
- Bug reports and pull requests are welcome on GitHub at https://github.com/tastybamboo/mythic-beasts.
300
+ Bug reports and pull requests are welcome on GitHub at
301
+ [tastybamboo/mythic-beasts](https://github.com/tastybamboo/mythic-beasts).
302
+
303
+ 1. Fork the repository
304
+ 2. Create your feature branch (`git checkout -b my-feature`)
305
+ 3. Run the test suite (`bundle exec rspec`)
306
+ 4. Ensure linting passes (`bundle exec standardrb`)
307
+ 5. Commit your changes
308
+ 6. Push to the branch and open a Pull Request
309
+
310
+ This project is intended to be a safe, welcoming space for collaboration.
311
+ Contributors are expected to adhere to the [code of conduct](CODE_OF_CONDUCT.md).
257
312
 
258
313
  ## Author
259
314
 
@@ -266,7 +321,7 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/tastyb
266
321
 
267
322
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
268
323
 
269
- Copyright © 2025, Otaina Limited
324
+ Copyright © 2025-2026, James Inman, Otaina Limited
270
325
 
271
326
  ## Resources
272
327
 
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MythicBeasts
4
+ class DNS
5
+ # Validates SPF, DKIM, and DMARC DNS records for a domain
6
+ #
7
+ # Uses the Mythic Beasts DNS API to fetch TXT records and validate
8
+ # email authentication configuration. Returns structured results
9
+ # rather than printing output, so callers can format as needed.
10
+ #
11
+ # @example Verify all email auth records
12
+ # result = client.dns.verify_email_auth("example.com", dkim_selectors: ["google", "mailchimp"])
13
+ # result.valid? # => true/false
14
+ # result.errors # => ["No SPF record found"]
15
+ # result.spf.record # => "v=spf1 include:_spf.google.com ~all"
16
+ #
17
+ # @see https://www.mythic-beasts.com/support/api/dnsv2
18
+ class EmailAuth
19
+ # Aggregated result of all email authentication checks
20
+ Result = Struct.new(:spf, :dmarc, :dkim, :errors, :warnings, keyword_init: true) do
21
+ # @return [Boolean] true if no errors were found across all checks
22
+ def valid?
23
+ errors.empty?
24
+ end
25
+ end
26
+
27
+ # Result of an individual check (SPF, DMARC, or DKIM)
28
+ CheckResult = Struct.new(:record, :valid, :errors, :warnings, :details, keyword_init: true) do
29
+ # @return [Boolean] true if this individual check passed
30
+ def valid?
31
+ valid
32
+ end
33
+ end
34
+
35
+ SPF_LOOKUP_MECHANISMS = %w[include a mx ptr exists redirect].freeze
36
+ VALID_DMARC_POLICIES = %w[none quarantine reject].freeze
37
+
38
+ # @param dns [MythicBeasts::DNS] the DNS client instance
39
+ # @param zone [String] the domain/zone to verify
40
+ def initialize(dns, zone)
41
+ @dns = dns
42
+ @zone = zone
43
+ end
44
+
45
+ # Run all email authentication checks
46
+ #
47
+ # @param dkim_selectors [Array<String>] DKIM selectors to check (e.g., ["google", "mailchimp"])
48
+ # @return [Result] aggregated result with per-check details
49
+ def verify(dkim_selectors: [])
50
+ spf_result = verify_spf
51
+ dmarc_result = verify_dmarc
52
+ dkim_results = dkim_selectors.map { |selector| [selector, verify_dkim(selector)] }.to_h
53
+
54
+ all_errors = spf_result.errors + dmarc_result.errors +
55
+ dkim_results.values.flat_map(&:errors)
56
+ all_warnings = spf_result.warnings + dmarc_result.warnings +
57
+ dkim_results.values.flat_map(&:warnings)
58
+
59
+ Result.new(
60
+ spf: spf_result,
61
+ dmarc: dmarc_result,
62
+ dkim: dkim_results,
63
+ errors: all_errors,
64
+ warnings: all_warnings
65
+ )
66
+ end
67
+
68
+ # Verify SPF record for the zone
69
+ #
70
+ # Fetches TXT records at the zone root and filters for v=spf1 records.
71
+ # Validates against RFC 7208: single record, valid syntax, <=10 DNS lookups.
72
+ #
73
+ # @return [CheckResult] with :lookup_count in details
74
+ def verify_spf
75
+ errors = []
76
+ warnings = []
77
+
78
+ txt_records = fetch_txt_records("@")
79
+ spf_records = txt_records.select { |r| r.start_with?("v=spf1") }
80
+
81
+ if spf_records.empty?
82
+ return CheckResult.new(record: nil, valid: false,
83
+ errors: ["No SPF record found"], warnings: [], details: {})
84
+ end
85
+
86
+ if spf_records.length > 1
87
+ return CheckResult.new(record: spf_records, valid: false,
88
+ errors: ["Multiple SPF records found (RFC 7208 violation)"],
89
+ warnings: [], details: {})
90
+ end
91
+
92
+ spf = spf_records.first
93
+
94
+ # Validate syntax
95
+ if spf.match?(/\ball\b/) && !spf.match?(/[~\-+?]all\s*$/)
96
+ warnings << "SPF 'all' mechanism should be at the end"
97
+ end
98
+
99
+ if spf.include?(";")
100
+ warnings << "SPF record contains semicolons (check if intentional)"
101
+ end
102
+
103
+ # Count DNS lookups
104
+ lookup_count = count_spf_lookups(spf)
105
+ if lookup_count > 10
106
+ errors << "SPF has #{lookup_count} DNS lookups (max 10 allowed per RFC 7208)"
107
+ elsif lookup_count > 8
108
+ warnings << "SPF has #{lookup_count} DNS lookups (approaching limit of 10)"
109
+ end
110
+
111
+ CheckResult.new(
112
+ record: spf, valid: errors.empty?, errors: errors, warnings: warnings,
113
+ details: {lookup_count: lookup_count}
114
+ )
115
+ end
116
+
117
+ # Verify DMARC record for the zone
118
+ #
119
+ # Fetches TXT records at _dmarc.{zone} and validates syntax,
120
+ # required p= tag, and recommended rua= tag.
121
+ #
122
+ # @return [CheckResult] with :policy and :tags in details
123
+ def verify_dmarc
124
+ errors = []
125
+ warnings = []
126
+
127
+ txt_records = fetch_txt_records("_dmarc")
128
+
129
+ if txt_records.empty?
130
+ return CheckResult.new(record: nil, valid: true,
131
+ errors: [],
132
+ warnings: ["No DMARC record found (recommended for email authentication)"],
133
+ details: {})
134
+ end
135
+
136
+ if txt_records.length > 1
137
+ return CheckResult.new(record: txt_records, valid: false,
138
+ errors: ["Multiple DMARC records found"], warnings: [], details: {})
139
+ end
140
+
141
+ dmarc = txt_records.first
142
+
143
+ unless dmarc.start_with?("v=DMARC1")
144
+ return CheckResult.new(record: dmarc, valid: false,
145
+ errors: ["DMARC record doesn't start with 'v=DMARC1'"],
146
+ warnings: [], details: {})
147
+ end
148
+
149
+ tags = parse_dmarc_tags(dmarc)
150
+
151
+ if tags["p"]
152
+ unless VALID_DMARC_POLICIES.include?(tags["p"])
153
+ errors << "Invalid DMARC policy: #{tags["p"]}"
154
+ end
155
+ else
156
+ errors << "DMARC record missing required 'p' tag"
157
+ end
158
+
159
+ unless tags["rua"]
160
+ warnings << "DMARC record missing 'rua' tag (no aggregate reports)"
161
+ end
162
+
163
+ CheckResult.new(
164
+ record: dmarc, valid: errors.empty?, errors: errors, warnings: warnings,
165
+ details: {policy: tags["p"], tags: tags}
166
+ )
167
+ end
168
+
169
+ # Verify DKIM record for a specific selector
170
+ #
171
+ # Fetches TXT records at {selector}._domainkey.{zone} and validates
172
+ # the presence of v=DKIM1 and a valid base64 public key.
173
+ #
174
+ # @param selector [String] the DKIM selector (e.g., "google", "mailchimp")
175
+ # @return [CheckResult]
176
+ def verify_dkim(selector)
177
+ errors = []
178
+ warnings = []
179
+
180
+ txt_records = fetch_txt_records("#{selector}._domainkey")
181
+
182
+ if txt_records.empty?
183
+ return CheckResult.new(record: nil, valid: false,
184
+ errors: ["No DKIM record found for selector '#{selector}'"],
185
+ warnings: [], details: {selector: selector})
186
+ end
187
+
188
+ dkim = txt_records.join
189
+
190
+ unless dkim.match?(/v=DKIM1/i)
191
+ return CheckResult.new(record: dkim, valid: false,
192
+ errors: ["DKIM record doesn't contain 'v=DKIM1'"],
193
+ warnings: warnings, details: {selector: selector})
194
+ end
195
+
196
+ key_match = dkim.match(/p=([A-Za-z0-9+\/=]+)/)
197
+ unless key_match
198
+ return CheckResult.new(record: dkim, valid: false,
199
+ errors: ["DKIM record missing or invalid public key (p= tag)"],
200
+ warnings: warnings, details: {selector: selector})
201
+ end
202
+
203
+ # Validate base64 encoding (without requiring the base64 gem)
204
+ key = key_match[1]
205
+ unless valid_base64?(key)
206
+ errors << "DKIM public key is not valid base64"
207
+ end
208
+
209
+ CheckResult.new(
210
+ record: dkim, valid: errors.empty?, errors: errors, warnings: warnings,
211
+ details: {selector: selector, key_length: key.length}
212
+ )
213
+ end
214
+
215
+ private
216
+
217
+ # Fetch TXT record data from the DNS API
218
+ #
219
+ # @param host [String] the hostname within the zone
220
+ # @return [Array<String>] array of TXT record data values
221
+ def fetch_txt_records(host)
222
+ response = @dns.get_record(@zone, host, "TXT")
223
+ records = response["records"] || []
224
+ records.map { |r| r["data"] }
225
+ rescue MythicBeasts::NotFoundError
226
+ []
227
+ end
228
+
229
+ # Count DNS lookup mechanisms in an SPF record
230
+ #
231
+ # @param spf [String] the SPF record text
232
+ # @return [Integer] number of DNS-querying mechanisms
233
+ def count_spf_lookups(spf)
234
+ SPF_LOOKUP_MECHANISMS.sum { |mechanism| spf.scan(/\b#{mechanism}[:\s]/i).length }
235
+ end
236
+
237
+ # Validate strict base64 encoding without requiring the base64 gem
238
+ #
239
+ # @param str [String] the string to validate
240
+ # @return [Boolean] true if valid base64
241
+ def valid_base64?(str)
242
+ return false if str.empty?
243
+ return false unless str.match?(/\A[A-Za-z0-9+\/]*={0,2}\z/)
244
+ str.length % 4 == 0
245
+ end
246
+
247
+ # Parse DMARC tag-value pairs
248
+ #
249
+ # @param dmarc [String] the DMARC record text
250
+ # @return [Hash<String, String>] tag => value pairs
251
+ def parse_dmarc_tags(dmarc)
252
+ tags = {}
253
+ dmarc.gsub(/v=DMARC1;?\s*/, "").split(";").each do |tag|
254
+ next if tag.strip.empty?
255
+ key, value = tag.split("=", 2).map(&:strip)
256
+ tags[key] = value if key && value
257
+ end
258
+ tags
259
+ end
260
+ end
261
+ end
262
+ end
@@ -1,3 +1,5 @@
1
+ require_relative "dns/email_auth"
2
+
1
3
  module MythicBeasts
2
4
  class DNS
3
5
  attr_reader :client
@@ -72,5 +74,39 @@ module MythicBeasts
72
74
  def create_txt_record(zone, host, text, ttl: 300)
73
75
  create_records(zone, [{host: host, ttl: ttl, type: "TXT", data: text}])
74
76
  end
77
+
78
+ # Verify all email authentication records (SPF, DKIM, DMARC) for a zone
79
+ #
80
+ # @param zone [String] the domain to verify
81
+ # @param dkim_selectors [Array<String>] DKIM selectors to check
82
+ # @return [MythicBeasts::DNS::EmailAuth::Result]
83
+ def verify_email_auth(zone, dkim_selectors: [])
84
+ EmailAuth.new(self, zone).verify(dkim_selectors: dkim_selectors)
85
+ end
86
+
87
+ # Verify SPF record for a zone
88
+ #
89
+ # @param zone [String] the domain to verify
90
+ # @return [MythicBeasts::DNS::EmailAuth::CheckResult]
91
+ def verify_spf(zone)
92
+ EmailAuth.new(self, zone).verify_spf
93
+ end
94
+
95
+ # Verify DMARC record for a zone
96
+ #
97
+ # @param zone [String] the domain to verify
98
+ # @return [MythicBeasts::DNS::EmailAuth::CheckResult]
99
+ def verify_dmarc(zone)
100
+ EmailAuth.new(self, zone).verify_dmarc
101
+ end
102
+
103
+ # Verify DKIM record for a specific selector
104
+ #
105
+ # @param zone [String] the domain to verify
106
+ # @param selector [String] the DKIM selector
107
+ # @return [MythicBeasts::DNS::EmailAuth::CheckResult]
108
+ def verify_dkim(zone, selector)
109
+ EmailAuth.new(self, zone).verify_dkim(selector)
110
+ end
75
111
  end
76
112
  end
@@ -1,3 +1,3 @@
1
1
  module MythicBeasts
2
- VERSION = "0.2.0"
2
+ VERSION = "1.0.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mythic-beasts
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Inman
@@ -9,6 +9,20 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: dotenv
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '3.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.1'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: faraday
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -164,6 +178,7 @@ files:
164
178
  - lib/mythic_beasts/auth.rb
165
179
  - lib/mythic_beasts/client.rb
166
180
  - lib/mythic_beasts/dns.rb
181
+ - lib/mythic_beasts/dns/email_auth.rb
167
182
  - lib/mythic_beasts/errors.rb
168
183
  - lib/mythic_beasts/proxy.rb
169
184
  - lib/mythic_beasts/version.rb
@@ -189,7 +204,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
189
204
  - !ruby/object:Gem::Version
190
205
  version: '0'
191
206
  requirements: []
192
- rubygems_version: 3.7.2
207
+ rubygems_version: 4.0.7
193
208
  specification_version: 4
194
209
  summary: Ruby client for Mythic Beasts API
195
210
  test_files: []