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