logstash-logger 1.0.0 → 1.1.0

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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/tests.yml +5 -3
  3. data/Appraisals +2 -4
  4. data/CHANGELOG.md +33 -0
  5. data/Gemfile +2 -1
  6. data/README.md +2 -2
  7. data/docs/buffering.md +16 -0
  8. data/docs/outputs.md +23 -0
  9. data/docs/rails.md +7 -2
  10. data/docs/ssl.md +49 -37
  11. data/docs/troubleshooting.md +22 -0
  12. data/gemfiles/rails_8.0.gemfile +3 -1
  13. data/gemfiles/rails_8.1.gemfile +2 -1
  14. data/lib/logstash-logger/buffer.rb +15 -4
  15. data/lib/logstash-logger/device/aws_stream.rb +96 -17
  16. data/lib/logstash-logger/device/connectable.rb +27 -1
  17. data/lib/logstash-logger/device/firehose.rb +6 -0
  18. data/lib/logstash-logger/device/http.rb +75 -4
  19. data/lib/logstash-logger/device/kafka.rb +27 -0
  20. data/lib/logstash-logger/device/kinesis.rb +10 -1
  21. data/lib/logstash-logger/device/tcp.rb +53 -7
  22. data/lib/logstash-logger/formatter/base.rb +44 -3
  23. data/lib/logstash-logger/formatter/cee_syslog.rb +8 -11
  24. data/lib/logstash-logger/version.rb +1 -1
  25. data/logstash-logger.gemspec +2 -6
  26. data/spec/device/aws_stream_limits_spec.rb +219 -0
  27. data/spec/device/connectable_spec.rb +51 -0
  28. data/spec/device/firehose_spec.rb +4 -1
  29. data/spec/device/http_spec.rb +166 -1
  30. data/spec/device/kafka_spec.rb +92 -0
  31. data/spec/device/kinesis_spec.rb +4 -1
  32. data/spec/device/tcp_spec.rb +94 -5
  33. data/spec/device/tcp_tls_integration_spec.rb +91 -0
  34. data/spec/formatter/base_spec.rb +42 -6
  35. data/spec/formatter/cee_syslog_spec.rb +71 -19
  36. data/spec/formatter/framing_spec.rb +58 -0
  37. data/spec/formatter/json_lines_spec.rb +18 -0
  38. data/spec/spec_helper.rb +5 -0
  39. data/spec/support/tls_helper.rb +30 -0
  40. data/spec/support/unserializable_value.rb +10 -0
  41. metadata +19 -7
  42. data/gemfiles/rails_7.2.gemfile +0 -8
@@ -4,8 +4,173 @@ describe LogStashLogger::Device::HTTP do
4
4
  include_context 'device'
5
5
 
6
6
  it "Post event to HTTP" do
7
- expect(Net::HTTP).to receive(:post)
7
+ expect(Net::HTTP).to receive(:post).and_return(Net::HTTPOK.new('1.1', '200', 'OK'))
8
8
  http_device.write('test')
9
9
  end
10
10
 
11
+ describe "body format" do
12
+ let(:url) { 'http://localhost' }
13
+ let(:messages) { ["{\"a\":1}\n", "{\"b\":2}"] }
14
+
15
+ it "sends a JSON array by default" do
16
+ device = LogStashLogger::Device.new(type: :http, url: url, sync: true)
17
+ expect(Net::HTTP).to receive(:post)
18
+ .with(URI(url), "[{\"a\":1}\n,{\"b\":2}]", anything)
19
+ .and_return(Net::HTTPOK.new('1.1', '200', 'OK'))
20
+ device.write_batch(messages)
21
+ end
22
+
23
+ it "sends a JSON array with format: :json_array" do
24
+ device = LogStashLogger::Device.new(type: :http, url: url, sync: true, format: :json_array)
25
+ expect(Net::HTTP).to receive(:post)
26
+ .with(URI(url), "[{\"a\":1}\n,{\"b\":2}]", anything)
27
+ .and_return(Net::HTTPOK.new('1.1', '200', 'OK'))
28
+ device.write_batch(messages)
29
+ end
30
+
31
+ it "sends newline-delimited JSON with format: :ndjson" do
32
+ device = LogStashLogger::Device.new(type: :http, url: url, sync: true, format: :ndjson)
33
+ expect(Net::HTTP).to receive(:post)
34
+ .with(URI(url), "{\"a\":1}\n{\"b\":2}\n", anything)
35
+ .and_return(Net::HTTPOK.new('1.1', '200', 'OK'))
36
+ device.write_batch(messages)
37
+ end
38
+
39
+ it "accepts the format as a string" do
40
+ device = LogStashLogger::Device.new(type: :http, url: url, sync: true, format: "ndjson")
41
+ expect(Net::HTTP).to receive(:post)
42
+ .with(URI(url), "{\"a\":1}\n{\"b\":2}\n", anything)
43
+ .and_return(Net::HTTPOK.new('1.1', '200', 'OK'))
44
+ device.write_batch(messages)
45
+ end
46
+
47
+ it "raises an error for an unknown format" do
48
+ expect {
49
+ LogStashLogger::Device.new(type: :http, url: url, sync: true, format: :xml)
50
+ }.to raise_error(ArgumentError, /Unknown HTTP body format: :xml/)
51
+ end
52
+ end
53
+
54
+ describe "content type" do
55
+ let(:url) { 'http://localhost' }
56
+
57
+ def expect_content_type(type)
58
+ expect(Net::HTTP).to receive(:post)
59
+ .with(anything, anything, {"Content-Type" => type})
60
+ .and_return(Net::HTTPOK.new('1.1', '200', 'OK'))
61
+ end
62
+
63
+ it "is application/json by default" do
64
+ device = LogStashLogger::Device.new(type: :http, url: url, sync: true)
65
+ expect_content_type("application/json")
66
+ device.write_batch(["{}"])
67
+ end
68
+
69
+ it "is application/x-ndjson for format: :ndjson" do
70
+ device = LogStashLogger::Device.new(type: :http, url: url, sync: true, format: :ndjson)
71
+ expect_content_type("application/x-ndjson")
72
+ device.write_batch(["{}"])
73
+ end
74
+
75
+ it "can be overridden with content_type" do
76
+ device = LogStashLogger::Device.new(type: :http, url: url, sync: true, format: :ndjson, content_type: "application/json")
77
+ expect_content_type("application/json")
78
+ device.write_batch(["{}"])
79
+ end
80
+ end
81
+
82
+ it "does not raise on a successful response" do
83
+ allow(Net::HTTP).to receive(:post).and_return(Net::HTTPOK.new('1.1', '200', 'OK'))
84
+ expect { http_device.write_batch(['test']) }.not_to raise_error
85
+ end
86
+
87
+ it "raises on an error response" do
88
+ allow(Net::HTTP).to receive(:post).and_return(Net::HTTPInternalServerError.new('1.1', '500', 'Internal Server Error'))
89
+ expect { http_device.write_batch(['test']) }.to raise_error(RuntimeError, "HTTP log upload failed: 500 Internal Server Error")
90
+ end
91
+
92
+ it "raises on a client error response" do
93
+ allow(Net::HTTP).to receive(:post).and_return(Net::HTTPUnauthorized.new('1.1', '401', 'Unauthorized'))
94
+ expect { http_device.write_batch(['test']) }.to raise_error(RuntimeError, /401 Unauthorized/)
95
+ end
96
+ end
97
+
98
+ describe LogStashLogger::Device::HTTP, 'records the collector rejects' do
99
+ let(:url) { 'http://localhost' }
100
+ let(:error_log) { StringIO.new }
101
+ let(:sent) { [] }
102
+ let(:small) { (1..5).map { |i| %({"n":#{i}}) } }
103
+
104
+ def device(**opts)
105
+ LogStashLogger::Device.new({ type: :http, url: url, sync: false, buffer_max_interval: 3600,
106
+ buffer_flush_at_exit: false, error_logger: ::Logger.new(error_log) }.merge(opts))
107
+ end
108
+
109
+ # Stubs the HTTP endpoint: any request containing a "BAD" record gets the
110
+ # given error status, and every other request succeeds and is recorded.
111
+ def stub_endpoint_rejecting_bad_records(status:, message:)
112
+ allow(Net::HTTP).to receive(:post) do |_url, body, _headers|
113
+ if body.include?('BAD')
114
+ klass = Net::HTTPResponse::CODE_TO_OBJ[status]
115
+ klass.new('1.1', status, message)
116
+ else
117
+ sent << body
118
+ Net::HTTPOK.new('1.1', '200', 'OK')
119
+ end
120
+ end
121
+ end
122
+
123
+ it 'delivers the other records and drops only the rejected one' do
124
+ stub_endpoint_rejecting_bad_records(status: '413', message: 'Payload Too Large')
125
+ device.write_batch(small + ['"BAD"'])
126
+ expect(sent).to eq(small.map { |m| "[#{m}]" })
127
+ expect(error_log.string).to include('dropping a record of 5 bytes that the collector rejected: HTTP 413')
128
+ end
129
+
130
+ it 'drops a rejected single-record batch without raising' do
131
+ stub_endpoint_rejecting_bad_records(status: '400', message: 'Bad Request')
132
+ expect { device.write_batch(['"BAD"']) }.not_to raise_error
133
+ expect(error_log.string).to include('HTTP 400')
134
+ end
135
+
136
+ it 'clears a buffered batch instead of retrying the rejected record forever' do
137
+ stub_endpoint_rejecting_bad_records(status: '422', message: 'Unprocessable Entity')
138
+ dev = device
139
+ small.each { |m| dev.write(m) }
140
+ dev.write('"BAD"')
141
+ dev.buffer_flush(force: true)
142
+ expect(dev.instance_variable_get(:@buffer_state)[:pending_count]).to eq(0)
143
+ expect(sent.size).to eq(small.size)
144
+ end
145
+
146
+ # Errors that do not depend on the records, so the batch is retried
147
+ {
148
+ '401' => 'Unauthorized', # missing or wrong credentials
149
+ '403' => 'Forbidden', # credentials not allowed
150
+ '404' => 'Not Found', # wrong URL, or the collector is not deployed yet
151
+ '429' => 'Too Many Requests', # rate limited
152
+ '500' => 'Internal Server Error', # collector error
153
+ '503' => 'Service Unavailable', # collector down or restarting
154
+ }.each do |status, message|
155
+ it "retries the whole batch on HTTP #{status} #{message}" do
156
+ stub_endpoint_rejecting_bad_records(status: status, message: message)
157
+ expect { device.write_batch(['"BAD"', 'ok']) }.to raise_error(RuntimeError, /HTTP log upload failed: #{status}/)
158
+ expect(error_log.string).to include("HTTP log upload failed: #{status}")
159
+ end
160
+ end
161
+
162
+ it 'raises if a record sent on its own hits a retryable error' do
163
+ calls = 0
164
+ allow(Net::HTTP).to receive(:post) do
165
+ calls += 1
166
+ calls == 1 ? Net::HTTPRequestEntityTooLarge.new('1.1', '413', 'Too Large') : Net::HTTPServiceUnavailable.new('1.1', '503', 'Unavailable')
167
+ end
168
+ expect { device.write_batch(['a', 'b']) }.to raise_error(RuntimeError, /503/)
169
+ end
170
+
171
+ it 'sends records that are not Strings as text' do
172
+ allow(Net::HTTP).to receive(:post) { |_u, body, _h| sent << body; Net::HTTPOK.new('1.1', '200', 'OK') }
173
+ device(format: :ndjson).write_batch([123, '{"a":1}'])
174
+ expect(sent).to eq(["123\n{\"a\":1}\n"])
175
+ end
11
176
  end
@@ -1,4 +1,7 @@
1
1
  require 'logstash-logger'
2
+ # The device requires ruby-kafka only when the first one is built. Load it here
3
+ # so the classes reopened below and Kafka::SslContext exist in any test order.
4
+ require 'ruby-kafka'
2
5
 
3
6
  describe LogStashLogger::Device::Kafka::TLSConfiguration do
4
7
  context "with TLS" do
@@ -353,3 +356,92 @@ describe LogStashLogger::Device::Kafka do
353
356
  end
354
357
  end
355
358
  end
359
+
360
+ describe LogStashLogger::Device::Kafka, 'records the broker always rejects' do
361
+ # Stands in for a ruby-kafka producer: rejects any delivery that contains a
362
+ # record over the limit, the way a broker answers MESSAGE_TOO_LARGE.
363
+ class FakeKafkaProducer
364
+ LIMIT = 1_000
365
+
366
+ def initialize(delivered, errors = {})
367
+ @delivered = delivered
368
+ @errors = errors
369
+ @buffer = []
370
+ end
371
+
372
+ def produce(message, topic:)
373
+ raise ::Kafka::BufferOverflow, 'buffer full' if message.bytesize > LIMIT * 10
374
+ @buffer << message
375
+ end
376
+
377
+ def deliver_messages
378
+ if (error = @errors.delete(:next_delivery))
379
+ @buffer.clear
380
+ raise error
381
+ end
382
+ if @buffer.any? { |m| m.bytesize > LIMIT }
383
+ @buffer.clear
384
+ raise ::Kafka::MessageSizeTooLarge, 'message too large'
385
+ end
386
+ @delivered.concat(@buffer)
387
+ @buffer.clear
388
+ end
389
+
390
+ def shutdown; end
391
+ end
392
+
393
+ let(:delivered) { [] }
394
+ let(:errors) { {} }
395
+ let(:error_log) { StringIO.new }
396
+ let(:device) do
397
+ LogStashLogger::Device::Kafka.new(topic: 'logs', brokers: 'localhost:9092', sync: false,
398
+ buffer_max_interval: 3600, buffer_flush_at_exit: false, error_logger: ::Logger.new(error_log))
399
+ end
400
+
401
+ before do
402
+ allow(device).to receive(:connection) do
403
+ double('connection', producer: FakeKafkaProducer.new(delivered, errors), close: true)
404
+ end
405
+ end
406
+
407
+ let(:small) { (1..5).map { |i| %({"n":#{i}}) } }
408
+
409
+ it 'delivers the other records and drops only an oversized one' do
410
+ device.write_batch(small + ['x' * 2_000])
411
+ expect(delivered).to eq(small)
412
+ expect(error_log.string).to include('dropping a record of 2000 bytes that Kafka rejected: Kafka::MessageSizeTooLarge')
413
+ end
414
+
415
+ it 'drops a record the client buffer cannot hold' do
416
+ device.write_batch(small + ['x' * 20_000])
417
+ expect(delivered).to eq(small)
418
+ expect(error_log.string).to include('Kafka::BufferOverflow')
419
+ end
420
+
421
+ it 'delivers everything when the batch is only too large as a whole' do
422
+ allow(device).to receive(:connection) do
423
+ producer = FakeKafkaProducer.new(delivered, errors)
424
+ def producer.deliver_messages
425
+ raise ::Kafka::MessageSizeTooLarge, 'batch too large' if @buffer.size > 1
426
+ super
427
+ end
428
+ double('connection', producer: producer, close: true)
429
+ end
430
+ device.write_batch(small)
431
+ expect(delivered).to eq(small)
432
+ end
433
+
434
+ it 'still raises transient errors so the buffer retries the batch' do
435
+ errors[:next_delivery] = ::Kafka::ConnectionError.new('broker unavailable')
436
+ expect { device.write_batch(small) }.to raise_error(::Kafka::ConnectionError)
437
+ end
438
+
439
+ it 'clears the buffer instead of holding other records behind the rejected one' do
440
+ small.each { |m| device.write(m) }
441
+ device.write('x' * 2_000)
442
+ device.buffer_flush(force: true)
443
+
444
+ expect(delivered).to eq(small)
445
+ expect(device.instance_variable_get(:@buffer_state)[:pending_count]).to eq(0)
446
+ end
447
+ end
@@ -30,9 +30,12 @@ describe LogStashLogger::Device::Kinesis do
30
30
  response.records = [failed_record]
31
31
 
32
32
  expect(client).to receive(:put_records) { response }
33
- expect(kinesis_device).to receive(:write).with("foo")
33
+ expect(kinesis_device).not_to receive(:write)
34
34
 
35
35
  kinesis_device.write_one "foo"
36
+ state = kinesis_device.instance_variable_get(:@buffer_state)
37
+ expect(state[:pending_items][nil]).to eq(["foo"])
38
+ expect(state[:pending_count]).to eq(1)
36
39
  end
37
40
 
38
41
  it "defaults the kinesis stream to logstash" do
@@ -1,4 +1,5 @@
1
1
  require 'logstash-logger'
2
+ require_relative '../support/tls_helper'
2
3
 
3
4
  describe LogStashLogger::Device::TCP do
4
5
  include_context 'device'
@@ -13,6 +14,9 @@ describe LogStashLogger::Device::TCP do
13
14
  allow(OpenSSL::SSL::SSLSocket).to receive(:new) { ssl_socket }
14
15
  allow(ssl_socket).to receive(:connect)
15
16
  allow(ssl_socket).to receive(:post_connection_check)
17
+ allow(ssl_socket).to receive(:sync_close=)
18
+ allow(ssl_socket).to receive(:hostname=)
19
+ allow(tcp_socket).to receive(:close)
16
20
  allow(ssl_tcp_device).to receive(:warn)
17
21
  end
18
22
 
@@ -103,16 +107,101 @@ describe LogStashLogger::Device::TCP do
103
107
  end
104
108
 
105
109
  context 'without a provided SSL context' do
106
- it 'ssl_context returns nil' do
107
- expect(ssl_tcp_device.ssl_context).to be_nil
110
+ it 'uses a context that verifies the server certificate' do
111
+ ctx = ssl_tcp_device.ssl_context
112
+ expect(ctx).to be_a(OpenSSL::SSL::SSLContext)
113
+ expect(ctx.verify_mode).to eq(OpenSSL::SSL::VERIFY_PEER)
114
+ expect(ctx.verify_hostname).to be true
115
+ expect(ctx.cert_store).not_to be_nil
116
+ end
117
+
118
+ it 'creates the socket with that context' do
119
+ expect(OpenSSL::SSL::SSLSocket).to receive(:new).with(tcp_socket, ssl_tcp_device.ssl_context)
120
+ ssl_tcp_device.connect
121
+ end
122
+
123
+ it 'verifies the hostname after connecting' do
124
+ expect(ssl_socket).to receive(:post_connection_check).with(HOST)
125
+ ssl_tcp_device.connect
126
+ end
127
+
128
+ context 'with verify_hostname: false' do
129
+ let(:ssl_tcp_device) { LogStashLogger::Device.new(type: :tcp, port: port, ssl_enable: true, sync: true, verify_hostname: false) }
130
+
131
+ it 'still verifies the certificate chain' do
132
+ expect(ssl_tcp_device.ssl_context.verify_mode).to eq(OpenSSL::SSL::VERIFY_PEER)
133
+ end
134
+
135
+ it 'does not verify the hostname' do
136
+ expect(ssl_tcp_device.ssl_context.verify_hostname).to be false
137
+ expect(ssl_socket).not_to receive(:post_connection_check)
138
+ ssl_tcp_device.connect
139
+ end
140
+ end
141
+ end
142
+
143
+ context 'server name indication' do
144
+ it 'is sent for a DNS hostname' do
145
+ device = LogStashLogger::Device.new(type: :tcp, host: 'logstash.example.com', port: port, ssl_enable: true, sync: true)
146
+ expect(ssl_socket).to receive(:hostname=).with('logstash.example.com')
147
+ device.connect
148
+ end
149
+
150
+ it 'is not sent for an IP address' do
151
+ expect(ssl_socket).not_to receive(:hostname=)
152
+ ssl_tcp_device.connect
153
+ end
154
+ end
155
+
156
+ it 'closes the TCP socket together with the SSL socket' do
157
+ expect(ssl_socket).to receive(:sync_close=).with(true)
158
+ ssl_tcp_device.connect
159
+ end
160
+
161
+ context 'when the SSL socket cannot be created' do
162
+ before do
163
+ allow(OpenSSL::SSL::SSLSocket).to receive(:new).and_raise(OpenSSL::SSL::SSLError, 'boom')
164
+ end
165
+
166
+ it 'closes the TCP socket it opened' do
167
+ expect(tcp_socket).to receive(:close).once
168
+ expect { ssl_tcp_device.connect }.to raise_error(OpenSSL::SSL::SSLError)
108
169
  end
109
170
  end
110
171
 
111
172
  context 'only providing a certificate file' do
112
- let(:ssl_tcp_device) { LogStashLogger::Device.new(type: :tcp, port: port, ssl_enable: true, sync: true, ssl_certificate: '/path/to/cert.pem') }
173
+ let(:cert_file) { TLSHelper.cert_file(TLSHelper.self_signed.first) }
174
+ let(:ssl_tcp_device) { LogStashLogger::Device.new(type: :tcp, port: port, ssl_enable: true, sync: true, ssl_certificate: cert_file.path) }
175
+
176
+ after { cert_file.close! }
177
+
178
+ it 'trusts that certificate and verifies the server against it' do
179
+ ctx = ssl_tcp_device.ssl_context
180
+ expect(ctx.ca_file).to eq(cert_file.path)
181
+ expect(ctx.verify_mode).to eq(OpenSSL::SSL::VERIFY_PEER)
182
+ expect(ctx.verify_hostname).to be true
183
+ end
184
+
185
+ it 'enables SSL implicitly' do
186
+ device = LogStashLogger::Device.new(type: :tcp, port: port, sync: true, ssl_certificate: cert_file.path)
187
+ expect(device.use_ssl?).to be_truthy
188
+ end
189
+
190
+ it 'rejects a missing file' do
191
+ expect {
192
+ LogStashLogger::Device.new(type: :tcp, port: port, sync: true, ssl_certificate: '/nonexistent/cert.pem')
193
+ }.to raise_error(ArgumentError, /readable certificate file/)
194
+ end
113
195
 
114
- it 'implicitly uses a context with the configured certificate' do
115
- expect(ssl_tcp_device.ssl_context.cert).to eq('/path/to/cert.pem')
196
+ it 'rejects a file that is not a certificate' do
197
+ not_a_cert = Tempfile.new('not-a-cert')
198
+ not_a_cert.write('hello')
199
+ not_a_cert.flush
200
+ expect {
201
+ LogStashLogger::Device.new(type: :tcp, port: port, sync: true, ssl_certificate: not_a_cert.path)
202
+ }.to raise_error(ArgumentError, /not a PEM or DER certificate/)
203
+ ensure
204
+ not_a_cert.close!
116
205
  end
117
206
  end
118
207
  end
@@ -0,0 +1,91 @@
1
+ require 'logstash-logger'
2
+ require 'logger'
3
+ require 'socket'
4
+ require_relative '../support/tls_helper'
5
+
6
+ # Real TLS handshakes against a loopback server that presents a throwaway
7
+ # self-signed certificate. Nothing leaves 127.0.0.1.
8
+ describe LogStashLogger::Device::TCP, 'TLS verification against a real server' do
9
+ before(:all) do
10
+ @cert, @key = TLSHelper.self_signed(dns: ['localhost'], ips: [])
11
+ @cert_file = TLSHelper.cert_file(@cert)
12
+ end
13
+
14
+ after(:all) { @cert_file.close! }
15
+
16
+ let(:received) { Queue.new }
17
+ let(:server) do
18
+ tcp = TCPServer.new('127.0.0.1', 0)
19
+ ctx = OpenSSL::SSL::SSLContext.new
20
+ ctx.cert = @cert
21
+ ctx.key = @key
22
+ OpenSSL::SSL::SSLServer.new(tcp, ctx)
23
+ end
24
+ let(:port) { server.to_io.addr[1] }
25
+
26
+ before do
27
+ srv = server
28
+ q = received
29
+ @server_thread = Thread.new do
30
+ loop do
31
+ begin
32
+ client = srv.accept
33
+ q << client.gets
34
+ client.close
35
+ rescue OpenSSL::SSL::SSLError, IOError, SystemCallError
36
+ # A rejected handshake is expected in several examples
37
+ break if srv.to_io.closed?
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ after do
44
+ server.close rescue nil
45
+ @server_thread.kill
46
+ @server_thread.join(1)
47
+ end
48
+
49
+ def device(**opts)
50
+ LogStashLogger::Device.new(
51
+ { type: :tcp, port: port, sync: true, buffer_flush_at_exit: false,
52
+ error_logger: ::Logger.new(IO::NULL) }.merge(opts)
53
+ )
54
+ end
55
+
56
+ def deliver(dev)
57
+ dev.connect
58
+ dev.io.write(%({"message":"hello"}\n))
59
+ dev.io.flush
60
+ line = received.pop(timeout: 5)
61
+ dev.close!
62
+ line
63
+ end
64
+
65
+ it 'refuses a self-signed certificate when only ssl_enable is set' do
66
+ expect { device(host: 'localhost', ssl_enable: true).connect }
67
+ .to raise_error(OpenSSL::SSL::SSLError)
68
+ end
69
+
70
+ it 'delivers when the certificate is trusted through ssl_certificate' do
71
+ expect(deliver(device(host: 'localhost', ssl_certificate: @cert_file.path)))
72
+ .to eq(%({"message":"hello"}\n))
73
+ end
74
+
75
+ it 'refuses a certificate whose name does not match the host' do
76
+ expect { device(host: '127.0.0.1', ssl_certificate: @cert_file.path).connect }
77
+ .to raise_error(OpenSSL::SSL::SSLError)
78
+ end
79
+
80
+ it 'checks the name given in verify_hostname when connecting by IP' do
81
+ dev = device(host: '127.0.0.1', ssl_certificate: @cert_file.path, verify_hostname: 'localhost')
82
+ expect(deliver(dev)).to eq(%({"message":"hello"}\n))
83
+ end
84
+
85
+ it 'still allows an explicit VERIFY_NONE context as an opt-out' do
86
+ ctx = OpenSSL::SSL::SSLContext.new
87
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
88
+ dev = device(host: '127.0.0.1', ssl_context: ctx, verify_hostname: false)
89
+ expect(deliver(dev)).to eq(%({"message":"hello"}\n))
90
+ end
91
+ end
@@ -24,19 +24,55 @@ describe LogStashLogger::Formatter::Base do
24
24
  end
25
25
  end
26
26
 
27
- context "when exception is raised" do
27
+ context "when building the event raises" do
28
28
  before do
29
- allow(subject).to receive(:error_logger).and_return(Logger.new('/dev/null'))
30
- allow(subject).to receive(:format_event).and_throw
29
+ allow(subject).to receive(:error_logger).and_return(Logger.new(IO::NULL))
30
+ allow(subject).to receive(:build_event).and_raise(ArgumentError, 'boom')
31
31
  end
32
32
 
33
33
  it "logs an exception" do
34
- expect(subject).to receive(:log_error)
34
+ expect(subject).to receive(:log_error).and_call_original
35
35
  subject.call(severity, time, progname, message)
36
36
  end
37
37
 
38
- it "returns a failed to format message" do
39
- expect(subject.call(severity, time, progname, message)).to eq(LogStashLogger::Formatter::Base::FAILED_TO_FORMAT_MSG)
38
+ it "returns a well-formed placeholder event instead of a bare string" do
39
+ event = subject.call(severity, time, progname, message)
40
+ expect(event).to be_a(LogStash::Event)
41
+ expect(event['message']).to eq(LogStashLogger::Formatter::Base::FAILED_TO_FORMAT_MSG)
42
+ expect(event['tags']).to eq([LogStashLogger::Formatter::Base::FAILED_TO_FORMAT_TAG])
43
+ expect(event['error']).to eq('ArgumentError')
44
+ expect(event['severity']).to eq(severity)
45
+ expect(event['@timestamp']).to eq(time.iso8601(3))
46
+ end
47
+ end
48
+
49
+ context "when even the placeholder cannot be formatted" do
50
+ before do
51
+ allow(subject).to receive(:error_logger).and_return(Logger.new(IO::NULL))
52
+ allow(subject).to receive(:format_event).and_raise(ArgumentError, 'boom')
53
+ end
54
+
55
+ it "returns nil, which devices drop" do
56
+ expect(subject.call(severity, time, progname, message)).to be_nil
57
+ end
58
+ end
59
+
60
+ context "when logged content carries an @timestamp that is not a time" do
61
+ let(:message) { '{"message":"hello","@timestamp":"not-a-time"}' }
62
+
63
+ it "keeps the logger time and formats the record normally" do
64
+ event = subject.call(severity, time, progname, message)
65
+ expect(event['message']).to eq('hello')
66
+ expect(event['@timestamp']).to eq(time.iso8601(3))
67
+ end
68
+ end
69
+
70
+ context "when logged content carries a valid @timestamp" do
71
+ let(:message) { '{"message":"hello","@timestamp":"2001-01-01T00:00:00Z"}' }
72
+
73
+ it "keeps it" do
74
+ event = subject.call(severity, time, progname, message)
75
+ expect(event['@timestamp']).to start_with('2001-01-01T00:00:00')
40
76
  end
41
77
  end
42
78
  end
@@ -1,43 +1,95 @@
1
1
  require 'logstash-logger'
2
+ require_relative '../support/unserializable_value'
2
3
 
3
4
  describe LogStashLogger::Formatter::CeeSyslog do
4
5
  include_context "formatter"
5
6
 
6
- describe "#call" do
7
- let(:facility) { "facility" }
8
-
9
- before do
10
- allow(subject).to receive(:build_facility).and_return(facility)
11
- end
7
+ before { subject.error_logger = Logger.new(IO::NULL) }
12
8
 
13
- it "outputs a facility before the @cee" do
14
- expect(formatted_message).to match(/\A#{facility}:@cee:/)
9
+ describe "#call" do
10
+ it "outputs this host and the progname before the @cee" do
11
+ expect(formatted_message).to start_with("#{hostname} #{progname}:@cee:")
15
12
  end
16
13
 
17
14
  it "serializes the LogStash::Event data as JSON" do
18
- json_data = formatted_message[/\A#{facility}:@cee:\s?(.*)\Z/, 1]
15
+ json_data = formatted_message[/\A[^:]*:@cee:\s?(.*)\Z/, 1]
19
16
  json_message = JSON.parse(json_data)
20
17
  expect(json_message["message"]).to eq(message)
21
18
  end
19
+
20
+ it "ends the record with exactly one newline" do
21
+ expect(formatted_message.count("\n")).to eq(1)
22
+ expect(formatted_message).to end_with("\n")
23
+ end
24
+
25
+ context "without progname" do
26
+ let(:progname) { nil }
27
+
28
+ it "only includes the hostname" do
29
+ expect(formatted_message).to start_with("#{hostname}:@cee:")
30
+ end
31
+ end
22
32
  end
23
33
 
24
- describe "#build_facility" do
25
- let(:host) { { 'hostname' => Socket.gethostname } }
34
+ describe "header" do
35
+ context "when logged content sets host.hostname" do
36
+ let(:message) { %q({"message":"hi","host":{"hostname":"web1\n<13>Jan 1 00:00:00 otherhost app: forged"}}) }
37
+
38
+ it "uses this process's hostname, not the logged one" do
39
+ expect(formatted_message).to start_with("#{hostname} #{progname}:@cee:")
40
+ end
41
+
42
+ it "cannot start a second record" do
43
+ expect(formatted_message.count("\n")).to eq(1)
44
+ expect(formatted_message).to end_with("\n")
45
+ end
46
+ end
47
+
48
+ context "when logged content sets host to a String" do
49
+ let(:message) { %q({"message":"hi","host":"web1"}) }
26
50
 
27
- before do
28
- formatted_message
51
+ it "formats the record normally" do
52
+ expect(formatted_message).to start_with("#{hostname} #{progname}:@cee:")
53
+ expect(JSON.parse(formatted_message[/@cee:(.*)\Z/, 1])['host']).to eq('web1')
54
+ end
29
55
  end
30
56
 
31
- it "includes hostname and progname" do
32
- expect(subject.send(:build_facility, host)).to match(/\A#{host['hostname']}\s#{progname}\z/)
57
+ context "when the progname contains control characters" do
58
+ let(:progname) { "app\n<13>forged" }
59
+
60
+ it "keeps the header on one line" do
61
+ expect(formatted_message.count("\n")).to eq(1)
62
+ expect(formatted_message).to start_with("#{hostname} app <13>forged:@cee:")
63
+ end
33
64
  end
34
65
 
35
- context "without progname" do
36
- let(:progname) { nil }
66
+ context "when a log call is made while another record is being built" do
67
+ let(:formatter) { subject }
68
+ let(:message) do
69
+ f = formatter
70
+ Object.new.tap do |obj|
71
+ obj.define_singleton_method(:inspect) do
72
+ f.call('INFO', Time.now, 'inner', 'nested')
73
+ 'outer message'
74
+ end
75
+ end
76
+ end
37
77
 
38
- it "only includes hostname" do
39
- expect(subject.send(:build_facility, host)).to match(/\A#{host['hostname']}\z/)
78
+ it "uses the outer call's progname in the outer header" do
79
+ expect(subject.call(severity, time, 'outer', message)).to start_with("#{hostname} outer:@cee:")
40
80
  end
41
81
  end
42
82
  end
83
+
84
+ describe "when the record cannot be serialized" do
85
+ let(:message) { { 'message' => 'x', 'n' => UnserializableValue.new } }
86
+
87
+ it "emits a placeholder in the same CEE/syslog framing" do
88
+ expect(formatted_message).to start_with("#{hostname} #{progname}:@cee:")
89
+ expect(formatted_message.count("\n")).to eq(1)
90
+ data = JSON.parse(formatted_message[/@cee:(.*)\Z/, 1])
91
+ expect(data['message']).to eq(LogStashLogger::Formatter::Base::FAILED_TO_FORMAT_MSG)
92
+ expect(data['tags']).to eq([LogStashLogger::Formatter::Base::FAILED_TO_FORMAT_TAG])
93
+ end
94
+ end
43
95
  end