mythic-beasts 0.1.2 → 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: b3ce37ca163918f82d1612c56fc75ce81e8de315abf65500a90b98ff6ca586dd
4
- data.tar.gz: 21885db9c87b16458429a26a886b5873437d29d4232d7b11833ce9a9c2016029
3
+ metadata.gz: d494ca87fec6261eef7a30bb03ca7fdab2d34c1b589de1d5cc19caaf543dfb77
4
+ data.tar.gz: ed5f8c6e915764a76e71d502e7210b850d78ce672699d34b04a33610605c1bef
5
5
  SHA512:
6
- metadata.gz: b4629b547c7aa646fdba1eedf30c51d9e524005f3e8c7de3c112abc1d38f305a1befa6f421cf2334520e9a8ba683450709b7398a7e29bdd2f8f1bb6ddf204e2a
7
- data.tar.gz: 8e9e1879278b5b9fa898328835be2ceaa3bcb1a4d6dd809f58050374903e609869a5af216095e09fb46f8a72b7f0a92d0e4944c7ca85afbf7e44d98fa6f68586
6
+ metadata.gz: 583a0776da3cdff5fabd1ba06f2b3eab45ca6c458564c546323e8f077f4e42eb356304e9d43c0ce601914cd33d1b4366f46ba99cdb1e995f0ec80f348e2a1ac0
7
+ data.tar.gz: ac6d3815a942466222b0d536e82c7d378b1fc020a58a9892b0ba6053e2434353f62b2822b149e0cb17f69efa2802440243fac450a7074c4892cf2af1b2926757
data/CHANGELOG.md CHANGED
@@ -5,9 +5,85 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
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
27
+
28
+ ### Added in 0.2.0
29
+
30
+ - Interactive CLI tool `mythic-beasts-provision` with numbered menu navigation for VPS provisioning
31
+ - Interactive VPS management tool `mythic-beasts-manage-vps` with DNS and proxy configuration
32
+ - IPv4 to IPv6 Proxy API client (`MythicBeasts.client.proxy`) for managing proxy endpoints
33
+ - List, create, replace, and delete proxy configurations
34
+ - Support for all proxy sites (sov, ams, hex) or "all"
35
+ - Convenience method `create_simple` for quick proxy setup
36
+ - Full CRUD operations on proxy endpoints by domain/hostname/address
37
+ - DNS record verification after proxy configuration
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
44
+
45
+ ### Changed in 0.2.0
46
+
47
+ - Replaced tty-prompt with simple numbered menus for better terminal compatibility (especially Ghostty)
48
+ - Smart bandwidth display: shows GB for >= 1024MB, MB otherwise
49
+
50
+ ### Fixed in 0.2.0
51
+
52
+ - Zones endpoint now uses correct `/beta/vps/zones` path
53
+ - DNS verification now handles non-array responses correctly
54
+ - DNS records method name corrected in provision-vps tool
55
+ - VPS provisioning polling now waits for actual completion
56
+ - Status polling output now uses newlines properly
57
+ - IPv4 allocation during VPS provisioning
58
+ - Bandwidth display showing MB correctly instead of 0GB
59
+
60
+ ## [0.1.3] - 2025-11-05
61
+
62
+ ### Fixed in 0.1.3
63
+
64
+ - Bearer token authentication for VPS API endpoints - now sets Authorization header on each request
65
+ - VPS API endpoints now use correct `/beta/vps/*` paths instead of `/vps/*`
66
+ - Location header now captured from 202 Accepted responses for polling async operations
67
+ - Improved error messages to show API response body for better debugging
68
+
69
+ ### Added to 0.1.3
70
+
71
+ - `VPS#images` method to list available OS images from `/beta/vps/images`
72
+ - `VPS#products` method to list available VPS products from `/beta/vps/products`
73
+ - `VPS#disk_sizes` method to list available disk sizes from `/beta/vps/disk-sizes`
74
+ - `VPS#zones` method to list available zones from `/beta/vps/zones`
75
+ - Example script `list_vps_options.rb` to display all available VPS configuration options
76
+
77
+ ### Changed in 0.1.3
78
+
79
+ - VPS creation now requires `product:` parameter (e.g., `VPSX16`) instead of `type:`
80
+ - VPS creation now requires `ssh_keys:` parameter instead of `ssh_key:`
81
+ - VPS creation now uses `zone:` parameter instead of `location:`
82
+ - 404 errors now show the full URL and HTTP method that failed
83
+
8
84
  ## [0.1.2] - 2025-11-05
9
85
 
10
- ### Added
86
+ ### Added in 0.1.2
11
87
 
12
88
  - VPS zones listing method (`MythicBeasts.client.vps.zones`) to query available datacenters
13
89
  - VPS types listing method (`MythicBeasts.client.vps.types`) to query available VPS plans
@@ -19,7 +95,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
19
95
  - `ipv6_only_vps.rb` - IPv6-only VPS with proxy setup guide
20
96
  - Test coverage for IPv6-only VPS provisioning
21
97
 
22
- ### Changed
98
+ ### Changed in 0.1.2
23
99
 
24
100
  - Updated README with new VPS methods and examples
25
101
  - Improved VPS create documentation with optional parameters
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,16 +84,72 @@ 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
 
117
+ #### Interactive CLI Tool (Recommended)
118
+
119
+ The gem includes an interactive CLI for easy VPS provisioning with numbered menus:
120
+
121
+ ```bash
122
+ # With 1Password
123
+ op run --env-file=.env.1password -- mythic-beasts-provision
124
+
125
+ # Or with environment variables
126
+ export MYTHIC_BEASTS_API_KEY="your_key"
127
+ export MYTHIC_BEASTS_API_SECRET="your_secret"
128
+ mythic-beasts-provision
129
+ ```
130
+
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:
137
+
138
+ - Selecting product (VPS size)
139
+ - Choosing datacenter/zone
140
+ - Picking OS image
141
+ - Setting disk size
142
+ - Network configuration (IPv4/IPv6)
143
+ - Server naming
144
+
145
+ #### Programmatic API
146
+
89
147
  ```ruby
90
- # List available zones/datacenters
148
+ # List available options
91
149
  zones = MythicBeasts.client.vps.zones
92
- # => ["london", "cambridge", "amsterdam", "fremont"]
93
-
94
- # List available VPS types/plans
95
- types = MythicBeasts.client.vps.types
96
- # => ["VPS-1", "VPS-2", "VPS-3", ...]
150
+ products = MythicBeasts.client.vps.products
151
+ images = MythicBeasts.client.vps.images
152
+ disk_sizes = MythicBeasts.client.vps.disk_sizes
97
153
 
98
154
  # List all VPS servers
99
155
  servers = MythicBeasts.client.vps.list
@@ -103,13 +159,14 @@ server = MythicBeasts.client.vps.get('my-server')
103
159
 
104
160
  # Create a new VPS
105
161
  MythicBeasts.client.vps.create(
106
- name: 'my-new-server',
107
- type: 'VPS-2',
108
- ssh_key: 'ssh-rsa AAAAB3...',
109
- location: 'london', # Optional: specify datacenter
110
- service: 'my-service', # Optional: service/project name
111
- description: 'My test server', # Optional: server description
112
- notes: 'Any additional notes' # Optional: special requests
162
+ product: 'VPSX16', # Required: product code (e.g., VPSX16 = 2 cores, 4GB RAM)
163
+ name: 'my-new-server', # Optional: friendly name
164
+ hostname: 'my-server', # Optional: server hostname
165
+ ssh_keys: 'ssh-rsa AAAAB3...', # Optional: SSH public key(s)
166
+ zone: 'cam', # Optional: datacenter code (e.g., 'cam' for Cambridge)
167
+ image: 'cloudinit-debian-bookworm.raw.gz', # Optional: OS image
168
+ disk_size: 20480, # Required: disk size in MB
169
+ ipv4: false # Optional: false for IPv6-only (cheaper)
113
170
  )
114
171
 
115
172
  # Control servers
@@ -124,16 +181,16 @@ console = MythicBeasts.client.vps.console('my-server')
124
181
  MythicBeasts.client.vps.delete('my-server')
125
182
  ```
126
183
 
127
- #### IPv6-Only Servers (Cost Savings)
184
+ #### IPv6-Only Servers
128
185
 
129
186
  Provision cheaper IPv6-only servers without IPv4 addresses. Perfect for services accessible via Mythic Beasts' IPv4-to-IPv6 proxy:
130
187
 
131
188
  ```ruby
132
- # Create an IPv6-only VPS (cheaper!)
189
+ # Create an IPv6-only VPS
133
190
  MythicBeasts.client.vps.create(
134
191
  name: 'my-ipv6-server',
135
192
  type: 'VPS-2',
136
- ipv4: false, # No IPv4 address = lower cost
193
+ ipv4: false,
137
194
  ssh_key: 'ssh-rsa AAAAB3...',
138
195
  location: 'london'
139
196
  )
@@ -186,7 +243,17 @@ Available error classes:
186
243
 
187
244
  See the `examples/` directory for complete working examples:
188
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
+
189
255
  - `examples/list_zones_and_types.rb` - List available zones and VPS types
256
+ - `examples/list_vps_options.rb` - List all VPS configuration options
190
257
  - `examples/provision_vps.rb` - Provision a new VPS server
191
258
  - `examples/ipv6_only_vps.rb` - Provision an IPv6-only VPS with proxy setup guide
192
259
 
@@ -195,7 +262,8 @@ Run examples with:
195
262
  ```bash
196
263
  export MYTHIC_BEASTS_API_KEY=your_key
197
264
  export MYTHIC_BEASTS_API_SECRET=your_secret
198
- 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
199
267
  ```
200
268
 
201
269
  ## Development
@@ -229,7 +297,18 @@ bundle exec bundler-audit check --update # Run security audit
229
297
 
230
298
  ## Contributing
231
299
 
232
- 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).
233
312
 
234
313
  ## Author
235
314
 
@@ -242,7 +321,7 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/tastyb
242
321
 
243
322
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
244
323
 
245
- Copyright (c) 2025 Otaina Limited
324
+ Copyright © 2025-2026, James Inman, Otaina Limited
246
325
 
247
326
  ## Resources
248
327
 
@@ -36,6 +36,9 @@ module MythicBeasts
36
36
  @token = data["access_token"]
37
37
  # Expire token 30 seconds before actual expiry to be safe
38
38
  @token_expires_at = Time.now + (data["expires_in"].to_i - 30)
39
+ rescue Faraday::UnauthorizedError => e
40
+ response_body = e.response&.dig(:body)
41
+ raise AuthenticationError, "Failed to authenticate with Mythic Beasts API. Check your API key and secret. Response: #{response_body}"
39
42
  rescue Faraday::Error => e
40
43
  raise AuthenticationError, "Failed to authenticate: #{e.message}"
41
44
  end
@@ -2,12 +2,13 @@ module MythicBeasts
2
2
  class Client
3
3
  API_BASE_URL = "https://api.mythic-beasts.com"
4
4
 
5
- attr_reader :auth, :dns, :vps
5
+ attr_reader :auth, :dns, :vps, :proxy
6
6
 
7
7
  def initialize(api_key:, api_secret:)
8
8
  @auth = Auth.new(api_key: api_key, api_secret: api_secret)
9
9
  @dns = DNS.new(self)
10
10
  @vps = VPS.new(self)
11
+ @proxy = Proxy.new(self)
11
12
  end
12
13
 
13
14
  def get(path, params: {})
@@ -22,6 +23,10 @@ module MythicBeasts
22
23
  request(:put, path, body: body, params: params)
23
24
  end
24
25
 
26
+ def patch(path, body: {}, params: {})
27
+ request(:patch, path, body: body, params: params)
28
+ end
29
+
25
30
  def delete(path, params: {})
26
31
  request(:delete, path, params: params)
27
32
  end
@@ -31,22 +36,34 @@ module MythicBeasts
31
36
  def request(method, path, body: {}, params: {})
32
37
  response = connection.send(method) do |req|
33
38
  req.url path
39
+ req.headers["Authorization"] = "Bearer #{auth.token}"
34
40
  req.params = params if params.any?
35
41
  req.body = body.to_json if body.any?
36
42
  end
37
43
 
38
- JSON.parse(response.body) if response.body && !response.body.empty?
44
+ result = JSON.parse(response.body) if response.body && !response.body.empty?
45
+
46
+ # For 202 Accepted responses, include Location header for polling
47
+ if response.status == 202 && response.headers["location"]
48
+ result ||= {}
49
+ result["location"] = response.headers["location"]
50
+ end
51
+
52
+ result
39
53
  rescue Faraday::UnauthorizedError, Faraday::ClientError => e
54
+ response_body = e.response&.dig(:body)
40
55
  if e.response&.dig(:status) == 401
41
- raise AuthenticationError, "Invalid credentials"
56
+ raise AuthenticationError, "Invalid credentials - #{response_body}"
42
57
  elsif e.response&.dig(:status) == 404
43
- raise NotFoundError, e.message
58
+ raise NotFoundError, "the server responded with status 404 for #{method.to_s.upcase} #{API_BASE_URL}#{path}"
44
59
  elsif e.response&.dig(:status) == 400
45
- raise ValidationError, e.message
60
+ raise ValidationError, "#{e.message} - #{response_body}"
61
+ elsif e.response&.dig(:status) == 409
62
+ raise ConflictError, "#{e.message} - #{response_body}"
46
63
  elsif e.response&.dig(:status) == 429
47
64
  raise RateLimitError, e.message
48
65
  else
49
- raise Error, e.message
66
+ raise Error, "#{e.message} - #{response_body}"
50
67
  end
51
68
  rescue Faraday::ServerError => e
52
69
  raise ServerError, e.message
@@ -54,7 +71,6 @@ module MythicBeasts
54
71
 
55
72
  def connection
56
73
  @connection ||= Faraday.new(url: API_BASE_URL) do |conn|
57
- conn.request :authorization, :bearer, -> { auth.token }
58
74
  conn.request :json
59
75
  conn.request :retry, {
60
76
  max: 3,
@@ -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
@@ -3,6 +3,7 @@ module MythicBeasts
3
3
  class AuthenticationError < Error; end
4
4
  class NotFoundError < Error; end
5
5
  class ValidationError < Error; end
6
+ class ConflictError < Error; end
6
7
  class RateLimitError < Error; end
7
8
  class ServerError < Error; end
8
9
  end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MythicBeasts
4
+ # IPv4 to IPv6 Proxy API
5
+ #
6
+ # Manages IPv4-to-IPv6 proxy endpoints for making IPv6-only servers
7
+ # accessible via IPv4. Useful for cost-effective IPv6-only VPS deployments.
8
+ #
9
+ # @see https://www.mythic-beasts.com/support/api/proxy
10
+ class Proxy
11
+ # @param client [MythicBeasts::Client] the API client
12
+ def initialize(client)
13
+ @client = client
14
+ end
15
+
16
+ # List all proxy endpoints accessible to the authenticated user
17
+ #
18
+ # @return [Array<Hash>] array of endpoint configurations
19
+ # @example
20
+ # endpoints = client.proxy.list
21
+ # # => [{"domain" => "example.com", "hostname" => "www", "address" => "2a00:...", "site" => "all"}]
22
+ def list
23
+ response = @client.get("/proxy/endpoints")
24
+ response["endpoints"] || response[:endpoints] || []
25
+ end
26
+
27
+ # List proxy endpoints for a specific domain
28
+ #
29
+ # @param domain [String] the domain name
30
+ # @return [Array<Hash>] array of endpoint configurations for the domain
31
+ # @example
32
+ # endpoints = client.proxy.list_for_domain("example.com")
33
+ def list_for_domain(domain)
34
+ response = @client.get("/proxy/endpoints/#{domain}")
35
+ response["endpoints"] || response[:endpoints] || []
36
+ end
37
+
38
+ # List proxy endpoints for a specific hostname
39
+ #
40
+ # @param domain [String] the domain name
41
+ # @param hostname [String] the hostname (use "@" for the domain itself)
42
+ # @return [Array<Hash>] array of endpoint configurations
43
+ # @example
44
+ # endpoints = client.proxy.list_for_hostname("example.com", "www")
45
+ def list_for_hostname(domain, hostname)
46
+ response = @client.get("/proxy/endpoints/#{domain}/#{hostname}")
47
+ response["endpoints"] || response[:endpoints] || []
48
+ end
49
+
50
+ # Create or replace proxy endpoints for a hostname
51
+ #
52
+ # This replaces ALL existing endpoints for the hostname.
53
+ #
54
+ # @param domain [String] the domain name
55
+ # @param hostname [String] the hostname (use "@" for the domain itself)
56
+ # @param endpoints [Array<Hash>] array of endpoint configurations
57
+ # @option endpoints [String] :address IPv6 address
58
+ # @option endpoints [String] :site proxy server location or "all"
59
+ # @option endpoints [Boolean] :proxy_protocol enable PROXY protocol (optional)
60
+ # @return [Hash] API response
61
+ # @example
62
+ # client.proxy.replace(
63
+ # "example.com",
64
+ # "www",
65
+ # [{address: "2a00:1098:4c4::1", site: "all"}]
66
+ # )
67
+ def replace(domain, hostname, endpoints)
68
+ @client.put("/proxy/endpoints/#{domain}/#{hostname}", body: {endpoints: endpoints})
69
+ end
70
+
71
+ # Add proxy endpoints for a hostname without replacing existing ones
72
+ #
73
+ # @param domain [String] the domain name
74
+ # @param hostname [String] the hostname (use "@" for the domain itself)
75
+ # @param endpoints [Array<Hash>] array of endpoint configurations
76
+ # @option endpoints [String] :address IPv6 address
77
+ # @option endpoints [String] :site proxy server location or "all"
78
+ # @option endpoints [Boolean] :proxy_protocol enable PROXY protocol (optional)
79
+ # @return [Hash] API response
80
+ # @example
81
+ # client.proxy.add(
82
+ # "example.com",
83
+ # "www",
84
+ # [{address: "2a00:1098:4c4::1", site: "all"}]
85
+ # )
86
+ def add(domain, hostname, endpoints)
87
+ @client.post("/proxy/endpoints/#{domain}/#{hostname}", body: {endpoints: endpoints})
88
+ end
89
+
90
+ # Delete all proxy endpoints for a hostname
91
+ #
92
+ # @param domain [String] the domain name
93
+ # @param hostname [String] the hostname
94
+ # @return [Hash] API response
95
+ # @example
96
+ # client.proxy.delete("example.com", "www")
97
+ def delete(domain, hostname)
98
+ @client.delete("/proxy/endpoints/#{domain}/#{hostname}")
99
+ end
100
+
101
+ # Delete a specific proxy endpoint
102
+ #
103
+ # @param domain [String] the domain name
104
+ # @param hostname [String] the hostname
105
+ # @param address [String] the IPv6 address
106
+ # @param site [String] the proxy site (optional, defaults to "all")
107
+ # @return [Hash] API response
108
+ # @example
109
+ # client.proxy.delete_endpoint("example.com", "www", "2a00:1098:4c4::1")
110
+ def delete_endpoint(domain, hostname, address, site = nil)
111
+ path = "/proxy/endpoints/#{domain}/#{hostname}/#{address}"
112
+ path += "/#{site}" if site
113
+ @client.delete(path)
114
+ end
115
+
116
+ # List available proxy server locations
117
+ #
118
+ # @return [Array<String>] array of site codes (e.g., ["sov", "ams", "hex"])
119
+ # @example
120
+ # sites = client.proxy.sites
121
+ # # => ["sov", "ams", "hex"]
122
+ def sites
123
+ response = @client.get("/proxy/sites")
124
+ response["sites"] || response[:sites] || []
125
+ end
126
+
127
+ # Convenience method: Create a simple proxy endpoint for a server
128
+ #
129
+ # @param domain [String] the domain name
130
+ # @param hostname [String] the hostname (use "@" for root domain)
131
+ # @param ipv6_address [String] the IPv6 address of your server
132
+ # @param proxy_protocol [Boolean] enable PROXY protocol (default: false)
133
+ # @return [Hash] API response
134
+ # @example
135
+ # # Make staging.example.com point to IPv6-only server
136
+ # client.proxy.create_simple(
137
+ # "example.com",
138
+ # "staging",
139
+ # "2a00:1098:4c4::1"
140
+ # )
141
+ def create_simple(domain, hostname, ipv6_address, proxy_protocol: false)
142
+ endpoint = {
143
+ address: ipv6_address,
144
+ site: "all"
145
+ }
146
+ endpoint[:proxy_protocol] = true if proxy_protocol
147
+
148
+ add(domain, hostname, [endpoint])
149
+ end
150
+ end
151
+ end
@@ -1,3 +1,3 @@
1
1
  module MythicBeasts
2
- VERSION = "0.1.2"
2
+ VERSION = "1.0.0"
3
3
  end
@@ -11,26 +11,55 @@ module MythicBeasts
11
11
  client.get("/api/vps")
12
12
  end
13
13
 
14
+ # List all VPS servers (newer endpoint)
15
+ def servers
16
+ client.get("/vps/servers")
17
+ end
18
+
14
19
  # Get details of a specific VPS
15
20
  def get(server_name)
16
21
  client.get("/api/vps/#{server_name}")
17
22
  end
18
23
 
24
+ # Get details of a specific VPS by identifier (newer endpoint)
25
+ def server(identifier)
26
+ client.get("/vps/servers/#{identifier}")
27
+ end
28
+
19
29
  # Create a new VPS
20
30
  # Options:
21
- # - name: Server name
22
- # - type: Server type (e.g., 'VPS-1', 'VPS-2')
23
- # - disk: Disk size in GB
24
- # - ssh_key: SSH public key for access
25
- def create(name:, type:, ssh_key: nil, **options)
31
+ # - product: Server product (e.g., 'VPS-1', 'VPS-2') - REQUIRED
32
+ # - name: Friendly name for the server
33
+ # - hostname: Hostname for the server
34
+ # - ssh_keys: SSH public key(s) for access
35
+ # - zone: Datacentre code (e.g., 'london', 'cambridge')
36
+ # - ipv4: Boolean - allocate IPv4 address (default true)
37
+ # - image: Operating system image name
38
+ # - disk_size: Disk size in MB
39
+ # And other optional parameters...
40
+ def create(product:, name: nil, ssh_keys: nil, **options)
41
+ body = {
42
+ product: product,
43
+ **options
44
+ }
45
+ body[:name] = name if name
46
+ body[:ssh_keys] = ssh_keys if ssh_keys
47
+
48
+ client.post("/beta/vps/servers", body: body)
49
+ end
50
+
51
+ # Create a new VPS with a custom identifier
52
+ # Returns 409 Conflict if identifier already exists
53
+ # Options are same as create method
54
+ def create_with_identifier(identifier, product:, name: nil, ssh_keys: nil, **options)
26
55
  body = {
27
- name: name,
28
- type: type,
56
+ product: product,
29
57
  **options
30
58
  }
31
- body[:ssh_key] = ssh_key if ssh_key
59
+ body[:name] = name if name
60
+ body[:ssh_keys] = ssh_keys if ssh_keys
32
61
 
33
- client.post("/api/vps", body: body)
62
+ client.post("/beta/vps/servers/#{identifier}", body: body)
34
63
  end
35
64
 
36
65
  # Start a VPS
@@ -48,24 +77,58 @@ module MythicBeasts
48
77
  client.post("/api/vps/#{server_name}/restart")
49
78
  end
50
79
 
80
+ # Reboot a VPS by identifier (newer endpoint)
81
+ def reboot(identifier)
82
+ client.post("/vps/servers/#{identifier}/reboot")
83
+ end
84
+
85
+ # Update a VPS (e.g., mount ISO image)
86
+ # Options:
87
+ # - iso_image: ISO image name to mount
88
+ def update(identifier, **options)
89
+ client.patch("/vps/servers/#{identifier}", body: options)
90
+ end
91
+
51
92
  # Delete a VPS
52
93
  def delete(server_name)
53
94
  client.delete("/api/vps/#{server_name}")
54
95
  end
55
96
 
97
+ # Unprovision (permanently delete) a VPS by identifier (newer endpoint)
98
+ def unprovision(identifier)
99
+ client.delete("/beta/vps/servers/#{identifier}")
100
+ end
101
+
56
102
  # Get VPS console access
57
103
  def console(server_name)
58
104
  client.get("/api/vps/#{server_name}/console")
59
105
  end
60
106
 
61
- # List available zones/locations for VPS provisioning
107
+ # List available ISO images for a specific server
108
+ # These can be mounted to the server for manual OS installation
109
+ def iso_images(server_identifier)
110
+ client.get("/beta/vps/servers/#{server_identifier}/iso-images")
111
+ end
112
+
113
+ # List available zones/datacenters for VPS provisioning
62
114
  def zones
63
- client.get("/api/vps/zones")
115
+ client.get("/beta/vps/zones")
116
+ end
117
+
118
+ # List available OS images for VPS provisioning
119
+ def images
120
+ client.get("/beta/vps/images")
121
+ end
122
+
123
+ # List available disk sizes
124
+ # Returns hash with 'ssd' and 'hdd' keys containing arrays of sizes in MB
125
+ def disk_sizes
126
+ client.get("/beta/vps/disk-sizes")
64
127
  end
65
128
 
66
- # List available VPS types/plans
67
- def types
68
- client.get("/api/vps/types")
129
+ # List available products
130
+ def products
131
+ client.get("/beta/vps/products")
69
132
  end
70
133
  end
71
134
  end
data/lib/mythic_beasts.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "mythic_beasts/client"
7
7
  require_relative "mythic_beasts/auth"
8
8
  require_relative "mythic_beasts/dns"
9
9
  require_relative "mythic_beasts/vps"
10
+ require_relative "mythic_beasts/proxy"
10
11
  require_relative "mythic_beasts/errors"
11
12
 
12
13
  module MythicBeasts
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.1.2
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,7 +178,9 @@ 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
183
+ - lib/mythic_beasts/proxy.rb
168
184
  - lib/mythic_beasts/version.rb
169
185
  - lib/mythic_beasts/vps.rb
170
186
  homepage: https://github.com/tastybamboo/mythic-beasts
@@ -188,7 +204,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
188
204
  - !ruby/object:Gem::Version
189
205
  version: '0'
190
206
  requirements: []
191
- rubygems_version: 3.7.2
207
+ rubygems_version: 4.0.7
192
208
  specification_version: 4
193
209
  summary: Ruby client for Mythic Beasts API
194
210
  test_files: []