proxi 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 125abf0d6cecec0a987df416390f7d903f896ea2
4
- data.tar.gz: 524c9c84a6494d9838b448fa1723d0e7b838ebae
3
+ metadata.gz: 32e73fc0b66c9d84b526d7b819498c971141804b
4
+ data.tar.gz: 2708a278216649879f072473ebd6e16375ad0d5e
5
5
  SHA512:
6
- metadata.gz: dd8bf72dfb3dabd7088fbb5fc73d8cb02d6be8013659fda142e9f9ea439a5f4b876b434d22676c0b7616630bba6887768342720b602c0ed7b452647b4bb0acbd
7
- data.tar.gz: 4b10253015c9ab46428ae0494fa5a2d4188e5f5087e8f40e8d45355fdf26709d705c78f082b749055948f6f0c8dc31a2ae6816f934e0f75f1076fa274fc46f75
6
+ metadata.gz: f7ed6e53d8984ef3127443daf857d68aedc5a70f05c8548be9d3d7fdb58504c84ae37f4d73b9934e72aa3dd8d3cf79ebb8d2ad3f2e93abfeb2f3a63a2aef93e3
7
+ data.tar.gz: 0bf15ffa0343656882bf45df6c69e7b66fcf346d72db149c60ed0669e132a5bdd88bdf4a6958e1d6281ce4b8d10094d43485e27bec6f415bcf41206021b44809
data/.gitignore CHANGED
@@ -1 +1,4 @@
1
- scratch.rb
1
+ scratch.rb
2
+ .yardoc
3
+ proxi-0.1.gem
4
+ doc
data/README.md CHANGED
@@ -6,6 +6,7 @@ This is intended as a development tool, for when you want to see exactly what
6
6
  goes over the wire, want to log or save specific requests, responses, simulate
7
7
  timeouts or slow connections, etc.
8
8
 
9
+ For documentation see the [annotated source code](http://plexus.github.io/proxi/).
9
10
 
10
11
  ## License
11
12
 
@@ -0,0 +1,7 @@
1
+ cat lib/proxi.rb lib/proxi/server.rb lib/proxi/connection.rb lib/proxi/socket_factory.rb lib/proxi/reporting.rb lib/proxi/listeners.rb > doc/proxi
2
+
3
+ # Use https://github.com/Billiam/rocco , seems to at least mostly still work
4
+
5
+ RUBYOPT=-I~/github/rocco/lib ~/github/rocco/bin/rocco --docblocks doc/proxi
6
+
7
+ firefox-dev doc/proxi.html
@@ -1,13 +1,86 @@
1
1
  require 'socket'
2
2
  require 'thread'
3
- require 'optparse'
4
- require 'tmpdir'
5
3
  require 'openssl'
6
4
 
7
5
  require 'wisper'
8
6
 
7
+ # [Proxi](https://github.com/plexus/proxi) gives you flexible TCP/HTTP proxy
8
+ # servers for use during development.
9
+ #
10
+ # This can be useful when developing against external services, to see what goes
11
+ # over the wire, capture responses, or simulate timeouts.
12
+ #
13
+ # There are three main concepts in Proxi, `Server`, `Connection`, and `Socket`.
14
+ # A server listens locally for incoming requests. When it receives a request, it
15
+ # establishes a `Connection`, which opens a socket to the remote host, and then
16
+ # acts as a bidirectional pipe between the incoming and outgoing network
17
+ # sockets.
18
+ #
19
+ # To allow for multiple ways to handle the proxying, and multiple strategies for
20
+ # making remote connections, the `Server` does not create `Connections`
21
+ # directly, but instead delegates this to a "connection factory".
22
+ #
23
+ # A `Connection` in turn delegates how to open a remote socket to a "socket
24
+ # factory".
25
+ #
26
+ # Both Servers and Connections are observable, they emit events that objects can
27
+ # subscribe to.
28
+ #
29
+ # To use Proxi, hook up these factories, and register event listeners, and then
30
+ # start the server.
9
31
  module Proxi
32
+
33
+ # ## Basic examples
34
+ #
35
+ # These are provided for basic use cases, and as a starting point for more
36
+ # complex uses. They return the server instance, call `#call` to start the
37
+ # server, or use `on` or `subscribe` to listen to events.
38
+
39
+ # With `Proxy.tcp_proxy` you get basic proxying from a local port to a remote
40
+ # host and port, all bytes are simply forwarded without caring about their
41
+ # contents.
42
+ #
43
+ # For example:
44
+ #
45
+ # Proxi.tcp_proxy(3000, 'foo.example.com', 4000).call
46
+ def self.tcp_proxy(local_port, remote_host, remote_port)
47
+ reporter = ConsoleReporter.new
48
+
49
+ connection_factory = -> in_socket do
50
+ socket_factory = TCPSocketFactory.new(remote_host, remote_port)
51
+ Connection.new(in_socket, socket_factory).subscribe(reporter)
52
+ end
53
+
54
+ Server.new(local_port, connection_factory)
55
+ end
56
+
57
+ # `Proxi.http_host_proxy` allows proxying to multiple remote hosts, based on
58
+ # the HTTP `Host:` header. To use it, gather the IP addresses that correspond
59
+ # to each domain name, and provide this name-to-ip mapping to
60
+ # `http_host_proxy`. Now configure these domain names in `/etc/hosts` to point
61
+ # to the local host, so Proxi can intercept traffic intended for these
62
+ # domains.
63
+ #
64
+ # For example
65
+ #
66
+ # Proxi.http_host_proxy(80, {'foo.example.org' => '10.10.0.1'}).call
67
+ def self.http_host_proxy(local_port, host_mapping)
68
+ reporter = ConsoleReporter.new
69
+
70
+ connection_factory = -> in_socket do
71
+ socket_factory = HTTPHostSocketFactory.new(host_mapping)
72
+ Connection
73
+ .new(in_socket, socket_factory)
74
+ .subscribe(socket_factory, on: :data_in)
75
+ .subscribe(reporter)
76
+ end
77
+
78
+ Server.new(local_port, connection_factory)
79
+ end
10
80
  end
11
81
 
12
82
  require_relative 'proxi/server'
13
83
  require_relative 'proxi/connection'
84
+ require_relative 'proxi/socket_factory'
85
+ require_relative 'proxi/reporting'
86
+ require_relative 'proxi/listeners'
@@ -1,18 +1,45 @@
1
+ # ## Proxi::Connection
2
+ #
1
3
  module Proxi
4
+
5
+ # A `Connection` is a bidirectional pipe between two sockets.
6
+ #
7
+ # The proxy server hands it the socket for the incoming request from, and
8
+ # `Connection` then initiates an outgoing request, after which it forwards all
9
+ # traffic in both directions.
10
+ #
11
+ # Creating the outgoing request is delegated to a `Proxi::SocketFactory`. The
12
+ # reason being that the type of socket can vary (`TCPSocket`, `SSLSocket`), or
13
+ # there might be some logic involved to dispatch to the correct host, e.g.
14
+ # based on the HTTP Host header (cfr. `Proxi::HTTPHostSocketFactory`).
15
+ #
16
+ # A socket factory can subscribe to events to make informed decision, e.g. to
17
+ # inspect incoming data for HTTP headers.
18
+ #
19
+ # Proxi::Connection broadcasts the following events:
20
+ #
21
+ # - `start_connection(Proxi::Connection)`
22
+ # - `end_connection(Proxi::Connection)`
23
+ # - `main_loop_error(Proxi::Connection, Exception)`
24
+ # - `data_in(Proxi::Connection, Array)`
25
+ # - `data_out(Proxi::Connection, Array)`
2
26
  class Connection
3
27
  include Wisper::Publisher
4
28
 
5
- attr_reader :in_socket, :thread, :remote_host, :remote_port
29
+ attr_reader :in_socket, :thread
6
30
 
7
- def initialize(in_socket, remote_host, remote_port)
31
+ def initialize(in_socket, socket_factory, max_block_size: 4096)
8
32
  @in_socket = in_socket
9
- @remote_host, @remote_port = remote_host, remote_port
10
- @state = :new
33
+ @socket_factory = socket_factory
34
+ @max_block_size = max_block_size
11
35
  end
12
36
 
37
+ # `Connection#call` starts the connection handler thread. This is called by
38
+ # the server, and spawns a new Thread that handles the forwarding of data.
13
39
  def call
14
40
  broadcast(:start_connection, self)
15
41
  @thread = Thread.new { proxy_loop }
42
+ self
16
43
  end
17
44
 
18
45
  def alive?
@@ -26,11 +53,12 @@ module Proxi
26
53
  private
27
54
 
28
55
  def out_socket
29
- @out_socket ||= TCPSocket.new(remote_host, remote_port)
56
+ @out_socket ||= @socket_factory.call
30
57
  end
31
58
 
32
59
  def proxy_loop
33
60
  begin
61
+ handle_socket(in_socket)
34
62
  loop do
35
63
  begin
36
64
  ready_sockets.each do |socket|
@@ -55,7 +83,7 @@ module Proxi
55
83
  end
56
84
 
57
85
  def handle_socket(socket)
58
- data = socket.readpartial(4096)
86
+ data = socket.readpartial(@max_block_size)
59
87
 
60
88
  if socket == in_socket
61
89
  broadcast(:data_in, self, data)
@@ -69,67 +97,4 @@ module Proxi
69
97
  end
70
98
  end
71
99
 
72
- module SSLConnection
73
- def connect
74
- @out_socket = OpenSSL::SSL::SSLSocket.new(super)
75
- @out_socket.connect
76
- end
77
- end
78
-
79
- class HTTPConnection < Connection
80
- def initialize(in_socket, host_to_ip)
81
- @in_socket, @host_to_ip = in_socket, host_to_ip
82
- @new = true
83
- end
84
-
85
- def handle_socket(socket)
86
- data = socket.readpartial(4096)
87
-
88
- if socket == in_socket
89
- broadcast(:data_in, self, data)
90
-
91
- if @new
92
- @first_packet = data
93
- @new = false
94
- end
95
-
96
- out_socket.write data
97
- out_socket.flush
98
- else
99
- broadcast(:data_out, self, data)
100
- in_socket.write data
101
- in_socket.flush
102
- end
103
- end
104
-
105
- def ready_sockets
106
- if @new
107
- sockets = [in_socket]
108
- else
109
- sockets = [in_socket, out_socket]
110
- end
111
- IO.select(sockets).first
112
- end
113
-
114
- def out_socket
115
- host, port = @host_to_ip.fetch(headers["host"]).split(':')
116
- port ||= 80
117
- @out_socket ||= TCPSocket.new(host, port.to_i)
118
- end
119
-
120
- def headers
121
- Hash[
122
- @first_packet
123
- .sub(/\r\n\r\n.*/m, '')
124
- .each_line
125
- .drop(1) # GET / HTTP/1.1
126
- .map do |line|
127
- k,v = line.split(':', 2)
128
- [k.downcase, v.strip].tap do |header|
129
- broadcast(:header, self, *header)
130
- end
131
- end
132
- ]
133
- end
134
- end
135
100
  end
@@ -0,0 +1,50 @@
1
+ # ## Server Listeners
2
+ #
3
+ # These can be attached to a server to add extra behavior
4
+ #
5
+ module Proxi
6
+
7
+ # Log all incoming and outgoing traffic to files under `/tmp`
8
+ #
9
+ # For example:
10
+ #
11
+ # Proxi.tcp_server(...).subscribe(RequestResponseLogging.new).call
12
+ #
13
+ # The in and outgoing traffic will be captured per connection in
14
+ # `/tmp/proxi.1.in` and `/tmp/proxi.1.out`; `/tmp/proxi.2.in`, etc.
15
+ class RequestResponseLogging
16
+ def initialize(dir: Dir.tmpdir, name: "proxi")
17
+ @dir = dir
18
+ @name = name
19
+ @count = 0
20
+ @mutex = Mutex.new
21
+ end
22
+
23
+ def new_connection(connection)
24
+ count = @mutex.synchronize { @count += 1 }
25
+ in_fd = File.open(log_name(count, "in"), 'w')
26
+ out_fd = File.open(log_name(count, "out"), 'w')
27
+
28
+ connection
29
+ .on(:data_in) { |_, data| in_fd.write(data) ; in_fd.flush }
30
+ .on(:data_out) { |_, data| out_fd.write(data) ; out_fd.flush }
31
+ .on(:end_connection) { in_fd.close ; out_fd.close }
32
+ end
33
+
34
+ def log_name(num, suffix)
35
+ '%s/%s.%d.%s' % [@dir, @name, num, suffix]
36
+ end
37
+ end
38
+
39
+ # Wait before handing back data coming from the remote, this simulates a slow
40
+ # connection, and can be used to test timeouts.
41
+ class SlowDown
42
+ def initialize(wait_seconds: 5)
43
+ @wait_seconds = wait_seconds
44
+ end
45
+
46
+ def new_connection(connection)
47
+ connection.on(:data_out) { sleep @wait_seconds }
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,57 @@
1
+ # ### Reporting
2
+ #
3
+ # Proxi's server and connection classes don't have any logging or UI
4
+ # capabilities built in, but they broadcast events that we can listen to to perform these tasks.
5
+
6
+ module Proxi
7
+ # This is a very basic console reporter to see what's happening
8
+ #
9
+ # Subscribe to connection events, and you will see output that looks like this
10
+ #
11
+ # 1. +++
12
+ # 1. < 91
13
+ # 2. +++
14
+ # 3. +++
15
+ # 2. < 91
16
+ # 3. < 91
17
+ # 1. > 4096
18
+ # 1. > 3422
19
+ # 1. ---
20
+ #
21
+ # Each connection gets a unique incremental number assigned, followed by:
22
+ #
23
+ # - '+++' new connection
24
+ # - '---' connection closed
25
+ # - '< 1234' number of bytes proxied to the remote
26
+ # - '> 1234' number of bytes proxied back from the remote
27
+ class ConsoleReporter
28
+ def initialize
29
+ @count = 0
30
+ @mutex = Mutex.new
31
+ @connections = {}
32
+ end
33
+
34
+ def start_connection(conn)
35
+ @mutex.synchronize { @connections[conn] = (@count += 1) }
36
+ puts "#{@connections[conn]}. +++"
37
+ end
38
+
39
+ def end_connection(conn)
40
+ puts "#{@connections[conn]}. ---"
41
+ @connections.delete(conn)
42
+ end
43
+
44
+ def data_in(conn, data)
45
+ puts "#{@connections[conn]}. < #{data.size}"
46
+ end
47
+
48
+ def data_out(conn, data)
49
+ puts "#{@connections[conn]}. > #{data.size}"
50
+ end
51
+
52
+ def main_loop_error(conn, exc)
53
+ STDERR.puts "#{@connections[conn]}. #{exc.class} #{exc.message}"
54
+ STDERR.puts exc.backtrace
55
+ end
56
+ end
57
+ end
@@ -1,19 +1,45 @@
1
+ # ## Proxi::Server
2
+ #
1
3
  module Proxi
4
+
5
+ # `Proxi::Server` accepts TCP requests, and forwards them, by creating an
6
+ # outbound connection and forwarding traffic in both directions.
7
+ #
8
+ # The destination of the outbound connection, and the forwarding of data, is
9
+ # handled by a `Proxi::Connection`, created by a factory object, which can be a
10
+ # lambda.
11
+ #
12
+ # Start listening for connections by calling #call.
13
+ #
14
+ # `Proxi::Server` broadcasts the following events:
15
+ #
16
+ # - `new_connection(Proxi::Connection)`
17
+ # - `dead_connection(Proxi::Connection)`
2
18
  class Server
3
19
  include Wisper::Publisher
4
20
 
5
- MAX_CONNECTIONS = 5
6
-
7
- def initialize(listen_port, connection_factory)
21
+ # Public: Initialize a Server
22
+ #
23
+ # listen_port - The String or Integer of the port to listen to for
24
+ # incoming connections
25
+ # connection_factory - Implements #call(in_socket) and returns a
26
+ # Proxi::Connection
27
+ # max_connections - The maximum amount of parallel connections to handle
28
+ # at once
29
+ def initialize(listen_port, connection_factory, max_connections: 5)
30
+ @listen_port = listen_port
8
31
  @connection_factory = connection_factory
9
-
10
- @server = TCPServer.new(nil, listen_port)
11
-
32
+ @max_connections = 5
12
33
  @connections = []
13
34
  end
14
35
 
36
+ # Public: Start the server
37
+ #
38
+ # Start accepting and forwarding requests
15
39
  def call
16
- loop do
40
+ @server = TCPServer.new('localhost', @listen_port)
41
+
42
+ until @server.closed?
17
43
  in_socket = @server.accept
18
44
  connection = @connection_factory.call(in_socket)
19
45
 
@@ -21,22 +47,36 @@ module Proxi
21
47
 
22
48
  @connections.push(connection)
23
49
 
24
- connection.call
50
+ connection.call # spawns a new thread that handles proxying
25
51
 
26
52
  reap_connections
27
- while @connections.size >= MAX_CONNECTIONS
53
+ while @connections.size >= @max_connections
28
54
  sleep 1
29
55
  reap_connections
30
56
  end
31
57
  end
58
+ ensure
59
+ close unless @server.closed?
60
+ end
61
+
62
+ # Public: close the TCP server socket
63
+ #
64
+ # Included for completeness, note that if the proxy server is active it will
65
+ # likely be blocking on TCPServer#accept, and the server port will stay open
66
+ # until it has accepted one final request.
67
+ def close
68
+ @server.close
32
69
  end
33
70
 
71
+ private
72
+
34
73
  def reap_connections
35
- @connections = @connections.select do |t|
36
- if t.alive?
74
+ @connections = @connections.select do |conn|
75
+ if conn.alive?
37
76
  true
38
77
  else
39
- t.join_thread
78
+ broadcast(:dead_connection, conn)
79
+ conn.join_thread
40
80
  false
41
81
  end
42
82
  end
@@ -0,0 +1,92 @@
1
+ # ## Socket factories
2
+ #
3
+ module Proxi
4
+ # ### TCPSocketFactory
5
+ #
6
+ # This is the most vanilla type of socket factory.
7
+ #
8
+ # Suitable when all requests need to be forwarded to the same host and port.
9
+ class TCPSocketFactory
10
+ def initialize(remote_host, remote_port)
11
+ @remote_host, @remote_port = remote_host, remote_port
12
+ end
13
+
14
+ def call
15
+ TCPSocket.new(@remote_host, @remote_port)
16
+ end
17
+ end
18
+
19
+ # ### SSLSocketFactory
20
+ #
21
+ # This will set up an encrypted (SSL, https) connection to the target host.
22
+ # This way the proxy server communicates *unencrypted* locally, but
23
+ # encrypts/decrypts communication with the remote host.
24
+ #
25
+ # If you want to forward SSL connections as-is, use a `TCPSocketFactory`, in
26
+ # that case however you won't be able to inspect any data passing through,
27
+ # since it will be encrypted.
28
+ class SSLSocketFactory < TCPSocketFactory
29
+ def call
30
+ OpenSSL::SSL::SSLSocket.new(super).tap(&:connect)
31
+ end
32
+ end
33
+
34
+ # ### HTTPHostSocketFactory
35
+ #
36
+ # Dispatches HTTP traffic to multiple hosts, based on the HTTP `Host:` header.
37
+ #
38
+ # HTTPHostSocketFactory expects to receive data events from the connection, so
39
+ # make sure you subscribe it to connection events. (see `Proxi.http_proxy` for
40
+ # an example).
41
+ #
42
+ # To use this effectively, configure your local `/etc/hosts` so the relevant
43
+ # domains point to localhost. That way the proxy will be able to intercept
44
+ # them.
45
+ #
46
+ # This class is single use only! Create a new instance for each `Proxi::Connection`.
47
+ class HTTPHostSocketFactory
48
+
49
+ # Initialize a HTTPHostSocketFactory
50
+ #
51
+ # `host_mapping` - A Hash mapping hostnames to IP addresses, and, optionally, ports
52
+ #
53
+ # For example:
54
+ #
55
+ # HTTPHostSocketFactory.new(
56
+ # 'foo.example.com' => '10.10.10.1:8080',
57
+ # 'bar.example.com' => '10.10.10.2:8080'
58
+ # )
59
+ def initialize(host_mapping)
60
+ @host_mapping = host_mapping
61
+ end
62
+
63
+ # This is an event listener, it will be broadcast by the `Connection` whenever
64
+ # it gets new request data. We capture the first packet, assuming it
65
+ # contains the HTTP headers.
66
+ #
67
+ # `Connection` will only request an outgoing socket from us (call `#call`)
68
+ # after it received the initial request payload.
69
+ def data_in(connection, data)
70
+ @first_packet ||= data
71
+ end
72
+
73
+ def call
74
+ host, port = @host_to_ip.fetch(headers["host"]).split(':')
75
+ port ||= 80
76
+ TCPSocket.new(host, port.to_i)
77
+ end
78
+
79
+ def headers
80
+ Hash[
81
+ @first_packet
82
+ .sub(/\r\n\r\n.*/m, '')
83
+ .each_line
84
+ .drop(1) # GET / HTTP/1.1
85
+ .map do |line|
86
+ k,v = line.split(':', 2)
87
+ [k.downcase, v.strip]
88
+ end
89
+ ]
90
+ end
91
+ end
92
+ end
@@ -1,6 +1,6 @@
1
1
  Gem::Specification.new do |gem|
2
2
  gem.name = 'proxi'
3
- gem.version = '0.1'
3
+ gem.version = '1.0'
4
4
  gem.authors = [ 'Arne Brasseur' ]
5
5
  gem.email = [ 'arne@arnebrasseur.net' ]
6
6
  gem.description = 'TCP and HTTP proxy scripts'
data/proxy.rb CHANGED
@@ -1,5 +1,12 @@
1
1
  #!/usr/bin/ruby
2
2
 
3
+ # This is my original proxy.rb script that served me well for many years, before
4
+ # I decided to clean it up and turn it into a gem.
5
+ #
6
+ # Have a look under lib/ for the much improved version. Leaving it here just a
7
+ # little longer for reference. There are still a few features that haven't been
8
+ # ported yet.
9
+
3
10
  require 'socket'
4
11
  require 'thread'
5
12
  require 'optparse'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: proxi
3
3
  version: !ruby/object:Gem::Version
4
- version: '0.1'
4
+ version: '1.0'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Arne Brasseur
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2015-10-24 00:00:00.000000000 Z
11
+ date: 2015-10-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: wisper
@@ -51,9 +51,13 @@ files:
51
51
  - Gemfile.lock
52
52
  - LICENSE
53
53
  - README.md
54
+ - generate_docs
54
55
  - lib/proxi.rb
55
56
  - lib/proxi/connection.rb
57
+ - lib/proxi/listeners.rb
58
+ - lib/proxi/reporting.rb
56
59
  - lib/proxi/server.rb
60
+ - lib/proxi/socket_factory.rb
57
61
  - proxi.gemspec
58
62
  - proxy.rb
59
63
  homepage: https://github.com/plexus/proxi