class FTW::Connection

A network connection. This is TCP.

You can use IO::select on this objects of this type. (at least, in MRI you can)

You can activate SSL/TLS on this connection by invoking FTW::Connection#secure

This class also implements buffering itself because some IO-like classes (OpenSSL::SSL::SSLSocket) do not support IO#ungetbyte

Public Class Methods

new(destinations) click to toggle source

A new network connection. The 'destination' argument can be an array of strings or a single string. String format is expected to be “host:port”

Example:

conn = FTW::Connection.new(["1.2.3.4:80", "1.2.3.5:80"])

If you specify multiple destinations, they are used in a round-robin decision made during reconnection.

# File lib/ftw/connection.rb, line 59
def initialize(destinations)
  if destinations.is_a?(String)
    @destinations = [destinations]
  else
    @destinations = destinations
  end

  @mode = :client
  setup
end

Private Class Methods

from_io(io, mode=:server) click to toggle source

Create a new connection from an existing IO instance (like a socket)

Valid modes are :server and :client.

  • specify :server if this connection is from a server (via Socket#accept)

  • specify :client if this connection is from a client (via Socket#connect)

# File lib/ftw/connection.rb, line 101
def self.from_io(io, mode=:server)
  valid_modes = [:server, :client]
  if !valid_modes.include?(mode)
    raise InvalidArgument.new("Invalid connection mode '#{mode}'. Valid modes: #{valid_modes.inspect}")
  end

  connection = self.new(nil) # New connection with no destinations
  connection.instance_eval do
    @socket = io
    @connected = true
    port, address = Socket.unpack_sockaddr_in(io.getpeername)
    @remote_address = "#{address}:#{port}"
    @mode = mode
  end
  return connection
end

Public Instance Methods

client?() click to toggle source

Is this a client connection?

# File lib/ftw/connection.rb, line 453
def client?
  return @mode == :client
end
connect(timeout=nil) click to toggle source

Connect now.

Timeout value is optional. If no timeout is given, this method blocks until a connection is successful or an error occurs.

You should check the return value of this method to determine if a connection was successful.

Possible return values are on error include:

@return [nil] if the connection was successful @return [StandardError or subclass] if the connection failed

# File lib/ftw/connection.rb, line 133
def connect(timeout=nil)
  # TODO(sissel): Raise if we're already connected?
  disconnect("reconnecting") if connected?
  host, port = @destinations.first.split(":")
  @destinations = @destinations.rotate # round-robin

  # Do dns resolution on the host. If there are multiple
  # addresses resolved, return one at random.
  addresses = FTW::DNS.singleton.resolve(host)

  addresses.each do |address|
    # Try each address until one works.
    @remote_address = address
    # Addresses with colon ':' in them are assumed to be IPv6
    family = @remote_address.include?(":") ? Socket::AF_INET6 : Socket::AF_INET
    @logger.debug("Connecting", :address => @remote_address,
                  :host => host, :port => port, :family => family)
    @socket = Socket.new(family, Socket::SOCK_STREAM, 0)
    @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)

    # This api is terrible. pack_sockaddr_in? This isn't C, man...
    @logger.debug("packing", :data => [port.to_i, @remote_address])
    sockaddr = Socket.pack_sockaddr_in(port.to_i, @remote_address)
    # TODO(sissel): Support local address binding

    # Connect with timeout
    begin
      @socket.connect_nonblock(sockaddr)
    rescue IO::WaitWritable, Errno::EINPROGRESS
      # Ruby actually raises Errno::EINPROGRESS, but for some reason
      # the documentation says to use this IO::WaitWritable thing...
      # I don't get it, but whatever :(

      writable = writable?(timeout)

      # http://jira.codehaus.org/browse/JRUBY-6528; IO.select doesn't behave
      # correctly on JRuby < 1.7, so work around it.
      if writable || (RUBY_PLATFORM == "java" and JRUBY_VERSION < "1.7.0")
        begin
          @socket.connect_nonblock(sockaddr) # check connection failure
        rescue Errno::EISCONN 
          # Ignore, we're already connected.
        rescue Errno::ECONNREFUSED => e
          # Fire 'disconnected' event with reason :refused
          @socket.close
          return ConnectRefused.new("#{host}[#{@remote_address}]:#{port}")
        rescue Errno::ETIMEDOUT
          # This occurs when the system's TCP timeout hits, we have no
          # control over this, as far as I can tell. *maybe* setsockopt(2)
          # has a flag for this, but I haven't checked..
          # TODO(sissel): We should instead do 'retry' unless we've exceeded
          # the timeout.
          @socket.close
          return ConnectTimeout.new("#{host}[#{@remote_address}]:#{port}")
        rescue Errno::EINPROGRESS
          # If we get here, it's likely JRuby version < 1.7.0. EINPROGRESS at
          # this point in the code means that we have timed out.
          @socket.close
          return ConnectTimeout.new("#{host}[#{@remote_address}]:#{port}")
        end
      else
        # Connection timeout;
        return ConnectTimeout.new("#{host}[#{@remote_address}]:#{port}")
      end

      # If no error at this point, we're now connected.
      @connected = true
      break
    end # addresses.each
  end 
  return nil
end
connected?() click to toggle source

Is this Connection connected?

# File lib/ftw/connection.rb, line 207
def connected?
  return @connected
end
disconnect(reason) click to toggle source

End this connection, specifying why.

# File lib/ftw/connection.rb, line 270
def disconnect(reason)
  io = @socket
  if @socket.is_a?(OpenSSL::SSL::SSLSocket)
    @socket.sysclose()
    io = @socket.io
  end
  begin 
    io.close_read
  rescue IOError => e
    # Ignore, perhaps we shouldn't ignore.
  end

  begin 
    io.close_write
  rescue IOError => e
    # Ignore, perhaps we shouldn't ignore.
  end
end
peer() click to toggle source

The host:port

# File lib/ftw/connection.rb, line 308
def peer
  return @remote_address
end
pushback(data) click to toggle source

Push back some data onto the connection's read buffer.

# File lib/ftw/connection.rb, line 265
def pushback(data)
  @pushback_buffer << data
end
read(length=16384, timeout=nil) click to toggle source

Read data from this connection This method blocks until the read succeeds unless a timeout is given.

This method is not guaranteed to read exactly 'length' bytes. See IO#sysread

# File lib/ftw/connection.rb, line 231
def read(length=16384, timeout=nil)
  data = ""
  data.force_encoding("BINARY") if data.respond_to?(:force_encoding)
  have_pushback = !@pushback_buffer.empty?
  if have_pushback
    data << @pushback_buffer
    @pushback_buffer = ""
    # We have data 'now' so don't wait.
    timeout = 0
  end

  if readable?(timeout)
    begin
      # Read at most 'length' data, so read less from the socket
      # We'll read less than 'length' if the pushback buffer has
      # data in it already.
      @socket.sysread(length - data.length, @read_buffer)
      data << @read_buffer
      return data
    rescue EOFError => e
      @socket.close
      @connected = false
      raise e
    end
  else
    if have_pushback
      return data
    else
      raise ReadTimeout.new
    end
  end
end
readable?(timeout) click to toggle source

Is this connection readable? Returns true if it is readable within the timeout period. False otherwise.

The time out is in seconds. Fractional seconds are OK.

# File lib/ftw/connection.rb, line 302
def readable?(timeout)
  readable, writable, errors = IO.select([@socket], nil, nil, timeout)
  return !readable.nil?
end
secure(options=nil) click to toggle source

Secure this connection with TLS.

Options:

  • :certificate_store, an OpenSSL::X509::Store

  • :timeout, a timeout threshold in seconds.

  • :ciphers, an OpenSSL ciphers string, see `openssl ciphers` manual for details.

  • :ssl_version, any of: SSLv2, SSLv3, TLSv1, TLSv1.1, TLSv1.2

  • :certificate, an OpenSSL::X509::Certificate

  • :key, an OpenSSL::PKey (like OpenSSL::PKey::RSA)

Both `certificate` and `key` are highly recommended if the connection belongs to a server (not a client connection).

Notes:

  • Version may depend on your platform (openssl compilation settings, JVM version, export restrictions, etc)

    • Available ciphers will depend on your version of Ruby (or JRuby and JVM),

    OpenSSL, etc.

# File lib/ftw/connection.rb, line 336
def secure(options=nil)
  # Skip this if we're already secure.
  return if secured?

  defaults = {
    :timeout => nil,
    :ciphers => FTW::Agent::Configuration::SSL_CIPHER_MAP["MOZILLA_MODERN"],
    :ssl_version => "TLSv1.1"
  }
  settings = defaults.merge(options) unless options.nil?

  @logger.info("Securing this connection", :peer => peer, :options => settings)
  # Wrap this connection with TLS/SSL
  sslcontext = OpenSSL::SSL::SSLContext.new
  # If you use VERIFY_NONE, you are removing the trust feature of TLS. Don't do that.
  # Encryption without trust means you don't know who you are talking to.
  sslcontext.verify_mode = OpenSSL::SSL::VERIFY_PEER

  # ruby-core is refusing to patch ruby's default openssl settings to be more
  # secure, so let's fix that here. The next few lines setting options and
  # ciphers come from jmhodges' proposed patch
  ssloptions = OpenSSL::SSL::OP_ALL
  if defined?(OpenSSL::SSL::OP_DONT_INSERT_EMPTY_FRAGMENTS)
    ssloptions &= ~OpenSSL::SSL::OP_DONT_INSERT_EMPTY_FRAGMENTS
  end
  if defined?(OpenSSL::SSL::OP_NO_COMPRESSION)
    ssloptions |= OpenSSL::SSL::OP_NO_COMPRESSION
  end
  # https://github.com/jruby/jruby/issues/1874
  version = OpenSSL::SSL::SSLContext::METHODS.find { |x| x.to_s.gsub("_",".") == settings[:ssl_version] }
  raise InvalidConfiguration, "Invalid SSL/TLS version '#{settings[:ssl_version]}'" if version.nil?
  sslcontext.ssl_version = version

  # We have to set ciphers *after* setting ssl_version because setting
  # ssl_version will reset the cipher set.
  sslcontext.options = ssloptions
  sslcontext.ciphers = settings[:ciphers]

  sslcontext.verify_callback = proc do |*args| 
    @logger.debug("Verify peer via FTW::Connection#secure", :callback => settings[:verify_callback])
    if settings[:verify_callback].respond_to?(:call)
      settings[:verify_callback].call(*args)
    end
  end
  sslcontext.cert_store = settings[:certificate_store]

  if settings.include?(:certificate) && settings.include?(:key)
    sslcontext.cert = settings[:certificate]
    sslcontext.key = settings[:key]
  end

  @socket = OpenSSL::SSL::SSLSocket.new(@socket, sslcontext)

  # TODO(sissel): Set up local certificat/key stuff. This is required for
  # server-side ssl operation, I think.

  if client?
    do_secure(:connect_nonblock, settings[:timeout])
  else
    do_secure(:accept_nonblock, settings[:timeout])
  end
end
secured?() click to toggle source

Has this connection been secured?

# File lib/ftw/connection.rb, line 448
def secured?
  return @secure
end
server?() click to toggle source

Is this a server connection?

# File lib/ftw/connection.rb, line 458
def server?
  return @mode == :server
end
to_io() click to toggle source

Support 'to_io' so you can use IO::select on this object.

# File lib/ftw/connection.rb, line 313
def to_io
  return @socket
end
writable?(timeout) click to toggle source

Is this connection writable? Returns true if it is writable within the timeout period. False otherwise.

The time out is in seconds. Fractional seconds are OK.

# File lib/ftw/connection.rb, line 293
def writable?(timeout)
  readable, writable, errors = IO.select(nil, [@socket], nil, timeout)
  return !writable.nil?
end
write(data, timeout=nil) click to toggle source

Write data to this connection. This method blocks until the write succeeds unless a timeout is given.

This method is not guaranteed to have written the full data given.

Returns the number of bytes written (See also IO#syswrite)

# File lib/ftw/connection.rb, line 217
def write(data, timeout=nil)
  #connect if !connected?
  if writable?(timeout)
    return @socket.syswrite(data)
  else
    raise FTW::Connection::WriteTimeout.new(self.inspect)
  end
end

Private Instance Methods

do_secure(handshake_method, timeout=nil) click to toggle source

Secure this connection.

The handshake method for OpenSSL::SSL::SSLSocket is different depending on the mode (client or server).

@param [Symbol] handshake_method The method to call on the socket to

complete the ssl handshake. See OpenSSL::SSL::SSLSocket#connect_nonblock
of #accept_nonblock for more details
# File lib/ftw/connection.rb, line 407
def do_secure(handshake_method, timeout=nil)
  # SSLSocket#connect_nonblock will do the SSL/TLS handshake.
  # TODO(sissel): refactor this into a method that both secure and connect
  # methods can call.
  start = Time.now
  begin
    @socket.send(handshake_method)
  rescue IO::WaitReadable, IO::WaitWritable
    # The ruby OpenSSL docs for 1.9.3 have example code saying I should use
    # IO::WaitReadable, but in the real world it raises an SSLError with
    # a specific string message instead of Errno::EAGAIN or IO::WaitReadable
    # explicitly...
    #
    # This SSLSocket#connect_nonblock raising WaitReadable (Technically,
    # OpenSSL::SSL::SSLError) is in contrast to what Socket#connect_nonblock
    # raises, WaitWritable (ok, Errno::EINPROGRESS, technically)
    # Ruby's SSL exception for 'this call would block' is pretty shitty.
    #
    # So we rescue both IO::Wait{Readable,Writable} and keep trying
    # until timeout occurs.
    #
    
    if !timeout.nil?
      time_left = timeout - (Time.now - start)
      raise SecureHandshakeTimeout.new if time_left < 0
      r, w, e = IO.select([@socket], [@socket], nil, time_left)
    else
      r, w, e = IO.select([@socket], [@socket], nil, timeout)
    end

    # keep going if the socket is ready
    retry if r.size > 0 || w.size > 0
  rescue => e
    @logger.warn(e)
    raise e
  end

  @secure = true
end
setup() click to toggle source

Set up this connection.

# File lib/ftw/connection.rb, line 71
def setup
  @logger = Cabin::Channel.get
  @connect_timeout = 2

  # Use a fixed-size string that we set to BINARY encoding.
  # Not all byte sequences are UTF-8 friendly :0
  @read_size = 16384
  @read_buffer = " " * @read_size
  @pushback_buffer = ""

  # Tell Ruby 1.9 that this string is a binary string, not utf-8 or somesuch.
  if @read_buffer.respond_to?(:force_encoding)
    @read_buffer.force_encoding("BINARY")
  end

  @inspectables = [:@destinations, :@connected, :@remote_address, :@secure]
  @connected = false
  @remote_address = nil
  @secure = false

  # TODO(sissel): Validate @destinations
  # TODO(sissel): Barf if a destination is not of the form "host:port"
end