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.
- checksums.yaml +4 -4
- data/.github/workflows/tests.yml +5 -3
- data/Appraisals +2 -4
- data/CHANGELOG.md +33 -0
- data/Gemfile +2 -1
- data/README.md +2 -2
- data/docs/buffering.md +16 -0
- data/docs/outputs.md +23 -0
- data/docs/rails.md +7 -2
- data/docs/ssl.md +49 -37
- data/docs/troubleshooting.md +22 -0
- data/gemfiles/rails_8.0.gemfile +3 -1
- data/gemfiles/rails_8.1.gemfile +2 -1
- data/lib/logstash-logger/buffer.rb +15 -4
- data/lib/logstash-logger/device/aws_stream.rb +96 -17
- data/lib/logstash-logger/device/connectable.rb +27 -1
- data/lib/logstash-logger/device/firehose.rb +6 -0
- data/lib/logstash-logger/device/http.rb +75 -4
- data/lib/logstash-logger/device/kafka.rb +27 -0
- data/lib/logstash-logger/device/kinesis.rb +10 -1
- data/lib/logstash-logger/device/tcp.rb +53 -7
- data/lib/logstash-logger/formatter/base.rb +44 -3
- data/lib/logstash-logger/formatter/cee_syslog.rb +8 -11
- data/lib/logstash-logger/version.rb +1 -1
- data/logstash-logger.gemspec +2 -6
- data/spec/device/aws_stream_limits_spec.rb +219 -0
- data/spec/device/connectable_spec.rb +51 -0
- data/spec/device/firehose_spec.rb +4 -1
- data/spec/device/http_spec.rb +166 -1
- data/spec/device/kafka_spec.rb +92 -0
- data/spec/device/kinesis_spec.rb +4 -1
- data/spec/device/tcp_spec.rb +94 -5
- data/spec/device/tcp_tls_integration_spec.rb +91 -0
- data/spec/formatter/base_spec.rb +42 -6
- data/spec/formatter/cee_syslog_spec.rb +71 -19
- data/spec/formatter/framing_spec.rb +58 -0
- data/spec/formatter/json_lines_spec.rb +18 -0
- data/spec/spec_helper.rb +5 -0
- data/spec/support/tls_helper.rb +30 -0
- data/spec/support/unserializable_value.rb +10 -0
- metadata +19 -7
- data/gemfiles/rails_7.2.gemfile +0 -8
data/spec/device/http_spec.rb
CHANGED
|
@@ -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
|
data/spec/device/kafka_spec.rb
CHANGED
|
@@ -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
|
data/spec/device/kinesis_spec.rb
CHANGED
|
@@ -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).
|
|
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
|
data/spec/device/tcp_spec.rb
CHANGED
|
@@ -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 '
|
|
107
|
-
|
|
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(:
|
|
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 '
|
|
115
|
-
|
|
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
|
data/spec/formatter/base_spec.rb
CHANGED
|
@@ -24,19 +24,55 @@ describe LogStashLogger::Formatter::Base do
|
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
context "when
|
|
27
|
+
context "when building the event raises" do
|
|
28
28
|
before do
|
|
29
|
-
allow(subject).to receive(:error_logger).and_return(Logger.new(
|
|
30
|
-
allow(subject).to receive(:
|
|
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
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
|
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 "
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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 "
|
|
36
|
-
let(:
|
|
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 "
|
|
39
|
-
expect(subject.
|
|
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
|