File Coverage

blib/lib/Mail/STS/Domain.pm
Criterion Covered Total %
statement 18 42 42.8
branch 0 12 0.0
condition 0 3 0.0
subroutine 6 10 60.0
pod 1 4 25.0
total 25 71 35.2


line stmt bran cond sub pod time code
1             package Mail::STS::Domain;
2              
3 1     1   16 use Moose;
  1         10  
  1         27  
4              
5             our $VERSION = '0.04'; # VERSION
6             # ABSTRACT: class for MTA-STS domain lookups
7              
8 1     1   11426 use Time::Piece;
  1         8314  
  1         4  
9 1     1   78 use Time::Seconds;
  1         2  
  1         69  
10              
11 1     1   409 use Mail::STS::STSRecord;
  1         4  
  1         44  
12 1     1   467 use Mail::STS::TLSRPTRecord;
  1         4  
  1         46  
13 1     1   489 use Mail::STS::Policy;
  1         5  
  1         1538  
14              
15              
16             has 'domain' => (
17             is => 'ro',
18             isa => 'Str',
19             required => 1,
20             );
21              
22             has 'resolver' => (
23             is => 'ro',
24             isa => 'Net::DNS::Resolver',
25             required => 1,
26             );
27              
28             has 'agent' => (
29             is => 'ro',
30             isa => 'LWP::UserAgent',
31             required => 1,
32             );
33              
34             has 'max_policy_size' => (
35             is => 'rw',
36             isa => 'Maybe[Int]',
37             default => 65536,
38             );
39              
40             my $RECORDS = {
41             'mx' => {
42             type => 'MX',
43             },
44             'a' => {
45             type => ['AAAA', 'A'],
46             },
47             'tlsa' => {
48             type => 'TLSA',
49             name => sub { '_25._tcp.'.shift },
50             from => 'primary',
51             },
52             'sts' => {
53             type => 'TXT',
54             name => sub { '_mta-sts.'.shift },
55             },
56             'tlsrpt' => {
57             type => 'TXT',
58             name => sub { '_smtp._tls.'.shift },
59             },
60             };
61              
62             foreach my $record (keys %$RECORDS) {
63             my $is_secure = "is_${record}_secure";
64             my $accessor = "_${record}";
65             my $type = $RECORDS->{$record}->{'type'};
66             my $name = $RECORDS->{$record}->{'name'} || sub { shift };
67             my $from = $RECORDS->{$record}->{'from'} || 'domain';
68              
69             has $is_secure => (
70             is => 'ro',
71             isa => 'Bool',
72             lazy => 1,
73             default => sub {
74             my $self = shift;
75             return 0 unless defined $self->$accessor;
76             return $self->$accessor->header->ad ? 1 : 0;
77             },
78             );
79              
80             has $accessor => (
81             is => 'ro',
82             isa => 'Maybe[Net::DNS::Packet]',
83             lazy => 1,
84             default => sub {
85             my $self = shift;
86             my $domainname = $name->($self->$from);
87             return $self->resolver->query($domainname, $type);
88             },
89             clearer => "_reset_${accessor}",
90             );
91             }
92              
93              
94             has 'mx' => (
95             is => 'ro',
96             isa => 'ArrayRef[Str]',
97             lazy => 1,
98             default => sub {
99             my $self = shift;
100             return [] unless defined $self->_mx;
101             my @mx;
102             if( $self->_mx->answer ) {
103             my @rr = grep { $_->type eq 'MX' } $self->_mx->answer;
104             @rr = sort { $a->preference <=> $b->preference } @rr;
105             @mx = map { $_->exchange } @rr;
106             }
107             return \@mx;
108             },
109             traits => ['Array'],
110             handles => {
111             'mx_count' => 'count',
112             },
113             );
114              
115              
116             has 'a' => (
117             is => 'ro',
118             isa => 'Maybe[Str]',
119             lazy => 1,
120             default => sub {
121             my $self = shift;
122             if( my @rr = $self->_a->answer ) {
123             return $self->domain;
124             }
125             return;
126             },
127             );
128              
129              
130             has 'record_type' => (
131             is => 'ro',
132             isa => 'Str',
133             lazy => 1,
134             default => sub {
135             my $self = shift;
136             return 'mx' if $self->mx_count;
137             return 'a' if defined $self->a;
138             return 'non-existent';
139             },
140             );
141              
142              
143             has 'primary' => (
144             is => 'ro',
145             isa => 'Maybe[Str]',
146             lazy => 1,
147             default => sub {
148             my $self = shift;
149             return $self->mx->[0] if $self->record_type eq 'mx';
150             return $self->a if $self->record_type eq 'a';
151             return;
152             },
153             );
154              
155              
156             has 'is_primary_secure' => (
157             is => 'ro',
158             isa => 'Bool',
159             lazy => 1,
160             default => sub {
161             my $self = shift;
162             return $self->is_mx_secure if $self->record_type eq 'mx';
163             return $self->is_a_secure if $self->record_type eq 'a';
164             return 0;
165             },
166             );
167              
168              
169              
170             has 'tlsa' => (
171             is => 'ro',
172             isa => 'Maybe[Net::DNS::RR]',
173             lazy => 1,
174             default => sub {
175             my $self = shift;
176             return unless defined $self->_tlsa;
177             if( my @rr = $self->_tlsa->answer ) {
178             return $rr[0];
179             }
180             return;
181             },
182             );
183              
184              
185             has 'tlsrpt' => (
186             is => 'ro',
187             isa => 'Maybe[Mail::STS::TLSRPTRecord]',
188             lazy => 1,
189             default => sub {
190             my $self = shift;
191             return unless defined $self->_tlsrpt;
192             if( my @rr = $self->_tlsrpt->answer ) {
193             return Mail::STS::TLSRPTRecord->new_from_string($rr[0]->txtdata);
194             }
195             return;
196             },
197             );
198              
199              
200             has 'sts' => (
201             is => 'ro',
202             isa => 'Maybe[Mail::STS::STSRecord]',
203             lazy => 1,
204             default => sub {
205             my $self = shift;
206             return unless defined $self->_sts;
207             if( my @rr = $self->_sts->answer ) {
208             return Mail::STS::STSRecord->new_from_string($rr[0]->txtdata);
209             }
210             return;
211             },
212             clearer => '_reset_sts',
213             );
214              
215              
216             has 'policy_id' => ( is => 'rw', isa => 'Maybe[Str]');
217             has 'policy_expires_at' => ( is => 'rw', isa => 'Maybe[Time::Piece]');
218              
219             sub set_policy_expire {
220 0     0 0   my ($self, $max_age) = @_;
221 0           return Time::Piece->new + Time::Seconds->new($max_age)
222             }
223              
224             sub is_policy_expired {
225 0     0 0   my $self = shift;
226 0 0         return 1 if Time::Piece->new > $self->policy_expires_at;
227 0           return 0;
228             }
229              
230             has 'policy' => (
231             is => 'ro',
232             isa => 'Mail::STS::Policy',
233             lazy => 1,
234             default => sub {
235             my $self = shift;
236             die('could not retrieve _mta_sts record') unless defined $self->sts;
237             $self->policy_id( $self->sts->id );
238             my $policy = $self->retrieve_policy();
239             $self->set_policy_expire($policy->max_age);
240             return $policy;
241             },
242             clearer => '_reset_policy',
243             );
244              
245             sub retrieve_policy {
246 0     0 0   my $self = shift;
247 0           my $url = 'https://mta-sts.'.$self->domain.'/.well-known/mta-sts.txt';
248 0           my $response = $self->agent->get($url);
249 0           my $content = $response->decoded_content;
250 0 0 0       if(defined $self->max_policy_size && length($content) > $self->max_policy_size) {
251 0           die('policy exceeding maximum policy size limit');
252             }
253 0 0         die('could not retrieve policy: '.$response->status_line) unless $response->is_success;
254 0           return Mail::STS::Policy->new_from_string($content);
255             }
256              
257              
258             sub check_policy_update {
259 0     0 1   my $self = shift;
260 0 0         return 0 unless $self->is_policy_expired;
261              
262 0           $self->_reset__sts;
263 0           $self->_reset_sts;
264 0 0         die('could not retrieve _mta_sts record') unless $self->sts;
265 0           my $new_id = $self->sts->id;
266 0 0         if($self->policy_id eq $new_id) {
267 0           $self->set_policy_expire($self->policy->max_age);
268 0           return 0;
269             }
270              
271 0           $self->_reset_policy;
272 0           return 1;
273             }
274              
275              
276              
277             1;
278              
279             __END__
280              
281             =pod
282              
283             =encoding UTF-8
284              
285             =head1 NAME
286              
287             Mail::STS::Domain - class for MTA-STS domain lookups
288              
289             =head1 VERSION
290              
291             version 0.04
292              
293             =head1 SYNOPSIS
294              
295             my $domain = $sts->domain('example.com');
296             # or construct it yourself
297             my $domain = Mail::STS::Domain(
298             resolver => $resolver, # Net::DNS::Resolver
299             agent => $agent, # LWP::UserAgent
300             domain => 'example.com',
301             );
302              
303             $domain->mx;
304             # [ 'mta1.example.com', ... ]
305             $domain->tlsa;
306             # undef or Net::DNS::RR:TLSA
307             $domain->primary
308             # mta1.example.com
309             $domain->tlsrpt;
310             # undef or Mail::STS::TLSRPTRecord
311             $domain->sts;
312             # undef or Mail::STS::STSRecord
313             $domain->policy;
314             # Mail::STS::Policy or will die()
315              
316             =head1 ATTRIBUTES
317              
318             =head2 domain (required)
319              
320             The domain to lookup.
321              
322             =head2 resolver (required)
323              
324             A Net::DNS::Resolver object to use for DNS lookups.
325              
326             =head2 agent (required)
327              
328             A LWP::UserAgent object to use for retrieving policy
329             documents by https.
330              
331             =head2 max_policy_size(default: 65536)
332              
333             Maximum size allowed for STS policy document.
334              
335             =head1 METHODS
336              
337             =head2 mx()
338              
339             Retrieves MX hostnames from DNS and returns a array reference.
340              
341             List is sorted by priority.
342              
343             $domain->mx;
344             # [ 'mta1.example.com', 'backup-mta1.example.com' ]
345              
346             =head2 a()
347              
348             Returns the domainname if a AAAA or A record exists for the domain.
349              
350             $domain->a;
351             # "example.com"
352              
353             =head2 record_type()
354              
355             Returns the type of record the domain resolves to:
356              
357             =over
358              
359             =item "mx"
360              
361             If domain has MX records.
362              
363             =item "a"
364              
365             If domain has an AAAA or A record.
366              
367             =item "non-existent"
368              
369             If the domain does not exist.
370              
371             =back
372              
373             =head2 primary()
374              
375             Returns the hostname of the primary MTA for this domain.
376              
377             In case of MX records the first element of mx().
378              
379             In case of an AAAA or A record the domainname.
380              
381             Or undef if the domain does not resolve at all.
382              
383             =head2 is_primary_secure()
384              
385             Returns 1 if resolver signaled successfull DNSSEC validation
386             for the hostname returned by primary().
387              
388             Otherwise returns 0.
389              
390             =head2 tlsa()
391              
392             Returns a Net::DNS::RR in case an TLSA record exists
393             for the hostname returned by primary() otherwise undef.
394              
395             =head2 tlsrpt()
396              
397             Returns an Mail::STS::TLSRPTRecord if a TLSRPT TXT
398             record for the domain could be lookup.
399              
400             =head2 sts()
401              
402             Returns an Mail::STS::STSRecord if a STS TXT
403             record for the domain could be lookup.
404              
405             =head2 policy()
406              
407             Returns a Mail::STS::Policy object if a policy for the domain
408             could be retrieved by the well known URL.
409              
410             Otherwise will die with an error.
411              
412             =head2 check_policy_update()
413              
414             Checks if a new version of the policy is available.
415              
416             First checks if the policy max_age has expired.
417             Then checks if the _mta_sts record lists a new policy version.
418              
419             If there is a new policy the current policy will be resettet
420             so the next call to ->policy() will return the new policy.
421              
422             Returns 1 if new policy was found otherwise 0.
423              
424             =head2 is_mx_secure()
425             =head2 is_a_secure()
426             =head2 is_tlsa_secure()
427             =head2 is_sts_secure()
428             =head2 is_tlsrpt_secure()
429              
430             Returns 1 if resolver signaled successfull DNSSEC validation
431             (ad flag) for returned record otherwise returns 0.
432              
433             =head1 AUTHOR
434              
435             Markus Benning <ich@markusbenning.de>
436              
437             =head1 COPYRIGHT AND LICENSE
438              
439             This software is copyright (c) 2018 by Markus Benning <ich@markusbenning.de>.
440              
441             This is free software; you can redistribute it and/or modify it under
442             the same terms as the Perl 5 programming language system itself.
443              
444             =cut