A perl daemon which sends structured logs from the Journal to a Gelf HTTP endpoint (made for Graylog)
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

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