How can I split a CA certificate bundle into separate files? - linux

I'm working with OpenSSL and need a sane default list of CAs. I'm using Mozilla's list of trusted CAs, as bundled by cURL. However, I need to split this bundle of CA certs, because the OpenSSL documentation says:
If CApath is not NULL, it points to a directory containing CA certificates in PEM format. The files each contain one CA certificate. The files are looked up by the CA subject name hash value, which must hence be available.
For example, using the ca-bundle.crt file directly works fine:
openssl-1.0.1g> ./apps/openssl s_client -connect www.google.com:443 -CAfile /home/user/certs/ca-bundle.crt
...
Verify return code: 0 (ok)
---
DONE
But specifying the directory containing the ca-bundle.crt file does not work:
openssl-1.0.1g> ./apps/openssl s_client -connect www.google.com:443 -CApath /opt/aspera/certs
Verify return code: 20 (unable to get local issuer certificate)
---
DONE
I presume this is because my folder doesn't adhere to what the documentation asks for (namely, a directory containing CA certs in PEM format, with each file containing one cert, named by hash value). My directory just has the single bundle of certs.
How can I split my bundle of certs to adhere to OpenSSL's request that each cert be in an individual file? Bonus points if the hashing can be done too (though if needed I could write a script to do that myself if all the certs are in individual files).

You can split the bundle with awk, like this, in an appropriate directory:
awk 'BEGIN {c=0;} /BEGIN CERT/{c++} { print > "cert." c ".pem"}' < ca-bundle.pem
Then, create the links OpenSSL wants by running the c_rehash utility that comes with OpenSSL:
c_rehash .
Note: use 'gawk' on non linux-platforms - as above relies on a GNU specific feature.

Just to give an alternative; facing the same issue I ended up with csplit:
csplit -k -f bar foo.pem '/END CERTIFICATE/+1' {10}

If you want to get a single certificate out of a multi-certificate PEM, try:
$ awk '/subject.*CN=host.domain.com/,/END CERTIFICATE/' INPUT.PEM
source

The following Ruby-script will split the bundle (with one or more certificates in it) into files named after the hashes -- side-stepping the c_rehash step in most cases.
To use, cd into the right directory (such as /etc/ssl/certs/) and run the script with the path to your certificate bundle as the sole argument. For example: ruby /tmp/split-certificates.rb ca-root-nss.crt.
#!/usr/bin/env ruby
require 'openssl'
blob = IO.binread(ARGV[0]) # Read the entire file at once
DELIMITER = "\n-----END CERTIFICATE-----\n"
blobs = blob.split(DELIMITER)
blobs.each do |blob|
blob.strip!
blob += DELIMITER # Does not break DER
begin
cert = OpenSSL::X509::Certificate.new blob
rescue
puts "Skipping what seems like junk"
next
end
begin
# XXX Need to handle clashes, suffix other than 0
filename=sprintf("%x.0", cert.subject.hash)
File.open(filename,
File::WRONLY|File::CREAT|File::EXCL) do |f|
f.write(blob)
end
rescue Errno::EEXIST
puts "#{filename} already exists, skipping"
end
end

Here is mine in Perl (so much code, but I like gonzo programming):
#!/usr/bin/perl -w
# -------
# Split "certificate bundles" like those found in /etc/pki/tls/certs into
# individual files and append the X509 cleartext description to each file.
#
# The file to split is given on the command line or piped via STDIN.
#
# Files are simply created in the current directory!
#
# Created files are named "certificate.XX" or "trusted-certificate.XX",
# with XX an index value.
#
# If a file with the same name as the output file already exists, it is not
# overwritten. Instead a new name with a higher index is tried.
#
# This works for bundles of both trusted and non-trusted certificates.
#
# See http://tygerclan.net/?q=node/49 for another program of this kind,
# which sets the name of the split-off files in function of the subject
# -------
my #lines = <> or die "Could not slurp: $!";
my $state = "outside"; # reader state machine state
my $count = 0; # index of the certificate file we create
my $fh; # file handle of the certificate file we create
my $fn; # file name of the certificate file we create
my $trusted; # either undef or "TRUSTED" depend on type of certificate
for my $line (#lines) {
chomp $line;
if ($state eq "outside") {
if ($line =~ /^(-----BEGIN (TRUSTED )?CERTIFICATE-----)\s*$/) {
my $marker = $1;
$trusted = $2;
$state = "inside";
my $created = 0;
my $prefix = "";
if ($trusted) {
$prefix = "trusted-"
}
while (!$created) {
$fn = "${prefix}certificate.$count";
$count++;
if (-f $fn) {
# print STDERR "File '$fn' exists; increasing version number to $count\n";
}
else {
print STDERR "Certificate data goes to file '$fn'\n";
open($fh,">$fn") || die "Could not create file '$fn': $!\n";
$created = 1;
print $fh "$marker\n"
}
}
}
else {
print STDERR "Skipping line '$line'\n"
}
}
else {
if ($line =~ /^(-----END (TRUSTED )?CERTIFICATE-----)\s*$/) {
my $marker = $1;
my $trustedCheck = $2;
if (!((($trusted && $trustedCheck) || (!$trusted && !$trustedCheck)))) {
die "Trusted flag difference detected\n"
}
$state = "outside";
print $fh "$marker\n";
print STDERR "Closing file '$fn'\n";
close $fh;
# Append x509 cleartext output by calling openssl tool
`openssl x509 -noout -text -in '$fn' >> '$fn'`;
if ($? != 0) {
die "Could not run 'openssl x509' command: $!\n";
}
}
else {
print $fh "$line\n"
}
}
}
if ($state eq "inside") {
die "Last certificate was not properly terminated\n";
}

Related

TCL Blowfish behavior

I made a small script that zips a file and then encrypts that file:
#-- cut from proc --
set outfile [open $out wb]
set ind [string last \/ $in]
set in [string range $in [expr $ind + 1] end]
zipfile::mkzip::mkzip $in.zip $in
set infile [open $in.zip rb]
if {[catch {blowfish::blowfish -mode $mode -key $key -iv $iv -out $outfile -in $infile} msg]} {
tk_messageBox -message "Error message: $msg"
continue
}
close $infile; close $outfile
#-- end of cut --
To decipher and unzip:
#-- cut from proc --
set outfile [open $out.zip wb]
set infile [open $in rb]
if {[catch {blowfish::blowfish -dir decrypt -mode $mode -key $key -iv $iv -out $outfile -in $infile} msg]} {
tk_messageBox -message "Error message: $msg"
close $outfile
continue
}
close $infile; close $outfile
if {[zipfile::decode::iszip $out.zip] < 1} {
tk_messageBox -message "bad zip file"
file delete -force $out.zip
return
}
zipfile::decode::unzipfile $out.zip $final
file delete -force $out.zip
#-- end of cut --
Now, all works fine, except if the deciphered zip file is bad, meaning that we've used a bad password or mode. I would've thought that catch {blowfish line would get me an error, but apparently blowfish doesn't care, and will just blow garbage into the output file with the .zip extension. In any case, in the case of the bad zip file, the script is not releasing the zip file, and will give me a permission error when trying to delete it. If the zip file is a good file, it will happily unzip and be deleted. I would presume that blowfish has the file locked but won't give an error or let it go. Any help nailing down what I'm doing wrong would be appreciated.
Update: Run the same script on a Linux os at home, and it works. At work on Win10 was the bad behavior, I should've noted that initially.
I've found the problem bug. It's in zipfile::decode::LocateEnd (called by iszip and others), which doesn't close the open file handle to the zip file if it throws an error. I'm not quite what all of the conditions are under which it throws an error, but one is definitely when the ZIP index can't be found. Which would be OK… except that the handle's open and on Windows that means that the file can't be deleted (because having a file open locks its directory entry; Unixes don't typically work that way).
It's definitely a bug.
Fortunately it's using a normal Tcl channel, not some kind of complicated C thing, so we can work around it.
# Assume you have Tcl 8.5
proc safeIsZip {filename} {
set channels [file channels]; # Hope this is short!
catch {
zipfile::decode::iszip $filename
} result options
foreach ch [file channels] {
if {$ch ni $channels} {
close $ch
}
}
return -options $options $result
}
In Tcl 8.6, you can do it a bit nicer:
proc safeIsZip {filename} {
set channels [file channels]; # Hope this is short!
try {
return [zipfile::decode::iszip $filename]
} finally {
foreach ch [file channels] {
if {$ch ni $channels} {
chan close $ch
}
}
}
}

Comparing files from tape and disk using MD5 with perl archive::tar fails

We want to create a report with MD5 checks between an tar archive on tape and the files on disk. I created a script that should do this, but it works correct using a tar file, but it failes when using a tar on tape. The tar was written with gnu tar to tape.
use strict;
use warnings;
use Archive::Tar;
use Digest::MD5 qw(md5 md5_hex md5_base64);
my $tarfile = '/dev/rmt/1';
my $iter = Archive::Tar->iter( $tarfile, 1, {md5 => 1} );
print "------------ TAR MD5 ----------- ----------- FILE MD5 ----------- ----- File -----\n";
while( my $f = $iter->() ) {
if ($f->is_file != 0) {
my $tarMd5 = md5_hex( $f->get_content);
my $filename = $f->full_path;
my $fileMd5 = '';
if (-e $filename) {
open(HANDLE, "<", $filename);
$fileMd5 = md5_hex(<HANDLE>);
} else {
$fileMd5 = "!!!!!!! FILE IS MISSING !!!!!!!!";
}
if ($tarMd5 eq $fileMd5) {
print "$tarMd5 <--> $fileMd5 --> $filename\n";
} else {
print "$tarMd5 ><>< $fileMd5 --> $filename\n";
}
}
}
As said it works correct when using a file based tar file, but when using a tar on tape we get the error:
Use of uninitialized value in subroutine entry at check_archive.pl line 12.
Can't use string ("") as a subroutine ref while "strict refs" in use at check_archive.pl line 12.
my $f is not defined.
Use of uninitialized value in subroutine entry at check_archive.pl line 12. Can't use string ("") as a subroutine ref while "strict refs" in use at check_archive.pl line 12. my $f is not defined.
if ($f && $f->is_file != 0) {## NOT AN IMPORTANT WARNING...
...
if (-e $filename) { ## CHECK IF FILE EXISTS
local $/=""; ## <= JUST ADD THIS FOR INSTANT OUTPUT (NO BUFFER)
open(HANDLE, "<", $filename); ## CREATE A FILE HANDLE TO READ A FILE
$fileMd5 = md5_hex(<HANDLE>); ## SEND HANDLE TO THE MD5 FUNCTION
}
...

Perl script for finding unowned files not finding anything

I've written a script which is designed to find all files not owned by either an existing user or group. However, despite having created a test user and then removing it leaving behind its /home directory, the script is not finding it. Clearly I have an error in the script's logic. I just can't find it.
#!/usr/bin/perl
# Directives which establish our execution environment
use warnings;
use strict;
use File::Find;
no warnings 'File::Find';
no warnings 'uninitialized';
# Variables used throughout the script
my $OUTDIR = "/var/log/tivoli/";
my $MTAB = "/etc/mtab";
my $PERMFILE = "orphan_files.txt";
my $TMPFILE = "orphan_files.tmp";
my $ROOT = "/";
my(#devNum, #uidNums, #gidNums);
# Create an array of the file stats for "/"
my #rootStats = stat("${ROOT}");
# Compile a list of mountpoints that need to be scanned
my #mounts;
open MT, "<${MTAB}" or die "Cannot open ${MTAB}, $!";
# We only want the local HDD mountpoints
while (<MT>) {
if ($_ =~ /ext[34]/) {
my #line = split;
push(#mounts, $line[1]);
}
}
close MT;
# Build an array of each mountpoint's device number for future comparison
foreach (#mounts) {
my #stats = stat($_);
push(#devNum, $stats[0]);
print $_ . ": " . $stats[0] . "\n";
}
# Build an array of the existing UIDs on the system
while((my($name, $passwd, $uid, $gid, $quota, $comment, $gcos, $dir, $shell)) = getpwent()) {
push(#uidNums, $uid);
}
# Build an array of existing GIDs on the system
while((my($name, $passwd, $gid, $members)) = getgrent()){
push(#gidNums, $gid);
}
# Create a regex to compare file device numbers to.
my $devRegex = do {
chomp #devNum;
local $" = '|';
qr/#devNum/;
};
# Create a regex to compare file UIDs to.
my $uidRegex = do {
chomp #uidNums;
local $" = '|';
qr/#uidNums/;
};
# Create a regex to compare file GIDs to.
my $gidRegex = do {
chomp #gidNums;
local $" = '|';
qr/#gidNums/;
};
print $gidRegex . "\n";
# Create the output file path if it doesn't already exist.
mkdir "${OUTDIR}" or die "Cannot execute mkdir on ${OUTDIR}, $!" unless (-d "${OUTDIR}");
# Create our filehandle for writing our findings
open ORPHFILE, ">${OUTDIR}${TMPFILE}" or die "Cannot open ${OUTDIR}${TMPFILE}, $!";
foreach (#mounts) {
# The anonymous subroutine which is executed by File::Find
find sub {
my #fileStats = stat($File::Find::name);
# Is it in a basic directory, ...
return if $File::Find::dir =~ /sys|proc|dev/;
# ...an actual file vs. a link, directory, pipe, etc, ...
return unless -f;
# ...local, ...
return unless $fileStats[0] =~ $devRegex;
# ...and unowned? If so write it to the output file
if (($fileStats[4] !~ $uidRegex) || ($fileStats[5] !~ $gidRegex)) {
print $File::Find::name . " UID: " . $fileStats[4] . "\n";
print $File::Find::name . " GID: " . $fileStats[5] . "\n";
print ORPHFILE "$File::Find::name\n";
}
}, $_;
}
close ORPHFILE;
# If no world-writable files have been found ${TMPFILE} should be zero-size;
# Delete it so Tivoli won't alert
if (-z "${OUTDIR}${TMPFILE}") {
unlink "${OUTDIR}${TMPFILE}";
} else {
rename("${OUTDIR}${TMPFILE}","${OUTDIR}${PERMFILE}") or die "Cannot rename file ${OUTDIR}${TMPFILE}, $!";
}
The test user's home directory showing ownership (or lack thereof):
drwx------ 2 20000 20000 4096 Apr 9 19:59 test
The regex for comparing a files GID to those existing on the system:
(?-xism:0|1|2|3|4|5|6|7|8|9|10|12|14|15|20|30|39|40|50|54|63|99|100|81|22|35|19|69|32|173|11|33|18|48|68|38|499|76|90|89|156|157|158|67|77|74|177|72|21|501|502|10000|10001|10002|10004|10005|10006|5001|5002|5005|5003|10007|10008|10009|10012|10514|47|51|6000|88|5998)
What am I missing with my logic?
I really recommend using find2perl for doing anything with locating files by different attributes. Although not as pretty as File::Find or File::Find::Rule it does the work for you.
mori#liberty ~ $ find2perl -nouser
#! /usr/bin/perl -w
eval 'exec /usr/bin/perl -S $0 ${1+"$#"}'
if 0; #$running_under_some_shell
use strict;
use File::Find ();
# Set the variable $File::Find::dont_use_nlink if you're using AFS,
# since AFS cheats.
# for the convenience of &wanted calls, including -eval statements:
use vars qw/*name *dir *prune/;
*name = *File::Find::name;
*dir = *File::Find::dir;
*prune = *File::Find::prune;
sub wanted;
my (%uid, %user);
while (my ($name, $pw, $uid) = getpwent) {
$uid{$name} = $uid{$uid} = $uid;
}
# Traverse desired filesystems
File::Find::find({wanted => \&wanted}, '.');
exit;
sub wanted {
my ($dev,$ino,$mode,$nlink,$uid,$gid);
(($dev,$ino,$mode,$nlink,$uid,$gid) = lstat($_)) &&
!exists $uid{$uid}
&& print("$name\n");
}
'20000' =~ /(0|1|2|...)/
matches. You probably want to anchor the expression:
/^(0|1|2|...)$/
(The other answer is better, just adding this for completeness.)

Search filesystem via perl script while ignoring remote mounts

I've written a perl script that is designed to search a server for world writable files. After some testing, though, I've found that I made a mistake in the logic. Specifically, I've told it to not search /. My initial thought behind this was that I was looking for locally mounted volumes while avoiding those of a remote variety (CIFS, NFS, what-have-you).
What I failed to take into consideration is that not every directory has a unique volume. As a result, by excluding / in my scan, I've missed several directories that should be included. Now I need to rework the script to include those while still excluding remote volumes.
#!/usr/bin/perl
# Directives which establish our execution environment
use warnings;
use strict;
use Fcntl ':mode';
use File::Find;
no warnings 'File::Find';
no warnings 'uninitialized';
# Variables used throughout the script
my $DIR = "/var/log/tivoli/";
my $MTAB = "/etc/mtab";
my $PERMFILE = "world_writable_w_files.txt";
my $TMPFILE = "world_writable_files.tmp";
my $EXCLUDE = "/usr/local/etc/world_writable_excludes.txt";
# Compile a list of mountpoints that need to be scanned
my #mounts;
# Create the filehandle for the /etc/mtab file
open MT, "<${MTAB}" or die "Cannot open ${MTAB}, $!";
# We only want the local mountpoints that are not "/"
while (<MT>) {
if ($_ =~ /ext[34]/) {
my #line = split;
push(#mounts, $line[1]) unless ($_ =~ /root/);
}
}
close MT;
# Read in the list of excluded files
my $regex = do {
open EXCLD, "<${EXCLUDE}" or die "Cannot open ${EXCLUDE}, $!\n";
my #ignore = <EXCLD>;
chomp #ignore;
local $" = '|';
qr/#ignore/;
};
# Create the output file path if it doesn't already exist.
mkdir "${DIR}" or die "Cannot execute mkdir on ${DIR}, $!" unless (-d "${DIR}");
# Create the filehandle for writing the findings
open WWFILE, ">${DIR}${TMPFILE}" or die "Cannot open ${DIR}${TMPFILE}, $!";
foreach (#mounts) {
# The anonymous subroutine which is executed by File::Find
find sub {
return unless -f; # Is it a regular file...
# ...and world writable.
return unless (((stat)[2] & S_IWUSR) && ((stat)[2] & S_IWGRP) && ((stat)[2] & S_IWOTH));
# Add the file to the list of found world writable files unless it is
# in the list if exclusions
print WWFILE "$File::Find::name\n" unless ($File::Find::name =~ $regex);
}, $_;
}
close WWFILE;
# If no world-writable files have been found ${TMPFILE} should be zero-size;
# Delete it so Tivoli won't alert
if (-z "${DIR}${TMPFILE}") {
unlink "${DIR}${TMPFILE}";
} else {
rename("${DIR}${TMPFILE}","${DIR}${PERMFILE}") or die "Cannot rename file ${DIR}${TMPFILE}, $!";
}
I'm at a bit of a loss as to how to approach this now. I know I can obtain the necessary information using stat -f -c %T but I don't see a similar option for perl's built-in stat (unless I'm misinterpreting the descriptions for output fields; perhaps it is found in one of the S_ variables?).
I'm just looking for a push in the right direction. I'd really rather not drop to a shell command to obtain this information.
EDIT: I've found this answer to a similar question, but it seems to be not entirely helpful. When I test the built-in stat against a CIFS mount I get 18. Perhaps what I need is a comprehensive list of values that could be returned for remote files to compare against?
EDIT2: This is the script in its new form which meets the requirements:
#!/usr/bin/perl
# Directives which establish our execution environment
use warnings;
use strict;
use Fcntl ':mode';
use File::Find;
no warnings 'File::Find';
no warnings 'uninitialized';
# Variables used throughout the script
my $DIR = "/var/log/tivoli/";
my $MTAB = "/etc/mtab";
my $PERMFILE = "world_writable_w_files.txt";
my $TMPFILE = "world_writable_files.tmp";
my $EXCLUDE = "/usr/local/etc/world_writable_excludes.txt";
my $ROOT = "/";
my #devNum;
# Create an array of the file stats for "/"
my #rootStats = stat("${ROOT}");
# Compile a list of mountpoints that need to be scanned
my #mounts;
open MT, "<${MTAB}" or die "Cannot open ${MTAB}, $!";
# We only want the local mountpoints
while (<MT>) {
if ($_ =~ /ext[34]/) {
my #line = split;
push(#mounts, $line[1]);
}
}
close MT;
# Build an array of each mountpoint's device number for future comparison
foreach (#mounts) {
my #stats = stat($_);
push(#devNum, $stats[0]);
}
# Read in the list of excluded files and create a regex from them
my $regExcld = do {
open XCLD, "<${EXCLUDE}" or die "Cannot open ${EXCLUDE}, $!\n";
my #ignore = <XCLD>;
chomp #ignore;
local $" = '|';
qr/#ignore/;
};
# Create a regex to compare file device numbers to.
my $devRegex = do {
chomp #devNum;
local $" = '|';
qr/#devNum/;
};
# Create the output file path if it doesn't already exist.
mkdir("${DIR}" or die "Cannot execute mkdir on ${DIR}, $!") unless (-d "${DIR}");
# Create our filehandle for writing our findings
open WWFILE, ">${DIR}${TMPFILE}" or die "Cannot open ${DIR}${TMPFILE}, $!";
foreach (#mounts) {
# The anonymous subroutine which is executed by File::Find
find sub {
# Is it in a basic directory, ...
return if $File::Find::dir =~ /sys|proc|dev/;
# ...a regular file, ...
return unless -f;
# ...local, ...
my #dirStats = stat($File::Find::name);
return unless $dirStats[0] =~ $devRegex;
# ...and world writable?
return unless (((stat)[2] & S_IWUSR) && ((stat)[2] & S_IWGRP) && ((stat)[2] & S_IWOTH));
# If so, add the file to the list of world writable files unless it is
# in the list if exclusions
print(WWFILE "$File::Find::name\n") unless ($File::Find::name =~ $regExcld);
}, $_;
}
close WWFILE;
# If no world-writable files have been found ${TMPFILE} should be zero-size;
# Delete it so Tivoli won't alert
if (-z "${DIR}${TMPFILE}") {
unlink "${DIR}${TMPFILE}";
} else {
rename("${DIR}${TMPFILE}","${DIR}${PERMFILE}") or die "Cannot rename file ${DIR}${TMPFILE}, $!";
}
The dev field result from stat() tells you the device number the inode lives on. That can be used to distinguish different mount points, as they'll have a different device number from the one you started at.

Issues with reducing duplicate output from log file search

This website has been a great help since I'm getting back into programming and I'm attempting to write a simple perl script that will analyze apache log files from a directory (multiple domains), pull the last 1000 lines of each log file, strip the IP addresses from the log file and then compare them with a known block list of bot spammers.
Now so far I've got the script working except for one issue. Lets say I have the IP address 10.128.45.5 in two log files, the script of course analyzes each log file in turn stripping and reducing the IP's to one PER log file but what I'm trying to do is narrow that down even more to one per instance I run this script, regardless if the same IP appears across multiple log files.
Here's the code I've gotten so far, sorry if it's a bit messy.
#!/usr/bin/perl
# Extract IP's from apache access logs for the last hour and matches with forum spam bot list.
# The fun work of Daniel Pearson
use strict;
use warnings;
use Socket;
# Declarations
my ($file,$list,#files,%ips,$match,$path,$sort);
my $timestamp = localtime(time);
# Check to see if matching file exists
$list ='list';
if (-e $list) {
Delete the file so we can download a new one if it exists
print "File Exists!";
print "Deleting File $list\n";
unlink($list);
}
sleep(5);
system ("wget http://www.domain.com/list");
sleep(5);
my $dir = $ARGV[0] or die "Need to specify the log file directory\n";
opendir(DIR, "$dir");
#files = grep(/\.*$/,readdir(DIR));
closedir(DIR);
foreach my $file(#files) {
my $sum = 0;
if (-d $file) {
print "Skipping Directory $file\n";
}
else {
$path = "$dir$file";
open my $path, "-|", "/usr/bin/tail", "-1000", "$path" or die "could not start tail on $path: $!";
my %ips;
while (my $line = <$path>) {
chomp $line;
if ($line =~ m/(?!0+\.0+\.0+\.0+$)(([01]?\d\d?|2[0-4]\d|25[0-5])\.([01]?\d\d?|2[0-4]\d|25[0-5])\.([01]?\d\d?|2[0-4]\d|25[0-5])\.([01]?\d\d?|2[0-4]\d|25[0-5]))/g) {
my $ip = $1;
$ips{$ip} = $ip;
}
}
}
foreach my $key (sort keys %ips) {
open ("files","$list");
while (my $sort = <files>) {
chomp $sort;
if ($key =~ $sort) {
open my $fh, '>>', 'banned.out';
print "Match Found we need to block it $key\n";
print $fh "$key:$timestamp\n";
close $fh;
}
}
}
}
Any advice that could be given I would be grateful for.
To achieve the task:
Move my %ips outside of (above) the foreach my $file (#files) loop.
Move foreach my $key ( sort keys %ips ) outside of (below) the foreach my $file (#files) loop.

Resources