A perl daemon which sends structured logs from the Journal to a Gelf HTTP endpoint (made for Graylog)
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

230 lines
6.8 KiB

#!/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=<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 = <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 = <JOURNAL> ){
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(<separator>):
# 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";
}
}