File: http_client.rb

package info (click to toggle)
ruby-puppetserver-ca-cli 2.7.0-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 696 kB
  • sloc: ruby: 6,970; sh: 4; makefile: 3
file content (232 lines) | stat: -rw-r--r-- 8,190 bytes parent folder | download
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
require 'net/https'
require 'openssl'
require 'uri'

require 'puppetserver/ca/errors'

module Puppetserver
  module Ca
    module Utils
      # Utilities for doing HTTPS against the CA that wraps Net::HTTP constructs
      class HttpClient

        attr_reader :store

        # Not all connections require a client cert to be present.
        # For example, when querying the status endpoint.
        def initialize(logger, settings, with_client_cert: true)
          @default_headers = make_headers(ENV['HOME'])
          @logger = logger
          @store = make_store(settings[:localcacert],
                              settings[:certificate_revocation],
                              settings[:hostcrl])

          if with_client_cert
            @cert = load_cert(settings[:hostcert])
            @key = load_key(settings[:hostprivkey])
          else
            @cert = nil
            @key = nil
          end
        end

        def load_cert(path)
          load_with_errors(path, 'hostcert') do |content|
            OpenSSL::X509::Certificate.new(content)
          end
        end

        def load_key(path)
          load_with_errors(path, 'hostprivkey') do |content|
            OpenSSL::PKey.read(content)
          end
        end

        # Takes an instance URL (defined lower in the file), and creates a
        # connection. The given block is passed our own Connection object.
        # The Connection object should have HTTP verbs defined on it that take
        # a body (and optional overrides). Returns whatever the block given returned.
        def with_connection(url, &block)
          request = ->(conn) { block.call(Connection.new(conn, url, @logger, @default_headers)) }

          begin
            Net::HTTP.start(url.host, url.port,
                            use_ssl: true, cert_store: @store,
                            cert: @cert, key: @key,
                            &request)
          rescue StandardError => e
            raise ConnectionFailed.create(e,
                    "Failed connecting to #{url.full_url}\n" +
                    "  Root cause: #{e.message}")
          end
        end

        private

        def make_headers(home)
          headers = {
            'User-Agent'   => 'PuppetserverCaCli',
            'Content-Type' => 'application/json',
            'Accept'       => 'application/json'
          }

          token_path = "#{home}/.puppetlabs/token"
          if File.exist?(token_path)
            headers['X-Authentication'] = File.read(token_path)
          end

          headers
        end

        def load_with_errors(path, setting, &block)
          begin
            content = File.read(path)
            block.call(content)
          rescue Errno::ENOENT => e
            raise FileNotFound.create(e,
                    "Could not find '#{setting}' at '#{path}'")

          rescue OpenSSL::OpenSSLError => e
            raise InvalidX509Object.create(e,
                    "Could not parse '#{setting}' at '#{path}'.\n" +
                    "  OpenSSL returned: #{e.message}")
          end
        end

        # Helper class that wraps a Net::HTTP connection, a HttpClient::URL
        # and defines methods named after HTTP verbs that are called on the
        # saved connection, returning a Result.
        class Connection

          def initialize(net_http_connection, url_struct, logger, default_headers)
            @conn = net_http_connection
            @url = url_struct
            @logger = logger
            @default_headers = default_headers
          end

          def get(url_overide = nil, header_overrides = {})
            url = url_overide || @url
            headers = @default_headers.merge(header_overrides)

            @logger.debug("Making a GET request at #{url.full_url}")

            request = Net::HTTP::Get.new(url.to_uri, headers)
            result = @conn.request(request)
            Result.new(result.code, result.body)

          end

          def put(body, url_override = nil, header_overrides = {})
            url = url_override || @url
            headers = @default_headers.merge(header_overrides)

            @logger.debug("Making a PUT request at #{url.full_url}")

            request = Net::HTTP::Put.new(url.to_uri, headers)
            request.body = body
            result = @conn.request(request)

            Result.new(result.code, result.body)
          end

          def post(body, url_override = nil, header_overrides = {})
            url = url_override || @url
            headers = @default_headers.merge(header_overrides)

            @logger.debug("Making a POST request at #{url.full_url}")

            request = Net::HTTP::Post.new(url.to_uri, headers)
            request.body = body
            result = @conn.request(request)

            Result.new(result.code, result.body)
          end

          def delete(url_override = nil, header_overrides = {})
            url = url_override || @url
            headers = @default_headers.merge(header_overrides)

            @logger.debug("Making a DELETE request at #{url.full_url}")

            result = @conn.request(Net::HTTP::Delete.new(url.to_uri, headers))

            Result.new(result.code, result.body)
          end
        end

        # Just provide the bits of Net::HTTPResponse we care about
        Result = Struct.new(:code, :body)

        # Like URI, but not... maybe of suspicious value
        URL = Struct.new(:protocol, :host, :port,
                         :endpoint, :version,
                         :resource_type, :resource_name, :query) do
                def full_url
                  url = protocol + '://' + host + ':' + port + '/' +
                        [endpoint, version, resource_type, resource_name].compact.join('/')

                  url = url + "?" + URI.encode_www_form(query) unless query.nil? || query.empty?
                  return url
                end

                def to_uri
                  URI(full_url)
                end
              end

        def make_store(bundle, crl_usage, crls = nil)
          store = OpenSSL::X509::Store.new
          store.purpose = OpenSSL::X509::PURPOSE_ANY
          store.add_file(bundle)

          if crl_usage != :ignore

            flags = OpenSSL::X509::V_FLAG_CRL_CHECK
            if crl_usage == :chain
              flags |= OpenSSL::X509::V_FLAG_CRL_CHECK_ALL
            end

            store.flags = flags
            delimiter = /-----BEGIN X509 CRL-----.*?-----END X509 CRL-----/m
            File.read(crls).scan(delimiter).each do |crl|
              store.add_crl(OpenSSL::X509::CRL.new(crl))
            end
          end

          store
        end

        # Queries the simple status endpoint for the status of the CA service.
        # Returns true if it receives back a response of "running", and false if
        # no connection can be made, or a different response is received.
        def self.check_server_online(settings, logger)
          status_url = URL.new('https', settings[:ca_server], settings[:ca_port], 'status', 'v1', 'simple', 'ca')
          begin
            # Generating certs offline is necessary if the server cert has been destroyed
            # or compromised. Since querying the status endpoint does not require a client cert, and
            # we commonly won't have one, don't require one for creating the connection.
            # Additionally, we want to ensure the server is stopped before migrating the CA dir to
            # avoid issues with writing to the CA dir and moving it.
            self.new(logger, settings, with_client_cert: false).with_connection(status_url) do |conn|
              result = conn.get
              if result.body == "running"
                logger.err "Puppetserver service is running. Please stop it before attempting to run this command."
                true
              else
                false
              end
            end
          rescue Puppetserver::Ca::ConnectionFailed => e
            if e.wrapped.is_a? Errno::ECONNREFUSED
              return false
            else
              raise e
            end
          end
        end

      end
    end
  end
end