gelf_redux 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,280 @@
1
+ require 'gelf/transport/udp'
2
+ require 'gelf/transport/tcp'
3
+ require 'gelf/transport/tcp_tls'
4
+
5
+ # replace JSON and #to_json with Yajl if available
6
+ begin
7
+ require 'yajl/json_gem'
8
+ rescue LoadError
9
+ end
10
+
11
+ module GELF
12
+ # Graylog2 notifier.
13
+ class Notifier
14
+ # Maximum number of GELF chunks as per GELF spec
15
+ MAX_CHUNKS = 128
16
+ MAX_CHUNK_SIZE_WAN = 1420
17
+ MAX_CHUNK_SIZE_LAN = 8154
18
+
19
+ attr_accessor :enabled, :collect_file_and_line, :rescue_network_errors
20
+ attr_reader :max_chunk_size, :level, :default_options, :level_mapping
21
+
22
+ # +host+ and +port+ are host/ip and port of graylog2-server.
23
+ # +max_size+ is passed to max_chunk_size=.
24
+ # +default_options+ is used in notify!
25
+ def initialize(host = 'localhost', port = 12201, max_size = 'WAN', default_options = {})
26
+ @enabled = true
27
+ @collect_file_and_line = true
28
+ @random = Random.new
29
+
30
+ self.level = GELF::DEBUG
31
+ self.max_chunk_size = max_size
32
+ self.rescue_network_errors = false
33
+
34
+ self.default_options = default_options.dup
35
+ self.default_options['version'] = SPEC_VERSION
36
+ self.default_options['host'] ||= Socket.gethostname
37
+ self.default_options['level'] ||= GELF::UNKNOWN
38
+ self.default_options['facility'] ||= 'gelf-rb'
39
+ self.default_options['protocol'] ||= GELF::Protocol::UDP
40
+
41
+ self.level_mapping = :logger
42
+ @sender = create_sender(host, port)
43
+ end
44
+
45
+ # Get a list of receivers.
46
+ # notifier.addresses # => [['localhost', 12201], ['localhost', 12202]]
47
+ def addresses
48
+ @sender.addresses
49
+ end
50
+
51
+ # Set a list of receivers.
52
+ # notifier.addresses = [['localhost', 12201], ['localhost', 12202]]
53
+ def addresses=(addrs)
54
+ @sender.addresses = addrs
55
+ end
56
+
57
+ # +size+ may be a number of bytes, 'WAN' (1420 bytes) or 'LAN' (8154).
58
+ # Default (safe) value is 'WAN'.
59
+ def max_chunk_size=(size)
60
+ case size.to_s.downcase
61
+ when 'wan'
62
+ @max_chunk_size = MAX_CHUNK_SIZE_WAN
63
+ when 'lan'
64
+ @max_chunk_size = MAX_CHUNK_SIZE_LAN
65
+ else
66
+ @max_chunk_size = size.to_int
67
+ end
68
+ end
69
+
70
+ def level=(new_level)
71
+ @level = if new_level.is_a?(Integer)
72
+ new_level
73
+ else
74
+ GELF.const_get(new_level.to_s.upcase)
75
+ end
76
+ end
77
+
78
+ def default_options=(options)
79
+ @default_options = self.class.stringify_keys(options)
80
+ end
81
+
82
+ # +mapping+ may be a hash, 'logger' (GELF::LOGGER_MAPPING) or 'direct' (GELF::DIRECT_MAPPING).
83
+ # Default (compatible) value is 'logger'.
84
+ def level_mapping=(mapping)
85
+ case mapping.to_s.downcase
86
+ when 'logger'
87
+ @level_mapping = GELF::LOGGER_MAPPING
88
+ when 'direct'
89
+ @level_mapping = GELF::DIRECT_MAPPING
90
+ else
91
+ @level_mapping = mapping
92
+ end
93
+ end
94
+
95
+ def disable
96
+ @enabled = false
97
+ end
98
+
99
+ def enable
100
+ @enabled = true
101
+ end
102
+
103
+ # Closes sender
104
+ def close
105
+ @sender.close
106
+ end
107
+
108
+ # Same as notify!, but rescues all exceptions (including +ArgumentError+)
109
+ # and sends them instead.
110
+ def notify(*args)
111
+ notify_with_level(nil, *args)
112
+ end
113
+
114
+ # Sends message to Graylog2 server.
115
+ # +args+ can be:
116
+ # - hash-like object (any object which responds to +to_hash+, including +Hash+ instance):
117
+ # notify!(:short_message => 'All your rebase are belong to us', :user => 'AlekSi')
118
+ # - exception with optional hash-like object:
119
+ # notify!(SecurityError.new('ALARM!'), :trespasser => 'AlekSi')
120
+ # - string-like object (anything which responds to +to_s+) with optional hash-like object:
121
+ # notify!('Plain olde text message', :scribe => 'AlekSi')
122
+ # Resulted fields are merged with +default_options+, the latter will never overwrite the former.
123
+ # This method will raise +ArgumentError+ if arguments are wrong. Consider using notify instead.
124
+ def notify!(*args)
125
+ notify_with_level!(nil, *args)
126
+ end
127
+
128
+ GELF::Levels.constants.each do |const|
129
+ define_method(const.downcase) do |*args|
130
+ level = GELF.const_get(const)
131
+ notify_with_level(level, *args)
132
+ end
133
+ end
134
+
135
+ private
136
+
137
+ def create_sender(host, port)
138
+ addresses = [[host, port]]
139
+ if default_options['protocol'] == GELF::Protocol::TCP
140
+ if default_options.key?('tls')
141
+ tls_options = default_options.delete('tls')
142
+ GELF::Transport::TCPTLS.new(addresses, tls_options)
143
+ else
144
+ GELF::Transport::TCP.new(addresses)
145
+ end
146
+ else
147
+ GELF::Transport::UDP.new(addresses)
148
+ end
149
+ end
150
+
151
+ def notify_with_level(message_level, *args)
152
+ notify_with_level!(message_level, *args)
153
+ rescue SocketError, SystemCallError
154
+ raise unless rescue_network_errors
155
+ rescue Exception => exception
156
+ notify_with_level!(GELF::UNKNOWN, exception)
157
+ end
158
+
159
+ def notify_with_level!(message_level, *args)
160
+ return unless @enabled
161
+ hash = extract_hash(*args)
162
+ hash['level'] = message_level unless message_level.nil?
163
+ if hash['level'] >= level
164
+ if default_options['protocol'] == GELF::Protocol::TCP
165
+ validate_hash(hash)
166
+ @sender.send(hash.to_json + "\0")
167
+ else
168
+ @sender.send_datagrams(datagrams_from_hash(hash))
169
+ end
170
+ end
171
+ end
172
+
173
+ def extract_hash(object = nil, args = {})
174
+ primary_data = if object.respond_to?(:to_hash)
175
+ object.to_hash
176
+ elsif object.is_a?(Exception)
177
+ args['level'] ||= GELF::ERROR
178
+ self.class.extract_hash_from_exception(object)
179
+ else
180
+ args['level'] ||= GELF::INFO
181
+ { 'short_message' => object.to_s }
182
+ end
183
+
184
+ hash = default_options.merge(self.class.stringify_keys(args.merge(primary_data)))
185
+ convert_hoptoad_keys_to_graylog2(hash)
186
+ set_file_and_line(hash) if @collect_file_and_line
187
+ set_timestamp(hash)
188
+ check_presence_of_mandatory_attributes(hash)
189
+ hash
190
+ end
191
+
192
+ def self.extract_hash_from_exception(exception)
193
+ bt = exception.backtrace || ["Backtrace is not available."]
194
+ {
195
+ 'short_message' => "#{exception.class}: #{exception.message}",
196
+ 'full_message' => "Backtrace:\n" + bt.join("\n")
197
+ }
198
+ end
199
+
200
+ # Converts Hoptoad-specific keys in +@hash+ to Graylog2-specific.
201
+ def convert_hoptoad_keys_to_graylog2(hash)
202
+ if hash['short_message'].to_s.empty?
203
+ if hash.has_key?('error_class') && hash.has_key?('error_message')
204
+ hash['short_message'] = hash.delete('error_class') + ': ' + hash.delete('error_message')
205
+ end
206
+ end
207
+ end
208
+
209
+ CALLER_REGEXP = /^(.*):(\d+).*/
210
+ LIB_GELF_PATTERN = File.join('lib', 'gelf')
211
+
212
+ def set_file_and_line(hash)
213
+ stack = caller
214
+ frame = stack.find { |f| !f.include?(LIB_GELF_PATTERN) }
215
+ match = CALLER_REGEXP.match(frame)
216
+ hash['file'] = match[1]
217
+ hash['line'] = match[2].to_i
218
+ end
219
+
220
+ def set_timestamp(hash)
221
+ hash['timestamp'] = Time.now.utc.to_f if hash['timestamp'].nil?
222
+ end
223
+
224
+ def check_presence_of_mandatory_attributes(hash)
225
+ %w(version short_message host).each do |attribute|
226
+ if hash[attribute].to_s.empty?
227
+ raise ArgumentError.new("#{attribute} is missing. Options version, short_message and host must be set.")
228
+ end
229
+ end
230
+ end
231
+
232
+ def datagrams_from_hash(hash)
233
+ data = serialize_hash(hash)
234
+ datagrams = []
235
+
236
+ # Maximum total size is 8192 byte for UDP datagram. Split to chunks if bigger. (GELF v1.0 supports chunking)
237
+ if data.count > @max_chunk_size
238
+ id = @random.bytes(8)
239
+ msg_id = Digest::MD5.digest("#{Time.now.to_f}-#{id}")[0, 8]
240
+ num, count = 0, (data.count.to_f / @max_chunk_size).ceil
241
+ if count > MAX_CHUNKS
242
+ raise ArgumentError, "Data too big (#{data.count} bytes), would create more than #{MAX_CHUNKS} chunks!"
243
+ end
244
+ data.each_slice(@max_chunk_size) do |slice|
245
+ datagrams << "\x1e\x0f" + msg_id + [num, count, *slice].pack('C*')
246
+ num += 1
247
+ end
248
+ else
249
+ datagrams << data.to_a.pack('C*')
250
+ end
251
+
252
+ datagrams
253
+ end
254
+
255
+ def validate_hash(hash)
256
+ raise ArgumentError.new("Hash is empty.") if hash.nil? || hash.empty?
257
+ hash['level'] = @level_mapping[hash['level']]
258
+ end
259
+
260
+ def serialize_hash(hash)
261
+ validate_hash(hash)
262
+
263
+ Zlib::Deflate.deflate(hash.to_json).bytes
264
+ end
265
+
266
+ def self.stringify_keys(data)
267
+ return data unless data.is_a? Hash
268
+
269
+ data.each_with_object({}) do |(key, value), obj|
270
+ key_s = key.to_s
271
+
272
+ if (key != key_s) && data.key?(key_s)
273
+ raise ArgumentError, "Both #{key.inspect} and #{key_s} are present."
274
+ end
275
+
276
+ obj[key_s] = value
277
+ end
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,50 @@
1
+ module GELF
2
+ # There are two things you should know about log levels/severity:
3
+ # - syslog defines levels from 0 (Emergency) to 7 (Debug).
4
+ # 0 (Emergency) and 1 (Alert) levels are reserved for OS kernel.
5
+ # - Ruby default Logger defines levels from 0 (DEBUG) to 4 (FATAL) and 5 (UNKNOWN).
6
+ # Note that order is inverted.
7
+ # For compatibility we define our constants as Ruby Logger, and convert values before
8
+ # generating GELF message, using defined mapping.
9
+
10
+ module Levels
11
+ DEBUG = 0
12
+ INFO = 1
13
+ WARN = 2
14
+ ERROR = 3
15
+ FATAL = 4
16
+ UNKNOWN = 5
17
+ # Additional native syslog severities. These will work in direct mapping mode
18
+ # only, for compatibility with syslog sources unrelated to Logger.
19
+ EMERGENCY = 10
20
+ ALERT = 11
21
+ CRITICAL = 12
22
+ WARNING = 14
23
+ NOTICE = 15
24
+ INFORMATIONAL = 16
25
+ end
26
+
27
+ include Levels
28
+
29
+ # Maps Ruby Logger levels to syslog levels as SyslogLogger and syslogger gems. This one is default.
30
+ LOGGER_MAPPING = {DEBUG => 7, # Debug
31
+ INFO => 6, # Informational
32
+ WARN => 5, # Notice
33
+ ERROR => 4, # Warning
34
+ FATAL => 3, # Error
35
+ UNKNOWN => 1} # Alert – shouldn't be used
36
+
37
+ # Maps Syslog or Ruby Logger levels directly to standard syslog numerical severities.
38
+ DIRECT_MAPPING = {DEBUG => 7, # Debug
39
+ INFORMATIONAL => 6, # Informational (syslog source)
40
+ INFO => 6, # Informational (Logger source)
41
+ NOTICE => 5, # Notice
42
+ WARNING => 4, # Warning (syslog source)
43
+ WARN => 4, # Warning (Logger source)
44
+ ERROR => 3, # Error
45
+ CRITICAL => 2, # Critical (syslog source)
46
+ FATAL => 2, # Critical (Logger source)
47
+ ALERT => 1, # Alert (syslog source)
48
+ UNKNOWN => 1, # Alert - shouldn't be used (Logger source)
49
+ EMERGENCY => 0} # Emergency (syslog source)
50
+ end
@@ -0,0 +1,79 @@
1
+ module GELF
2
+ module Transport
3
+ class TCP
4
+ attr_reader :addresses
5
+
6
+ # `addresses` Array of [host, port] pairs
7
+ def initialize(addresses)
8
+ @sockets = []
9
+ self.addresses = addresses
10
+ end
11
+
12
+ def addresses=(addresses)
13
+ @addresses = addresses.dup.freeze.tap do |addrs|
14
+ @sockets.each(&:close)
15
+ @sockets = addrs.map { |peer| connect(*peer) }
16
+ end
17
+ end
18
+
19
+ def send(message)
20
+ return if @addresses.empty?
21
+ loop do
22
+ connected = @sockets.reject(&:closed?)
23
+ reconnect_all if connected.empty?
24
+ break if write_any(connected, message)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def connect(host, port)
31
+ socket_class.new(host, port)
32
+ end
33
+
34
+ def reconnect_all
35
+ @sockets = @sockets.each_with_index.map do |old_socket, index|
36
+ old_socket.closed? ? connect(*@addresses[index]) : old_socket
37
+ end
38
+ end
39
+
40
+ def socket_class
41
+ if defined?(Celluloid::IO::TCPSocket)
42
+ Celluloid::IO::TCPSocket
43
+ else
44
+ ::TCPSocket
45
+ end
46
+ end
47
+
48
+ def write_any(sockets, message)
49
+ sockets.shuffle.each do |socket|
50
+ return true if write_socket(socket, message)
51
+ end
52
+ false
53
+ end
54
+
55
+ def write_socket(socket, message)
56
+ unsafe_write_socket(socket, message)
57
+ rescue IOError, SystemCallError
58
+ socket.close unless socket.closed?
59
+ false
60
+ end
61
+
62
+ def unsafe_write_socket(socket, message)
63
+ r,w = IO.select([socket], [socket])
64
+ # Read everything first
65
+ while r.any? do
66
+ # don't expect any reads, but a readable socket might
67
+ # mean the remote end closed, so read it and throw it away.
68
+ # we'll get an EOFError if it happens.
69
+ socket.sysread(16384)
70
+ r = IO.select([socket])
71
+ end
72
+
73
+ # Now send the payload
74
+ return false unless w.any?
75
+ return socket.syswrite(message) > 0
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,129 @@
1
+ require 'openssl'
2
+
3
+ module GELF
4
+ module Transport
5
+ # Provides encryption capabilities for TCP connections
6
+ class TCPTLS < TCP
7
+ # Supported tls_options:
8
+ # 'no_default_ca' [Boolean] prevents OpenSSL from using the systems CA store.
9
+ # 'version' [Symbol] any of :TLSv1, :TLSv1_1, :TLSv1_2 (default)
10
+ # 'ca' [String] the path to a custom CA store
11
+ # 'cert' [String, IO] the client certificate file
12
+ # 'key' [String, IO] the key for the client certificate
13
+ # 'all_ciphers' [Boolean] allows any ciphers to be used, may be insecure
14
+ # 'rescue_ssl_errors' [Boolean] similar to rescue_network_errors in notifier.rb, allows SSL exceptions to be raised
15
+ # 'no_verify' [Boolean] disable peer verification
16
+
17
+ attr_accessor :rescue_ssl_errors
18
+
19
+ def initialize(addresses, tls_options={})
20
+ @tls_options = tls_options
21
+ @rescue_ssl_errors = @tls_options['rescue_ssl_errors']
22
+ @rescue_ssl_errors if @rescue_ssl_errors.nil?
23
+ super(addresses)
24
+ end
25
+
26
+ protected
27
+
28
+ def write_socket(socket, message)
29
+ super(socket, message)
30
+ rescue OpenSSL::SSL::SSLError
31
+ socket.close unless socket.closed?
32
+ raise unless rescue_ssl_errors
33
+ false
34
+ end
35
+
36
+ def connect(host, port)
37
+ plain_socket = super(host, port)
38
+ start_tls(plain_socket)
39
+ rescue OpenSSL::SSL::SSLError
40
+ plain_socket.close unless plain_socket.closed?
41
+ raise unless rescue_ssl_errors
42
+ nil
43
+ end
44
+
45
+ # Initiates TLS communication on the socket
46
+ def start_tls(plain_socket)
47
+ ssl_socket_class.new(plain_socket, ssl_context).tap do |ssl_socket|
48
+ ssl_socket.sync_close = true
49
+ ssl_socket.connect
50
+ end
51
+ end
52
+
53
+ def ssl_socket_class
54
+ if defined?(Celluloid::IO::SSLSocket)
55
+ Celluloid::IO::SSLSocket
56
+ else
57
+ OpenSSL::SSL::SSLSocket
58
+ end
59
+ end
60
+
61
+ def ssl_context
62
+ @ssl_context ||= OpenSSL::SSL::SSLContext.new.tap do |ctx|
63
+ ctx.cert_store = ssl_cert_store
64
+ ctx.ssl_version = tls_version
65
+ ctx.verify_mode = verify_mode
66
+ set_certificate_and_key(ctx)
67
+ restrict_ciphers(ctx) unless @tls_options['all_ciphers']
68
+ end
69
+ end
70
+
71
+ def set_certificate_and_key(context)
72
+ return unless @tls_options['cert'] && @tls_options['key']
73
+ context.cert = OpenSSL::X509::Certificate.new(resource(@tls_options['cert']))
74
+ context.key = OpenSSL::PKey::RSA.new(resource(@tls_options['key']))
75
+ end
76
+
77
+ # checks whether {resource} is a filename and tries to read it
78
+ # otherwise treats it as if it already contains certificate/key data
79
+ def resource(data)
80
+ if data.is_a?(String) && File.exist?(data)
81
+ File.read(data)
82
+ else
83
+ data
84
+ end
85
+ end
86
+
87
+ # Ciphers have to come from the CipherString class, specifically the _TXT_ constants here - https://github.com/jruby/jruby-openssl/blob/master/src/main/java/org/jruby/ext/openssl/CipherStrings.java#L47-L178
88
+ def restrict_ciphers(ctx)
89
+ # This CipherString is will allow a variety of 'currently' cryptographically secure ciphers,
90
+ # while also retaining a broad level of compatibility
91
+ ctx.ciphers = "TLSv1_2:TLSv1_1:TLSv1:!aNULL:!eNULL:!LOW:!3DES:!MD5:!EXP:!PSK:!DSS:!RC4:!SEED:!ECDSA:!ADH:!IDEA:!3DES"
92
+ end
93
+
94
+ def verify_mode
95
+ @tls_options['no_verify'] ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
96
+ end
97
+
98
+ # SSL v2&3 are insecure, forces at least TLS v1.0 and defaults to v1.2
99
+ def tls_version
100
+ if @tls_options.key?('version') &&
101
+ OpenSSL::SSL::SSLContext::METHODS.include?(@tls_options['version']) &&
102
+ @tls_options['version'] =~ /\ATLSv/
103
+ @tls_options['version']
104
+ else
105
+ :TLSv1_2
106
+ end
107
+ end
108
+
109
+ def ssl_cert_store
110
+ OpenSSL::X509::Store.new.tap do |store|
111
+ unless @tls_options['no_default_ca']
112
+ store.set_default_paths
113
+ end
114
+
115
+ if @tls_options.key?('ca')
116
+ ca = @tls_options['ca']
117
+ if File.directory?(ca)
118
+ store.add_path(@tls_options['ca'])
119
+ elsif File.file?(ca)
120
+ store.add_file(ca)
121
+ else
122
+ $stderr.puts "No directory or file: #{ca}"
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,41 @@
1
+ module GELF
2
+ module Transport
3
+ class UDP
4
+ attr_accessor :addresses
5
+
6
+ def initialize(addresses)
7
+ @addresses = addresses
8
+ end
9
+
10
+ def send_datagrams(datagrams)
11
+ socket = get_socket
12
+ idx = get_address_index
13
+
14
+ host, port = @addresses[idx]
15
+ set_address_index((idx + 1) % @addresses.length)
16
+ datagrams.each do |datagram|
17
+ socket.send(datagram, 0, host, port)
18
+ end
19
+ end
20
+
21
+ def close
22
+ socket = get_socket
23
+ socket.close if socket
24
+ end
25
+
26
+ private
27
+
28
+ def get_socket
29
+ Thread.current[:gelf_udp_socket] ||= UDPSocket.open
30
+ end
31
+
32
+ def get_address_index
33
+ Thread.current[:gelf_udp_address_idx] ||= 0
34
+ end
35
+
36
+ def set_address_index(value)
37
+ Thread.current[:gelf_udp_address_idx] = value
38
+ end
39
+ end
40
+ end
41
+ end
data/lib/gelf.rb ADDED
@@ -0,0 +1,16 @@
1
+ require 'json'
2
+ require 'socket'
3
+ require 'zlib'
4
+ require 'digest/md5'
5
+
6
+ module GELF
7
+ SPEC_VERSION = '1.0'
8
+ module Protocol
9
+ UDP = 0
10
+ TCP = 1
11
+ end
12
+ end
13
+
14
+ require 'gelf/severity'
15
+ require 'gelf/notifier'
16
+ require 'gelf/logger'
data/test/helper.rb ADDED
@@ -0,0 +1,11 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ require 'shoulda'
4
+ require 'mocha/setup'
5
+
6
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
7
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
8
+ require 'gelf'
9
+
10
+ class Test::Unit::TestCase
11
+ end