summaryrefslogtreecommitdiff
path: root/lib/bundler/cli/doctor/ssl.rb
blob: 21fc4edf2d39c8723ca33e512ab2c5e5b38a62d3 (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
# frozen_string_literal: true

require "rubygems/remote_fetcher"
require "uri"

module Bundler
  class CLI::Doctor::SSL
    attr_reader :options

    def initialize(options)
      @options = options
    end

    def run
      return unless openssl_installed?

      output_ssl_environment
      bundler_success = bundler_connection_successful?
      rubygem_success = rubygem_connection_successful?

      return unless net_http_connection_successful?

      Explanation.summarize(bundler_success, rubygem_success, host)
    end

    private

    def host
      @options[:host] || "rubygems.org"
    end

    def tls_version
      @options[:"tls-version"].then do |version|
        "TLS#{version.sub(".", "_")}".to_sym if version
      end
    end

    def verify_mode
      mode = @options[:"verify-mode"] || :peer

      @verify_mode ||= mode.then {|mod| OpenSSL::SSL.const_get("verify_#{mod}".upcase) }
    end

    def uri
      @uri ||= URI("https://#{host}")
    end

    def openssl_installed?
      require "openssl"

      true
    rescue LoadError
      Bundler.ui.warn(<<~MSG)
        Oh no! Your Ruby doesn't have OpenSSL, so it can't connect to #{host}.
        You'll need to recompile or reinstall Ruby with OpenSSL support and try again.
      MSG

      false
    end

    def output_ssl_environment
      Bundler.ui.info(<<~MESSAGE)
        Here's your OpenSSL environment:

        OpenSSL:       #{OpenSSL::VERSION}
        Compiled with: #{OpenSSL::OPENSSL_VERSION}
        Loaded with:   #{OpenSSL::OPENSSL_LIBRARY_VERSION}
      MESSAGE
    end

    def bundler_connection_successful?
      Bundler.ui.info("\nTrying connections to #{uri}:\n")

      bundler_uri = Gem::URI(uri.to_s)
      Bundler::Fetcher.new(
        Bundler::Source::Rubygems::Remote.new(bundler_uri)
      ).send(:connection).request(bundler_uri)

      Bundler.ui.info("Bundler:       success")

      true
    rescue StandardError => error
      Bundler.ui.warn("Bundler:       failed     (#{Explanation.explain_bundler_or_rubygems_error(error)})")

      false
    end

    def rubygem_connection_successful?
      Gem::RemoteFetcher.fetcher.fetch_path(uri)
      Bundler.ui.info("RubyGems:      success")

      true
    rescue StandardError => error
      Bundler.ui.warn("RubyGems:      failed     (#{Explanation.explain_bundler_or_rubygems_error(error)})")

      false
    end

    def net_http_connection_successful?
      ::Gem::Net::HTTP.new(uri.host, uri.port).tap do |http|
        http.use_ssl = true
        http.min_version = tls_version
        http.max_version = tls_version
        http.verify_mode = verify_mode
      end.start

      Bundler.ui.info("Ruby net/http: success")
      warn_on_unsupported_tls12

      true
    rescue StandardError => error
      Bundler.ui.warn(<<~MSG)
        Ruby net/http: failed

        Unfortunately, this Ruby can't connect to #{host}.

        #{Explanation.explain_net_http_error(error, host, tls_version)}
      MSG

      false
    end

    def warn_on_unsupported_tls12
      ctx = OpenSSL::SSL::SSLContext.new
      supported = true

      if ctx.respond_to?(:min_version=)
        begin
          ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_2_VERSION
        rescue OpenSSL::SSL::SSLError, NameError
          supported = false
        end
      else
        supported = OpenSSL::SSL::SSLContext::METHODS.include?(:TLSv1_2) # rubocop:disable Naming/VariableNumber
      end

      Bundler.ui.warn(<<~EOM) unless supported

        WARNING: Although your Ruby can connect to #{host} today, your OpenSSL is very old!
        WARNING: You will need to upgrade OpenSSL to use #{host}.

      EOM
    end

    module Explanation
      extend self

      def explain_bundler_or_rubygems_error(error)
        case error.message
        when /certificate verify failed/
          "certificate verification"
        when /read server hello A/
          "SSL/TLS protocol version mismatch"
        when /tlsv1 alert protocol version/
          "requested TLS version is too old"
        else
          error.message
        end
      end

      def explain_net_http_error(error, host, tls_version)
        case error.message
        # Check for certificate errors
        when /certificate verify failed/
          <<~MSG
            #{show_ssl_certs}
            Your Ruby can't connect to #{host} because you are missing the certificate files OpenSSL needs to verify you are connecting to the genuine #{host} servers.
          MSG
        # Check for TLS version errors
        when /read server hello A/, /tlsv1 alert protocol version/
          if tls_version.to_s == "TLS1_3"
            "Your Ruby can't connect to #{host} because #{tls_version} isn't supported yet.\n"
          else
            <<~MSG
              Your Ruby can't connect to #{host} because your version of OpenSSL is too old.
              You'll need to upgrade your OpenSSL install and/or recompile Ruby to use a newer OpenSSL.
            MSG
          end
        # OpenSSL doesn't support TLS version specified by argument
        when /unknown SSL method/
          "Your Ruby can't connect because #{tls_version} isn't supported by your version of OpenSSL."
        else
          <<~MSG
            Even worse, we're not sure why.

            Here's the full error information:
            #{error.class}: #{error.message}
              #{error.backtrace.join("\n  ")}

            You might have more luck using Mislav's SSL doctor.rb script. You can get it here:
            https://github.com/mislav/ssl-tools/blob/8b3dec4/doctor.rb

            Read more about the script and how to use it in this blog post:
            https://mislav.net/2013/07/ruby-openssl/
          MSG
        end
      end

      def summarize(bundler_success, rubygems_success, host)
        guide_url = "http://ruby.to/ssl-check-failed"

        message = if bundler_success && rubygems_success
          <<~MSG
            Hooray! This Ruby can connect to #{host}.
            You are all set to use Bundler and RubyGems.

          MSG
        elsif !bundler_success && !rubygems_success
          <<~MSG
            For some reason, your Ruby installation can connect to #{host}, but neither RubyGems nor Bundler can.
            The most likely fix is to manually upgrade RubyGems by following the instructions at #{guide_url}.
            After you've done that, run `gem install bundler` to upgrade Bundler, and then run this script again to make sure everything worked. ❣

          MSG
        elsif !bundler_success
          <<~MSG
            Although your Ruby installation and RubyGems can both connect to #{host}, Bundler is having trouble.
            The most likely way to fix this is to upgrade Bundler by running `gem install bundler`.
            Run this script again after doing that to make sure everything is all set.
            If you're still having trouble, check out the troubleshooting guide at #{guide_url}.

          MSG
        else
          <<~MSG
            It looks like Ruby and Bundler can connect to #{host}, but RubyGems itself cannot.
            You can likely solve this by manually downloading and installing a RubyGems update.
            Visit #{guide_url} for instructions on how to manually upgrade RubyGems.

          MSG
        end

        Bundler.ui.info("\n#{message}")
      end

      private

      def show_ssl_certs
        ssl_cert_file = ENV["SSL_CERT_FILE"] || OpenSSL::X509::DEFAULT_CERT_FILE
        ssl_cert_dir  = ENV["SSL_CERT_DIR"]  || OpenSSL::X509::DEFAULT_CERT_DIR

        <<~MSG
          Below affect only Ruby net/http connections:
          SSL_CERT_FILE: #{File.exist?(ssl_cert_file) ? "exists     #{ssl_cert_file}" : "is missing #{ssl_cert_file}"}
          SSL_CERT_DIR:  #{Dir.exist?(ssl_cert_dir)   ? "exists     #{ssl_cert_dir}"  : "is missing #{ssl_cert_dir}"}
        MSG
      end
    end
  end
end