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.

systemd-journal-gelf 6.8KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. #!/usr/bin/perl
  2. use warnings;
  3. use strict;
  4. use JSON;
  5. use LWP::UserAgent;
  6. use Encode qw(encode);
  7. use Compress::Zlib;
  8. use Getopt::Long;
  9. use YAML::Tiny;
  10. use MIME::Base64;
  11. use Net::Domain qw(hostfqdn);
  12. #### Global vars ####
  13. my $conf = {};
  14. my $cmd = {
  15. config => '/etc/systemd/journal-gelf.yml',
  16. compress => 1,
  17. state => '/var/lib/systemd-journal-gelf/state',
  18. keep_alive => 1
  19. };
  20. my $cursor = undef;
  21. my $last_save = 0;
  22. 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]+$};
  23. #### End global vars
  24. END {
  25. print "Saving current cursor to " . $conf->{state} . "\n";
  26. save_cursor();
  27. }
  28. #### Routines #####
  29. sub help {
  30. print <<"_EOF"
  31. Usage: $0 --url=<URL> [--compress|--no-compress] [--user=production --password=secr3t] [--state=/path/to/file]
  32. * --url is the http or https URL where you will push your gelf formated logs. This is mandatory
  33. * --compress or --no-compress : will turn on or off gzip compression of logs. Default is on, but can be usefull to disable for debugging
  34. * --username and --password may be used if URL is protected with a basic auth mecanism. Either both or none must be provided
  35. * --state can be used to specify where to record the last correctly sent message, so we can start from here when
  36. systemd-journal-gelf is restarted or if there's a network problem. Default value is /var/lib/systemd-journal-gelf/state
  37. * --no-keep-alive turns off Keep Alive, which might be needed for some remote server not handling it correctly
  38. _EOF
  39. }
  40. sub save_cursor {
  41. if ($cursor and $cursor =~ m/$cursor_re/){
  42. open CURSOR, ">", $conf->{state};
  43. print CURSOR $cursor;
  44. close CURSOR
  45. }
  46. }
  47. sub yaml_convert_bool {
  48. my $val = shift;
  49. if ( $val =~ m/^y|Y|yes|Yes|YES|true|True|TRUE$/ ){
  50. return 1;
  51. } else {
  52. return 0;
  53. }
  54. }
  55. #### End Routines ####
  56. GetOptions (
  57. 'c|config=s' => \$cmd->{config},
  58. 'state=s' => \$cmd->{state},
  59. 'compress!' => \$cmd->{compress},
  60. 'url=s' => \$cmd->{url},
  61. 'username=s' => \$cmd->{username},
  62. 'password=s' => \$cmd->{password},
  63. 'keep-alive!' => \$cmd->{keep_alive}
  64. );
  65. # Open config file
  66. if (-e $cmd->{config}) {
  67. print "Reading config file " . $cmd->{config} . "\n";
  68. my $yaml = YAML::Tiny->read( $cmd->{config} )
  69. or die "Config file " . $cmd->{config} . " is invalid\n";
  70. if ( not $yaml->[0] ) {
  71. die "Config file " . $cmd->{config} . " is invalid\n";
  72. }
  73. # File could be parsed, lets load
  74. # settings in $conf
  75. $conf = $yaml->[0];
  76. } else {
  77. print "Config file " . $cmd->{config} . " does not exist, ignoring it\n";
  78. }
  79. # Command line override config file
  80. foreach ( keys %{ $cmd } ){
  81. $conf->{$_} = $cmd->{$_} if ( $cmd->{$_} );
  82. }
  83. # YAML::Tiny doesn't handle boolean
  84. foreach my $key ( qw(compress keep_alive) ) {
  85. $conf->{$key} = yaml_convert_bool($conf->{$key});
  86. }
  87. # Now check config makes sens
  88. if (
  89. not $conf->{url} or
  90. ( $conf->{username} and not $conf->{password} ) or
  91. ( not $conf->{username} and $conf->{password} )
  92. ){
  93. help();
  94. die;
  95. }
  96. print "Starting the Systemd Journal GELF uploader daemon\n";
  97. my $ua = LWP::UserAgent->new(
  98. agent => 'SystemdJournalGelf',
  99. env_proxy => 1,
  100. keep_alive => $conf->{keep_alive}
  101. );
  102. $ua->default_header( 'Content-Type' => 'application/json' );
  103. if ( $conf->{compress} ){
  104. $ua->default_header( 'Accept-Encoding' => HTTP::Message::decodable );
  105. $ua->default_header( 'Content-Encoding' => 'gzip' );
  106. }
  107. # Add basic auth header if set in the config
  108. # Note that we do not check the realm, nor we check for a 401 response, we consider
  109. # admins will be careful enough not to set wrong server in the conf
  110. if ( $conf->{username} and $conf->{password} ) {
  111. $ua->default_header( 'Authorization' => 'Basic ' . encode_base64($conf->{username} . ':' . $conf->{password}) );
  112. }
  113. # Check if the state file exists and contains a valid cursor
  114. my $cursor_arg = '';
  115. open CURSOR, "+<", $conf->{state};
  116. if ( -e $conf->{state} ){
  117. my $cursor = <CURSOR>;
  118. close CURSOR;
  119. if ( $cursor and $cursor =~ m/$cursor_re/ ){
  120. print "Valid cursor found in " . $conf->{state} . ", will start back from here\n";
  121. $cursor_arg = " --after-cursor='" . $cursor . "'";
  122. } else {
  123. print $conf->{state} . " contains an invalid cursor, so we're wiping it\n";
  124. unlink $conf->{state};
  125. }
  126. }
  127. open JOURNAL, "/bin/journalctl -f -o json$cursor_arg |";
  128. while ( my $entry = <JOURNAL> ){
  129. my $msg = from_json( $entry );
  130. if ( not $msg ) {
  131. # Oups, something is obviously wrong here
  132. # journalctl didn't sent us valid JSON ?
  133. print "Error parsing message ($msg) \n";
  134. next;
  135. }
  136. # Build a basic GELF message
  137. my $gelf = {
  138. version => 1.1,
  139. short_message => $msg->{MESSAGE},
  140. host => hostfqdn(),
  141. timestamp => int ( $msg->{__REALTIME_TIMESTAMP} / ( 1000 * 1000 ) ),
  142. level => $msg->{PRIORITY}
  143. };
  144. # Now lets look at the message. If it starts with gelf: or gelf(<separator>):
  145. # we can split it and have further fields to send.
  146. # I use this to handle httpd or nginx logs for example
  147. # If separator is not specified, the default is | eg
  148. # gelf:code=200|url=/index.html|remote_ip=10.99.5.12|referer=http://test.local/
  149. #
  150. # OR
  151. #
  152. # gelf(~):code=200~url=/index.html~remote_ip=10.99.5.12~referer=http://test.local/
  153. if ( $msg->{MESSAGE} =~ m/^gelf(\([^\(\)]+\))?:([a-zA-Z\d_\-]+=([^\|]+)\|?)+/ ){
  154. $msg->{MESSAGE} =~ s/^gelf(\([^\(\)]+\))?://;
  155. my $separator = ($1 && length $1 > 0) ? qr{$1} : qr{\|};
  156. foreach ( split /$separator/, $msg->{MESSAGE} ){
  157. my ( $key, $val ) = split /=/, $_, 2;
  158. # Allow overriding short message
  159. $key = '_' . $key unless ($key eq 'short_message');
  160. $gelf->{$key} = $val;
  161. }
  162. }
  163. # Add the other attributes to the gelf message, except those already treated
  164. foreach ( grep !/^MESSAGE|_HOSTNAME|__REALTIME_TIMESTAMP|PRIORITY$/, keys %$msg ){
  165. $gelf->{$_} = $msg->{$_};
  166. }
  167. # Now, we'll try to POST this message
  168. my $retry = 0;
  169. my $resp;
  170. do {
  171. if ($retry > 0){
  172. print "Sending message to " . $conf->{url} . " failed : got code " .
  173. $resp->code . " (" . $resp->message . "). Trying again in $retry seconds\n";
  174. sleep $retry;
  175. }
  176. $resp = $ua->post(
  177. $conf->{url},
  178. Content => ($conf->{compress}) ? Compress::Zlib::memGzip(encode('utf-8', to_json($gelf))) : encode('utf-8', to_json($gelf))
  179. );
  180. $retry = ($retry > 0) ? $retry * 2 : 1;
  181. } while (not $resp->is_success and $retry < 600);
  182. # The message has been accepted, we can save the current cursor and
  183. # continue
  184. if ($resp->is_success){
  185. $cursor = $msg->{__CURSOR};
  186. # Save the current cursor to disk if
  187. # it hasn't been done for the past 30 sec
  188. if (time - $last_save > 30) {
  189. $last_save = time;
  190. save_cursor();
  191. }
  192. } else {
  193. # We can't upload our current message for
  194. # too much time, no much left we can do, lets die and hope
  195. # our service manager will restart us :-)
  196. die "Error sending data to GELF server\n";
  197. }
  198. }