idrac 0.1.31 → 0.1.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +49 -107
- data/bin/idrac +130 -15
- data/idrac.gemspec +9 -8
- data/lib/idrac/client.rb +22 -383
- data/lib/idrac/session.rb +278 -0
- data/lib/idrac/version.rb +1 -1
- data/lib/idrac/web.rb +187 -0
- data/lib/idrac.rb +3 -1
- metadata +7 -39
- data/.rspec +0 -3
- data/Rakefile +0 -17
- data/dell_firmware_downloads/Catalog.etag +0 -1
- data/dell_firmware_downloads/Catalog.xml +0 -0
- data/idrac-0.1.6/.rspec +0 -3
- data/idrac-0.1.6/README.md +0 -103
- data/idrac-0.1.6/Rakefile +0 -17
- data/idrac-0.1.6/bin/console +0 -11
- data/idrac-0.1.6/bin/idrac +0 -179
- data/idrac-0.1.6/bin/setup +0 -8
- data/idrac-0.1.6/idrac.gemspec +0 -51
- data/idrac-0.1.6/lib/idrac/client.rb +0 -109
- data/idrac-0.1.6/lib/idrac/firmware.rb +0 -366
- data/idrac-0.1.6/lib/idrac/version.rb +0 -5
- data/idrac-0.1.6/lib/idrac.rb +0 -30
- data/idrac-0.1.6/sig/idrac.rbs +0 -4
- data/idrac-0.1.7/.rspec +0 -3
- data/idrac-0.1.7/README.md +0 -103
- data/idrac-0.1.7/Rakefile +0 -17
- data/idrac-0.1.7/bin/console +0 -11
- data/idrac-0.1.7/bin/idrac +0 -179
- data/idrac-0.1.7/bin/setup +0 -8
- data/idrac-0.1.7/idrac.gemspec +0 -51
- data/idrac-0.1.7/lib/idrac/client.rb +0 -109
- data/idrac-0.1.7/lib/idrac/firmware.rb +0 -366
- data/idrac-0.1.7/lib/idrac/screenshot.rb +0 -49
- data/idrac-0.1.7/lib/idrac/version.rb +0 -5
- data/idrac-0.1.7/lib/idrac.rb +0 -30
- data/idrac-0.1.7/sig/idrac.rbs +0 -4
- data/idrac.py +0 -500
- data/lib/idrac/screenshot.rb +0 -49
- data/sig/idrac.rbs +0 -4
- data/test_firmware_update.rb +0 -68
- data/updater.rb +0 -729
data/lib/idrac/client.rb
CHANGED
@@ -9,7 +9,7 @@ require 'colorize'
|
|
9
9
|
|
10
10
|
module IDRAC
|
11
11
|
class Client
|
12
|
-
attr_reader :host, :username, :password, :port, :use_ssl, :verify_ssl, :auto_delete_sessions
|
12
|
+
attr_reader :host, :username, :password, :port, :use_ssl, :verify_ssl, :auto_delete_sessions, :session, :web
|
13
13
|
attr_accessor :direct_mode
|
14
14
|
|
15
15
|
def initialize(host:, username:, password:, port: 443, use_ssl: true, verify_ssl: true, direct_mode: false, auto_delete_sessions: true)
|
@@ -19,13 +19,12 @@ module IDRAC
|
|
19
19
|
@port = port
|
20
20
|
@use_ssl = use_ssl
|
21
21
|
@verify_ssl = verify_ssl
|
22
|
-
@session_id = nil
|
23
|
-
@cookies = nil
|
24
|
-
@x_auth_token = nil
|
25
22
|
@direct_mode = direct_mode
|
26
|
-
@sessions_maxed = false
|
27
|
-
@tried_clearing_sessions = false
|
28
23
|
@auto_delete_sessions = auto_delete_sessions
|
24
|
+
|
25
|
+
# Initialize the session and web classes
|
26
|
+
@session = Session.new(self)
|
27
|
+
@web = Web.new(self)
|
29
28
|
end
|
30
29
|
|
31
30
|
def connection
|
@@ -36,353 +35,6 @@ module IDRAC
|
|
36
35
|
end
|
37
36
|
end
|
38
37
|
|
39
|
-
# Force clear all sessions by directly using Basic Auth
|
40
|
-
def force_clear_sessions
|
41
|
-
puts "Attempting to force clear all sessions...".light_cyan
|
42
|
-
|
43
|
-
# Try to delete sessions directly using Basic Auth
|
44
|
-
if delete_all_sessions_with_basic_auth
|
45
|
-
puts "Successfully cleared sessions using Basic Auth".green
|
46
|
-
return true
|
47
|
-
else
|
48
|
-
puts "Failed to clear sessions using Basic Auth".red
|
49
|
-
return false
|
50
|
-
end
|
51
|
-
end
|
52
|
-
|
53
|
-
# Delete all sessions using Basic Authentication
|
54
|
-
def delete_all_sessions_with_basic_auth
|
55
|
-
puts "Attempting to delete all sessions using Basic Authentication...".light_cyan
|
56
|
-
|
57
|
-
# First, get the list of sessions
|
58
|
-
sessions_url = '/redfish/v1/SessionService/Sessions'
|
59
|
-
|
60
|
-
# Create a connection with Basic Auth
|
61
|
-
basic_auth_headers = {
|
62
|
-
'Authorization' => "Basic #{Base64.strict_encode64("#{username}:#{password}")}",
|
63
|
-
'Content-Type' => 'application/json'
|
64
|
-
}
|
65
|
-
|
66
|
-
begin
|
67
|
-
# Get the list of sessions
|
68
|
-
response = connection.get(sessions_url) do |req|
|
69
|
-
req.headers.merge!(basic_auth_headers)
|
70
|
-
end
|
71
|
-
|
72
|
-
if response.status != 200
|
73
|
-
puts "Failed to get sessions list: #{response.status} - #{response.body}".red
|
74
|
-
return false
|
75
|
-
end
|
76
|
-
|
77
|
-
# Parse the response to get session IDs
|
78
|
-
begin
|
79
|
-
sessions_data = JSON.parse(response.body)
|
80
|
-
|
81
|
-
if sessions_data['Members'] && sessions_data['Members'].any?
|
82
|
-
puts "Found #{sessions_data['Members'].count} active sessions".light_yellow
|
83
|
-
|
84
|
-
# Delete each session
|
85
|
-
success = true
|
86
|
-
sessions_data['Members'].each do |session|
|
87
|
-
session_url = session['@odata.id']
|
88
|
-
|
89
|
-
# Skip if no URL
|
90
|
-
next unless session_url
|
91
|
-
|
92
|
-
# Delete the session
|
93
|
-
delete_response = connection.delete(session_url) do |req|
|
94
|
-
req.headers.merge!(basic_auth_headers)
|
95
|
-
end
|
96
|
-
|
97
|
-
if delete_response.status == 200 || delete_response.status == 204
|
98
|
-
puts "Successfully deleted session: #{session_url}".green
|
99
|
-
else
|
100
|
-
puts "Failed to delete session #{session_url}: #{delete_response.status}".red
|
101
|
-
success = false
|
102
|
-
end
|
103
|
-
|
104
|
-
# Small delay between deletions
|
105
|
-
sleep(1)
|
106
|
-
end
|
107
|
-
|
108
|
-
return success
|
109
|
-
else
|
110
|
-
puts "No active sessions found".light_yellow
|
111
|
-
return true
|
112
|
-
end
|
113
|
-
rescue JSON::ParserError => e
|
114
|
-
puts "Error parsing sessions response: #{e.message}".red.bold
|
115
|
-
return false
|
116
|
-
end
|
117
|
-
rescue => e
|
118
|
-
puts "Error during session deletion with Basic Auth: #{e.message}".red.bold
|
119
|
-
return false
|
120
|
-
end
|
121
|
-
end
|
122
|
-
|
123
|
-
# Create a Redfish session
|
124
|
-
def create_redfish_session
|
125
|
-
# Skip if we're in direct mode
|
126
|
-
if @direct_mode
|
127
|
-
puts "Skipping Redfish session creation (direct mode)".light_yellow
|
128
|
-
return false
|
129
|
-
end
|
130
|
-
|
131
|
-
url = '/redfish/v1/SessionService/Sessions'
|
132
|
-
payload = { "UserName" => username, "Password" => password }
|
133
|
-
|
134
|
-
# Try first with just Content-Type header (no Basic Auth)
|
135
|
-
begin
|
136
|
-
response = connection.post(url) do |req|
|
137
|
-
req.headers['Content-Type'] = 'application/json'
|
138
|
-
req.body = payload.to_json
|
139
|
-
end
|
140
|
-
|
141
|
-
if response.status == 201 || response.status == 200
|
142
|
-
# Extract X-Auth-Token from response headers
|
143
|
-
@x_auth_token = response.headers['X-Auth-Token']
|
144
|
-
|
145
|
-
# Extract session location from response headers
|
146
|
-
@session_location = response.headers['Location']
|
147
|
-
|
148
|
-
puts "Redfish session created successfully".green
|
149
|
-
@sessions_maxed = false
|
150
|
-
return true
|
151
|
-
end
|
152
|
-
rescue => e
|
153
|
-
puts "First session creation attempt failed: #{e.message}".light_red
|
154
|
-
end
|
155
|
-
|
156
|
-
# If that fails, try with Basic Auth
|
157
|
-
begin
|
158
|
-
# Use Basic Auth for the session creation
|
159
|
-
basic_auth_headers = {
|
160
|
-
'Authorization' => "Basic #{Base64.strict_encode64("#{username}:#{password}")}",
|
161
|
-
'Content-Type' => 'application/json'
|
162
|
-
}
|
163
|
-
|
164
|
-
response = connection.post(url) do |req|
|
165
|
-
req.headers.merge!(basic_auth_headers)
|
166
|
-
req.body = payload.to_json
|
167
|
-
end
|
168
|
-
|
169
|
-
if response.status == 201 || response.status == 200
|
170
|
-
# Extract X-Auth-Token from response headers
|
171
|
-
@x_auth_token = response.headers['X-Auth-Token']
|
172
|
-
|
173
|
-
# Extract session location from response headers
|
174
|
-
@session_location = response.headers['Location']
|
175
|
-
|
176
|
-
puts "Redfish session created successfully with Basic Auth".green
|
177
|
-
@sessions_maxed = false
|
178
|
-
return true
|
179
|
-
elsif response.status == 400 && response.body.include?("maximum number of user sessions")
|
180
|
-
puts "Maximum sessions reached during Redfish session creation".light_red
|
181
|
-
@sessions_maxed = true
|
182
|
-
|
183
|
-
# Try to clear sessions if auto_delete_sessions is enabled
|
184
|
-
if @auto_delete_sessions
|
185
|
-
puts "Auto-delete sessions is enabled, attempting to clear sessions".light_cyan
|
186
|
-
if force_clear_sessions
|
187
|
-
puts "Successfully cleared sessions, trying to create a new session".green
|
188
|
-
|
189
|
-
# Try one more time after clearing
|
190
|
-
response = connection.post(url) do |req|
|
191
|
-
req.headers.merge!(basic_auth_headers)
|
192
|
-
req.body = payload.to_json
|
193
|
-
end
|
194
|
-
|
195
|
-
if response.status == 201 || response.status == 200
|
196
|
-
@x_auth_token = response.headers['X-Auth-Token']
|
197
|
-
@session_location = response.headers['Location']
|
198
|
-
puts "Redfish session created successfully after clearing sessions".green
|
199
|
-
@sessions_maxed = false
|
200
|
-
return true
|
201
|
-
else
|
202
|
-
puts "Failed to create Redfish session after clearing: #{response.status} - #{response.body}".red
|
203
|
-
# If we still can't create a session, switch to direct mode
|
204
|
-
@direct_mode = true
|
205
|
-
return false
|
206
|
-
end
|
207
|
-
else
|
208
|
-
puts "Failed to clear sessions, switching to direct mode".light_yellow
|
209
|
-
@direct_mode = true
|
210
|
-
return false
|
211
|
-
end
|
212
|
-
else
|
213
|
-
puts "Auto-delete sessions is disabled, switching to direct mode".light_yellow
|
214
|
-
@direct_mode = true
|
215
|
-
return false
|
216
|
-
end
|
217
|
-
else
|
218
|
-
puts "Failed to create Redfish session: #{response.status} - #{response.body}".red
|
219
|
-
|
220
|
-
# If we get a 415 error, try with form-urlencoded
|
221
|
-
if response.status == 415
|
222
|
-
puts "Trying with form-urlencoded content type".light_cyan
|
223
|
-
response = connection.post(url) do |req|
|
224
|
-
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
225
|
-
req.headers['Authorization'] = "Basic #{Base64.strict_encode64("#{username}:#{password}")}"
|
226
|
-
req.body = "UserName=#{URI.encode_www_form_component(username)}&Password=#{URI.encode_www_form_component(password)}"
|
227
|
-
end
|
228
|
-
|
229
|
-
if response.status == 201 || response.status == 200
|
230
|
-
@x_auth_token = response.headers['X-Auth-Token']
|
231
|
-
@session_location = response.headers['Location']
|
232
|
-
puts "Redfish session created successfully with form-urlencoded".green
|
233
|
-
@sessions_maxed = false
|
234
|
-
return true
|
235
|
-
else
|
236
|
-
puts "Failed with form-urlencoded too: #{response.status} - #{response.body}".red
|
237
|
-
@direct_mode = true
|
238
|
-
return false
|
239
|
-
end
|
240
|
-
else
|
241
|
-
@direct_mode = true
|
242
|
-
return false
|
243
|
-
end
|
244
|
-
end
|
245
|
-
rescue => e
|
246
|
-
puts "Error during Redfish session creation: #{e.message}".red.bold
|
247
|
-
@direct_mode = true
|
248
|
-
return false
|
249
|
-
end
|
250
|
-
end
|
251
|
-
|
252
|
-
# Delete the Redfish session
|
253
|
-
def delete_redfish_session
|
254
|
-
return unless @x_auth_token && @session_location
|
255
|
-
|
256
|
-
begin
|
257
|
-
puts "Deleting Redfish session...".light_cyan
|
258
|
-
|
259
|
-
# Use the X-Auth-Token for authentication
|
260
|
-
headers = { 'X-Auth-Token' => @x_auth_token }
|
261
|
-
|
262
|
-
response = connection.delete(@session_location) do |req|
|
263
|
-
req.headers.merge!(headers)
|
264
|
-
end
|
265
|
-
|
266
|
-
if response.status == 200 || response.status == 204
|
267
|
-
puts "Redfish session deleted successfully".green
|
268
|
-
@x_auth_token = nil
|
269
|
-
@session_location = nil
|
270
|
-
return true
|
271
|
-
else
|
272
|
-
puts "Failed to delete Redfish session: #{response.status} - #{response.body}".red
|
273
|
-
return false
|
274
|
-
end
|
275
|
-
rescue => e
|
276
|
-
puts "Error during Redfish session deletion: #{e.message}".red.bold
|
277
|
-
return false
|
278
|
-
end
|
279
|
-
end
|
280
|
-
|
281
|
-
# Login to the WebUI (for screenshot functionality)
|
282
|
-
def webui_login(retry_count = 0)
|
283
|
-
# Limit retries to prevent infinite loops
|
284
|
-
if retry_count >= 3
|
285
|
-
puts "Maximum retry count reached for WebUI login".red
|
286
|
-
return false
|
287
|
-
end
|
288
|
-
|
289
|
-
# Skip if we already have a session ID
|
290
|
-
return true if @session_id
|
291
|
-
|
292
|
-
begin
|
293
|
-
puts "Logging in to WebUI...".light_cyan
|
294
|
-
|
295
|
-
# Create the login URL
|
296
|
-
login_url = "#{base_url}/data/login"
|
297
|
-
|
298
|
-
# Create the login payload
|
299
|
-
payload = {
|
300
|
-
'user' => username,
|
301
|
-
'password' => password
|
302
|
-
}
|
303
|
-
|
304
|
-
# Make the login request
|
305
|
-
response = HTTParty.post(
|
306
|
-
login_url,
|
307
|
-
body: payload,
|
308
|
-
verify: verify_ssl,
|
309
|
-
headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }
|
310
|
-
)
|
311
|
-
|
312
|
-
# Check if the login was successful
|
313
|
-
if response.code == 200
|
314
|
-
# Extract the session ID from the response
|
315
|
-
if response.body.include?('ST2')
|
316
|
-
@session_id = response.body.match(/ST2=([^;]+)/)[1]
|
317
|
-
@cookies = response.headers['set-cookie']
|
318
|
-
puts "WebUI login successful".green
|
319
|
-
return true
|
320
|
-
else
|
321
|
-
puts "WebUI login failed: No session ID found in response".red
|
322
|
-
return false
|
323
|
-
end
|
324
|
-
elsif response.code == 400 && response.body.include?("maximum number of user sessions")
|
325
|
-
puts "Maximum sessions reached during WebUI login".light_red
|
326
|
-
|
327
|
-
# Try to clear sessions if auto_delete_sessions is enabled
|
328
|
-
if @auto_delete_sessions && !@tried_clearing_sessions
|
329
|
-
puts "Auto-delete sessions is enabled, attempting to clear sessions".light_cyan
|
330
|
-
@tried_clearing_sessions = true
|
331
|
-
|
332
|
-
if force_clear_sessions
|
333
|
-
puts "Successfully cleared sessions, trying WebUI login again".green
|
334
|
-
return webui_login(retry_count + 1)
|
335
|
-
else
|
336
|
-
puts "Failed to clear sessions for WebUI login".red
|
337
|
-
return false
|
338
|
-
end
|
339
|
-
else
|
340
|
-
puts "Auto-delete sessions is disabled or already tried clearing".light_yellow
|
341
|
-
return false
|
342
|
-
end
|
343
|
-
else
|
344
|
-
puts "WebUI login failed: #{response.code} - #{response.body}".red
|
345
|
-
return false
|
346
|
-
end
|
347
|
-
rescue => e
|
348
|
-
puts "Error during WebUI login: #{e.message}".red.bold
|
349
|
-
return false
|
350
|
-
end
|
351
|
-
end
|
352
|
-
|
353
|
-
# Logout from the WebUI
|
354
|
-
def webui_logout
|
355
|
-
return unless @session_id
|
356
|
-
|
357
|
-
begin
|
358
|
-
puts "Logging out from WebUI...".light_cyan
|
359
|
-
|
360
|
-
# Create the logout URL
|
361
|
-
logout_url = "#{base_url}/data/logout"
|
362
|
-
|
363
|
-
# Make the logout request
|
364
|
-
response = HTTParty.get(
|
365
|
-
logout_url,
|
366
|
-
verify: verify_ssl,
|
367
|
-
headers: { 'Cookie' => @cookies }
|
368
|
-
)
|
369
|
-
|
370
|
-
# Check if the logout was successful
|
371
|
-
if response.code == 200
|
372
|
-
puts "WebUI logout successful".green
|
373
|
-
@session_id = nil
|
374
|
-
@cookies = nil
|
375
|
-
return true
|
376
|
-
else
|
377
|
-
puts "WebUI logout failed: #{response.code} - #{response.body}".red
|
378
|
-
return false
|
379
|
-
end
|
380
|
-
rescue => e
|
381
|
-
puts "Error during WebUI logout: #{e.message}".red.bold
|
382
|
-
return false
|
383
|
-
end
|
384
|
-
end
|
385
|
-
|
386
38
|
# Login to iDRAC
|
387
39
|
def login
|
388
40
|
# If we're in direct mode, skip login attempts
|
@@ -392,16 +44,11 @@ module IDRAC
|
|
392
44
|
end
|
393
45
|
|
394
46
|
# Try to create a Redfish session
|
395
|
-
if
|
47
|
+
if session.create
|
396
48
|
puts "Successfully logged in to iDRAC using Redfish session".green
|
397
49
|
return true
|
398
50
|
else
|
399
|
-
|
400
|
-
puts "Maximum sessions reached and could not clear sessions".light_red
|
401
|
-
else
|
402
|
-
puts "Failed to create Redfish session, falling back to direct mode".light_yellow
|
403
|
-
end
|
404
|
-
|
51
|
+
puts "Failed to create Redfish session, falling back to direct mode".light_yellow
|
405
52
|
@direct_mode = true
|
406
53
|
return true
|
407
54
|
end
|
@@ -409,14 +56,8 @@ module IDRAC
|
|
409
56
|
|
410
57
|
# Logout from iDRAC
|
411
58
|
def logout
|
412
|
-
if
|
413
|
-
|
414
|
-
end
|
415
|
-
|
416
|
-
if @session_id
|
417
|
-
webui_logout
|
418
|
-
end
|
419
|
-
|
59
|
+
session.delete if session.x_auth_token
|
60
|
+
web.logout if web.session_id
|
420
61
|
puts "Logged out from iDRAC".green
|
421
62
|
return true
|
422
63
|
end
|
@@ -452,10 +93,10 @@ module IDRAC
|
|
452
93
|
end
|
453
94
|
else
|
454
95
|
# Use X-Auth-Token if available
|
455
|
-
if
|
96
|
+
if session.x_auth_token
|
456
97
|
# Add the X-Auth-Token header to the request
|
457
98
|
options[:headers] ||= {}
|
458
|
-
options[:headers]['X-Auth-Token'] =
|
99
|
+
options[:headers]['X-Auth-Token'] = session.x_auth_token
|
459
100
|
|
460
101
|
# Make the request
|
461
102
|
begin
|
@@ -469,7 +110,7 @@ module IDRAC
|
|
469
110
|
puts "Session expired or invalid, attempting to create a new session...".light_yellow
|
470
111
|
|
471
112
|
# Try to create a new session
|
472
|
-
if
|
113
|
+
if session.create
|
473
114
|
puts "Successfully created a new session, retrying request...".green
|
474
115
|
return authenticated_request(method, path, options, retry_count + 1)
|
475
116
|
else
|
@@ -484,7 +125,7 @@ module IDRAC
|
|
484
125
|
puts "Error during authenticated request (token mode): #{e.message}".red.bold
|
485
126
|
|
486
127
|
# Try to create a new session
|
487
|
-
if
|
128
|
+
if session.create
|
488
129
|
puts "Successfully created a new session after error, retrying request...".green
|
489
130
|
return authenticated_request(method, path, options, retry_count + 1)
|
490
131
|
else
|
@@ -495,7 +136,7 @@ module IDRAC
|
|
495
136
|
end
|
496
137
|
else
|
497
138
|
# If we don't have a token, try to create a session
|
498
|
-
if
|
139
|
+
if session.create
|
499
140
|
puts "Successfully created a new session, making request...".green
|
500
141
|
return authenticated_request(method, path, options, retry_count + 1)
|
501
142
|
else
|
@@ -509,8 +150,8 @@ module IDRAC
|
|
509
150
|
|
510
151
|
def get(path:, headers: {})
|
511
152
|
# For screenshot functionality, we need to use the WebUI cookies
|
512
|
-
if
|
513
|
-
|
153
|
+
if web.cookies.nil? && path.include?('screen/screen.jpg')
|
154
|
+
web.login unless web.session_id
|
514
155
|
end
|
515
156
|
|
516
157
|
headers_to_use = {
|
@@ -518,16 +159,16 @@ module IDRAC
|
|
518
159
|
"Accept-Encoding" => "deflate, gzip"
|
519
160
|
}
|
520
161
|
|
521
|
-
if
|
522
|
-
headers_to_use["Cookie"] =
|
162
|
+
if web.cookies
|
163
|
+
headers_to_use["Cookie"] = web.cookies
|
523
164
|
elsif @direct_mode
|
524
165
|
# In direct mode, use Basic Auth
|
525
166
|
headers_to_use["Authorization"] = "Basic #{Base64.strict_encode64("#{username}:#{password}")}"
|
526
|
-
elsif
|
527
|
-
headers_to_use["X-Auth-Token"] =
|
167
|
+
elsif session.x_auth_token
|
168
|
+
headers_to_use["X-Auth-Token"] = session.x_auth_token
|
528
169
|
end
|
529
170
|
|
530
|
-
|
171
|
+
HTTParty.get(
|
531
172
|
"#{base_url}/#{path}",
|
532
173
|
headers: headers_to_use.merge(headers),
|
533
174
|
verify: false
|
@@ -535,9 +176,7 @@ module IDRAC
|
|
535
176
|
end
|
536
177
|
|
537
178
|
def screenshot
|
538
|
-
|
539
|
-
screenshot_instance = Screenshot.new(self)
|
540
|
-
screenshot_instance.capture
|
179
|
+
web.capture_screenshot
|
541
180
|
end
|
542
181
|
|
543
182
|
def base_url
|