experella-proxy 0.0.6

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.
Files changed (43) hide show
  1. data/.gitignore +15 -0
  2. data/Gemfile +3 -0
  3. data/README.md +219 -0
  4. data/Rakefile +25 -0
  5. data/TODO.txt +20 -0
  6. data/bin/experella-proxy +54 -0
  7. data/config/default/404.html +16 -0
  8. data/config/default/503.html +18 -0
  9. data/config/default/config.rb +64 -0
  10. data/config/default/ssl/certs/experella-proxy.pem +18 -0
  11. data/config/default/ssl/private/experella-proxy.key +28 -0
  12. data/dev/experella-proxy +62 -0
  13. data/experella-proxy.gemspec +39 -0
  14. data/lib/experella-proxy/backend.rb +58 -0
  15. data/lib/experella-proxy/backend_server.rb +100 -0
  16. data/lib/experella-proxy/configuration.rb +154 -0
  17. data/lib/experella-proxy/connection.rb +557 -0
  18. data/lib/experella-proxy/connection_manager.rb +167 -0
  19. data/lib/experella-proxy/globals.rb +37 -0
  20. data/lib/experella-proxy/http_status_codes.rb +45 -0
  21. data/lib/experella-proxy/proxy.rb +61 -0
  22. data/lib/experella-proxy/request.rb +106 -0
  23. data/lib/experella-proxy/response.rb +204 -0
  24. data/lib/experella-proxy/server.rb +68 -0
  25. data/lib/experella-proxy/version.rb +15 -0
  26. data/lib/experella-proxy.rb +93 -0
  27. data/spec/echo-server/echo_server.rb +49 -0
  28. data/spec/experella-proxy/backend_server_spec.rb +101 -0
  29. data/spec/experella-proxy/configuration_spec.rb +27 -0
  30. data/spec/experella-proxy/connection_manager_spec.rb +159 -0
  31. data/spec/experella-proxy/experella-proxy_spec.rb +471 -0
  32. data/spec/experella-proxy/request_spec.rb +88 -0
  33. data/spec/experella-proxy/response_spec.rb +44 -0
  34. data/spec/fixtures/404.html +16 -0
  35. data/spec/fixtures/503.html +18 -0
  36. data/spec/fixtures/spec.log +331 -0
  37. data/spec/fixtures/test_config.rb +34 -0
  38. data/spec/spec.log +235 -0
  39. data/spec/spec_helper.rb +35 -0
  40. data/test/sinatra/hello_world_server.rb +17 -0
  41. data/test/sinatra/server_one.rb +89 -0
  42. data/test/sinatra/server_two.rb +89 -0
  43. metadata +296 -0
@@ -0,0 +1,557 @@
1
+ require 'uri'
2
+
3
+ module ExperellaProxy
4
+
5
+ # The proxies TCP Connection to the client
6
+ #
7
+ # Responsible for parsing and buffering the clients http requests,
8
+ # connecting to the backend server, sending data to the backend server and returning responses to the client.
9
+ #
10
+ # See EventMachine::Connection documentation for more information
11
+ #
12
+ # @see http://eventmachine.rubyforge.org/EventMachine/Connection.html EventMachine::Connection
13
+ class Connection < EventMachine::Connection
14
+ include ExperellaProxy::Globals
15
+
16
+ # Used to pass an optional block to the connection which will be executed when the {#connected} event occurs
17
+ #
18
+ # @example
19
+ # # called on successful backend connection
20
+ # # backend is the name of the connected server
21
+ # conn.on_connect do |backend|
22
+ #
23
+ # end
24
+ #
25
+ # @param blk [Block] a block to be executed
26
+ def on_connect(&blk)
27
+ @on_connect = blk
28
+ end
29
+
30
+ # Used to pass an optional block to the connection which will be executed when the {#receive_data} event occurs
31
+ #
32
+ # @example
33
+ # # modify / process request stream
34
+ # # and return modified data
35
+ # conn.on_data do |data|
36
+ # data
37
+ # end
38
+ #
39
+ # @param blk [Block] a block to be executed
40
+ # @return [String] the modified data
41
+ def on_data(&blk)
42
+ @on_data = blk
43
+ end
44
+
45
+ # Used to pass an optional block to the connection which will be executed when the {#relay_from_backend} event occurs
46
+ #
47
+ # @example
48
+ # # modify / process response stream
49
+ # # and return modified response
50
+ # # backend is the name of the connected server
51
+ # conn.on_response do |backend, resp|
52
+ # resp
53
+ # end
54
+ #
55
+ # @param blk [Block] a block to be executed
56
+ # @return [String] the modified response
57
+ def on_response(&blk)
58
+ @on_response = blk
59
+ end
60
+
61
+ # Used to pass an optional block to the connection which will be executed when the {#unbind_backend} event occurs
62
+ #
63
+ # @example
64
+ # # termination logic
65
+ # # backend is the name of the connected server
66
+ # conn.on_finish do |backend|
67
+ #
68
+ # end
69
+ #
70
+ # @param blk [Block] a block to be executed
71
+ def on_finish(&blk)
72
+ @on_finish = blk
73
+ end
74
+
75
+ # Used to pass an optional block to the connection which will be executed when the {#unbind} event occurs
76
+ #
77
+ # @example
78
+ # # called if client terminates connection
79
+ # # or timeout occurs
80
+ # conn.on_unbind do
81
+ #
82
+ # end
83
+ #
84
+ # @param blk [Block] a block to be executed
85
+ def on_unbind(&blk)
86
+ @on_unbind = blk
87
+ end
88
+
89
+ # calls EventMachine close_connection_after_writing method with 1 tick delay
90
+ # waits 1 tick to make sure reactor i/o does not have unnecessary loop delay
91
+ def close
92
+ @unbound = true
93
+ EM.next_tick(method(:close_connection_after_writing))
94
+ log.debug [msec, :unbind_client_after_writing, @signature.to_s]
95
+ end
96
+
97
+ # Connects self to a BackendServer object
98
+ #
99
+ # Any request mangling configured in {BackendServer#mangle} will be done here
100
+ #
101
+ # Method provides additional support for BackendServer's named "web".
102
+ # Host and Port will be determined through the Request instead of BackendServer settings.
103
+ #
104
+ # @param backend [BackendServer] the BackendServer object
105
+ def connect_backendserver(backend)
106
+ @backend = backend
107
+ connection_manager.free_connection(self)
108
+ #mangle http header if backend wants to
109
+ unless @backend.mangle.nil?
110
+ @backend.mangle.each do |k, v|
111
+ if v.respond_to?(:call)
112
+ get_request.update_header({k => v.call(get_request.header[k])})
113
+ else
114
+ get_request.update_header({k => v})
115
+ end
116
+ end
117
+ end
118
+
119
+ # reconstruct the request header
120
+ get_request.reconstruct_header
121
+
122
+ #special web support for unknown hosts
123
+ if @backend.name.eql?("web")
124
+ xport = get_request.header[:Host].match(/:[0-9]+/)
125
+ if xport.nil? || xport.to_s.empty?
126
+ xport = "80"
127
+ else
128
+ xport = xport.to_s.gsub(/:/, "")
129
+ end
130
+ xhost = get_request.header[:Host].gsub(":#{xport}", "")
131
+ server(@backend.name, :host => xhost, :port => xport)
132
+ else
133
+ server(@backend.name, :host => @backend.host, :port => @backend.port)
134
+ end
135
+ end
136
+
137
+ # Called by backend connections when the remote TCP connection attempt completes successfully.
138
+ #
139
+ # {#on_connect} block will be executed here
140
+ #
141
+ # This method triggers the {#relay_to_server} method
142
+ #
143
+ # @param name [String] name of the Server used for logging
144
+ def connected(name)
145
+ log.debug [msec, :connected]
146
+ @on_connect.call(name) if @on_connect
147
+ log.info msec + 'on_connect'.ljust(12) + " @" + @signature.to_s + ' ' + name
148
+ relay_to_server
149
+ end
150
+
151
+ # Used for accessing the connections first request
152
+ #
153
+ # Buffered requests must not be handled before first in done
154
+ #
155
+ # @return [Request] the Request to be handled
156
+ def get_request
157
+ @requests.first
158
+ end
159
+
160
+ # Called by the EventMachine loop whenever data has been received by the network connection.
161
+ # It is never called by user code. {#receive_data} is called with a single parameter,
162
+ # a String containing the network protocol data, which may of course be binary.
163
+ #
164
+ # Data gets passed to the specified {#on_data} block first
165
+ # Then data gets passed to the parser and the {#relay_to_server} method gets fired
166
+ #
167
+ # On Http::Parser::Error a 400 Bad Request error send to the client and the Connection will be closed
168
+ #
169
+ # @param data [String] Opaque incoming data
170
+ def receive_data(data)
171
+ log.debug [msec, :connection, data]
172
+ data = @on_data.call(data) if @on_data
173
+ begin
174
+ @request_parser << data
175
+ rescue Http::Parser::Error
176
+ log.warn [msec, "Parser error caused by invalid request data", "@#{@signature}"]
177
+ # on error unbind request_parser object, so additional data doesn't get parsed anymore
178
+ #
179
+ # assigning a string to the parser variable, will cause incoming data to get buffered
180
+ # imho this is a better solution than adding a condition for this rare error case
181
+ @request_parser = ""
182
+ send_data "HTTP/1.1 400 Bad Request\r\nVia: 1.1 experella\r\nConnection: close\r\n\r\n"
183
+ close
184
+ end
185
+
186
+ log.info msec + 'on_data'.ljust(12) + " @" + @signature.to_s
187
+ log.debug data
188
+ relay_to_server
189
+ end
190
+
191
+ # Called by {Backend} connections.
192
+ # Relays data from backend server to the client
193
+ #
194
+ # {#on_response} block will be executed here
195
+ #
196
+ # @param name [String] name of the Server used for logging
197
+ # @param data [String] opaque response data
198
+ def relay_from_backend(name, data)
199
+ log.info msec + 'on_response'.ljust(12) + " @" + @signature.to_s + " from #{name}"
200
+ log.debug msec + "#{name.inspect} " + data
201
+ @got_response = true
202
+ data = @on_response.call(name, data) if @on_response
203
+ get_request.response << data
204
+ end
205
+
206
+ # Initialize a {Backend} connection
207
+ #
208
+ # Can connect to host:port server address
209
+ #
210
+ # @param name [String] name of the Server used for logging
211
+ # @param opts [Hash] Hash containing connection parameters
212
+ def server(name, opts)
213
+ srv = EventMachine::bind_connect(opts[:bind_host], opts[:bind_port], opts[:host], opts[:port], Backend) do |c|
214
+ c.name = name
215
+ c.plexer = self
216
+ end
217
+
218
+ @server = srv
219
+ end
220
+
221
+ #
222
+ # ip, port of the connected client
223
+ #
224
+ def peer
225
+ @peer ||= begin
226
+ peername = get_peername
227
+ peername ? Socket.unpack_sockaddr_in(peername).reverse : nil
228
+ end
229
+ end
230
+
231
+ # Called by the event loop immediately after the network connection has been established,
232
+ # and before resumption of the network loop.
233
+ # This method is generally not called by user code, but is called automatically
234
+ # by the event loop. The base-class implementation is a no-op.
235
+ # This is a very good place to initialize instance variables that will
236
+ # be used throughout the lifetime of the network connection.
237
+ #
238
+ # This is currently used to initiate start_tls on @options[:tls] enabled
239
+ #
240
+ #
241
+ # @see #connection_completed
242
+ # @see #unbind
243
+ # @see #send_data
244
+ # @see #receive_data
245
+ def post_init
246
+ if @options[:tls]
247
+ log.info [msec, "starting tls handshake @#{@signature}"]
248
+ start_tls(:private_key_file => @options[:private_key_file], :cert_chain_file => @options[:cert_chain_file], :verify_peer => false)
249
+ end
250
+ end
251
+
252
+ #
253
+ # ip, port of the local server connect
254
+ #
255
+ def sock
256
+ @sock ||= begin
257
+ sockname = get_sockname
258
+ sockname ? Socket.unpack_sockaddr_in(sockname).reverse : nil
259
+ end
260
+ end
261
+
262
+
263
+ # Called by EventMachine when the SSL/TLS handshake has been completed, as a result of calling start_tls to
264
+ # initiate SSL/TLS on the connection.
265
+ #
266
+ # This callback exists because {#post_init} and connection_completed are not reliable for indicating when an
267
+ # SSL/TLS connection is ready to have its certificate queried for.
268
+ def ssl_handshake_completed
269
+ log.info [msec, "ssl_handshake_completed successful"]
270
+ end
271
+
272
+ # Called by backend connections whenever their connection is closed.
273
+ # The close can occur because the code intentionally closes it
274
+ # (using #close_connection and #close_connection_after_writing), because
275
+ # the remote peer closed the connection, or because of a network error.
276
+ #
277
+ # Therefor connection errors, reconnections and queues need to be handled here
278
+ #
279
+ # {#on_finish} block will be executed here
280
+ #
281
+ # @param name [String] name of the Server used for logging
282
+ def unbind_backend(name)
283
+
284
+ log.info msec + 'on_finish'.ljust(12) + " @" + @signature.to_s + " for #{name}" + " responded? " + @got_response.to_s
285
+ if @on_finish
286
+ @on_finish.call(name)
287
+ end
288
+
289
+ @server = nil
290
+
291
+ #if backend responded or client unbound connection (timeout probably triggers this too)
292
+ if @got_response || @unbound
293
+ log.info msec + "Request done! @" + @signature.to_s
294
+ log.debug [msec, :backend_unbound, @requests.size, "keep alive? " + get_request.keep_alive.to_s]
295
+ unless get_request.keep_alive
296
+ close
297
+ log.debug [msec, :connection_closed, "close non persistent connection after writing"]
298
+ end
299
+ @requests.shift #pop first element,request is done
300
+ @got_response = false #reset response flag
301
+
302
+ #free backend server and connect to next conn if matching conn exists
303
+ unless @backend.nil?
304
+ connect_next
305
+ end
306
+
307
+ #check if queued requests find a matching backend
308
+ unless @requests.empty? || @unbound
309
+ #try to dispatch first request to backend
310
+ dispatch_request
311
+ end
312
+ else
313
+ #handle no backend response here
314
+ log.error msec + "Error, backend didnt respond"
315
+ error_page = "HTTP/1.1 503 Service unavailable\r\nContent-Length: #{config.error_pages[503].length}\r\nContent-Type: text/html;charset=utf-8\r\nConnection: close\r\n\r\n"
316
+ unless get_request.header[:http_method].eql? "HEAD"
317
+ error_page << config.error_pages[503]
318
+ end
319
+ log.error [msec, :error_to_client, error_page]
320
+ send_data error_page
321
+
322
+ close
323
+ end
324
+ end
325
+
326
+ # Called by the EventMachine loop whenever the client connection is closed.
327
+ # The close can occur because the code intentionally closes it
328
+ # (using #close_connection and #close_connection_after_writing), because
329
+ # the remote peer closed the connection, or because of a network error.
330
+ #
331
+ # This is used to clean up associations made to the connection object while it was open.
332
+ #
333
+ # {#on_unbind} block will be executed here
334
+ #
335
+ def unbind
336
+ @unbound = true
337
+ @on_unbind.call if @on_unbind
338
+
339
+ log.info [msec, "Client connection unbound! @" + @signature.to_s]
340
+ #lazy evaluated. if first is true, second would cause a nil-pointer!
341
+ unless @requests.empty? || get_request.flushed?
342
+ log.debug [msec, @requests.inspect]
343
+ end
344
+ #delete conn from queue if still queued
345
+ connection_manager.free_connection(self)
346
+
347
+ # reconnect backend to new connection if this has not happened already
348
+ unless @backend.nil?
349
+ connect_next
350
+ end
351
+ # terminate any unfinished backend connections
352
+ unless @server.nil?
353
+ @server.close_connection_after_writing
354
+ end
355
+ end
356
+
357
+
358
+ private
359
+
360
+ # @private constructor, gets called by EventMachine::Connection's overwritten new method
361
+ # Initializes http parser and timeout_timer
362
+ #
363
+ # @param options [Hash] options Hash passed to the connection
364
+ def initialize(options)
365
+ @options = options
366
+ @backend = nil
367
+ @server = nil
368
+ @requests = [] #contains request objects
369
+ @unbound = false
370
+ @got_response = false
371
+ @request_parser = Http::Parser.new
372
+ init_http_parser
373
+ timeout_timer
374
+ @start = Time.now
375
+ end
376
+
377
+ # checks if the free backend matches any queued connection
378
+ # if there is a match, fire connection event to that connection
379
+ #
380
+ # @note DeHerr: imho ugly, initiating anothers connection backend from a connection feels just wrong
381
+ # did this for testability. 27.11.2013
382
+ #
383
+ def connect_next
384
+ #free backend server and connect to next conn if matching conn exists
385
+ next_conn = connection_manager.free_backend(@backend)
386
+ unless next_conn.nil?
387
+ next_conn.connect_backendserver(@backend)
388
+ end
389
+ @backend = nil
390
+ end
391
+
392
+ # Tries to dispatch the connections first request to a BackendServer object. Usually this should not be called
393
+ # more than once per request.
394
+ #
395
+ # connects to the backend if a BackendServer object is available
396
+ #
397
+ # logs if the connection got queued
398
+ #
399
+ # sends a 404 Error to client if no registered BackendServer matches the request
400
+ def dispatch_request
401
+ backend = connection_manager.backend_available?(get_request)
402
+
403
+ if backend.is_a?(BackendServer)
404
+ log.debug [msec + "Backend found"]
405
+ connect_backendserver(backend)
406
+ elsif backend == :queued
407
+ log.debug [msec + "pushed on queue: no backend available"]
408
+ else
409
+ log.error msec + "Error, send client error message and unbind! No backend will match"
410
+ error_page = "HTTP/1.1 404 Not Found\r\nContent-Length: #{config.error_pages[404].length}\r\nContent-Type: text/html;charset=utf-8\r\nConnection: close\r\n\r\n"
411
+ unless get_request.header[:http_method].eql? "HEAD"
412
+ error_page << config.error_pages[404]
413
+ end
414
+ log.error [msec, :error_to_client, error_page]
415
+ send_data error_page
416
+ close
417
+ end
418
+ end
419
+
420
+ # initializes http parser callbacks and blocks
421
+ def init_http_parser
422
+
423
+ @request_parser.on_message_begin = proc do
424
+ @requests.push(Request.new(self))
425
+ # this log also triggers if client sends new keep-alive request before backend was unbound
426
+ log.info [msec + "pipelined request"] if @requests.length > 1
427
+ log.debug [msec, :message_begin]
428
+ end
429
+
430
+ #called when request headers are completely parsed (first \r\n\r\n triggers this)
431
+ @request_parser.on_headers_complete = proc do |h|
432
+ log.info [msec, "new Request @#{@signature} Host: " + h["Host"] + " Request Path: " + @request_parser.request_url]
433
+ request = @requests.last
434
+
435
+ # cache if client wants persistent connection
436
+ if @request_parser.http_version[0] == 1 && @request_parser.http_version[1] == 0
437
+ request.keep_alive = false unless h["Connection"].to_s.downcase.eql? "keep-alive"
438
+ else
439
+ request.keep_alive = false if h["Connection"].to_s.downcase.include? "close"
440
+ end
441
+ request.update_header({:Connection => "close"}) #update Connection header to close for backends
442
+
443
+ # if there is a transfer-encoding, stream the message as Transfer-Encoding: chunked to backends
444
+ unless h["Transfer-Encoding"].nil?
445
+ h.delete("Content-Length")
446
+ request.chunked = true
447
+ request.update_header({:"Transfer-Encoding" => "chunked"})
448
+ end
449
+
450
+ # remove all hop-by-hop header fields
451
+ unless h["Connection"].nil?
452
+ h["Connection"].each do |s|
453
+ h.delete(s)
454
+ end
455
+ end
456
+ HOP_HEADERS.each do |s|
457
+ h.delete(s)
458
+ end
459
+
460
+
461
+ via = h.delete("Via")
462
+ if via.nil?
463
+ via = "1.1 experella"
464
+ else
465
+ via << "1.1 experella"
466
+ end
467
+ request.update_header({:Via => via})
468
+
469
+ request.update_header(h)
470
+ request.update_header({:http_version => @request_parser.http_version})
471
+ request.update_header({:http_method => @request_parser.http_method}) # for requests
472
+ request.update_header({:request_url => @request_parser.request_url})
473
+ if @request_parser.request_url.include? "http://"
474
+ u = URI.parse(@request_parser.request_url)
475
+ request.update_header(:Host => u.host)
476
+ log.debug [msec, "Host set to absolut host from request_url", u.host]
477
+ else
478
+ u = URI.parse("http://" + h["Host"] + @request_parser.request_url)
479
+ end
480
+
481
+ request.add_uri(:port => u.port, :path => u.path, :query => u.query)
482
+
483
+
484
+ # try to connect request to backend
485
+ # but only try to connect if this (.last) equals (.first), true at length == 1
486
+ # according to http-protocol requests must always be handled in order.
487
+ if @requests.length == 1
488
+ dispatch_request
489
+ end
490
+
491
+ log.debug [msec, :on_header_complete, @requests.size]
492
+ end
493
+
494
+ @request_parser.on_body = proc do |chunk|
495
+ request = @requests.last
496
+ if request.chunked
497
+ # add hexadecimal chunk size
498
+ request << chunk.size.to_s(16)
499
+ request << "\r\n"
500
+ request << chunk
501
+ request << "\r\n"
502
+ else
503
+ request << chunk
504
+ end
505
+ end
506
+
507
+ @request_parser.on_message_complete = proc do
508
+ request = @requests.last
509
+ if request.chunked
510
+ # add closing chunk
511
+ request << "0\r\n\r\n"
512
+ end
513
+ end
514
+
515
+ end
516
+
517
+ # This method sends the first requests send_buffer to the backend server, if
518
+ # any backend is set and there is request data to dispatch
519
+ #
520
+ # Request header will be reconstructed here before dispatch
521
+ #
522
+ # If the backend server is not yet connected, data is already buffered to be sent when the connection gets established
523
+ #
524
+ def relay_to_server
525
+ log.debug [:relay_to_server, "@" + @signature.to_s, @backend, @requests.empty?, get_request.flushed?, @server.nil?]
526
+ if @backend && !@requests.empty? && !get_request.flushed? && !@server.nil?
527
+ # save some memory here if logger isn't set on debug
528
+ if log.debug?
529
+ data = get_request.flush
530
+ @server.send_data data
531
+ log.debug [msec, :data_to_server_send, "@" + @signature.to_s, data]
532
+ else
533
+ @server.send_data get_request.flush
534
+ end
535
+ end
536
+ end
537
+
538
+ #returns milliseconds since connection startup as string
539
+ def msec
540
+ (((Time.now.tv_sec - @start.tv_sec) * 1000) + ((Time.now.tv_usec - @start.tv_usec) / 1000.0)).to_s + "ms: "
541
+ end
542
+
543
+ # starts the timeout timer and closes connection if timeout was exceeded
544
+ def timeout_timer
545
+ timer = EventMachine::PeriodicTimer.new(1) do
546
+ if get_idle_time.nil?
547
+ timer.cancel
548
+ elsif get_idle_time > config.timeout
549
+ log.info [msec, :unbind_client, :timeout, "@" + @signature.to_s]
550
+ timer.cancel
551
+ close
552
+ end
553
+ end
554
+ end
555
+
556
+ end
557
+ end