mailresolver 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +21 -0
- data/README.md +170 -0
- data/lib/mailresolver/resolver.rb +136 -0
- data/lib/mailresolver/testing.rb +170 -0
- data/lib/mailresolver/version.rb +3 -0
- data/lib/mailresolver.rb +6 -0
- data/mailresolver.gemspec +32 -0
- metadata +65 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 12a1881f2f511ee3179b481e2b0edfd43f79bfed4fdc9f7d96ce94a4fb511650
|
|
4
|
+
data.tar.gz: a1f1aadf2ecfbe335cba1c5ddd33daeba467760ad7b0d1ec070905f9622d4120
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9ee55c2b9fefb4afd0d576e7469804e9c969c653a4b285f1beb77b80efad56dbe77f12397568dba54c17bea7e37c20fa34326c988407fd3a4e3d7d8cc659a847
|
|
7
|
+
data.tar.gz: 3a6ef2bbe9788425d1e65c8ac21364218eac4d67a0db34bdafc2918030bcf7f5557625410b9497a7095b272e55d5043b9f60a74e15b994409ab62a64e15f47db
|
data/CHANGELOG.md
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Simon Lev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# mailresolver
|
|
2
|
+
|
|
3
|
+
๐ DNS resolution for mail, in Ruby.
|
|
4
|
+
|
|
5
|
+
**mailresolver** is a small, injectable DNS client for mail libraries โ TXT, A/AAAA, MX, PTR, and CNAME lookups.
|
|
6
|
+
|
|
7
|
+
For mail libraries and the apps that use them: SPF/DKIM/DMARC/MTA-STS checks that need a missing record told apart from a server that couldn't answer, apps running several mail libraries that want one shared resolver instead of one apiece, test suites that want to fake DNS without touching a network.
|
|
8
|
+
|
|
9
|
+
**mailresolver** handles:
|
|
10
|
+
|
|
11
|
+
- TXT, A, AAAA, MX, PTR, and CNAME lookups, plus a merged `addresses` for A+AAAA
|
|
12
|
+
- a three-way error taxonomy โ `NotFound`, `ServerFailure`, `Timeout` โ instead of collapsing every failure into an empty answer
|
|
13
|
+
- one shared resolver across every mail library in your app, multiplexed over dnsruby's single I/O thread
|
|
14
|
+
- configurable retry/timeout schedule
|
|
15
|
+
- DNSSEC validation and answer caching off, unconditionally, for callers that have no way to act on either
|
|
16
|
+
- test doubles โ a hash-backed fake and a real loopback nameserver โ so a suite never needs the network
|
|
17
|
+
|
|
18
|
+
> Not a general-purpose DNS client โ five record types and three errors, built for what mail authentication needs.
|
|
19
|
+
|
|
20
|
+
## Contents
|
|
21
|
+
|
|
22
|
+
- [Installation](#installation)
|
|
23
|
+
- [Usage](#usage)
|
|
24
|
+
- [The Error Taxonomy](#the-error-taxonomy)
|
|
25
|
+
- [Why This Distinction Exists](#why-this-distinction-exists)
|
|
26
|
+
- [One Resolver, Shared](#one-resolver-shared)
|
|
27
|
+
- [Configuration](#configuration)
|
|
28
|
+
- [Custom Resolvers](#custom-resolvers)
|
|
29
|
+
- [Test Doubles](#test-doubles)
|
|
30
|
+
- [Testing](#testing)
|
|
31
|
+
- [History](#history)
|
|
32
|
+
- [Contributing](#contributing)
|
|
33
|
+
- [Acknowledgments](#acknowledgments)
|
|
34
|
+
- [License](#license)
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
Add this line to your application's Gemfile:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
gem "mailresolver"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Requires Ruby >= 3.4
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
require "mailresolver"
|
|
50
|
+
|
|
51
|
+
resolver = MailResolver::Resolver.new
|
|
52
|
+
|
|
53
|
+
resolver.txt("example.com") # => ["v=spf1 -all"]
|
|
54
|
+
resolver.a("example.com") # => ["203.0.113.10"]
|
|
55
|
+
resolver.aaaa("example.com") # => ["2001:db8::1"]
|
|
56
|
+
resolver.addresses("example.com") # => ["203.0.113.10", "2001:db8::1"]
|
|
57
|
+
resolver.mx("example.com") # => ["mx1.example.com", "mx2.example.com"], sorted by preference
|
|
58
|
+
resolver.ptr("203.0.113.10") # => ["mail.example.com"]
|
|
59
|
+
resolver.cname("www.example.com") # => ["example.com"]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`addresses` merges A and AAAA. A family the name doesn't publish contributes nothing to it rather than ending the lookup โ but a name that doesn't exist at all raises `NoSuchName`, and a nameserver that couldn't answer still raises, same as every other method here.
|
|
63
|
+
|
|
64
|
+
IPv6 addresses come back lower case: dnsruby renders them upper case, and RFC 5952 ยง4.3 asks for lower.
|
|
65
|
+
|
|
66
|
+
## The Error Taxonomy
|
|
67
|
+
|
|
68
|
+
Every method raises one of three errors instead of guessing:
|
|
69
|
+
|
|
70
|
+
- `MailResolver::NotFound` โ the server said there is nothing there: NXDOMAIN, or NOERROR with no records of the type asked for. NXDOMAIN specifically raises the subclass `MailResolver::NoSuchName`, for the callers โ implicit MX, for one โ to whom "the name doesn't exist" means something different from "the name has no records of this type"
|
|
71
|
+
- `MailResolver::ServerFailure` โ the server didn't say: SERVFAIL, REFUSED, or a transport failure (a connect that failed, a socket dnsruby couldn't open, an address that wouldn't resolve)
|
|
72
|
+
- `MailResolver::Timeout` โ no answer arrived in time
|
|
73
|
+
|
|
74
|
+
All of them descend from `MailResolver::Error`. Malformed input is the caller's bug, not a DNS answer, and raises `ArgumentError` instead โ `ptr("not an ip")`, for instance.
|
|
75
|
+
|
|
76
|
+
## Why This Distinction Exists
|
|
77
|
+
|
|
78
|
+
`NotFound` and `ServerFailure` look interchangeable from a distance โ both mean "I don't have an answer for you" โ but they mean opposite things to the caller, and mail authentication is built on the difference.
|
|
79
|
+
|
|
80
|
+
SPF (RFC 7208) counts a void lookup โ a `NotFound` โ against its ten-lookup budget and keeps evaluating. It abandons evaluation entirely on a `ServerFailure` or a `Timeout`, returning `temperror` rather than pretending the record said nothing. Collapse the two, the way `Resolv` does, and a nameserver having a bad minute reads as a domain that designates no senders โ SPF fails a message that should have deferred, and a forged one passes for the same reason a legitimate one would have failed.
|
|
81
|
+
|
|
82
|
+
The same shape recurs in DKIM, DMARC, and MTA-STS: a missing record is a policy decision the domain made; a server that couldn't answer is not.
|
|
83
|
+
|
|
84
|
+
## One Resolver, Shared
|
|
85
|
+
|
|
86
|
+
Build one `MailResolver::Resolver` and hand it to every mail library your app uses:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
resolver = MailResolver::Resolver.new
|
|
90
|
+
|
|
91
|
+
MailAuth.authenticate(raw, ip: ip, resolver: resolver)
|
|
92
|
+
MtaSts.lookup(domain, known: nil, resolver: resolver)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
dnsruby multiplexes concurrent queries over a single I/O thread, and can only do that if callers share one `Dnsruby::Resolver`. Handing each library its own `MailResolver::Resolver` โ rather than one instance shared across them โ buys nothing and costs an I/O thread apiece.
|
|
96
|
+
|
|
97
|
+
## Configuration
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
MailResolver::Resolver.new(timeouts: [5, 3, 3])
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`timeouts` is one attempt per element: the first element is how long the first packet gets before the first retry, dnsruby doubles the interval for each retry after that, and the sum is the hard deadline. The default sends packets at 0, 5, and 10 seconds and gives the whole query 11 seconds before raising `Timeout`.
|
|
104
|
+
|
|
105
|
+
DNSSEC validation and dnsruby's answer cache are both off, unconditionally. DNSSEC costs a round trip that most callers of this gem have no way to act on โ MTA-STS, for one, trusts the certificate on the policy host rather than the DNS answer. Caching is dnsruby's own concern to have off: its cache is process-global, so a nameserver that starts failing would otherwise keep serving its last good answer to every caller in the process.
|
|
106
|
+
|
|
107
|
+
## Custom Resolvers
|
|
108
|
+
|
|
109
|
+
Anything that responds to `txt`, `a`, `aaaa`, `addresses`, `mx`, `ptr`, and `cname` โ raising the three errors above โ is a drop-in replacement. You don't have to write one: see [Test Doubles](#test-doubles).
|
|
110
|
+
|
|
111
|
+
## Test Doubles
|
|
112
|
+
|
|
113
|
+
`MailResolver::Testing` ships what every caller's suite would otherwise hand-roll. It isn't loaded with the gem:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
require "mailresolver/testing"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`FakeResolver` stands a hash in for a zone, no network involved:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
resolver = MailResolver::Testing::FakeResolver.new(
|
|
123
|
+
"example.com" => { txt: [ "v=spf1 -all" ], a: [ "192.0.2.1" ] },
|
|
124
|
+
"broken.example.com" => { txt: :servfail }
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
resolver.txt("example.com") # => ["v=spf1 -all"]
|
|
128
|
+
resolver.mx("example.com") # raises NotFound โ the name is here, MX isn't
|
|
129
|
+
resolver.txt("absent.example.com") # raises NoSuchName โ the name isn't here at all
|
|
130
|
+
resolver.txt("broken.example.com") # raises ServerFailure
|
|
131
|
+
resolver.queries # => [["example.com", :txt], ...]
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The sentinels `:timeout`, `:servfail`, and `:nxdomain` stand where records would, for the failures a zone can't otherwise express. PTR is keyed by the address itself rather than its reverse name.
|
|
135
|
+
|
|
136
|
+
`LocalNameserver` goes a layer lower, for the cases a double can only assert by decree โ what dnsruby actually raises per rcode. It binds a UDP socket on loopback, answers every query with the rcode you name, and hands you a `Resolver` pointed at it:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
MailResolver::Testing::LocalNameserver.answering(:servfail) do |resolver|
|
|
140
|
+
assert_raises(MailResolver::ServerFailure) { resolver.txt("example.com") }
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`:noerror`, `:servfail`, `:nxdomain`, and `:refused` are the rcodes; `nil` never replies, which is what a timeout looks like from the client. Pass `records:` to answer a `:noerror` query.
|
|
145
|
+
|
|
146
|
+
## Testing
|
|
147
|
+
|
|
148
|
+
```sh
|
|
149
|
+
bundle install
|
|
150
|
+
rake
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The suite stubs `Dnsruby::Resolver`'s query path throughout.
|
|
154
|
+
|
|
155
|
+
## History
|
|
156
|
+
|
|
157
|
+
View the [changelog](CHANGELOG.md).
|
|
158
|
+
|
|
159
|
+
## Contributing
|
|
160
|
+
|
|
161
|
+
Everyone is encouraged to help improve this project:
|
|
162
|
+
|
|
163
|
+
- [Report bugs](https://github.com/mailpiece/mailresolver/issues)
|
|
164
|
+
- Fix bugs and submit pull requests
|
|
165
|
+
- Write, clarify, or fix documentation
|
|
166
|
+
- Suggest or add new features
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
require "dnsruby"
|
|
2
|
+
require "ipaddr"
|
|
3
|
+
|
|
4
|
+
module MailResolver
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
# NXDOMAIN, or NOERROR with no records of the type asked for.
|
|
7
|
+
class NotFound < Error; end
|
|
8
|
+
# NXDOMAIN: the name itself doesn't exist, not just records of that type.
|
|
9
|
+
class NoSuchName < NotFound; end
|
|
10
|
+
# No answer arrived in time.
|
|
11
|
+
class Timeout < Error; end
|
|
12
|
+
# SERVFAIL, REFUSED, or transport failure โ distinct from NotFound's "nothing there".
|
|
13
|
+
class ServerFailure < Error; end
|
|
14
|
+
|
|
15
|
+
# DNS for mail. Built on dnsruby rather than Resolv, which collapses SERVFAIL/REFUSED into NXDOMAIN.
|
|
16
|
+
class Resolver
|
|
17
|
+
# One attempt per element; intervals double after the first, and the sum is the hard deadline.
|
|
18
|
+
# Default: attempts at 0s, 5s, and 10s, over at 11s.
|
|
19
|
+
TIMEOUTS = [ 5, 3, 3 ].freeze
|
|
20
|
+
|
|
21
|
+
def initialize(timeouts: TIMEOUTS)
|
|
22
|
+
@timeouts = timeouts
|
|
23
|
+
@lock = Mutex.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# TXT character-strings are joined into one value (RFC 7208 ยง3.3).
|
|
27
|
+
def txt(name)
|
|
28
|
+
records("TXT", name).map { |record| record.strings.join }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def a(name)
|
|
32
|
+
records("A", name).map { |record| record.address.to_s }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Dnsruby prints IPv6 in capitals; RFC 5952 ยง4.3 asks for lower case.
|
|
36
|
+
def aaaa(name)
|
|
37
|
+
records("AAAA", name).map { |record| record.address.to_s.downcase }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A missing family contributes nothing; NXDOMAIN or nameserver failure still raises.
|
|
41
|
+
def addresses(name)
|
|
42
|
+
%w[ A AAAA ].flat_map { |type| family(type, name) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Equal preferences tie-break on the exchange name so callers always see the same order.
|
|
46
|
+
def mx(name)
|
|
47
|
+
records("MX", name).sort_by { |record| [ record.preference, record.exchange.to_s ] }
|
|
48
|
+
.map { |record| record.exchange.to_s }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Dnsruby puts the question name in `name` and the host in `domainname`.
|
|
52
|
+
def ptr(address)
|
|
53
|
+
records("PTR", IPAddr.new(address.to_s).reverse).map { |record| record.domainname.to_s }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def cname(name)
|
|
57
|
+
records("CNAME", name).map { |record| record.domainname.to_s }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
def family(type, name)
|
|
62
|
+
records(type, name).map { |record| record.address.to_s.downcase }
|
|
63
|
+
rescue NoSuchName
|
|
64
|
+
raise
|
|
65
|
+
rescue NotFound
|
|
66
|
+
[]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Filtered by type: a CNAME answer carries the alias beside the records it leads to.
|
|
70
|
+
def records(type, name)
|
|
71
|
+
answers = resolver.query(name.to_s, type).answer.select { |record| record.type == type }
|
|
72
|
+
|
|
73
|
+
if answers.any?
|
|
74
|
+
answers
|
|
75
|
+
else
|
|
76
|
+
raise NotFound, "no #{type} records for #{name}"
|
|
77
|
+
end
|
|
78
|
+
rescue Dnsruby::NXDomain
|
|
79
|
+
raise NoSuchName, "no such name #{name}"
|
|
80
|
+
rescue Dnsruby::ResolvTimeout
|
|
81
|
+
# Descends from Timeout::Error, not Dnsruby::ResolvError.
|
|
82
|
+
raise Timeout, "timed out resolving #{type} #{name}"
|
|
83
|
+
# Connect/send failures arrive as bare IOError/SocketError/SystemCallError from dnsruby's queue.
|
|
84
|
+
rescue Dnsruby::ResolvError, IOError, SocketError, SystemCallError => error
|
|
85
|
+
raise ServerFailure, "couldn't resolve #{type} #{name}: #{reason(error)}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def reason(error)
|
|
89
|
+
if error.is_a?(Dnsruby::ResolvError)
|
|
90
|
+
rcode(error)
|
|
91
|
+
else
|
|
92
|
+
without_backtrace(error.message)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Dnsruby rcode classes often carry no message, and `class.name` is nil on an anonymous class.
|
|
97
|
+
def rcode(error)
|
|
98
|
+
name = error.class.name&.split("::")&.last
|
|
99
|
+
|
|
100
|
+
case
|
|
101
|
+
when name.nil? then error.message
|
|
102
|
+
when error.message == error.class.name then name
|
|
103
|
+
else "#{name}, #{error.message}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Transport error messages carry dnsruby's own backtrace inline.
|
|
108
|
+
def without_backtrace(message)
|
|
109
|
+
message.sub(/\s*\[".*\z/m, "")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Built once and shared so concurrent queries multiplex over one I/O thread.
|
|
113
|
+
# Caching and DNSSEC are off โ the former is process-global, the latter unused by callers.
|
|
114
|
+
def resolver
|
|
115
|
+
@lock.synchronize do
|
|
116
|
+
@resolver ||= Dnsruby::Resolver.new(**options).tap do |dns|
|
|
117
|
+
dns.dnssec = false
|
|
118
|
+
dns.do_caching = false
|
|
119
|
+
dns.query_timeout = @timeouts.sum
|
|
120
|
+
dns.packet_timeout = @timeouts.first
|
|
121
|
+
dns.retry_times = @timeouts.size
|
|
122
|
+
# Dnsruby fires attempt n at 2^(n-1) times this; halving the first element lands the schedule.
|
|
123
|
+
dns.retry_delay = @timeouts.first / 2.0
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
rescue ArgumentError => error
|
|
127
|
+
# Dnsruby raises this when no configured nameserver is usable.
|
|
128
|
+
raise ServerFailure, "no usable nameserver: #{error.message}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Overridden by MailResolver::Testing::LocalNameserver to point at a loopback nameserver.
|
|
132
|
+
def options
|
|
133
|
+
{}
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
require "socket"
|
|
2
|
+
require "resolv"
|
|
3
|
+
|
|
4
|
+
module MailResolver
|
|
5
|
+
# Doubles for tests that need DNS to misbehave. Require from your test helper:
|
|
6
|
+
#
|
|
7
|
+
# require "mailresolver/testing"
|
|
8
|
+
module Testing
|
|
9
|
+
# A zone in a hash: missing names raise NoSuchName, missing types raise NotFound,
|
|
10
|
+
# and :timeout/:servfail/:nxdomain stand in for misbehavior. PTR is keyed by address.
|
|
11
|
+
#
|
|
12
|
+
# FakeResolver.new("example.com" => { txt: [ "v=spf1 -all" ], mx: :servfail })
|
|
13
|
+
class FakeResolver
|
|
14
|
+
attr_reader :queries
|
|
15
|
+
|
|
16
|
+
def initialize(zone = {})
|
|
17
|
+
@zone = zone.transform_keys { |name| normalized(name) }
|
|
18
|
+
@queries = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def txt(name)
|
|
22
|
+
lookup(name, :txt)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def a(name)
|
|
26
|
+
lookup(name, :a)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def aaaa(name)
|
|
30
|
+
lookup(name, :aaaa)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A missing family contributes nothing; NXDOMAIN or nameserver failure still raises.
|
|
34
|
+
def addresses(name)
|
|
35
|
+
%i[ a aaaa ].flat_map { |type| family(type, name) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def mx(name)
|
|
39
|
+
lookup(name, :mx)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def ptr(address)
|
|
43
|
+
lookup(address, :ptr)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def cname(name)
|
|
47
|
+
lookup(name, :cname)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
def family(type, name)
|
|
52
|
+
lookup(name, type)
|
|
53
|
+
rescue NoSuchName
|
|
54
|
+
raise
|
|
55
|
+
rescue NotFound
|
|
56
|
+
[]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def lookup(name, type)
|
|
60
|
+
key = normalized(name)
|
|
61
|
+
@queries << [ key, type ]
|
|
62
|
+
|
|
63
|
+
case records = @zone.dig(key, type)
|
|
64
|
+
when :timeout then raise Timeout, "timed out resolving #{type.upcase} #{key}"
|
|
65
|
+
when :servfail then raise ServerFailure, "server failure resolving #{type.upcase} #{key}"
|
|
66
|
+
when :nxdomain then raise NoSuchName, "no such name #{key}"
|
|
67
|
+
when nil, [] then raise absence(key, type)
|
|
68
|
+
else records
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A name the zone never mentions doesn't exist; one there without this type does.
|
|
73
|
+
def absence(key, type)
|
|
74
|
+
if @zone.key?(key)
|
|
75
|
+
NotFound.new("no #{type.upcase} records for #{key}")
|
|
76
|
+
else
|
|
77
|
+
NoSuchName.new("no such name #{key}")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def normalized(name)
|
|
82
|
+
name.to_s.downcase.chomp(".")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A loopback nameserver for one rcode (records for :noerror, [name, record]
|
|
87
|
+
# for an alias, nil for a timeout), with replies encoded via Resolv.
|
|
88
|
+
#
|
|
89
|
+
# LocalNameserver.answering(:servfail) do |resolver|
|
|
90
|
+
# assert_raises(MailResolver::ServerFailure) { resolver.txt("example.com") }
|
|
91
|
+
# end
|
|
92
|
+
class LocalNameserver
|
|
93
|
+
RCODES = { noerror: 0, servfail: 2, nxdomain: 3, refused: 5 }.freeze
|
|
94
|
+
TIMEOUTS = [ 0.5 ].freeze
|
|
95
|
+
|
|
96
|
+
def self.answering(rcode, records: [], timeouts: TIMEOUTS)
|
|
97
|
+
nameserver = new(rcode, records: records)
|
|
98
|
+
yield nameserver.resolver(timeouts: timeouts)
|
|
99
|
+
ensure
|
|
100
|
+
nameserver&.close
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def initialize(rcode, records: [])
|
|
104
|
+
@rcode, @records = rcode, records
|
|
105
|
+
@socket = UDPSocket.new
|
|
106
|
+
@socket.bind("127.0.0.1", 0)
|
|
107
|
+
@thread = Thread.new { serve }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def port
|
|
111
|
+
@socket.addr[1]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def resolver(timeouts: TIMEOUTS)
|
|
115
|
+
LocalResolver.new(port, timeouts: timeouts)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def close
|
|
119
|
+
@thread.kill
|
|
120
|
+
@socket.close
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Overrides options so configuring a nameserver stays off the public API.
|
|
124
|
+
class LocalResolver < Resolver
|
|
125
|
+
def initialize(port, **options)
|
|
126
|
+
super(**options)
|
|
127
|
+
@port = port
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
def options
|
|
132
|
+
{ nameserver: "127.0.0.1", port: @port }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
def serve
|
|
138
|
+
loop do
|
|
139
|
+
message, sender = @socket.recvfrom(4096)
|
|
140
|
+
@socket.send(reply_to(message).encode, 0, sender[3], sender[1]) if @rcode
|
|
141
|
+
end
|
|
142
|
+
rescue IOError, Errno::EBADF
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def reply_to(message)
|
|
147
|
+
query = Resolv::DNS::Message.decode(message)
|
|
148
|
+
reply = Resolv::DNS::Message.new(query.id)
|
|
149
|
+
reply.qr = 1
|
|
150
|
+
reply.rd = query.rd
|
|
151
|
+
reply.ra = 1
|
|
152
|
+
reply.rcode = RCODES.fetch(@rcode)
|
|
153
|
+
|
|
154
|
+
query.each_question do |name, typeclass|
|
|
155
|
+
reply.add_question(name, typeclass)
|
|
156
|
+
|
|
157
|
+
@records.each do |record|
|
|
158
|
+
if record.is_a?(Array)
|
|
159
|
+
reply.add_answer(Resolv::DNS::Name.create(record.first), 60, record.last)
|
|
160
|
+
else
|
|
161
|
+
reply.add_answer(name, 60, record)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
reply
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
data/lib/mailresolver.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require_relative "lib/mailresolver/version"
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = "mailresolver"
|
|
5
|
+
spec.version = MailResolver::VERSION
|
|
6
|
+
spec.platform = Gem::Platform::RUBY
|
|
7
|
+
spec.required_ruby_version = ">= 3.4"
|
|
8
|
+
spec.authors = [ "Simon Lev" ]
|
|
9
|
+
|
|
10
|
+
spec.summary = "DNS resolution for mail, in Ruby."
|
|
11
|
+
spec.description = "DNS resolution for mail, in Ruby. **mailresolver** is a small, injectable DNS client for mail libraries โ TXT, A/AAAA, MX, PTR, and CNAME lookups."
|
|
12
|
+
|
|
13
|
+
spec.homepage = "https://github.com/mailpiece/mailresolver"
|
|
14
|
+
spec.license = "MIT"
|
|
15
|
+
|
|
16
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
17
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
18
|
+
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
19
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
20
|
+
|
|
21
|
+
spec.files = Dir[
|
|
22
|
+
"lib/**/*.rb",
|
|
23
|
+
"CHANGELOG.md",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"mailresolver.gemspec"
|
|
27
|
+
]
|
|
28
|
+
spec.require_paths = [ "lib" ]
|
|
29
|
+
|
|
30
|
+
# Resolv collapses SERVFAIL/REFUSED into empty answers; dnsruby does not.
|
|
31
|
+
spec.add_dependency "dnsruby", "~> 1.74"
|
|
32
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mailresolver
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.5.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Simon Lev
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: dnsruby
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.74'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.74'
|
|
26
|
+
description: DNS resolution for mail, in Ruby. **mailresolver** is a small, injectable
|
|
27
|
+
DNS client for mail libraries โ TXT, A/AAAA, MX, PTR, and CNAME lookups.
|
|
28
|
+
executables: []
|
|
29
|
+
extensions: []
|
|
30
|
+
extra_rdoc_files: []
|
|
31
|
+
files:
|
|
32
|
+
- CHANGELOG.md
|
|
33
|
+
- LICENSE
|
|
34
|
+
- README.md
|
|
35
|
+
- lib/mailresolver.rb
|
|
36
|
+
- lib/mailresolver/resolver.rb
|
|
37
|
+
- lib/mailresolver/testing.rb
|
|
38
|
+
- lib/mailresolver/version.rb
|
|
39
|
+
- mailresolver.gemspec
|
|
40
|
+
homepage: https://github.com/mailpiece/mailresolver
|
|
41
|
+
licenses:
|
|
42
|
+
- MIT
|
|
43
|
+
metadata:
|
|
44
|
+
source_code_uri: https://github.com/mailpiece/mailresolver
|
|
45
|
+
changelog_uri: https://github.com/mailpiece/mailresolver/blob/main/CHANGELOG.md
|
|
46
|
+
bug_tracker_uri: https://github.com/mailpiece/mailresolver/issues
|
|
47
|
+
rubygems_mfa_required: 'true'
|
|
48
|
+
rdoc_options: []
|
|
49
|
+
require_paths:
|
|
50
|
+
- lib
|
|
51
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
52
|
+
requirements:
|
|
53
|
+
- - ">="
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '3.4'
|
|
56
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
requirements: []
|
|
62
|
+
rubygems_version: 4.0.10
|
|
63
|
+
specification_version: 4
|
|
64
|
+
summary: DNS resolution for mail, in Ruby.
|
|
65
|
+
test_files: []
|