File Coverage

lib/Net/DNS/SPF/Expander.pm
Criterion Covered Total %
statement 272 308 88.3
branch 41 56 73.2
condition 6 10 60.0
subroutine 36 37 97.3
pod 2 2 100.0
total 357 413 86.4


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