1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461
|
require "ftw/namespace"
require "ftw/request"
require "ftw/connection"
require "ftw/protocol"
require "ftw/pool"
require "ftw/websocket"
require "addressable/uri"
require "cabin"
require "openssl"
# This should act as a proper web agent.
#
# * Reuse connections.
# * SSL/TLS.
# * HTTP Upgrade support.
# * HTTP 1.1 (RFC2616).
# * WebSockets (RFC6455).
# * Support Cookies.
#
# All standard HTTP methods defined by RFC2616 are available as methods on
# this agent: get, head, put, etc.
#
# Example:
#
# agent = FTW::Agent.new
# request = agent.get("http://www.google.com/")
# response = agent.execute(request)
# puts response.body.read
#
# For any standard http method (like 'get') you can invoke it with '!' on the end
# and it will execute and return a FTW::Response object:
#
# agent = FTW::Agent.new
# response = agent.get!("http://www.google.com/")
# puts response.body.head
#
# TODO(sissel): TBD: implement cookies... delicious chocolate chip cookies.
class FTW::Agent
include FTW::Protocol
require "ftw/agent/configuration"
include FTW::Agent::Configuration
# Thrown when too many redirects are encountered
# See also {FTW::Agent::Configuration::REDIRECTION_LIMIT}
class TooManyRedirects < StandardError
attr_accessor :response
def initialize(reason, response)
super(reason)
@response = response
end
end
# List of standard HTTP methods described in RFC2616
STANDARD_METHODS = %w(options get head post put delete trace connect)
# Everything is private by default.
# At the bottom of this class, public methods will be declared.
private
def initialize
@pool = FTW::Pool.new
@logger = Cabin::Channel.get
configuration[REDIRECTION_LIMIT] = 20
end # def initialize
# Verify a certificate.
#
# host => the host (string)
# port => the port (number)
# verified => true/false, was this cert verified by our certificate store?
# context => an OpenSSL::SSL::StoreContext
def certificate_verify(host, port, verified, context)
# Now verify the entire chain.
begin
@logger.debug("Verify peer via OpenSSL::X509::Store",
:verified => verified, :chain => context.chain.collect { |c| c.subject },
:context => context, :depth => context.error_depth,
:error => context.error, :string => context.error_string)
# Untrusted certificate; prompt to accept if possible.
if !verified and STDOUT.tty?
# TODO(sissel): Factor this out into a verify callback where this
# happens to be the default.
puts "Untrusted certificate found; here's what I know:"
puts " Why it's untrusted: (#{context.error}) #{context.error_string}"
if context.error_string =~ /local issuer/
puts " Missing cert for issuer: #{context.current_cert.issuer}"
puts " Issuer hash: #{context.current_cert.issuer.hash.to_s(16)}"
else
puts " What you think it's for: #{host} (port #{port})"
cn = context.chain[0].subject.to_s.split("/").grep(/^CN=/).first.split("=",2).last rescue "<unknown, no CN?>"
puts " What it's actually for: #{cn}"
end
puts " Full chain:"
context.chain.each_with_index do |cert, i|
puts " Subject(#{i}): [#{cert.subject.hash.to_s(16)}] #{cert.subject}"
end
print "Trust? [(N)o/(Y)es/(P)ersistent] "
system("stty raw")
answer = $stdin.getc.downcase
system("stty sane")
puts
if ["y", "p"].include?(answer)
# TODO(sissel): Factor this out into Agent::Trust or somesuch
context.chain.each do |cert|
# For each certificate, add it to the in-process certificate store.
begin
certificate_store.add_cert(cert)
rescue OpenSSL::X509::StoreError => e
# If the cert is already trusted, move along.
if e.to_s != "cert already in hash table"
raise # this is a real error, reraise.
end
end
# TODO(sissel): Factor this out into Agent::Trust or somesuch
# For each certificate, if persistence is requested, write the cert to
# the configured ssl trust store (usually ~/.ftw/ssl-trust.db/)
if answer == "p" # persist this trusted cert
require "fileutils"
if !File.directory?(configuration[SSL_TRUST_STORE])
FileUtils.mkdir_p(configuration[SSL_TRUST_STORE])
end
# openssl verify recommends the 'ca path' have files named by the
# hashed subject name. Turns out openssl really expects the
# hexadecimal version of this.
name = File.join(configuration[SSL_TRUST_STORE], cert.subject.hash.to_s(16))
# Find a filename that doesn't exist.
num = 0
num += 1 while File.exists?("#{name}.#{num}")
# Write it out
path = "#{name}.#{num}"
@logger.info("Persisting certificate", :subject => cert.subject, :path => path)
File.write(path, cert.to_pem)
end # if answer == "p"
end # context.chain.each
return true
end # if answer was "y" or "p"
end # if !verified and stdout is a tty
return verified
rescue => e
# We have to rescue all and emit because openssl verify_callback ignores
# exceptions silently
@logger.error(e)
return verified
end
end # def certificate_verify
# Define all the standard HTTP methods (Per RFC2616)
# As an example, for "get" method, this will define these methods:
#
# * FTW::Agent#get(uri, options={})
# * FTW::Agent#get!(uri, options={})
#
# The first one returns a FTW::Request you must pass to Agent#execute(...)
# The second does the execute for you and returns a FTW::Response.
#
# For a full list of these available methods, see STANDARD_METHODS.
#
STANDARD_METHODS.each do |name|
m = name.upcase
# 'def get' (put, post, etc)
public
define_method(name.to_sym) do |uri, options={}|
return request(m, uri, options)
end
# 'def get!' (put!, post!, etc)
public
define_method("#{name}!".to_sym) do |uri, options={}|
return execute(request(m, uri, options))
end
end # STANDARD_METHODS.each
# Send the request as an HTTP upgrade.
#
# Returns the response and the FTW::Connection for this connection.
# If the upgrade was denied, the connection returned will be nil.
def upgrade!(uri, protocol, options={})
req = request("GET", uri, options)
req.headers["Connection"] = "Upgrade"
req.headers["Upgrade"] = protocol
response = execute(req)
if response.status == 101
# Success, return the response object and the connection to hand off.
return response, response.body
else
return response, nil
end
end # def upgrade!
# Make a new websocket connection.
#
# This will send the http request. If the websocket handshake
# is successful, a FTW::WebSocket instance will be returned.
# Otherwise, a FTW::Response will be returned.
#
# See {#request} for what the 'uri' and 'options' parameters should be.
def websocket!(uri, options={})
# TODO(sissel): Use FTW::Agent#upgrade! ?
req = request("GET", uri, options)
ws = FTW::WebSocket.new(req)
response = execute(req)
if ws.handshake_ok?(response)
# response.body is a FTW::Connection
ws.connection = response.body
# There seems to be a bug in http_parser.rb where websocket responses
# lead with a newline for some reason. It's like the header terminator
# CRLF still has the LF character left in the buffer. Work around it.
data = response.body.read
if data[0] == "\n"
response.body.pushback(data[1..-1])
else
response.body.pushback(data)
end
return ws
else
return response
end
end # def websocket!
# Build a request. Returns a FTW::Request object.
#
# Arguments:
#
# * method - the http method
# * uri - the URI to make the request to
# * options - a hash of options
#
# uri can be a valid url or an Addressable::URI object.
# The uri will be used to choose the host/port to connect to. It also sets
# the protocol (https, etc). Further, it will set the 'Host' header.
#
# The 'options' hash supports the following keys:
#
# * :headers => { string => string, ... }. This allows you to set header values.
def request(method, uri, options)
@logger.info("Creating new request", :method => method, :uri => uri, :options => options)
request = FTW::Request.new(uri)
request.method = method
request.headers.add("Connection", "keep-alive")
if options.include?(:headers)
options[:headers].each do |key, value|
request.headers.add(key, value)
end
end
if options.include?(:body)
request.body = options[:body]
end
return request
end # def request
# Execute a FTW::Request in this Agent.
#
# If an existing, idle connection is already open to the target server
# of this Request, it will be reused. Otherwise, a new connection
# is opened.
#
# Redirects are always followed.
#
# @param [FTW::Request]
# @return [FTW::Response] the response for this request.
def execute(request)
# TODO(sissel): Make redirection-following optional, but default.
tries = 3
begin
connection, error = connect(request.headers["Host"], request.port,
request.protocol == "https")
if !error.nil?
p :error => error
raise error
end
response = request.execute(connection)
rescue EOFError => e
tries -= 1
@logger.warn("Error while sending request, will retry.",
:tries_left => tries,
:exception => e)
retry if tries > 0
end
redirects = 0
# Follow redirects
while response.redirect? and response.headers.include?("Location")
# RFC2616 section 10.3.3 indicates HEAD redirects must not include a
# body. Otherwise, the redirect response can have a body, so let's
# throw it away.
if request.method == "HEAD"
# Head requests have no body
connection.release
elsif response.content?
# Throw away the body
response.body = connection
# read_body will consume the body and release this connection
response.read_http_body { |chunk| }
end
# TODO(sissel): If this response has any cookies, store them in the
# agent's cookie store
redirects += 1
if redirects > configuration[REDIRECTION_LIMIT]
# TODO(sissel): include original a useful debugging information like
# the trace of redirections, etc.
raise TooManyRedirects.new("Redirect more than " \
"#{configuration[REDIRECTION_LIMIT]} times, aborting.", response)
# I don't like this api from FTW::Agent. I think 'get' and other methods
# should return (object, error), and if there's an error
end
@logger.debug("Redirecting", :location => response.headers["Location"])
request.use_uri(response.headers["Location"])
connection, error = connect(request.headers["Host"], request.port, request.protocol == "https")
# TODO(sissel): Do better error handling than raising.
if !error.nil?
p :error => error
raise error
end
response = request.execute(connection)
end # while being redirected
# RFC 2616 section 9.4, HEAD requests MUST NOT have a message body.
if request.method != "HEAD"
response.body = connection
else
connection.release
end
# TODO(sissel): If this response has any cookies, store them in the
# agent's cookie store
return response
end # def execute
# shutdown this agent.
#
# This will shutdown all active connections.
def shutdown
@pool.each do |identifier, list|
list.each do |connection|
connection.disconnect("stopping agent")
end
end
end # def shutdown
def certificate_store
return @certificate_store if @certificate_store
@certificate_store = load_certificate_store
end
def load_certificate_store
return @certificate_store if @certificate_store_last == configuration[SSL_TRUST_STORE]
@certificate_store_last = configuration[SSL_TRUST_STORE]
need_ssl_ca_certs = true
@certificate_store = OpenSSL::X509::Store.new
if configuration[SSL_USE_DEFAULT_CERTS]
if File.readable?(OpenSSL::X509::DEFAULT_CERT_FILE)
@logger.debug("Adding default certificate file",
:path => OpenSSL::X509::DEFAULT_CERT_FILE)
begin
@certificate_store.add_file(OpenSSL::X509::DEFAULT_CERT_FILE)
need_ssl_ca_certs = false
rescue OpenSSL::X509::StoreError => e
# Work around jruby#1055 "Duplicate extensions not allowed"
@logger.warn("Failure loading #{OpenSSL::X509::DEFAULT_CERT_FILE}. " \
"Will try another cacert source.")
end
end
if need_ssl_ca_certs
# Use some better defaults from http://curl.haxx.se/docs/caextract.html
# Can we trust curl's CA list? Global ssl trust is a tragic joke, anyway :\
@logger.info("Using upstream ssl ca certs from curl. Possibly untrustworthy.")
default_ca = File.join(File.dirname(__FILE__), "cacert.pem")
# JRUBY-6870 - strip 'jar:' prefix if it is present.
if default_ca =~ /^jar:file.*!/
default_ca.gsub!(/^jar:/, "")
end
@certificate_store.add_file(default_ca)
end
end # SSL_USE_DEFAULT_CERTS
# Handle the local user/app trust store as well.
if File.directory?(configuration[SSL_TRUST_STORE])
# This is a directory, so use add_path
@logger.debug("Adding SSL_TRUST_STORE",
:path => configuration[SSL_TRUST_STORE])
@certificate_store.add_path(configuration[SSL_TRUST_STORE])
end
return @certificate_store
end # def load_certificate_store
# Returns a FTW::Connection connected to this host:port.
def connect(host, port, secure=false)
address = "#{host}:#{port}"
@logger.debug("Fetching from pool", :address => address)
error = nil
connection = @pool.fetch(address) do
@logger.info("New connection to #{address}")
connection = FTW::Connection.new(address)
error = connection.connect
if !error.nil?
# Return nil to the pool, so like, we failed..
nil
else
# Otherwise return our new connection
connection
end
end
if !error.nil?
@logger.error("Connection failed", :destination => address, :error => error)
return nil, error
end
@logger.debug("Pool fetched a connection", :connection => connection)
connection.mark
if secure
# Curry a certificate_verify callback for this connection.
verify_callback = proc do |verified, context|
begin
certificate_verify(host, port, verified, context)
rescue => e
@logger.error("Error in certificate_verify call", :exception => e)
end
end
ciphers = SSL_CIPHER_MAP[configuration[SSL_CIPHERS]] || configuration[SSL_CIPHERS]
connection.secure(:certificate_store => certificate_store, :verify_callback => verify_callback,
:ciphers => ciphers, :ssl_version => configuration[SSL_VERSION])
end # if secure
return connection, nil
end # def connect
# TODO(sissel): Implement methods for managing the certificate store
# TODO(sissel): Implement methods for managing the cookie store
# TODO(sissel): Implement methods for managing the cache
# TODO(sissel): Implement configuration stuff? Is FTW::Agent::Configuration the best way?
public(:initialize, :execute, :websocket!, :upgrade!, :shutdown, :request)
end # class FTW::Agent
|