palo_alto 0.3.1 → 0.4.0

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.
data/lib/palo_alto/log.rb CHANGED
@@ -12,7 +12,7 @@ module PaloAlto
12
12
  class Log < Enumerator
13
13
  def initialize(client:, query:, log_type:, nlogs: 20, dir: :backward, show_detail: false, days: 7) # rubocop:disable Metrics/MethodLength,Metrics/ParameterLists
14
14
  @client = client
15
- payload = {
15
+ @log_query_payload = {
16
16
  type: 'log',
17
17
  'log-type': log_type,
18
18
  nlogs: nlogs,
@@ -22,15 +22,22 @@ module PaloAlto
22
22
  }
23
23
 
24
24
  if days
25
- payload[:query] += " AND (receive_time geq '#{(Time.now - days * 3600 * 24).strftime('%Y/%m/%d %H:%M:%S')}')"
25
+ @log_query_payload[:query] += " AND (receive_time geq '#{(Time.now - days * 3600 * 24).strftime('%Y/%m/%d %H:%M:%S')}')"
26
26
  end
27
27
 
28
- result = @client.execute(payload)
28
+ run_query
29
+
30
+ @first_result = fetch_result
31
+ super
32
+ end
33
+
34
+ def run_query
35
+ result = @client.execute(@log_query_payload)
29
36
  @job_id = result.at_xpath('response/result/job').text
37
+ warn "#{@client.host} #{Time.now}: Got job id #{@job_id} for log query"
38
+
30
39
  @count = nil
31
40
  @skip = 0
32
- @first_result = fetch_result
33
- super
34
41
  end
35
42
 
36
43
  def restore_first
@@ -46,17 +53,24 @@ module PaloAlto
46
53
  def fetch_result # rubocop:disable Metrics/MethodLength
47
54
  return nil if @count && @skip == @count
48
55
 
49
- payload = {
50
- type: 'log',
51
- action: 'get',
52
- 'job-id': @job_id,
53
- skip: @skip
54
- }
55
-
56
56
  i = 0
57
57
  loop do
58
58
  sleep 0.5 if i.positive?
59
- @current_result = @client.execute(payload)
59
+ begin
60
+ payload = {
61
+ type: 'log',
62
+ action: 'get',
63
+ 'job-id': @job_id,
64
+ skip: @skip
65
+ }
66
+ @current_result = @client.execute(payload)
67
+ rescue PaloAlto::UnknownErrorException => e
68
+ if e.message == 'Query timed out'
69
+ warn 'Retrying log query'
70
+ run_query
71
+ retry
72
+ end
73
+ end
60
74
  i += 1
61
75
  break if @current_result.at_xpath('response/result/job/status').text == 'FIN'
62
76
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PaloAlto
4
- VERSION = '0.3.1'
4
+ VERSION = '0.4.0'
5
5
  end
data/lib/palo_alto.rb CHANGED
@@ -30,7 +30,7 @@ module PaloAlto
30
30
  class UnknownCommandException < PermanentException
31
31
  end
32
32
 
33
- class InternalErrorsException < TemporaryException
33
+ class UnknownErrorException < TemporaryException
34
34
  end
35
35
 
36
36
  class BadXpathException < PermanentException
@@ -39,6 +39,9 @@ module PaloAlto
39
39
  class ObjectNotPresentException < PermanentException
40
40
  end
41
41
 
42
+ class ObjectNotFoundException < PermanentException
43
+ end
44
+
42
45
  class ObjectNotUniqueException < PermanentException
43
46
  end
44
47
 
@@ -122,6 +125,8 @@ module PaloAlto
122
125
  case response.code
123
126
  when '200'
124
127
  return response.body
128
+ when '401'
129
+ raise SessionTimedOutException
125
130
  when '400', '403'
126
131
  begin
127
132
  data = Nokogiri::XML.parse(response.body)
@@ -136,7 +141,7 @@ module PaloAlto
136
141
  end
137
142
 
138
143
  nil
139
- rescue Net::OpenTimeout, Errno::ECONNREFUSED => e
144
+ rescue Net::OpenTimeout, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
140
145
  raise ConnectionErrorException, e.message
141
146
  end
142
147
 
@@ -145,13 +150,12 @@ module PaloAlto
145
150
  when 400 then BadRequestException
146
151
  when 403 then ForbiddenException
147
152
  when 1 then UnknownCommandException
148
- when 2..5 then InternalErrorsException
149
153
  when 6 then BadXpathException
150
154
  when 7 then ObjectNotPresentException
151
155
  when 8 then ObjectNotUniqueException
152
156
  when 10 then ReferenceCountNotZeroException
153
- when 0, 11, 21 then InternalErrorException # also if there is no code..
154
157
  when 12 then InvalidObjectException
158
+ when 13 then ObjectNotFoundException
155
159
  when 14 then OperationNotPossibleException
156
160
  when 15 then OperationDeniedException
157
161
  when 16 then UnauthorizedException
@@ -159,7 +163,8 @@ module PaloAlto
159
163
  when 18 then MalformedCommandException
160
164
  when 19..20 then SuccessException
161
165
  when 22 then SessionTimedOutException
162
- else InternalErrorException
166
+ when 2..5, 11, 21 then InternalErrorException
167
+ else UnknownErrorException
163
168
  end
164
169
  raise error, message
165
170
  end
@@ -170,15 +175,39 @@ module PaloAlto
170
175
  attr_accessor :host, :username, :auth_key, :verify_ssl, :debug, :timeout
171
176
 
172
177
  def pretty_print_instance_variables
173
- super - [:@password, :@subclasses, :@subclasses, :@expression, :@arguments]
178
+ super - [:@password, :@subclasses, :@subclasses, :@expression, :@arguments, :@cache, :@op, :@auth_key]
174
179
  end
175
180
 
176
- def execute(payload, skip_authentication: false)
181
+ def execute(payload, skip_authentication: false, skip_cache: false)
177
182
  if !auth_key && !skip_authentication
178
183
  get_auth_key
179
184
  end
180
185
 
181
- retried = false
186
+ if payload[:type] == 'config' && !skip_cache
187
+ if payload[:action] == 'get'
188
+ start_time = Time.now
189
+ @cache.each do |cached_xpath, cache|
190
+ search_xpath = payload[:xpath].sub('/descendant::device-group[1]/', '/device-group/')
191
+ next unless search_xpath.start_with?(cached_xpath)
192
+
193
+ remove = cached_xpath.split('/')[1...-1].join('/').length
194
+ new_xpath = 'response/result/' + search_xpath[(remove+2)..]
195
+
196
+ results = cache.xpath(new_xpath)
197
+ xml = Nokogiri.parse("<?xml version=\"1.0\"?><response><result>#{results.to_s}</result></response>")
198
+
199
+ if debug.include?(:statistics)
200
+ warn "Elapsed for parsing cache: #{Time.now - start_time} seconds"
201
+ end
202
+
203
+ return xml
204
+ end
205
+ elsif !@keep_cache_on_edit
206
+ @cache = {}
207
+ end
208
+ end
209
+
210
+ retried = 0
182
211
  begin
183
212
  # configure options for the request
184
213
  options = {}
@@ -198,47 +227,102 @@ module PaloAlto
198
227
  start_time = Time.now
199
228
  text = Helpers::Rest.make_request(options)
200
229
  if debug.include?(:statistics)
201
- warn "Elapsed for API call #{payload[:type]}/#{payload[:action] || '(unknown action)'} on #{host}: #{Time.now - start_time} seconds"
230
+ warn "Elapsed for API call #{payload[:type]}/#{payload[:action] || '(unknown action)'} on #{host}: #{Time.now - start_time} seconds, #{text.length} bytes"
202
231
  end
203
232
 
204
233
  warn "received: #{Time.now}\n#{text}\n" if debug.include?(:received)
205
234
 
206
235
  data = Nokogiri::XML.parse(text)
207
236
  unless data.xpath('//response/@status').to_s == 'success'
208
- warn "command failed on host #{host} at #{Time.now}"
209
- warn "sent:\n#{options.inspect}\n" if debug.include?(:sent_on_error)
210
- warn "received:\n#{text.inspect}\n" if debug.include?(:received_on_error)
237
+ unless %w[op commit].include?(payload[:type]) # here we fail silent
238
+ warn "command failed on host #{host} at #{Time.now}"
239
+ warn "sent:\n#{options.inspect}\n" if debug.include?(:sent_on_error)
240
+ warn "received:\n#{text.inspect}\n" if debug.include?(:received_on_error)
241
+ end
211
242
  code = data.at_xpath('//response/@code')&.value.to_i # sometimes there is no code :( e.g. for 'op' errors
212
243
  message = data.xpath('/response/msg/line').map(&:text).map(&:strip).join("\n")
213
244
  Helpers::Rest.raise_error(code, message)
214
245
  end
215
246
 
216
247
  data
248
+ rescue EOFError, Net::ReadTimeout => e
249
+ max_retries = if %w[keygen config].include?(payload[:type])
250
+ # TODO: only retry on config, when it's get or edit, otherwise you may get strange errors
251
+ 3
252
+ else
253
+ 0
254
+ end
255
+
256
+ raise e if retried >= max_retries
257
+
258
+ retried += 1
259
+ warn "Got error #{e.inspect}; retrying (try #{retried})" if debug.include?(:warnings)
260
+ sleep 10
261
+ retry
217
262
  rescue TemporaryException => e
218
263
  dont_retry_at = [
219
264
  'Partial revert is not allowed. Full system commit must be completed.',
220
265
  'Config for scope ',
221
266
  'Config is not currently locked for scope ',
222
267
  'Commit lock is not currently held by',
223
- 'You already own a config lock for scope '
268
+ 'You already own a config lock for scope ',
269
+ 'This operation is blocked because of ',
270
+ 'Other administrators are holding config locks ',
271
+ 'Configuration is locked by ',
272
+ ' device-group' # device-group -> ... is already in use
224
273
  ]
225
- raise e if retried || dont_retry_at.any? { |x| e.message.start_with?(x) }
226
274
 
227
- warn "Got error #{e.inspect}; retrying" if debug.include?(:warnings)
228
- retried = true
275
+ max_retries = if dont_retry_at.any? { |str| e.message.start_with?(str) }
276
+ 0
277
+ elsif e.message.start_with?('Timed out while getting config lock. Please try again.')
278
+ 10
279
+ else
280
+ 1
281
+ end
282
+
283
+ raise e if retried >= max_retries
284
+
285
+ retried += 1
286
+ warn "Got error #{e.inspect}; retrying (try #{retried})" if debug.include?(:warnings)
287
+
229
288
  get_auth_key if e.is_a?(SessionTimedOutException)
230
289
  retry
231
290
  end
232
291
  end
233
292
 
234
- def commit!(all: false, device_groups: nil, templates: nil, wait_for_completion: true, wait: 5, timeout: 480)
293
+ def clear_cache!
294
+ @cache = {}
295
+ @keep_cache_on_edit = nil
296
+ end
297
+
298
+ def keep_cache_on_edit!
299
+ @keep_cache_on_edit = true
300
+ end
301
+
302
+ def cache!(xpath)
303
+ cached_xpath = xpath.is_a?(String) ? xpath : xpath.to_xpath
304
+
305
+ payload = {
306
+ type: 'config',
307
+ action: 'get',
308
+ xpath: cached_xpath
309
+ }
310
+
311
+ @cache[cached_xpath] = execute(payload, skip_cache: true)
312
+ true
313
+ end
314
+
315
+ def commit!(all: false, device_groups: nil, templates: nil,
316
+ admins: [username],
317
+ raw_result: false,
318
+ wait_for_completion: true, wait: 5, timeout: 60 * 20)
235
319
  return nil if device_groups.is_a?(Array) && device_groups.empty? && templates.is_a?(Array) && templates.empty?
236
320
 
237
321
  cmd = if all
238
322
  'commit'
239
323
  else
240
324
  { commit: { partial: [
241
- { 'admin': [username] },
325
+ { 'admin': admins },
242
326
  if device_groups
243
327
  device_groups.empty? ? 'no-device-group' : { 'device-group': device_groups }
244
328
  end,
@@ -256,8 +340,9 @@ module PaloAlto
256
340
  end
257
341
  result = op.execute(cmd)
258
342
 
259
- job_id = result.at_xpath('response/result/job')&.text
343
+ return result if raw_result
260
344
 
345
+ job_id = result.at_xpath('response/result/job')&.text
261
346
  return result unless job_id && wait_for_completion
262
347
 
263
348
  wait_for_job_completion(job_id, wait: wait, timeout: timeout) if job_id
@@ -294,6 +379,7 @@ module PaloAlto
294
379
 
295
380
  # will execute block if given and unlock afterwards. returns false if lock could not be aquired
296
381
  def lock(area:, comment: nil, type: nil, location: nil)
382
+ raise MalformedCommandException, 'No type specified' if location && !type
297
383
  if block_given?
298
384
  return false unless lock(area: area, comment: comment, type: type, location: location)
299
385
 
@@ -308,8 +394,10 @@ module PaloAlto
308
394
  cmd = { request: { "#{area}-lock": { add: { comment: comment || '(null)' } } } }
309
395
  op.execute(cmd, type: type, location: location)
310
396
  true
311
- rescue PaloAlto::InternalErrorException => e
312
- return true if e.message.start_with?('You already own a config lock for scope ')
397
+ rescue PaloAlto::UnknownErrorException => e
398
+ return true if e.message.start_with?('You already own a config lock for scope ') ||
399
+ e.message == "Config for scope shared is currently locked by #{username}" ||
400
+ e.message == "Config for scope #{location} is currently locked by #{username}"
313
401
 
314
402
  false
315
403
  end
@@ -323,7 +411,9 @@ module PaloAlto
323
411
  { request: { "#{area}-lock": 'remove' } }
324
412
  end
325
413
  op.execute(cmd, type: type, location: location)
326
- rescue PaloAlto::InternalErrorException
414
+ rescue PaloAlto::UnknownErrorException => e
415
+ return true if e.message.start_with?('Config is not currently locked')
416
+
327
417
  return false
328
418
  end
329
419
  true
@@ -337,9 +427,9 @@ module PaloAlto
337
427
  end
338
428
  end
339
429
 
340
- def check_for_changes(usernames: [username])
341
- cmd = if usernames
342
- { show: { config: { list: { 'change-summary': { partial: { admin: usernames } } } } } }
430
+ def check_for_changes(admins: [username])
431
+ cmd = if admins
432
+ { show: { config: { list: { 'change-summary': { partial: { admin: admins } } } } } }
343
433
  else
344
434
  { show: { config: { list: 'change-summary' } } }
345
435
  end
@@ -350,7 +440,43 @@ module PaloAlto
350
440
  }
351
441
  end
352
442
 
353
- def wait_for_job_completion(job_id, wait: 5, timeout: 600)
443
+ # returns nil if job isn't finished yet, otherwise the job result
444
+ def query_and_parse_job(job_id)
445
+ cmd = { show: { jobs: { id: job_id } } }
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
+
450
+ nil
451
+ rescue => e
452
+ warn [:job_query_error, @host, e].inspect
453
+ false
454
+ end
455
+
456
+ # returns true if successful
457
+ # returns nil if not completed yet
458
+ # otherwise returns the error
459
+ def commit_successful?(commit_result)
460
+ 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') ||
461
+ commit_result.at_xpath('response/msg')&.text == 'There are no changes to commit.'
462
+ return true
463
+ end
464
+
465
+ job_id = commit_result.at_xpath('response/result/job')&.text
466
+ unless job_id
467
+ warn [:no_job_id, result].inspect
468
+ return false
469
+ end
470
+
471
+ job_result = query_and_parse_job(job_id)
472
+ return job_result if !job_result # can be either nil or false (errored)
473
+
474
+ return true if job_result.xpath('response/result/job/details/line').text&.include?('Configuration committed successfully')
475
+
476
+ job_result
477
+ end
478
+
479
+ def wait_for_job_completion(job_id, wait: 5, timeout: 20 * 60)
354
480
  cmd = { show: { jobs: { id: job_id } } }
355
481
  start = Time.now
356
482
  loop do
@@ -359,11 +485,13 @@ module PaloAlto
359
485
  status = result.at_xpath('response/result/job/status')&.text
360
486
  return result unless %w[ACT PEND].include?(status)
361
487
  rescue => e
362
- warn [:job_query_error, e].inspect
488
+ warn [:job_query_error, Time.now, @host, e].inspect
489
+ return false if e.message =~ /\Ajob \d+ not found\z/
363
490
  end
364
491
  sleep wait
365
492
  break unless start + timeout > Time.now
366
493
  end
494
+ warn [:job_query_error, Time.now, @host, :timeout].inspect
367
495
  false
368
496
  end
369
497
 
@@ -406,6 +534,8 @@ module PaloAlto
406
534
 
407
535
  @subclasses = {}
408
536
 
537
+ @cache = {}
538
+
409
539
  # xpath
410
540
  @expression = :root
411
541
  @arguments = [Expression.new(:this_node), []]
data/palo_alto.gemspec CHANGED
@@ -10,7 +10,7 @@ Gem::Specification.new do |spec|
10
10
 
11
11
  spec.summary = 'Palo Alto API for Ruby'
12
12
  spec.homepage = 'https://github.com/Sebbb/'
13
- spec.license = 'artistic-2.0'
13
+ spec.license = 'Artistic-2.0'
14
14
  spec.required_ruby_version = '>= 2.7.0'
15
15
 
16
16
  spec.metadata['homepage_uri'] = spec.homepage
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.1
4
+ version: 0.4.0
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-09-09 00:00:00.000000000 Z
11
+ date: 2023-10-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri
@@ -52,7 +52,7 @@ files:
52
52
  - palo_alto.gemspec
53
53
  homepage: https://github.com/Sebbb/
54
54
  licenses:
55
- - artistic-2.0
55
+ - Artistic-2.0
56
56
  metadata:
57
57
  homepage_uri: https://github.com/Sebbb/
58
58
  source_code_uri: https://github.com/Sebbb/palo_alto/