palo_alto 0.5.0 → 0.6.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/op.rb CHANGED
@@ -75,63 +75,84 @@ module PaloAlto
75
75
  end
76
76
  end
77
77
 
78
- def xml_builder(xml, ops, obj)
79
- case obj
78
+ def xml_builder_iter(xml, ops, data)
79
+ raise 'No Ops?!' if ops.nil?
80
+
81
+ case data
80
82
  when String
81
- section = obj
82
- data = nil
83
- when Hash
84
- section = obj.keys.first
85
- data = obj[section]
83
+ section = data
84
+ data2 = nil
85
+ xml_builder(xml, ops, section, data2)
86
+ when Hash, Array
87
+ data.each do |section, data2|
88
+ xml_builder(xml, ops, section, data2)
89
+ end
86
90
  else
87
- raise obj.pretty_inspect
88
- end
89
-
90
- unless ops.key?(section.to_s)
91
- err = "Error #{section} does not exist. Valid: " + ops.keys.pretty_inspect
92
- raise err
91
+ raise data.pretty_inspect
93
92
  end
93
+ end
94
94
 
95
- ops_tree = ops[section.to_s]
96
-
97
- section = escape_xpath_tag(section)
95
+ def xml_builder(xml, ops, section, data, type = ops[section.to_s]&.[](:obj))
96
+ ops_tree = ops[section.to_s] || raise("no ops tree for section #{section}, #{ops.keys.inspect}")
97
+ # pp [:xml_builder, :section, section, :type, type]
98
+ # obj = data
98
99
 
99
- case ops_tree[:obj]
100
+ case type
100
101
  when :element
101
- xml.public_send(section, data)
102
+ xml.public_send(escape_xpath_tag(section), data)
102
103
  when :array
103
- xml.public_send(section) do
104
+ xml.public_send(escape_xpath_tag(section)) do
105
+ raise 'data is Hash and should be Array' if data.is_a?(Hash)
106
+
104
107
  data.each do |el|
105
108
  key = ops_tree.keys.first
106
- xml.public_send(escape_xpath_tag(key), el)
109
+ case el
110
+ when Hash
111
+ attr = ops_tree[key].find { |_k, v| v.is_a?(Hash) && v[:obj] == :'attr-req' }.first
112
+ xml.public_send(escape_xpath_tag(key), { attr => el[attr.to_sym] }) do
113
+ remaining_attrs = el.reject { |k, _v| k == attr.to_sym }
114
+
115
+ if remaining_attrs.any?
116
+ xml_builder(xml, ops_tree[key], remaining_attrs.keys.first.to_s, remaining_attrs.values.first,
117
+ :array)
118
+ end
119
+ end
120
+ when String
121
+ xml.public_send(key, el)
122
+ end
107
123
  end
108
124
  end
109
125
  when :sequence
110
- if data.nil?
111
- xml.send(section)
126
+ if data.nil? || data == true
127
+ xml.send(escape_xpath_tag(section))
112
128
  elsif data.is_a?(Hash)
113
- xml.send(section) do
114
- xml_builder(xml, ops_tree, data)
129
+ xml.send(escape_xpath_tag(section)) do
130
+ xml_builder_iter(xml, ops_tree, data)
115
131
  end
116
- else # array
132
+ else # array, what else could it be?!
133
+ raise "Unknown: #{attr.inspect}" unless data.is_a?(Array)
117
134
 
118
- if data.is_a?(Array)
119
- attr = data.find { |child| child.is_a?(Hash) && ops_tree[child.keys.first.to_s][:obj] == :'attr-req' }
120
- data.delete(attr)
121
- else
122
- attr = {}
123
- end
135
+ raise 'Too many hashes in an array, please update' if data.length > 1
136
+
137
+ key = ops_tree.keys.first
138
+ attr_name = ops_tree[key].find { |_k, v| v.is_a?(Hash) && v[:obj] == :'attr-req' }.first
139
+
140
+ hash = data.first.dup
141
+
142
+ data = [hash.reject { |k| k == attr_name.to_sym }]
143
+ attr = { attr_name => hash[attr_name.to_sym] }
124
144
 
125
- xml.public_send(section, attr) do
126
- data.each do |child|
127
- xml_builder(xml, ops_tree, child)
145
+ xml.public_send(escape_xpath_tag(section)) do
146
+ xml.public_send(escape_xpath_tag(key), attr) do
147
+ data.each do |child|
148
+ xml_builder_iter(xml, ops_tree[key], child)
149
+ end
128
150
  end
129
151
  end
130
152
  end
131
153
  when :union
132
- k, v = obj.first
133
- xml.send("#{k}_") do
134
- xml_builder(xml, ops_tree, v)
154
+ xml.public_send(escape_xpath_tag(section)) do
155
+ xml_builder_iter(xml, ops[section.to_s], data)
135
156
  end
136
157
  else
137
158
  raise ops_tree[:obj].pretty_inspect
@@ -141,7 +162,7 @@ module PaloAlto
141
162
 
142
163
  def to_xml(obj)
143
164
  builder = Nokogiri::XML::Builder.new do |xml|
144
- xml_builder(xml, @@ops, obj)
165
+ xml_builder_iter(xml, @@ops, obj)
145
166
  end
146
167
  builder.doc.root.to_xml
147
168
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PaloAlto
4
- VERSION = '0.5.0'
4
+ VERSION = '0.6.0'
5
5
  end
data/lib/palo_alto.rb CHANGED
@@ -3,7 +3,6 @@
3
3
  require 'openssl'
4
4
  require 'nokogiri'
5
5
  require 'net/http'
6
- require 'pp'
7
6
 
8
7
  require_relative 'palo_alto/version'
9
8
 
@@ -129,6 +128,7 @@ module PaloAlto
129
128
  raise SessionTimedOutException
130
129
  when '400', '403'
131
130
  begin
131
+ pp [:error, options[:host], response.code, response.message]
132
132
  data = Nokogiri::XML.parse(response.body)
133
133
  message = data.xpath('//response/response/msg').text
134
134
  code = response.code.to_i
@@ -141,8 +141,8 @@ module PaloAlto
141
141
  end
142
142
 
143
143
  nil
144
- rescue Net::OpenTimeout, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
145
- raise ConnectionErrorException, e.message
144
+ rescue Net::OpenTimeout, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::ECONNRESET => e
145
+ raise ConnectionErrorException, [e.message, options[:host]].inspect
146
146
  end
147
147
 
148
148
  def self.raise_error(code, message)
@@ -175,13 +175,43 @@ module PaloAlto
175
175
  attr_accessor :host, :username, :auth_key, :verify_ssl, :debug, :timeout
176
176
 
177
177
  def pretty_print_instance_variables
178
- super - [:@password, :@subclasses, :@subclasses, :@expression, :@arguments, :@cache, :@op, :@auth_key]
178
+ super - %i[@password @subclasses @subclasses @expression @arguments @cache @op @auth_key]
179
179
  end
180
180
 
181
- def execute(payload, skip_authentication: false, skip_cache: false)
182
- if !auth_key && !skip_authentication
183
- get_auth_key
181
+ @@output_lock = Mutex.new
182
+
183
+ def print_sent(options, method = :puts)
184
+ @@output_lock.synchronize do
185
+ send(method, "Sent (#{Time.now}, #{options[:session_id]}):")
186
+ options.each do |k, v|
187
+ case k
188
+ when :debug
189
+ send(method, " #{k}: #{v.reject { |str| str.start_with?('_') }.join(', ')}")
190
+ when :headers
191
+ headers = options[:headers].dup.transform_keys(&:to_s)
192
+ headers['X-PAN-KEY'] = '***' if headers.key?('X-PAN-KEY')
193
+ puts " #{k}: #{headers}"
194
+ when :payload
195
+ send(method, ' payload: ' + options[:payload].map do |k, v|
196
+ element_length = 1024
197
+ if k == :element && v.length >= element_length
198
+ [k.to_s, "#{v[..element_length]}..."]
199
+ elsif k == :password
200
+ [k.to_s, '***']
201
+ else
202
+ [k.to_s, v]
203
+ end
204
+ end.to_h.inspect)
205
+ else
206
+ send(method, " #{k}: #{v}")
207
+ end
208
+ end
184
209
  end
210
+ end
211
+
212
+ def execute(payload, skip_authentication: false, skip_cache: false)
213
+ get_auth_key if !auth_key && !skip_authentication
214
+ session_id = (0...6).map { ('a'..'z').to_a[rand(26)] }.join
185
215
 
186
216
  if payload[:type] == 'config' && !skip_cache
187
217
  if payload[:action] == 'get'
@@ -191,13 +221,13 @@ module PaloAlto
191
221
  next unless search_xpath.start_with?(cached_xpath)
192
222
 
193
223
  remove = cached_xpath.split('/')[1...-1].join('/').length
194
- new_xpath = 'response/result/' + search_xpath[(remove+2)..]
224
+ new_xpath = "response/result/#{search_xpath[(remove + 2)..]}"
195
225
 
196
226
  results = cache.xpath(new_xpath)
197
- xml = Nokogiri.parse("<?xml version=\"1.0\"?><response><result>#{results.to_s}</result></response>")
227
+ xml = Nokogiri.parse("<?xml version=\"1.0\"?><response><result>#{results}</result></response>")
198
228
 
199
229
  if debug.include?(:statistics)
200
- warn "Elapsed for parsing cache: #{Time.now - start_time} seconds"
230
+ warn "Elapsed for parsing cache: #{Time.now - start_time} seconds (#{session_id})"
201
231
  end
202
232
 
203
233
  return xml
@@ -212,32 +242,40 @@ module PaloAlto
212
242
  # configure options for the request
213
243
  options = {}
214
244
  options[:host] = host
245
+ options[:session_id] = session_id
215
246
  options[:verify_ssl] = verify_ssl
216
247
  options[:payload] = payload
217
248
  options[:debug] = debug
218
- options[:timeout] = timeout || 180
249
+ options[:timeout] = timeout || 600
219
250
  options[:headers] = if payload[:type] == 'keygen'
220
251
  {}
221
252
  else
222
253
  { 'X-PAN-KEY': auth_key }
223
254
  end
224
255
 
225
- warn "sent: (#{Time.now}\n#{options.pretty_inspect}\n" if debug.include?(:sent)
256
+ print_sent(options) if debug.include?(:sent)
226
257
 
227
258
  start_time = Time.now
228
259
  text = Helpers::Rest.make_request(options)
229
- if debug.include?(:statistics)
230
- warn "Elapsed for API call #{payload[:type]}/#{payload[:action] || '(unknown action)'} on #{host}: #{Time.now - start_time} seconds, #{text.length} bytes"
231
- end
232
260
 
233
- warn "received: #{Time.now}\n#{text}\n" if debug.include?(:received)
261
+ @@output_lock.synchronize do
262
+ if debug.include?(:statistics)
263
+ warn "Elapsed for API call #{payload[:type]}/#{payload[:action] || '(unknown action)'} on #{host}: #{Time.now - start_time} seconds, #{text.length} bytes (#{session_id})"
264
+ end
265
+
266
+ warn "Received at #{Time.now} (#{session_id}):\n#{text.inspect}\n" if debug.include?(:received)
267
+ end
234
268
 
235
269
  data = Nokogiri::XML.parse(text)
236
270
  unless data.xpath('//response/@status').to_s == 'success'
237
271
  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)
272
+ warn "Command failed on host #{host} at #{Time.now} (#{session_id})"
273
+ print_sent(options, :warn) if debug.include?(:sent_on_error) && !debug.include?(:sent)
274
+ if debug.include?(:received_on_error) && !debug.include?(:received)
275
+ @@output_lock.synchronize do
276
+ warn "Received at #{Time.now} (#{session_id}):\n#{text.inspect}\n"
277
+ end
278
+ end
241
279
  end
242
280
  code = data.at_xpath('//response/@code')&.value.to_i # sometimes there is no code :( e.g. for 'op' errors
243
281
  message = data.xpath('/response/msg/line').map(&:text).map(&:strip).join("\n")
@@ -245,23 +283,35 @@ module PaloAlto
245
283
  end
246
284
 
247
285
  data
286
+ rescue ConnectionErrorException => e
287
+ # for ConnectionErrorException, you don't know if the command was successful as you don't get a result after an action started:(
288
+ # As it's a temporary error, we need to rescue/raise it explicitly
289
+ # #edit! rescues it, for other calls, it normally should not happen.....
290
+ raise e
248
291
  rescue EOFError, Net::ReadTimeout => e
249
292
  max_retries = if %w[keygen config].include?(payload[:type])
250
293
  # TODO: only retry on config, when it's get or edit, otherwise you may get strange errors
251
- 3
294
+ 40
252
295
  else
253
296
  0
254
297
  end
255
298
 
256
- raise e if retried >= max_retries
299
+ if retried >= max_retries
300
+ raise ConnectionErrorException, [e.message, options[:host], payload[:type]].inspect
301
+ end
257
302
 
258
303
  retried += 1
259
- warn "Got error #{e.inspect}; retrying (try #{retried})" if debug.include?(:warnings)
304
+ if debug.include?(:warnings)
305
+ @@output_lock.synchronize do
306
+ warn "Got connection error #{e.inspect} from #{host}; retrying (try #{retried} of #{max_retries}, #{session_id})"
307
+ end
308
+ end
260
309
  sleep 10
261
310
  retry
262
311
  rescue TemporaryException => e
263
312
  dont_retry_at = [
264
313
  'Partial revert is not allowed. Full system commit must be completed.',
314
+ 'Local commit jobs are queued. Revert operation is not allowed.',
265
315
  'Config for scope ',
266
316
  'Config is not currently locked for scope ',
267
317
  'Commit lock is not currently held by',
@@ -269,13 +319,14 @@ module PaloAlto
269
319
  'This operation is blocked because of ',
270
320
  'Other administrators are holding config locks ',
271
321
  'Configuration is locked by ',
322
+ 'device-group', # device-group -> ... is already in use
272
323
  ' device-group' # device-group -> ... is already in use
273
324
  ]
274
325
 
275
326
  max_retries = if dont_retry_at.any? { |str| e.message.start_with?(str) }
276
327
  0
277
328
  elsif e.message.start_with?('Timed out while getting config lock. Please try again.')
278
- 10
329
+ 40
279
330
  else
280
331
  1
281
332
  end
@@ -283,7 +334,11 @@ module PaloAlto
283
334
  raise e if retried >= max_retries
284
335
 
285
336
  retried += 1
286
- warn "Got error #{e.inspect}; retrying (try #{retried})" if debug.include?(:warnings)
337
+ if debug.include?(:warnings)
338
+ @@output_lock.synchronize do
339
+ warn "Got temporary error #{e.inspect}; retrying (try #{retried} of #{max_retries})"
340
+ end
341
+ end
287
342
 
288
343
  get_auth_key if e.is_a?(SessionTimedOutException)
289
344
  retry
@@ -321,23 +376,29 @@ module PaloAlto
321
376
  cmd = if all
322
377
  'commit'
323
378
  else
324
- { commit: { partial: [
325
- { 'admin': admins },
326
- if device_groups
327
- device_groups.empty? ? 'no-device-group' : { 'device-group': device_groups }
328
- end,
329
- if templates
330
- templates.empty? ? 'no-template' : { 'template': templates }
331
- end,
332
- 'no-template-stack',
333
- 'no-log-collector',
334
- 'no-log-collector-group',
335
- 'no-wildfire-appliance',
336
- 'no-wildfire-appliance-cluster',
337
- { 'device-and-network': 'excluded' },
338
- { 'shared-object': 'excluded' }
339
- ].compact } }
379
+ commit_partial = {
380
+ 'no-template-stack': true,
381
+ 'no-log-collector': true,
382
+ 'no-log-collector-group': true,
383
+ 'no-wildfire-appliance': true,
384
+ 'no-wildfire-appliance-cluster': true,
385
+ 'device-and-network': 'excluded',
386
+ 'shared-object': 'excluded'
387
+ }
388
+
389
+ if device_groups
390
+ commit_partial.merge!(device_groups.empty? ? { 'no-device-group': true } : { 'device-group': device_groups })
391
+ end
392
+
393
+ if templates
394
+ commit_partial.merge!(templates.empty? ? { 'no-template': true } : { template: templates })
395
+ end
396
+
397
+ commit_partial.merge!({ admin: admins }) if admins
398
+
399
+ { commit: { partial: commit_partial } }
340
400
  end
401
+
341
402
  result = op.execute(cmd)
342
403
 
343
404
  return result if raw_result
@@ -358,7 +419,7 @@ module PaloAlto
358
419
  def primary_active?
359
420
  cmd = { show: { 'high-availability': 'state' } }
360
421
  state = op.execute(cmd)
361
- state.at_xpath('response/result/local-info/state').text == 'primary-active'
422
+ state.at_xpath('response/result/local-info/state')&.text == 'primary-active'
362
423
  end
363
424
 
364
425
  # area: config, commit
@@ -380,6 +441,7 @@ module PaloAlto
380
441
  # will execute block if given and unlock afterwards. returns false if lock could not be aquired
381
442
  def lock(area:, comment: nil, type: nil, location: nil)
382
443
  raise MalformedCommandException, 'No type specified' if location && !type
444
+
383
445
  if block_given?
384
446
  return false unless lock(area: area, comment: comment, type: type, location: location)
385
447
 
@@ -448,7 +510,7 @@ module PaloAlto
448
510
  return result unless %w[ACT PEND].include?(status)
449
511
 
450
512
  nil
451
- rescue => e
513
+ rescue StandardError => e
452
514
  warn [:job_query_error, @host, e].inspect
453
515
  false
454
516
  end
@@ -469,9 +531,11 @@ module PaloAlto
469
531
  end
470
532
 
471
533
  job_result = query_and_parse_job(job_id)
472
- return job_result if !job_result # can be either nil or false (errored)
534
+ return job_result unless job_result # can be either nil or false (errored)
473
535
 
474
- return true if job_result.xpath('response/result/job/details/line').text&.include?('Configuration committed successfully')
536
+ if job_result.xpath('response/result/job/details/line').text&.include?('Configuration committed successfully')
537
+ return true
538
+ end
475
539
 
476
540
  job_result
477
541
  end
@@ -484,7 +548,7 @@ module PaloAlto
484
548
  result = op.execute(cmd)
485
549
  status = result.at_xpath('response/result/job/status')&.text
486
550
  return result unless %w[ACT PEND].include?(status)
487
- rescue => e
551
+ rescue StandardError => e
488
552
  warn [:job_query_error, Time.now, @host, e].inspect
489
553
  return false if e.message =~ /\Ajob \d+ not found\z/
490
554
  end
@@ -500,17 +564,17 @@ module PaloAlto
500
564
  cmd = if all
501
565
  { revert: 'config' }
502
566
  else
503
- { revert: { config: { partial: [
504
- { 'admin': [username] },
505
- 'no-template',
506
- 'no-template-stack',
507
- 'no-log-collector',
508
- 'no-log-collector-group',
509
- 'no-wildfire-appliance',
510
- 'no-wildfire-appliance-cluster',
511
- { 'device-and-network': 'excluded' },
512
- { 'shared-object': 'excluded' }
513
- ] } } }
567
+ { revert: { config: { partial: {
568
+ admin: [username],
569
+ 'no-template': true,
570
+ 'no-template-stack': true,
571
+ 'no-log-collector': true,
572
+ 'no-log-collector-group': true,
573
+ 'no-wildfire-appliance': true,
574
+ 'no-wildfire-appliance-cluster': true,
575
+ 'device-and-network': 'excluded',
576
+ 'shared-object': 'excluded'
577
+ } } } }
514
578
  end
515
579
 
516
580
  waited = 0
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.5.0
4
+ version: 0.6.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: 2023-11-28 00:00:00.000000000 Z
11
+ date: 2024-12-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri