#!/usr/bin/perl
use v5.38;
# Those are enabled automatically now w/ this version of Perl
# use strict;
# use warnings;
use builtin qw(true false);
use experimental qw(builtin);
use feature qw(refaliasing);
no warnings qw(experimental::refaliasing);
# TODO: UNDO
use diagnostics;
use constant VERSION => 'v0.1.0';
# TODO: Blog post about this script and the new Perl features used.
# TODO NEXT:
# * Write out a nice output from each merged file, also merge if multiple hosts results
# * Fix bug with .gmi.*.gmi in the log parser
# * Nicely formatted .txt output by stats by count by date
# * Print out all UAs, to add new excludes/blocked IPs
package FileHelper {
use JSON;
sub write ( $path, $content ) {
open my $fh, '>', "$path.tmp"
or die "\nCannot open file: $!";
print $fh $content;
close $fh;
rename
"$path.tmp",
$path;
}
sub write_json_gz ( $path, $data ) {
my $json = encode_json $data;
say "Writing $path";
open my $fd, '>:gzip', "$path.tmp"
or die "$path.tmp: $!";
print $fd $json;
close $fd;
rename "$path.tmp", $path
or die "$path.tmp: $!";
}
sub read_json_gz ($path) {
say "Reading $path";
open my $fd, '<:gzip', $path
or die "$path: $!";
my $json = decode_json <$fd>;
close $fd;
return $json;
}
sub read_lines ($path) {
my @lines;
open( my $fh, '<', $path )
or die "$path: $!";
chomp( @lines = <$fh> );
close($fh);
return @lines;
}
}
package DateHelper {
use Time::Piece;
sub last_month_dates () {
my $today = localtime;
my @dates;
for my $days_ago ( 0 .. 30 ) {
my $date = $today - ( $days_ago * 24 * 60 * 60 );
push
@dates,
$date->strftime('%Y%m%d');
}
return @dates;
}
}
package Foostats::Logreader {
use Digest::SHA3 'sha3_512_base64';
use File::stat;
use PerlIO::gzip;
use Time::Piece;
use String::Util qw(contains startswith endswith);
use constant {
GEMINI_LOGS_GLOB => '/var/log/daemon*',
WEB_LOGS_GLOB => '/var/www/logs/access.log*',
};
sub anonymize_ip ($ip) {
my $ip_proto =
contains( $ip, ':' )
? 'IPv6'
: 'IPv4';
my $ip_hash = sha3_512_base64 $ip;
return ( $ip_hash, $ip_proto );
}
sub read_lines ( $glob, $cb ) {
my sub year ($path) {
localtime( ( stat $path )->mtime )->strftime('%Y');
}
my sub open_file ($path) {
my $flag =
$path =~ /\.gz$/
? '<:gzip'
: '<';
open my $fd, $flag, $path
or die "$path: $!";
return $fd;
}
my $last = false;
say 'File path glob matches: ' . join( ' ', glob $glob );
LAST:
for my $path ( sort { -M $a <=> -M $b } glob $glob ) {
say "Processing $path";
my $file = open_file $path;
my $year = year $file;
while (<$file>) {
next
if contains( $_, 'logfile turned over' );
# last == true means: After this file, don't process more
$last = true
unless defined $cb->( $year, split / +/ );
}
say "Closing $path (last:$last)";
close $file;
last LAST
if $last;
}
}
sub parse_web_logs ( $last_processed_date, $cb ) {
my sub parse_date ($date) {
my $t = Time::Piece->strptime( $date, '[%d/%b/%Y:%H:%M:%S' );
return ( $t->strftime('%Y%m%d'), $t->strftime('%H%M%S') );
}
my sub parse_web_line (@line) {
my ( $date, $time ) = parse_date $line [4];
return undef
if $date < $last_processed_date;
# X-Forwarded-For?
my $ip =
$line[-2] eq '-'
? $line[1]
: $line[-2];
my ( $ip_hash, $ip_proto ) = anonymize_ip $ip;
return {
proto => 'web',
host => $line[0],
ip_hash => $ip_hash,
ip_proto => $ip_proto,
date => $date,
time => $time,
uri_path => $line[7],
status => $line[9],
};
}
read_lines WEB_LOGS_GLOB, sub ( $year, @line ) {
$cb->( parse_web_line @line );
};
}
sub parse_gemini_logs ( $last_processed_date, $cb ) {
my sub parse_date ( $year, @line ) {
my $timestr = "$line[0] $line[1]";
return Time::Piece->strptime( $timestr, '%b %d' )
->strftime("$year%m%d");
}
my sub parse_vger_line ( $year, @line ) {
my $full_path = $line[5];
$full_path =~ s/"//g;
my ( $proto, undef, $host, $uri_path ) =
split '/',
$full_path,
4;
$uri_path = ''
unless defined $uri_path;
return {
proto => 'gemini',
host => $host,
uri_path => "/$uri_path",
status => $line[6],
date => int( parse_date( $year, @line ) ),
time => $line[2],
};
}
my sub parse_relayd_line ( $year, @line ) {
my $date = int( parse_date( $year, @line ) );
my ( $ip_hash, $ip_proto ) = anonymize_ip $line [12];
return {
ip_hash => $ip_hash,
ip_proto => $ip_proto,
date => $date,
time => $line[2],
};
}
# Expect one vger and one relayd log line per event! So collect
# both events (one from one log line each) and then merge the result hash!
my ( $vger, $relayd );
read_lines GEMINI_LOGS_GLOB, sub ( $year, @line ) {
if ( $line[4] eq 'vger:' ) {
$vger = parse_vger_line $year, @line;
}
elsif ( $line[5] eq 'relay'
and startswith( $line[6], 'gemini' ) )
{
$relayd = parse_relayd_line $year, @line;
return undef
if $relayd->{date} < $last_processed_date;
}
if ( defined $vger
and defined $relayd
and $vger->{time} eq $relayd->{time} )
{
$cb->( { %$vger, %$relayd } );
$vger = $relayd = undef;
}
true;
};
}
sub parse_logs ( $la
|