lib/connection_tester.rb
# 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, Faraday::ServerError => 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 Faraday::ClientError => e
    raise HTTPFailure, "#{e.class}: #{e.message}"
  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 StandardError => e
    unexpected_error(e)
  end

  private

  def with_http_connection
    @http ||= Faraday.new(@url) do |c|
      c.use Faraday::Response::RaiseError
      c.use Faraday::FollowRedirects::Middleware, 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
