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 +4 -4
- data/CHANGELOG.md +78 -2
- data/LICENSE +1 -1
- data/README.md +98 -19
- data/lib/mythic_beasts/auth.rb +3 -0
- data/lib/mythic_beasts/client.rb +23 -7
- data/lib/mythic_beasts/dns/email_auth.rb +262 -0
- data/lib/mythic_beasts/dns.rb +36 -0
- data/lib/mythic_beasts/errors.rb +1 -0
- data/lib/mythic_beasts/proxy.rb +151 -0
- data/lib/mythic_beasts/version.rb +1 -1
- data/lib/mythic_beasts/vps.rb +77 -14
- data/lib/mythic_beasts.rb +1 -0
- metadata +18 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d494ca87fec6261eef7a30bb03ca7fdab2d34c1b589de1d5cc19caaf543dfb77
|
|
4
|
+
data.tar.gz: ed5f8c6e915764a76e71d502e7210b850d78ce672699d34b04a33610605c1bef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
|
|
148
|
+
# List available options
|
|
91
149
|
zones = MythicBeasts.client.vps.zones
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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
|
|
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,
|
|
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/
|
|
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
|
|
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
|
|
324
|
+
Copyright © 2025-2026, James Inman, Otaina Limited
|
|
246
325
|
|
|
247
326
|
## Resources
|
|
248
327
|
|
data/lib/mythic_beasts/auth.rb
CHANGED
|
@@ -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
|
data/lib/mythic_beasts/client.rb
CHANGED
|
@@ -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,
|
|
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
|
data/lib/mythic_beasts/dns.rb
CHANGED
|
@@ -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
|
data/lib/mythic_beasts/errors.rb
CHANGED
|
@@ -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
|
data/lib/mythic_beasts/vps.rb
CHANGED
|
@@ -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
|
-
# -
|
|
22
|
-
# -
|
|
23
|
-
# -
|
|
24
|
-
# -
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
type: type,
|
|
56
|
+
product: product,
|
|
29
57
|
**options
|
|
30
58
|
}
|
|
31
|
-
body[:
|
|
59
|
+
body[:name] = name if name
|
|
60
|
+
body[:ssh_keys] = ssh_keys if ssh_keys
|
|
32
61
|
|
|
33
|
-
client.post("/
|
|
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
|
|
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("/
|
|
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
|
|
67
|
-
def
|
|
68
|
-
client.get("/
|
|
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.
|
|
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:
|
|
207
|
+
rubygems_version: 4.0.7
|
|
192
208
|
specification_version: 4
|
|
193
209
|
summary: Ruby client for Mythic Beasts API
|
|
194
210
|
test_files: []
|