File Coverage

lib/Net/DNS/SPF/Expander.pm
Criterion Covered Total %
statement 268 304 88.1
branch 39 56 69.6
condition 5 10 50.0
subroutine 35 36 97.2
pod 2 2 100.0
total 349 408 85.5


line stmt bran cond sub pod time code
1             package Net::DNS::SPF::Expander;
2             $Net::DNS::SPF::Expander::VERSION = '0.020';
3 4     4   8593 use Moose;
  4         1792337  
  4         24  
4 4     4   29924 use IO::All -utf8;
  4         54276  
  4         38  
5 4     4   2651 use Net::DNS::ZoneFile;
  4         164736  
  4         286  
6 4     4   2127 use Net::DNS::Resolver;
  4         236078  
  4         653  
7 4     4   2990 use MooseX::Types::IO::All 'IO_All';
  4         333438  
  4         34  
8 4     4   6438 use List::AllUtils qw(sum any part first uniq);
  4         40138  
  4         453  
9 4     4   34 use Scalar::Util ();
  4         8  
  4         15940  
10              
11             with 'MooseX::Getopt';
12              
13             # ABSTRACT: Expands DNS SPF records, so you don't have to.
14             # The problem is that you only get 10 per SPF records,
15             # and recursions count against you. Your record won't
16             # validate.
17              
18             =head1 NAME
19              
20             Net::DNS::SPF::Expander
21              
22             =head1 DESCRIPTION
23              
24             This module expands DNS SPF records, so you don't have to.
25             The problem is that you only get 10 per SPF record,
26             and recursions count against you. Your record won't
27             validate.
28              
29             Let's say you start with this as an SPF record:
30              
31             @ TXT "v=spf1 include:_spf.google.com include:sendgrid.net a:hq1.campusexplorer.com a:hq2.campusexplorer.com a:mail2.campusexplorer.com ~all"
32              
33             You go to http://www.kitterman.com/spf/validate.html and check this record.
34             It passes validation. But later you come back and add salesforce, so that you
35             now have:
36              
37             @ TXT "v=spf1 include:_spf.google.com include:sendgrid.net include:salesforce.com a:hq1.campusexplorer.com a:hq2.campusexplorer.com a:mail2.campusexplorer.com ~all"
38              
39             And now your record fails validation.
40              
41             _spf.google.com takes 3 lookups.
42             _spf1.google.com
43             _spf2.google.com
44             _spf3.google.com
45             sendgrid.net takes 1 lookup.
46             _sendgrid.biz
47             hq1 takes 1 lookup.
48             hq2 takes 1 lookup.
49             mail2 takes 1 lookup.
50              
51             Salesforce adds:
52              
53             _spf.google.com (3 you already did)
54             _spf1.google.com
55             _spf2.google.com
56             _spf3.google.com
57             mx takes 4 lookups.
58             salesforce.com.s8a1.psmtp.com.
59             salesforce.com.s8a2.psmtp.com.
60             salesforce.com.s8b1.psmtp.com.
61             salesforce.com.s8b2.psmtp.com.
62              
63             So now instead of 7 you have 14. The common advice is to
64             expand them, and that is a tedious process. It's especially
65             tedious when, say, salesforce changes their mx record.
66              
67             So this module and the accompanying script attempt
68             to automate this process for you.
69              
70             =head1 SYNOPSIS
71              
72             Using the script:
73              
74             myhost:~/ $ dns-dpf-expander --input_file zone.db
75             myhost:~/ $ ls
76             zone.db zone.db.new zone.db.bak
77              
78             Using the module:
79              
80             {
81             package MyDNSExpander;
82              
83             use Net::DNS::SPF::Expander;
84              
85             my $input_file = '/home/me/project/etc/zone.db';
86             my $expander = Net::DNS::SPF::Expander->new(
87             input_file => $input_file
88             );
89              
90             my $string = $expander->write;
91              
92             1;
93             }
94              
95             =head1 CONFIGURABLE ATTRIBUTES
96              
97             =head2 input_file
98              
99             This is the path and name of the zonefile whose SPF records you want
100             to expand. It must be a valid L<Net::DNS::Zonefile> zonefile.
101              
102             =cut
103              
104             has 'input_file' => (
105             is => 'ro',
106             isa => IO_All,
107             coerce => 1,
108             required => 1,
109             );
110              
111             =head2 output_file
112              
113             The path and name of the output file. By default, we tack ".new"
114             onto the end of the original filename.
115              
116             =cut
117              
118             has 'output_file' => (
119             is => 'ro',
120             isa => 'Str',
121             lazy => 1,
122             builder => '_build_output_file',
123             );
124              
125             =head2 backup_file
126              
127             The path and name of the backup file. By default, we tack ".bak"
128             onto the end of the original filename.
129              
130             =cut
131              
132             has 'backup_file' => (
133             is => 'ro',
134             isa => IO_All,
135             lazy => 1,
136             coerce => 1,
137             builder => '_build_backup_file',
138             );
139              
140             =head2 nameservers
141              
142             A list of nameservers that will be passed to the resolver.
143              
144             =cut
145              
146             has 'nameservers' => (
147             is => 'ro',
148             isa => 'Maybe[ArrayRef]',
149             );
150              
151             =head2 parsed_file
152              
153             The L<Net::DNS::Zonefile> object created from the input_file.
154              
155             =cut
156              
157             has 'parsed_file' => (
158             is => 'ro',
159             isa => 'Net::DNS::ZoneFile',
160             lazy => 1,
161             builder => '_build_parsed_file',
162             );
163              
164             =head2 to_expand
165              
166             An arrayref of regexes that we will expand. By default we expand
167             a, mx, include, and redirect records. Configurable.
168              
169             =cut
170              
171             has 'to_expand' => (
172             is => 'ro',
173             isa => 'ArrayRef[RegexpRef]',
174             default => sub {
175             [ qr/^a:/, qr/^mx/, qr/^include/, qr/^redirect/, ];
176             },
177             );
178              
179             =head2 to_copy
180              
181             An arrayref of regexes that we will simply copy over. By default
182             we will copy ip4, ip6, ptr, and exists records. Configurable.
183              
184             =cut
185              
186             has 'to_copy' => (
187             is => 'rw',
188             isa => 'ArrayRef[RegexpRef]',
189             default => sub {
190             [ qr/v=spf1/, qr/^ip4/, qr/^ip6/, qr/^ptr/, qr/^exists/, ];
191             },
192             );
193              
194             =head2 to_ignore
195              
196             An arrayref of regexes that we will ignore. By default we ignore ?all,
197             exp, v=spf1, and ~all.
198              
199             =cut
200              
201             has 'to_ignore' => (
202             is => 'ro',
203             isa => 'ArrayRef[RegexpRef]',
204             default => sub {
205             [ qr/^v=spf1/, qr/^(\??)all/, qr/^exp/, qr/^~all/ ];
206             },
207             );
208              
209             =head2 maximum_record_length
210              
211             We leave out the protocol declaration and the trailing ~all
212             while we are expanding records, so we need to subtract their length
213             from our length calculation.
214              
215             =cut
216              
217             has 'maximum_record_length' => (
218             is => 'ro',
219             isa => 'Int',
220             default => sub {
221             255 - length('v=spf1 ') - length(' ~all') - length('"') - length('"');
222             },
223             );
224              
225             =head2 ttl
226              
227             Default time to live is 10 minutes. Configurable.
228              
229             =cut
230              
231             has 'ttl' => (
232             is => 'ro',
233             isa => 'Str',
234             default => sub {
235             '10M',;
236             },
237             );
238              
239             =head2 origin
240              
241             The origin of the zonefile. We take it from the zonefile,
242             or you can set it if you like.
243              
244             =cut
245              
246             has 'origin' => (
247             is => 'ro',
248             isa => 'Str',
249             lazy => 1,
250             builder => '_build_origin',
251             );
252              
253             =head1 PRIVATE ATTRIBUTES
254              
255             =head2 _resource_records
256              
257             An arrayref of all the L<Net::DNS::RR> resource records
258             found in the entire parsed_file.
259              
260             =cut
261              
262             has '_resource_records' => (
263             is => 'ro',
264             isa => 'Maybe[ArrayRef[Net::DNS::RR]]',
265             lazy => 1,
266             builder => '_build__resource_records',
267             );
268              
269             =head2 _spf_records
270              
271             An arrayref of the L<Net::DNS::RR::TXT> or L<Net::DNS::RR::SPF>
272             records found in the entire parsed_file.
273              
274             =cut
275              
276             has '_spf_records' => (
277             is => 'ro',
278             isa => 'Maybe[ArrayRef[Net::DNS::RR]]',
279             lazy => 1,
280             builder => '_build__spf_records',
281             );
282              
283             =head2 _resolver
284              
285             What we use to do the DNS lookups and expand the records. A
286             L<Net::DNS::Resolver> object. You can still set environment
287             variables if you want to change the nameserver it uses.
288              
289             =cut
290              
291             has '_resolver' => (
292             is => 'ro',
293             isa => 'Net::DNS::Resolver',
294             lazy => 1,
295             builder => '_build__resolver',
296             );
297              
298             =head2 _expansions
299              
300             This is a hashref representing the expanded SPF records. The keys
301             are the names of the SPF records, and the values are hashrefs.
302             Those are keyed on the include, and the values are arrayrefs of the
303             expanded values. There is also a key called "elements" which gathers
304             all the includes into one place, e.g.,
305              
306             "*.test_zone.com" => {
307             "~all" => undef,
308             elements => [
309             "ip4:216.239.32.0/19", "ip4:64.233.160.0/19",
310             "ip4:66.249.80.0/20", "ip4:72.14.192.0/18",
311             ...
312             ],
313             "include:_spf.google.com" => [
314             "ip4:216.239.32.0/19",
315             "ip4:64.233.160.0/19",
316             ...
317             ],
318             "ip4:96.43.144.0/20" => [ "ip4:96.43.144.0/20" ],
319             "v=spf1" => undef
320             }
321              
322             They are alpha sorted in the final results for predictability in tests.
323              
324             =cut
325              
326             has '_expansions' => (
327             is => 'ro',
328             isa => 'HashRef',
329             lazy => 1,
330             builder => '_build__expansions',
331             );
332              
333             =head2 _lengths_of_expansions
334              
335             We need to know how long the expanded record would be, because
336             SPF records should be less than 256 bytes. If the expanded
337             record would be longer than that, we need to split it into
338             pieces.
339              
340             =cut
341              
342             has '_lengths_of_expansions' => (
343             is => 'ro',
344             isa => 'HashRef',
345             lazy => 1,
346             builder => '_build__lengths_of_expansions',
347             );
348              
349             =head2 _record_class
350              
351             What sort of records are SPF records? IN records.
352              
353             =cut
354              
355             has '_record_class' => (
356             is => 'ro',
357             isa => 'Str',
358             default => sub {
359             'IN',;
360             },
361             );
362              
363             =head1 BUILDERS
364              
365             =head2 _build_resolver
366              
367             Return a L<Net::DNS::Resolver>. Any nameservers will be passed
368             through to the resolver.
369              
370             =cut
371              
372             sub _build__resolver {
373 4     4   10 my $self = shift;
374 4         87 my $nameservers = $self->nameservers;
375 4 50       71 my $resolver = Net::DNS::Resolver->new(
376             recurse => 1,
377             ( $nameservers ? ( nameservers => $nameservers ) : () ),
378             );
379 4         2054 return $resolver;
380             }
381              
382             =head2 _build_origin
383              
384             Extract the origin from parsed_file.
385              
386             =cut
387              
388             sub _build_origin {
389 3     3   8 my $self = shift;
390 3         103 return $self->parsed_file->origin;
391             }
392              
393             =head2 _build_expansions
394              
395             =cut
396              
397             sub _build__expansions {
398 4     4   10 my $self = shift;
399 4         17 return $self->_expand;
400             }
401              
402             =head2 _build_backup_file
403              
404             Tack a ".bak" onto the end of the input_file.
405              
406             =cut
407              
408             sub _build_backup_file {
409 3     3   7 my $self = shift;
410 3         66 my $path = $self->input_file->filepath;
411 3         208 my $name = $self->input_file->filename;
412 3         139 return "${path}${name}.bak";
413             }
414              
415             =head2 _build_output_file
416              
417             Tack a ".new" onto the end of the input_file.
418              
419             =cut
420              
421             sub _build_output_file {
422 3     3   6 my $self = shift;
423 3         64 my $path = $self->input_file->filepath;
424 3         173 my $name = $self->input_file->filename;
425 3         137 return "${path}${name}.new";
426             }
427              
428             =head2 _build_parsed_file
429              
430             Turn the L<IO::All> filehandle into a L<Net::DNS::Zonefile>
431             object, so that we can extract the SPF records.
432              
433             =cut
434              
435             sub _build_parsed_file {
436 4     4   7 my $self = shift;
437 4         89 my $path = $self->input_file->filepath;
438 4         405 my $name = $self->input_file->filename;
439 4         169 return Net::DNS::ZoneFile->new("${path}${name}");
440             }
441              
442             =head2 _build_resource_records
443              
444             Extract all the resource records from the L<Net::DNS::Zonefile>.
445              
446             =cut
447              
448             sub _build__resource_records {
449 4     4   9 my $self = shift;
450 4         88 my @resource_records = $self->parsed_file->read;
451 4         35987 return \@resource_records;
452             }
453              
454             =head2 _build__spf_records
455              
456             Grep through the _resource_records to find the SPF
457             records. They can be both "TXT" and "SPF" records,
458             so we search for the protocol string, v=spf1.
459              
460             =cut
461              
462             sub _build__spf_records {
463 4     4   7 my $self = shift;
464              
465             # This is crude but correct: SPF records can be both TXT and SPF.
466             my @spf_records =
467 22         653 grep { $_->txtdata =~ /v=spf1/ }
468 38         97 grep { $_->can('txtdata') }
469 4         20 @{ $self->_resource_records };
  4         97  
470 4         196 return \@spf_records;
471             }
472              
473             =head2 _build__lengths_of_expansions
474              
475             Calculate the length of each fully expanded SPF record,
476             because they can't be longer than 256 bytes. We have to split them
477             up into multiple records if they are.
478              
479             =cut
480              
481             sub _build__lengths_of_expansions {
482 4     4   12 my $self = shift;
483 4         94 my $expansions = $self->_expansions;
484 4         13 my $length_per_domain = {};
485 4         17 for my $domain ( keys %$expansions ) {
486             my $record_string = join(
487             ' ',
488 9         13 @{ $expansions->{$domain}{elements} }
  9         81  
489             );
490 9         22 $length_per_domain->{$domain} = length($record_string);
491             }
492 4         102 return $length_per_domain;
493             }
494              
495             =head1 PUBLIC METHODS
496              
497             =head2 write
498              
499             This is the only method you really need to call. This expands all your SPF
500             records and writes out the new and the backup files.
501              
502             Returns a scalar string of the data written to the file.
503              
504             =cut
505              
506             sub write {
507 3     3 1 1222 my $self = shift;
508 3         21 my $lines = $self->_new_records_lines;
509 3         90 io( $self->backup_file )->print( $self->input_file->all );
510 3         7304 io( $self->output_file )->print(@$lines);
511 3         2120 return join( '', @$lines );
512             }
513              
514             =head2 new_spf_records
515              
516             In case you want to see how your records were expanded, this returns
517             the hashref of L<Net::DNS::RR> objects used to create the new records.
518              
519             =cut
520              
521             sub new_spf_records {
522 4     4 1 494 my $self = shift;
523 4         132 my $lengths = $self->_lengths_of_expansions;
524 4         112 my $expansions = $self->_expansions;
525              
526 4         12 my %new_spf_records = ();
527              
528 4         17 for my $domain ( keys %$lengths ) {
529 9         14 my $new_records = [];
530              
531             # We need to make sure the SPF record is less than 256 chars,
532             # including the spf version and trailing ~all.
533 9 50       221 if ( $lengths->{$domain} > $self->maximum_record_length ) {
534             $new_records = $self->_new_records_from_partition(
535             $domain,
536             $expansions->{$domain}{elements},
537 9         34 );
538             } else {
539             $new_records = $self->_new_records_from_arrayref(
540             $domain,
541             $expansions->{$domain}{elements},
542 0         0 );
543             }
544 9         26 $new_spf_records{$domain} = $new_records;
545             }
546 4         30 return \%new_spf_records;
547             }
548              
549             =head1 PRIVATE METHODS
550              
551             =head2 _normalize_component
552              
553             Each component of an SPF record has a prefix, like include:, mx:, etc.
554             Here we chop off the prefix before performing the lookup on the value.
555              
556             =cut
557              
558             sub _normalize_component {
559 220     220   382 my ( $self, $component ) = @_;
560 220         295 my $return = $component;
561 220         779 $return =~ s/^.+?://g;
562 220         420 return $return;
563             }
564              
565             =head2 _perform_expansion
566              
567             Expand a single SPF record component. This returns either undef or the
568             full SPF record string from L<Net::DNS::RR::TXT>->txtdata.
569              
570             =cut
571              
572             sub _perform_expansion {
573 34     34   72 my ( $self, $component ) = @_;
574 34         89 $component = $self->_normalize_component($component);
575 34         673 my $packet = $self->_resolver->search( $component, 'TXT', 'IN' );
576 34 50 33     272875 return unless ($packet) && $packet->isa('Net::DNS::Packet');
577 34         111 my ($answer) = $packet->answer;
578 34 50 33     367 return unless ($answer) && $answer->isa('Net::DNS::RR::TXT');
579 34         110 my $data = $answer->txtdata;
580 34         1584 return $data;
581             }
582              
583             =head2 _expand_spf_component
584              
585             Recursively call _perform_expansion for each component of the SPF record.
586             This returns an array consisting of the component, e.g., include:salesforce.com,
587             and an arrayref consisting of its full expansion, e.g.,
588              
589             [
590             "ip4:216.239.32.0/19",
591             "ip4:64.233.160.0/19",
592             ...
593             "ip6:2c0f:fb50:4000::/36"
594             ]
595              
596             =cut
597              
598             sub _expand_spf_component {
599 882     882   2396 my ( $self, $component, $expansions ) = @_;
600              
601 882   100     1657 $expansions ||= [];
602              
603 882 50       1237 return unless $component;
604              
605 882         1755 my @component_splits = split( ' ', $component );
606 882         1099 my $splits = @component_splits;
607 882 100       1230 if ( $splits > 1 ) {
608 62         114 for my $component (@component_splits) {
609 662         1009 $self->_expand_spf_component( $component, $expansions );
610             }
611             } else {
612 820 100   3028   1876 if (( any { $component =~ $_ } @{ $self->to_ignore } )) {
  3028 100       6908  
  820 100       16771  
613 128         466 return $component;
614 1576     1576   4053 } elsif (( any { $component =~ $_ } @{ $self->to_copy } )) {
  692         13177  
615 656         843 push @{$expansions}, $component;
  656         1111  
616 110     110   284 } elsif (( any { $component =~ $_ } @{ $self->to_expand } )) {
  36         687  
617 34         157 my $new_component = $self->_perform_expansion($component);
618 34         140 $self->_expand_spf_component( $new_component, $expansions );
619             } else {
620 2         7 return $component;
621             }
622             }
623 752         2224 return ( $component, $expansions );
624             }
625              
626             =head2 _expand
627              
628             Create the _expansions hashref from which we generate new SPF records.
629              
630             =cut
631              
632             sub _expand {
633 4     4   8 my $self = shift;
634 4         9 my %spf_hash = ();
635 4         8 my %keys_to_delete = ();
636 4         7 for my $spf_record ( @{ $self->_spf_records } ) {
  4         110  
637 22         340 my @spf_components = split( ' ', $spf_record->txtdata );
638 22         836 for my $spf_component (@spf_components) {
639 186         1938 my $component_name = $self->_normalize_component($spf_component);
640             # We want to make sure that we do not look up spf records that are
641             # defined in this zonefile. So that we could run this tool on a
642             # previously expanded zonefile if we want to. That sort of defeats
643             # the point of the tool, but you may edit the previously expanded zonefile,
644             # adding a new include: or mx, appending it to the other _spfX includes.
645             # We just take the component and its existing expansions and stick them
646             # into the component's parent as a key and value, and then we remove that
647             # component as a separate key from our hash.
648 186 100   2518   583 if ( any { $component_name eq $_->name } @{ $self->_spf_records } ) {
  2518         21868  
  186         4206  
649             my ($zonefile_record)
650 480         4326 = grep { $component_name eq $_->name }
651 30         278 @{ $self->_spf_records };
  30         605  
652 30         329 my ( $comp, $expansions )
653             = $self->_expand_spf_component(
654             $zonefile_record->txtdata );
655 30         97 $spf_hash{ $spf_record->name }{$spf_component} = $expansions;
656 30         477 $keys_to_delete{$component_name} = 1;
657             # If the include or what have you is not defined in the zonefile,
658             # proceed as normal.
659             } else {
660 156         1687 my ( $comp, $expansions )
661             = $self->_expand_spf_component($spf_component);
662 156         455 $spf_hash{ $spf_record->name }{$spf_component} = $expansions;
663             }
664             }
665             my $expansion_elements = $self->_extract_expansion_elements(
666 22         313 $spf_hash{ $spf_record->name } );
667 22         77 $spf_hash{ $spf_record->name }{elements} = $expansion_elements;
668             }
669 4         89 delete @spf_hash{ keys %keys_to_delete };
670 4         108 return \%spf_hash;
671             }
672              
673             =head2 _extract_expansion_elements
674              
675             Filter ignored elements from component expansions.
676              
677             =cut
678              
679             sub _extract_expansion_elements {
680 22     22   235 my ( $self, $expansions ) = @_;
681 22         36 my @elements = ();
682 22         35 my @leading = ();
683 22         32 my @trailing = ();
684 22         94 KEY: for my $key ( keys %$expansions ) {
685 194 100   710   479 if ( any { $key =~ $_ } @{ $self->to_ignore } ) {
  710         1435  
  194         3862  
686 34         115 next KEY;
687             }
688 160 50       486 if ( ref( $expansions->{$key} ) eq 'ARRAY' ) {
689 160         176 for my $expansion ( @{ $expansions->{$key} } ) {
  160         596  
690 902         1129 push @elements, $expansion;
691             }
692             }
693             }
694             # We sort these so we can be sure of the order in tests.
695 22         112 my @return = uniq sort { $a cmp $b } ( @leading, @elements, @trailing );
  3625         3991  
696 22         145 return \@return;
697             }
698              
699             =head2 _new_records_from_arrayref
700              
701             The full expansion of a given SPF record is contained in an arrayref,
702             and if the length of the resulting new SPF record would be less than the
703             maximum_record_length, we can use this method to make new
704             L<Net::DNS::RR> objects that will later be stringified for the new
705             SPF record.
706              
707             =cut
708              
709             sub _new_records_from_arrayref {
710 36     36   60 my ( $self, $domain, $expansions ) = @_;
711              
712 36         101 my $txtdata = join(' ', @$expansions);
713              
714 36         50 my @new_records = ();
715 36         899 push @new_records, new Net::DNS::RR(
716             type => 'TXT',
717             name => $domain,
718             class => $self->_record_class,
719             ttl => $self->ttl,
720             txtdata => $txtdata,
721             );
722 36         4722 return \@new_records;
723             }
724              
725             =head2 _new_records_from_partition
726              
727             The full expansion of a given SPF record is contained in an arrayref,
728             and if the length of the resulting new SPF record would be greater than the
729             maximum_record_length, we have to jump through some hoops to properly split
730             it into new SPF records. Because there will be more than one, and each needs
731             to be less than the maximum_record_length. We do our partitioning here, and
732             then call _new_records_from_arrayref on each of the resulting partitions.
733              
734             =cut
735              
736             sub _new_records_from_partition {
737 9     9   21 my ( $self, $domain, $elements, $partitions_only ) = @_;
738 9         45 my $record_string = join( ' ', @$elements );
739 9         18 my $record_length = length($record_string);
740 9         206 my $max_length = $self->maximum_record_length;
741 9         14 my $offset = 0;
742 9         24 my $result = index( $record_string, ' ', $offset );
743 9         17 my @space_indices = ();
744              
745 9         25 while ( $result != -1 ) {
746 326 50       413 push @space_indices, $result if $result;
747 326         293 $offset = $result + 1;
748 326         423 $result = index( $record_string, ' ', $offset );
749             }
750              
751 9 50       45 my $number_of_partitions = int($record_length / $max_length + 0.5)
752             + ( ( $record_length % $max_length ) ? 1 : 0 );
753              
754 9         15 my @partitions = ();
755 9         14 my $partition_offset = 0;
756              
757 9         24 for my $part ( 1 .. $number_of_partitions ) {
758              
759             # We want the first space_index that is
760             # 1. less than the max_length times the number of parts, and
761             # 2. subtracting the partition_offset from it is less than
762             # max_length.
763             my $split_point = first {
764 499 100   499   766 ( $_ < ( $max_length * $part ) )
765             && ( ( $_ - $partition_offset ) < $max_length )
766 36         174 } reverse @space_indices;
767              
768 36         78 my $partition_length = $split_point - $partition_offset;
769              
770             # Go to the end of the string if we are dealing with
771             # the last partition. Otherwise, the last element
772             # gets chopped off, because it is after the last space_index!
773 36 100       85 my $length
774             = ( $part == $number_of_partitions ) ? undef : $partition_length;
775 36         38 my $substring;
776 36 100       49 if ( $part == $number_of_partitions ) {
777             # Go to the end.
778 9         15 $substring = substr( $record_string, $partition_offset );
779             } else {
780             # Take a specific length.
781 27         61 $substring = substr( $record_string, $partition_offset,
782             $partition_length );
783             }
784              
785 36         146 push @partitions, [ split( ' ', $substring ) ];
786 36         74 $partition_offset = $split_point;
787             }
788 9 50       20 return \@partitions if $partitions_only;
789              
790 9         22 my @return = ();
791              
792 9         18 for my $partition (@partitions) {
793 36         74 my $result = $self->_new_records_from_arrayref( $domain, $partition );
794 36         72 push @return, $result;
795             }
796 9         53 return \@return;
797             }
798              
799             =head2 _get_single_record_string
800              
801             Stringify the L<Net::DNS::RR::TXT> records when they will fit into
802             a single SPF record.
803              
804             =cut
805              
806             sub _get_single_record_string {
807 0     0   0 my ( $self, $domain, $record_set ) = @_;
808 0         0 my $origin = $self->origin;
809              
810 0         0 my @record_strings = ();
811              
812 0         0 my @sorted_record_set = map { $_ }
813 0         0 sort { $a->string cmp $b->string }
  0         0  
814             @$record_set;
815              
816 0         0 for my $record (@sorted_record_set) {
817 0         0 $record->name($domain);
818 0         0 $record->txtdata( 'v=spf1 ' . $record->txtdata . ' ~all' );
819              
820 0         0 my $string = $self->_normalize_record_name( $record->string );
821 0         0 push @record_strings, $string;
822             }
823 0         0 return \@record_strings;
824             }
825              
826             =head2 _normalize_record_name
827              
828             L<Net::DNS> uses fully qualified record names, so that new SPF records
829             will be named *.domain.com, and domain.com, instead of * and @. I prefer
830             the symbols. This code replaces the fully qualified record names with symbols.
831              
832             =cut
833              
834             sub _normalize_record_name {
835 19     19   4037 my ( $self, $record ) = @_;
836              
837 19         74 $record =~ /(.+?)\s/;
838 19         41 my $original_name = $1;
839 19         461 my $origin = $self->origin;
840              
841 19         28 my $name;
842              
843 19 100       172 if ( $original_name =~ /^$origin(.?)$/ ) {
    50          
    100          
844 3         7 $name = '@';
845             } elsif ( $original_name =~ /^\.$/ ) {
846 0         0 $name = '@';
847             } elsif ( $original_name =~ /^\*/ ) {
848 3         9 $name = '*';
849             } else {
850 13         20 $name = $original_name;
851             }
852 19         270 $record =~ s/\Q$original_name\E/$name/g;
853 19         76 $record =~ s/\n//g;
854 19         126 $record =~ s/(\(|\))//g;
855 19         75 $record =~ s/\t\s/\t/g;
856 19         44 $record =~ s/\s\t/\t/g;
857 19         28 $record =~ s/\t\t/\t/g;
858 19         79 $record =~ s/\t/ /g;
859 19         230 $record =~ s/\s/ /g;
860 19         29 $record = $record."\n";
861 19         73 return $record;
862             }
863              
864             =head2 _get_multiple_record_strings
865              
866             Whereas a single new SPF record needs to be concatenated from
867             the stringified L<Net::DNS::RR::TXT>s, and have the trailing
868             ~all added, multiple new SPF records do not need that. They need to be given
869             special _spf names that will then be included in "master" SPF records, and
870             they don't need the trailing ~all.
871              
872             =cut
873              
874             sub _get_multiple_record_strings {
875 3     3   7 my ( $self, $values, $start_index ) = @_;
876 3         84 my $origin = $self->origin;
877              
878 3         8 my @record_strings = ();
879              
880 3         6 my @containing_records = ();
881              
882 3   50     17 my $i = $start_index // 1;
883 3         10 for my $value (@$values) {
884 12         302 push @containing_records,
885             new Net::DNS::RR(
886             type => 'TXT',
887             name => "_spf$i.$origin",
888             class => $self->_record_class,
889             ttl => $self->ttl,
890             txtdata => 'v=spf1 ' . $value,
891             );
892 12         1957 $i++;
893             }
894              
895             @record_strings = map {
896 12         1173 $self->_normalize_record_name($_->string)
897             } sort {
898 3         14 $a->string cmp $b->string
  15         5939  
899             } @containing_records;
900              
901 3         25 return \@record_strings;
902             }
903              
904             =head2 _get_master_record_strings
905              
906             Create our "master" SPF records that include the split _spf records created
907             in _get_multiple_record_strings, e.g.,
908              
909             * 600 IN TXT "v=spf1 include:_spf1.test_zone.com include:_spf2.test_zone.com ~all"
910              
911             =cut
912              
913             sub _get_master_record_strings {
914 3     3   10 my ( $self, $values, $domains ) = @_;
915              
916 3         90 (my $origin = $self->origin) =~ s/\.$//g;
917 3         11 my @record_strings = ();
918              
919 3         5 my @containing_records = ();
920              
921 3         41 my $master_records = [ map {"include:_spf$_.$origin"} ( 1 .. scalar(@$values)) ];
  12         38  
922 3         14 my $master_record = join(' ', @$master_records);
923              
924             # If our master record will be too long, split it into multiple strings
925 3 50       84 if (length($master_record) > $self->maximum_record_length) {
926              
927 0         0 my $new_master_record_partitions = $self->_new_records_from_partition(
928             "master",
929             $master_records,
930             1, # Just return raw partitions
931             );
932              
933 0         0 my @master_record_strings = ();
934 0         0 my $i = 0;
935 0         0 for my $partition (@$new_master_record_partitions) {
936 0         0 my @master_record_partition = @$master_records[$i .. ($i + $#{$partition})];
  0         0  
937 0         0 push @master_record_strings, join(' ', @master_record_partition);
938 0         0 $i += scalar(@$partition);
939             }
940 0         0 $master_record_strings[0] = 'v=spf1 '. $master_record_strings[0];
941 0         0 $master_record_strings[-1] = $master_record_strings[-1].' ~all';
942 0         0 my $master_record_string = '';
943 0         0 my $index = 0;
944 0         0 for my $master_record (@master_record_strings) {
945 0 0       0 $master_record = " ".$master_record unless $index == 0;
946 0         0 $master_record_string .= qq|"$master_record"|;
947 0         0 $index++;
948             }
949              
950 0         0 for my $domain (@$domains) {
951              
952 0         0 push @containing_records,
953             new Net::DNS::RR(
954             type => 'TXT',
955             name => $domain,
956             class => $self->_record_class,
957             ttl => $self->ttl,
958             txtdata => \@master_record_strings,
959             );
960             }
961              
962             # Otherwise, proceed as normal
963             } else {
964              
965 3         10 for my $domain (@$domains) {
966              
967             push @containing_records,
968             new Net::DNS::RR(
969             type => 'TXT',
970             name => $domain,
971             class => $self->_record_class,
972             ttl => $self->ttl,
973             txtdata => 'v=spf1 ' . (join(
974             ' ',
975 7         673 ( map {"include:_spf$_.$origin"} ( 1 .. scalar(@$values) ) )
  30         91  
976             )) . ' ~all',
977             );
978             }
979              
980             }
981              
982             @record_strings = map {
983 7         1556 $self->_normalize_record_name($_->string)
984             } sort {
985 3         390 $a->string cmp $b->string
  4         723  
986             } @containing_records;
987              
988 3         25 return \@record_strings;
989             }
990              
991             =head2 _new_records_lines
992              
993             Assemble the new DNS zonefile from the lines of the original,
994             comment out the old SPF records, add in the new lines, and append the
995             end of the original.
996              
997             =cut
998              
999             sub _new_records_lines {
1000 3     3   5 my $self = shift;
1001 3 50       24 my %new_records = %{ $self->new_spf_records || {} };
  3         15  
1002 3         11 my @record_strings = ();
1003              
1004             # Make a list of the unique records in case we need it.
1005 3         8 my @autosplit = ();
1006 3         11 for my $domain ( keys %new_records ) {
1007 7         88 for my $record_set ( @{ $new_records{$domain} } ) {
  7         15  
1008 30 50       573 if ( ref($record_set) eq 'ARRAY' ) {
1009 30         41 for my $record (@$record_set) {
1010 30         67 push @autosplit, $record->txtdata;
1011             }
1012             } else {
1013 0         0 push @autosplit, $record_set->txtdata;
1014             }
1015             }
1016             }
1017 3         111 @autosplit = uniq @autosplit;
1018              
1019             # If there are any autosplit SPF records, we just do that right away.
1020             # This test is kind of nasty.
1021             my $make_autosplit_records = grep {
1022 3         25 defined( ${ $new_records{$_} }[0] )
  7         23  
1023 7 50       11 && ref( ${ $new_records{$_} }[0] ) eq 'ARRAY'
  7         28  
1024             } sort keys %new_records;
1025 3 50       12 if ($make_autosplit_records) {
1026 3         21 my $master_record_strings
1027             = $self->_get_master_record_strings( \@autosplit,
1028             [ keys %new_records ] );
1029 3         18 my $record_strings
1030             = $self->_get_multiple_record_strings( \@autosplit );
1031 3         10 push @record_strings, @$master_record_strings;
1032 3         15 push @record_strings, @$record_strings;
1033             } else {
1034 0         0 for my $domain ( sort keys %new_records ) {
1035             my $record_string = $self->_get_single_record_string(
1036             $domain,
1037 0         0 $new_records{$domain},
1038             );
1039 0         0 push @record_strings, @$record_string;
1040             }
1041             }
1042 3         77 my @original_lines = $self->input_file->slurp;
1043 3         2388 my @new_lines = ();
1044 3         7 my @spf_indices;
1045 3         63 my $i = 0;
1046 3         12 LINE: for my $line (@original_lines) {
1047 44 100       110 if ( $line =~ /^[^;].+?v=spf1/ ) {
1048 20         26 push @spf_indices, $i;
1049 20         42 $line = ";" . $line;
1050             }
1051 44         64 push @new_lines, $line;
1052 44         47 $i++;
1053             }
1054 3         15 my @first_segment = @new_lines[ 0 .. $spf_indices[-1] ];
1055 3         12 my @last_segment = @new_lines[ $spf_indices[-1] + 1 .. $#new_lines ];
1056 3         13 my @final_lines = ( @first_segment, @record_strings, @last_segment );
1057              
1058 3         8 for my $line (@final_lines) {
1059 63         77 $line =~ s/\t/ /g;
1060 63         63 $line =~ s/\n\s+/\n/g;
1061 63         181 $line =~ s/\s+\n/\n/g;
1062 63         170 $line =~ s/\n+/\n/g;
1063             }
1064 3         56 return \@final_lines;
1065             }
1066              
1067             __PACKAGE__->meta->make_immutable;
1068             __PACKAGE__->new_with_options->run unless caller;
1069              
1070             1;
1071              
1072             __END__
1073              
1074             =head1 AUTHOR
1075              
1076             Amiri Barksdale E<lt>amiri@campusexplorer.comE<gt>
1077              
1078             =head2 CONTRIBUTORS
1079              
1080             Neil Bowers E<lt>neil@bowers.comE<gt>
1081              
1082             Marc Bradshaw E<lt>marc@marcbradshaw.netE<gt>
1083              
1084             Karen Etheridge E<lt>ether@cpan.orgE<gt>
1085              
1086             Chris Weyl E<lt>cweyl@campusexplorer.comE<gt>
1087              
1088             =head1 COPYRIGHT
1089              
1090             Copyright (c) 2019 Campus Explorer, Inc.
1091              
1092             =head1 LICENSE
1093              
1094             This library is free software; you can redistribute it and/or modify
1095             it under the same terms as Perl itself.
1096              
1097             =head1 SEE ALSO
1098              
1099             L<Net::DNS>
1100              
1101             L<Net::DNS::RR::TXT>
1102              
1103             L<MooseX::Getopt>
1104              
1105             =cut