log-courier 1.10.0 → 2.7.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/lib/log-courier/client.rb +141 -181
- data/lib/log-courier/client_tcp.rb +117 -87
- data/lib/log-courier/event_queue.rb +18 -23
- data/lib/log-courier/server.rb +32 -58
- data/lib/log-courier/server_tcp.rb +174 -120
- metadata +9 -26
- data/lib/log-courier/server_zmq.rb +0 -400
- data/lib/log-courier/zmq_qpoll.rb +0 -324
@@ -1,6 +1,4 @@
|
|
1
|
-
#
|
2
|
-
|
3
|
-
# Copyright 2014-2019 Jason Woods and Contributors.
|
1
|
+
# Copyright 2014-2021 Jason Woods and Contributors.
|
4
2
|
#
|
5
3
|
# This file is a modification of code from Logstash Forwarder.
|
6
4
|
# Copyright 2012-2013 Jordan Sissel and contributors.
|
@@ -19,7 +17,6 @@
|
|
19
17
|
|
20
18
|
require 'openssl'
|
21
19
|
require 'socket'
|
22
|
-
require 'thread'
|
23
20
|
|
24
21
|
module LogCourier
|
25
22
|
# Wrap around TCPServer to grab last error for use in reporting which peer had an error
|
@@ -42,12 +39,12 @@ module LogCourier
|
|
42
39
|
peer = sock.peeraddr
|
43
40
|
end
|
44
41
|
@peer = "#{peer[2]}:#{peer[1]}"
|
45
|
-
|
42
|
+
sock
|
46
43
|
end
|
47
44
|
|
48
45
|
def reset_peer
|
49
46
|
@peer = 'unknown'
|
50
|
-
|
47
|
+
nil
|
51
48
|
end
|
52
49
|
end
|
53
50
|
|
@@ -58,29 +55,31 @@ module LogCourier
|
|
58
55
|
# Create a new TLS transport endpoint
|
59
56
|
def initialize(options = {})
|
60
57
|
@options = {
|
61
|
-
logger:
|
62
|
-
transport:
|
63
|
-
port:
|
64
|
-
address:
|
65
|
-
ssl_certificate:
|
66
|
-
ssl_key:
|
67
|
-
ssl_key_passphrase:
|
68
|
-
ssl_verify:
|
58
|
+
logger: nil,
|
59
|
+
transport: 'tls',
|
60
|
+
port: 0,
|
61
|
+
address: '0.0.0.0',
|
62
|
+
ssl_certificate: nil,
|
63
|
+
ssl_key: nil,
|
64
|
+
ssl_key_passphrase: nil,
|
65
|
+
ssl_verify: false,
|
69
66
|
ssl_verify_default_ca: false,
|
70
|
-
ssl_verify_ca:
|
71
|
-
max_packet_size:
|
72
|
-
add_peer_fields:
|
67
|
+
ssl_verify_ca: nil,
|
68
|
+
max_packet_size: 10_485_760,
|
69
|
+
add_peer_fields: false,
|
70
|
+
min_tls_version: 1.2,
|
71
|
+
disable_handshake: false,
|
73
72
|
}.merge!(options)
|
74
73
|
|
75
74
|
@logger = @options[:logger]
|
76
75
|
|
77
76
|
if @options[:transport] == 'tls'
|
78
77
|
[:ssl_certificate, :ssl_key].each do |k|
|
79
|
-
|
78
|
+
raise "input/courier: '#{k}' is required" if @options[k].nil?
|
80
79
|
end
|
81
80
|
|
82
|
-
if @options[:ssl_verify]
|
83
|
-
|
81
|
+
if @options[:ssl_verify] && (!@options[:ssl_verify_default_ca] && @options[:ssl_verify_ca].nil?)
|
82
|
+
raise 'input/courier: Either \'ssl_verify_default_ca\' or \'ssl_verify_ca\' must be specified when ssl_verify is true'
|
84
83
|
end
|
85
84
|
end
|
86
85
|
|
@@ -99,8 +98,15 @@ module LogCourier
|
|
99
98
|
ssl.set_params
|
100
99
|
# Modify the default options to ensure SSLv2 and SSLv3 is disabled
|
101
100
|
# This retains any beneficial options set by default in the current Ruby implementation
|
102
|
-
|
103
|
-
|
101
|
+
# TODO: https://github.com/jruby/jruby-openssl/pull/215 is fixed in JRuby 9.3.0.0
|
102
|
+
# As of 7.15 Logstash, JRuby version is still 9.2
|
103
|
+
# Once 9.3 is in use we can switch to using min_version and max_version
|
104
|
+
ssl.options |= OpenSSL::SSL::OP_NO_SSLv2
|
105
|
+
ssl.options |= OpenSSL::SSL::OP_NO_SSLv3
|
106
|
+
ssl.options |= OpenSSL::SSL::OP_NO_TLSv1 if @options[:min_tls_version] > 1
|
107
|
+
ssl.options |= OpenSSL::SSL::OP_NO_TLSv1_1 if @options[:min_tls_version] > 1.1
|
108
|
+
ssl.options |= OpenSSL::SSL::OP_NO_TLSv1_2 if @options[:min_tls_version] > 1.2
|
109
|
+
raise 'Invalid min_tls_version - max is 1.3' if @options[:min_tls_version] > 1.3
|
104
110
|
|
105
111
|
# Set the certificate file
|
106
112
|
ssl.cert = OpenSSL::X509::Certificate.new(File.read(@options[:ssl_certificate]))
|
@@ -130,13 +136,11 @@ module LogCourier
|
|
130
136
|
@server = @tcp_server
|
131
137
|
end
|
132
138
|
|
133
|
-
if @options[:port]
|
134
|
-
|
135
|
-
end
|
136
|
-
rescue => e
|
139
|
+
@logger&.warn 'Ephemeral port allocated', transport: @options[:transport], port: @port if @options[:port].zero?
|
140
|
+
rescue StandardError, NativeException => e # Until Jruby updated we need NativeException
|
137
141
|
raise "input/courier: Failed to initialise: #{e}"
|
138
142
|
end
|
139
|
-
end
|
143
|
+
end
|
140
144
|
|
141
145
|
def run(&block)
|
142
146
|
client_threads = {}
|
@@ -148,14 +152,18 @@ module LogCourier
|
|
148
152
|
client = nil
|
149
153
|
begin
|
150
154
|
client = @server.accept
|
151
|
-
rescue
|
155
|
+
rescue OpenSSL::SSL::SSLError, IOError => e
|
152
156
|
# Accept failure or other issue
|
153
|
-
@logger
|
154
|
-
|
157
|
+
@logger&.warn 'Connection failed to accept', error: e.message, peer: @tcp_server.peer
|
158
|
+
begin
|
159
|
+
client&.close
|
160
|
+
rescue OpenSSL::SSL::SSLError, IOError
|
161
|
+
# Ignore IO error during close
|
162
|
+
end
|
155
163
|
next
|
156
164
|
end
|
157
165
|
|
158
|
-
|
166
|
+
@logger&.info 'New connection', peer: @tcp_server.peer
|
159
167
|
|
160
168
|
# Clear up finished threads
|
161
169
|
client_threads.delete_if do |_, thr|
|
@@ -167,13 +175,13 @@ module LogCourier
|
|
167
175
|
run_thread client_copy, peer_copy, &block
|
168
176
|
end
|
169
177
|
end
|
170
|
-
|
178
|
+
nil
|
171
179
|
rescue ShutdownSignal
|
172
|
-
|
173
|
-
rescue StandardError, NativeException => e
|
180
|
+
nil
|
181
|
+
rescue StandardError, NativeException => e # Can remove NativeException after 9.2.14.0 JRuby
|
174
182
|
# Some other unknown problem
|
175
|
-
@logger
|
176
|
-
|
183
|
+
@logger&.warn e.message, hint: 'Unknown error, shutting down'
|
184
|
+
nil
|
177
185
|
ensure
|
178
186
|
# Raise shutdown in all client threads and join then
|
179
187
|
client_threads.each do |_, thr|
|
@@ -188,25 +196,28 @@ module LogCourier
|
|
188
196
|
private
|
189
197
|
|
190
198
|
def run_thread(client, peer, &block)
|
191
|
-
|
192
|
-
|
193
|
-
|
199
|
+
# Perform the handshake inside the new thread so we don't block TCP accept
|
200
|
+
if @options[:transport] == 'tls'
|
201
|
+
begin
|
202
|
+
client.accept
|
203
|
+
rescue OpenSSL::SSL::SSLError, IOError => e
|
204
|
+
# Handshake failure or other issue
|
205
|
+
@logger&.warn 'Connection failed to initialise', error: e.message, peer: peer
|
194
206
|
begin
|
195
|
-
client.accept
|
196
|
-
rescue EOFError, OpenSSL::SSL::SSLError, IOError => e
|
197
|
-
# Handshake failure or other issue
|
198
|
-
@logger.warn 'Connection failed to initialise', :error => e.message, :peer => peer unless @logger.nil?
|
199
207
|
client.close
|
200
|
-
|
208
|
+
rescue OpenSSL::SSL::SSLError, IOError
|
209
|
+
# Ignore during close
|
201
210
|
end
|
211
|
+
return
|
202
212
|
end
|
203
213
|
|
204
|
-
|
205
|
-
rescue ShutdownSignal
|
206
|
-
# Shutting down
|
207
|
-
@logger.info 'Server shutting down, connection closed', :peer => peer unless @logger.nil?
|
208
|
-
return
|
214
|
+
@logger&.info 'Connection setup successfully', peer: peer, ssl_version: client.ssl_version
|
209
215
|
end
|
216
|
+
|
217
|
+
ConnectionTcp.new(@logger, client, peer, @options).run(&block)
|
218
|
+
rescue ShutdownSignal
|
219
|
+
# Shutting down
|
220
|
+
@logger&.info 'Server shutting down, connection closed', peer: peer
|
210
221
|
end
|
211
222
|
end
|
212
223
|
|
@@ -214,93 +225,105 @@ module LogCourier
|
|
214
225
|
class ConnectionTcp
|
215
226
|
attr_accessor :peer
|
216
227
|
|
217
|
-
def initialize(logger,
|
228
|
+
def initialize(logger, sfd, peer, options)
|
218
229
|
@logger = logger
|
219
|
-
@fd =
|
230
|
+
@fd = sfd
|
220
231
|
@peer = peer
|
221
232
|
@peer_fields = {}
|
222
233
|
@in_progress = false
|
223
234
|
@options = options
|
235
|
+
@client = 'Unknown'
|
236
|
+
@major_version = 0
|
237
|
+
@minor_version = 0
|
238
|
+
@patch_version = 0
|
239
|
+
@version = '0.0.0'
|
240
|
+
@client_version = 'Unknown'
|
224
241
|
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
242
|
+
return unless @options[:add_peer_fields]
|
243
|
+
|
244
|
+
@peer_fields['peer'] = peer
|
245
|
+
return unless @options[:transport] == 'tls' && !@fd.peer_cert.nil?
|
246
|
+
|
247
|
+
@peer_fields['peer_ssl_cn'] = get_cn(@fd.peer_cert)
|
231
248
|
end
|
232
249
|
|
233
250
|
def add_fields(event)
|
234
|
-
event.merge! @peer_fields
|
251
|
+
event.merge! @peer_fields unless @peer_fields.empty?
|
235
252
|
end
|
236
253
|
|
237
254
|
def run(&block)
|
238
|
-
|
239
|
-
rescue ShutdownSignal
|
240
|
-
# Shutting down
|
241
|
-
@logger.info 'Server shutting down, closing connection', :peer => @peer unless @logger.nil?
|
242
|
-
return
|
243
|
-
rescue StandardError, NativeException => e
|
244
|
-
# Some other unknown problem
|
245
|
-
@logger.warn e.message, :hint => 'Unknown error, connection aborted', :peer => @peer unless @logger.nil?
|
246
|
-
return
|
247
|
-
end
|
255
|
+
handshake(&block)
|
248
256
|
|
249
|
-
def process_messages
|
250
257
|
loop do
|
251
|
-
|
252
|
-
# Each message begins with a header
|
253
|
-
# 4 byte signature
|
254
|
-
# 4 byte length
|
255
|
-
# Normally we would not parse this inside transport, but for TLS we have to in order to locate frame boundaries
|
256
|
-
signature, length = recv(8).unpack('A4N')
|
257
|
-
|
258
|
-
# Sanity
|
259
|
-
if length > @options[:max_packet_size]
|
260
|
-
fail ProtocolError, "packet too large (#{length} > #{@options[:max_packet_size]})"
|
261
|
-
end
|
262
|
-
|
263
|
-
# While we're processing, EOF is bad as it may occur during send
|
264
|
-
@in_progress = true
|
265
|
-
|
266
|
-
# Read the message
|
267
|
-
if length == 0
|
268
|
-
data = ''
|
269
|
-
else
|
270
|
-
data = recv(length)
|
271
|
-
end
|
258
|
+
signature, data = receive
|
272
259
|
|
273
260
|
# Send for processing
|
274
261
|
yield signature, data, self
|
275
|
-
|
276
|
-
# If we EOF next it's a graceful close
|
277
|
-
@in_progress = false
|
278
262
|
end
|
279
263
|
rescue TimeoutError
|
280
264
|
# Timeout of the connection, we were idle too long without a ping/pong
|
281
|
-
@logger
|
282
|
-
|
265
|
+
@logger&.warn 'Connection timed out', peer: @peer
|
266
|
+
nil
|
283
267
|
rescue EOFError
|
284
268
|
if @in_progress
|
285
|
-
@logger
|
269
|
+
@logger&.warn 'Unexpected EOF', peer: @peer
|
286
270
|
else
|
287
|
-
@logger
|
271
|
+
@logger&.info 'Connection closed', peer: @peer
|
288
272
|
end
|
289
|
-
|
273
|
+
nil
|
290
274
|
rescue OpenSSL::SSL::SSLError => e
|
291
275
|
# Read errors, only action is to shutdown which we'll do in ensure
|
292
|
-
@logger
|
293
|
-
|
276
|
+
@logger&.warn 'SSL error, connection aborted', error: e.message, peer: @peer
|
277
|
+
nil
|
294
278
|
rescue IOError, Errno::ECONNRESET => e
|
295
279
|
# Read errors, only action is to shutdown which we'll do in ensure
|
296
|
-
@logger
|
297
|
-
|
280
|
+
@logger&.warn 'Connection aborted', error: e.message, peer: @peer
|
281
|
+
nil
|
298
282
|
rescue ProtocolError => e
|
299
283
|
# Connection abort request due to a protocol error
|
300
|
-
@logger
|
301
|
-
|
284
|
+
@logger&.warn 'Protocol error, connection aborted', error: e.message, peer: @peer
|
285
|
+
nil
|
286
|
+
rescue ShutdownSignal
|
287
|
+
# Shutting down
|
288
|
+
@logger&.info 'Server shutting down, closing connection', peer: @peer
|
289
|
+
nil
|
290
|
+
rescue StandardError, NativeException => e # Can remove NativeException after 9.2.14.0 JRuby
|
291
|
+
# Some other unknown problem
|
292
|
+
@logger&.warn e.message, hint: 'Unknown error, connection aborted', peer: @peer
|
293
|
+
nil
|
302
294
|
ensure
|
303
|
-
|
295
|
+
begin
|
296
|
+
@fd.close
|
297
|
+
rescue OpenSSL::SSL::SSLError, IOError, NativeException
|
298
|
+
# Ignore during close
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
def receive
|
303
|
+
# Read message
|
304
|
+
# Each message begins with a header
|
305
|
+
# 4 byte signature
|
306
|
+
# 4 byte length
|
307
|
+
# Normally we would not parse this inside transport, but for TLS we have to in order to locate frame boundaries
|
308
|
+
signature, length = recv(8).unpack('A4N')
|
309
|
+
|
310
|
+
# Sanity
|
311
|
+
raise ProtocolError, "packet too large (#{length} > #{@options[:max_packet_size]})" if length > @options[:max_packet_size]
|
312
|
+
|
313
|
+
# While we're processing, EOF is bad as it may occur during send
|
314
|
+
@in_progress = true
|
315
|
+
|
316
|
+
# Read the message
|
317
|
+
data = if length.zero?
|
318
|
+
''
|
319
|
+
else
|
320
|
+
recv(length)
|
321
|
+
end
|
322
|
+
|
323
|
+
# If we EOF next it's a graceful close
|
324
|
+
@in_progress = false
|
325
|
+
|
326
|
+
[signature, data]
|
304
327
|
end
|
305
328
|
|
306
329
|
def send(signature, message)
|
@@ -311,24 +334,55 @@ module LogCourier
|
|
311
334
|
begin
|
312
335
|
written = @fd.write_nonblock(data[done...data.length])
|
313
336
|
rescue IO::WaitReadable
|
314
|
-
|
337
|
+
raise TimeoutError if IO.select([@fd], nil, [@fd], @timeout - Time.now.to_i).nil?
|
338
|
+
|
315
339
|
retry
|
316
340
|
rescue IO::WaitWritable
|
317
|
-
|
341
|
+
raise TimeoutError if IO.select(nil, [@fd], [@fd], @timeout - Time.now.to_i).nil?
|
342
|
+
|
318
343
|
retry
|
319
344
|
end
|
320
|
-
|
345
|
+
raise ProtocolError, "write failure (#{done}/#{data.length})" if written.zero?
|
346
|
+
|
321
347
|
done += written
|
322
348
|
break if done >= data.length
|
323
349
|
end
|
324
|
-
|
350
|
+
nil
|
325
351
|
end
|
326
352
|
|
327
353
|
private
|
328
354
|
|
355
|
+
def handshake
|
356
|
+
return if @options[:disable_handshake]
|
357
|
+
|
358
|
+
signature, data = receive
|
359
|
+
if signature == 'JDAT'
|
360
|
+
@helo = Protocol.parse_helo_vers('')
|
361
|
+
@logger&.info 'Remote does not support protocol handshake', peer: @peer
|
362
|
+
yield signature, data, self
|
363
|
+
return
|
364
|
+
elsif signature != 'HELO'
|
365
|
+
raise ProtocolError, "unexpected #{signature} message"
|
366
|
+
end
|
367
|
+
|
368
|
+
@helo = Protocol.parse_helo_vers(data)
|
369
|
+
@logger&.info 'Remote identified', peer: @peer, client_version: @helo[:client_version]
|
370
|
+
|
371
|
+
# Flags 1 byte - EVNT flag = 0
|
372
|
+
# (Significant rewrite would be required to support streaming messages as currently we read
|
373
|
+
# first and then yield for processing. To support EVNT we have to move protocol parsing to
|
374
|
+
# the connection layer here so we can keep reading until we reach the end of the stream)
|
375
|
+
# Major Version 1 byte
|
376
|
+
# Minor Version 1 byte
|
377
|
+
# Patch Version 1 byte
|
378
|
+
# Client String 4 bytes
|
379
|
+
data = [1, 2, 7, 0, 'RYLC'].pack('CCCCA4')
|
380
|
+
send 'VERS', data
|
381
|
+
end
|
382
|
+
|
329
383
|
def get_cn(cert)
|
330
384
|
cert.subject.to_a.find do |oid, value|
|
331
|
-
return value if oid ==
|
385
|
+
return value if oid == 'CN'
|
332
386
|
end
|
333
387
|
nil
|
334
388
|
end
|
@@ -338,20 +392,20 @@ module LogCourier
|
|
338
392
|
have = ''
|
339
393
|
loop do
|
340
394
|
begin
|
341
|
-
|
395
|
+
buffer = @fd.read_nonblock need - have.length
|
342
396
|
rescue IO::WaitReadable
|
343
|
-
|
397
|
+
raise TimeoutError if IO.select([@fd], nil, [@fd], @timeout - Time.now.to_i).nil?
|
398
|
+
|
344
399
|
retry
|
345
400
|
rescue IO::WaitWritable
|
346
|
-
|
401
|
+
raise TimeoutError if IO.select(nil, [@fd], [@fd], @timeout - Time.now.to_i).nil?
|
402
|
+
|
347
403
|
retry
|
348
404
|
end
|
349
|
-
if buffer.nil?
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
end
|
354
|
-
if have.length == 0
|
405
|
+
raise EOFError if buffer.nil?
|
406
|
+
raise ProtocolError, "read failure (#{have.length}/#{need})" if buffer.length.zero?
|
407
|
+
|
408
|
+
if have.length.zero?
|
355
409
|
have = buffer
|
356
410
|
else
|
357
411
|
have << buffer
|
@@ -364,7 +418,7 @@ module LogCourier
|
|
364
418
|
def reset_timeout
|
365
419
|
# TODO: Make configurable
|
366
420
|
@timeout = Time.now.to_i + 1_800
|
367
|
-
|
421
|
+
nil
|
368
422
|
end
|
369
423
|
end
|
370
424
|
end
|
metadata
CHANGED
@@ -1,22 +1,22 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: log-courier
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 2.7.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jason Woods
|
8
|
-
autorequire:
|
8
|
+
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2021-10-20 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
|
-
name: cabin
|
15
14
|
requirement: !ruby/object:Gem::Requirement
|
16
15
|
requirements:
|
17
16
|
- - "~>"
|
18
17
|
- !ruby/object:Gem::Version
|
19
18
|
version: '0.6'
|
19
|
+
name: cabin
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
@@ -25,26 +25,12 @@ dependencies:
|
|
25
25
|
- !ruby/object:Gem::Version
|
26
26
|
version: '0.6'
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
|
-
name: ffi-rzmq
|
29
|
-
requirement: !ruby/object:Gem::Requirement
|
30
|
-
requirements:
|
31
|
-
- - "~>"
|
32
|
-
- !ruby/object:Gem::Version
|
33
|
-
version: '2.0'
|
34
|
-
type: :runtime
|
35
|
-
prerelease: false
|
36
|
-
version_requirements: !ruby/object:Gem::Requirement
|
37
|
-
requirements:
|
38
|
-
- - "~>"
|
39
|
-
- !ruby/object:Gem::Version
|
40
|
-
version: '2.0'
|
41
|
-
- !ruby/object:Gem::Dependency
|
42
|
-
name: multi_json
|
43
28
|
requirement: !ruby/object:Gem::Requirement
|
44
29
|
requirements:
|
45
30
|
- - "~>"
|
46
31
|
- !ruby/object:Gem::Version
|
47
32
|
version: '1.10'
|
33
|
+
name: multi_json
|
48
34
|
type: :runtime
|
49
35
|
prerelease: false
|
50
36
|
version_requirements: !ruby/object:Gem::Requirement
|
@@ -64,13 +50,11 @@ files:
|
|
64
50
|
- lib/log-courier/event_queue.rb
|
65
51
|
- lib/log-courier/server.rb
|
66
52
|
- lib/log-courier/server_tcp.rb
|
67
|
-
- lib/log-courier/server_zmq.rb
|
68
|
-
- lib/log-courier/zmq_qpoll.rb
|
69
53
|
homepage: https://github.com/driskell/ruby-log-courier
|
70
54
|
licenses:
|
71
|
-
- Apache
|
55
|
+
- Apache-2.0
|
72
56
|
metadata: {}
|
73
|
-
post_install_message:
|
57
|
+
post_install_message:
|
74
58
|
rdoc_options: []
|
75
59
|
require_paths:
|
76
60
|
- lib
|
@@ -85,9 +69,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
85
69
|
- !ruby/object:Gem::Version
|
86
70
|
version: '0'
|
87
71
|
requirements: []
|
88
|
-
|
89
|
-
|
90
|
-
signing_key:
|
72
|
+
rubygems_version: 3.0.6
|
73
|
+
signing_key:
|
91
74
|
specification_version: 4
|
92
75
|
summary: Ruby implementation of the Courier protocol
|
93
76
|
test_files: []
|