dongjia_ritm 1.0.4
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 +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: []
|