palo_alto 0.3.0 → 0.3.2

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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PaloAlto
4
- VERSION = '0.3.0'
4
+ VERSION = '0.3.2'
5
5
  end
data/lib/palo_alto.rb CHANGED
@@ -74,7 +74,6 @@ module PaloAlto
74
74
 
75
75
  module Helpers
76
76
  class Rest
77
- @http_clients = {} # will include [http_client, lock]
78
77
  @global_lock = Mutex.new
79
78
 
80
79
  def self.make_request(opts)
@@ -91,16 +90,18 @@ module PaloAlto
91
90
  options = options.merge(opts)
92
91
  options[:headers].merge!(headers)
93
92
 
94
- http_client = lock = nil
93
+ http_client = nil
95
94
 
96
95
  @global_lock.synchronize do
97
- unless (http_client, lock = @http_clients[options[:host]])
96
+ Thread.current[:http_clients] ||= {}
97
+
98
+ unless (http_client = Thread.current[:http_clients][options[:host]])
98
99
  http_client = Net::HTTP.new(options[:host], 443)
99
100
  http_client.use_ssl = true
100
101
  http_client.verify_mode = options[:verify_ssl]
101
102
  http_client.read_timeout = http_client.open_timeout = (options[:timeout] || 60)
102
103
  http_client.set_debug_output(options[:debug].include?(:http) ? $stdout : nil)
103
- @http_clients[options[:host]] = [http_client, (lock = Mutex.new)]
104
+ Thread.current[:http_clients][options[:host]] = http_client
104
105
  end
105
106
  end
106
107
 
@@ -114,11 +115,9 @@ module PaloAlto
114
115
  post_req.set_form_data(payload)
115
116
  end
116
117
 
117
- response = lock.synchronize do
118
- http_client.start unless http_client.started?
118
+ http_client.start unless http_client.started?
119
119
 
120
- http_client.request(post_req)
121
- end
120
+ response = http_client.request(post_req)
122
121
 
123
122
  case response.code
124
123
  when '200'
@@ -168,9 +167,41 @@ module PaloAlto
168
167
  end
169
168
 
170
169
  class XML
171
- attr_accessor :host, :username, :password, :auth_key, :verify_ssl, :debug, :timeout
170
+ attr_accessor :host, :username, :auth_key, :verify_ssl, :debug, :timeout
171
+
172
+ def pretty_print_instance_variables
173
+ super - [:@password, :@subclasses, :@subclasses, :@expression, :@arguments, :@cache]
174
+ end
175
+
176
+ def execute(payload, skip_authentication: false, skip_cache: false)
177
+ if !auth_key && !skip_authentication
178
+ get_auth_key
179
+ end
180
+
181
+ if payload[:type] == 'config' && !skip_cache
182
+ if payload[:action] == 'get'
183
+ start_time = Time.now
184
+ @cache.each do |cached_xpath, cache|
185
+ search_xpath = payload[:xpath].sub('/descendant::device-group[1]/', '/device-group/')
186
+ next unless search_xpath.start_with?(cached_xpath)
187
+
188
+ remove = cached_xpath.split('/')[1...-1].join('/').length
189
+ new_xpath = 'response/result/' + search_xpath[(remove+2)..]
190
+
191
+ results = cache.xpath(new_xpath)
192
+ xml = Nokogiri.parse("<?xml version=\"1.0\"?><response><result>#{results.to_s}</result></response>")
193
+
194
+ if debug.include?(:statistics)
195
+ warn "Elapsed for parsing cache: #{Time.now - start_time} seconds"
196
+ end
197
+
198
+ return xml
199
+ end
200
+ elsif !@keep_cache_on_edit
201
+ @cache = {}
202
+ end
203
+ end
172
204
 
173
- def execute(payload)
174
205
  retried = false
175
206
  begin
176
207
  # configure options for the request
@@ -191,14 +222,14 @@ module PaloAlto
191
222
  start_time = Time.now
192
223
  text = Helpers::Rest.make_request(options)
193
224
  if debug.include?(:statistics)
194
- warn "Elapsed for API call #{payload[:type]}/#{payload[:action] || '(unknown action)'}: #{Time.now - start_time} seconds"
225
+ warn "Elapsed for API call #{payload[:type]}/#{payload[:action] || '(unknown action)'} on #{host}: #{Time.now - start_time} seconds, #{text.length} bytes"
195
226
  end
196
227
 
197
228
  warn "received: #{Time.now}\n#{text}\n" if debug.include?(:received)
198
229
 
199
230
  data = Nokogiri::XML.parse(text)
200
231
  unless data.xpath('//response/@status').to_s == 'success'
201
- warn 'command failed'
232
+ warn "command failed on host #{host} at #{Time.now}"
202
233
  warn "sent:\n#{options.inspect}\n" if debug.include?(:sent_on_error)
203
234
  warn "received:\n#{text.inspect}\n" if debug.include?(:received_on_error)
204
235
  code = data.at_xpath('//response/@code')&.value.to_i # sometimes there is no code :( e.g. for 'op' errors
@@ -224,16 +255,45 @@ module PaloAlto
224
255
  end
225
256
  end
226
257
 
227
- def commit!(all: false, device_groups: nil, templates: nil, wait_for_completion: true, wait: 5, timeout: 480)
258
+ def clear_cache!
259
+ @cache = {}
260
+ @keep_cache_on_edit = nil
261
+ end
262
+
263
+ def keep_cache_on_edit!
264
+ @keep_cache_on_edit = true
265
+ end
266
+
267
+ def cache!(xpath)
268
+ cached_xpath = xpath.is_a?(String) ? xpath : xpath.to_xpath
269
+
270
+ payload = {
271
+ type: 'config',
272
+ action: 'get',
273
+ xpath: cached_xpath
274
+ }
275
+
276
+ @cache[cached_xpath] = execute(payload, skip_cache: true)
277
+ true
278
+ end
279
+
280
+ def commit!(all: false, device_groups: nil, templates: nil,
281
+ admins: [username],
282
+ raw_result: false,
283
+ wait_for_completion: true, wait: 5, timeout: 480)
228
284
  return nil if device_groups.is_a?(Array) && device_groups.empty? && templates.is_a?(Array) && templates.empty?
229
285
 
230
286
  cmd = if all
231
287
  'commit'
232
288
  else
233
289
  { commit: { partial: [
234
- { 'admin': [username] },
235
- device_groups ? ( device_groups.empty? ? 'no-device-group' : { 'device-group': device_groups } ) : nil,
236
- templates ? ( templates.empty? ? 'no-template' : { 'template': templates } ) : nil,
290
+ { 'admin': admins },
291
+ if device_groups
292
+ device_groups.empty? ? 'no-device-group' : { 'device-group': device_groups }
293
+ end,
294
+ if templates
295
+ templates.empty? ? 'no-template' : { 'template': templates }
296
+ end,
237
297
  'no-template-stack',
238
298
  'no-log-collector',
239
299
  'no-log-collector-group',
@@ -245,8 +305,9 @@ module PaloAlto
245
305
  end
246
306
  result = op.execute(cmd)
247
307
 
248
- job_id = result.at_xpath('response/result/job')&.text
308
+ return result if raw_result
249
309
 
310
+ job_id = result.at_xpath('response/result/job')&.text
250
311
  return result unless job_id && wait_for_completion
251
312
 
252
313
  wait_for_job_completion(job_id, wait: wait, timeout: timeout) if job_id
@@ -283,6 +344,7 @@ module PaloAlto
283
344
 
284
345
  # will execute block if given and unlock afterwards. returns false if lock could not be aquired
285
346
  def lock(area:, comment: nil, type: nil, location: nil)
347
+ raise MalformedCommandException, 'No type specified' if location && !type
286
348
  if block_given?
287
349
  return false unless lock(area: area, comment: comment, type: type, location: location)
288
350
 
@@ -298,7 +360,8 @@ module PaloAlto
298
360
  op.execute(cmd, type: type, location: location)
299
361
  true
300
362
  rescue PaloAlto::InternalErrorException => e
301
- return true if e.message.start_with?('You already own a config lock for scope ')
363
+ return true if e.message.start_with?('You already own a config lock for scope ') ||
364
+ e.message == "Config for scope shared is currently locked by #{username}"
302
365
 
303
366
  false
304
367
  end
@@ -326,9 +389,9 @@ module PaloAlto
326
389
  end
327
390
  end
328
391
 
329
- def check_for_changes(usernames: [username])
330
- cmd = if usernames
331
- { show: { config: { list: { 'change-summary': { partial: { admin: usernames } } } } } }
392
+ def check_for_changes(admins: [username])
393
+ cmd = if admins
394
+ { show: { config: { list: { 'change-summary': { partial: { admin: admins } } } } } }
332
395
  else
333
396
  { show: { config: { list: 'change-summary' } } }
334
397
  end
@@ -339,14 +402,53 @@ module PaloAlto
339
402
  }
340
403
  end
341
404
 
342
- def wait_for_job_completion(job_id, wait: 5, timeout: 480)
405
+ # returns nil if job isn't finished yet, otherwise the job result
406
+ def query_and_parse_job(job_id)
407
+ cmd = { show: { jobs: { id: job_id } } }
408
+ result = op.execute(cmd)
409
+ status = result.at_xpath('response/result/job/status')&.text
410
+ return result unless %w[ACT PEND].include?(status)
411
+
412
+ nil
413
+ rescue => e
414
+ warn [:job_query_error, e].inspect
415
+ false
416
+ end
417
+
418
+ # returns true if successful
419
+ # returns nil if not completed yet
420
+ # otherwise returns the error
421
+ def commit_successful?(commit_result)
422
+ if commit_result.at_xpath('response/msg')&.text&.start_with?('The result of this commit would be the same as the previous commit queued/processed') ||
423
+ commit_result.at_xpath('response/msg')&.text == 'There are no changes to commit.'
424
+ return true
425
+ end
426
+
427
+ job_id = commit_result.at_xpath('response/result/job')&.text
428
+ unless job_id
429
+ warn [:no_job_id, result].inspect
430
+ return false
431
+ end
432
+
433
+ job_result = query_and_parse_job(job_id)
434
+ return job_result if !job_result # can be either nil or false (errored)
435
+
436
+ return true if job_result.xpath('response/result/job/details/line').text&.include?('Configuration committed successfully')
437
+
438
+ job_result
439
+ end
440
+
441
+ def wait_for_job_completion(job_id, wait: 5, timeout: 600)
343
442
  cmd = { show: { jobs: { id: job_id } } }
344
443
  start = Time.now
345
444
  loop do
346
- result = op.execute(cmd)
347
- status = result.at_xpath('response/result/job/status')&.text
348
- return result unless %w[ACT PEND].include?(status)
349
-
445
+ begin
446
+ result = op.execute(cmd)
447
+ status = result.at_xpath('response/result/job/status')&.text
448
+ return result unless %w[ACT PEND].include?(status)
449
+ rescue => e
450
+ warn [:job_query_error, e].inspect
451
+ end
350
452
  sleep wait
351
453
  break unless start + timeout > Time.now
352
454
  end
@@ -384,20 +486,19 @@ module PaloAlto
384
486
  end
385
487
 
386
488
  def initialize(host:, username:, password:, verify_ssl: OpenSSL::SSL::VERIFY_NONE, debug: [])
387
- self.host = host
388
- self.username = username
389
- self.password = password
390
- self.verify_ssl = verify_ssl
391
- self.debug = debug
489
+ @host = host
490
+ @username = username
491
+ @password = password
492
+ @verify_ssl = verify_ssl
493
+ @debug = debug
392
494
 
393
495
  @subclasses = {}
394
496
 
497
+ @cache = {}
498
+
395
499
  # xpath
396
500
  @expression = :root
397
501
  @arguments = [Expression.new(:this_node), []]
398
-
399
- # attempt to obtain the auth_key
400
- get_auth_key
401
502
  end
402
503
 
403
504
  # Perform a query to the API endpoint for an auth_key based on the credentials provided
@@ -405,10 +506,10 @@ module PaloAlto
405
506
  # establish the required options for the key request
406
507
  payload = { type: 'keygen',
407
508
  user: username,
408
- password: password }
509
+ password: @password }
409
510
 
410
511
  # get and parse the response for the key
411
- xml_data = execute(payload)
512
+ xml_data = execute(payload, skip_authentication: true)
412
513
  self.auth_key = xml_data.xpath('//response/result/key')[0].content
413
514
  end
414
515
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: palo_alto
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Roesner
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-08-09 00:00:00.000000000 Z
11
+ date: 2023-01-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri