1 # Functions for managing BIND 4 and 8/9 records files
3 # read_zone_file(file, origin, [previous], [only-soa], [no-chroot])
4 # Reads a DNS zone file and returns a data structure of records. The origin
5 # must be a domain without the trailing dot, or just .
8 local($file, $lnum, $line, $t, @tok, @lnum, @coms,
9 $i, @rv, $origin, $num, $j, @inc, @oset, $comment);
12 # Remove trailing dots in origin name, as they are added automatically
16 $file = &absolute_path($_[0]);
17 local $rootfile = $_[4] ? $file : &make_chroot($file);
18 open(FILE, $rootfile);
20 local ($gotsoa, $aftersoa);
21 while($line = <FILE>) {
22 local($glen, $merged_2, $merge);
23 # strip comments (# is not a valid comment separator here!)
25 # parsing splited into separate cases to fasten it
28 $line =~ /^((?:[^;\"]+|\"\"|(?:\"(?:[^\"]*)\"))*);(.*)/) ||
30 $line =~ /^((?:[^;\\]|\\.)*);(.*)/) ||
31 # expresion below is the most general, but very slow
32 # if ";" is quoted somewhere
33 $line =~ /^((?:(?:[^;\"\\]|\\.)+|(?:\"(?:[^\"\\]|\\.)*\"))*);(.*)/) {
36 if ($line =~ /^[^"]*"[^"]*$/) {
37 # Line has only one ", meaning that a ; in the middle
38 # of a quoted string broke it! Fix up
39 $line .= ";".$comment;
47 # split line into tokens
52 if ($line =~ /^(\s*)\"((?:[^\"\\]|\\.)*)\"(.*)/ ||
53 $line =~ /^(\s*)((?:[^\s\(\)\"\\]|\\.)+)(.*)/ ||
54 ($merge = 0) || $line =~ /^(\s*)([\(\)])(.*)/) {
64 if (!$merge || $line =~ /^([\s\(\)]|$)/) {
65 push(@tok, $merged_2); push(@lnum, $lnum);
67 push(@coms, $comment); $comment = "";
69 # Check if we have the SOA
70 if (uc($merged_2) eq "SOA") {
86 # Check if we have a complete SOA record
87 if ($aftersoa > 10 && $_[3]) {
93 # parse into data structures
96 if ($tok[$i] =~ /^\$origin$/i) {
97 # $ORIGIN directive (may be relative or absolute)
98 if ($tok[$i+1] =~ /^(\S*)\.$/) {
99 $origin = $1 ? $1 : ".";
101 elsif ($origin eq ".") { $origin = $tok[$i+1]; }
102 else { $origin = "$tok[$i+1].$origin"; }
105 elsif ($tok[$i] =~ /^\$include$/i) {
106 # including another file
107 if ($lnum[$i+1] == $lnum[$i+2]) {
108 # $INCLUDE zonefile origin
110 if ($tok[$i+2] =~ /^(\S+)\.$/) {
111 $inc_origin = $1 ? $1 : ".";
113 elsif ($origin eq ".") { $inc_origin = $tok[$i+2]; }
114 else { $inc_origin = "$tok[$i+2].$origin"; }
115 @inc = &read_zone_file($tok[$i+1], $inc_origin,
116 @rv ? $rv[$#rv] : undef);
121 @inc = &read_zone_file($tok[$i+1], $origin,
122 @rv ? $rv[$#rv] : undef);
125 foreach $j (@inc) { $j->{'num'} = $num++; }
128 elsif ($tok[$i] =~ /^\$generate$/i) {
129 # a generate directive .. add it as a special record
130 local $gen = { 'file' => $file,
131 'rootfile' => $rootfile,
132 'comment' => $coms[$i],
136 while($lnum[++$i] == $gen->{'line'}) {
139 $gen->{'generate'} = \@gv;
142 elsif ($tok[$i] =~ /^\$ttl$/i) {
145 local $defttl = { 'file' => $file,
146 'rootfile' => $rootfile,
149 'defttl' => $tok[$i++] };
152 elsif ($tok[$i] =~ /^\$(\S+)/i) {
153 # some other special directive
154 local $ln = $lnum[$i];
155 while($lnum[$i] == $ln) {
161 local(%dir, @values, $l);
162 $dir{'line'} = $lnum[$i];
163 $dir{'file'} = $file;
164 $dir{'rootfile'} = $rootfile;
165 $dir{'comment'} = $coms[$i];
166 if ($tok[$i] =~ /^(in|hs)$/i && $oset[$i] > 0) {
167 # starting with a class
168 $dir{'class'} = uc($tok[$i]);
171 elsif ($tok[$i] =~ /^\d/ && $tok[$i] !~ /in-addr/i &&
172 $oset[$i] > 0 && $tok[$i+1] =~ /^(in|hs)$/i) {
173 # starting with a TTL and class
174 $dir{'ttl'} = $tok[$i];
175 $dir{'class'} = uc($tok[$i+1]);
178 elsif ($tok[$i+1] =~ /^(in|hs)$/i) {
179 # starting with a name and class
180 $dir{'name'} = $tok[$i];
181 $dir{'class'} = uc($tok[$i+1]);
184 elsif ($oset[$i] > 0 && $tok[$i] =~ /^\d+/) {
185 # starting with just a ttl
186 $dir{'ttl'} = $tok[$i];
187 $dir{'class'} = "IN";
190 elsif ($oset[$i] > 0) {
191 # starting with nothing
192 $dir{'class'} = "IN";
194 elsif ($tok[$i+1] =~ /^\d/ && $tok[$i+2] =~ /^(in|hs)$/i) {
195 # starting with a name, ttl and class
196 $dir{'name'} = $tok[$i];
197 $dir{'ttl'} = $tok[$i+1];
198 $dir{'class'} = uc($tok[$i+2]);
201 elsif ($tok[$i+1] =~ /^\d/) {
202 # starting with a name and ttl
203 $dir{'name'} = $tok[$i];
204 $dir{'ttl'} = $tok[$i+1];
205 $dir{'class'} = "IN";
209 # starting with a name
210 $dir{'name'} = $tok[$i];
211 $dir{'class'} = "IN";
214 if ($dir{'name'} eq '') {
215 # Name comes from previous record
216 for(my $p=$#rv; $p>=0; $p--) {
218 last if ($prv->{'name'});
221 $prv || &error(&text('efirst', $lnum[$i]+1, $file));
222 $dir{'name'} = $prv->{'name'};
223 $dir{'realname'} = $prv->{'realname'};
226 $dir{'realname'} = $dir{'name'};
228 $dir{'type'} = uc($tok[$i++]);
230 # read values until end of line, unless a ( is found, in which
231 # case read till the )
233 while($lnum[$i] == $l && $i < @tok) {
234 if ($tok[$i] eq "(") {
235 my $olnum = $lnum[$i];
236 while($tok[++$i] ne ")") {
237 push(@values, $tok[$i]);
239 &error("No ending ) found for ".
240 "( starting at $olnum");
246 push(@values, $tok[$i++]);
248 $dir{'values'} = \@values;
249 $dir{'eline'} = $lnum[$i-1];
251 # Work out canonical form, and maybe use it
252 my $canon = $dir{'name'};
254 $canon = $origin eq "." ? "." : "$origin.";
256 elsif ($canon !~ /\.$/) {
257 $canon .= $origin eq "." ? "." : ".$origin.";
259 if (!$config{'short_names'}) {
260 $dir{'name'} = $canon;
262 $dir{'canon'} = $canon;
263 $dir{'num'} = $num++;
265 # If this is an SPF record .. adjust the class
267 if ($dir{'type'} eq 'TXT' &&
268 ($spf=&parse_spf(@{$dir{'values'}}))) {
269 if (!@{$spf->{'other'}}) {
270 $dir{'type'} = 'SPF';
276 # Stop processing if this was an SOA record
277 if ($dir{'type'} eq 'SOA' && $_[3]) {
285 # create_record(file, name, ttl, class, type, values, comment)
286 # Add a new record of some type to some zone file
289 local $fn = &make_chroot(&absolute_path($_[0]));
290 local $lref = &read_file_lines($fn);
291 push(@$lref, &make_record(@_[1..$#_]));
292 &flush_file_lines($fn);
295 # modify_record(file, &old, name, ttl, class, type, values, comment)
296 # Updates an existing record in some zone file
299 local $fn = &make_chroot(&absolute_path($_[0]));
300 local $lref = &read_file_lines($fn);
301 local $lines = $_[1]->{'eline'} - $_[1]->{'line'} + 1;
302 splice(@$lref, $_[1]->{'line'}, $lines, &make_record(@_[2..$#_]));
303 &flush_file_lines($fn);
306 # delete_record(file, &old)
307 # Deletes a record in some zone file
310 local $fn = &make_chroot(&absolute_path($_[0]));
311 local $lref = &read_file_lines($fn);
312 local $lines = $_[1]->{'eline'} - $_[1]->{'line'} + 1;
313 splice(@$lref, $_[1]->{'line'}, $lines);
314 &flush_file_lines($fn);
317 # create_generator(file, range, lhs, type, rhs, [comment])
318 # Add a new $generate line to some zone file
321 local $f = &make_chroot(&absolute_path($_[0]));
322 local $lref = &read_file_lines($f);
323 push(@$lref, join(" ", '$generate', @_[1..4]).
324 ($_[5] ? " ;$_[5]" : ""));
325 &flush_file_lines($f);
328 # modify_generator(file, &old, range, lhs, type, rhs, [comment])
329 # Updates an existing $generate line in some zone file
332 local $f = &make_chroot(&absolute_path($_[0]));
333 local $lref = &read_file_lines($f);
334 $lref->[$_[1]->{'line'}] = join(" ", '$generate', @_[2..5]).
335 ($_[6] ? " ;$_[6]" : "");
336 &flush_file_lines($f);
339 # delete_generator(file, &old)
340 # Deletes a $generate line in some zone file
343 local $f = &make_chroot(&absolute_path($_[0]));
344 local $lref = &read_file_lines($f);
345 splice(@$lref, $_[1]->{'line'}, 1);
346 &flush_file_lines($f);
349 # create_defttl(file, value)
350 # Adds a $ttl line to a records file
353 local $f = &make_chroot(&absolute_path($_[0]));
354 local $lref = &read_file_lines($f);
355 splice(@$lref, 0, 0, "\$ttl $_[1]");
356 &flush_file_lines($f);
359 # modify_defttl(file, &old, value)
360 # Updates the $ttl line with a new value
363 local $f = &make_chroot(&absolute_path($_[0]));
364 local $lref = &read_file_lines($f);
365 $lref->[$_[1]->{'line'}] = "\$ttl $_[2]";
366 &flush_file_lines($f);
369 # delete_defttl(file, &old)
370 # Removes the $ttl line from a records file
373 local $f = &make_chroot(&absolute_path($_[0]));
374 local $lref = &read_file_lines($f);
375 splice(@$lref, $_[1]->{'line'}, 1);
376 &flush_file_lines($f);
381 # make_record(name, ttl, class, type, values, comment)
382 # Returns a string for some zone record
385 local $type = $_[3] eq "SPF" ? "TXT" : $_[3];
386 return $_[0] . ($_[1] ? "\t$_[1]" : "") . "\t$_[2]\t$type\t$_[4]" .
387 ($_[5] ? "\t;$_[5]" : "");
390 # bump_soa_record(file, &records)
391 # Increase the serial number in some SOA record by 1
394 local($i, $r, $v, $vals);
395 for($i=0; $i<@{$_[1]}; $i++) {
397 if ($r->{'type'} eq "SOA") {
399 # already set serial if no acl allow it to update or update
402 if ($config{'updserial_on'}) {
403 # automatically handle serial numbers ?
404 $serial = &compute_serial($v->[2]);
406 $vals = "$v->[0] $v->[1] (\n\t\t\t$serial\n\t\t\t$v->[3]\n".
407 "\t\t\t$v->[4]\n\t\t\t$v->[5]\n\t\t\t$v->[6] )";
408 &modify_record($r->{'file'}, $r, $r->{'realname'}, $r->{'ttl'},
409 $r->{'class'}, $r->{'type'}, $vals);
415 # Returns a string like YYYYMMDD
419 local @tm = localtime($now);
420 return sprintf "%4.4d%2.2d%2.2d", $tm[5]+1900, $tm[4]+1, $tm[3];
423 # get_zone_defaults(&hash)
424 sub get_zone_defaults
426 if (!&read_file("$module_config_directory/zonedef", $_[0])) {
427 $_[0]->{'refresh'} = 10800; $_[0]->{'retry'} = 3600;
428 $_[0]->{'expiry'} = 604800; $_[0]->{'minimum'} = 38400;
429 $_[0]->{'refunit'} = ""; $_[0]->{'retunit'} = "";
430 $_[0]->{'expunit'} = ""; $_[0]->{'minunit'} = "";
433 $_[0]->{'refunit'} = $1 if ($_[0]->{'refresh'} =~ s/([^0-9])$//);
434 $_[0]->{'retunit'} = $1 if ($_[0]->{'retry'} =~ s/([^0-9])$//);
435 $_[0]->{'expunit'} = $1 if ($_[0]->{'expiry'} =~ s/([^0-9])$//);
436 $_[0]->{'minunit'} = $1 if ($_[0]->{'minimum'} =~ s/([^0-9])$//);
440 # save_zone_defaults(&array)
441 sub save_zone_defaults
443 &write_file("$module_config_directory/zonedef", $_[0]);
446 # allowed_zone_file(&access, file)
447 sub allowed_zone_file
449 return 0 if ($_[1] =~ /\.\./);
450 return 0 if (-l $_[1] && !&allowed_zone_file($_[0], readlink($_[1])));
451 local $l = length($_[0]->{'dir'});
452 return length($_[1]) > $l && substr($_[1], 0, $l) eq $_[0]->{'dir'};
459 local $s = $in{'sort'} ? $in{'sort'} : $config{'records_order'};
462 if ($_[0]->{'type'} eq "PTR") {
463 return sort ptr_sort_func @_;
466 return sort { $a->{'name'} cmp $b->{'name'} } @_;
471 if ($_[0]->{'type'} eq "A") {
472 return sort ip_sort_func @_;
474 elsif ($_[0]->{'type'} eq "MX") {
475 return sort { $a->{'values'}->[1] cmp $b->{'values'}->[1] } @_;
478 return sort { $a->{'values'}->[0] cmp $b->{'values'}->[0] } @_;
482 # Sort by IP address or by value if there is no IP
483 if ($_[0]->{'type'} eq "A") {
484 return sort ip_sort_func @_;
486 elsif ($_[0]->{'type'} eq "PTR") {
487 return sort ptr_sort_func @_;
489 elsif ($_[0]->{'type'} eq "MX") {
490 return sort { $a->{'values'}->[1] cmp $b->{'values'}->[1] } @_;
493 return sort { $a->{'values'}->[0] cmp $b->{'values'}->[0] } @_;
498 return sort { $b->{'comment'} cmp $a->{'comment'} } @_;
502 return sort { $a->{'type'} cmp $b->{'type'} } @_;
511 $a->{'name'} =~ /^(\d+)\.(\d+)\.(\d+)\.(\d+)/;
512 local ($a1, $a2, $a3, $a4) = ($1, $2, $3, $4);
513 $b->{'name'} =~ /^(\d+)\.(\d+)\.(\d+)\.(\d+)/;
514 return $a4 < $4 ? -1 :
526 $a->{'values'}->[0] =~ /^(\d+)\.(\d+)\.(\d+)\.(\d+)/;
527 local ($a1, $a2, $a3, $a4) = ($1, $2, $3, $4);
528 $b->{'values'}->[0] =~ /^(\d+)\.(\d+)\.(\d+)\.(\d+)/;
529 return $a1 < $1 ? -1 :
540 # Converts an address like 4.3.2.1.in-addr.arpa. to 1.2.3.4
543 if ($_[0] =~ /^([\d\-\.\/]+)\.in-addr\.arpa/i) {
544 return join('.',reverse(split(/\./, $1)));
549 # ip_to_arpa(address)
550 # Converts an IP address like 1.2.3.4 to 4.3.2.1.in-addr.arpa.
553 if ($_[0] =~ /^([\d\-\.\/]+)$/) {
554 return join('.',reverse(split(/\./,$1))).".in-addr.arpa.";
559 $ipv6revzone = $config{'ipv6_mode'} ? "ip6.arpa" : "ip6.int";
561 # ip6int_to_net(name)
562 # Converts an address like a.b.c.d.4.3.2.1.ip6.int. to 1234:dcba::
565 local($n, $addr = $_[0]);
566 if ($addr =~ /^([\da-f]\.)+$ipv6revzone/i) {
567 $addr =~ s/\.$ipv6revzone/\./i;
568 $addr = reverse(split(/\./, $addr));
569 $addr =~ s/([\w]{4})/$1:/g;
570 $n = ($addr =~ s/([\w])/$1/g) * 4;
571 $addr =~ s/(\w+)$/$+0000/;
572 $addr =~ s/([\w]{4})0+$/$1:/;
574 $addr =~ s/:0{1,3}/:/g;
577 $addr =~ s/(:0)+:/::/;
587 # net_to_ip6int(address, bits)
588 # Converts an IPv6 address like 1234:dcba:: to a.b.c.d.4.3.2.1.ip6.int.
591 local($addr = lc($_[0]), $n = $_[1] >> 2);
592 if (&check_ip6address($addr)) {
593 $addr = reverse(split(/\:/, &expandall_ip6($addr)));
594 $addr =~ s/(\w)/$1\./g;
596 $addr = substr($addr, -2 * $n, 2 * $n);
598 $addr = $addr.$ipv6revzone.".";
603 $uscore = $config{'allow_underscore'} ? "_" : "";
604 $star = $config{'allow_wild'} ? "\\*" : "";
606 # valdnsname(name, wild, origin)
610 $fqdn = $_[0] !~ /\.$/ ? "$_[0].$_[2]." : $_[0];
611 if (length($fqdn) > 255) {
612 &error(&text('edit_efqdn', $fqdn));
614 if ($_[0] =~ /[^\.]{64}/) {
615 # no label longer than 63 chars
616 &error(&text('edit_elabel', $_[0]));
618 return ((($_[1] && $config{'allow_wild'})
619 ? (($_[0] =~ /^[\*A-Za-z0-9\-\.$uscore]+$/)
620 && ($_[0] !~ /.\*/ || $bind_version >= 9) # "*" can be only the first
622 && ($_[0] !~ /\*[^\.]/)) # a "." must always follow "*"
623 : ($_[0] =~ /^[\A-Za-z0-9\-\.$uscore]+$/))
624 && ($_[0] !~ /\.\./) # no ".." inside
625 && ($_[0] !~ /^\../) # no "." at the beginning
626 && ($_[0] !~ /^\-/) # no "-" at the beginning
627 && ($_[0] !~ /\-$/) # no "-" at the end
628 && ($_[0] !~ /\.\-/) # no ".-" inside
629 && ($_[0] !~ /\-\./) # no "-." inside
630 && ($_[0] !~ /\.[0-9]+\.$/)); # last label in FQDN may not be
637 return $_[0] eq "." ||
638 $_[0] =~ /^[A-Za-z0-9\.\-]+$/ ||
639 $_[0] =~ /(.*)\@(.*)/ &&
640 &valdnsname($2, 0, ".") &&
641 $1 =~ /[a-z][\w\-\.$uscore]+/i;
644 # absolute_path(path)
645 # If a path does not start with a /, prepend the base directory
648 if ($_[0] =~ /^([a-zA-Z]:)?\//) { return $_[0]; }
649 return &base_directory()."/".$_[0];
652 # parse_spf(text, ...)
653 # If some text looks like an SPF TXT record, return a parsed hash ref
656 my $txt = join(" ", @_);
657 if ($txt =~ /^v=spf1/) {
658 local @w = split(/\s+/, $txt);
662 if ($w eq "a" || $w eq "mx" || $w eq "ptr") {
665 elsif ($w =~ /^(a|mx|ip4|ip6|ptr|include|exists):(\S+)$/) {
666 push(@{$spf->{"$1:"}}, $2);
668 elsif ($w eq "-all") {
671 elsif ($w eq "~all") {
674 elsif ($w eq "?all") {
677 elsif ($w eq "+all" || $w eq "all") {
680 elsif ($w eq "v=spf1") {
683 elsif ($w =~ /^(redirect|exp)=(\S+)$/) {
684 # Modifier for domain redirect or expansion
688 push(@{$spf->{'other'}}, $w);
697 # Converts an SPF record structure to a string, designed to be inserted into
698 # quotes in a TXT record. If it is longer than 255 bytes, it will be split
699 # into multiple quoted strings.
703 local @rv = ( "v=spf1" );
704 foreach my $s ("a", "mx", "ptr") {
705 push(@rv, $s) if ($spf->{$s});
707 foreach my $s ("a", "mx", "ip4", "ip6", "ptr", "include", "exists") {
708 foreach my $v (@{$spf->{"$s:"}}) {
712 push(@rv, @{$spf->{'other'}});
713 if ($spf->{'all'} == 3) { push(@rv, "-all"); }
714 elsif ($spf->{'all'} == 2) { push(@rv, "~all"); }
715 elsif ($spf->{'all'} == 1) { push(@rv, "?all"); }
716 elsif ($spf->{'all'} eq '0') { push(@rv, "all"); }
717 foreach my $m ("redirect", "exp") {
719 push(@rv, $m."=".$spf->{$m});
726 if (length($rvword)+length($w)+1 >= 255) {
727 push(@rvwords, $rvword);
730 $rvword .= " " if ($rvword);
733 push(@rvwords, $rvword);
734 return join("\" \"", @rvwords);
737 # join_record_values(&record)
738 # Given the values for a record, joins them into a space-separated string
739 # with quoting if needed
740 sub join_record_values
743 if ($r->{'type'} eq 'SOA') {
744 # Multiliple lines, with brackets
745 local $v = $r->{'values'};
746 return "$v->[0] $v->[1] (\n\t\t\t$v->[2]\n\t\t\t$v->[3]\n".
747 "\t\t\t$v->[4]\n\t\t\t$v->[5]\n\t\t\t$v->[6] )";
752 foreach my $v (@{$r->{'values'}}) {
753 push(@rv, $v =~ /\s/ ? "\"$v\"" : $v);
755 return join(" ", @rv);
759 # compute_serial(old)
760 # Given an old serial number, returns a new one using the configured method
764 if ($config{'soa_style'} == 1 && $old =~ /^(\d{8})(\d\d)$/) {
765 if ($1 >= &date_serial()) {
767 # Have to roll over to next day
768 return sprintf "%d%2.2d", $1+1, $config{'soa_start'};
771 # Just increment within this day
772 return sprintf "%d%2.2d", $1, $2+1;
777 return &date_serial().sprintf("%2.2d", $config{'soa_start'});
780 elsif ($config{'soa_style'} == 2) {
789 # Incrementing number
794 # convert_to_absolute(short, origin)
795 # Make a short name like foo a fully qualified name like foo.domain.com.
796 sub convert_to_absolute
798 local ($name, $origin) = @_;
799 if ($name eq $origin ||
800 $name =~ /\.\Q$origin\E$/) {
801 # Name already ends in domain name - add . automatically, so we don't
802 # re-append the domain name.
805 local $rv = $name eq "" ? "$origin." :
806 $name eq "@" ? "$origin." :
807 $name !~ /\.$/ ? "$name.$origin." : $name;
812 # get_zone_file(&zone|&zonename, [absolute])
813 # Returns the relative-to-chroot path to a domain's zone file.
814 # If absolute is 1, the path is made absolute. If 2, it is also un-chrooted
817 local ($z, $abs) = @_;
819 if ($z->{'members'}) {
820 local $file = &find("file", $z->{'members'});
821 return undef if (!$file);
822 $fn = $file->{'values'}->[0];
828 $fn = &absolute_path($fn);
831 $fn = &make_chroot($fn);
836 # get_dnskey_record(&zone|&zonename, [&records])
837 # Returns the DNSKEY record for some domain, or undef if none
838 sub get_dnskey_record
840 local ($z, $recs) = @_;
842 # Need to get zone file and thus records
843 local $fn = &get_zone_file($z);
844 $recs = [ &read_zone_file($fn, $dom) ];
847 local $dom = $z->{'members'} ? $z->{'values'}->[0] : $z->{'name'};
848 foreach my $r (@$recs) {
849 if ($r->{'type'} eq 'DNSKEY' &&
850 $r->{'name'} eq $dom.'.') {
858 # Returns a unique ID string for a record, based on the name and value
862 return $r->{'name'}."/".$r->{'type'}.
863 (uc($r->{'type'}) eq 'SOA' ? '' : '/'.join('/', @{$r->{'values'}}));
866 # find_record_by_id(&recs, id, index)
867 # Find a record by ID and possibly index
868 sub find_record_by_id
870 my ($recs, $id, $num) = @_;
871 my @rv = grep { &record_id($_) eq $id } @$recs;
879 # Multiple matches .. find the one with the right index
880 @rv = grep { $_->{'num'} == $num } @rv;
881 return @rv ? $rv[0] : undef;