#!/usr/bin/perl use warnings; use strict; use JSON; use LWP::UserAgent; use Encode qw(encode); use Compress::Zlib; use Getopt::Long; use YAML::Tiny; use MIME::Base64; use Net::Domain qw(hostfqdn); #### Global vars #### my $conf = {}; my $cmd = { config => '/etc/systemd/journal-gelf.yml', compress => 1, state => '/var/lib/systemd-journal-gelf/state', keep_alive => 1 }; my $cursor = undef; my $last_save = 0; my $cursor_re = qr{^s=[a-z\d]+;i=[a-z\d]+;b=[a-z\d]+;m=[a-z\d]+;t=[a-z\d]+;x=[a-z\d]+$}; #### End global vars END { print "Saving current cursor to " . $conf->{state} . "\n"; save_cursor(); } #### Routines ##### sub help { print <<"_EOF" Usage: $0 --url= [--compress|--no-compress] [--user=production --password=secr3t] [--state=/path/to/file] * --url is the http or https URL where you will push your gelf formated logs. This is mandatory * --compress or --no-compress : will turn on or off gzip compression of logs. Default is on, but can be usefull to disable for debugging * --username and --password may be used if URL is protected with a basic auth mecanism. Either both or none must be provided * --state can be used to specify where to record the last correctly sent message, so we can start from here when systemd-journal-gelf is restarted or if there's a network problem. Default value is /var/lib/systemd-journal-gelf/state * --no-keep-alive turns off Keep Alive, which might be needed for some remote server not handling it correctly _EOF } sub save_cursor { if ($cursor and $cursor =~ m/$cursor_re/){ open CURSOR, ">", $conf->{state}; print CURSOR $cursor; close CURSOR } } sub yaml_convert_bool { my $val = shift; if ( $val =~ m/^y|Y|yes|Yes|YES|true|True|TRUE$/ ){ return 1; } else { return 0; } } #### End Routines #### GetOptions ( 'c|config=s' => \$cmd->{config}, 'state=s' => \$cmd->{state}, 'compress!' => \$cmd->{compress}, 'url=s' => \$cmd->{url}, 'username=s' => \$cmd->{username}, 'password=s' => \$cmd->{password}, 'keep-alive!' => \$cmd->{keep_alive} ); # Open config file if (-e $cmd->{config}) { print "Reading config file " . $cmd->{config} . "\n"; my $yaml = YAML::Tiny->read( $cmd->{config} ) or die "Config file " . $cmd->{config} . " is invalid\n"; if ( not $yaml->[0] ) { die "Config file " . $cmd->{config} . " is invalid\n"; } # File could be parsed, lets load # settings in $conf $conf = $yaml->[0]; } else { print "Config file " . $cmd->{config} . " does not exist, ignoring it\n"; } # Command line override config file foreach ( keys %{ $cmd } ){ $conf->{$_} = $cmd->{$_} if ( $cmd->{$_} ); } # YAML::Tiny doesn't handle boolean foreach my $key ( qw(compress keep_alive) ) { $conf->{$key} = yaml_convert_bool($conf->{$key}); } # Now check config makes sens if ( not $conf->{url} or ( $conf->{username} and not $conf->{password} ) or ( not $conf->{username} and $conf->{password} ) ){ help(); die; } print "Starting the Systemd Journal GELF uploader daemon\n"; my $ua = LWP::UserAgent->new( agent => 'SystemdJournalGelf', env_proxy => 1, keep_alive => $conf->{keep_alive} ); $ua->default_header( 'Content-Type' => 'application/json' ); if ( $conf->{compress} ){ $ua->default_header( 'Accept-Encoding' => HTTP::Message::decodable ); $ua->default_header( 'Content-Encoding' => 'gzip' ); } # Add basic auth header if set in the config # Note that we do not check the realm, nor we check for a 401 response, we consider # admins will be careful enough not to set wrong server in the conf if ( $conf->{username} and $conf->{password} ) { $ua->default_header( 'Authorization' => 'Basic ' . encode_base64($conf->{username} . ':' . $conf->{password}) ); } # Check if the state file exists and contains a valid cursor my $cursor_arg = ''; open CURSOR, "+<", $conf->{state}; if ( -e $conf->{state} ){ my $cursor = ; close CURSOR; if ( $cursor and $cursor =~ m/$cursor_re/ ){ print "Valid cursor found in " . $conf->{state} . ", will start back from here\n"; $cursor_arg = " --after-cursor='" . $cursor . "'"; } else { print $conf->{state} . " contains an invalid cursor, so we're wiping it\n"; unlink $conf->{state}; } } open JOURNAL, "/bin/journalctl -f -o json$cursor_arg |"; while ( my $entry = ){ my $msg = from_json( $entry ); if ( not $msg ) { # Oups, something is obviously wrong here # journalctl didn't sent us valid JSON ? print "Error parsing message ($msg) \n"; next; } # Build a basic GELF message my $gelf = { version => 1.1, short_message => $msg->{MESSAGE}, host => hostfqdn(), timestamp => int ( $msg->{__REALTIME_TIMESTAMP} / ( 1000 * 1000 ) ), level => $msg->{PRIORITY} }; # Now lets look at the message. If it starts with gelf: or gelf(): # we can split it and have further fields to send. # I use this to handle httpd or nginx logs for example # If separator is not specified, the default is | eg # gelf:code=200|url=/index.html|remote_ip=10.99.5.12|referer=http://test.local/ # # OR # # gelf(~):code=200~url=/index.html~remote_ip=10.99.5.12~referer=http://test.local/ if ( $msg->{MESSAGE} =~ m/^gelf(\([^\(\)]+\))?:([a-zA-Z\d_\-]+=([^\|]+)\|?)+/ ){ $msg->{MESSAGE} =~ s/^gelf(\([^\(\)]+\))?://; my $separator = ($1 && length $1 > 0) ? qr{$1} : qr{\|}; foreach ( split /$separator/, $msg->{MESSAGE} ){ my ( $key, $val ) = split /=/, $_, 2; # Allow overriding short message $key = '_' . $key unless ($key eq 'short_message'); $gelf->{$key} = $val; } } # Add the other attributes to the gelf message, except those already treated foreach ( grep !/^MESSAGE|_HOSTNAME|__REALTIME_TIMESTAMP|PRIORITY$/, keys %$msg ){ $gelf->{$_} = $msg->{$_}; } # Now, we'll try to POST this message my $retry = 0; my $resp; do { if ($retry > 0){ print "Sending message to " . $conf->{url} . " failed : got code " . $resp->code . " (" . $resp->message . "). Trying again in $retry seconds\n"; sleep $retry; } $resp = $ua->post( $conf->{url}, Content => ($conf->{compress}) ? Compress::Zlib::memGzip(encode('utf-8', to_json($gelf))) : encode('utf-8', to_json($gelf)) ); $retry = ($retry > 0) ? $retry * 2 : 1; } while (not $resp->is_success and $retry < 600); # The message has been accepted, we can save the current cursor and # continue if ($resp->is_success){ $cursor = $msg->{__CURSOR}; # Save the current cursor to disk if # it hasn't been done for the past 30 sec if (time - $last_save > 30) { $last_save = time; save_cursor(); } } else { # We can't upload our current message for # too much time, no much left we can do, lets die and hope # our service manager will restart us :-) die "Error sending data to GELF server\n"; } }