1
0
مراية لـ https://github.com/postalserver/postal.git تم المزامنة 2025-12-01 05:43:04 +00:00

fix: raise an error if MX lookup times out during sending

This avoids potentially sending mail to the A record when an MX query times out.

closes #2833
هذا الالتزام موجود في:
Adam Cooke
2024-03-01 21:36:07 +00:00
الأصل 77e818a472
التزام fadca88f45
7 ملفات معدلة مع 231 إضافات و33 حذوفات

عرض الملف

@@ -4,20 +4,22 @@ require "resolv"
class DNSResolver class DNSResolver
class LocalResolversUnavailableError < StandardError
end
attr_reader :nameservers attr_reader :nameservers
attr_reader :timeout attr_reader :timeout
def initialize(nameservers: nil, timeout: 5) def initialize(nameservers)
@nameservers = nameservers @nameservers = nameservers
@timeout = timeout
end end
# Return all A records for the given name # Return all A records for the given name
# #
# @param [String] name # @param [String] name
# @return [Array<String>] # @return [Array<String>]
def a(name) def a(name, **options)
get_resources(name, Resolv::DNS::Resource::IN::A).map do |s| get_resources(name, Resolv::DNS::Resource::IN::A, **options).map do |s|
s.address.to_s s.address.to_s
end end
end end
@@ -26,8 +28,8 @@ class DNSResolver
# #
# @param [String] name # @param [String] name
# @return [Array<String>] # @return [Array<String>]
def aaaa(name) def aaaa(name, **options)
get_resources(name, Resolv::DNS::Resource::IN::AAAA).map do |s| get_resources(name, Resolv::DNS::Resource::IN::AAAA, **options).map do |s|
s.address.to_s s.address.to_s
end end
end end
@@ -36,8 +38,8 @@ class DNSResolver
# #
# @param [String] name # @param [String] name
# @return [Array<String>] # @return [Array<String>]
def txt(name) def txt(name, **options)
get_resources(name, Resolv::DNS::Resource::IN::TXT).map do |s| get_resources(name, Resolv::DNS::Resource::IN::TXT, **options).map do |s|
s.data.to_s.strip s.data.to_s.strip
end end
end end
@@ -46,8 +48,8 @@ class DNSResolver
# #
# @param [String] name # @param [String] name
# @return [Array<String>] # @return [Array<String>]
def cname(name) def cname(name, **options)
get_resources(name, Resolv::DNS::Resource::IN::CNAME).map do |s| get_resources(name, Resolv::DNS::Resource::IN::CNAME, **options).map do |s|
s.name.to_s.downcase s.name.to_s.downcase
end end
end end
@@ -56,8 +58,8 @@ class DNSResolver
# #
# @param [String] name # @param [String] name
# @return [Array<Array<Integer, String>>] # @return [Array<Array<Integer, String>>]
def mx(name) def mx(name, **options)
records = get_resources(name, Resolv::DNS::Resource::IN::MX).map do |m| records = get_resources(name, Resolv::DNS::Resource::IN::MX, **options).map do |m|
[m.preference.to_i, m.exchange.to_s] [m.preference.to_i, m.exchange.to_s]
end end
records.sort do |a, b| records.sort do |a, b|
@@ -73,13 +75,13 @@ class DNSResolver
# #
# @param [String] name # @param [String] name
# @return [Array<String>] # @return [Array<String>]
def effective_ns(name) def effective_ns(name, **options)
records = [] records = []
parts = name.split(".") parts = name.split(".")
(parts.size - 1).times do |n| (parts.size - 1).times do |n|
d = parts[n, parts.size - n + 1].join(".") d = parts[n, parts.size - n + 1].join(".")
records = get_resources(d, Resolv::DNS::Resource::IN::NS).map do |s| records = get_resources(d, Resolv::DNS::Resource::IN::NS, **options).map do |s|
s.name.to_s s.name.to_s
end end
@@ -94,27 +96,31 @@ class DNSResolver
# #
# @param [String] ip_address # @param [String] ip_address
# @return [String] # @return [String]
def ip_to_hostname(ip_address) def ip_to_hostname(ip_address, **options)
dns do |dns| dns(**options) do |dns|
dns.getname(ip_address)&.to_s dns.getname(ip_address)&.to_s
end end
rescue Resolv::ResolvError rescue Resolv::ResolvError => e
raise if e.message =~ /timeout/ && options[:raise_timeout_errors]
ip_address ip_address
end end
private private
def dns def dns(raise_timeout_errors: false)
kwargs = @nameservers ? { nameserver: @nameservers } : {} Resolv::DNS.open(nameserver: @nameservers,
Resolv::DNS.open(**kwargs) do |dns| raise_timeout_errors: raise_timeout_errors) do |dns|
dns.timeouts = [@timeout, @timeout / 2] dns.timeouts = [Postal::Config.dns.timeout,
Postal::Config.dns.timeout / 2,
Postal::Config.dns.timeout / 2]
yield dns yield dns
end end
end end
def get_resources(name, type) def get_resources(name, type, **options)
encoded_name = DomainName::Punycode.encode_hostname(name) encoded_name = DomainName::Punycode.encode_hostname(name)
dns do |dns| dns(**options) do |dns|
dns.getresources(encoded_name, type) dns.getresources(encoded_name, type)
end end
end end
@@ -126,19 +132,28 @@ class DNSResolver
# @param [String] name # @param [String] name
# @return [DNSResolver] # @return [DNSResolver]
def for_domain(name) def for_domain(name)
resolver = new nameservers = local.effective_ns(name)
nameservers = resolver.effective_ns(name)
ips = nameservers.map do |ns| ips = nameservers.map do |ns|
resolver.a(ns) local.a(ns)
end.flatten.uniq end.flatten.uniq
new(nameservers: ips) new(ips)
end end
# Return a local resolver to use for lookups # Return a local resolver to use for lookups
# #
# @return [DNSResolver] # @return [DNSResolver]
def local def local
@local ||= new @local ||= begin
resolv_conf_path = Postal::Config.dns.resolv_conf_path
raise LocalResolversUnavailableError, "No resolver config found at #{resolv_conf_path}" unless File.file?(resolv_conf_path)
resolv_conf = Resolv::DNS::Config.parse_resolv_conf(resolv_conf_path)
if resolv_conf.nil? || resolv_conf[:nameserver].nil? || resolv_conf[:nameserver].empty?
raise LocalResolversUnavailableError, "Could not find nameservers in #{resolv_conf_path}"
end
new(resolv_conf[:nameserver])
end
end end
end end

عرض الملف

@@ -162,7 +162,7 @@ class SMTPSender < BaseSender
# #
# @return [Array<String>] # @return [Array<String>]
def resolve_mx_records_for_domain def resolve_mx_records_for_domain
hostnames = DNSResolver.local.mx(@domain).map(&:last) hostnames = DNSResolver.local.mx(@domain, raise_timeout_errors: true).map(&:last)
return [SMTPClient::Server.new(@domain)] if hostnames.empty? return [SMTPClient::Server.new(@domain)] if hostnames.empty?
hostnames.map { |hostname| SMTPClient::Server.new(hostname) } hostnames.map { |hostname| SMTPClient::Server.new(hostname) }

عرض الملف

@@ -63,6 +63,8 @@ This document contains all the environment variables which are available for thi
| `DNS_DKIM_IDENTIFIER` | String | The identifier to use for DKIM keys in DNS records | postal | | `DNS_DKIM_IDENTIFIER` | String | The identifier to use for DKIM keys in DNS records | postal |
| `DNS_DOMAIN_VERIFY_PREFIX` | String | The prefix to add before TXT record verification string | postal-verification | | `DNS_DOMAIN_VERIFY_PREFIX` | String | The prefix to add before TXT record verification string | postal-verification |
| `DNS_CUSTOM_RETURN_PATH_PREFIX` | String | The domain to use on external domains which points to the Postal return path domain | psrp | | `DNS_CUSTOM_RETURN_PATH_PREFIX` | String | The domain to use on external domains which points to the Postal return path domain | psrp |
| `DNS_TIMEOUT` | Integer | The timeout to wait for DNS resolution | 5 |
| `DNS_RESOLV_CONF_PATH` | String | The path to the resolv.conf file containing addresses for local nameservers | /etc/resolv.conf |
| `SMTP_HOST` | String | The hostname to send application-level e-mails to | 127.0.0.1 | | `SMTP_HOST` | String | The hostname to send application-level e-mails to | 127.0.0.1 |
| `SMTP_PORT` | Integer | The port number to send application-level e-mails to | 25 | | `SMTP_PORT` | Integer | The port number to send application-level e-mails to | 25 |
| `SMTP_USERNAME` | String | The username to use when authentication to the SMTP server | | | `SMTP_USERNAME` | String | The username to use when authentication to the SMTP server | |

عرض الملف

@@ -137,6 +137,10 @@ dns:
domain_verify_prefix: postal-verification domain_verify_prefix: postal-verification
# The domain to use on external domains which points to the Postal return path domain # The domain to use on external domains which points to the Postal return path domain
custom_return_path_prefix: psrp custom_return_path_prefix: psrp
# The timeout to wait for DNS resolution
timeout: 5
# The path to the resolv.conf file containing addresses for local nameservers
resolv_conf_path: /etc/resolv.conf
smtp: smtp:
# The hostname to send application-level e-mails to # The hostname to send application-level e-mails to

عرض الملف

@@ -329,6 +329,16 @@ module Postal
description "The domain to use on external domains which points to the Postal return path domain" description "The domain to use on external domains which points to the Postal return path domain"
default "psrp" default "psrp"
end end
integer :timeout do
description "The timeout to wait for DNS resolution"
default 5
end
string :resolv_conf_path do
description "The path to the resolv.conf file containing addresses for local nameservers"
default "/etc/resolv.conf"
end
end end
group :smtp do group :smtp do

عرض الملف

@@ -3,7 +3,7 @@
require "rails_helper" require "rails_helper"
RSpec.describe DNSResolver do RSpec.describe DNSResolver do
subject(:resolver) { described_class.new } subject(:resolver) { described_class.local }
# Now, we could mock everything in here which would give us some comfort # Now, we could mock everything in here which would give us some comfort
# but I do think that we'll benefit more from having a full E2E test here # but I do think that we'll benefit more from having a full E2E test here
@@ -14,15 +14,52 @@ RSpec.describe DNSResolver do
it "returns a list of IP addresses" do it "returns a list of IP addresses" do
expect(resolver.a("www.test.postalserver.io").sort).to eq ["1.2.3.4", "2.3.4.5"] expect(resolver.a("www.test.postalserver.io").sort).to eq ["1.2.3.4", "2.3.4.5"]
end end
it "resolves a domain name containing an emoji" do it "resolves a domain name containing an emoji" do
expect(resolver.a("☺.test.postalserver.io").sort).to eq ["3.4.5.6"] expect(resolver.a("☺.test.postalserver.io").sort).to eq ["3.4.5.6"]
end end
it "returns an empty array when timeout is exceeded" do
allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)
expect(resolver.a("www.test.postalserver.io")).to eq []
end
context "when raise_timeout_errors is true" do
it "returns a list of IP addresses" do
expect(resolver.a("www.test.postalserver.io", raise_timeout_errors: true).sort).to eq ["1.2.3.4", "2.3.4.5"]
end
it "raises an error when the timeout is exceeded" do
allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)
expect do
resolver.a("www.test.postalserver.io", raise_timeout_errors: true)
end.to raise_error(Resolv::ResolvError, /timeout/)
end
end
end end
describe "#aaaa" do describe "#aaaa" do
it "returns a list of IP addresses" do it "returns a list of IP addresses" do
expect(resolver.aaaa("www.test.postalserver.io").sort).to eq ["2a00:67a0:a::1", "2a00:67a0:a::2"] expect(resolver.aaaa("www.test.postalserver.io").sort).to eq ["2a00:67a0:a::1", "2a00:67a0:a::2"]
end end
it "returns an empty array when timeout is exceeded" do
allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)
expect(resolver.aaaa("www.test.postalserver.io")).to eq []
end
context "when raise_timeout_errors is true" do
it "returns a list of IP addresses" do
expect(resolver.aaaa("www.test.postalserver.io", raise_timeout_errors: true).sort).to eq ["2a00:67a0:a::1", "2a00:67a0:a::2"]
end
it "raises an error when the timeout is exceeded" do
allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)
expect do
resolver.aaaa("www.test.postalserver.io", raise_timeout_errors: true)
end.to raise_error(Resolv::ResolvError, /timeout/)
end
end
end end
describe "#txt" do describe "#txt" do
@@ -32,12 +69,51 @@ RSpec.describe DNSResolver do
"another example" "another example"
] ]
end end
it "returns an empty array when timeout is exceeded" do
allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)
expect(resolver.txt("test.postalserver.io")).to eq []
end
context "when raise_timeout_errors is true" do
it "returns a list of TXT records" do
expect(resolver.txt("test.postalserver.io", raise_timeout_errors: true).sort).to eq [
"an example txt record",
"another example"
]
end
it "raises an error when the timeout is exceeded" do
allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)
expect do
resolver.txt("test.postalserver.io", raise_timeout_errors: true)
end.to raise_error(Resolv::ResolvError, /timeout/)
end
end
end end
describe "#cname" do describe "#cname" do
it "returns a list of CNAME records" do it "returns a list of CNAME records" do
expect(resolver.cname("cname.test.postalserver.io")).to eq ["www.test.postalserver.io"] expect(resolver.cname("cname.test.postalserver.io")).to eq ["www.test.postalserver.io"]
end end
it "returns an empty array when timeout is exceeded" do
allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)
expect(resolver.cname("cname.test.postalserver.io")).to eq []
end
context "when raise_timeout_errors is true" do
it "returns a list of CNAME records" do
expect(resolver.cname("cname.test.postalserver.io", raise_timeout_errors: true)).to eq ["www.test.postalserver.io"]
end
it "raises an error when the timeout is exceeded" do
allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)
expect do
resolver.cname("cname.test.postalserver.io", raise_timeout_errors: true)
end.to raise_error(Resolv::ResolvError, /timeout/)
end
end
end end
describe "#mx" do describe "#mx" do
@@ -47,6 +123,27 @@ RSpec.describe DNSResolver do
[20, "mx2.test.postalserver.io"] [20, "mx2.test.postalserver.io"]
] ]
end end
it "returns an empty array when timeout is exceeded" do
allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)
expect(resolver.mx("test.postalserver.io")).to eq []
end
context "when raise_timeout_errors is true" do
it "returns a list of MX records" do
expect(resolver.mx("test.postalserver.io", raise_timeout_errors: true)).to eq [
[10, "mx1.test.postalserver.io"],
[20, "mx2.test.postalserver.io"]
]
end
it "raises an error when the timeout is exceeded" do
allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)
expect do
resolver.mx("test.postalserver.io", raise_timeout_errors: true)
end.to raise_error(Resolv::ResolvError, /timeout/)
end
end
end end
describe "#effective_ns" do describe "#effective_ns" do
@@ -56,12 +153,51 @@ RSpec.describe DNSResolver do
"the-cake-is-a-lie.katapultdns.com" "the-cake-is-a-lie.katapultdns.com"
] ]
end end
it "returns an empty array when timeout is exceeded" do
allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)
expect(resolver.effective_ns("postalserver.io")).to eq []
end
context "when raise_timeout_errors is true" do
it "returns a list of NS records" do
expect(resolver.effective_ns("postalserver.io", raise_timeout_errors: true).sort).to eq [
"prestigious-honeybadger.katapultdns.com",
"the-cake-is-a-lie.katapultdns.com"
]
end
it "raises an error when the timeout is exceeded" do
allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)
expect do
resolver.effective_ns("postalserver.io", raise_timeout_errors: true)
end.to raise_error(Resolv::ResolvError, /timeout/)
end
end
end end
describe "#ip_to_hostname" do describe "#ip_to_hostname" do
it "returns the hostname for the given IP" do it "returns the hostname for the given IP" do
expect(resolver.ip_to_hostname("151.252.1.100")).to eq "ns1.katapultdns.com" expect(resolver.ip_to_hostname("151.252.1.100")).to eq "ns1.katapultdns.com"
end end
it "returns the IP when the timeout is exceeded" do
allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)
expect(resolver.ip_to_hostname("151.252.1.100")).to eq "151.252.1.100"
end
context "when raise_timeout_errors is true" do
it "returns the hostname for the given IP" do
expect(resolver.ip_to_hostname("151.252.1.100", raise_timeout_errors: true)).to eq "ns1.katapultdns.com"
end
it "raises an error when the timeout is exceeded" do
allow(Postal::Config.dns).to receive(:timeout).and_return(0.00001)
expect do
resolver.ip_to_hostname("151.252.1.100", raise_timeout_errors: true)
end.to raise_error(Resolv::ResolvError, /timeout/)
end
end
end end
describe ".for_domain" do describe ".for_domain" do
@@ -72,9 +208,40 @@ RSpec.describe DNSResolver do
end end
describe ".local" do describe ".local" do
it "returns a resolver with no nameservers" do after do
# Remove all cached values for the local resolver
DNSResolver.instance_variable_set(:@local, nil)
end
it "returns a resolver with the local machine's resolvers" do
resolver = described_class.local resolver = described_class.local
expect(resolver.nameservers).to be nil expect(resolver.nameservers).to be_a Array
expect(resolver.nameservers).to_not be_empty
end
context "when there is no resolv.conf" do
it "raises an error" do
allow(File).to receive(:file?).with("/etc/resolv.conf").and_return(false)
expect { described_class.local }.to raise_error(DNSResolver::LocalResolversUnavailableError,
/no resolver config found at/i)
end
end
context "when no nameservers are found in resolv.conf" do
it "raises an error" do
allow(Resolv::DNS::Config).to receive(:parse_resolv_conf).with("/etc/resolv.conf").and_return({})
expect { described_class.local }.to raise_error(DNSResolver::LocalResolversUnavailableError,
/could not find nameservers in/i)
end
end
context "when an empty array of nameserver is found in resolv.conf" do
it "raises an error" do
allow(Resolv::DNS::Config).to receive(:parse_resolv_conf).with("/etc/resolv.conf")
.and_return({ nameserver: [] })
expect { described_class.local }.to raise_error(DNSResolver::LocalResolversUnavailableError,
/could not find nameservers in/i)
end
end end
end end

عرض الملف

@@ -301,7 +301,7 @@ describe Domain do
context "when local nameservers should not be used" do context "when local nameservers should not be used" do
it "uses the a resolver for this domain" do it "uses the a resolver for this domain" do
allow(DNSResolver).to receive(:for_domain).with(domain.name).and_return(DNSResolver.new(nameservers: ["1.2.3.4"])) allow(DNSResolver).to receive(:for_domain).with(domain.name).and_return(DNSResolver.new(["1.2.3.4"]))
expect(domain.resolver).to be_a DNSResolver expect(domain.resolver).to be_a DNSResolver
expect(domain.resolver.nameservers).to eq ["1.2.3.4"] expect(domain.resolver.nameservers).to eq ["1.2.3.4"]
end end