├── .batcave └── manifest ├── .gitignore ├── .travis.yml ├── Gemfile ├── Gemfile.lock ├── Makefile ├── README.md ├── certs └── cacert.pem ├── examples ├── config.ru ├── connection.rb ├── embedded-sinatra.rb ├── es.rb ├── flood.rb ├── ftw.rb ├── log-to-websockets.rb ├── logstash.rb ├── lsread.rb ├── lstime.rb ├── request-body-reader.rb ├── server.rb ├── sinatra-example.rb ├── web.rb ├── websocket.rb └── wss.rb ├── ftw.gemspec ├── lib ├── ftw.rb ├── ftw │ ├── agent.rb │ ├── agent │ │ └── configuration.rb │ ├── cacert.pem │ ├── connection.rb │ ├── cookies.rb │ ├── crlf.rb │ ├── dns.rb │ ├── dns │ │ ├── dns.rb │ │ └── hash.rb │ ├── http │ │ ├── headers.rb │ │ └── message.rb │ ├── namespace.rb │ ├── pool.rb │ ├── poolable.rb │ ├── protocol.rb │ ├── request.rb │ ├── response.rb │ ├── server.rb │ ├── singleton.rb │ ├── version.rb │ ├── webserver.rb │ ├── websocket.rb │ └── websocket │ │ ├── constants.rb │ │ ├── parser.rb │ │ ├── rack.rb │ │ └── writer.rb └── rack │ └── handler │ └── ftw.rb ├── notify-failure.sh ├── spec ├── fixtures │ └── websockets.rb ├── ftw-agent_spec.rb ├── integration │ └── websockets_spec.rb └── webserver_spec.rb ├── test.rb └── test ├── all.rb ├── docs.rb ├── ftw ├── crlf.rb ├── http │ ├── dns.rb │ └── headers.rb ├── protocol.rb └── singleton.rb └── testing.rb /.batcave/manifest: -------------------------------------------------------------------------------- 1 | --- 2 | things: 3 | ruby: 4 | args: 5 | - ftw 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | *.gem 3 | *.class 4 | *.tar.gz 5 | *.jar 6 | .bundle 7 | .rbx 8 | build 9 | .sass-cache 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | #- 1.8.7 4 | - 1.9.2 5 | - 1.9.3 6 | #- jruby-18mode # JRuby in 1.8 mode 7 | #- jruby-19mode # JRuby in 1.9 mode 8 | #- rbx-18mode 9 | # - rbx-19mode # currently in active development, may or may not work for your project 10 | script: bundle exec make test && bundle exec rspec 11 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source :rubygems 2 | 3 | gem "json" # for json 4 | gem "cabin", "0.4.4" # for logging 5 | gem "http_parser.rb", "~> 0.6" # for http request/response parsing 6 | gem "addressable", ">= 2.4" # because stdlib URI is terrible 7 | gem "backports", "2.6.2" # for hacking stuff in to ruby <1.9 8 | gem "minitest" # for unit tests, latest of this is fine 9 | 10 | group :testing do 11 | gem "simplecov" 12 | gem "yard" 13 | gem "insist" 14 | gem "rspec", "~>2" 15 | gem "stud" 16 | gem "awesome_print" 17 | gem "pry" 18 | end 19 | 20 | group :examples do 21 | gem "sinatra" 22 | end 23 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: http://rubygems.org/ 3 | specs: 4 | addressable (2.6.0) 5 | public_suffix (>= 2.0.2, < 4.0) 6 | awesome_print (1.8.0) 7 | backports (2.6.2) 8 | cabin (0.4.4) 9 | json 10 | coderay (1.1.2) 11 | diff-lcs (1.3) 12 | docile (1.1.5) 13 | ffi (1.9.21-java) 14 | http_parser.rb (0.6.0) 15 | http_parser.rb (0.6.0-java) 16 | insist (1.0.0) 17 | json (2.1.0) 18 | json (2.1.0-java) 19 | method_source (0.9.0) 20 | minitest (5.11.3) 21 | mustermann (1.0.1) 22 | pry (0.11.3) 23 | coderay (~> 1.1.0) 24 | method_source (~> 0.9.0) 25 | pry (0.11.3-java) 26 | coderay (~> 1.1.0) 27 | method_source (~> 0.9.0) 28 | spoon (~> 0.0) 29 | public_suffix (3.1.1) 30 | rack (2.0.4) 31 | rack-protection (2.0.0) 32 | rack 33 | rspec (2.99.0) 34 | rspec-core (~> 2.99.0) 35 | rspec-expectations (~> 2.99.0) 36 | rspec-mocks (~> 2.99.0) 37 | rspec-core (2.99.2) 38 | rspec-expectations (2.99.2) 39 | diff-lcs (>= 1.1.3, < 2.0) 40 | rspec-mocks (2.99.4) 41 | simplecov (0.15.1) 42 | docile (~> 1.1.0) 43 | json (>= 1.8, < 3) 44 | simplecov-html (~> 0.10.0) 45 | simplecov-html (0.10.2) 46 | sinatra (2.0.0) 47 | mustermann (~> 1.0) 48 | rack (~> 2.0) 49 | rack-protection (= 2.0.0) 50 | tilt (~> 2.0) 51 | spoon (0.0.6) 52 | ffi 53 | stud (0.0.23) 54 | tilt (2.0.8) 55 | yard (0.9.12) 56 | 57 | PLATFORMS 58 | java 59 | ruby 60 | 61 | DEPENDENCIES 62 | addressable (>= 2.4) 63 | awesome_print 64 | backports (= 2.6.2) 65 | cabin (= 0.4.4) 66 | http_parser.rb (~> 0.6) 67 | insist 68 | json 69 | minitest 70 | pry 71 | rspec (~> 2) 72 | simplecov 73 | sinatra 74 | stud 75 | yard 76 | 77 | BUNDLED WITH 78 | 2.0.2 79 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GEMSPEC=$(shell ls *.gemspec) 2 | VERSION=$(shell ruby -rrubygems -e 'puts Gem::Specification.load("ftw.gemspec").version') 3 | NAME=$(shell awk -F\" '/spec.name/ { print $$2 }' $(GEMSPEC)) 4 | GEM=$(NAME)-$(VERSION).gem 5 | 6 | .PHONY: test 7 | test: 8 | sh notify-failure.sh ruby test/all.rb 9 | 10 | .PHONY: testloop 11 | testloop: 12 | while true; do \ 13 | $(MAKE) test; \ 14 | $(MAKE) wait-for-changes; \ 15 | done 16 | 17 | .PHONY: serve-coverage 18 | serve-coverage: 19 | cd coverage; python -mSimpleHTTPServer 20 | 21 | .PHONY: wait-for-changes 22 | wait-for-changes: 23 | -inotifywait --exclude '\.swp' -e modify $$(find $(DIRS) -name '*.rb'; find $(DIRS) -type d) 24 | 25 | certs/cacert.pem: 26 | wget -O certs/cacert.pem http://curl.haxx.se/ca/cacert.pem 27 | 28 | .PHONY: package 29 | package: | $(GEM) 30 | 31 | .PHONY: gem 32 | gem: $(GEM) 33 | 34 | $(GEM): 35 | gem build $(GEMSPEC) 36 | 37 | .PHONY: test-package 38 | test-package: $(GEM) 39 | # Sometimes 'gem build' makes a faulty gem. 40 | gem unpack $(GEM) 41 | rm -rf ftw-$(VERSION)/ 42 | 43 | .PHONY: publish 44 | publish: test-package 45 | gem push $(GEM) 46 | 47 | .PHONY: install 48 | install: $(GEM) 49 | gem install $(GEM) 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # For The Web 2 | 3 | ## Getting Started 4 | 5 | * For web agents: {FTW::Agent} 6 | * For dns: {FTW::DNS} 7 | * For tcp connections: {FTW::Connection} 8 | * For tcp servers: {FTW::Server} 9 | 10 | ## Overview 11 | 12 | net/http is pretty much not good. Additionally, DNS behavior in ruby changes quite frequently. 13 | 14 | I primarily want two things in both client and server operations: 15 | 16 | * A consistent API with good documentation, readable code, and high quality tests. 17 | * Modern web features: websockets, spdy, etc. 18 | 19 | Desired features: 20 | 21 | * Awesome documentation 22 | * A HTTP client that acts as a full user agent, not just a single connections. (With connection reuse) 23 | * HTTP and SPDY support. 24 | * WebSockets support. 25 | * SSL/TLS support. 26 | * Browser Agent features like cookies and caching 27 | * An API that lets me do what I need. 28 | * Server and Client modes. 29 | * Support for both normal operation and EventMachine would be nice. 30 | 31 | For reference: 32 | 33 | * [DNS in Ruby stdlib is broken](https://github.com/jordansissel/experiments/tree/master/ruby/dns-resolving-bug), so I need to provide my own DNS api. 34 | 35 | ## Agent API 36 | 37 | Reference: {FTW::Agent} 38 | 39 | ### Common case 40 | 41 | agent = FTW::Agent.new 42 | 43 | request = agent.get("http://www.google.com/") 44 | response = request.execute 45 | puts response.body.read 46 | 47 | # Simpler 48 | response = agent.get!("http://www.google.com/").read 49 | puts response.body.read 50 | 51 | ### SPDY 52 | 53 | * This is not implemented yet 54 | 55 | SPDY should automatically be attempted. The caller should be unaware. 56 | 57 | I do not plan on exposing any direct means for invoking SPDY. 58 | 59 | ### WebSockets 60 | 61 | # 'http(s)' or 'ws(s)' urls are valid here. They will mean the same thing. 62 | websocket = agent.websocket!("http://somehost/endpoint") 63 | 64 | websocket.publish("Hello world") 65 | websocket.each do |message| 66 | puts :received => message 67 | end 68 | 69 | ## Web Server API 70 | 71 | I have implemented a rack server, Rack::Handler::FTW. It does not comply fully 72 | with the Rack spec. See 'Rack Compliance Issues' below. 73 | 74 | Under the FTW rack handler, there is an environment variable added, 75 | "ftw.connection". This will be a FTW::Connection you can use for CONNECT, 76 | Upgrades, etc. 77 | 78 | There's also a websockets wrapper, FTW::WebSockets::Rack, that will help you 79 | specifically with websocket requests and such. 80 | 81 | ## Rack Compliance issues 82 | 83 | Due to some awkward and bad requirements - specifically those around the 84 | specified behavior of 'rack.input' - I can't support the rack specification fully. 85 | 86 | The 'rack.input' must be an IO-like object supporting #rewind which rewinds to 87 | the beginning of the request. 88 | 89 | For high-data connections (like uploads, HTTP CONNECT, and HTTP Upgrade), it's 90 | not practical to hold the entire history of time in a buffer. We'll run out of 91 | memory, you crazy fools! 92 | 93 | Details here: https://github.com/rack/rack/issues/347 94 | 95 | ## Other Projects 96 | 97 | Here are some related projects that I have no affiliation with: 98 | 99 | * https://github.com/igrigorik/em-websocket - websocket server for eventmachine 100 | * https://github.com/faye/faye - pubsub for the web (includes a websockets implementation) 101 | * https://github.com/faye/faye-websocket-ruby - websocket client and server in ruby 102 | * https://github.com/lifo/cramp - real-time web framework (async, websockets) 103 | * https://github.com/igrigorik/em-http-request - HTTP client for EventMachine 104 | * https://github.com/geemus/excon - http client library 105 | 106 | Given some of the above (especially the server-side stuff), I'm likely try and integrate 107 | with those projects. For example, writing a Faye handler that uses the FTW server, if the 108 | FTW web server even stays around. 109 | -------------------------------------------------------------------------------- /examples/config.ru: -------------------------------------------------------------------------------- 1 | require "sinatra/base" 2 | $: << File.join(File.dirname(__FILE__), "..", "lib") 3 | require "ftw/websocket/rack" 4 | 5 | class Foo < Sinatra::Application 6 | get "/" do 7 | ap env 8 | [200, {}, "OK"] 9 | end 10 | 11 | # Make an echo server over websockets. 12 | get "/websocket/echo" do 13 | ws = FTW::WebSocket::Rack.new(env) 14 | stream(:keep_open) do |out| 15 | ws.each do |payload| 16 | # 'payload' is the text payload of a single websocket message 17 | # publish it back to the client 18 | ws.publish(payload) 19 | end 20 | end 21 | ws.rack_response 22 | end 23 | end 24 | 25 | run Foo.new 26 | -------------------------------------------------------------------------------- /examples/connection.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | $: << File.join(File.dirname(__FILE__), "..", "lib") 3 | require "ftw" # gem ftw 4 | 5 | connection = FTW::Connection.new(ARGV[0]) 6 | tries = 4 7 | tries.times do 8 | error = connection.connect 9 | p error 10 | break if error.nil? 11 | end 12 | 13 | p connection.connected? 14 | -------------------------------------------------------------------------------- /examples/embedded-sinatra.rb: -------------------------------------------------------------------------------- 1 | require "sinatra/base" 2 | $:.unshift File.join(File.dirname(__FILE__), "..", "lib") 3 | require "ftw/websocket/rack" 4 | require "cabin" 5 | 6 | class App < Sinatra::Base 7 | # Make an echo server over websockets. 8 | get "/websocket/echo" do 9 | ws = FTW::WebSocket::Rack.new(env) 10 | stream(:keep_open) do |out| 11 | ws.each do |payload| 12 | # 'payload' is the text payload of a single websocket message 13 | # publish it back to the client 14 | ws.publish(payload) 15 | end 16 | end 17 | ws.rack_response 18 | end 19 | end 20 | 21 | # Run the sinatra app in another thread 22 | require "rack/handler/ftw" 23 | Thread.new do 24 | Rack::Handler::FTW.run(App.new, :Host => "0.0.0.0", :Port => 8080) 25 | end 26 | 27 | logger = Cabin::Channel.get 28 | logger.level = :info 29 | logger.subscribe(STDOUT) 30 | 31 | agent = FTW::Agent.new 32 | ws = agent.websocket!("http://127.0.0.1:8080/websocket/echo") 33 | ws.publish("Hello") 34 | ws.each do |message| 35 | p message 36 | end 37 | -------------------------------------------------------------------------------- /examples/es.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | $: << File.join(File.dirname(__FILE__), "..", "lib") 3 | require "ftw" # gem ftw 4 | require "cabin" # gem cabin 5 | 6 | agent = FTW::Agent.new 7 | 8 | logger = Cabin::Channel.new 9 | logger.subscribe(Logger.new(STDOUT)) 10 | 11 | worker_count = 24 12 | queue = Queue.new 13 | threads = worker_count.times.collect do 14 | Thread.new(queue) do |queue| 15 | while true do 16 | data = queue.pop 17 | break if data == :quit 18 | request = agent.post("http://localhost:9200/foo/bar", :body => data.to_json) 19 | response = agent.execute(request) 20 | response.read_body { |a| } 21 | end 22 | end 23 | end 24 | 25 | 10000.times do |i| 26 | queue << { "hello" => "world", "value" => i } 27 | end 28 | 29 | worker_count.times { queue << :quit } 30 | threads.map(&:join) 31 | -------------------------------------------------------------------------------- /examples/flood.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | $: << File.join(File.dirname(__FILE__), "..", "lib") 3 | require "ftw" # gem ftw 4 | require "benchmark" 5 | 6 | if ARGV.length != 1 7 | puts "Usage: #{$0} " 8 | exit 1 9 | end 10 | 11 | agent = FTW::Agent.new 12 | url = ARGV[0] 13 | 14 | loop do 15 | result = Benchmark.measure do 16 | response = agent.get!(url) 17 | bytes = 0 18 | response.read_body do |chunk| 19 | bytes += chunk.size 20 | end 21 | end 22 | puts result 23 | end 24 | -------------------------------------------------------------------------------- /examples/ftw.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | $: << File.join(File.dirname(__FILE__), "..", "lib") 3 | require "ftw" # gem ftw 4 | require "cabin" # gem cabin 5 | require "logger" # ruby stdlib 6 | 7 | if ARGV.length != 1 8 | puts "Usage: #{$0} " 9 | exit 1 10 | end 11 | 12 | logger = Cabin::Channel.get 13 | logger.level=:info 14 | logger.subscribe(STDOUT) 15 | 16 | agent = FTW::Agent.new 17 | agent.configuration[FTW::Agent::SSL_VERSION] = "TLSv1.1" 18 | 19 | ARGV.each do |url| 20 | logger.time("Fetch #{url}") do 21 | response = agent.get!(url) 22 | bytes = 0 23 | response.read_body do |chunk| 24 | bytes += chunk.size 25 | end 26 | logger.info("Request complete", :body_length => bytes) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /examples/log-to-websockets.rb: -------------------------------------------------------------------------------- 1 | # This example uses the 'cabin' log library. 2 | # 3 | # Logs will appear on stdout and also be shipped to over websocket server. 4 | require "rubygems" 5 | require "cabin" 6 | require "thread" 7 | require "logger" 8 | 9 | $: << File.join(File.dirname(__FILE__), "..", "lib") 10 | require "ftw" 11 | 12 | if ARGV.length != 1 13 | $stderr.puts "Usage: #{$0} " 14 | exit 1 15 | end 16 | 17 | url = ARGV[0] 18 | 19 | agent = FTW::Agent.new 20 | logger = Cabin::Channel.new 21 | queue = Queue.new 22 | 23 | # Log to a queue *and* stdout 24 | logger.subscribe(queue) 25 | logger.subscribe(Logger.new(STDOUT)) 26 | 27 | # Start a thread that takes events from the queue and pushes 28 | # them in JSON format over a websocket. 29 | # 30 | # Logging to a queue and processing separately ensures logging does not block 31 | # the main application. 32 | Thread.new do 33 | ws = agent.websocket!(url) 34 | if ws.is_a?(FTW::Response) 35 | puts "WebSocket handshake failed. Here's the HTTP response:" 36 | puts ws 37 | exit 0 38 | end 39 | 40 | loop do 41 | event = queue.pop 42 | ws.publish(event.to_json) 43 | end 44 | end # websocket publisher thread 45 | 46 | while true 47 | logger.info("Hello world") 48 | sleep 1 49 | end 50 | -------------------------------------------------------------------------------- /examples/logstash.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | require "rubygems" 3 | require "addressable/uri" 4 | require "json" 5 | require "date" 6 | 7 | $: << File.join(File.dirname(__FILE__), "..", "lib") 8 | require "ftw" 9 | 10 | agent = FTW::Agent.new 11 | Thread.abort_on_exception = true 12 | 13 | queue = Queue.new 14 | 15 | threads = [] 16 | ARGV.each do |arg| 17 | threads << Thread.new(arg, queue) do |arg, queue| 18 | uri = Addressable::URI.parse(arg) 19 | ws = agent.websocket!(uri) 20 | if ws.is_a?(FTW::Response) 21 | puts "WebSocket handshake failed. Here's the HTTP response:" 22 | puts "---" 23 | puts ws 24 | exit 0 25 | end 26 | ws.each do |payload| 27 | next if payload.nil? 28 | queue << JSON.parse(payload) 29 | end 30 | end 31 | end 32 | 33 | require "metriks" 34 | 35 | #meter = Metriks.meter("events") 36 | start = Time.now 37 | count = 0 38 | 39 | screen = [] 40 | metrics = Hash.new do |h, k| 41 | m = Metriks.meter(k) 42 | screen << m 43 | h[k] = m 44 | end 45 | 46 | ages = Hash.new do |h, k| 47 | screen << k 48 | h[k] = 0 49 | end 50 | 51 | blocks = [] 52 | %w{ ░ ▒ ▓ █ }.each_with_index do |block, i| 53 | 54 | # bleed over on some colors because a block[n] at color m is much darker than block[n+1] 55 | 8.times do |v| 56 | color = (i * 6) + v + 232 57 | break if color >= 256 58 | blocks << "\x1b[38;5;#{color}m#{block}\x1b[0m" 59 | end 60 | end 61 | puts blocks.join("") 62 | 63 | #.collect do |tick| 64 | ## 256 color support, use grayscale 65 | #24.times.collect do |shade| 66 | # '38' is foreground 67 | # '48' is background 68 | # Grey colors start at 232, but let's use the brighter half. 69 | # escape [ 38 ; 5 ; 70 | #"\x1b[38;5;#{232 + 12 + 2 * shade}m#{tick}\x1b[0m" 71 | #end 72 | #tick 73 | #end.flatten 74 | 75 | #overall = Metriks.meter("-overall-") 76 | start = Time.now 77 | #fakehost = Hash.new { |h,k| h[k] = "fringe#{rand(1000)}" } 78 | count = 0 79 | while true 80 | event = queue.pop 81 | count += 1 82 | host = event["@source_host"] 83 | #host = fakehost[event["@source_host"]] 84 | 85 | now = Time.now 86 | ages[host] # lame hack to append to screen 87 | ages[host] = now - DateTime.parse(event["@timestamp"]).to_time 88 | 89 | if count > 10000 90 | count = 0 91 | # on-screen-order values 92 | sov = screen.collect { |host| ages[host] } 93 | max = sov.max 94 | start = Time.now 95 | next if max == 0 96 | 97 | $stdout.write("\x1b[H\x1b[2J") 98 | puts "Hosts: #{ages.count}" 99 | worst5 = ages.sort_by { |k,v| -v }[0..5] 100 | puts "Worst 5 (hours): #{worst5.collect { |host, value| "#{host}(#{"%.1f" % (value / 60.0 / 60)})" }.join(", ") }" 101 | 102 | # Write the legend 103 | $stdout.write("Legend: "); 104 | (0..5).each do |i| 105 | v = (i * (max / 5.0)) 106 | block = blocks[((blocks.size - 1) * (i / 5.0)).floor] 107 | $stdout.write("%s %0.2f " % [block, v/60/60.0]) 108 | end 109 | puts 110 | $stdout.write(sov.collect do |value| 111 | if value < 0 112 | "\x1b[1;46m⚉\x1b[0m" 113 | else 114 | blocks[ ((blocks.size) * (value / max)).floor ] 115 | end 116 | end.join("")) 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /examples/lsread.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | require "rubygems" 3 | require "addressable/uri" 4 | require "json" 5 | 6 | $: << File.join(File.dirname(__FILE__), "..", "lib") 7 | require "ftw" 8 | require "metriks" 9 | 10 | agent = FTW::Agent.new 11 | Thread.abort_on_exception = true 12 | 13 | queue = Queue.new 14 | 15 | threads = [] 16 | ARGV.each do |arg| 17 | threads << Thread.new(arg, queue) do |arg, queue| 18 | uri = Addressable::URI.parse(arg) 19 | ws = agent.websocket!(uri) 20 | if ws.is_a?(FTW::Response) 21 | puts "WebSocket handshake failed. Here's the HTTP response:" 22 | puts "---" 23 | puts ws 24 | exit 0 25 | end 26 | ws.each do |payload| 27 | next if payload.nil? 28 | queue << JSON.parse(payload) 29 | end 30 | end 31 | end 32 | 33 | seen = Hash.new { |h,k| h[k] = 0 } 34 | count = 0 35 | while true 36 | event = queue.pop 37 | next unless event["@source_host"] == "seahawks" 38 | identity = event["@source_path"] + event["@message"] 39 | count += 1 40 | p count => event["@message"] 41 | #seen[identity] += 1 42 | #if seen[identity] > 2 43 | #p seen[identity] => event 44 | #end 45 | end 46 | 47 | -------------------------------------------------------------------------------- /examples/lstime.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | require "rubygems" 3 | require "addressable/uri" 4 | 5 | $: << File.join(File.dirname(__FILE__), "..", "lib") 6 | require "ftw" 7 | 8 | agent = FTW::Agent.new 9 | Thread.abort_on_exception = true 10 | 11 | queue = Queue.new 12 | 13 | threads = [] 14 | ARGV.each do |arg| 15 | threads << Thread.new(arg, queue) do |arg, queue| 16 | uri = Addressable::URI.parse(arg) 17 | ws = agent.websocket!(uri) 18 | if ws.is_a?(FTW::Response) 19 | puts "WebSocket handshake failed. Here's the HTTP response:" 20 | puts "---" 21 | puts ws 22 | exit 0 23 | end 24 | ws.each do |payload| 25 | next if payload.nil? 26 | queue << JSON.parse(payload) 27 | end 28 | end 29 | end 30 | 31 | require "metriks" 32 | 33 | #meter = Metriks.meter("events") 34 | start = Time.now 35 | count = 0 36 | 37 | screen = [] 38 | metrics = Hash.new do |h, k| 39 | m = Metriks.meter(k) 40 | screen << m 41 | h[k] = m 42 | end 43 | 44 | blocks = [] 45 | %w{ ░ ▒ ▓ █ }.each_with_index do |block, i| 46 | 47 | # bleed over on some colors because a block[n] at color m is much darker than block[n+1] 48 | 8.times do |v| 49 | color = (i * 6) + v + 232 50 | break if color > 256 51 | blocks << "\x1b[38;5;#{color}m#{block}\x1b[0m" 52 | end 53 | end 54 | puts blocks.join("") 55 | 56 | #.collect do |tick| 57 | ## 256 color support, use grayscale 58 | #24.times.collect do |shade| 59 | # '38' is foreground 60 | # '48' is background 61 | # Grey colors start at 232, but let's use the brighter half. 62 | # escape [ 38 ; 5 ; 63 | #"\x1b[38;5;#{232 + 12 + 2 * shade}m#{tick}\x1b[0m" 64 | #end 65 | #tick 66 | #end.flatten 67 | 68 | overall = Metriks.meter("-overall-") 69 | require "date" 70 | start = Time.now 71 | 72 | hush = Hash.new { |h,k| h[k] = Time.at(0) } 73 | while true 74 | event = queue.pop 75 | now = Time.now 76 | age = now - DateTime.parse(event["@timestamp"]).to_time 77 | days = (age / 24.0 / 60.0 / 60.0) 78 | host = event["@source_host"] 79 | if age > 300 && (now - hush[host]) > 10 80 | puts age => [event["@fields"]["lumberjack"], event["@source_host"]] 81 | hush[host] = now 82 | $stdout.flush 83 | end 84 | end 85 | 86 | -------------------------------------------------------------------------------- /examples/request-body-reader.rb: -------------------------------------------------------------------------------- 1 | require "ftw" 2 | 3 | $stdout.sync = true 4 | server = FTW::WebServer.new("0.0.0.0", ENV["PORT"].to_i || 8888) do |request, response| 5 | puts request.headers 6 | 7 | request.read_body do |chunk| 8 | puts "Chunk: #{chunk.inspect}" 9 | end 10 | 11 | response.status = 200 12 | response.body = "Done!" 13 | end 14 | 15 | server.run 16 | 17 | -------------------------------------------------------------------------------- /examples/server.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | $: << File.join(File.dirname(__FILE__), "..", "lib") 3 | require "ftw" # gem ftw 4 | require "cabin" # gem cabin 5 | require "logger" # ruby stdlib 6 | 7 | server = FTW::Server.new("localhost:8080") 8 | 9 | server.each_connection do |connection| 10 | connection.write("Hello") 11 | connection.disconnect("normal") 12 | end 13 | -------------------------------------------------------------------------------- /examples/sinatra-example.rb: -------------------------------------------------------------------------------- 1 | require "sinatra" 2 | $: << File.join(File.dirname(__FILE__), "..", "lib") 3 | require "ftw/websocket/rack" 4 | 5 | # Using the FTW rack server is required for this websocket support. 6 | set :server, :FTW 7 | 8 | # Make an echo server over websockets. 9 | get "/websocket/echo" do 10 | ws = FTW::WebSocket::Rack.new(env) 11 | stream(:keep_open) do |out| 12 | ws.each do |payload| 13 | # 'payload' is the text payload of a single websocket message 14 | # publish it back to the client 15 | ws.publish(payload) 16 | end 17 | end 18 | ws.rack_response 19 | end 20 | -------------------------------------------------------------------------------- /examples/web.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | $:.unshift File.join(File.dirname(__FILE__), "..", "lib") 3 | require "ftw" # gem ftw 4 | 5 | server = FTW::WebServer.new("0.0.0.0", 8888) do |request, response| 6 | response.status = 200 7 | response.body = "Hello world" 8 | end 9 | 10 | server.run 11 | -------------------------------------------------------------------------------- /examples/websocket.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "addressable/uri" 3 | 4 | $: << File.join(File.dirname(__FILE__), "..", "lib") 5 | require "ftw" 6 | 7 | agent = FTW::Agent.new 8 | uri = Addressable::URI.parse(ARGV[0]) 9 | ws = agent.websocket!(uri) 10 | if ws.is_a?(FTW::Response) 11 | puts "WebSocket handshake failed. Here's the HTTP response:" 12 | puts "---" 13 | puts ws 14 | exit 0 15 | end 16 | 17 | iterations = 100000 18 | 19 | # Start a thread to publish messages over the websocket 20 | Thread.new do 21 | iterations.times do |i| 22 | ws.publish({ "time" => Time.now.to_f}.to_json) 23 | end 24 | end 25 | 26 | count = 0 27 | start = Time.now 28 | 29 | # For each message, keep a count and report the rate of messages coming in. 30 | ws.each do |payload| 31 | data = JSON.parse(payload) 32 | count += 1 33 | 34 | if count % 5000 == 0 35 | p :rate => (count / (Time.now - start)), :total => count 36 | break if count == iterations 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /examples/wss.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "eventmachine" 3 | require "em-websocket" 4 | require "cabin" 5 | require "logger" 6 | 7 | logger = Cabin::Channel.new 8 | logger.subscribe(Logger.new(STDOUT)) 9 | 10 | EventMachine.run do 11 | EventMachine::WebSocket.start(:host => "0.0.0.0", :port => 8081) do |ws| 12 | ws.onopen do |*args| 13 | p :onopen => self, :args => args 14 | puts "WebSocket connection open" 15 | end 16 | 17 | ws.onclose { puts "Connection closed" } 18 | ws.onmessage do |msg| 19 | data = JSON.parse(msg) 20 | logger.info(data) 21 | end 22 | end 23 | end 24 | 25 | -------------------------------------------------------------------------------- /ftw.gemspec: -------------------------------------------------------------------------------- 1 | $: << File.join(File.dirname(__FILE__), "lib") 2 | require "ftw/version" # For FTW::VERSION 3 | 4 | Gem::Specification.new do |spec| 5 | files = [] 6 | paths = %w{lib test README.md} 7 | paths.each do |path| 8 | if File.file?(path) 9 | files << path 10 | else 11 | files += Dir["#{path}/**/*"] 12 | end 13 | end 14 | 15 | spec.name = "ftw" 16 | spec.version = FTW::VERSION 17 | spec.description = "For The Web. Trying to build a solid and sane API for client and server web stuff. Client and Server operations for HTTP, WebSockets, SPDY, etc." 18 | spec.summary = spec.description 19 | spec.license = "Apache License (2.0)" 20 | 21 | spec.add_dependency("cabin", ">0") # for logging, latest is fine for now 22 | spec.add_dependency("http_parser.rb", "~> 0.6") # for http request/response parsing 23 | spec.add_dependency("addressable", ">= 2.4") # because stdlib URI is terrible 24 | spec.add_dependency("backports", ">= 2.6.2") # for hacking stuff into ruby <1.9 25 | spec.add_development_dependency("minitest", ">0") # for unit tests, latest of this is fine 26 | 27 | spec.files = files 28 | spec.require_paths << "lib" 29 | #spec.bindir = "bin" 30 | 31 | spec.authors = ["Jordan Sissel"] 32 | spec.email = ["jls@semicomplete.com"] 33 | spec.homepage = "http://github.com/jordansissel/ruby-ftw" 34 | end 35 | -------------------------------------------------------------------------------- /lib/ftw.rb: -------------------------------------------------------------------------------- 1 | require "ftw/agent" 2 | require "ftw/connection" 3 | require "ftw/dns" 4 | require "ftw/version" 5 | require "ftw/server" 6 | require "ftw/webserver" 7 | -------------------------------------------------------------------------------- /lib/ftw/agent.rb: -------------------------------------------------------------------------------- 1 | require "ftw/namespace" 2 | require "ftw/request" 3 | require "ftw/connection" 4 | require "ftw/protocol" 5 | require "ftw/pool" 6 | require "ftw/websocket" 7 | require "addressable/uri" 8 | require "cabin" 9 | require "openssl" 10 | 11 | # This should act as a proper web agent. 12 | # 13 | # * Reuse connections. 14 | # * SSL/TLS. 15 | # * HTTP Upgrade support. 16 | # * HTTP 1.1 (RFC2616). 17 | # * WebSockets (RFC6455). 18 | # * Support Cookies. 19 | # 20 | # All standard HTTP methods defined by RFC2616 are available as methods on 21 | # this agent: get, head, put, etc. 22 | # 23 | # Example: 24 | # 25 | # agent = FTW::Agent.new 26 | # request = agent.get("http://www.google.com/") 27 | # response = agent.execute(request) 28 | # puts response.body.read 29 | # 30 | # For any standard http method (like 'get') you can invoke it with '!' on the end 31 | # and it will execute and return a FTW::Response object: 32 | # 33 | # agent = FTW::Agent.new 34 | # response = agent.get!("http://www.google.com/") 35 | # puts response.body.head 36 | # 37 | # TODO(sissel): TBD: implement cookies... delicious chocolate chip cookies. 38 | class FTW::Agent 39 | include FTW::Protocol 40 | require "ftw/agent/configuration" 41 | include FTW::Agent::Configuration 42 | 43 | # Thrown when too many redirects are encountered 44 | # See also {FTW::Agent::Configuration::REDIRECTION_LIMIT} 45 | class TooManyRedirects < StandardError 46 | attr_accessor :response 47 | def initialize(reason, response) 48 | super(reason) 49 | @response = response 50 | end 51 | end 52 | 53 | # List of standard HTTP methods described in RFC2616 54 | STANDARD_METHODS = %w(options get head post put delete trace connect) 55 | 56 | # Everything is private by default. 57 | # At the bottom of this class, public methods will be declared. 58 | private 59 | 60 | def initialize 61 | @pool = FTW::Pool.new 62 | @logger = Cabin::Channel.get 63 | 64 | configuration[REDIRECTION_LIMIT] = 20 65 | 66 | end # def initialize 67 | 68 | # Verify a certificate. 69 | # 70 | # host => the host (string) 71 | # port => the port (number) 72 | # verified => true/false, was this cert verified by our certificate store? 73 | # context => an OpenSSL::SSL::StoreContext 74 | def certificate_verify(host, port, verified, context) 75 | # Now verify the entire chain. 76 | begin 77 | @logger.debug("Verify peer via OpenSSL::X509::Store", 78 | :verified => verified, :chain => context.chain.collect { |c| c.subject }, 79 | :context => context, :depth => context.error_depth, 80 | :error => context.error, :string => context.error_string) 81 | # Untrusted certificate; prompt to accept if possible. 82 | if !verified and STDOUT.tty? 83 | # TODO(sissel): Factor this out into a verify callback where this 84 | # happens to be the default. 85 | 86 | puts "Untrusted certificate found; here's what I know:" 87 | puts " Why it's untrusted: (#{context.error}) #{context.error_string}" 88 | 89 | if context.error_string =~ /local issuer/ 90 | puts " Missing cert for issuer: #{context.current_cert.issuer}" 91 | puts " Issuer hash: #{context.current_cert.issuer.hash.to_s(16)}" 92 | else 93 | puts " What you think it's for: #{host} (port #{port})" 94 | cn = context.chain[0].subject.to_s.split("/").grep(/^CN=/).first.split("=",2).last rescue "" 95 | puts " What it's actually for: #{cn}" 96 | end 97 | 98 | puts " Full chain:" 99 | context.chain.each_with_index do |cert, i| 100 | puts " Subject(#{i}): [#{cert.subject.hash.to_s(16)}] #{cert.subject}" 101 | end 102 | print "Trust? [(N)o/(Y)es/(P)ersistent] " 103 | 104 | system("stty raw") 105 | answer = $stdin.getc.downcase 106 | system("stty sane") 107 | puts 108 | 109 | if ["y", "p"].include?(answer) 110 | # TODO(sissel): Factor this out into Agent::Trust or somesuch 111 | context.chain.each do |cert| 112 | # For each certificate, add it to the in-process certificate store. 113 | begin 114 | certificate_store.add_cert(cert) 115 | rescue OpenSSL::X509::StoreError => e 116 | # If the cert is already trusted, move along. 117 | if e.to_s != "cert already in hash table" 118 | raise # this is a real error, reraise. 119 | end 120 | end 121 | 122 | # TODO(sissel): Factor this out into Agent::Trust or somesuch 123 | # For each certificate, if persistence is requested, write the cert to 124 | # the configured ssl trust store (usually ~/.ftw/ssl-trust.db/) 125 | if answer == "p" # persist this trusted cert 126 | require "fileutils" 127 | if !File.directory?(configuration[SSL_TRUST_STORE]) 128 | FileUtils.mkdir_p(configuration[SSL_TRUST_STORE]) 129 | end 130 | 131 | # openssl verify recommends the 'ca path' have files named by the 132 | # hashed subject name. Turns out openssl really expects the 133 | # hexadecimal version of this. 134 | name = File.join(configuration[SSL_TRUST_STORE], cert.subject.hash.to_s(16)) 135 | # Find a filename that doesn't exist. 136 | num = 0 137 | num += 1 while File.exists?("#{name}.#{num}") 138 | 139 | # Write it out 140 | path = "#{name}.#{num}" 141 | @logger.info("Persisting certificate", :subject => cert.subject, :path => path) 142 | File.write(path, cert.to_pem) 143 | end # if answer == "p" 144 | end # context.chain.each 145 | return true 146 | end # if answer was "y" or "p" 147 | end # if !verified and stdout is a tty 148 | 149 | return verified 150 | rescue => e 151 | # We have to rescue all and emit because openssl verify_callback ignores 152 | # exceptions silently 153 | @logger.error(e) 154 | return verified 155 | end 156 | end # def certificate_verify 157 | 158 | # Define all the standard HTTP methods (Per RFC2616) 159 | # As an example, for "get" method, this will define these methods: 160 | # 161 | # * FTW::Agent#get(uri, options={}) 162 | # * FTW::Agent#get!(uri, options={}) 163 | # 164 | # The first one returns a FTW::Request you must pass to Agent#execute(...) 165 | # The second does the execute for you and returns a FTW::Response. 166 | # 167 | # For a full list of these available methods, see STANDARD_METHODS. 168 | # 169 | STANDARD_METHODS.each do |name| 170 | m = name.upcase 171 | 172 | # 'def get' (put, post, etc) 173 | public 174 | define_method(name.to_sym) do |uri, options={}| 175 | return request(m, uri, options) 176 | end 177 | 178 | # 'def get!' (put!, post!, etc) 179 | public 180 | define_method("#{name}!".to_sym) do |uri, options={}| 181 | return execute(request(m, uri, options)) 182 | end 183 | end # STANDARD_METHODS.each 184 | 185 | # Send the request as an HTTP upgrade. 186 | # 187 | # Returns the response and the FTW::Connection for this connection. 188 | # If the upgrade was denied, the connection returned will be nil. 189 | def upgrade!(uri, protocol, options={}) 190 | req = request("GET", uri, options) 191 | req.headers["Connection"] = "Upgrade" 192 | req.headers["Upgrade"] = protocol 193 | response = execute(req) 194 | if response.status == 101 195 | # Success, return the response object and the connection to hand off. 196 | return response, response.body 197 | else 198 | return response, nil 199 | end 200 | end # def upgrade! 201 | 202 | # Make a new websocket connection. 203 | # 204 | # This will send the http request. If the websocket handshake 205 | # is successful, a FTW::WebSocket instance will be returned. 206 | # Otherwise, a FTW::Response will be returned. 207 | # 208 | # See {#request} for what the 'uri' and 'options' parameters should be. 209 | def websocket!(uri, options={}) 210 | # TODO(sissel): Use FTW::Agent#upgrade! ? 211 | req = request("GET", uri, options) 212 | ws = FTW::WebSocket.new(req) 213 | response = execute(req) 214 | if ws.handshake_ok?(response) 215 | # response.body is a FTW::Connection 216 | ws.connection = response.body 217 | 218 | # There seems to be a bug in http_parser.rb where websocket responses 219 | # lead with a newline for some reason. It's like the header terminator 220 | # CRLF still has the LF character left in the buffer. Work around it. 221 | data = response.body.read 222 | if data[0] == "\n" 223 | response.body.pushback(data[1..-1]) 224 | else 225 | response.body.pushback(data) 226 | end 227 | 228 | return ws 229 | else 230 | return response 231 | end 232 | end # def websocket! 233 | 234 | # Build a request. Returns a FTW::Request object. 235 | # 236 | # Arguments: 237 | # 238 | # * method - the http method 239 | # * uri - the URI to make the request to 240 | # * options - a hash of options 241 | # 242 | # uri can be a valid url or an Addressable::URI object. 243 | # The uri will be used to choose the host/port to connect to. It also sets 244 | # the protocol (https, etc). Further, it will set the 'Host' header. 245 | # 246 | # The 'options' hash supports the following keys: 247 | # 248 | # * :headers => { string => string, ... }. This allows you to set header values. 249 | def request(method, uri, options) 250 | @logger.info("Creating new request", :method => method, :uri => uri, :options => options) 251 | request = FTW::Request.new(uri) 252 | request.method = method 253 | request.headers.add("Connection", "keep-alive") 254 | 255 | if options.include?(:headers) 256 | options[:headers].each do |key, value| 257 | request.headers.add(key, value) 258 | end 259 | end 260 | 261 | if options.include?(:body) 262 | request.body = options[:body] 263 | end 264 | 265 | return request 266 | end # def request 267 | 268 | # Execute a FTW::Request in this Agent. 269 | # 270 | # If an existing, idle connection is already open to the target server 271 | # of this Request, it will be reused. Otherwise, a new connection 272 | # is opened. 273 | # 274 | # Redirects are always followed. 275 | # 276 | # @param [FTW::Request] 277 | # @return [FTW::Response] the response for this request. 278 | def execute(request) 279 | # TODO(sissel): Make redirection-following optional, but default. 280 | 281 | tries = 3 282 | begin 283 | connection, error = connect(request.headers["Host"], request.port, 284 | request.protocol == "https") 285 | if !error.nil? 286 | p :error => error 287 | raise error 288 | end 289 | response = request.execute(connection) 290 | rescue EOFError => e 291 | tries -= 1 292 | @logger.warn("Error while sending request, will retry.", 293 | :tries_left => tries, 294 | :exception => e) 295 | retry if tries > 0 296 | end 297 | 298 | redirects = 0 299 | # Follow redirects 300 | while response.redirect? and response.headers.include?("Location") 301 | # RFC2616 section 10.3.3 indicates HEAD redirects must not include a 302 | # body. Otherwise, the redirect response can have a body, so let's 303 | # throw it away. 304 | if request.method == "HEAD" 305 | # Head requests have no body 306 | connection.release 307 | elsif response.content? 308 | # Throw away the body 309 | response.body = connection 310 | # read_body will consume the body and release this connection 311 | response.read_http_body { |chunk| } 312 | end 313 | 314 | # TODO(sissel): If this response has any cookies, store them in the 315 | # agent's cookie store 316 | 317 | redirects += 1 318 | if redirects > configuration[REDIRECTION_LIMIT] 319 | # TODO(sissel): include original a useful debugging information like 320 | # the trace of redirections, etc. 321 | raise TooManyRedirects.new("Redirect more than " \ 322 | "#{configuration[REDIRECTION_LIMIT]} times, aborting.", response) 323 | # I don't like this api from FTW::Agent. I think 'get' and other methods 324 | # should return (object, error), and if there's an error 325 | end 326 | 327 | @logger.debug("Redirecting", :location => response.headers["Location"]) 328 | request.use_uri(response.headers["Location"]) 329 | connection, error = connect(request.headers["Host"], request.port, request.protocol == "https") 330 | # TODO(sissel): Do better error handling than raising. 331 | if !error.nil? 332 | p :error => error 333 | raise error 334 | end 335 | response = request.execute(connection) 336 | end # while being redirected 337 | 338 | # RFC 2616 section 9.4, HEAD requests MUST NOT have a message body. 339 | if request.method != "HEAD" 340 | response.body = connection 341 | else 342 | connection.release 343 | end 344 | 345 | # TODO(sissel): If this response has any cookies, store them in the 346 | # agent's cookie store 347 | return response 348 | end # def execute 349 | 350 | # shutdown this agent. 351 | # 352 | # This will shutdown all active connections. 353 | def shutdown 354 | @pool.each do |identifier, list| 355 | list.each do |connection| 356 | connection.disconnect("stopping agent") 357 | end 358 | end 359 | end # def shutdown 360 | 361 | def certificate_store 362 | return @certificate_store if @certificate_store 363 | @certificate_store = load_certificate_store 364 | end 365 | 366 | def load_certificate_store 367 | return @certificate_store if @certificate_store_last == configuration[SSL_TRUST_STORE] 368 | 369 | @certificate_store_last = configuration[SSL_TRUST_STORE] 370 | need_ssl_ca_certs = true 371 | 372 | @certificate_store = OpenSSL::X509::Store.new 373 | if configuration[SSL_USE_DEFAULT_CERTS] 374 | if File.readable?(OpenSSL::X509::DEFAULT_CERT_FILE) 375 | @logger.debug("Adding default certificate file", 376 | :path => OpenSSL::X509::DEFAULT_CERT_FILE) 377 | begin 378 | @certificate_store.add_file(OpenSSL::X509::DEFAULT_CERT_FILE) 379 | need_ssl_ca_certs = false 380 | rescue OpenSSL::X509::StoreError => e 381 | # Work around jruby#1055 "Duplicate extensions not allowed" 382 | @logger.warn("Failure loading #{OpenSSL::X509::DEFAULT_CERT_FILE}. " \ 383 | "Will try another cacert source.") 384 | end 385 | end 386 | 387 | if need_ssl_ca_certs 388 | # Use some better defaults from http://curl.haxx.se/docs/caextract.html 389 | # Can we trust curl's CA list? Global ssl trust is a tragic joke, anyway :\ 390 | @logger.info("Using upstream ssl ca certs from curl. Possibly untrustworthy.") 391 | default_ca = File.join(File.dirname(__FILE__), "cacert.pem") 392 | 393 | # JRUBY-6870 - strip 'jar:' prefix if it is present. 394 | if default_ca =~ /^jar:file.*!/ 395 | default_ca.gsub!(/^jar:/, "") 396 | end 397 | @certificate_store.add_file(default_ca) 398 | end 399 | end # SSL_USE_DEFAULT_CERTS 400 | 401 | # Handle the local user/app trust store as well. 402 | if File.directory?(configuration[SSL_TRUST_STORE]) 403 | # This is a directory, so use add_path 404 | @logger.debug("Adding SSL_TRUST_STORE", 405 | :path => configuration[SSL_TRUST_STORE]) 406 | @certificate_store.add_path(configuration[SSL_TRUST_STORE]) 407 | end 408 | 409 | return @certificate_store 410 | end # def load_certificate_store 411 | 412 | # Returns a FTW::Connection connected to this host:port. 413 | def connect(host, port, secure=false) 414 | address = "#{host}:#{port}" 415 | @logger.debug("Fetching from pool", :address => address) 416 | error = nil 417 | 418 | connection = @pool.fetch(address) do 419 | @logger.info("New connection to #{address}") 420 | connection = FTW::Connection.new(address) 421 | error = connection.connect 422 | if !error.nil? 423 | # Return nil to the pool, so like, we failed.. 424 | nil 425 | else 426 | # Otherwise return our new connection 427 | connection 428 | end 429 | end 430 | 431 | if !error.nil? 432 | @logger.error("Connection failed", :destination => address, :error => error) 433 | return nil, error 434 | end 435 | 436 | @logger.debug("Pool fetched a connection", :connection => connection) 437 | connection.mark 438 | 439 | if secure 440 | # Curry a certificate_verify callback for this connection. 441 | verify_callback = proc do |verified, context| 442 | begin 443 | certificate_verify(host, port, verified, context) 444 | rescue => e 445 | @logger.error("Error in certificate_verify call", :exception => e) 446 | end 447 | end 448 | ciphers = SSL_CIPHER_MAP[configuration[SSL_CIPHERS]] || configuration[SSL_CIPHERS] 449 | connection.secure(:certificate_store => certificate_store, :verify_callback => verify_callback, 450 | :ciphers => ciphers, :ssl_version => configuration[SSL_VERSION]) 451 | end # if secure 452 | 453 | return connection, nil 454 | end # def connect 455 | 456 | # TODO(sissel): Implement methods for managing the certificate store 457 | # TODO(sissel): Implement methods for managing the cookie store 458 | # TODO(sissel): Implement methods for managing the cache 459 | # TODO(sissel): Implement configuration stuff? Is FTW::Agent::Configuration the best way? 460 | public(:initialize, :execute, :websocket!, :upgrade!, :shutdown, :request) 461 | end # class FTW::Agent 462 | -------------------------------------------------------------------------------- /lib/ftw/agent/configuration.rb: -------------------------------------------------------------------------------- 1 | require "ftw" 2 | 3 | # Experimentation with an agent configuration similar to Firefox's about:config 4 | module FTW::Agent::Configuration 5 | # The config key for setting how many redirects will be followed before 6 | # giving up. 7 | REDIRECTION_LIMIT = "redirection-limit".freeze 8 | 9 | # SSL Trust Store 10 | SSL_TRUST_STORE = "ssl.trustdb".freeze 11 | 12 | # SSL: Use the system's global default certs? 13 | SSL_USE_DEFAULT_CERTS = "ssl.use-default-certs".freeze 14 | 15 | # SSL cipher strings 16 | SSL_CIPHERS = "ssl.ciphers".freeze 17 | 18 | SSL_CIPHER_MAP = { 19 | # https://wiki.mozilla.org/Security/Server_Side_TLS 20 | "MOZILLA_MODERN" => "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK", 21 | "MOZILLA_INTERMEDIATE" => "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA", 22 | "MOZILLA_OLD" => "ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:ECDHE-RSA-DES-CBC3-SHA:ECDHE-ECDSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:AES:DES-CBC3-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA" 23 | } 24 | 25 | SSL_CIPHER_DEFAULT = SSL_CIPHER_MAP["MOZILLA_MODERN"] 26 | 27 | SSL_VERSION = "ssl.version" 28 | 29 | private 30 | 31 | # Get the configuration hash 32 | def configuration 33 | return @configuration ||= default_configuration 34 | end # def configuration 35 | 36 | # default configuration 37 | def default_configuration 38 | require "tmpdir" 39 | home = File.join(ENV.fetch("HOME", tmpdir), ".ftw") 40 | return { 41 | REDIRECTION_LIMIT => 20, 42 | SSL_TRUST_STORE => File.join(home, "ssl-trust.db"), 43 | SSL_USE_DEFAULT_CERTS => true, 44 | SSL_CIPHERS => SSL_CIPHER_DEFAULT, 45 | SSL_VERSION => "TLSv1.1", 46 | } 47 | end # def default_configuration 48 | 49 | def tmpdir 50 | return File.join(Dir.tmpdir, "ftw-#{Process.uid}") 51 | end # def tmpdir 52 | 53 | public(:configuration) 54 | end # def FTW::Agent::Configuration 55 | -------------------------------------------------------------------------------- /lib/ftw/connection.rb: -------------------------------------------------------------------------------- 1 | require "cabin" # rubygem "cabin" 2 | require "ftw/dns" 3 | require "ftw/poolable" 4 | require "ftw/namespace" 5 | require "ftw/agent" 6 | require "socket" 7 | require "timeout" # ruby stdlib, just for the Timeout exception. 8 | 9 | if RUBY_VERSION =~ /^1\.8/ 10 | # for Array#rotate, IO::WaitWritable, etc, in ruby < 1.9 11 | require "backports" 12 | end 13 | 14 | require "openssl" 15 | 16 | # A network connection. This is TCP. 17 | # 18 | # You can use IO::select on this objects of this type. 19 | # (at least, in MRI you can) 20 | # 21 | # You can activate SSL/TLS on this connection by invoking FTW::Connection#secure 22 | # 23 | # This class also implements buffering itself because some IO-like classes 24 | # (OpenSSL::SSL::SSLSocket) do not support IO#ungetbyte 25 | class FTW::Connection 26 | include FTW::Poolable 27 | include Cabin::Inspectable 28 | 29 | # A connection attempt timed out 30 | class ConnectTimeout < StandardError; end 31 | 32 | # A connection attempt was rejected 33 | class ConnectRefused < StandardError; end 34 | 35 | # A read timed out 36 | class ReadTimeout < StandardError; end 37 | 38 | # A write timed out 39 | class WriteTimeout < StandardError; end 40 | 41 | # Secure setup timed out 42 | class SecureHandshakeTimeout < StandardError; end 43 | 44 | # Invalid connection configuration 45 | class InvalidConfiguration < StandardError; end 46 | 47 | private 48 | 49 | # A new network connection. 50 | # The 'destination' argument can be an array of strings or a single string. 51 | # String format is expected to be "host:port" 52 | # 53 | # Example: 54 | # 55 | # conn = FTW::Connection.new(["1.2.3.4:80", "1.2.3.5:80"]) 56 | # 57 | # If you specify multiple destinations, they are used in a round-robin 58 | # decision made during reconnection. 59 | def initialize(destinations) 60 | if destinations.is_a?(String) 61 | @destinations = [destinations] 62 | else 63 | @destinations = destinations 64 | end 65 | 66 | @mode = :client 67 | setup 68 | end # def initialize 69 | 70 | # Set up this connection. 71 | def setup 72 | @logger = Cabin::Channel.get 73 | @connect_timeout = 2 74 | 75 | # Use a fixed-size string that we set to BINARY encoding. 76 | # Not all byte sequences are UTF-8 friendly :0 77 | @read_size = 16384 78 | @read_buffer = " " * @read_size 79 | @pushback_buffer = "" 80 | 81 | # Tell Ruby 1.9 that this string is a binary string, not utf-8 or somesuch. 82 | if @read_buffer.respond_to?(:force_encoding) 83 | @read_buffer.force_encoding("BINARY") 84 | end 85 | 86 | @inspectables = [:@destinations, :@connected, :@remote_address, :@secure] 87 | @connected = false 88 | @remote_address = nil 89 | @secure = false 90 | 91 | # TODO(sissel): Validate @destinations 92 | # TODO(sissel): Barf if a destination is not of the form "host:port" 93 | end # def setup 94 | 95 | # Create a new connection from an existing IO instance (like a socket) 96 | # 97 | # Valid modes are :server and :client. 98 | # 99 | # * specify :server if this connection is from a server (via Socket#accept) 100 | # * specify :client if this connection is from a client (via Socket#connect) 101 | def self.from_io(io, mode=:server) 102 | valid_modes = [:server, :client] 103 | if !valid_modes.include?(mode) 104 | raise InvalidArgument.new("Invalid connection mode '#{mode}'. Valid modes: #{valid_modes.inspect}") 105 | end 106 | 107 | connection = self.new(nil) # New connection with no destinations 108 | connection.instance_eval do 109 | @socket = io 110 | @connected = true 111 | port, address = Socket.unpack_sockaddr_in(io.getpeername) 112 | @remote_address = "#{address}:#{port}" 113 | @mode = mode 114 | end 115 | return connection 116 | end # def self.from_io 117 | 118 | # Connect now. 119 | # 120 | # Timeout value is optional. If no timeout is given, this method 121 | # blocks until a connection is successful or an error occurs. 122 | # 123 | # You should check the return value of this method to determine if 124 | # a connection was successful. 125 | # 126 | # Possible return values are on error include: 127 | # 128 | # * FTW::Connection::ConnectRefused 129 | # * FTW::Connection::ConnectTimeout 130 | # 131 | # @return [nil] if the connection was successful 132 | # @return [StandardError or subclass] if the connection failed 133 | def connect(timeout=nil) 134 | # TODO(sissel): Raise if we're already connected? 135 | disconnect("reconnecting") if connected? 136 | host, port = @destinations.first.split(":") 137 | @destinations = @destinations.rotate # round-robin 138 | 139 | # Do dns resolution on the host. If there are multiple 140 | # addresses resolved, return one at random. 141 | addresses = FTW::DNS.singleton.resolve(host) 142 | 143 | addresses.each do |address| 144 | # Try each address until one works. 145 | @remote_address = address 146 | # Addresses with colon ':' in them are assumed to be IPv6 147 | family = @remote_address.include?(":") ? Socket::AF_INET6 : Socket::AF_INET 148 | @logger.debug("Connecting", :address => @remote_address, 149 | :host => host, :port => port, :family => family) 150 | @socket = Socket.new(family, Socket::SOCK_STREAM, 0) 151 | @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) 152 | 153 | # This api is terrible. pack_sockaddr_in? This isn't C, man... 154 | @logger.debug("packing", :data => [port.to_i, @remote_address]) 155 | sockaddr = Socket.pack_sockaddr_in(port.to_i, @remote_address) 156 | # TODO(sissel): Support local address binding 157 | 158 | # Connect with timeout 159 | begin 160 | @socket.connect_nonblock(sockaddr) 161 | rescue IO::WaitWritable, Errno::EINPROGRESS 162 | # Ruby actually raises Errno::EINPROGRESS, but for some reason 163 | # the documentation says to use this IO::WaitWritable thing... 164 | # I don't get it, but whatever :( 165 | 166 | writable = writable?(timeout) 167 | 168 | # http://jira.codehaus.org/browse/JRUBY-6528; IO.select doesn't behave 169 | # correctly on JRuby < 1.7, so work around it. 170 | if writable || (RUBY_PLATFORM == "java" and JRUBY_VERSION < "1.7.0") 171 | begin 172 | @socket.connect_nonblock(sockaddr) # check connection failure 173 | rescue Errno::EISCONN 174 | # Ignore, we're already connected. 175 | rescue Errno::ECONNREFUSED => e 176 | # Fire 'disconnected' event with reason :refused 177 | @socket.close 178 | return ConnectRefused.new("#{host}[#{@remote_address}]:#{port}") 179 | rescue Errno::ETIMEDOUT 180 | # This occurs when the system's TCP timeout hits, we have no 181 | # control over this, as far as I can tell. *maybe* setsockopt(2) 182 | # has a flag for this, but I haven't checked.. 183 | # TODO(sissel): We should instead do 'retry' unless we've exceeded 184 | # the timeout. 185 | @socket.close 186 | return ConnectTimeout.new("#{host}[#{@remote_address}]:#{port}") 187 | rescue Errno::EINPROGRESS 188 | # If we get here, it's likely JRuby version < 1.7.0. EINPROGRESS at 189 | # this point in the code means that we have timed out. 190 | @socket.close 191 | return ConnectTimeout.new("#{host}[#{@remote_address}]:#{port}") 192 | end 193 | else 194 | # Connection timeout; 195 | return ConnectTimeout.new("#{host}[#{@remote_address}]:#{port}") 196 | end 197 | 198 | # If no error at this point, we're now connected. 199 | @connected = true 200 | break 201 | end # addresses.each 202 | end 203 | return nil 204 | end # def connect 205 | 206 | # Is this Connection connected? 207 | def connected? 208 | return @connected 209 | end # def connected? 210 | 211 | # Write data to this connection. 212 | # This method blocks until the write succeeds unless a timeout is given. 213 | # 214 | # This method is not guaranteed to have written the full data given. 215 | # 216 | # Returns the number of bytes written (See also IO#syswrite) 217 | def write(data, timeout=nil) 218 | #connect if !connected? 219 | if writable?(timeout) 220 | return @socket.syswrite(data) 221 | else 222 | raise FTW::Connection::WriteTimeout.new(self.inspect) 223 | end 224 | end # def write 225 | 226 | # Read data from this connection 227 | # This method blocks until the read succeeds unless a timeout is given. 228 | # 229 | # This method is not guaranteed to read exactly 'length' bytes. See 230 | # IO#sysread 231 | def read(length=16384, timeout=nil) 232 | data = "" 233 | data.force_encoding("BINARY") if data.respond_to?(:force_encoding) 234 | have_pushback = !@pushback_buffer.empty? 235 | if have_pushback 236 | data << @pushback_buffer 237 | @pushback_buffer = "" 238 | # We have data 'now' so don't wait. 239 | timeout = 0 240 | end 241 | 242 | if readable?(timeout) 243 | begin 244 | # Read at most 'length' data, so read less from the socket 245 | # We'll read less than 'length' if the pushback buffer has 246 | # data in it already. 247 | @socket.sysread(length - data.length, @read_buffer) 248 | data << @read_buffer 249 | return data 250 | rescue EOFError => e 251 | @socket.close 252 | @connected = false 253 | raise e 254 | end 255 | else 256 | if have_pushback 257 | return data 258 | else 259 | raise ReadTimeout.new 260 | end 261 | end 262 | end # def read 263 | 264 | # Push back some data onto the connection's read buffer. 265 | def pushback(data) 266 | @pushback_buffer << data 267 | end # def pushback 268 | 269 | # End this connection, specifying why. 270 | def disconnect(reason) 271 | io = @socket 272 | if @socket.is_a?(OpenSSL::SSL::SSLSocket) 273 | @socket.sysclose() 274 | io = @socket.io 275 | end 276 | begin 277 | io.close_read 278 | rescue IOError => e 279 | # Ignore, perhaps we shouldn't ignore. 280 | end 281 | 282 | begin 283 | io.close_write 284 | rescue IOError => e 285 | # Ignore, perhaps we shouldn't ignore. 286 | end 287 | end # def disconnect 288 | 289 | # Is this connection writable? Returns true if it is writable within 290 | # the timeout period. False otherwise. 291 | # 292 | # The time out is in seconds. Fractional seconds are OK. 293 | def writable?(timeout) 294 | readable, writable, errors = IO.select(nil, [@socket], nil, timeout) 295 | return !writable.nil? 296 | end # def writable? 297 | 298 | # Is this connection readable? Returns true if it is readable within 299 | # the timeout period. False otherwise. 300 | # 301 | # The time out is in seconds. Fractional seconds are OK. 302 | def readable?(timeout) 303 | readable, writable, errors = IO.select([@socket], nil, nil, timeout) 304 | return !readable.nil? 305 | end # def readable? 306 | 307 | # The host:port 308 | def peer 309 | return @remote_address 310 | end # def peer 311 | 312 | # Support 'to_io' so you can use IO::select on this object. 313 | def to_io 314 | return @socket 315 | end # def to_io 316 | 317 | # Secure this connection with TLS. 318 | # 319 | # Options: 320 | # 321 | # * :certificate_store, an OpenSSL::X509::Store 322 | # * :timeout, a timeout threshold in seconds. 323 | # * :ciphers, an OpenSSL ciphers string, see `openssl ciphers` manual for details. 324 | # * :ssl_version, any of: SSLv2, SSLv3, TLSv1, TLSv1.1, TLSv1.2 325 | # * :certificate, an OpenSSL::X509::Certificate 326 | # * :key, an OpenSSL::PKey (like OpenSSL::PKey::RSA) 327 | # 328 | # Both `certificate` and `key` are highly recommended if the connection 329 | # belongs to a server (not a client connection). 330 | # 331 | # Notes: 332 | # * Version may depend on your platform (openssl compilation settings, JVM 333 | # version, export restrictions, etc) 334 | # * Available ciphers will depend on your version of Ruby (or JRuby and JVM), 335 | # OpenSSL, etc. 336 | def secure(options=nil) 337 | # Skip this if we're already secure. 338 | return if secured? 339 | 340 | defaults = { 341 | :timeout => nil, 342 | :ciphers => FTW::Agent::Configuration::SSL_CIPHER_MAP["MOZILLA_MODERN"], 343 | :ssl_version => "TLSv1.1" 344 | } 345 | settings = defaults.merge(options) unless options.nil? 346 | 347 | @logger.info("Securing this connection", :peer => peer, :options => settings) 348 | # Wrap this connection with TLS/SSL 349 | sslcontext = OpenSSL::SSL::SSLContext.new 350 | # If you use VERIFY_NONE, you are removing the trust feature of TLS. Don't do that. 351 | # Encryption without trust means you don't know who you are talking to. 352 | sslcontext.verify_mode = OpenSSL::SSL::VERIFY_PEER 353 | 354 | # ruby-core is refusing to patch ruby's default openssl settings to be more 355 | # secure, so let's fix that here. The next few lines setting options and 356 | # ciphers come from jmhodges' proposed patch 357 | ssloptions = OpenSSL::SSL::OP_ALL 358 | if defined?(OpenSSL::SSL::OP_DONT_INSERT_EMPTY_FRAGMENTS) 359 | ssloptions &= ~OpenSSL::SSL::OP_DONT_INSERT_EMPTY_FRAGMENTS 360 | end 361 | if defined?(OpenSSL::SSL::OP_NO_COMPRESSION) 362 | ssloptions |= OpenSSL::SSL::OP_NO_COMPRESSION 363 | end 364 | # https://github.com/jruby/jruby/issues/1874 365 | version = OpenSSL::SSL::SSLContext::METHODS.find { |x| x.to_s.gsub("_",".") == settings[:ssl_version] } 366 | raise InvalidConfiguration, "Invalid SSL/TLS version '#{settings[:ssl_version]}'" if version.nil? 367 | sslcontext.ssl_version = version 368 | 369 | # We have to set ciphers *after* setting ssl_version because setting 370 | # ssl_version will reset the cipher set. 371 | sslcontext.options = ssloptions 372 | sslcontext.ciphers = settings[:ciphers] 373 | 374 | sslcontext.verify_callback = proc do |*args| 375 | @logger.debug("Verify peer via FTW::Connection#secure", :callback => settings[:verify_callback]) 376 | if settings[:verify_callback].respond_to?(:call) 377 | settings[:verify_callback].call(*args) 378 | end 379 | end 380 | sslcontext.cert_store = settings[:certificate_store] 381 | 382 | if settings.include?(:certificate) && settings.include?(:key) 383 | sslcontext.cert = settings[:certificate] 384 | sslcontext.key = settings[:key] 385 | end 386 | 387 | @socket = OpenSSL::SSL::SSLSocket.new(@socket, sslcontext) 388 | 389 | # TODO(sissel): Set up local certificat/key stuff. This is required for 390 | # server-side ssl operation, I think. 391 | 392 | if client? 393 | do_secure(:connect_nonblock, settings[:timeout]) 394 | else 395 | do_secure(:accept_nonblock, settings[:timeout]) 396 | end 397 | end # def secure 398 | 399 | # Secure this connection. 400 | # 401 | # The handshake method for OpenSSL::SSL::SSLSocket is different depending 402 | # on the mode (client or server). 403 | # 404 | # @param [Symbol] handshake_method The method to call on the socket to 405 | # complete the ssl handshake. See OpenSSL::SSL::SSLSocket#connect_nonblock 406 | # of #accept_nonblock for more details 407 | def do_secure(handshake_method, timeout=nil) 408 | # SSLSocket#connect_nonblock will do the SSL/TLS handshake. 409 | # TODO(sissel): refactor this into a method that both secure and connect 410 | # methods can call. 411 | start = Time.now 412 | begin 413 | @socket.send(handshake_method) 414 | rescue IO::WaitReadable, IO::WaitWritable 415 | # The ruby OpenSSL docs for 1.9.3 have example code saying I should use 416 | # IO::WaitReadable, but in the real world it raises an SSLError with 417 | # a specific string message instead of Errno::EAGAIN or IO::WaitReadable 418 | # explicitly... 419 | # 420 | # This SSLSocket#connect_nonblock raising WaitReadable (Technically, 421 | # OpenSSL::SSL::SSLError) is in contrast to what Socket#connect_nonblock 422 | # raises, WaitWritable (ok, Errno::EINPROGRESS, technically) 423 | # Ruby's SSL exception for 'this call would block' is pretty shitty. 424 | # 425 | # So we rescue both IO::Wait{Readable,Writable} and keep trying 426 | # until timeout occurs. 427 | # 428 | 429 | if !timeout.nil? 430 | time_left = timeout - (Time.now - start) 431 | raise SecureHandshakeTimeout.new if time_left < 0 432 | r, w, e = IO.select([@socket], [@socket], nil, time_left) 433 | else 434 | r, w, e = IO.select([@socket], [@socket], nil, timeout) 435 | end 436 | 437 | # keep going if the socket is ready 438 | retry if r.size > 0 || w.size > 0 439 | rescue => e 440 | @logger.warn(e) 441 | raise e 442 | end 443 | 444 | @secure = true 445 | end # def do_secure 446 | 447 | # Has this connection been secured? 448 | def secured? 449 | return @secure 450 | end # def secured? 451 | 452 | # Is this a client connection? 453 | def client? 454 | return @mode == :client 455 | end # def client? 456 | 457 | # Is this a server connection? 458 | def server? 459 | return @mode == :server 460 | end # def server? 461 | 462 | public(:connect, :connected?, :write, :read, :pushback, :disconnect, 463 | :writable?, :readable?, :peer, :to_io, :secure, :secured?, 464 | :client?, :server?) 465 | end # class FTW::Connection 466 | 467 | -------------------------------------------------------------------------------- /lib/ftw/cookies.rb: -------------------------------------------------------------------------------- 1 | require "ftw/namespace" 2 | require "cabin" 3 | 4 | # Based on behavior and things described in RFC6265 5 | class FTW::Cookies 6 | 7 | # This is a Cookie. It expires, has a value, a name, etc. 8 | # I could have used stdlib CGI::Cookie, but it actually parses cookie strings 9 | # incorrectly and also lacks the 'httponly' attribute. 10 | class Cookie 11 | attr_accessor :name 12 | attr_accessor :value 13 | 14 | attr_accessor :domain 15 | attr_accessor :path 16 | attr_accessor :comment 17 | attr_accessor :expires # covers both 'expires' and 'max-age' behavior 18 | attr_accessor :secure 19 | attr_accessor :httponly # part of RFC6265 20 | 21 | # TODO(sissel): Support 'extension-av' ? RFC6265 section 4.1.1 22 | # extension-av = 23 | 24 | # List of standard cookie attributes 25 | STANDARD_ATTRIBUTES = [:domain, :path, :comment, :expires, :secure, :httponly] 26 | 27 | # A new cookie. Value and attributes are optional. 28 | def initialize(name, value=nil, attributes={}) 29 | @name = name 30 | @value = value 31 | 32 | STANDARD_ATTRIBUTES.each do |iv| 33 | instance_variable_set("@#{iv.to_s}", attributes.delete(iv)) 34 | end 35 | 36 | if !attributes.empty? 37 | raise InvalidArgument.new("Invalid Cookie attributes: #{attributes.inspect}") 38 | end 39 | end # def initialize 40 | 41 | # See RFC6265 section 4.1.1 42 | def self.parse(set_cookie_string) 43 | @logger ||= Cabin::Channel.get($0) 44 | # TODO(sissel): Implement 45 | # grammar is: 46 | # set-cookie-string = cookie-pair *( ";" SP cookie-av ) 47 | # cookie-pair = cookie-name "=" cookie-value 48 | # cookie-name = token 49 | # cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE ) 50 | pair, *attributes = set_cookie_string.split(/\s*;\s*/) 51 | name, value = pair.split(/\s*=\s*/) 52 | extra = {} 53 | attributes.each do |attr| 54 | case attr 55 | when /^Expires=/ 56 | #extra[:expires] = 57 | when /^Max-Age=/ 58 | # TODO(sissel): Parse the Max-Age value and convert it to 'expires' 59 | #extra[:expires] = 60 | when /^Domain=/ 61 | extra[:domain] = attr[7..-1] 62 | when /^Path=/ 63 | extra[:path] = attr[5..-1] 64 | when /^Secure/ 65 | extra[:secure] = true 66 | when /^HttpOnly/ 67 | extra[:httponly] = true 68 | else 69 | 70 | end 71 | end 72 | end # def Cookie.parse 73 | end # class Cookie 74 | 75 | # A new cookies store 76 | def initialize 77 | @cookies = [] 78 | end # def initialize 79 | 80 | # Add a cookie 81 | def add(name, value=nil, attributes={}) 82 | cookie = Cookie.new(name, value, attributes) 83 | @cookies << cookie 84 | end # def add 85 | 86 | # Add a cookie from a header 'Set-Cookie' value 87 | def add_from_header(set_cookie_string) 88 | cookie = Cookie.parse(set_cookie_string) 89 | @cookies << cookie 90 | end # def add_from_header 91 | 92 | # Get cookies for a URL 93 | def for_url(url) 94 | # TODO(sissel): only return cookies that are valid for the url 95 | return @cookies 96 | end # def for_url 97 | end # class FTW::Cookies 98 | -------------------------------------------------------------------------------- /lib/ftw/crlf.rb: -------------------------------------------------------------------------------- 1 | require "ftw/namespace" 2 | 3 | # This module provides a 'CRLF' constant for use with protocols that need it. 4 | # I find it easier to specify CRLF instead of literal "\r\n" 5 | module FTW::CRLF 6 | # carriage-return + line-feed 7 | CRLF = "\r\n" 8 | end # module FTW::CRLF 9 | -------------------------------------------------------------------------------- /lib/ftw/dns.rb: -------------------------------------------------------------------------------- 1 | require "ftw/namespace" 2 | require "socket" # for Socket.gethostbyname 3 | require "ftw/singleton" 4 | require "ftw/dns/dns" 5 | 6 | # I wrap whatever Ruby provides because it is historically very 7 | # inconsistent in implementation behavior across ruby platforms and versions. 8 | # In the future, this will probably implement the DNS protocol, but for now 9 | # chill in the awkward, but already-written, ruby stdlib. 10 | # 11 | # I didn't really want to write a DNS library, but a consistent API and 12 | # behavior is necessary for my continued sanity :) 13 | class FTW::DNS 14 | extend FTW::Singleton 15 | 16 | # The ipv4-in-ipv6 address space prefix. 17 | V4_IN_V6_PREFIX = "0:" * 12 18 | 19 | # An array of resolvers. By default this includes a FTW::DNS::DNS instance. 20 | attr_reader :resolvers 21 | 22 | private 23 | 24 | # A new resolver. 25 | # 26 | # The default set of resolvers is only {FTW::DNS::DNS} which does DNS 27 | # resolution. 28 | def initialize 29 | @resolvers = [FTW::DNS::DNS.new] 30 | end # def initialize 31 | 32 | # Resolve a hostname. 33 | # 34 | # Returns an array of all addresses for this host. Empty array resolution 35 | # failure. 36 | def resolve(hostname) 37 | return @resolvers.reduce([]) do |memo, resolver| 38 | result = resolver.resolve(hostname) 39 | memo += result unless result.nil? 40 | end 41 | end # def resolve 42 | 43 | # Resolve hostname and choose one of the results at random. 44 | # 45 | # Use this method if you are connecting to a hostname that resolves to 46 | # multiple addresses. 47 | def resolve_random(hostname) 48 | addresses = resolve(hostname) 49 | return addresses[rand(addresses.size)] 50 | end # def resolve_random 51 | 52 | public(:resolve, :resolve_random) 53 | end # class FTW::DNS 54 | -------------------------------------------------------------------------------- /lib/ftw/dns/dns.rb: -------------------------------------------------------------------------------- 1 | require "ftw/namespace" 2 | 3 | # A FTW::DNS resolver that uses Socket.gethostbyname() to resolve addresses. 4 | class FTW::DNS::DNS 5 | # TODO(sissel): Switch to using Resolv::DNS since it lets you (the programmer) 6 | # choose dns configuration (servers, etc) 7 | private 8 | 9 | # Resolve a hostname. 10 | # 11 | # It will return an array of all known addresses for the host. 12 | def resolve(hostname) 13 | official, aliases, family, *addresses = Socket.gethostbyname(hostname) 14 | # We ignore family, here. Ruby will return v6 *and* v4 addresses in 15 | # the same gethostbyname() call. It is confusing. 16 | # 17 | # Let's just rely entirely on the length of the address string. 18 | return addresses.collect do |address| 19 | if address.length == 16 20 | unpack_v6(address) 21 | else 22 | unpack_v4(address) 23 | end 24 | end 25 | end # def resolve 26 | 27 | # Unserialize a 4-byte ipv4 address into a human-readable a.b.c.d string 28 | def unpack_v4(address) 29 | return address.unpack("C4").join(".") 30 | end # def unpack_v4 31 | 32 | # Unserialize a 16-byte ipv6 address into a human-readable a:b:c:...:d string 33 | def unpack_v6(address) 34 | if address.length == 16 35 | # Unpack 16 bit chunks, convert to hex, join with ":" 36 | address.unpack("n8").collect { |p| p.to_s(16) } \ 37 | .join(":").sub(/(?:^|:)0:(?:0:)+/, "::") 38 | else 39 | # assume ipv4 40 | # Per the following sites, "::127.0.0.1" is valid and correct 41 | # http://en.wikipedia.org/wiki/IPv6#IPv4-mapped_IPv6_addresses 42 | # http://www.tcpipguide.com/free/t_IPv6IPv4AddressEmbedding.htm 43 | "::" + unpack_v4(address) 44 | end 45 | end # def unpack_v6 46 | 47 | public(:resolve) 48 | end # class FTW::DNS::DNS 49 | -------------------------------------------------------------------------------- /lib/ftw/dns/hash.rb: -------------------------------------------------------------------------------- 1 | require "ftw/namespace" 2 | 3 | # Provide resolution name -> address mappings through hash lookups 4 | class FTW::DNS::Hash 5 | private 6 | 7 | # A new hash dns resolver. 8 | # 9 | # @param [#[]] data Must be a hash-like thing responding to #[] 10 | def initialize(data={}) 11 | @data = data 12 | end # def initialize 13 | 14 | # Resolve a hostname. 15 | # 16 | # It will return an array of all known addresses for the host. 17 | def resolve(hostname) 18 | result = @data[hostname] 19 | return nil if result.nil? 20 | return result if result.is_a?(Array) 21 | return [result] 22 | end # def resolve 23 | 24 | public(:resolve) 25 | end 26 | -------------------------------------------------------------------------------- /lib/ftw/http/headers.rb: -------------------------------------------------------------------------------- 1 | require "ftw/namespace" 2 | require "ftw/crlf" 3 | 4 | # HTTP Headers 5 | # 6 | # See RFC2616 section 4.2: 7 | # 8 | # Section 14.44 says Field Names in the header are case-insensitive, so 9 | # this library always forces field names to be lowercase. This includes 10 | # get() calls. 11 | # 12 | # headers.set("HELLO", "world") 13 | # headers.get("hello") # ===> "world" 14 | # 15 | class FTW::HTTP::Headers 16 | include Enumerable 17 | include FTW::CRLF 18 | 19 | private 20 | 21 | # Make a new headers container. 22 | # 23 | # @param [Hash, optional] a hash of headers to start with. 24 | def initialize(headers={}) 25 | super() 26 | @headers = headers 27 | end # def initialize 28 | 29 | # Set a header field to a specific value. 30 | # Any existing value(s) for this field are destroyed. 31 | # 32 | # @param [String] the name of the field to set 33 | # @param [String or Array] the value of the field to set 34 | def set(field, value) 35 | @headers[field.downcase] = value 36 | end # def set 37 | 38 | alias_method :[]=, :set 39 | 40 | # Does this header include this field name? 41 | # @return [true, false] 42 | def include?(field) 43 | @headers.include?(field.downcase) 44 | end # def include? 45 | 46 | # Add a header field with a value. 47 | # 48 | # If this field already exists, another value is added. 49 | # If this field does not already exist, it is set. 50 | def add(field, value) 51 | field = field.downcase 52 | if @headers.include?(field) 53 | if @headers[field].is_a?(Array) 54 | @headers[field] << value 55 | else 56 | @headers[field] = [@headers[field], value] 57 | end 58 | else 59 | set(field, value) 60 | end 61 | end # def add 62 | 63 | # Removes a header entry. If the header has multiple values 64 | # (like X-Forwarded-For can), you can delete a specific entry 65 | # by passing the value of the header field to remove. 66 | # 67 | # # Remove all X-Forwarded-For entries 68 | # headers.remove("X-Forwarded-For") 69 | # # Remove a specific X-Forwarded-For entry 70 | # headers.remove("X-Forwarded-For", "1.2.3.4") 71 | # 72 | # * If you remove a field that doesn't exist, no error will occur. 73 | # * If you remove a field value that doesn't exist, no error will occur. 74 | # * If you remove a field value that is the only value, it is the same as 75 | # removing that field by name. 76 | def remove(field, value=nil) 77 | field = field.downcase 78 | if value.nil? 79 | # no value, given, remove the entire field. 80 | @headers.delete(field) 81 | else 82 | field_value = @headers[field] 83 | if field_value.is_a?(Array) 84 | # remove a specific value 85 | field_value.delete(value) 86 | # Down to a String again if there's only one value. 87 | if field_value.size == 1 88 | set(field, field_value.first) 89 | end 90 | else 91 | # Remove this field if the value matches 92 | if field_value == value 93 | remove(field) 94 | end 95 | end 96 | end 97 | end # def remove 98 | 99 | # Get a field value. 100 | # 101 | # @return [String] if there is only one value for this field 102 | # @return [Array] if there are multiple values for this field 103 | # @return [nil] if there are no values for this field 104 | def get(field) 105 | field = field.downcase 106 | return @headers[field] 107 | end # def get 108 | 109 | alias_method :[], :get 110 | 111 | # Iterate over headers. Given to the block are two arguments, the field name 112 | # and the field value. For fields with multiple values, you will receive 113 | # that same field name multiple times, like: 114 | # yield "Host", "www.example.com" 115 | # yield "X-Forwarded-For", "1.2.3.4" 116 | # yield "X-Forwarded-For", "1.2.3.5" 117 | def each(&block) 118 | @headers.each do |field_name, field_value| 119 | if field_value.is_a?(Array) 120 | field_value.map { |value| yield field_name, value } 121 | else 122 | yield field_name, field_value 123 | end 124 | end 125 | end # end each 126 | 127 | # @return [Hash] String keys and values of String (field value) or Array (of String field values) 128 | def to_hash 129 | return @headers 130 | end # def to_hash 131 | 132 | # Serialize this object to a string in HTTP format described by RFC2616 133 | # 134 | # Example: 135 | # 136 | # headers = FTW::HTTP::Headers.new 137 | # headers.add("Host", "example.com") 138 | # headers.add("X-Forwarded-For", "1.2.3.4") 139 | # headers.add("X-Forwarded-For", "192.168.0.1") 140 | # puts headers.to_s 141 | # 142 | # # Result 143 | # Host: example.com 144 | # X-Forwarded-For: 1.2.3.4 145 | # X-Forwarded-For: 192.168.0.1 146 | def to_s 147 | return @headers.collect { |name, value| "#{name}: #{value}" }.join(CRLF) + CRLF 148 | end # def to_s 149 | 150 | # Inspect this object 151 | def inspect 152 | return "#{self.class.name} <#{to_hash.inspect}>" 153 | end # def inspect 154 | 155 | public(:set, :[]=, :include?, :add, :remove, :get, :[], :each, :to_hash, :to_s, :inspect) 156 | end # class FTW::HTTP::Headers 157 | -------------------------------------------------------------------------------- /lib/ftw/http/message.rb: -------------------------------------------------------------------------------- 1 | require "ftw/namespace" 2 | require "ftw/http/headers" 3 | require "ftw/crlf" 4 | 5 | # HTTP Message, RFC2616 6 | # For specification, see RFC2616 section 4: 7 | # 8 | # You probably won't use this class much. Instead, check out {FTW::Request} and {FTW::Response} 9 | module FTW::HTTP::Message 10 | include FTW::CRLF 11 | 12 | # The HTTP headers - See {FTW::HTTP::Headers}. 13 | # RFC2616 5.3 - 14 | attr_reader :headers 15 | 16 | # The HTTP version. See {VALID_VERSIONS} for valid versions. 17 | # This will always be a Numeric object. 18 | # Both Request and Responses have version, so put it in the parent class. 19 | attr_accessor :version 20 | 21 | # HTTP Versions that are valid. 22 | VALID_VERSIONS = [1.0, 1.1] 23 | 24 | # For backward-compatibility, this exception inherits from ArgumentError 25 | UnsupportedHTTPVersion = Class.new(ArgumentError) 26 | 27 | private 28 | 29 | # A new HTTP message. 30 | def initialize 31 | @headers = FTW::HTTP::Headers.new 32 | @body = nil 33 | end # def initialize 34 | 35 | # Get a header value by field name. 36 | # 37 | # @param [String] the name of the field. (case insensitive) 38 | def [](field) 39 | return @headers[field] 40 | end # def [] 41 | 42 | # Set a header field 43 | # 44 | # @param [String] the name of the field. (case insensitive) 45 | # @param [String] the value to set for this field 46 | def []=(field, value) 47 | @headers[field] = value 48 | end # def []= 49 | 50 | # Set the body of this message 51 | # 52 | # The 'message_body' can be an IO-like object, Enumerable, or String. 53 | # 54 | # See RFC2616 section 4.3: 55 | def body=(message_body) 56 | # TODO(sissel): if message_body is a string, set Content-Length header 57 | # TODO(sissel): if it's an IO object, set Transfer-Encoding to chunked 58 | # TODO(sissel): if it responds to each or appears to be Enumerable, then 59 | # set Transfer-Encoding to chunked. 60 | @body = message_body 61 | 62 | # don't set any additional length/encoding headers if they are already set. 63 | return if headers.include?("Content-Length") or headers.include?("Transfer-Encoding") 64 | 65 | if (message_body.respond_to?(:read) or message_body.respond_to?(:each)) and 66 | headers["Transfer-Encoding"] = "chunked" 67 | else 68 | headers["Content-Length"] = message_body.bytesize 69 | end 70 | end # def body= 71 | 72 | # Get the body of this message 73 | # 74 | # Returns an Enumerable, IO-like object, or String, depending on how this 75 | # message was built. 76 | def body 77 | # TODO(sissel): verification todos follow... 78 | # TODO(sissel): RFC2616 section 4.3 - if there is a message body 79 | # then one of "Transfer-Encoding" *or* "Content-Length" MUST be present. 80 | # otherwise, if neither header is present, no body is present. 81 | # TODO(sissel): Responses to HEAD requests or those with status 1xx, 204, 82 | # or 304 MUST NOT have a body. All other requests have a message body, 83 | # even if that body is of zero length. 84 | return @body 85 | end # def body 86 | 87 | # Should this message have a content? 88 | # 89 | # In HTTP 1.1, there is a body if response sets Content-Length *or* 90 | # Transfer-Encoding, it has a body. Otherwise, there is no body. 91 | def content? 92 | return (headers.include?("Content-Length") and headers["Content-Length"].to_i > 0) \ 93 | || headers.include?("Transfer-Encoding") 94 | end # def content? 95 | 96 | # Does this message have a body? 97 | def body? 98 | return !@body.nil? 99 | end # def body? 100 | 101 | # Set the HTTP version. Must be a valid version. See VALID_VERSIONS. 102 | def version=(ver) 103 | # Accept string "1.0" or simply "1", etc. 104 | ver = ver.to_f if !ver.is_a?(Float) 105 | 106 | if !VALID_VERSIONS.include?(ver) 107 | raise UnsupportedHTTPVersion.new("#{self.class.name}#version = #{ver.inspect} is" \ 108 | "invalid. It must be a number, one of #{VALID_VERSIONS.join(", ")}") 109 | end 110 | @version = ver 111 | end # def version= 112 | 113 | # Serialize this Request according to RFC2616 114 | # Note: There is *NO* trailing CRLF. This is intentional. 115 | # The RFC defines: 116 | # generic-message = start-line 117 | # *(message-header CRLF) 118 | # CRLF 119 | # [ message-body ] 120 | # Thus, the CRLF between header and body is not part of the header. 121 | def to_s 122 | return [start_line, @headers].join(CRLF) 123 | end 124 | 125 | public(:initialize, :headers, :version, :version=, :[], :[]=, :body=, :body, 126 | :content?, :body?, :to_s) 127 | end # class FTW::HTTP::Message 128 | -------------------------------------------------------------------------------- /lib/ftw/namespace.rb: -------------------------------------------------------------------------------- 1 | module FTW 2 | # :nodoc: 3 | module HTTP; end 4 | # :nodoc: 5 | class WebSocket; end 6 | # :nodoc: 7 | class DNS; end 8 | end 9 | -------------------------------------------------------------------------------- /lib/ftw/pool.rb: -------------------------------------------------------------------------------- 1 | require "ftw/namespace" 2 | require "thread" 3 | 4 | # A simple thread-safe resource pool. 5 | # 6 | # Resources in this pool must respond to 'available?'. 7 | # For best results, your resources should just 'include FTW::Poolable' 8 | # 9 | # The primary use case was as a way to pool FTW::Connection instances. 10 | class FTW::Pool 11 | def initialize 12 | # Pool is a hash of arrays. 13 | @pool = Hash.new { |h,k| h[k] = Array.new } 14 | @lock = Mutex.new 15 | end # def initialize 16 | 17 | # Add an object to the pool with a given identifier. For example: 18 | # 19 | # pool.add("www.google.com:80", connection1) 20 | # pool.add("www.google.com:80", connection2) 21 | # pool.add("github.com:443", connection3) 22 | def add(identifier, object) 23 | @lock.synchronize do 24 | @pool[identifier] << object 25 | end 26 | return object 27 | end # def add 28 | 29 | # Fetch a resource from this pool. If no available resources 30 | # are found, the 'default_block' is invoked and expected to 31 | # return a new resource to add to the pool that satisfies 32 | # the fetch.. 33 | # 34 | # Example: 35 | # 36 | # pool.fetch("github.com:443") do 37 | # conn = FTW::Connection.new("github.com:443") 38 | # conn.secure 39 | # conn 40 | # end 41 | def fetch(identifier, &default_block) 42 | @lock.synchronize do 43 | @pool[identifier].delete_if { |o| o.available? && !o.connected? } 44 | object = @pool[identifier].find { |o| o.available? } 45 | return object if !object.nil? 46 | end 47 | # Otherwise put the return value of default_block in the 48 | # pool and return it, but don't put nil values in the pool. 49 | obj = default_block.call 50 | if obj.nil? 51 | return nil 52 | else 53 | return add(identifier, obj) 54 | end 55 | end # def fetch 56 | 57 | # Iterate over all pool members. 58 | # 59 | # This holds the pool lock during this method, so you should not call 'fetch' 60 | # or 'add'. 61 | def each(&block) 62 | @lock.synchronize do 63 | @pool.each do |identifier, object| 64 | block.call(identifier, object) 65 | end 66 | end 67 | end # def each 68 | end # class FTW::Pool 69 | -------------------------------------------------------------------------------- /lib/ftw/poolable.rb: -------------------------------------------------------------------------------- 1 | require "ftw/namespace" 2 | 3 | # A poolable mixin. This is for use with the FTW::Pool class. 4 | module FTW::Poolable 5 | # Mark that this resource is in use 6 | def mark 7 | @__in_use = true 8 | end # def mark 9 | 10 | # Release this resource 11 | def release 12 | @__in_use = false 13 | end # def release 14 | 15 | # Is this resource available for use? 16 | def available? 17 | return !@__in_use 18 | end # def avialable? 19 | end # module FTW::Poolable 20 | -------------------------------------------------------------------------------- /lib/ftw/protocol.rb: -------------------------------------------------------------------------------- 1 | require "ftw/namespace" 2 | require "ftw/crlf" 3 | require "cabin" 4 | require "logger" 5 | 6 | # This module provides web protocol handling as a mixin. 7 | module FTW::Protocol 8 | include FTW::CRLF 9 | 10 | # Read an HTTP message from a given connection 11 | # 12 | # This method blocks until a full http message header has been consumed 13 | # (request *or* response) 14 | # 15 | # The body of the message, if any, will not be consumed, and the read 16 | # position for the connection will be left at the end of the message headers. 17 | # 18 | # The 'connection' object must respond to #read(timeout) and #pushback(string) 19 | def read_http_message(connection) 20 | parser = HTTP::Parser.new 21 | headers_done = false 22 | parser.on_headers_complete = proc { headers_done = true; :stop } 23 | 24 | # headers_done will be set to true when parser finishes parsing the http 25 | # headers for this request 26 | while !headers_done 27 | # TODO(sissel): This read could toss an exception of the server aborts 28 | # prior to sending the full headers. Figure out a way to make this happy. 29 | # Perhaps fabricating a 500 response? 30 | data = connection.read(16384) 31 | 32 | # Feed the data into the parser. Offset will be nonzero if there's 33 | # extra data beyond the header. 34 | offset = parser << data 35 | end 36 | 37 | # If we consumed part of the body while parsing headers, put it back 38 | # onto the connection's read buffer so the next consumer can use it. 39 | if offset < data.length 40 | connection.pushback(data[offset .. -1]) 41 | end 42 | 43 | # This will have an 'http_method' if it's a request 44 | if !parser.http_method.nil? 45 | # have http_method, so this is an HTTP Request message 46 | request = FTW::Request.new 47 | request.method = parser.http_method 48 | request.request_uri = parser.request_url 49 | request.version = "#{parser.http_major}.#{parser.http_minor}".to_f 50 | parser.headers.each { |field, value| request.headers.add(field, value) } 51 | return request 52 | else 53 | # otherwise, no http_method, so this is an HTTP Response message 54 | response = FTW::Response.new 55 | response.version = "#{parser.http_major}.#{parser.http_minor}".to_f 56 | response.status = parser.status_code 57 | parser.headers.each { |field, value| response.headers.add(field, value) } 58 | return response 59 | end 60 | end # def read_http_message 61 | 62 | def write_http_body(body, io, chunked=false) 63 | if chunked 64 | write_http_body_chunked(body, io) 65 | else 66 | write_http_body_normal(body, io) 67 | end 68 | end # def write_http_body 69 | 70 | # Encode the given text as in 'chunked' encoding. 71 | def encode_chunked(text) 72 | return sprintf("%x%s%s%s", text.bytesize, CRLF, text, CRLF) 73 | end # def encode_chunked 74 | 75 | def write_http_body_chunked(body, io) 76 | if body.is_a?(String) 77 | write_all( io, encode_chunked(body)) 78 | elsif body.respond_to?(:sysread) 79 | begin 80 | while cont = body.sysread(16384) 81 | write_all( io, encode_chunked(cont)) 82 | end 83 | rescue EOFError 84 | end 85 | elsif body.respond_to?(:read) 86 | while cont = body.read(16384) 87 | write_all( io, encode_chunked(cont) ) 88 | end 89 | elsif body.respond_to?(:each) 90 | body.each { |s| write_all( io, encode_chunked(s)) } 91 | end 92 | 93 | # The terminating chunk is an empty one. 94 | write_all(io, encode_chunked("")) 95 | end # def write_http_body_chunked 96 | 97 | def write_http_body_normal(body, io) 98 | if body.is_a?(String) 99 | write_all(io, body) 100 | elsif body.respond_to?(:read) 101 | while cont = body.read(16384) 102 | write_all(io, cont) 103 | end 104 | elsif body.respond_to?(:each) 105 | body.each { |s| write_all( io, s) } 106 | end 107 | end # def write_http_body_normal 108 | 109 | def write_all(io, string) 110 | while string.bytesize > 0 111 | w = io.write(string) 112 | string = string.byteslice(w..-1) 113 | end 114 | end # def write_all 115 | 116 | # Read the body of this message. The block is called with chunks of the 117 | # response as they are read in. 118 | # 119 | # This method is generally only called by http clients, not servers. 120 | def read_http_body(&block) 121 | if @body.respond_to?(:read) 122 | if headers.include?("Content-Length") and headers["Content-Length"].to_i > 0 123 | @logger.debug("Reading body with Content-Length") 124 | read_http_body_length(headers["Content-Length"].to_i, &block) 125 | elsif headers["Transfer-Encoding"] == "chunked" 126 | @logger.debug("Reading body with chunked encoding") 127 | read_http_body_chunked(&block) 128 | end 129 | 130 | # If this is a poolable resource, release it (like a FTW::Connection) 131 | @body.release if @body.respond_to?(:release) 132 | elsif !@body.nil? 133 | block.call(@body) 134 | end 135 | end # def read_http_body 136 | 137 | # Read the body of this message. The block is called with chunks of the 138 | # response as they are read in. 139 | # 140 | # This method is generally only called by http clients, not servers. 141 | # 142 | # If no block is given, the entire response body is returned as a string. 143 | def read_body(&block) 144 | if !block_given? 145 | content = "" 146 | read_http_body { |chunk| content << chunk } 147 | return content 148 | else 149 | read_http_body(&block) 150 | end 151 | end # def read_body 152 | 153 | # A shorthand for discarding the body of a request or response. 154 | # 155 | # This is the same as: 156 | # 157 | # foo.read_body { |c| } 158 | def discard_body 159 | read_body { |c| } 160 | end # def discard_body 161 | 162 | # Read the length bytes from the body. Yield each chunk read to the block 163 | # given. This method is generally only called by http clients, not servers. 164 | def read_http_body_length(length, &block) 165 | remaining = length 166 | while remaining > 0 167 | data = @body.read(remaining) 168 | @logger.debug("Read bytes", :length => data.bytesize) 169 | if data.bytesize > remaining 170 | # Read too much data, only wanted part of this. Push the rest back. 171 | yield data[0..remaining] 172 | remaining = 0 173 | @body.pushback(data[remaining .. -1]) if remaining < 0 174 | else 175 | yield data 176 | remaining -= data.bytesize 177 | end 178 | end 179 | end # def read_http_body_length 180 | 181 | # This is kind of messed, need to fix it. 182 | def read_http_body_chunked(&block) 183 | parser = HTTP::Parser.new 184 | 185 | # Fake fill-in the response we've already read into the parser. 186 | parser << to_s 187 | parser << CRLF 188 | parser.on_body = block 189 | done = false 190 | parser.on_message_complete = proc { done = true } 191 | 192 | while !done # will break on special conditions below 193 | # TODO(sissel): In JRuby, this read will sometimes hang for ever 194 | # because there's some wonkiness in IO.select on SSLSockets in JRuby. 195 | # Maybe we should fix it... 196 | data = @body.read 197 | offset = parser << data 198 | if offset != data.length 199 | raise "Parser did not consume all data read?" 200 | end 201 | end 202 | end # def read_http_body_chunked 203 | end # module FTW::Protocol 204 | -------------------------------------------------------------------------------- /lib/ftw/request.rb: -------------------------------------------------------------------------------- 1 | require "addressable/uri" # gem addressable 2 | require "cabin" # gem cabin 3 | require "ftw/crlf" 4 | require "ftw/http/message" 5 | require "ftw/namespace" 6 | require "ftw/response" 7 | require "ftw/protocol" 8 | require "uri" # ruby stdlib 9 | require "base64" # ruby stdlib 10 | 11 | # An HTTP Request. 12 | # 13 | # See RFC2616 section 5: 14 | class FTW::Request 15 | include FTW::HTTP::Message 16 | include FTW::Protocol 17 | include FTW::CRLF 18 | include Cabin::Inspectable 19 | 20 | private 21 | 22 | # The http method. Like GET, PUT, POST, etc.. 23 | # RFC2616 5.1.1 - 24 | # 25 | # Warning: this accessor obscures the ruby Kernel#method() method. 26 | # I would like to call this 'verb', but my preference is first to adhere to 27 | # RFC terminology. Further, ruby's stdlib Net::HTTP calls this 'method' as 28 | # well (See Net::HTTPGenericRequest). 29 | attr_accessor :method 30 | 31 | # This is the Request-URI. Many people call this the 'path' of the request. 32 | # RFC2616 5.1.2 - 33 | attr_accessor :request_uri 34 | 35 | # Lemmings. Everyone else calls Request-URI the 'path' (including me, most of 36 | # the time), so let's just follow along. 37 | alias_method :path, :request_uri 38 | 39 | # RFC2616 section 14.23 allows the Host header to include a port, but I have 40 | # never seen this in practice, and I shudder to think about what poorly-behaving 41 | # web servers will barf if the Host header includes a port. So, instead of 42 | # storing the port in the Host header, it is stored here. It is not included 43 | # in the Request when sent from a client and it is not used on a server. 44 | attr_accessor :port 45 | 46 | # This is *not* an RFC2616 field. It exists so that the connection handling 47 | # this request knows what protocol to use. The protocol for this request. 48 | # Usually 'http' or 'https' or perhaps 'spdy' maybe? 49 | attr_accessor :protocol 50 | 51 | # Make a new request with a uri if given. 52 | # 53 | # The uri is used to set the address, protocol, Host header, etc. 54 | def initialize(uri=nil) 55 | super() 56 | @port = 80 57 | @protocol = "http" 58 | @version = 1.1 59 | use_uri(uri) if !uri.nil? 60 | @logger = Cabin::Channel.get 61 | end # def initialize 62 | 63 | # Execute this request on a given connection: Writes the request, returns a 64 | # Response object. 65 | # 66 | # This method will block until the HTTP response header has been completely 67 | # received. The body will not have been read yet at the time of this 68 | # method's return. 69 | # 70 | # The 'connection' should be a FTW::Connection instance, but it might work 71 | # with a normal IO object. 72 | # 73 | def execute(connection) 74 | tries = 3 75 | begin 76 | connection.write(to_s + CRLF) 77 | if body? 78 | write_http_body(body, connection, 79 | headers["Transfer-Encoding"] == "chunked") 80 | end 81 | rescue => e 82 | # TODO(sissel): Rescue specific exceptions, not just anything. 83 | # Reconnect and retry 84 | if tries > 0 85 | tries -= 1 86 | connection.connect 87 | retry 88 | else 89 | raise e 90 | end 91 | end 92 | 93 | response = read_http_message(connection) 94 | # TODO(sissel): make sure we got a response, not a request, cuz that'd be weird. 95 | return response 96 | end # def execute 97 | 98 | # Use a URI to help fill in parts of this Request. 99 | def use_uri(uri) 100 | # Convert URI objects to Addressable::URI 101 | case uri 102 | when URI, String 103 | uri = Addressable::URI.parse(uri.to_s) 104 | end 105 | 106 | # TODO(sissel): Use uri.password and uri.user to set Authorization basic 107 | # stuff. 108 | if uri.password || uri.user 109 | encoded = Base64.strict_encode64("#{uri.user}:#{uri.password}") 110 | @headers.set("Authorization", "Basic #{encoded}") 111 | end 112 | # uri.password 113 | # uri.user 114 | @request_uri = uri.path 115 | # Include the query string, too. 116 | @request_uri += "?#{uri.query}" if !uri.query.nil? 117 | 118 | @headers.set("Host", uri.host) 119 | @protocol = uri.scheme 120 | if uri.port.nil? 121 | # default to port 80 122 | uri.port = { "http" => 80, "https" => 443 }.fetch(uri.scheme, 80) 123 | end 124 | @port = uri.port 125 | 126 | # TODO(sissel): support authentication 127 | end # def use_uri 128 | 129 | # Set the method for this request. Usually something like "GET" or "PUT" 130 | # etc. See 131 | def method=(method) 132 | # RFC2616 5.1.1 doesn't say the method has to be uppercase. 133 | # It can be any 'token' besides the ones defined in section 5.1.1: 134 | # The grammar for 'token' is: 135 | # token = 1* 136 | # TODO(sissel): support section 5.1.1 properly. Don't upcase, but 137 | # maybe upcase things that are defined in 5.1.1 like GET, etc. 138 | @method = method.upcase 139 | end # def method= 140 | 141 | # Get the request line (first line of the http request) 142 | # From the RFC: Request-Line = Method SP Request-URI SP HTTP-Version CRLF 143 | # 144 | # Note: I skip the trailing CRLF. See the to_s method where it is provided. 145 | def request_line 146 | return "#{method} #{request_uri} HTTP/#{version}" 147 | end # def request_line 148 | 149 | # Define the Message's start_line as request_line 150 | alias_method :start_line, :request_line 151 | 152 | public(:method, :method=, :request_uri, :request_uri=, :path, :port, :port=, 153 | :protocol, :protocol=, :execute, :use_uri, :request_line, :start_line) 154 | 155 | end # class FTW::Request < Message 156 | -------------------------------------------------------------------------------- /lib/ftw/response.rb: -------------------------------------------------------------------------------- 1 | require "ftw/namespace" 2 | require "ftw/protocol" 3 | require "ftw/http/message" 4 | require "cabin" # gem cabin 5 | require "http/parser" # gem http_parser.rb 6 | 7 | # An HTTP Response. 8 | # 9 | # See RFC2616 section 6: 10 | class FTW::Response 11 | include FTW::HTTP::Message 12 | include FTW::Protocol 13 | 14 | # The http status code (RFC2616 6.1.1) 15 | # See RFC2616 section 6.1.1: 16 | attr_reader :status 17 | 18 | # The reason phrase (RFC2616 6.1.1) 19 | # See RFC2616 section 6.1.1: 20 | attr_reader :reason 21 | 22 | # Translated from the recommendations listed in RFC2616 section 6.1.1 23 | # See RFC2616 section 6.1.1: 24 | STATUS_REASON_MAP = { 25 | 100 => "Continue", 26 | 101 => "Switching Protocols", 27 | 200 => "OK", 28 | 201 => "Created", 29 | 202 => "Accepted", 30 | 203 => "Non-Authoritative Information", 31 | 204 => "No Content", 32 | 205 => "Reset Content", 33 | 206 => "Partial Content", 34 | 300 => "Multiple Choices", 35 | 301 => "Moved Permanently", 36 | 302 => "Found", 37 | 303 => "See Other", 38 | 304 => "Not Modified", 39 | 305 => "Use Proxy", 40 | 307 => "Temporary Redirect", 41 | 400 => "Bad Request", 42 | 401 => "Unauthorized", 43 | 402 => "Payment Required", 44 | 403 => "Forbidden", 45 | 404 => "Not Found", 46 | 405 => "Method Not Allowed", 47 | 406 => "Not Acceptable" 48 | } # STATUS_REASON_MAP 49 | 50 | private 51 | 52 | # Create a new Response. 53 | def initialize 54 | super 55 | @logger = Cabin::Channel.get 56 | @reason = "" # Empty reason string by default. It is not required. 57 | end # def initialize 58 | 59 | # Is this response a redirect? 60 | def redirect? 61 | # redirects are 3xx 62 | return @status >= 300 && @status < 400 63 | end # redirect? 64 | 65 | # Is this response an error? 66 | def error? 67 | # 4xx and 5xx are errors 68 | return @status >= 400 && @status < 600 69 | end # def error? 70 | 71 | # Set the status code 72 | def status=(code) 73 | code = code.to_i if !code.is_a?(Fixnum) 74 | # TODO(sissel): Validate that 'code' is a 3 digit number 75 | @status = code 76 | 77 | # Attempt to set the reason if the status code has a known reason 78 | # recommendation. If one is not found, default to the current reason. 79 | @reason = STATUS_REASON_MAP.fetch(@status, @reason) 80 | end # def status= 81 | 82 | # Get the status-line string, like "HTTP/1.0 200 OK" 83 | def status_line 84 | # First line is 'Status-Line' from RFC2616 section 6.1 85 | # Status-Line = HTTP-Version SP Status-Code SP Reason-Phrase CRLF 86 | # etc... 87 | return "HTTP/#{version} #{status} #{reason}" 88 | end # def status_line 89 | 90 | # Define the Message's start_line as status_line 91 | alias_method :start_line, :status_line 92 | 93 | # Is this Response the result of a successful Upgrade request? 94 | def upgrade? 95 | return false unless status == 101 # "Switching Protocols" 96 | return false unless headers["Connection"] == "Upgrade" 97 | return true 98 | end # def upgrade? 99 | 100 | public(:status=, :status, :reason, :initialize, :upgrade?, :redirect?, 101 | :error?, :status_line) 102 | end # class FTW::Response 103 | 104 | -------------------------------------------------------------------------------- /lib/ftw/server.rb: -------------------------------------------------------------------------------- 1 | require "ftw/namespace" 2 | require "ftw/dns" 3 | require "ftw/connection" 4 | 5 | # A web server. 6 | class FTW::Server 7 | # This class is raised when an error occurs starting the server sockets. 8 | class ServerSetupFailure < StandardError; end 9 | 10 | # This class is raised when an invalid address is given to the server to 11 | # listen on. 12 | class InvalidAddress < StandardError; end 13 | 14 | private 15 | 16 | # The pattern addresses must match. This is used in FTW::Server#initialize. 17 | ADDRESS_RE = /^(.*):([^:]+)$/ 18 | 19 | # Create a new server listening on the given addresses 20 | # 21 | # This method will create, bind, and listen, so any errors during that 22 | # process be raised as ServerSetupFailure 23 | # 24 | # The parameter 'addresses' can be a single string or an array of strings. 25 | # These strings MUST have the form "address:port". If the 'address' part 26 | # is missing, it is assumed to be 0.0.0.0 27 | def initialize(addresses) 28 | addresses = [addresses] if !addresses.is_a?(Array) 29 | dns = FTW::DNS.singleton 30 | 31 | @control_lock = Mutex.new 32 | @sockets = {} 33 | 34 | failures = [] 35 | # address format is assumed to be 'host:port' 36 | # TODO(sissel): The split on ":" breaks ipv6 addresses, yo. 37 | addresses.each do |address| 38 | m = ADDRESS_RE.match(address) 39 | if !m 40 | raise InvalidAddress.new("Invalid address #{address.inspect}, spected string with format 'host:port'") 41 | end 42 | host, port = m[1..2] # first capture is host, second capture is port 43 | 44 | # Permit address being simply ':PORT' 45 | host = "0.0.0.0" if host.nil? 46 | 47 | # resolve each hostname, use the first one that successfully binds. 48 | local_failures = [] 49 | dns.resolve(host).each do |ip| 50 | #family = ip.include?(":") ? Socket::AF_INET6 : Socket::AF_INET 51 | #socket = Socket.new(family, Socket::SOCK_STREAM, 0) 52 | #sockaddr = Socket.pack_sockaddr_in(port, ip) 53 | socket = TCPServer.new(ip, port) 54 | #begin 55 | #socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true) 56 | #socket.bind(sockaddr) 57 | # If we get here, bind was successful 58 | #rescue Errno::EADDRNOTAVAIL => e 59 | # TODO(sissel): Record this failure. 60 | #local_failures << "Could not bind to #{ip}:#{port}, address not available on this system." 61 | #next 62 | #rescue Errno::EACCES 63 | # TODO(sissel): Record this failure. 64 | #local_failures << "No permission to bind to #{ip}:#{port}: #{e.inspect}" 65 | #next 66 | #end 67 | 68 | begin 69 | socket.listen(100) 70 | rescue Errno::EADDRINUSE 71 | local_failures << "Address in use, #{ip}:#{port}, cannot listen." 72 | next 73 | end 74 | 75 | # Break when successfully listened 76 | #p :accept? => socket.respond_to?(:accept) 77 | @sockets["#{host}(#{ip}):#{port}"] = socket 78 | local_failures.clear 79 | break 80 | end 81 | failures += local_failures 82 | end 83 | 84 | # This allows us to interrupt the #each_connection's select() later 85 | # when anyone calls stop() 86 | @stopper = IO.pipe 87 | 88 | # Abort if there were failures 89 | raise ServerSetupFailure.new(failures) if failures.any? 90 | end # def initialize 91 | 92 | # Stop serving. 93 | def stop 94 | @stopper[1].syswrite(".") 95 | @stopper[1].close() 96 | @control_lock.synchronize do 97 | @sockets.each do |name, socket| 98 | socket.close 99 | end 100 | @sockets.clear 101 | end 102 | end # def stop 103 | 104 | # Yield FTW::Connection instances to the block as clients connect. 105 | def each_connection(&block) 106 | # TODO(sissel): Select on all sockets 107 | # TODO(sissel): Accept and yield to the block 108 | stopper = @stopper[0] 109 | while !@sockets.empty? 110 | @control_lock.synchronize do 111 | sockets = @sockets.values + [stopper] 112 | read, write, error = IO.select(sockets, nil, nil, nil) 113 | break if read.include?(stopper) 114 | read.each do |serversocket| 115 | socket, addrinfo = serversocket.accept 116 | connection = FTW::Connection.from_io(socket) 117 | yield connection 118 | end 119 | end 120 | end 121 | end # def each_connection 122 | 123 | public(:initialize, :stop, :each_connection) 124 | end # class FTW::Server 125 | 126 | -------------------------------------------------------------------------------- /lib/ftw/singleton.rb: -------------------------------------------------------------------------------- 1 | require "ftw/namespace" 2 | 3 | # A mixin that provides singleton-ness 4 | # 5 | # Usage: 6 | # 7 | # class Foo 8 | # extend FTW::Singleton 9 | # 10 | # ... 11 | # end 12 | # 13 | # foo = Foo.singleton 14 | module FTW::Singleton 15 | # This is invoked when you include this module. It raises an exception because you should be 16 | # using 'extend' not 'include' for this module.. 17 | def self.included(klass) 18 | raise ArgumentError.new("In #{klass.name}, you want to use 'extend #{self.name}', not 'include ...'") 19 | end # def included 20 | 21 | # Create a singleton instance of whatever class this module is extended into. 22 | # 23 | # Example: 24 | # 25 | # class Foo 26 | # extend FTW::Singleton 27 | # def bar 28 | # "Hello!" 29 | # end 30 | # end 31 | # 32 | # p Foo.singleton.bar # == "Hello!" 33 | def singleton 34 | @instance ||= self.new 35 | return @instance 36 | end # def self.singleton 37 | end # module FTW::Singleton 38 | 39 | -------------------------------------------------------------------------------- /lib/ftw/version.rb: -------------------------------------------------------------------------------- 1 | require "ftw/namespace" 2 | 3 | # :nodoc: 4 | module FTW 5 | # The version of this library 6 | VERSION = "0.0.49" 7 | end 8 | -------------------------------------------------------------------------------- /lib/ftw/webserver.rb: -------------------------------------------------------------------------------- 1 | require "ftw" 2 | require "ftw/protocol" 3 | require "ftw/crlf" 4 | require "socket" 5 | require "cabin" 6 | 7 | # An attempt to invent a simple FTW web server. 8 | class FTW::WebServer 9 | include FTW::Protocol 10 | include FTW::CRLF 11 | 12 | def initialize(host, port, &block) 13 | @host = host 14 | @port = port 15 | @handler = block 16 | 17 | @logger = Cabin::Channel.get 18 | @threads = [] 19 | end # def initialize 20 | 21 | # Run the server. 22 | # 23 | # Connections are farmed out to threads. 24 | def run 25 | logger.info("Starting server", :config => @config) 26 | @server = FTW::Server.new([@host, @port].join(":")) 27 | @server.each_connection do |connection| 28 | @threads << Thread.new do 29 | handle_connection(connection) 30 | end 31 | end 32 | end # def run 33 | 34 | def stop 35 | @server.stop unless @server.nil? 36 | @threads.each(&:join) 37 | end # def stop 38 | 39 | # Handle a new connection. 40 | # 41 | # This method parses http requests and passes them on to #handle_request 42 | # 43 | # @param connection The FTW::Connection being handled. 44 | def handle_connection(connection) 45 | while true 46 | begin 47 | request = read_http_message(connection) 48 | rescue EOFError, Errno::EPIPE, Errno::ECONNRESET, HTTP::Parser::Error, IOError 49 | # Connection EOF'd or errored before we finished reading a full HTTP 50 | # message, shut it down. 51 | break 52 | rescue FTW::HTTP::Message::UnsupportedHTTPVersion 53 | break 54 | end 55 | 56 | if request["Content-Length"] || request["Transfer-Encoding"] 57 | request.body = connection 58 | end 59 | 60 | begin 61 | handle_request(request, connection) 62 | rescue => e 63 | puts e.inspect 64 | puts e.backtrace 65 | raise e 66 | end 67 | end 68 | connection.disconnect("Fun") 69 | end # def handle_connection 70 | 71 | # Handle a request. This will set up the rack 'env' and invoke the 72 | # application associated with this handler. 73 | def handle_request(request, connection) 74 | response = FTW::Response.new 75 | response.version = request.version 76 | response["Connection"] = request.headers["Connection"] || "close" 77 | 78 | # Process this request with the handler 79 | @handler.call(request, response, connection) 80 | 81 | # Write the response 82 | begin 83 | connection.write(response.to_s + CRLF) 84 | if response.body? 85 | write_http_body(response.body, connection, 86 | response["Transfer-Encoding"] == "chunked") 87 | end 88 | rescue => e 89 | @logger.error(e) 90 | connection.disconnect(e.inspect) 91 | end 92 | 93 | if response["Connection"] == "close" or response["Connection"].nil? 94 | connection.disconnect("'Connection' header was close or nil") 95 | end 96 | end # def handle_request 97 | 98 | # Get the logger. 99 | def logger 100 | if @logger.nil? 101 | @logger = Cabin::Channel.get 102 | end 103 | return @logger 104 | end # def logger 105 | 106 | public(:run, :initialize, :stop) 107 | end # class FTW::WebServer 108 | -------------------------------------------------------------------------------- /lib/ftw/websocket.rb: -------------------------------------------------------------------------------- 1 | require "ftw/namespace" 2 | require "openssl" 3 | require "base64" # stdlib 4 | require "digest/sha1" # stdlib 5 | require "cabin" 6 | require "ftw/websocket/parser" 7 | require "ftw/websocket/writer" 8 | require "ftw/crlf" 9 | 10 | # WebSockets, RFC6455. 11 | # 12 | # TODO(sissel): Find a comfortable way to make this websocket stuff 13 | # both use HTTP::Connection for the HTTP handshake and also be usable 14 | # from HTTP::Client 15 | # TODO(sissel): Also consider SPDY and the kittens. 16 | class FTW::WebSocket 17 | include FTW::CRLF 18 | include Cabin::Inspectable 19 | 20 | # The frame identifier for a 'text' frame 21 | TEXTFRAME = 0x0001 22 | 23 | # Search RFC6455 for this string and you will find its definitions. 24 | # It is used in servers accepting websocket upgrades. 25 | WEBSOCKET_ACCEPT_UUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" 26 | 27 | # Protocol phases 28 | # 1. tcp connect 29 | # 2. http handshake (RFC6455 section 4) 30 | # 3. websocket protocol 31 | 32 | private 33 | 34 | # Creates a new websocket and fills in the given http request with any 35 | # necessary settings. 36 | def initialize(request) 37 | @key_nonce = generate_key_nonce 38 | @request = request 39 | prepare(@request) 40 | @parser = FTW::WebSocket::Parser.new 41 | @messages = [] 42 | end # def initialize 43 | 44 | # Set the connection for this websocket. This is usually invoked by FTW::Agent 45 | # after the websocket upgrade and handshake have been successful. 46 | # 47 | # You probably don't call this yourself. 48 | def connection=(connection) 49 | @connection = connection 50 | end # def connection= 51 | 52 | # Prepare the request. This sets any required headers and attributes as 53 | # specified by RFC6455 54 | def prepare(request) 55 | # RFC6455 section 4.1: 56 | # "2. The method of the request MUST be GET, and the HTTP version MUST 57 | # be at least 1.1." 58 | request.method = "GET" 59 | request.version = 1.1 60 | 61 | # RFC6455 section 4.2.1 bullet 3 62 | request.headers.set("Upgrade", "websocket") 63 | # RFC6455 section 4.2.1 bullet 4 64 | request.headers.set("Connection", "Upgrade") 65 | # RFC6455 section 4.2.1 bullet 5 66 | request.headers.set("Sec-WebSocket-Key", @key_nonce) 67 | # RFC6455 section 4.2.1 bullet 6 68 | request.headers.set("Sec-WebSocket-Version", 13) 69 | # RFC6455 section 4.2.1 bullet 7 (optional) 70 | # The Origin header is optional for non-browser clients. 71 | #request.headers.set("Origin", ...) 72 | # RFC6455 section 4.2.1 bullet 8 (optional) 73 | #request.headers.set("Sec-Websocket-Protocol", ...) 74 | # RFC6455 section 4.2.1 bullet 9 (optional) 75 | #request.headers.set("Sec-Websocket-Extensions", ...) 76 | # RFC6455 section 4.2.1 bullet 10 (optional) 77 | # TODO(sissel): Any other headers like cookies, auth headers, are allowed. 78 | end # def prepare 79 | 80 | # Generate a websocket key nonce. 81 | def generate_key_nonce 82 | # RFC6455 section 4.1 says: 83 | # --- 84 | # 7. The request MUST include a header field with the name 85 | # |Sec-WebSocket-Key|. The value of this header field MUST be a 86 | # nonce consisting of a randomly selected 16-byte value that has 87 | # been base64-encoded (see Section 4 of [RFC4648]). The nonce 88 | # MUST be selected randomly for each connection. 89 | # --- 90 | # 91 | # It's not totally clear to me how cryptographically strong this random 92 | # nonce needs to be, and if it does not need to be strong and it would 93 | # benefit users who do not have ruby with openssl enabled, maybe just use 94 | # rand() to generate this string. 95 | # 96 | # Thus, generate a random 16 byte string and encode i with base64. 97 | # Array#pack("m") packs with base64 encoding. 98 | return Base64.strict_encode64(OpenSSL::Random.random_bytes(16)) 99 | end # def generate_key_nonce 100 | 101 | # Is this Response acceptable for our WebSocket Upgrade request? 102 | def handshake_ok?(response) 103 | # See RFC6455 section 4.2.2 104 | return false unless response.status == 101 # "Switching Protocols" 105 | return false unless response.headers.get("upgrade").downcase == "websocket" 106 | return false unless response.headers.get("connection").downcase == "upgrade" 107 | 108 | # Now verify Sec-WebSocket-Accept. It should be the SHA-1 of the 109 | # Sec-WebSocket-Key (in base64) + WEBSOCKET_ACCEPT_UUID 110 | expected = @key_nonce + WEBSOCKET_ACCEPT_UUID 111 | expected_hash = Digest::SHA1.base64digest(expected) 112 | return false unless response.headers.get("Sec-WebSocket-Accept") == expected_hash 113 | 114 | return true 115 | end # def handshake_ok? 116 | 117 | # Iterate over each WebSocket message. This method will run forever unless you 118 | # break from it. 119 | # 120 | # The text payload of each message will be yielded to the block. 121 | def each(&block) 122 | while true 123 | block.call(receive) 124 | end 125 | end # def each 126 | 127 | # Receive a single payload 128 | def receive 129 | @messages += network_consume if @messages.empty? 130 | @messages.shift 131 | end # def receive 132 | 133 | # Consume payloads from the network. 134 | def network_consume 135 | payloads = [] 136 | @parser.feed(@connection.read(16384)) do |payload| 137 | payloads << payload 138 | end 139 | return payloads 140 | end # def network_consume 141 | 142 | # Publish a message text. 143 | # 144 | # This will send a websocket text frame over the connection. 145 | def publish(message) 146 | writer = FTW::WebSocket::Writer.singleton 147 | writer.write_text(@connection, message) 148 | end # def publish 149 | 150 | public(:initialize, :connection=, :handshake_ok?, :each, :publish, :receive) 151 | end # class FTW::WebSocket 152 | -------------------------------------------------------------------------------- /lib/ftw/websocket/constants.rb: -------------------------------------------------------------------------------- 1 | 2 | # The UUID comes from: 3 | # http://tools.ietf.org/html/rfc6455#page-23 4 | # 5 | # The opcode definitions come from: 6 | # http://tools.ietf.org/html/rfc6455#section-11.8 7 | module FTW::WebSocket::Constants 8 | # websocket uuid, used in hash signing of websocket responses (RFC6455) 9 | WEBSOCKET_ACCEPT_UUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" 10 | 11 | # Indication that this frame is a continuation in a fragmented message 12 | # See RFC6455 page 33. 13 | OPCODE_CONTINUATION = 0 14 | 15 | # Indication that this frame contains a text message 16 | OPCODE_TEXT = 1 17 | 18 | # Indication that this frame contains a binary message 19 | OPCODE_BINARY = 2 20 | 21 | # Indication that this frame is a 'connection close' message 22 | OPCODE_CLOSE = 8 23 | 24 | # Indication that this frame is a 'ping' message 25 | OPCODE_PING = 9 26 | 27 | # Indication that this frame is a 'pong' message 28 | OPCODE_PONG = 10 29 | end # module FTW::WebSocket::Constants 30 | -------------------------------------------------------------------------------- /lib/ftw/websocket/parser.rb: -------------------------------------------------------------------------------- 1 | require "ftw/namespace" 2 | require "ftw/websocket" 3 | 4 | # This class implements a parser for WebSocket messages over a stream. 5 | # 6 | # Protocol diagram copied from RFC6455 7 | # 0 1 2 3 8 | # 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 9 | # +-+-+-+-+-------+-+-------------+-------------------------------+ 10 | # |F|R|R|R| opcode|M| Payload len | Extended payload length | 11 | # |I|S|S|S| (4) |A| (7) | (16/64) | 12 | # |N|V|V|V| |S| | (if payload len==126/127) | 13 | # | |1|2|3| |K| | | 14 | # +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + 15 | # | Extended payload length continued, if payload len == 127 | 16 | # + - - - - - - - - - - - - - - - +-------------------------------+ 17 | # | |Masking-key, if MASK set to 1 | 18 | # +-------------------------------+-------------------------------+ 19 | # | Masking-key (continued) | Payload Data | 20 | # +-------------------------------- - - - - - - - - - - - - - - - + 21 | # : Payload Data continued ... : 22 | # + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + 23 | # | Payload Data continued ... | 24 | # +---------------------------------------------------------------+ 25 | # 26 | # Example use: 27 | # 28 | # socket = FTW::Connection.new("example.com:80") 29 | # parser = FTW::WebSocket::Parser.new 30 | # # ... do HTTP Upgrade request to websockets 31 | # loop do 32 | # data = socket.sysread(4096) 33 | # payload = parser.feed(data) 34 | # if payload 35 | # # We got a full websocket frame, print the payload. 36 | # p :payload => payload 37 | # end 38 | # end 39 | # 40 | class FTW::WebSocket::Parser 41 | # XXX: Implement control frames: http://tools.ietf.org/html/rfc6455#section-5.5 42 | 43 | # States are based on the minimal unit of 'byte' 44 | STATES = [ :flags_and_opcode, :mask_and_payload_init, :payload_length, :payload ] 45 | 46 | private 47 | 48 | # A new WebSocket protocol parser. 49 | def initialize 50 | @logger = Cabin::Channel.get 51 | @opcode = 0 52 | @masking_key = "" 53 | @flag_final_payload = 0 54 | @flag_mask = 0 55 | 56 | transition(:flags_and_opcode, 1) 57 | @buffer = "" 58 | @buffer.force_encoding("BINARY") 59 | end # def initialize 60 | 61 | # Transition to a specified state and set the next required read length. 62 | def transition(state, next_length) 63 | @logger.debug("Transitioning", :transition => state, :nextlen => next_length) 64 | @state = state 65 | # TODO(sissel): Assert this self.respond_to?(state) 66 | # TODO(sissel): Assert next_length is a number 67 | need(next_length) 68 | end # def transition 69 | 70 | # Feed data to this parser. 71 | # 72 | # Currently, it will return the raw payload of websocket messages. 73 | # Otherwise, it returns nil if no complete message has yet been consumed. 74 | # 75 | # @param [String] the string data to feed into the parser. 76 | # @return [String, nil] the websocket message payload, if any, nil otherwise. 77 | def feed(data) 78 | @buffer << data 79 | while have?(@need) 80 | value = send(@state) 81 | # Return if our state yields a value. 82 | yield value if !value.nil? and block_given? 83 | end 84 | return nil 85 | end # def << 86 | 87 | # Do we have at least 'length' bytes in the buffer? 88 | def have?(length) 89 | return length <= @buffer.bytesize 90 | end # def have? 91 | 92 | # Get 'length' string from the buffer. 93 | def get(length=nil) 94 | length = @need if length.nil? 95 | data = @buffer[0 ... length] 96 | @buffer = @buffer[length .. -1] 97 | return data 98 | end # def get 99 | 100 | # Set the minimum number of bytes we need in the buffer for the next read. 101 | def need(length) 102 | @need = length 103 | end # def need 104 | 105 | # State: Flags (fin, etc) and Opcode. 106 | # See: http://tools.ietf.org/html/rfc6455#section-5.3 107 | def flags_and_opcode 108 | # 0 109 | # 0 1 2 3 4 5 6 7 110 | # +-+-+-+-+------- 111 | # |F|R|R|R| opcode 112 | # |I|S|S|S| (4) 113 | # |N|V|V|V| 114 | # | |1|2|3| 115 | byte = get(@need).bytes.first 116 | @opcode = byte & 0xF # last 4 bites 117 | @fin = (byte & 0x80 == 0x80)# first bit 118 | 119 | #p :byte => byte, :bits => byte.to_s(2), :opcode => @opcode, :fin => @fin 120 | # mask_and_payload_length has a minimum length 121 | # of 1 byte, so start there. 122 | transition(:mask_and_payload_init, 1) 123 | 124 | # This state yields no output. 125 | return nil 126 | end # def flags_and_opcode 127 | 128 | # State: mask_and_payload_init 129 | # See: http://tools.ietf.org/html/rfc6455#section-5.2 130 | def mask_and_payload_init 131 | byte = get(@need).bytes.first 132 | @masked = (byte & 0x80) == 0x80 # first bit (msb) 133 | @payload_length = byte & 0x7F # remaining bits are the length 134 | case @payload_length 135 | when 126 # 2 byte, unsigned value is the payload length 136 | transition(:extended_payload_length, 2) 137 | when 127 # 8 byte, unsigned value is the payload length 138 | transition(:extended_payload_length, 8) 139 | else 140 | # If there is a mask, read that next 141 | if @masked 142 | transition(:mask, 4) 143 | else 144 | # Otherwise, the payload is next. 145 | # Keep the current payload length, a 7 bit value. 146 | # Go to read the payload 147 | transition(:payload, @payload_length) 148 | end 149 | end # case @payload_length 150 | 151 | # This state yields no output. 152 | return nil 153 | end # def mask_and_payload_init 154 | 155 | # State: payload_length 156 | # This is the 'extended payload length' with support for both 16 157 | # and 64 bit lengths. 158 | # See: http://tools.ietf.org/html/rfc6455#section-5.2 159 | def extended_payload_length 160 | # 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 161 | # +-+-+-+-+-------+-+-------------+-------------------------------+ 162 | # |F|R|R|R| opcode|M| Payload len | Extended payload length | 163 | # |I|S|S|S| (4) |A| (7) | (16/64) | 164 | # |N|V|V|V| |S| | (if payload len==126/127) | 165 | # | |1|2|3| |K| | | 166 | # +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + 167 | # | Extended payload length continued, if payload len == 127 | 168 | # + - - - - - - - - - - - - - - - +-------------------------------+ 169 | # | |Masking-key, if MASK set to 1 | 170 | # +-------------------------------+-------------------------------+ 171 | data = get 172 | case @need 173 | when 2 174 | @payload_length = data.unpack("S>").first 175 | when 8 176 | @payload_length = data.unpack("Q>").first 177 | else 178 | raise "Unknown payload_length byte length '#{@need}'" 179 | end 180 | 181 | if @masked 182 | # Read the mask next if there is one. 183 | transition(:mask, 4) 184 | else 185 | # Otherwise, next is the payload 186 | transition(:payload, @payload_length) 187 | end 188 | 189 | # This state yields no output. 190 | return nil 191 | end # def extended_payload_length 192 | 193 | # State: mask 194 | # Read the mask key 195 | def mask 196 | # + - - - - - - - - - - - - - - - +-------------------------------+ 197 | # | |Masking-key, if MASK set to 1 | 198 | # +-------------------------------+-------------------------------+ 199 | # | Masking-key (continued) | Payload Data | 200 | # +-------------------------------- - - - - - - - - - - - - - - - + 201 | @mask = get(@need) 202 | transition(:payload, @payload_length) 203 | return nil 204 | end # def mask 205 | 206 | # State: payload 207 | # Read the full payload and return it. 208 | # See: http://tools.ietf.org/html/rfc6455#section-5.3 209 | def payload 210 | # TODO(sissel): Handle massive payload lengths without exceeding memory. 211 | # Perhaps if the payload is large (say, larger than 500KB by default), 212 | # instead of returning the whole thing, simply return an Enumerable that 213 | # yields chunks of the payload. There's no reason to buffer the entire 214 | # thing. Have the consumer of this library make that decision. 215 | data = get(@need) 216 | transition(:flags_and_opcode, 1) 217 | if @masked 218 | return unmask(data, @mask) 219 | else 220 | return data 221 | end 222 | end # def payload 223 | 224 | # Unmask the message using the key. 225 | # 226 | # For implementation specification, see 227 | # http://tools.ietf.org/html/rfc6455#section-5.3 228 | def unmask(message, key) 229 | masked = [] 230 | mask_bytes = key.unpack("C4") 231 | i = 0 232 | message.each_byte do |byte| 233 | masked << (byte ^ mask_bytes[i % 4]) 234 | i += 1 235 | end 236 | #p :unmasked => masked.pack("C*"), :original => message 237 | return masked.pack("C*") 238 | end # def unmask 239 | 240 | public(:feed) 241 | end # class FTW::WebSocket::Parser 242 | -------------------------------------------------------------------------------- /lib/ftw/websocket/rack.rb: -------------------------------------------------------------------------------- 1 | require "ftw/namespace" 2 | require "ftw/websocket/parser" 3 | require "ftw/crlf" 4 | require "base64" # stdlib 5 | require "digest/sha1" # stdlib 6 | 7 | # A websocket helper for Rack 8 | # 9 | # An example with Sinatra: 10 | # 11 | # get "/websocket/echo" do 12 | # ws = FTW::WebSocket::Rack.new(env) 13 | # stream(:keep_open) do |out| 14 | # ws.each do |payload| 15 | # # 'payload' is the text payload of a single websocket message 16 | # # publish it back to the client 17 | # ws.publish(payload) 18 | # end 19 | # end 20 | # ws.rack_response 21 | # end 22 | class FTW::WebSocket::Rack 23 | include FTW::WebSocket::Constants 24 | include FTW::CRLF 25 | 26 | private 27 | 28 | # Create a new websocket rack helper... thing. 29 | # 30 | # @param rack_env the 'env' bit given to your Rack application 31 | def initialize(rack_env) 32 | @env = rack_env 33 | @handshake_errors = [] 34 | 35 | # RFC6455 section 4.2.1 bullet 3 36 | expect_equal("websocket", @env["HTTP_UPGRADE"], 37 | "The 'Upgrade' header must be set to 'websocket'") 38 | # RFC6455 section 4.2.1 bullet 4 39 | # Firefox uses a multivalued 'Connection' header, that appears like this: 40 | # Connection: keep-alive, Upgrade 41 | # So we have to split this multivalue field. 42 | expect_equal(true, 43 | @env["HTTP_CONNECTION"].split(/, +/).include?("Upgrade"), 44 | "The 'Connection' header must be set to 'Upgrade'") 45 | # RFC6455 section 4.2.1 bullet 6 46 | expect_equal("13", @env["HTTP_SEC_WEBSOCKET_VERSION"], 47 | "Sec-WebSocket-Version must be set to 13") 48 | 49 | # RFC6455 section 4.2.1 bullet 5 50 | @key = @env["HTTP_SEC_WEBSOCKET_KEY"] 51 | 52 | @parser = FTW::WebSocket::Parser.new 53 | end # def initialize 54 | 55 | # Test values for equality. This is used in handshake tests. 56 | def expect_equal(expected, actual, message) 57 | if expected != actual 58 | @handshake_errors << message 59 | end 60 | end # def expected 61 | 62 | # Is this a valid handshake? 63 | def valid? 64 | return @handshake_errors.empty? 65 | end # def valid? 66 | 67 | # Get the response Rack is expecting. 68 | # 69 | # If this was a valid websocket request, it will return a response 70 | # that completes the HTTP portion of the websocket handshake. 71 | # 72 | # If this was an invalid websocket request, it will return a 73 | # 400 status code and descriptions of what failed in the body 74 | # of the response. 75 | # 76 | # @return [number, hash, body] 77 | def rack_response 78 | if valid? 79 | # Return the status, headers, body that is expected. 80 | sec_accept = @key + WEBSOCKET_ACCEPT_UUID 81 | sec_accept_hash = Digest::SHA1.base64digest(sec_accept) 82 | 83 | headers = { 84 | "Upgrade" => "websocket", 85 | "Connection" => "Upgrade", 86 | "Sec-WebSocket-Accept" => sec_accept_hash 87 | } 88 | # See RFC6455 section 4.2.2 89 | return 101, headers, nil 90 | else 91 | # Invalid request, tell the client why. 92 | return 400, { "Content-Type" => "text/plain" }, 93 | @handshake_errors.map { |m| "#{m}#{CRLF}" } 94 | end 95 | end # def rack_response 96 | 97 | # Enumerate each websocket payload (message). 98 | # 99 | # The payload of each message will be yielded to the block. 100 | # 101 | # Example: 102 | # 103 | # ws.each do |payload| 104 | # puts "Received: #{payload}" 105 | # end 106 | def each 107 | connection = @env["ftw.connection"] 108 | # There seems to be a bug in http_parser.rb where websocket responses 109 | # lead with a newline for some reason. It's like the header terminator 110 | # CRLF still has the LF character left in the buffer. Work around it. 111 | data = connection.read 112 | if data[0] == "\n" 113 | connection.pushback(data[1..-1]) 114 | else 115 | connection.pushback(data) 116 | end 117 | 118 | while true 119 | begin 120 | data = connection.read(16384) 121 | rescue EOFError 122 | # connection shutdown, close up. 123 | break 124 | end 125 | 126 | @parser.feed(data) do |payload| 127 | yield payload if !payload.nil? 128 | end 129 | end 130 | end # def each 131 | 132 | # Publish a message over this websocket. 133 | # 134 | # @param message Publish a string message to the websocket. 135 | def publish(message) 136 | writer = FTW::WebSocket::Writer.singleton 137 | writer.write_text(@env["ftw.connection"], message) 138 | end # def publish 139 | 140 | public(:initialize, :valid?, :rack_response, :each, :publish) 141 | end # class FTW::WebSocket::Rack 142 | -------------------------------------------------------------------------------- /lib/ftw/websocket/writer.rb: -------------------------------------------------------------------------------- 1 | require "ftw/namespace" 2 | require "ftw/websocket" 3 | require "ftw/singleton" 4 | require "ftw/websocket/constants" 5 | 6 | # This class implements a writer for WebSocket messages over a stream. 7 | # 8 | # Protocol diagram copied from RFC6455 9 | # 0 1 2 3 10 | # 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 11 | # +-+-+-+-+-------+-+-------------+-------------------------------+ 12 | # |F|R|R|R| opcode|M| Payload len | Extended payload length | 13 | # |I|S|S|S| (4) |A| (7) | (16/64) | 14 | # |N|V|V|V| |S| | (if payload len==126/127) | 15 | # | |1|2|3| |K| | | 16 | # +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + 17 | # | Extended payload length continued, if payload len == 127 | 18 | # + - - - - - - - - - - - - - - - +-------------------------------+ 19 | # | |Masking-key, if MASK set to 1 | 20 | # +-------------------------------+-------------------------------+ 21 | # | Masking-key (continued) | Payload Data | 22 | # +-------------------------------- - - - - - - - - - - - - - - - + 23 | # : Payload Data continued ... : 24 | # + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + 25 | # | Payload Data continued ... | 26 | # +---------------------------------------------------------------+ 27 | 28 | class FTW::WebSocket::Writer 29 | include FTW::WebSocket::Constants 30 | extend FTW::Singleton 31 | 32 | # A list of valid modes. Used to validate input in #write_text. 33 | # 34 | # In :server mode, payloads are not masked. In :client mode, payloads 35 | # are masked. Masking is described in RFC6455. 36 | VALID_MODES = [:server, :client] 37 | 38 | private 39 | 40 | # Write the given text in a websocket frame to the connection. 41 | # 42 | # Valid 'mode' settings are :server or :client. If :client, the 43 | # payload will be masked according to RFC6455 section 5.3: 44 | # http://tools.ietf.org/html/rfc6455#section-5.3 45 | def write_text(connection, text, mode=:server) 46 | if !VALID_MODES.include?(mode) 47 | raise InvalidArgument.new("Invalid message mode: #{mode}, expected one of" \ 48 | "#{VALID_MODES.inspect}") 49 | end 50 | 51 | data = [] 52 | pack = [] 53 | 54 | # For now, assume single-fragment, text frames 55 | pack_opcode(data, pack, OPCODE_TEXT) 56 | pack_payload(data, pack, text, mode) 57 | connection.write(data.pack(pack.join(""))) 58 | end # def write_text 59 | 60 | # Pack the opcode and flags 61 | # 62 | # Currently assumes 'fin' flag is set. 63 | def pack_opcode(data, pack, opcode) 64 | # Pack the first byte (fin + opcode) 65 | data << ((1 << 7) | opcode) 66 | pack << "C" 67 | end # def pack_opcode 68 | 69 | # Pack the payload. 70 | def pack_payload(data, pack, text, mode) 71 | pack_maskbit_and_length(data, pack, text.bytesize, mode) 72 | pack_extended_length(data, pack, text.bytesize) if text.bytesize >= 126 73 | if mode == :client 74 | mask_key = [rand(1 << 32)].pack("Q") 75 | pack_mask(data, pack, mask_key) 76 | data << mask(text, mask_key) 77 | pack << "A*" 78 | else 79 | data << text 80 | pack << "A*" 81 | end 82 | end # def pack_payload 83 | 84 | # Implement masking as described by http://tools.ietf.org/html/rfc6455#section-5.3 85 | # Basically, we take a 4-byte random string and use it, round robin, to XOR 86 | # every byte. Like so: 87 | # message[0] ^ key[0] 88 | # message[1] ^ key[1] 89 | # message[2] ^ key[2] 90 | # message[3] ^ key[3] 91 | # message[4] ^ key[0] 92 | # ... 93 | def mask(message, key) 94 | masked = [] 95 | mask_bytes = key.unpack("C4") 96 | i = 0 97 | message.each_byte do |byte| 98 | masked << (byte ^ mask_bytes[i % 4]) 99 | i += 1 100 | end 101 | return masked.pack("C*") 102 | end # def mask 103 | 104 | # Pack the first part of the length (mask and 7-bit length) 105 | def pack_maskbit_and_length(data, pack, length, mode) 106 | # Pack mask + payload length 107 | maskbit = (mode == :client) ? (1 << 7) : 0 108 | if length >= 126 109 | if length < (1 << 16) # if less than 2^16, use 2 bytes 110 | lengthbits = 126 111 | else 112 | lengthbits = 127 113 | end 114 | else 115 | lengthbits = length 116 | end 117 | data << (maskbit | lengthbits) 118 | pack << "C" 119 | end # def pack_maskbit_and_length 120 | 121 | # Pack the extended length. 16 bits or 64 bits 122 | def pack_extended_length(data, pack, length) 123 | data << length 124 | if length >= (1 << 16) 125 | # For lengths >= 16 bits, pack 8 byte length 126 | pack << "Q>" 127 | else 128 | # For lengths < 16 bits, pack 2 byte length 129 | pack << "S>" 130 | end 131 | end # def pack_extended_length 132 | 133 | public(:initialize, :write_text) 134 | end # module FTW::WebSocket::Writer 135 | -------------------------------------------------------------------------------- /lib/rack/handler/ftw.rb: -------------------------------------------------------------------------------- 1 | require "rack" 2 | require "ftw" 3 | require "ftw/protocol" 4 | require "ftw/crlf" 5 | require "socket" 6 | require "cabin" 7 | 8 | # FTW cannot fully respect the Rack 1.1 specification due to technical 9 | # limitations in the Rack design, specifically: 10 | # 11 | # * rack.input must be buffered, to support IO#rewind, for the duration of each 12 | # request. This is not safe if that request is an HTTP Upgrade or a long 13 | # upload. 14 | # 15 | # FTW::Connection does not implement #rewind. Need it? File a ticket. 16 | # 17 | # To support HTTP Upgrade, CONNECT, and protocol-switching features, this 18 | # server handler will set "ftw.connection" to the FTW::Connection related 19 | # to this request. 20 | # 21 | # The above data is based on the response to this ticket: 22 | # https://github.com/rack/rack/issues/347 23 | class Rack::Handler::FTW 24 | include FTW::Protocol 25 | include FTW::CRLF 26 | 27 | # The version of the rack specification supported by this handler. 28 | RACK_VERSION = [1,1] 29 | 30 | # A string constant value (used to avoid typos). 31 | REQUEST_METHOD = "REQUEST_METHOD".freeze 32 | # A string constant value (used to avoid typos). 33 | SCRIPT_NAME = "SCRIPT_NAME".freeze 34 | # A string constant value (used to avoid typos). 35 | PATH_INFO = "PATH_INFO".freeze 36 | # A string constant value (used to avoid typos). 37 | QUERY_STRING = "QUERY_STRING".freeze 38 | # A string constant value (used to avoid typos). 39 | SERVER_NAME = "SERVER_NAME".freeze 40 | # A string constant value (used to avoid typos). 41 | SERVER_PORT = "SERVER_PORT".freeze 42 | 43 | # A string constant value (used to avoid typos). 44 | RACK_DOT_VERSION = "rack.version".freeze 45 | # A string constant value (used to avoid typos). 46 | RACK_DOT_URL_SCHEME = "rack.url_scheme".freeze 47 | # A string constant value (used to avoid typos). 48 | RACK_DOT_INPUT = "rack.input".freeze 49 | # A string constant value (used to avoid typos). 50 | RACK_DOT_ERRORS = "rack.errors".freeze 51 | # A string constant value (used to avoid typos). 52 | RACK_DOT_MULTITHREAD = "rack.multithread".freeze 53 | # A string constant value (used to avoid typos). 54 | RACK_DOT_MULTIPROCESS = "rack.multiprocess".freeze 55 | # A string constant value (used to avoid typos). 56 | RACK_DOT_RUN_ONCE = "rack.run_once".freeze 57 | # A string constant value (used to avoid typos). 58 | RACK_DOT_LOGGER = "rack.logger".freeze 59 | # A string constant value (used to avoid typos). 60 | FTW_DOT_CONNECTION = "ftw.connection".freeze 61 | 62 | # This method is invoked when rack starts this as the server. 63 | def self.run(app, config) 64 | #@logger.subscribe(STDOUT) 65 | server = self.new(app, config) 66 | server.run 67 | end # def self.run 68 | 69 | private 70 | 71 | # setup a new rack server 72 | def initialize(app, config) 73 | @app = app 74 | @config = config 75 | @threads = [] 76 | end # def initialize 77 | 78 | # Run the server. 79 | # 80 | # Connections are farmed out to threads. 81 | def run 82 | # {:environment=>"development", :pid=>nil, :Port=>9292, :Host=>"0.0.0.0", 83 | # :AccessLog=>[], :config=>"/home/jls/projects/ruby-ftw/examples/test.ru", 84 | # :server=>"FTW"} 85 | # 86 | # listen, pass connections off 87 | # 88 | # 89 | # """A Rack application is an Ruby object (not a class) that responds to 90 | # call. It takes exactly one argument, the environment and returns an 91 | # Array of exactly three values: The status, the headers, and the body.""" 92 | # 93 | logger.info("Starting server", :config => @config) 94 | @server = FTW::Server.new([@config[:Host], @config[:Port]].join(":")) 95 | @server.each_connection do |connection| 96 | # The rack specification insists that 'rack.input' objects support 97 | # #rewind. Bleh. Just lie about it and monkeypatch it in. 98 | # This is required for Sinatra to accept 'post' requests, otherwise 99 | # it barfs. 100 | class << connection 101 | def rewind(*args) 102 | # lolrack, nothing to do here. 103 | end 104 | end 105 | 106 | @threads << Thread.new do 107 | handle_connection(connection) 108 | end 109 | end 110 | end # def run 111 | 112 | def stop 113 | @server.stop unless @server.nil? 114 | @threads.each(&:join) 115 | end # def stop 116 | 117 | # Handle a new connection. 118 | # 119 | # This method parses http requests and passes them on to #handle_request 120 | # 121 | # @param connection The FTW::Connection being handled. 122 | def handle_connection(connection) 123 | while true 124 | begin 125 | request = read_http_message(connection) 126 | rescue IOError, EOFError, Errno::EPIPE, Errno::ECONNRESET, HTTP::Parser::Error 127 | # Connection EOF'd or errored before we finished reading a full HTTP 128 | # message, shut it down. 129 | break 130 | rescue ArgumentError 131 | # Invalid http request sent 132 | break 133 | end 134 | 135 | begin 136 | handle_request(request, connection) 137 | rescue => e 138 | puts e.inspect 139 | puts e.backtrace 140 | raise e 141 | end 142 | end 143 | ensure 144 | connection.disconnect("Closing...") 145 | end # def handle_connection 146 | 147 | # Handle a request. This will set up the rack 'env' and invoke the 148 | # application associated with this handler. 149 | def handle_request(request, connection) 150 | path, query = request.path.split("?", 2) 151 | env = { 152 | # CGI-like environment as required by the Rack SPEC version 1.1 153 | REQUEST_METHOD => request.method, 154 | SCRIPT_NAME => "/", # TODO(sissel): not totally sure what this really should be 155 | PATH_INFO => path, 156 | QUERY_STRING => query.nil? ? "" : query, 157 | SERVER_NAME => "hahaha, no", # TODO(sissel): Set this 158 | SERVER_PORT => "", # TODO(sissel): Set this 159 | 160 | # Rack-specific environment, also required by Rack SPEC version 1.1 161 | RACK_DOT_VERSION => RACK_VERSION, 162 | RACK_DOT_URL_SCHEME => "http", # TODO(sissel): support https 163 | RACK_DOT_INPUT => connection, 164 | RACK_DOT_ERRORS => STDERR, 165 | RACK_DOT_MULTITHREAD => true, 166 | RACK_DOT_MULTIPROCESS => false, 167 | RACK_DOT_RUN_ONCE => false, 168 | RACK_DOT_LOGGER => logger, 169 | 170 | # Extensions, not in Rack v1.1. 171 | 172 | # ftw.connection lets you access the connection involved in this request. 173 | # It should be used when you need to hijack the connection for use 174 | # in proxying, HTTP CONNECT, websockets, SPDY(maybe?), etc. 175 | FTW_DOT_CONNECTION => connection 176 | } # env 177 | 178 | request.headers.each do |name, value| 179 | # The Rack spec says: 180 | # """ Variables corresponding to the client-supplied HTTP request headers 181 | # (i.e., variables whose names begin with HTTP_). The presence or 182 | # absence of these variables should correspond with the presence or 183 | # absence of the appropriate HTTP header in the request. """ 184 | # 185 | # It doesn't specify how to translate the header names into this hash syntax. 186 | # I looked at what Thin does, and it capitalizes and replaces dashes with 187 | # underscores, so I'll just copy that behavior. The specific code that implements 188 | # this in thin is here: 189 | # https://github.com/macournoyer/thin/blob/2e9db13e414ae7425/ext/thin_parser/thin.c#L89-L95 190 | # 191 | # The Rack spec also doesn't describe what should be done for headers 192 | # with multiple values. 193 | # 194 | env["HTTP_#{name.upcase.gsub("-", "_")}"] = value 195 | end # request.headers.each 196 | 197 | # Invoke the application in this rack app 198 | status, headers, body = @app.call(env) 199 | 200 | # The application is done handling this request, respond to the client. 201 | response = FTW::Response.new 202 | response.status = status.to_i 203 | response.version = request.version 204 | headers.each do |name, value| 205 | response.headers.add(name, value) 206 | end 207 | response.body = body 208 | 209 | begin 210 | connection.write(response.to_s + CRLF) 211 | write_http_body(body, connection, response["Transfer-Encoding"] == "chunked") 212 | rescue => e 213 | @logger.error(e) 214 | connection.disconnect(e.inspect) 215 | end 216 | end # def handle_request 217 | 218 | # Get the logger. 219 | def logger 220 | if @logger.nil? 221 | @logger = Cabin::Channel.get 222 | end 223 | return @logger 224 | end # def logger 225 | 226 | public(:run, :initialize, :stop) 227 | end 228 | -------------------------------------------------------------------------------- /notify-failure.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | "$@" 4 | status=$? 5 | 6 | if [ ! -z "$TMUX" ] ; then 7 | if [ "$status" -ne 0 ] ; then 8 | tmux display-message "Tests Fail" 9 | else 10 | tmux display-message "Tests OK" 11 | fi 12 | fi 13 | 14 | exit $status 15 | 16 | -------------------------------------------------------------------------------- /spec/fixtures/websockets.rb: -------------------------------------------------------------------------------- 1 | require "sinatra/base" 2 | require "ftw/websocket/rack" 3 | 4 | Thread.abort_on_exception = true 5 | class Fixtures; class WebEcho < Sinatra::Base 6 | get "/" do 7 | [ 200, {"Content-Type" => "text/json"}, params.to_json ] 8 | end 9 | 10 | # Make an echo server over websockets. 11 | get "/websocket" do 12 | ws = FTW::WebSocket::Rack.new(env) 13 | stream(:keep_open) do |out| 14 | ws.each do |payload| 15 | # 'payload' is the text payload of a single websocket message 16 | # publish it back to the client 17 | ws.publish(payload) 18 | end 19 | end 20 | ws.rack_response 21 | end 22 | end; end # class EchoServer 23 | -------------------------------------------------------------------------------- /spec/ftw-agent_spec.rb: -------------------------------------------------------------------------------- 1 | require "cabin" 2 | require "ftw/agent" 3 | 4 | describe "FTW Agent for client request" do 5 | let (:logger) { Cabin::Channel.get("rspec") } 6 | 7 | before :all do 8 | logger.subscribe(STDERR) 9 | logger.level = :info 10 | end 11 | 12 | context "when re-using connection" do 13 | let (:agent) { FTW::Agent.new } 14 | 15 | after :each do 16 | agent.shutdown 17 | end 18 | 19 | #This test currently fail 20 | it "should not fail on SSL EOF error" do 21 | url = "https://google.com/" 22 | response = agent.get!(url) 23 | response.discard_body # Consume body to let this connection be reused 24 | response = agent.get!(url) # Re-use connection 25 | response.discard_body # Consume body to let this connection be reused 26 | end 27 | end 28 | 29 | context "ssl strength" do 30 | let (:agent) { FTW::Agent.new } 31 | 32 | it "should pass howsmyssl's tests" do 33 | response = agent.get!("https://www.howsmyssl.com/a/check") 34 | reject { response }.error? 35 | payload = response.read_body 36 | require "json" 37 | result = JSON.parse(payload) 38 | insist { result["beast_vuln"] } == false 39 | insist { result["rating"] } != "Bad" 40 | end 41 | end 42 | end 43 | 44 | -------------------------------------------------------------------------------- /spec/integration/websockets_spec.rb: -------------------------------------------------------------------------------- 1 | require "fixtures/websockets" 2 | require "rack/handler/ftw" 3 | require "stud/try" 4 | require "insist" 5 | 6 | describe "WebSockets" do 7 | let (:logger) { Cabin::Channel.get("rspec") } 8 | let (:app) { Fixtures::WebEcho.new } 9 | let (:port) { rand(20000) + 1000 } 10 | 11 | let (:rack) do 12 | # Listen on a random port 13 | Rack::Handler::FTW.new(app, :Host => "127.0.0.1", :Port => port) 14 | end # let rack 15 | 16 | let (:address) do 17 | "127.0.0.1:#{port}" 18 | end # let address 19 | 20 | before :all do 21 | logger.subscribe(STDERR) 22 | logger.level = :info 23 | end 24 | 25 | before :each do 26 | Thread.new { rack.run } 27 | end 28 | 29 | after :each do 30 | rack.stop 31 | end 32 | 33 | context "when using the EchoServer" do 34 | let (:agent) { FTW::Agent.new } 35 | 36 | after :each do 37 | agent.shutdown 38 | end 39 | 40 | subject do 41 | ws = nil 42 | Stud::try(5.times) do 43 | ws = agent.websocket!("http://#{address}/websocket") 44 | insist { ws }.is_a?(FTW::WebSocket) 45 | end 46 | ws 47 | end 48 | 49 | it "should echo messages back over websockets" do 50 | iterations = 1000 51 | iterations.times do |i| 52 | message = "Hello #{i}" 53 | subject.publish(message) 54 | insist { subject.receive } == message 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/webserver_spec.rb: -------------------------------------------------------------------------------- 1 | require 'ftw/webserver' 2 | 3 | describe "FTW Webserver" do 4 | describe '#run' do 5 | let(:webserver) { FTW::WebServer.new(host, port, &connection_handler) } 6 | let(:host) { 'localhost' } 7 | let(:port) { 9999 } 8 | 9 | let(:connection_handler) { Proc.new {|request, response| } } 10 | 11 | it 'should return when the webserver has been stopped' do 12 | webserver_thread = Thread.new { webserver.run } 13 | sleep 0.2 # wait for server to start 14 | webserver.stop 15 | 16 | webserver_thread.join(10) || begin 17 | webserver_thread.kill 18 | fail("Webserver#run failed to return after 10s") 19 | end 20 | end 21 | 22 | context 'when receiving a message claiming to be an unsupported HTTP version' do 23 | it "doesn't crash" do 24 | require 'socket' 25 | 26 | webserver_thread = Thread.new { webserver.run } 27 | sleep 0.2 # wait for plugin to bind to port 28 | 29 | socket = TCPSocket.new('localhost', port) 30 | socket.write("GET / HTTP/0.9\r\n") # party like it's 1991 31 | socket.write("\r\n") 32 | socket.flush 33 | socket.close_write 34 | # nothing is written in reply, because we don't know how to support the given protocol 35 | expect(socket.read).to be_empty 36 | 37 | # server should still be alive, so send something valid to check 38 | socket2 = TCPSocket.new('localhost', port) 39 | socket2.write("GET / HTTP/1.1\r\n") 40 | socket2.write("\r\n") 41 | socket2.flush 42 | socket2.close_write 43 | expect(socket2.read).to start_with 'HTTP/1.1' 44 | 45 | webserver.stop 46 | webserver_thread.join(10) || begin 47 | webserver_thread.kill 48 | fail("Webserver#run failed to return after 10s") 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "addressable/uri" 3 | require "thread" 4 | 5 | $: << File.join(File.dirname(__FILE__), "lib") 6 | require "net-ftw" # gem net-ftw 7 | require "net/ftw/http/client2" 8 | 9 | client = Net::FTW::HTTP::Client2.new 10 | #uri = Addressable::URI.parse("http://httpbin.org/ip") 11 | uri = Addressable::URI.parse("http://google.com/") 12 | #uri = Addressable::URI.parse("http://twitter.com/") 13 | 14 | # 'client.get' is not the end of this api. still in progress. 15 | fiber = client.get(uri) 16 | p fiber.resume 17 | p fiber.resume 18 | p fiber.resume 19 | p fiber.resume 20 | 21 | -------------------------------------------------------------------------------- /test/all.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "minitest/spec" 3 | require "minitest/autorun" 4 | 5 | # Get coverage report 6 | require "simplecov" 7 | SimpleCov.start 8 | 9 | # Add '../lib' to the require path. 10 | $: << File.join(File.dirname(__FILE__), "..", "lib") 11 | 12 | def use(path) 13 | puts "Loading tests from #{path}" 14 | require File.expand_path(path) 15 | end 16 | 17 | dirname = File.dirname(__FILE__) 18 | use File.join(dirname, "docs.rb") 19 | 20 | # Load tests from ./*/**/*.rb (usually ./libraryname/....) 21 | glob = File.join(dirname, "*", "**", "*.rb") 22 | Dir.glob(glob).each do |path| 23 | use path 24 | end 25 | -------------------------------------------------------------------------------- /test/docs.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "yard" 3 | require File.join(File.expand_path(File.dirname(__FILE__)), "testing") 4 | 5 | describe "documentation tests" do 6 | before do 7 | # Use YARD to parse all ruby files found in '../lib' 8 | libdir = File.join(File.dirname(__FILE__), "..", "lib") 9 | YARD::Registry.load(Dir.glob(File.join(libdir, "**", "*.rb"))) 10 | @registry = YARD::Registry.all 11 | end 12 | 13 | test "All classes, methods, modules, and constants must be documented" do 14 | # YARD's parser works best in ruby 1.9.x, so skip 1.8.x 15 | skip if RUBY_VERSION < "1.9.2" 16 | # Note, the 'find the undocumented things' code here is 17 | # copied mostly from: YARD 0.7.5's lib/yard/cli/stats.rb 18 | # 19 | # Find all undocumented classes, modules, and constants 20 | undocumented = @registry.select do |o| 21 | [:class, :module, :constant].include?(o.type) && o.docstring.blank? 22 | end 23 | 24 | # Find all undocumented methods 25 | methods = @registry.select { |m| m.type == :method } 26 | methods.reject! { |m| m.is_alias? || !m.is_explicit? } 27 | undocumented += methods.select do |m| 28 | m.docstring.blank? && !m.overridden_method 29 | end 30 | 31 | if (undocumented.length > 0) 32 | message = ["The following are not documented"] 33 | undocumented.each do |o| 34 | message << "* #{o.type.to_s} #{o.to_s} <#{o.file}:#{o.line}>" 35 | end 36 | 37 | flunk(message.join("\n")) 38 | else 39 | pass 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/ftw/crlf.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.expand_path(__FILE__).sub(/\/ftw\/.*/, "/testing")) 2 | require "ftw/crlf" 3 | 4 | describe FTW::CRLF do 5 | test "CRLF is as expected" do 6 | class Foo 7 | include FTW::CRLF 8 | end 9 | 10 | assert_equal("\r\n", Foo::CRLF) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/ftw/http/dns.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.expand_path(__FILE__).sub(/\/ftw\/.*/, "/testing")) 2 | require "ftw/dns" 3 | 4 | describe FTW::DNS do 5 | # TODO(sissel): mock Socket.gethostbyname? 6 | end 7 | -------------------------------------------------------------------------------- /test/ftw/http/headers.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.expand_path(__FILE__).sub(/\/ftw\/.*/, "/testing")) 2 | require "ftw/http/headers" 3 | 4 | describe FTW::HTTP::Headers do 5 | before do 6 | @headers = FTW::HTTP::Headers.new 7 | end 8 | 9 | test "add adds" do 10 | @headers.add("foo", "bar") 11 | @headers.add("baz", "fizz") 12 | assert_equal("fizz", @headers.get("baz")) 13 | assert_equal("bar", @headers.get("foo")) 14 | end 15 | 16 | test "add dup field name makes an array" do 17 | @headers.add("foo", "bar") 18 | @headers.add("foo", "fizz") 19 | assert_equal(["bar", "fizz"], @headers.get("foo")) 20 | end 21 | 22 | test "set replaces" do 23 | @headers.add("foo", "bar") 24 | @headers.set("foo", "hello") 25 | assert_equal("hello", @headers.get("foo")) 26 | end 27 | 28 | test "remove field" do 29 | @headers.add("foo", "one") 30 | @headers.add("bar", "two") 31 | assert_equal("one", @headers.get("foo")) 32 | assert_equal("two", @headers.get("bar")) 33 | 34 | @headers.remove("bar") 35 | assert_equal("one", @headers.get("foo")) 36 | # bar was removed, must not be present 37 | assert(!@headers.include?("bar")) 38 | end 39 | 40 | test "remove field value" do 41 | @headers.add("foo", "one") 42 | @headers.add("foo", "two") 43 | assert_equal(["one", "two"], @headers.get("foo")) 44 | 45 | @headers.remove("foo", "three") # nothing to remove 46 | assert_equal(["one", "two"], @headers.get("foo")) 47 | @headers.remove("foo", "two") 48 | assert_equal("one", @headers.get("foo")) 49 | end 50 | 51 | test "duplicate headers return multiple key value pairs" do 52 | @headers.add("foo", "bar") 53 | @headers.add("foo", "fizz") 54 | @headers.each do |key, value| 55 | assert_equal("foo", key) 56 | assert( value == "bar" || value == "fizz") 57 | end 58 | end 59 | end # describe FTW::HTTP::Headers 60 | -------------------------------------------------------------------------------- /test/ftw/protocol.rb: -------------------------------------------------------------------------------- 1 | #require File.join(File.expand_path(__FILE__).sub(/\/ftw\/.*/, "/testing")) 2 | require 'ftw/protocol' 3 | require 'stringio' 4 | 5 | describe FTW::Protocol do 6 | 7 | class OnlySysread < Struct.new(:io) 8 | def sysread(*args) 9 | io.sysread(*args) 10 | end 11 | end 12 | 13 | class OnlyRead < Struct.new(:io) 14 | def read(*args) 15 | io.read(*args) 16 | end 17 | end 18 | 19 | test "reading body via #read" do 20 | protocol = Object.new 21 | protocol.extend FTW::Protocol 22 | 23 | output = StringIO.new 24 | input = OnlyRead.new( StringIO.new('Some example input') ) 25 | 26 | protocol.write_http_body(input, output, false) 27 | 28 | output.rewind 29 | assert_equal( output.string, 'Some example input') 30 | end 31 | 32 | test "reading body via #sysread chunked" do 33 | protocol = Object.new 34 | protocol.extend FTW::Protocol 35 | 36 | output = StringIO.new 37 | input = OnlySysread.new( StringIO.new('Some example input') ) 38 | 39 | protocol.write_http_body(input, output, true) 40 | 41 | output.rewind 42 | assert_equal( output.string, "12\r\nSome example input\r\n0\r\n\r\n") 43 | end 44 | 45 | test "reading body via #read chunked" do 46 | protocol = Object.new 47 | protocol.extend FTW::Protocol 48 | 49 | output = StringIO.new 50 | input = OnlyRead.new( StringIO.new('Some example input') ) 51 | 52 | protocol.write_http_body(input, output, true) 53 | 54 | output.rewind 55 | assert_equal( output.string, "12\r\nSome example input\r\n0\r\n\r\n") 56 | end 57 | 58 | class OneByteWriter < Struct.new(:io) 59 | 60 | def write( str ) 61 | io.write(str[0..1]) 62 | end 63 | 64 | end 65 | 66 | test "writing partially" do 67 | protocol = Object.new 68 | protocol.extend FTW::Protocol 69 | 70 | output = OneByteWriter.new( StringIO.new ) 71 | input = OnlyRead.new( StringIO.new('Some example input') ) 72 | 73 | protocol.write_http_body(input, output, true) 74 | 75 | output.io.rewind 76 | assert_equal( output.io.string, "12\r\nSome example input\r\n0\r\n\r\n") 77 | end 78 | 79 | test "writing non ascii characters" do 80 | protocol = Object.new 81 | protocol.extend FTW::Protocol 82 | 83 | output = StringIO.new 84 | input = "è".force_encoding(Encoding::UTF_8) 85 | 86 | protocol.write_http_body(input, output, true) 87 | 88 | output.rewind 89 | assert_equal( output.string, "2\r\nè\r\n0\r\n\r\n") 90 | end 91 | 92 | end 93 | -------------------------------------------------------------------------------- /test/ftw/singleton.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.expand_path(__FILE__).sub(/\/ftw\/.*/, "/testing")) 2 | require "ftw/singleton" 3 | 4 | describe FTW::Singleton do 5 | test "extending with FTW::Singleton gives a singleton method" do 6 | class Foo 7 | extend FTW::Singleton 8 | end 9 | assert_respond_to(Foo, :singleton) 10 | end 11 | 12 | test "FTW::Singleton gives a singleton instance" do 13 | class Foo 14 | extend FTW::Singleton 15 | end 16 | assert_instance_of(Foo, Foo.singleton) 17 | assert_equal(Foo.singleton.object_id, Foo.singleton.object_id) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/testing.rb: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "minitest/spec" 3 | 4 | # Add '../lib' to the require path. 5 | $: << File.join(File.dirname(__FILE__), "..", "lib") 6 | 7 | # I don't really like monkeypatching, but whatever, this is probably better 8 | # than overriding the 'describe' method. 9 | class MiniTest::Spec 10 | class << self 11 | # 'it' sounds wrong, call it 'test' 12 | alias :test :it 13 | end 14 | end 15 | --------------------------------------------------------------------------------