Class: Radfish::HttpClient

Inherits:
Object
  • Object
show all
Includes:
Debuggable
Defined in:
lib/radfish/http_client.rb

Overview

Shared HTTP client for all BMC connections

Constant Summary collapse

REDIRECT_STATUSES =

Redirects we follow, and the methods we follow them for. GET/HEAD only: 307/308 preserve the method, but 301/302/303 do not, so re-sending a POST body after one would be wrong. Callers doing anything else get the 3xx back.

[301, 302, 303, 307, 308].freeze
REDIRECT_METHODS =
[:get, :head].freeze
IDEMPOTENT_METHODS =

Methods we retry by default. A repeated GET or DELETE is harmless, but a repeated POST is not: on Redfish it can mean a second session, a second reset, a second job. Callers that know a POST is safe to repeat can widen this per client with retry_methods:.

[:get, :head, :put, :delete].freeze
RETRY_STATUSES =
[408, 429, 500, 502, 503, 504].freeze
LOG_FILTERS =

Secrets stripped from the debug log. Redfish session payloads use "Password" where other calls use "password", and the session token comes back as a header, so match either case and both header shapes.

[
  # Faraday logs a header as `Authorization: "Basic dXNlcjpwdw=="` and a
  # headers hash as `"Authorization"=>"Basic ..."`, so the quote sits
  # between the colon and the scheme. Match both shapes, and any scheme,
  # keeping the scheme itself visible because it is useful and not secret.
  [/(Authorization"?\s*(?:=>|:)\s*"?)((?:Basic|Bearer|Digest)\s+)?[^"\n]+/i, '\1\2[FILTERED]'],
  [/(Password"=>"?)([^,"]+)/i, '\1[FILTERED]'],
  [/("password"\s*:\s*")([^"]+)/i, '\1[FILTERED]'],
  [/((?:X-Auth-Token)"?\s*(?:=>|:)\s*"?)([^",\n]+)/i, '\1[FILTERED]']
].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Debuggable

#debug

Constructor Details

#initialize(host:, port: 443, use_ssl: true, verify_ssl: false, username: nil, password: nil, verbosity: 0, retry_count: 3, retry_delay: 1, retry_methods: IDEMPOTENT_METHODS, max_redirects: 3, log_device: STDOUT, **options) ⇒ HttpClient

Returns a new instance of HttpClient.



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/radfish/http_client.rb', line 45

def initialize(host:, port: 443, use_ssl: true, verify_ssl: false, 
               username: nil, password: nil, verbosity: 0,
               retry_count: 3, retry_delay: 1,
               retry_methods: IDEMPOTENT_METHODS, max_redirects: 3,
               log_device: STDOUT, **options)
  @host = host
  @port = port
  @use_ssl = use_ssl
  @verify_ssl = verify_ssl
  @username = username
  @password = password
  @verbosity = verbosity
  @retry_count = retry_count
  @retry_delay = retry_delay
  @retry_methods = retry_methods
  @max_redirects = max_redirects
  @log_device = log_device
  @options = options
end

Instance Attribute Details

#hostObject (readonly)

Returns the value of attribute host.



41
42
43
# File 'lib/radfish/http_client.rb', line 41

def host
  @host
end

#log_deviceObject

Returns the value of attribute log_device.



42
43
44
# File 'lib/radfish/http_client.rb', line 42

def log_device
  @log_device
end

#max_redirectsObject

Returns the value of attribute max_redirects.



42
43
44
# File 'lib/radfish/http_client.rb', line 42

def max_redirects
  @max_redirects
end

#passwordObject

Returns the value of attribute password.



42
43
44
# File 'lib/radfish/http_client.rb', line 42

def password
  @password
end

#portObject (readonly)

Returns the value of attribute port.



41
42
43
# File 'lib/radfish/http_client.rb', line 41

def port
  @port
end

#retry_countObject

Returns the value of attribute retry_count.



42
43
44
# File 'lib/radfish/http_client.rb', line 42

def retry_count
  @retry_count
end

#retry_delayObject

Returns the value of attribute retry_delay.



42
43
44
# File 'lib/radfish/http_client.rb', line 42

def retry_delay
  @retry_delay
end

#retry_methodsObject

Returns the value of attribute retry_methods.



42
43
44
# File 'lib/radfish/http_client.rb', line 42

def retry_methods
  @retry_methods
end

#use_sslObject (readonly)

Returns the value of attribute use_ssl.



41
42
43
# File 'lib/radfish/http_client.rb', line 41

def use_ssl
  @use_ssl
end

#usernameObject

Returns the value of attribute username.



42
43
44
# File 'lib/radfish/http_client.rb', line 42

def username
  @username
end

#verbosityObject

Returns the value of attribute verbosity.



42
43
44
# File 'lib/radfish/http_client.rb', line 42

def verbosity
  @verbosity
end

#verify_sslObject (readonly)

Returns the value of attribute verify_ssl.



41
42
43
# File 'lib/radfish/http_client.rb', line 41

def verify_ssl
  @verify_ssl
end

Class Method Details

.scrub(text) ⇒ Object

The Faraday logger applies LOG_FILTERS, but our own debug lines bypass it, so anything we print that can carry a header or a body goes through here.



67
68
69
# File 'lib/radfish/http_client.rb', line 67

def self.scrub(text)
  LOG_FILTERS.reduce(text.to_s) { |t, (pattern, replacement)| t.gsub(pattern, replacement) }
end

Instance Method Details

#base_urlObject



71
72
73
74
# File 'lib/radfish/http_client.rb', line 71

def base_url
  protocol = use_ssl ? 'https' : 'http'
  "#{protocol}://#{host}:#{port}"
end

#delete(path, headers: {}, **options) ⇒ Object



92
93
94
# File 'lib/radfish/http_client.rb', line 92

def delete(path, headers: {}, **options)
  request(:delete, path, headers: headers, **options)
end

#get(path, headers: {}, **options) ⇒ Object



76
77
78
# File 'lib/radfish/http_client.rb', line 76

def get(path, headers: {}, **options)
  request(:get, path, headers: headers, **options)
end

#patch(path, body: nil, headers: {}, **options) ⇒ Object



88
89
90
# File 'lib/radfish/http_client.rb', line 88

def patch(path, body: nil, headers: {}, **options)
  request(:patch, path, body: body, headers: headers, **options)
end

#post(path, body: nil, headers: {}, **options) ⇒ Object



80
81
82
# File 'lib/radfish/http_client.rb', line 80

def post(path, body: nil, headers: {}, **options)
  request(:post, path, body: body, headers: headers, **options)
end

#put(path, body: nil, headers: {}, **options) ⇒ Object



84
85
86
# File 'lib/radfish/http_client.rb', line 84

def put(path, body: nil, headers: {}, **options)
  request(:put, path, body: body, headers: headers, **options)
end

#redirect?(method, response) ⇒ Boolean

Returns:

  • (Boolean)


189
190
191
# File 'lib/radfish/http_client.rb', line 189

def redirect?(method, response)
  REDIRECT_METHODS.include?(method) && REDIRECT_STATUSES.include?(response.status)
end

#request(method, path, body: nil, headers: {}, auth: true, timeout: nil, max_redirects: nil, **options) ⇒ Object

max_redirects overrides the client default for one call; pass 0 to get the 3xx response itself rather than what it points at.



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
# File 'lib/radfish/http_client.rb', line 98

def request(method, path, body: nil, headers: {}, auth: true, timeout: nil,
            max_redirects: nil, **options)
  debug "Starting HTTP #{method.upcase} request to #{path}", 2, :yellow
  redirects_left = max_redirects.nil? ? @max_redirects.to_i : max_redirects.to_i
  
  # Add host header if specified (needed for SSH tunnels to iDRAC)
  if @options[:host_header]
    headers = headers.merge('Host' => @options[:host_header])
    debug "Added Host header: #{@options[:host_header]}", 2, :cyan
  end
  
  debug "Creating connection...", 2, :yellow
  conn = connection(auth: auth)
  debug "Connection created, sending #{method} request...", 2, :yellow
  
  response = conn.send(method) do |req|
    debug "Setting request URL: #{path}", 3, :cyan
    req.url path
    debug "Merging headers: #{self.class.scrub(headers)}", 3, :cyan
    req.headers.merge!(headers)
    req.body = body if body
    
    # Override timeout if specified
    if timeout
      debug "Setting timeout: #{timeout}s", 3, :cyan
      req.options.timeout = timeout
      req.options.open_timeout = [timeout / 2, 5].min
    end
    
    # Apply any additional options
    options.each do |key, value|
      req.options[key] = value if req.options.respond_to?(:"#{key}=")
    end
    debug "Request configured, about to send...", 2, :yellow
  end
  
  debug "Request completed with status: #{response.status}", 2, :green
  
  if redirects_left > 0 && redirect?(method, response)
    target = safe_redirect_path(response['location'])
    
    if target.nil?
      debug "Not following redirect to #{response['location'].inspect} - it leaves #{base_url}", 1, :yellow
    else
      debug "Following redirect to #{target}, #{redirects_left - 1} left", 2, :yellow
      return request(method, target, body: body, headers: headers, auth: auth,
                     timeout: timeout, max_redirects: redirects_left - 1, **options)
    end
  end
  
  response
rescue Faraday::ConnectionFailed => e
  debug "Connection failed: #{e.message}", 1, :red
  raise Radfish::ConnectionError, "Failed to connect to #{host}: #{e.message}"
rescue Faraday::TimeoutError => e
  debug "Request timed out: #{e.message}", 1, :red
  raise Radfish::TimeoutError, "Request to #{host} timed out: #{e.message}"
rescue Faraday::SSLError => e
  debug "SSL error: #{e.message}", 1, :red
  
  # Test if this might be HTTP instead of HTTPS
  begin
    debug "Testing if endpoint supports HTTP instead of HTTPS...", 2, :yellow
    test_http_socket = TCPSocket.new(host, port)
    test_http_socket.write("GET /redfish/v1 HTTP/1.1\r\nHost: #{@host_header || host}\r\nConnection: close\r\n\r\n")
    response = test_http_socket.read(1024) # Read first 1KB
    test_http_socket.close
    
    if response && response.include?("HTTP/") && !response.include?("301") && !response.include?("302")
      debug "HTTP response received - this endpoint might support HTTP instead of HTTPS", 1, :yellow
      debug "HTTP response preview: #{response[0..200]}", 2
    else
      debug "No valid HTTP response - endpoint requires HTTPS but SSL failed", 2, :red
    end
  rescue => http_test_error
    debug "HTTP test failed: #{http_test_error.message}", 2
  end
  
  raise Radfish::ConnectionError, "SSL error connecting to #{host}: #{e.message}"
rescue Faraday::Error => e
  # HttpClient is the seam: callers speak Radfish errors, not Faraday ones.
  debug "HTTP request failed: #{e.class} - #{e.message}", 1, :red
  raise Radfish::Error, "Request to #{host} failed: #{e.message}"
rescue => e
  debug "HTTP request failed: #{e.class} - #{e.message}", 1, :red
  debug "Exception backtrace: #{e.backtrace.first(5).join("\n")}", 1, :red
  
  # Don't re-raise as generic error - let the original exception propagate
  raise e
end

#safe_redirect_path(location) ⇒ Object

The path (with query) to request for location, or nil when we must not follow it. Every request we make carries credentials -- Basic auth here, an X-Auth-Token on the adapters' authenticated_request -- and Faraday honours an absolute URL by replacing the host, so a BMC answering with "Location: https://elsewhere/" would be handed those credentials. Accept a relative path, or an absolute URL for this same endpoint, and return only the path so the request stays on this connection.

Note: with an SSH tunnel (host_header set) an absolute redirect to the BMC's own name is refused, since that name is not the host we connect to. Relative redirects are unaffected.



204
205
206
207
208
209
210
211
212
213
214
215
# File 'lib/radfish/http_client.rb', line 204

def safe_redirect_path(location)
  location = location.to_s
  return nil if location.empty?
  
  uri = URI.parse(location)
  return nil if uri.path.to_s.empty?
  return nil unless uri.host.nil? || same_endpoint?(uri)
  
  uri.query ? "#{uri.path}?#{uri.query}" : uri.path
rescue URI::InvalidURIError
  nil
end