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
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";
|
|
}
|
|
}
|
|
|