diaspora/lib/connection_tester.rb
Benjamin Neff 78b28c3d54
Handle nodeinfo timeouts gracefully
some (especially bigger pods) are sometimes slow to respond with
statistics, so lets handle that gracefully and not mark the pods as
down.
2022-07-24 17:19:04 +02:00

273 lines
8.1 KiB
Ruby

# frozen_string_literal: true
class ConnectionTester
include Diaspora::Logging
NODEINFO_FRAGMENT = "/.well-known/nodeinfo"
class << self
# Test the reachability of a server by the given HTTP/S URL.
# In the first step, a DNS query is performed to check whether the
# given name even resolves correctly.
# The second step is to send a HTTP request and look at the returned
# status code or any returned errors.
# This function isn't intended to check for the availability of a
# specific page, instead a GET request is sent to the root directory
# of the server.
# In the third step an attempt is made to determine the software version
# used on the server, via the nodeinfo page.
#
# @api This is the entry point you're supposed to use for testing
# connections to other diaspora-compatible servers.
# @param [String] url URL
# @return [Result] result object containing information about the
# server and to what point the connection was successful
def check(url)
result = Result.new
begin
ct = ConnectionTester.new(url, result)
# test DNS resolving
ct.resolve
# test HTTP request
ct.request
# test for the diaspora* version
ct.nodeinfo
rescue Failure => e
result_from_failure(result, e)
end
result.freeze
end
private
# infer some attributes of the result object based on the failure
def result_from_failure(result, error)
result.error = error
case error
when AddressFailure, DNSFailure, NetFailure
result.reachable = false
when SSLFailure
result.reachable = true
result.ssl = false
when HTTPFailure
result.reachable = true
when NodeInfoFailure
result.software_version = ""
end
end
end
# @raise [AddressFailure] if the specified url is not http(s)
def initialize(url, result=Result.new)
@url ||= url
@result ||= result
@uri ||= URI.parse(@url)
raise AddressFailure,
"invalid protocol: '#{@uri.scheme.upcase}'" unless http_uri?(@uri)
rescue AddressFailure => e
raise e
rescue URI::InvalidURIError => e
raise AddressFailure, e.message
rescue StandardError => e
unexpected_error(e)
end
# Perform the DNS query, the IP address will be stored in the result
# @raise [DNSFailure] caused by a failure to resolve or a timeout
def resolve
@result.ip = IPSocket.getaddress(@uri.host)
rescue SocketError => e
raise DNSFailure, "'#{@uri.host}' - #{e.message}"
rescue StandardError => e
unexpected_error(e)
end
# Perform a HTTP GET request to determine the following information
# * is the host reachable
# * is port 80/443 open
# * is the SSL certificate valid (only on HTTPS)
# * does the server return a successful HTTP status code
# * is there a reasonable amount of redirects (3 by default)
# (can't do a HEAD request, since that's not a defined route in the app)
#
# @raise [NetFailure, SSLFailure, HTTPFailure] if any of the checks fail
# @return [Integer] HTTP status code
def request
with_http_connection do |http|
capture_response_time { handle_http_response(http.get("/")) }
end
rescue HTTPFailure => e
raise e
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
raise NetFailure, e.message
rescue Faraday::SSLError => e
raise SSLFailure, e.message
rescue ArgumentError, Faraday::ClientError => e
raise HTTPFailure, "#{e.class}: #{e.message}"
rescue StandardError => e
unexpected_error(e)
end
# Try to find out the version of the other servers software.
# Assuming the server speaks nodeinfo
#
# @raise [HTTPFailure] if the document can't be fetched
# @raise [NodeInfoFailure] if the document can't be parsed or is invalid
def nodeinfo
with_http_connection do |http|
ni_resp = http.get(NODEINFO_FRAGMENT)
ni_urls = find_nodeinfo_urls(ni_resp.body)
raise NodeInfoFailure, "No supported NodeInfo version found" if ni_urls.empty?
version, url = ni_urls.max
find_software_version(version, http.get(url).body)
end
rescue NodeInfoFailure => e
raise e
rescue JSON::Schema::ValidationError, JSON::Schema::SchemaError, Faraday::TimeoutError => e
raise NodeInfoFailure, "#{e.class}: #{e.message}"
rescue JSON::JSONError => e
raise NodeInfoFailure, e.message[0..255].encode(Encoding.default_external, undef: :replace)
rescue Faraday::ClientError => e
raise HTTPFailure, "#{e.class}: #{e.message}"
rescue StandardError => e
unexpected_error(e)
end
private
def with_http_connection
@http ||= Faraday.new(@url) do |c|
c.use Faraday::Response::RaiseError
c.use FaradayMiddleware::FollowRedirects, limit: 3
c.adapter(Faraday.default_adapter)
c.headers[:user_agent] = "diaspora-connection-tester"
c.options.timeout = 12
c.options.open_timeout = 6
# use the configured CA
c.ssl.ca_file = Faraday.default_connection.ssl.ca_file
end
yield(@http) if block_given?
end
def http_uri?(uri)
uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
end
# request root path, measure response time
# measured time may be skewed, if there are redirects
#
# @return [Faraday::Response]
def capture_response_time
start = Time.zone.now
resp = yield if block_given?
@result.rt = ((Time.zone.now - start) * 1000.0).to_i # milliseconds
resp
end
def handle_http_response(response)
@result.status_code = Integer(response.status)
raise HTTPFailure, "unsuccessful response code: #{response.status}" unless response.success?
raise HTTPFailure, "redirected to other hostname: #{response.env.url}" unless @uri.host == response.env.url.host
@result.reachable = true
@result.ssl = (response.env.url.scheme == "https")
end
# walk the JSON document, get the actual document locations
def find_nodeinfo_urls(body)
jrd = JSON.parse(body)
links = jrd.fetch("links")
raise NodeInfoFailure, "invalid JRD: '#/links' is not an array!" unless links.is_a?(Array)
supported_rel_map = NodeInfo::VERSIONS.index_by {|v| "http://nodeinfo.diaspora.software/ns/schema/#{v}" }
links.map {|entry|
version = supported_rel_map[entry.fetch("rel")]
[version, entry.fetch("href")] if version
}.compact.to_h
end
# walk the JSON document, find the version string
def find_software_version(version, body)
info = JSON.parse(body)
JSON::Validator.validate!(NodeInfo.schema(version), info)
sw = info.fetch("software")
@result.software_version = "#{sw.fetch('name')} #{sw.fetch('version')}"
end
def unexpected_error(error)
logger.error "unexpected error: #{error.class}: #{error.message}\n#{error.backtrace.first(15).join("\n")}"
raise Failure, error.inspect
end
class Failure < StandardError
end
class AddressFailure < Failure
end
class DNSFailure < Failure
end
class NetFailure < Failure
end
class SSLFailure < Failure
end
class HTTPFailure < Failure
end
class NodeInfoFailure < Failure
end
Result = Struct.new(
:ip, :reachable, :ssl, :status_code, :rt, :software_version, :error
) do
# @!attribute ip
# @return [String] resolved IP address from DNS query
# @!attribute reachable
# @return [Boolean] whether the host was reachable over the network
# @!attribute ssl
# @return [Boolean] whether the host has working ssl
# @!attribute status_code
# @return [Integer] HTTP status code that was returned for the HEAD request
# @!attribute rt
# @return [Integer] response time for the HTTP request
# @!attribute software_version
# @return [String] version of diaspora* as reported by nodeinfo
# @!attribute error
# @return [Exception] if the test is unsuccessful, this will contain
# an exception of type {ConnectionTester::Failure}
def initialize
self.rt = -1
end
def success?
error.nil?
end
def error?
!error.nil?
end
def failure_message
"#{error.class.name}: #{error.message}" if error?
end
end
end