dongjia_ritm 1.0.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/ritm/certs/ca.rb +41 -0
- data/lib/ritm/certs/certificate.rb +48 -0
- data/lib/ritm/configuration.rb +74 -0
- data/lib/ritm/dispatcher.rb +37 -0
- data/lib/ritm/helpers/encodings.rb +63 -0
- data/lib/ritm/helpers/patches.rb +58 -0
- data/lib/ritm/helpers/utils.rb +15 -0
- data/lib/ritm/interception/handlers.rb +12 -0
- data/lib/ritm/interception/http_forwarder.rb +80 -0
- data/lib/ritm/interception/intercept_utils.rb +82 -0
- data/lib/ritm/interception/request_interceptor_servlet.rb +15 -0
- data/lib/ritm/main.rb +17 -0
- data/lib/ritm/proxy/cert_signing_https_server.rb +67 -0
- data/lib/ritm/proxy/launcher.rb +70 -0
- data/lib/ritm/proxy/proxy_server.rb +57 -0
- data/lib/ritm/proxy/ssl_reverse_proxy.rb +57 -0
- data/lib/ritm/session.rb +57 -0
- data/lib/ritm/version.rb +3 -0
- data/lib/ritm.rb +2 -0
- metadata +229 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: a153a707f90726c604a8ee1d7f4b5c8b56b46bdfbe5508b3eaa73ccd83c4e752
|
4
|
+
data.tar.gz: db8814c5492caafa00c56334520c24eba69efc81d5e85482cc1479ca6ac71ffe
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 66da915d817a478faaa7e7551ef1a9e7f0798a5bccd980d7e593b9143511d444f9bb19ca2d6474f929767abc3d2b4ed3bb1a65ffe51f092d8e4b8ee73b721bd7
|
7
|
+
data.tar.gz: fa0b5609a8c211da90b70748b70a301577200993d842c9ca753ad3a8c52bc1a00f133db7f57abcb0f5ff327fdb3402fe23fb7dcfbd11c1a6916d70c72003e35f
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'certificate_authority'
|
2
|
+
require 'ritm/certs/certificate'
|
3
|
+
|
4
|
+
module Ritm
|
5
|
+
# Wrapper on a Certificate Authority with ability of signing certificates
|
6
|
+
class CA < Ritm::Certificate
|
7
|
+
def self.create(common_name: 'RubyInTheMiddle')
|
8
|
+
super(common_name, serial_number: 1) do |cert|
|
9
|
+
cert.signing_entity = true
|
10
|
+
cert.sign!(ca_signing_profile)
|
11
|
+
yield cert if block_given?
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.load(crt, private_key)
|
16
|
+
super(crt, private_key) do |cert|
|
17
|
+
cert.signing_entity = true
|
18
|
+
cert.sign!(ca_signing_profile)
|
19
|
+
yield cert if block_given?
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def sign(certificate)
|
24
|
+
certificate.cert.parent = @cert
|
25
|
+
certificate.cert.sign!(self.class.signing_profile)
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.signing_profile
|
29
|
+
{
|
30
|
+
'extensions' => {
|
31
|
+
'keyUsage' => { 'usage' => %w[keyEncipherment digitalSignature] },
|
32
|
+
'extendedKeyUsage' => { 'usage' => %w[serverAuth clientAuth] }
|
33
|
+
}
|
34
|
+
}
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.ca_signing_profile
|
38
|
+
{ 'extensions' => { 'keyUsage' => { 'usage' => %w[critical keyCertSign keyEncipherment digitalSignature] } } }
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'certificate_authority'
|
2
|
+
|
3
|
+
module Ritm
|
4
|
+
# Wraps a SSL Certificate via on-the-fly creation or loading from files
|
5
|
+
class Certificate
|
6
|
+
attr_accessor :cert
|
7
|
+
|
8
|
+
def self.load(crt, private_key)
|
9
|
+
x509 = OpenSSL::X509::Certificate.new(crt)
|
10
|
+
cert = CertificateAuthority::Certificate.from_openssl(x509)
|
11
|
+
cert.key_material.private_key = OpenSSL::PKey::RSA.new(private_key)
|
12
|
+
yield cert if block_given?
|
13
|
+
new cert
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.create(common_name, serial_number: nil)
|
17
|
+
cert = CertificateAuthority::Certificate.new
|
18
|
+
cert.subject.common_name = common_name
|
19
|
+
cert.subject.organization = cert.subject.organizational_unit = 'RubyInTheMiddle'
|
20
|
+
cert.subject.country = 'AR'
|
21
|
+
cert.not_before = cert.not_before - 3600 * 24 * 30 # Substract 30 days
|
22
|
+
cert.serial_number.number = serial_number || common_name.hash.abs
|
23
|
+
cert.key_material.generate_key(2048)
|
24
|
+
yield cert if block_given?
|
25
|
+
new cert
|
26
|
+
end
|
27
|
+
|
28
|
+
def initialize(cert)
|
29
|
+
@cert = cert
|
30
|
+
end
|
31
|
+
|
32
|
+
def private_key
|
33
|
+
@cert.key_material.private_key
|
34
|
+
end
|
35
|
+
|
36
|
+
def public_key
|
37
|
+
@cert.key_material.public_key
|
38
|
+
end
|
39
|
+
|
40
|
+
def pem
|
41
|
+
@cert.to_pem
|
42
|
+
end
|
43
|
+
|
44
|
+
def x509
|
45
|
+
@cert.openssl_body
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
require 'dot_hash'
|
2
|
+
require 'set'
|
3
|
+
|
4
|
+
module Ritm
|
5
|
+
class Configuration
|
6
|
+
def default_settings # rubocop:disable Metrics/MethodLength
|
7
|
+
{
|
8
|
+
proxy: {
|
9
|
+
bind_address: '127.0.0.1',
|
10
|
+
bind_port: 8080
|
11
|
+
},
|
12
|
+
|
13
|
+
ssl_reverse_proxy: {
|
14
|
+
bind_address: '127.0.0.1',
|
15
|
+
bind_port: 8081,
|
16
|
+
ca: {
|
17
|
+
pem: nil,
|
18
|
+
key: nil
|
19
|
+
}
|
20
|
+
},
|
21
|
+
|
22
|
+
intercept: {
|
23
|
+
enabled: true,
|
24
|
+
request: {
|
25
|
+
add_headers: {},
|
26
|
+
strip_headers: [/proxy-*/],
|
27
|
+
unpack_gzip_deflate: true,
|
28
|
+
update_content_length: true
|
29
|
+
},
|
30
|
+
response: {
|
31
|
+
add_headers: { 'connection' => 'close' },
|
32
|
+
strip_headers: ['strict-transport-security'],
|
33
|
+
unpack_gzip_deflate: true,
|
34
|
+
update_content_length: true
|
35
|
+
},
|
36
|
+
process_chunked_encoded_transfer: true
|
37
|
+
},
|
38
|
+
|
39
|
+
misc: {
|
40
|
+
ssl_pass_through: [],
|
41
|
+
upstream_proxy: nil
|
42
|
+
}
|
43
|
+
}
|
44
|
+
end
|
45
|
+
|
46
|
+
def initialize
|
47
|
+
reset
|
48
|
+
end
|
49
|
+
|
50
|
+
def reset
|
51
|
+
@settings = default_settings.to_properties
|
52
|
+
end
|
53
|
+
|
54
|
+
def method_missing(m, *args, &block)
|
55
|
+
if @settings.respond_to?(m)
|
56
|
+
@settings.send(m, *args, &block)
|
57
|
+
else
|
58
|
+
super
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def enable
|
63
|
+
@settings.intercept[:enabled] = true
|
64
|
+
end
|
65
|
+
|
66
|
+
def disable
|
67
|
+
@settings.intercept[:enabled] = false
|
68
|
+
end
|
69
|
+
|
70
|
+
def respond_to_missing?(method_name, _include_private = false)
|
71
|
+
@settings.respond_to?(method_name) || super
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module Ritm
|
2
|
+
# Keeps a list of subscribers and notifies them when requests/responses are intercepted
|
3
|
+
class Dispatcher
|
4
|
+
def initialize
|
5
|
+
@handlers = { on_request: [], on_response: [] }
|
6
|
+
end
|
7
|
+
|
8
|
+
def add_handler(handler)
|
9
|
+
on_request { |*args| handler.on_request(*args) } if handler.respond_to? :on_request
|
10
|
+
on_response { |*args| handler.on_response(*args) } if handler.respond_to? :on_response
|
11
|
+
end
|
12
|
+
|
13
|
+
def on_request(&block)
|
14
|
+
@handlers[:on_request] << block
|
15
|
+
end
|
16
|
+
|
17
|
+
def on_response(&block)
|
18
|
+
@handlers[:on_response] << block
|
19
|
+
end
|
20
|
+
|
21
|
+
def notify_request(request)
|
22
|
+
notify(:on_request, request)
|
23
|
+
end
|
24
|
+
|
25
|
+
def notify_response(request, response)
|
26
|
+
notify(:on_response, request, response)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def notify(event, *args)
|
32
|
+
@handlers[event].each do |handler|
|
33
|
+
handler.call(*args)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
require 'zlib'
|
2
|
+
|
3
|
+
module Ritm
|
4
|
+
module Encodings
|
5
|
+
ENCODINGS = %i[identity gzip deflate].freeze
|
6
|
+
|
7
|
+
def self.encode(encoding, data)
|
8
|
+
case encoding
|
9
|
+
when :gzip
|
10
|
+
encode_gzip(data)
|
11
|
+
when :deflate
|
12
|
+
encode_deflate(data)
|
13
|
+
when :identity
|
14
|
+
identity(data)
|
15
|
+
else
|
16
|
+
raise "Unsupported encoding #{encoding}"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.decode(encoding, data)
|
21
|
+
case encoding
|
22
|
+
when :gzip
|
23
|
+
decode_gzip(data)
|
24
|
+
when :deflate
|
25
|
+
decode_deflate(data)
|
26
|
+
when :identity
|
27
|
+
identity(data)
|
28
|
+
else
|
29
|
+
raise "Unsupported encoding #{encoding}"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
class << self
|
34
|
+
private
|
35
|
+
|
36
|
+
def identity(data)
|
37
|
+
data
|
38
|
+
end
|
39
|
+
|
40
|
+
def encode_gzip(data)
|
41
|
+
wio = StringIO.new('wb')
|
42
|
+
w_gz = Zlib::GzipWriter.new(wio)
|
43
|
+
w_gz.write(data)
|
44
|
+
w_gz.close
|
45
|
+
wio.string
|
46
|
+
end
|
47
|
+
|
48
|
+
def decode_gzip(data)
|
49
|
+
io = StringIO.new(data, 'rb')
|
50
|
+
gz = Zlib::GzipReader.new(io)
|
51
|
+
gz.read
|
52
|
+
end
|
53
|
+
|
54
|
+
def encode_deflate(data)
|
55
|
+
Zlib::Deflate.deflate(data)
|
56
|
+
end
|
57
|
+
|
58
|
+
def decode_deflate(data)
|
59
|
+
Zlib::Inflate.inflate(data)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'openssl'
|
2
|
+
require 'webrick'
|
3
|
+
require 'webrick/httpproxy'
|
4
|
+
require 'ritm/helpers/utils'
|
5
|
+
|
6
|
+
# Patch WEBrick too short max uri length
|
7
|
+
Ritm::Utils.silence_warnings { WEBrick::HTTPRequest::MAX_URI_LENGTH = 4000 }
|
8
|
+
|
9
|
+
# Digest::Digest is deprecated. This has been fixed in certificate_authority master
|
10
|
+
# but the project is no longer maintained and the fixed version was never published
|
11
|
+
Ritm::Utils.silence_warnings { OpenSSL::Digest::Digest = OpenSSL::Digest }
|
12
|
+
|
13
|
+
# Make request method writable
|
14
|
+
WEBrick::HTTPRequest.instance_eval { attr_accessor :request_method, :unparsed_uri }
|
15
|
+
|
16
|
+
module WEBrick
|
17
|
+
# Other patches to WEBrick::HTTPRequest
|
18
|
+
class HTTPRequest
|
19
|
+
def []=(header_name, *values)
|
20
|
+
@header[header_name.downcase] = values.map(&:to_s)
|
21
|
+
end
|
22
|
+
|
23
|
+
def body=(str)
|
24
|
+
body # Read current body
|
25
|
+
@body = str
|
26
|
+
end
|
27
|
+
|
28
|
+
def request_uri=(uri)
|
29
|
+
@request_uri = parse_uri(uri.to_s)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Support other methods in HTTPServer
|
34
|
+
class HTTPServer
|
35
|
+
def do_DELETE(req, res)
|
36
|
+
perform_proxy_request(req, res) do |http, path, header|
|
37
|
+
http.delete(path, header)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def do_PUT(req, res)
|
42
|
+
perform_proxy_request(req, res) do |http, path, header|
|
43
|
+
http.put(path, req.body || '', header)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def do_PATCH(req, res)
|
48
|
+
perform_proxy_request(req, res) do |http, path, header|
|
49
|
+
http.patch(path, req.body || '', header)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# TODO: make sure options gets proxied too (so trace)
|
54
|
+
def do_OPTIONS(_req, res)
|
55
|
+
res['allow'] = 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS,CONNECT'
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
|
2
|
+
module Ritm
|
3
|
+
# Shortcuts and other utilities to be included
|
4
|
+
# in modules/classes
|
5
|
+
module Utils
|
6
|
+
# Runs a block of code without warnings.
|
7
|
+
def self.silence_warnings
|
8
|
+
warn_level = $VERBOSE
|
9
|
+
$VERBOSE = nil
|
10
|
+
result = yield
|
11
|
+
$VERBOSE = warn_level
|
12
|
+
result
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
|
2
|
+
def default_request_handler(session)
|
3
|
+
proc do |req|
|
4
|
+
session.dispatcher.notify_request(req) if session.conf.intercept.enabled
|
5
|
+
end
|
6
|
+
end
|
7
|
+
|
8
|
+
def default_response_handler(session)
|
9
|
+
proc do |req, res|
|
10
|
+
session.dispatcher.notify_response(req, res) if session.conf.intercept.enabled
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
require 'faraday'
|
2
|
+
require 'ritm/interception/intercept_utils'
|
3
|
+
require 'webrick/cookie'
|
4
|
+
|
5
|
+
module Ritm
|
6
|
+
# Forwarder that acts as a WEBrick <-> Faraday adaptor: Works this way:
|
7
|
+
# 1. A WEBrick request objects is received
|
8
|
+
# 2. The WEBrick request object is sent to the request interceptor
|
9
|
+
# 3. The (maybe modified) WEBrick request object is transformed into a Faraday request and sent to destination server
|
10
|
+
# 4. The Faraday response obtained from the server is transformed into a WEBrick response
|
11
|
+
# 5. The WEBrick response object is sent to the response interceptor
|
12
|
+
# 6. The (maybe modified) WEBrick response object is sent back to the client
|
13
|
+
#
|
14
|
+
# Besides the possible modifications to be done by interceptors there might be automated globally configured
|
15
|
+
# transformations like header stripping/adding.
|
16
|
+
|
17
|
+
class ParamEncoder
|
18
|
+
def encode(params)
|
19
|
+
pairs = params.map { |k, v| "#{k}=#{v}" }
|
20
|
+
pairs.join('&')
|
21
|
+
end
|
22
|
+
|
23
|
+
def decode(query)
|
24
|
+
query.split('&').each_with_object({}) do |param, params|
|
25
|
+
k, v = param.split('=')
|
26
|
+
params[k] = v
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
class HTTPForwarder
|
32
|
+
include InterceptUtils
|
33
|
+
|
34
|
+
def initialize(request_interceptor, response_interceptor, context_config)
|
35
|
+
@request_interceptor = request_interceptor
|
36
|
+
@response_interceptor = response_interceptor
|
37
|
+
@config = context_config
|
38
|
+
# TODO: make SSL verification a configuration setting
|
39
|
+
@client = Faraday.new(
|
40
|
+
ssl: { verify: false },
|
41
|
+
request: { params_encoder: ParamEncoder.new }
|
42
|
+
) do |conn|
|
43
|
+
conn.adapter :net_http
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def forward(request, response)
|
48
|
+
intercept_request(@request_interceptor, request, @config.intercept.request)
|
49
|
+
faraday_response = faraday_forward request
|
50
|
+
to_webrick_response faraday_response, response
|
51
|
+
intercept_response(@response_interceptor, request, response, @config.intercept.response)
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def faraday_forward(request)
|
57
|
+
req_method = request.request_method.downcase
|
58
|
+
@client.send req_method do |req|
|
59
|
+
req.options[:proxy] = @config.misc.upstream_proxy
|
60
|
+
req.url request.request_uri
|
61
|
+
req.body = request.body
|
62
|
+
request.header.each { |name, value| req.headers[name] = value }
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def to_webrick_response(faraday_response, webrick_response)
|
67
|
+
webrick_response.status = faraday_response.status
|
68
|
+
webrick_response.body = faraday_response.body
|
69
|
+
faraday_response.headers.each do |name, value|
|
70
|
+
case name
|
71
|
+
when 'set-cookie'
|
72
|
+
WEBrick::Cookie.parse_set_cookies(value).each { |cookie| webrick_response.cookies << cookie }
|
73
|
+
else
|
74
|
+
webrick_response[name] = value
|
75
|
+
end
|
76
|
+
end
|
77
|
+
webrick_response
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
require 'ritm/helpers/encodings'
|
2
|
+
|
3
|
+
module Ritm
|
4
|
+
# Interceptor callbacks calling logic shared by the HTTP Proxy Server and the SSL Reverse Proxy Server
|
5
|
+
module InterceptUtils
|
6
|
+
def intercept_request(handler, request, intercept_request_settings)
|
7
|
+
return if handler.nil?
|
8
|
+
preprocess(request, intercept_request_settings)
|
9
|
+
handler.call(request)
|
10
|
+
postprocess(request, intercept_request_settings)
|
11
|
+
end
|
12
|
+
|
13
|
+
def intercept_response(handler, request, response, intercept_response_settings)
|
14
|
+
return if handler.nil?
|
15
|
+
preprocess(response, intercept_response_settings)
|
16
|
+
handler.call(request, response)
|
17
|
+
postprocess(response, intercept_response_settings)
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def preprocess(req_res, settings)
|
23
|
+
headers = header_obj(req_res)
|
24
|
+
decode(req_res) if settings.unpack_gzip_deflate
|
25
|
+
req_res.header.delete_if { |name, _v| strip?(name, settings.strip_headers) }
|
26
|
+
settings.add_headers.each { |name, value| headers[name] = value }
|
27
|
+
req_res.header.delete('transfer-encoding') if chunked?(headers)
|
28
|
+
end
|
29
|
+
|
30
|
+
def postprocess(req_res, settings)
|
31
|
+
header_obj(req_res)['content-length'] = (req_res.body || '').bytesize.to_s if settings.update_content_length
|
32
|
+
end
|
33
|
+
|
34
|
+
def chunked?(headers)
|
35
|
+
headers['transfer-encoding'] && headers['transfer-encoding'].casecmp('chunked')
|
36
|
+
end
|
37
|
+
|
38
|
+
def content_encoding(req_res)
|
39
|
+
ce = header_obj(req_res)['content-encoding'] || ''
|
40
|
+
case ce.downcase
|
41
|
+
when 'gzip', 'x-gzip'
|
42
|
+
:gzip
|
43
|
+
when 'deflate'
|
44
|
+
:deflate
|
45
|
+
else
|
46
|
+
:identity
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def header_obj(req_res)
|
51
|
+
case req_res
|
52
|
+
when WEBrick::HTTPRequest
|
53
|
+
req_res
|
54
|
+
when WEBrick::HTTPResponse
|
55
|
+
req_res.header
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def decode(req_res)
|
60
|
+
encoding = content_encoding(req_res)
|
61
|
+
return if encoding == :identity
|
62
|
+
|
63
|
+
req_res.body = Encodings.decode(encoding, req_res.body)
|
64
|
+
req_res.header.delete('content-encoding')
|
65
|
+
headers = header_obj(req_res)
|
66
|
+
headers['content-length'] = (req_res.body || '').bytesize.to_s
|
67
|
+
end
|
68
|
+
|
69
|
+
def strip?(header, rules)
|
70
|
+
header = header.to_s.downcase
|
71
|
+
rules.each do |rule|
|
72
|
+
case rule
|
73
|
+
when String
|
74
|
+
return true if header == rule
|
75
|
+
when Regexp
|
76
|
+
return true if header =~ rule
|
77
|
+
end
|
78
|
+
end
|
79
|
+
false
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require 'webrick'
|
2
|
+
|
3
|
+
module Ritm
|
4
|
+
# Actual implementation of the SSL Reverse Proxy service (decoupled from the certificate handling)
|
5
|
+
class RequestInterceptorServlet < WEBrick::HTTPServlet::AbstractServlet
|
6
|
+
def initialize(server, forwarder)
|
7
|
+
super server
|
8
|
+
@forwarder = forwarder
|
9
|
+
end
|
10
|
+
|
11
|
+
def service(request, response)
|
12
|
+
@forwarder.forward(request, response)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
data/lib/ritm/main.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
require 'ritm/session'
|
2
|
+
|
3
|
+
module Ritm
|
4
|
+
GLOBAL_SESSION = Session.new
|
5
|
+
|
6
|
+
def self.method_missing(m, *args, &block)
|
7
|
+
if GLOBAL_SESSION.respond_to?(m)
|
8
|
+
GLOBAL_SESSION.send(m, *args, &block)
|
9
|
+
else
|
10
|
+
super
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.respond_to_missing?(method_name, _include_private = false)
|
15
|
+
GLOBAL_SESSION.respond_to?(method_name) || super
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
require 'openssl'
|
2
|
+
require 'webrick'
|
3
|
+
require 'webrick/https'
|
4
|
+
require 'ritm/certs/certificate'
|
5
|
+
|
6
|
+
IS_RUBY_2_4_OR_OLDER = Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('2.4')
|
7
|
+
|
8
|
+
module Ritm
|
9
|
+
module Proxy
|
10
|
+
# Patches WEBrick::HTTPServer SSL context creation to get
|
11
|
+
# a callback on the 'Client Helo' step of the SSL-Handshake if SNI is specified
|
12
|
+
# So RitM can create self-signed certificates on the fly
|
13
|
+
class CertSigningHTTPSServer < WEBrick::HTTPServer
|
14
|
+
# Override
|
15
|
+
def setup_ssl_context(config)
|
16
|
+
ctx = super(config)
|
17
|
+
prepare_sni_callback(ctx, config[:ca])
|
18
|
+
ctx
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
# Keeps track of the created self-signed certificates
|
24
|
+
# TODO: this can grow a lot and take up memory, fix by either:
|
25
|
+
# 1. implementing wildcard certificates generation (so there's one certificate per top level domain)
|
26
|
+
# 2. Use the same key material (private/public keys) for all the server names and just do the signing on-the-fly
|
27
|
+
# 3. both of the above
|
28
|
+
def prepare_sni_callback(ctx, ca)
|
29
|
+
contexts = {}
|
30
|
+
mutex = Mutex.new
|
31
|
+
|
32
|
+
# Sets the SNI callback on the SSLTCPSocket
|
33
|
+
ctx.servername_cb = proc do |sock, servername|
|
34
|
+
mutex.synchronize do
|
35
|
+
unless contexts.include? servername
|
36
|
+
cert = Ritm::Certificate.create(servername)
|
37
|
+
cert.cert.extensions['subjectAltName'].dns_names = [servername]
|
38
|
+
ca.sign(cert)
|
39
|
+
contexts[servername] = context_with_cert(sock.context, cert)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
contexts[servername]
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def context_with_cert(original_ctx, cert)
|
47
|
+
ctx = duplicate_context(original_ctx)
|
48
|
+
ctx.key = cert.private_key
|
49
|
+
ctx.cert = cert.x509
|
50
|
+
ctx
|
51
|
+
end
|
52
|
+
|
53
|
+
def duplicate_context(original_ctx)
|
54
|
+
return original_ctx.dup unless IS_RUBY_2_4_OR_OLDER
|
55
|
+
|
56
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
57
|
+
|
58
|
+
original_ctx.instance_variables.each do |variable_name|
|
59
|
+
prop_name = variable_name.to_s.sub(/^@/, '')
|
60
|
+
set_prop_method = "#{prop_name}="
|
61
|
+
ctx.send(set_prop_method, original_ctx.send(prop_name)) if ctx.respond_to? set_prop_method
|
62
|
+
end
|
63
|
+
ctx
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
require 'ritm/proxy/ssl_reverse_proxy'
|
2
|
+
require 'ritm/proxy/proxy_server'
|
3
|
+
require 'ritm/certs/ca'
|
4
|
+
require 'ritm/interception/handlers'
|
5
|
+
require 'ritm/interception/http_forwarder'
|
6
|
+
|
7
|
+
module Ritm
|
8
|
+
module Proxy
|
9
|
+
# Launches the Proxy server and the SSL Reverse Proxy with the given settings
|
10
|
+
class Launcher
|
11
|
+
def initialize(session)
|
12
|
+
build_settings(session)
|
13
|
+
build_reverse_proxy
|
14
|
+
build_proxy
|
15
|
+
end
|
16
|
+
|
17
|
+
def start
|
18
|
+
@https.start_async
|
19
|
+
@http.start_async
|
20
|
+
end
|
21
|
+
|
22
|
+
def shutdown
|
23
|
+
@https.shutdown
|
24
|
+
@http.shutdown
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def build_settings(session)
|
30
|
+
@conf = session.conf
|
31
|
+
ssl_proxy_host = @conf.ssl_reverse_proxy.bind_address
|
32
|
+
ssl_proxy_port = @conf.ssl_reverse_proxy.bind_port
|
33
|
+
@https_forward = "#{ssl_proxy_host}:#{ssl_proxy_port}"
|
34
|
+
|
35
|
+
request_interceptor = default_request_handler(session)
|
36
|
+
response_interceptor = default_response_handler(session)
|
37
|
+
@forwarder = HTTPForwarder.new(request_interceptor, response_interceptor, @conf)
|
38
|
+
|
39
|
+
crt_path = @conf.ssl_reverse_proxy.ca.pem
|
40
|
+
key_path = @conf.ssl_reverse_proxy.ca.key
|
41
|
+
@certificate = ca_certificate(crt_path, key_path)
|
42
|
+
end
|
43
|
+
|
44
|
+
def build_proxy
|
45
|
+
@http = Ritm::Proxy::ProxyServer.new(BindAddress: @conf.proxy.bind_address,
|
46
|
+
Port: @conf.proxy.bind_port,
|
47
|
+
AccessLog: [],
|
48
|
+
Logger: WEBrick::Log.new(File.open(File::NULL, 'w')),
|
49
|
+
https_forward: @https_forward,
|
50
|
+
ProxyVia: nil,
|
51
|
+
forwarder: @forwarder,
|
52
|
+
ritm_conf: @conf)
|
53
|
+
end
|
54
|
+
|
55
|
+
def build_reverse_proxy
|
56
|
+
@https = Ritm::Proxy::SSLReverseProxy.new(@conf.ssl_reverse_proxy.bind_port,
|
57
|
+
@certificate,
|
58
|
+
@forwarder)
|
59
|
+
end
|
60
|
+
|
61
|
+
def ca_certificate(pem, key)
|
62
|
+
if pem.nil? || key.nil?
|
63
|
+
Ritm::CA.create
|
64
|
+
else
|
65
|
+
Ritm::CA.load(File.read(pem), File.read(key))
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
require 'webrick'
|
2
|
+
require 'webrick/httpproxy'
|
3
|
+
require 'ritm/helpers/patches'
|
4
|
+
|
5
|
+
module Ritm
|
6
|
+
module Proxy
|
7
|
+
# Proxy server that accepts request and response intercept handlers for HTTP traffic
|
8
|
+
# HTTPS traffic is redirected to the SSLReverseProxy for interception
|
9
|
+
class ProxyServer < WEBrick::HTTPProxyServer
|
10
|
+
def start_async
|
11
|
+
trap(:TERM) { shutdown }
|
12
|
+
trap(:INT) { shutdown }
|
13
|
+
Thread.new { start }
|
14
|
+
end
|
15
|
+
|
16
|
+
# Override
|
17
|
+
# Patches the destination address on HTTPS connections to go via the HTTPS Reverse Proxy
|
18
|
+
def do_CONNECT(req, res)
|
19
|
+
req.unparsed_uri = @config[:https_forward] unless ssl_pass_through? req.unparsed_uri
|
20
|
+
super
|
21
|
+
end
|
22
|
+
|
23
|
+
# Override
|
24
|
+
# Handles HTTP (no SSL) traffic interception
|
25
|
+
def proxy_service(req, res)
|
26
|
+
# Proxy Authentication
|
27
|
+
proxy_auth(req, res)
|
28
|
+
@config[:forwarder].forward(req, res)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Override
|
32
|
+
def proxy_uri(req, _res)
|
33
|
+
if req.request_method == 'CONNECT'
|
34
|
+
# Let the reverse proxy handle upstream proxies for https
|
35
|
+
nil
|
36
|
+
else
|
37
|
+
proxy = @config[:ritm_conf].misc.upstream_proxy
|
38
|
+
proxy.nil? ? nil : URI.parse(proxy)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def ssl_pass_through?(destination)
|
45
|
+
@config[:ritm_conf].misc.ssl_pass_through.each do |matcher|
|
46
|
+
case matcher
|
47
|
+
when String
|
48
|
+
return true if destination == matcher
|
49
|
+
when Regexp
|
50
|
+
return true if destination =~ matcher
|
51
|
+
end
|
52
|
+
end
|
53
|
+
false
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
require 'ritm/helpers/patches'
|
2
|
+
require 'ritm/interception/request_interceptor_servlet'
|
3
|
+
require 'ritm/proxy/cert_signing_https_server'
|
4
|
+
require 'ritm/certs/certificate'
|
5
|
+
|
6
|
+
module Ritm
|
7
|
+
module Proxy
|
8
|
+
# SSL Intercept reverse proxy server. Supports interception of https request and responses
|
9
|
+
# It does man-in-the-middle with on-the-fly certificate signing using the given CA
|
10
|
+
class SSLReverseProxy
|
11
|
+
# Creates a HTTPS server with the given settings
|
12
|
+
# @param port [Fixnum]: TCP port to bind the service
|
13
|
+
# @param ca [Ritm::CA]: The certificate authority used to sign fake server certificates
|
14
|
+
# @param forwarder [Ritm::HTTPForwarder]: Forwards http traffic with interception
|
15
|
+
def initialize(port, ca, forwarder)
|
16
|
+
@ca = ca
|
17
|
+
default_vhost = 'localhost'
|
18
|
+
@server = CertSigningHTTPSServer.new(Port: port,
|
19
|
+
AccessLog: [],
|
20
|
+
Logger: WEBrick::Log.new(File.open(File::NULL, 'w')),
|
21
|
+
ca: ca,
|
22
|
+
**vhost_settings(default_vhost))
|
23
|
+
@server.mount '/', RequestInterceptorServlet, forwarder
|
24
|
+
end
|
25
|
+
|
26
|
+
def start_async
|
27
|
+
trap(:TERM) { shutdown }
|
28
|
+
trap(:INT) { shutdown }
|
29
|
+
Thread.new { @server.start }
|
30
|
+
end
|
31
|
+
|
32
|
+
def shutdown
|
33
|
+
@server.shutdown
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def gen_signed_cert(common_name)
|
39
|
+
cert = Ritm::Certificate.create(common_name)
|
40
|
+
@ca.sign(cert)
|
41
|
+
cert
|
42
|
+
end
|
43
|
+
|
44
|
+
def vhost_settings(hostname)
|
45
|
+
cert = gen_signed_cert(hostname)
|
46
|
+
{
|
47
|
+
ServerName: hostname,
|
48
|
+
SSLEnable: true,
|
49
|
+
SSLVerifyClient: OpenSSL::SSL::VERIFY_NONE,
|
50
|
+
SSLPrivateKey: OpenSSL::PKey::RSA.new(cert.private_key),
|
51
|
+
SSLCertificate: OpenSSL::X509::Certificate.new(cert.pem),
|
52
|
+
SSLCertName: [['CN', hostname]]
|
53
|
+
}
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
data/lib/ritm/session.rb
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
require 'ritm/dispatcher'
|
2
|
+
require 'ritm/proxy/launcher'
|
3
|
+
require 'ritm/configuration'
|
4
|
+
|
5
|
+
module Ritm
|
6
|
+
# Holds the context of a interception session.
|
7
|
+
# Changes in the context configuration should affect only this session
|
8
|
+
class Session
|
9
|
+
attr_reader :conf, :dispatcher
|
10
|
+
|
11
|
+
def initialize
|
12
|
+
@conf = Configuration.new
|
13
|
+
@dispatcher = Dispatcher.new
|
14
|
+
@proxy = nil
|
15
|
+
end
|
16
|
+
|
17
|
+
# Define configuration settings
|
18
|
+
def configure(&block)
|
19
|
+
conf.instance_eval(&block)
|
20
|
+
end
|
21
|
+
|
22
|
+
# Re-enable fuzzing (if it was disabled)
|
23
|
+
def enable
|
24
|
+
conf.enable
|
25
|
+
end
|
26
|
+
|
27
|
+
# Disable fuzzing (if it was enabled)
|
28
|
+
def disable
|
29
|
+
conf.disable
|
30
|
+
end
|
31
|
+
|
32
|
+
# Start the proxy service
|
33
|
+
def start
|
34
|
+
raise 'Proxy already started' unless @proxy.nil?
|
35
|
+
@proxy = Proxy::Launcher.new(self)
|
36
|
+
@proxy.start
|
37
|
+
end
|
38
|
+
|
39
|
+
# Shutdown the proxy service
|
40
|
+
def shutdown
|
41
|
+
@proxy.shutdown unless @proxy.nil?
|
42
|
+
@proxy = nil
|
43
|
+
end
|
44
|
+
|
45
|
+
def add_handler(handler)
|
46
|
+
dispatcher.add_handler(handler)
|
47
|
+
end
|
48
|
+
|
49
|
+
def on_request(&block)
|
50
|
+
dispatcher.on_request(&block)
|
51
|
+
end
|
52
|
+
|
53
|
+
def on_response(&block)
|
54
|
+
dispatcher.on_response(&block)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
data/lib/ritm/version.rb
ADDED
data/lib/ritm.rb
ADDED
metadata
ADDED
@@ -0,0 +1,229 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: dongjia_ritm
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.4
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- dongjia
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2020-03-18 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: certificate_authority
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 0.1.6
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 0.1.6
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: dot_hash
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0.5'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0.5'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: faraday
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0.13'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0.13'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: webrick
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '1.3'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '1.3'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: httpclient
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '2.8'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '2.8'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rake
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '12.2'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '12.2'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: rspec
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - "~>"
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '3.7'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - "~>"
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '3.7'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: rubocop
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - "~>"
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0.51'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - "~>"
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0.51'
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: simplecov
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - "~>"
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '0.15'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - "~>"
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '0.15'
|
139
|
+
- !ruby/object:Gem::Dependency
|
140
|
+
name: sinatra
|
141
|
+
requirement: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - "~>"
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: '2.0'
|
146
|
+
type: :development
|
147
|
+
prerelease: false
|
148
|
+
version_requirements: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - "~>"
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: '2.0'
|
153
|
+
- !ruby/object:Gem::Dependency
|
154
|
+
name: sinatra-contrib
|
155
|
+
requirement: !ruby/object:Gem::Requirement
|
156
|
+
requirements:
|
157
|
+
- - "~>"
|
158
|
+
- !ruby/object:Gem::Version
|
159
|
+
version: '2.0'
|
160
|
+
type: :development
|
161
|
+
prerelease: false
|
162
|
+
version_requirements: !ruby/object:Gem::Requirement
|
163
|
+
requirements:
|
164
|
+
- - "~>"
|
165
|
+
- !ruby/object:Gem::Version
|
166
|
+
version: '2.0'
|
167
|
+
- !ruby/object:Gem::Dependency
|
168
|
+
name: thin
|
169
|
+
requirement: !ruby/object:Gem::Requirement
|
170
|
+
requirements:
|
171
|
+
- - "~>"
|
172
|
+
- !ruby/object:Gem::Version
|
173
|
+
version: '1.7'
|
174
|
+
type: :development
|
175
|
+
prerelease: false
|
176
|
+
version_requirements: !ruby/object:Gem::Requirement
|
177
|
+
requirements:
|
178
|
+
- - "~>"
|
179
|
+
- !ruby/object:Gem::Version
|
180
|
+
version: '1.7'
|
181
|
+
description: HTTP(S) Intercept Proxy
|
182
|
+
email: ios@idongjia.cn
|
183
|
+
executables: []
|
184
|
+
extensions: []
|
185
|
+
extra_rdoc_files: []
|
186
|
+
files:
|
187
|
+
- lib/ritm.rb
|
188
|
+
- lib/ritm/certs/ca.rb
|
189
|
+
- lib/ritm/certs/certificate.rb
|
190
|
+
- lib/ritm/configuration.rb
|
191
|
+
- lib/ritm/dispatcher.rb
|
192
|
+
- lib/ritm/helpers/encodings.rb
|
193
|
+
- lib/ritm/helpers/patches.rb
|
194
|
+
- lib/ritm/helpers/utils.rb
|
195
|
+
- lib/ritm/interception/handlers.rb
|
196
|
+
- lib/ritm/interception/http_forwarder.rb
|
197
|
+
- lib/ritm/interception/intercept_utils.rb
|
198
|
+
- lib/ritm/interception/request_interceptor_servlet.rb
|
199
|
+
- lib/ritm/main.rb
|
200
|
+
- lib/ritm/proxy/cert_signing_https_server.rb
|
201
|
+
- lib/ritm/proxy/launcher.rb
|
202
|
+
- lib/ritm/proxy/proxy_server.rb
|
203
|
+
- lib/ritm/proxy/ssl_reverse_proxy.rb
|
204
|
+
- lib/ritm/session.rb
|
205
|
+
- lib/ritm/version.rb
|
206
|
+
homepage: https://github.com/argos83/ritm
|
207
|
+
licenses:
|
208
|
+
- MIT
|
209
|
+
metadata: {}
|
210
|
+
post_install_message:
|
211
|
+
rdoc_options: []
|
212
|
+
require_paths:
|
213
|
+
- lib
|
214
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
215
|
+
requirements:
|
216
|
+
- - ">="
|
217
|
+
- !ruby/object:Gem::Version
|
218
|
+
version: '0'
|
219
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
220
|
+
requirements:
|
221
|
+
- - ">="
|
222
|
+
- !ruby/object:Gem::Version
|
223
|
+
version: '0'
|
224
|
+
requirements: []
|
225
|
+
rubygems_version: 3.1.2
|
226
|
+
signing_key:
|
227
|
+
specification_version: 4
|
228
|
+
summary: Ruby In The Middle
|
229
|
+
test_files: []
|