Welcome to mirror list, hosted at ThFree Co, Russian Federation.

connection_tester.rb « lib - github.com/diaspora/diaspora.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
blob: 5bc58d75b3f4a39a786f99ff244b74adcc91abb2 (plain)
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

# frozen_string_literal: true

class ConnectionTester
  include Diaspora::Logging

  NODEINFO_SCHEMA   = "http://nodeinfo.diaspora.software/ns/schema/1.0"
  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)
  # * is there a /.well-known/host-meta (this is needed to work, this can be replaced with a mandatory NodeInfo later)
  # (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 { http.get("/") }
      response = http.get("/.well-known/host-meta")
      handle_http_response(response)
    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, FaradayMiddleware::RedirectLimitReached, Faraday::ClientError => e
    raise HTTPFailure, 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 [NodeInfoFailure] if the document can't be fetched
  #   or the attempt to parse it failed
  def nodeinfo
    with_http_connection do |http|
      ni_resp = http.get(NODEINFO_FRAGMENT)
      nd_resp = http.get(find_nodeinfo_url(ni_resp.body))
      find_software_version(nd_resp.body)
    end
  rescue NodeInfoFailure => e
    raise e
  rescue JSON::Schema::ValidationError, JSON::Schema::SchemaError => e
    raise NodeInfoFailure, "#{e.class}: #{e.message}"
  rescue Faraday::ResourceNotFound, 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 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)

    if 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")
    else
      raise HTTPFailure, "unsuccessful response code: #{response.status}"
    end
  end

  # walk the JSON document, get the actual document location
  def find_nodeinfo_url(body)
    jrd = JSON.parse(body)
    links = jrd.fetch("links")
    raise NodeInfoFailure, "invalid JRD: '#/links' is not an array!" unless links.is_a?(Array)
    links.find { |entry|
      entry.fetch("rel") == NODEINFO_SCHEMA
    }.fetch("href")
  end

  # walk the JSON document, find the version string
  def find_software_version(body)
    info = JSON.parse(body)
    JSON::Validator.validate!(NodeInfo.schema("1.0"), 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