triauth 0.0.1 → 1.0.0.beta.4

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/LICENSE +202 -28
  4. data/README.md +2036 -7
  5. data/lib/triauth/api/attestation.rb +419 -0
  6. data/lib/triauth/api/authentication.rb +651 -0
  7. data/lib/triauth/api/identity.rb +245 -0
  8. data/lib/triauth/api/signing.rb +740 -0
  9. data/lib/triauth/api/validation.rb +162 -0
  10. data/lib/triauth/api.rb +33 -0
  11. data/lib/triauth/authentication_endpoint.rb +196 -0
  12. data/lib/triauth/challenge.rb +115 -0
  13. data/lib/triauth/challenge_response_flow.rb +344 -0
  14. data/lib/triauth/config.rb +93 -0
  15. data/lib/triauth/error.rb +95 -0
  16. data/lib/triauth/helpers.rb +1222 -0
  17. data/lib/triauth/identity.rb +301 -0
  18. data/lib/triauth/identity_domain.rb +212 -0
  19. data/lib/triauth/identity_includes.rb +249 -0
  20. data/lib/triauth/identity_keys.rb +309 -0
  21. data/lib/triauth/logger.rb +156 -0
  22. data/lib/triauth/multi_signature.rb +177 -0
  23. data/lib/triauth/protocol.rb +74 -0
  24. data/lib/triauth/request_cipher.rb +121 -0
  25. data/lib/triauth/resolvers/base.rb +147 -0
  26. data/lib/triauth/resolvers/caching_resolver.rb +202 -0
  27. data/lib/triauth/resolvers/cloudflare.rb +40 -0
  28. data/lib/triauth/resolvers/dns_json.rb +391 -0
  29. data/lib/triauth/resolvers/dns_sb.rb +40 -0
  30. data/lib/triauth/resolvers/google.rb +39 -0
  31. data/lib/triauth/resolvers/multi_resolver.rb +202 -0
  32. data/lib/triauth/resolvers/system_dns.rb +259 -0
  33. data/lib/triauth/resolvers.rb +33 -0
  34. data/lib/triauth/response.rb +142 -0
  35. data/lib/triauth/response_cipher.rb +341 -0
  36. data/lib/triauth/signature.rb +405 -0
  37. data/lib/triauth/validator.rb +549 -0
  38. data/lib/triauth/verifiers/base.rb +147 -0
  39. data/lib/triauth/verifiers/ecdsa.rb +87 -0
  40. data/lib/triauth/verifiers/ed25519.rb +89 -0
  41. data/lib/triauth/verifiers/registry.rb +52 -0
  42. data/lib/triauth/verifiers/web_authn.rb +253 -0
  43. data/lib/triauth/verifiers.rb +37 -0
  44. data/lib/triauth/version.rb +23 -0
  45. data/lib/triauth.rb +104 -7
  46. metadata +55 -13
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2026 The Triauth Authors (https://www.triauth.org/)
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ # SPDX-License-Identifier: Apache-2.0
18
+
19
+
20
+ require_relative 'dns_json'
21
+
22
+ module Triauth
23
+ module Resolvers
24
+ # A DNS resolver that uses the Google Public DNS DoH JSON API endpoint
25
+ #
26
+ # @see https://developers.google.com/speed/public-dns/docs/doh/json Documentation
27
+ # @see https://developers.google.com/speed/public-dns/privacy Privacy Policy
28
+ # @see https://developers.google.com/speed/public-dns/terms Terms Of Service
29
+ class Google < DnsJson
30
+ # @param options [Hash] Resolver options, as documented on {Triauth::Resolvers::DnsJson}. Defaults to `{}`.
31
+ def initialize(options = {})
32
+ # Google returns TXT data as logical record content: character-strings arrive already
33
+ # concatenated with no separator and without surrounding quotes, so neither
34
+ # normalizeTxtRecords nor decodeTxtRecords is needed.
35
+ super('https://dns.google/resolve', options)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2026 The Triauth Authors (https://www.triauth.org/)
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ # SPDX-License-Identifier: Apache-2.0
18
+
19
+
20
+ require_relative 'base'
21
+ require_relative '../helpers'
22
+
23
+ module Triauth
24
+ module Resolvers
25
+ # A multi-resolver that takes an array of resolvers, and returns an intersection of DNS resource record values
26
+ # as returned by each of the listed resolvers. This allows responses from multiple independent DNS resolver service
27
+ # providers to be cross-validated against each other, helping to protect from BGP poisoning, rogue providers and provider-specific attacks.
28
+ #
29
+ # The TTL of each record is calculated as a minimal TTL returned by the resolvers.
30
+ # The dnssec property for returned record is set to true only when at least one resolver has set it to 'true' and no resolvers have set it to 'false'.
31
+ # The aggregation is independent of resolver order: resolvers that report no DNSSEC status (nil) express no opinion,
32
+ # and the property remains nil when no resolver reports an explicit status.
33
+ class MultiResolver < Base
34
+ # The carrier of the rejection value of `MultiResolver#resolve`: triauth-js rejects with an Array of the
35
+ # sub-resolver errors, and Ruby raises exceptions only, so that Array travels in `reasons`.
36
+ class ResolutionError < StandardError
37
+ # @return [Array<Exception>] the errors raised by the failed sub-resolvers, in resolver order.
38
+ attr_reader :reasons
39
+
40
+ # @param reasons [Array<Exception>] The errors raised by the failed sub-resolvers.
41
+ def initialize(reasons)
42
+ @reasons = reasons
43
+ super("MultiResolver: #{reasons.length} resolver(s) failed: #{reasons.map { |r| r.respond_to?(:message) ? r.message : r.to_s }.join('; ')}")
44
+ end
45
+ end
46
+
47
+ # @return [Array<Base>] the resolvers queried by every `resolve` call.
48
+ attr_reader :resolvers
49
+ # @return [Hash] the options passed to the constructor.
50
+ attr_reader :options
51
+
52
+ # @param resolvers [Array<Base>] An array of resolver objects (descendants of Triauth::Resolvers::Base).
53
+ # @param options [Hash] The options listed below are consumed at this layer; any other entries
54
+ # are forwarded to each sub-resolver's resolve() call. Defaults to `{}`.
55
+ # @option options [Integer] :maxFailures The maximal number of resolvers that are allowed to fail for the resolution to still successfully complete.
56
+ # @option options [Integer] :timeout How long to wait (ms) before aborting the multi-resolver call with a timeout error.
57
+ # A sub-resolver still running at that deadline is left to finish on its own timeouts and its answer is discarded.
58
+ def initialize(resolvers, options = {})
59
+ super()
60
+ @resolvers = resolvers
61
+ @options = options
62
+ end
63
+
64
+ # Queries every configured resolver for the given records, and returns the intersection of their answers.
65
+ #
66
+ # A record is returned only when every resolver that answered carries it. Its `ttl` is the lowest one
67
+ # reported for it, and its `dnssec` follows the aggregation described in the class documentation above.
68
+ #
69
+ # @param domain [String] A domain name to query the DNS for.
70
+ # @param type [String] A type of DNS resource record to look for (e.g., 'TXT', 'A').
71
+ # @param options [Hash] Per-call overrides for the resolver options passed to the constructor.
72
+ # `maxFailures` and `timeout` are consumed at this layer; every other entry
73
+ # fans out to each sub-resolver's resolve() call. Defaults to `{}`.
74
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
75
+ #
76
+ # @return [Array<Hash>] The array of retrieved DNS resource records in `{value, ttl, dnssec}` format
77
+ # (`ttl` an Integer or nil, `dnssec` a Boolean or nil).
78
+ #
79
+ # @raise [ResolutionError] carrying the Array of the sub-resolver errors once more than `options[:maxFailures]`
80
+ # of them fail or none of them answers.
81
+ # @raise [RuntimeError] 'MultiResolver timeout' once `options[:timeout]` elapses.
82
+ def resolve(domain, type, options = {}, config = {})
83
+ config = Helpers.merge_config(config)
84
+ options = Helpers.object_assign({}, @options, options)
85
+
86
+ # `return new Promise((multiResolve, multiReject) => {...})`: the promise body runs inline below; `multiResolve(x)` is `return x`, `multiReject(err)` is `raise`
87
+ results = [] # one Thread per sub-resolver (the JS array of promises)
88
+
89
+ # Handle timeouts through a deadline: the AbortController and its timer have no counterpart, a sub-resolver still
90
+ # running at the deadline is left to finish on its own timeouts and its answer is discarded (nil and 0 mean no deadline, as `if (options.timeout)`)
91
+ deadline = options[:timeout].nil? || options[:timeout] == 0 ? nil : Helpers.now + options[:timeout]
92
+
93
+ # maxFailures and timeout are consumed at this layer; every other option fans out
94
+ # to the sub-resolvers, with the shared abort signal replacing any caller-provided one
95
+ child_options = Helpers.object_assign({}, options)
96
+ child_options.delete(:maxFailures)
97
+ child_options.delete(:timeout)
98
+ # the `childOptions.signal` branches have no counterpart: no `:signal` key ever exists
99
+
100
+ @resolvers.each do |resolver|
101
+ results.push(Thread.new do
102
+ Thread.current.report_on_exception = false
103
+ resolver_name = resolver.class.name.to_s.split('::').last # `resolver.constructor.name` (an anonymous class prints as '' there too)
104
+ Helpers.log(config, :debug, "[DNS] Querying resolver #{resolver_name} for #{type} records of #{domain}")
105
+ ts = Helpers.now
106
+
107
+ begin
108
+ res = resolver.resolve(domain, type, child_options, config)
109
+ Helpers.log(config, :debug, "[DNS] Resolved #{res.length} record(s) with #{resolver_name} in #{(Helpers.now - ts).round}ms", { res: res })
110
+ [:fulfilled, res]
111
+ rescue StandardError => err
112
+ Helpers.log(config, :debug, "[DNS] Failed resolution with #{resolver_name} in #{(Helpers.now - ts).round}ms", { cause: err })
113
+ [:rejected, err]
114
+ end
115
+ end)
116
+ end
117
+
118
+ # the port of `Promise.allSettled(results).then((values) => ...)`: each Thread is joined with the time left to the
119
+ # deadline; a Thread still running at the deadline is left to finish on its own resolver timeouts and its answer is
120
+ # discarded, and the call fails as the JS `multiReject(new Error('MultiResolver timeout'))` does
121
+ values = results.map do |thread|
122
+ joined = deadline.nil? ? thread.join : thread.join([(deadline - Helpers.now) / 1000.0, 0].max)
123
+ raise 'MultiResolver timeout' if joined.nil?
124
+
125
+ thread.value
126
+ end
127
+
128
+ # `clearTimeout(timeoutHandle)` has no counterpart: no timer is armed
129
+
130
+ failed_resolutions = values.select do |v|
131
+ v[0] != :fulfilled
132
+ end
133
+
134
+ fulfilled_resolutions = values.select do |v|
135
+ v[0] == :fulfilled
136
+ end
137
+
138
+ # Enforce the maxFailures limit from options
139
+ if failed_resolutions.length > (options[:maxFailures].nil? || options[:maxFailures] == 0 ? 0 : options[:maxFailures]) || fulfilled_resolutions.length <= 0
140
+ raise(ResolutionError, failed_resolutions.map { |fr| fr[1] })
141
+ end
142
+
143
+ # Start with the first result, and limit/modify it basing on other
144
+ retval = fulfilled_resolutions[0][1]
145
+
146
+ # Remove entries which values are not present in responses of _all_ fulfilled resolvers
147
+ fulfilled_resolutions.each do |v|
148
+ retval = retval.select do |record|
149
+ rv = v[1].map do |e|
150
+ e[:value]
151
+ end.include?(record[:value])
152
+
153
+ if !rv
154
+ Helpers.log(config, :debug, '[DNS] Ignoring DNS record for which non-matching responses were received', { record: record })
155
+ end
156
+
157
+ rv
158
+ end
159
+ end
160
+
161
+ # Modify dnssec and ttl properties
162
+ retval.each do |e|
163
+ # Track explicit per-record dnssec reports across all fulfilled resolvers
164
+ saw_true = false
165
+ saw_false = false
166
+
167
+ fulfilled_resolutions.each do |v|
168
+ v[1].each do |r|
169
+ if r[:value] == e[:value]
170
+
171
+ # Any defined non-true report counts as an explicit veto
172
+ # (`r.dnssec !== undefined`: a nil dnssec is "no opinion", the JS `null` veto cannot be expressed by Ruby data)
173
+ if r[:dnssec] == true
174
+ saw_true = true
175
+ elsif !r[:dnssec].nil?
176
+ saw_false = true
177
+ end
178
+
179
+ # e.ttl and/or r.ttl may be undefined; ?? keeps a TTL of 0 in the min-aggregation
180
+ # (`undefined < number` is false in JS: a record without a TTL never lowers the min)
181
+ if !r[:ttl].nil? && r[:ttl] < (e[:ttl].nil? ? Float::INFINITY : e[:ttl])
182
+ e[:ttl] = r[:ttl]
183
+ end
184
+
185
+ end
186
+ end
187
+ end
188
+
189
+ # dnssec is true only when at least one resolver explicitly reported true and
190
+ # none reported false; it stays nil when no resolver expressed an opinion.
191
+ e[:dnssec] = if saw_false
192
+ false
193
+ elsif saw_true
194
+ true
195
+ end
196
+ end
197
+
198
+ return retval
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2026 The Triauth Authors (https://www.triauth.org/)
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ # SPDX-License-Identifier: Apache-2.0
18
+
19
+
20
+ require 'resolv'
21
+ require 'socket'
22
+ require_relative 'base'
23
+ require_relative '../helpers'
24
+
25
+ module Triauth
26
+ module Resolvers
27
+ # A DNS resolver that uses the Ruby standard library's `Resolv::DNS` to perform queries.
28
+ # This resolver works with the system's configured nameservers (`/etc/resolv.conf`, or the registry on Windows).
29
+ #
30
+ # @see https://docs.ruby-lang.org/en/3.2/Resolv/DNS.html Documentation
31
+ class SystemDns < Base
32
+ # @return [Hash] The resolver options passed to the constructor.
33
+ attr_reader :options
34
+
35
+ # @param options [Hash] Defaults to `{}`.
36
+ # @option options [Integer] :tries How many times each name server is tried before the query fails
37
+ # (`ARES_OPT_TRIES`; this works differently than `retries` in DnsJson). Defaults to 3.
38
+ # @option options [Integer] :timeout How long to wait (ms) for a single try before it counts as failed.
39
+ # Defaults to nil, which leaves the `Resolv::DNS` default (5 s per try) in place.
40
+ def initialize(options = {})
41
+ super()
42
+ @options = options
43
+ # `this.dns = null` has no counterpart: `resolv` is loaded with the file, there is nothing to load lazily
44
+ end
45
+
46
+ # `#loadDns` has no counterpart: `require 'resolv'` above is the whole load, and there is no browser lane
47
+
48
+ # Queries the DNS for resource records of a given type that are stored under a given domain name,
49
+ # through the Ruby `Resolv::DNS` resolver.
50
+ #
51
+ # @param domain [String] A domain name to query the DNS for.
52
+ # @param type [String] A type of DNS resource record to look for; only 'TXT' is supported.
53
+ # @param options [Hash] Per-call overrides for the resolver options passed to the constructor. Defaults to `{}`.
54
+ # @param config [Hash] Optional overrides for the global Triauth.config settings. Defaults to `{}`.
55
+ #
56
+ # @return [Array<Hash>] The array of retrieved DNS resource records in `{value, ttl, dnssec}`
57
+ # format. `Resolv::DNS` reports the TTL of every resource record but no DNSSEC status, so `ttl` carries the
58
+ # record's TTL in seconds and `dnssec` is always nil, and a record whose bytes are not well-formed UTF-8 is
59
+ # left out of the answer. Every TXT record of the reply's answer section is returned whatever its owner name,
60
+ # as `node:dns` does, so a name that is a CNAME alias resolves to its target's TXT records. A missing name
61
+ # (NXDOMAIN) and a name with no records of this type (NODATA) both resolve to an empty array. It raises for a
62
+ # non-TXT query type, and upon any other resolver error.
63
+ def resolve(domain, type, options = {}, config = {})
64
+ config = Helpers.merge_config(config)
65
+
66
+ options = Helpers.object_assign({
67
+ tries: 3, # this works differently than "retries" in dns_json - @see ARES_OPT_TRIES on https://manpages.ubuntu.com/manpages/oracular/en/man3/ares_init_options.3.html
68
+ timeout: nil
69
+ # `signal: null` has no counterpart: there is no abort signal here
70
+ }, @options, options)
71
+
72
+ # `return new Promise((resolve, reject) => {...})`: the promise body runs inline below; `resolve(x)` is `return x`, `reject(err)` is `raise err`
73
+
74
+ # the `typeof window` rejection has no counterpart: there is no browser lane
75
+
76
+ if type != 'TXT'
77
+ raise 'Non TXT queries are not supported yet for this resolver'
78
+ end
79
+
80
+ # the `#loadDns().then((dns) => ...)` chain and the 'node:dns resolver is not available' rejection have no counterpart: `resolv` is always loaded
81
+
82
+ # @see https://nodejs.org/dist/latest-v20.x/docs/api/dns.html#resolveroptions
83
+ # @see https://manpages.ubuntu.com/manpages/oracular/en/man3/ares_init_options.3.html
84
+ # @see https://docs.ruby-lang.org/en/3.2/Resolv/DNS.html
85
+ # the port of `new dns.Resolver({timeout, tries})`: `options.timeout || -1` leaves the resolver's default per-try
86
+ # timeout in place (`Resolv::DNS::Config::InitialTimeout`, 5 s), `options.tries || 3` the number of rounds
87
+ tries = options[:tries].nil? || options[:tries] == 0 ? 3 : options[:tries]
88
+ per_try_seconds = options[:timeout].nil? || options[:timeout] == 0 ? Resolv::DNS::Config::InitialTimeout : options[:timeout] / 1000.0
89
+
90
+ # the `onAbort` listener and its removal have no counterpart: there is no abort signal
91
+
92
+ reply = resolve_txt(domain, Array.new(tries, per_try_seconds))
93
+
94
+ # the port of the `resolveTxt` callback: `err.code` is the reply's rcode here
95
+ # A missing name (NXDOMAIN/ENOTFOUND) and a name with no records of this type (ENODATA)
96
+ # are both empty answers - same as the DoH resolvers report them; other errors propagate
97
+ case reply.rcode
98
+ when Resolv::DNS::RCode::NXDomain
99
+ return []
100
+ when Resolv::DNS::RCode::NoError
101
+ # NODATA is a NoError reply with no TXT answers: an empty answer below
102
+ else
103
+ raise Resolv::ResolvError, "DNS result has error: rcode #{reply.rcode} for #{domain}"
104
+ end
105
+
106
+ # c-ares (`ares_parse_txt_reply`) returns every TXT record of the answer section whatever its owner name, so a CNAME
107
+ # chain is followed to its target's records; the DoH resolvers filter by name instead, as `dns_json.js` does
108
+ records = []
109
+ reply.each_answer do |_n, ttl, data|
110
+ if data.is_a?(Resolv::DNS::Resource::IN::TXT)
111
+ records.push([ttl, data.strings])
112
+ end
113
+ end
114
+
115
+ return records.flat_map do |ttl, strings|
116
+ # A TXT record's character-strings concatenate in order with no separator;
117
+ # Resolv delivers them as `strings`, an array of binary chunks, one byte per octet.
118
+ raw = strings.join
119
+
120
+ begin
121
+ val = Helpers.utf8_bytes_to_string(raw)
122
+ rescue StandardError => err
123
+ Helpers.log(config, :debug, err)
124
+ # A record whose bytes are not well-formed UTF-8 is ignored as a whole
125
+ next []
126
+ end
127
+
128
+ # Resolv reports the TTL of the resource record (`node:dns` reports none); the AD bit is not exposed, so `dnssec` stays nil
129
+ [{
130
+ value: val,
131
+ ttl: ttl,
132
+ dnssec: nil
133
+ }]
134
+ end
135
+ end
136
+
137
+ private
138
+
139
+ # The port of the `dns.Resolver` + `resolveTxt` primitive: performs the TXT query for the absolute name through
140
+ # `Resolv::DNS::Requester::UnconnectedUDP` and returns the final `Resolv::DNS::Message` reply, whatever its rcode
141
+ # (`resolve` maps the rcode where `node_dns.js` maps `err.code`). `Resolv::DNS#getresources` cannot be used here:
142
+ # it swallows SERVFAIL and timeouts into an empty answer, which `node:dns` reports as `ESERVFAIL`/`ETIMEOUT`. Each
143
+ # try sends the query to every configured nameserver with the try's timeout; a truncated UDP reply repeats the
144
+ # query over TCP through `tcp_exchange`, bounded by the same timeout (Resolv's own `Requester::TCP` connects without
145
+ # one). A timeout of either exchange ends the try (the next nameserver, then the next try); other socket errors
146
+ # propagate. The unit tests replace this method on the instance with `define_singleton_method`.
147
+ #
148
+ # @param domain [String] The domain name to query (the query is made for its absolute form).
149
+ # @param timeouts [Array<Numeric>] The per-try timeouts in seconds, one entry per try.
150
+ #
151
+ # @return [Resolv::DNS::Message] the reply.
152
+ #
153
+ # @raise [Resolv::ResolvError] when every try times out.
154
+ def resolve_txt(domain, timeouts)
155
+ name = Resolv::DNS::Name.create("#{domain}.")
156
+ config = Resolv::DNS::Config.new
157
+ config.lazy_initialize
158
+ nameserver_port = config.nameserver_port
159
+ requester = Resolv::DNS::Requester::UnconnectedUDP.new(*nameserver_port)
160
+ begin
161
+ timeouts.each do |tout|
162
+ nameserver_port.each do |host, port|
163
+ msg = Resolv::DNS::Message.new
164
+ msg.rd = 1
165
+ msg.add_question(name, Resolv::DNS::Resource::IN::TXT)
166
+ sender = requester.sender(msg, name, host, port)
167
+ next if sender.nil? # no socket for the nameserver's address family (as `Resolv::DNS#fetch_resource` skips it)
168
+
169
+ begin
170
+ reply, = requester.request(sender, tout)
171
+ if reply.tc == 1
172
+ # a truncated UDP reply: the same query over TCP, under the same try timeout
173
+ reply = tcp_exchange(msg, name, host, port, tout)
174
+ end
175
+ rescue Resolv::ResolvTimeout
176
+ next
177
+ end
178
+ return reply
179
+ end
180
+ end
181
+ raise Resolv::ResolvError, "DNS resolv timeout: #{domain}"
182
+ ensure
183
+ requester.close
184
+ end
185
+ end
186
+
187
+ # The TCP fallback of `resolve_txt` for a truncated UDP reply: one query over a fresh TCP connection, every phase
188
+ # under the try's timeout `tout` as one deadline - `Socket.tcp(host, port, connect_timeout: tout)`, the
189
+ # length-prefixed message written once the socket is writable, then the 2-byte length and the message read with
190
+ # `wait_readable` and `read_nonblock` until the deadline. The query goes out under a fresh random id (allocated
191
+ # and freed through `Resolv::DNS.allocate_request_id` / `free_request_id`, as Resolv's own senders do), which the
192
+ # reply must carry. A connect timeout, the deadline passing or the peer closing early raise `Resolv::ResolvTimeout`
193
+ # (the try is over, as after a UDP timeout); a reply whose id is not the query's raises `Resolv::ResolvError`;
194
+ # other socket errors propagate. The socket is closed in an `ensure`.
195
+ #
196
+ # @param msg [Resolv::DNS::Message] The query message (sent under a fresh id, see above).
197
+ # @param name [Resolv::DNS::Name] The queried name, for the error message.
198
+ # @param host [String] The nameserver address.
199
+ # @param port [Integer] The nameserver port.
200
+ # @param tout [Numeric] The try's timeout in seconds.
201
+ #
202
+ # @return [Resolv::DNS::Message] the reply.
203
+ #
204
+ # @raise [Resolv::ResolvTimeout] when the exchange does not complete within `tout`.
205
+ # @raise [Resolv::ResolvError] when the reply carries another id.
206
+ def tcp_exchange(msg, name, host, port, tout)
207
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + tout
208
+ begin
209
+ sock = Socket.tcp(host, port, connect_timeout: tout)
210
+ rescue Errno::ETIMEDOUT
211
+ raise Resolv::ResolvTimeout
212
+ end
213
+ begin
214
+ # a fresh random query id, as Resolv's own senders (and c-ares, across its TCP retry) give every query: the
215
+ # Message's default id is a process-wide counter, which the reply check below must not rely on; freed with the socket
216
+ id = Resolv::DNS.allocate_request_id(host, port)
217
+ data = msg.encode
218
+ data[0, 2] = [id].pack('n')
219
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
220
+ if remaining <= 0 || sock.wait_writable(remaining).nil?
221
+ raise Resolv::ResolvTimeout
222
+ end
223
+
224
+ sock.write([data.bytesize].pack('n') + data)
225
+
226
+ # the 2-byte length prefix, then the message, read until the deadline
227
+ buffer = ''.b
228
+ expected = 2
229
+ while buffer.bytesize < expected
230
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
231
+ if remaining <= 0 || sock.wait_readable(remaining).nil?
232
+ raise Resolv::ResolvTimeout
233
+ end
234
+
235
+ chunk = sock.read_nonblock(expected - buffer.bytesize, exception: false)
236
+ if chunk.nil? # EOF: the peer closed before the whole reply arrived
237
+ raise Resolv::ResolvTimeout
238
+ end
239
+
240
+ buffer << chunk if chunk != :wait_readable
241
+ if expected == 2 && buffer.bytesize == 2
242
+ expected = 2 + buffer.unpack1('n')
243
+ end
244
+ end
245
+
246
+ reply = Resolv::DNS::Message.decode(buffer.byteslice(2, expected - 2))
247
+ if reply.id != id
248
+ raise Resolv::ResolvError, "DNS result has an unexpected id for #{name}"
249
+ end
250
+
251
+ return reply
252
+ ensure
253
+ sock.close
254
+ Resolv::DNS.free_request_id(host, port, id) if !id.nil?
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2026 The Triauth Authors (https://www.triauth.org/)
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ #
17
+ # SPDX-License-Identifier: Apache-2.0
18
+
19
+
20
+ module Triauth
21
+ # The namespace for DNS resolvers.
22
+ module Resolvers
23
+ end
24
+ end
25
+
26
+ require_relative 'resolvers/base'
27
+ require_relative 'resolvers/dns_json'
28
+ require_relative 'resolvers/multi_resolver'
29
+ require_relative 'resolvers/caching_resolver'
30
+ require_relative 'resolvers/cloudflare'
31
+ require_relative 'resolvers/google'
32
+ require_relative 'resolvers/system_dns'
33
+ require_relative 'resolvers/dns_sb'