require "ftw/namespace" require "openssl" require "base64" # stdlib require "digest/sha1" # stdlib require "cabin" require "ftw/websocket/parser" require "ftw/websocket/writer" require "ftw/crlf" # WebSockets, RFC6455. # # TODO(sissel): Find a comfortable way to make this websocket stuff # both use HTTP::Connection for the HTTP handshake and also be usable # from HTTP::Client # TODO(sissel): Also consider SPDY and the kittens. class FTW::WebSocket include FTW::CRLF include Cabin::Inspectable # The frame identifier for a 'text' frame TEXTFRAME = 0x0001 # Search RFC6455 for this string and you will find its definitions. # It is used in servers accepting websocket upgrades. WEBSOCKET_ACCEPT_UUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" # Protocol phases # 1. tcp connect # 2. http handshake (RFC6455 section 4) # 3. websocket protocol private # Creates a new websocket and fills in the given http request with any # necessary settings. def initialize(request) @key_nonce = generate_key_nonce @request = request prepare(@request) @parser = FTW::WebSocket::Parser.new @messages = [] end # def initialize # Set the connection for this websocket. This is usually invoked by FTW::Agent # after the websocket upgrade and handshake have been successful. # # You probably don't call this yourself. def connection=(connection) @connection = connection end # def connection= # Prepare the request. This sets any required headers and attributes as # specified by RFC6455 def prepare(request) # RFC6455 section 4.1: # "2. The method of the request MUST be GET, and the HTTP version MUST # be at least 1.1." request.method = "GET" request.version = 1.1 # RFC6455 section 4.2.1 bullet 3 request.headers.set("Upgrade", "websocket") # RFC6455 section 4.2.1 bullet 4 request.headers.set("Connection", "Upgrade") # RFC6455 section 4.2.1 bullet 5 request.headers.set("Sec-WebSocket-Key", @key_nonce) # RFC6455 section 4.2.1 bullet 6 request.headers.set("Sec-WebSocket-Version", 13) # RFC6455 section 4.2.1 bullet 7 (optional) # The Origin header is optional for non-browser clients. #request.headers.set("Origin", ...) # RFC6455 section 4.2.1 bullet 8 (optional) #request.headers.set("Sec-Websocket-Protocol", ...) # RFC6455 section 4.2.1 bullet 9 (optional) #request.headers.set("Sec-Websocket-Extensions", ...) # RFC6455 section 4.2.1 bullet 10 (optional) # TODO(sissel): Any other headers like cookies, auth headers, are allowed. end # def prepare # Generate a websocket key nonce. def generate_key_nonce # RFC6455 section 4.1 says: # --- # 7. The request MUST include a header field with the name # |Sec-WebSocket-Key|. The value of this header field MUST be a # nonce consisting of a randomly selected 16-byte value that has # been base64-encoded (see Section 4 of [RFC4648]). The nonce # MUST be selected randomly for each connection. # --- # # It's not totally clear to me how cryptographically strong this random # nonce needs to be, and if it does not need to be strong and it would # benefit users who do not have ruby with openssl enabled, maybe just use # rand() to generate this string. # # Thus, generate a random 16 byte string and encode i with base64. # Array#pack("m") packs with base64 encoding. return Base64.strict_encode64(OpenSSL::Random.random_bytes(16)) end # def generate_key_nonce # Is this Response acceptable for our WebSocket Upgrade request? def handshake_ok?(response) # See RFC6455 section 4.2.2 return false unless response.status == 101 # "Switching Protocols" return false unless response.headers.get("upgrade").downcase == "websocket" return false unless response.headers.get("connection").downcase == "upgrade" # Now verify Sec-WebSocket-Accept. It should be the SHA-1 of the # Sec-WebSocket-Key (in base64) + WEBSOCKET_ACCEPT_UUID expected = @key_nonce + WEBSOCKET_ACCEPT_UUID expected_hash = Digest::SHA1.base64digest(expected) return false unless response.headers.get("Sec-WebSocket-Accept") == expected_hash return true end # def handshake_ok? # Iterate over each WebSocket message. This method will run forever unless you # break from it. # # The text payload of each message will be yielded to the block. def each(&block) while true block.call(receive) end end # def each # Receive a single payload def receive @messages += network_consume if @messages.empty? @messages.shift end # def receive # Consume payloads from the network. def network_consume payloads = [] @parser.feed(@connection.read(16384)) do |payload| payloads << payload end return payloads end # def network_consume # Publish a message text. # # This will send a websocket text frame over the connection. def publish(message) writer = FTW::WebSocket::Writer.singleton writer.write_text(@connection, message) end # def publish public(:initialize, :connection=, :handshake_ok?, :each, :publish, :receive) end # class FTW::WebSocket