Skip to content

Commit 4609f77

Browse files
committed
(FM-7602) Implement the panos Transport
1 parent e216f86 commit 4609f77

File tree

5 files changed

+744
-686
lines changed

5 files changed

+744
-686
lines changed

lib/puppet/transport/panos.rb

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
require 'net/http'
2+
require 'openssl'
3+
require 'puppet/util/network_device/simple/device'
4+
require 'rexml/document'
5+
require 'securerandom'
6+
require 'cgi'
7+
8+
module Puppet::Transport
9+
# The main connection class to a PAN-OS API endpoint
10+
class Panos
11+
def self.validate_config(config)
12+
raise Puppet::ResourceError, 'Could not find host or address in the configuration' unless config.key?('host') || config.key?('address')
13+
raise Puppet::ResourceError, 'The port attribute in the configuration is not an integer' if config.key?('port') && config['port'] !~ %r{\A[0-9]+\Z}
14+
raise Puppet::ResourceError, 'Could not find user/password or apikey in the configuration' unless ((config.key?('user') || config.key?('username')) && config.key?('password')) || config.key?('apikey') # rubocop:disable Metrics/LineLength
15+
raise Puppet::ResourceError, 'User and username are mutually exclusive' if config.key?('user') && config.key?('username')
16+
raise Puppet::ResourceError, 'Host and address are mutually exclusive' if config.key?('host') && config.key?('address')
17+
config
18+
end
19+
20+
attr_reader :config
21+
22+
def initialize(config)
23+
@config = self.class.validate_config(config)
24+
end
25+
26+
def facts
27+
@facts ||= parse_device_facts(fetch_device_facts)
28+
end
29+
30+
def fetch_device_facts
31+
Puppet.debug('Retrieving PANOS Device Facts')
32+
# https://<firewall>/api/?key=apikey&type=version
33+
api.request('version')
34+
end
35+
36+
def parse_device_facts(response)
37+
facts = {}
38+
39+
model = response.elements['/response/result/model'].text
40+
version = response.elements['/response/result/sw-version'].text
41+
vsys = response.elements['/response/result/multi-vsys'].text
42+
43+
facts['operatingsystem'] = model if model
44+
facts['operatingsystemrelease'] = version if version
45+
facts['multi-vsys'] = vsys if vsys
46+
facts
47+
end
48+
49+
def get_config(xpath)
50+
Puppet.debug("Retrieving #{xpath}")
51+
# https://<firewall>/api/?key=apikey&type=config&action=get&xpath=<path-to-config-node>
52+
api.request('config', action: 'get', xpath: xpath)
53+
end
54+
55+
def set_config(xpath, document)
56+
Puppet.debug("Writing to #{xpath}")
57+
# https://<firewall>/api/?key=apikey&type=config&action=set&xpath=xpath-value&element=element-value
58+
api.request('config', action: 'set', xpath: xpath, element: document)
59+
end
60+
61+
def edit_config(xpath, document)
62+
Puppet.debug("Updating #{xpath}")
63+
# https://<firewall>/api/?key=apikey&type=config&action=edit&xpath=xpath-value&element=element-value
64+
api.request('config', action: 'edit', xpath: xpath, element: document)
65+
end
66+
67+
def delete_config(xpath)
68+
Puppet.debug("Deleting #{xpath}")
69+
# https://<firewall>/api/?key=apikey&type=config&action=delete&xpath=xpath-value
70+
api.request('config', action: 'delete', xpath: xpath)
71+
end
72+
73+
def import(file_path, category)
74+
Puppet.debug("Importing #{category}")
75+
# https://<firewall>/api/?key=apikey&type=import&category=category
76+
# POST: File(file_path)
77+
api.upload('import', file_path, category: category)
78+
end
79+
80+
def load_config(file_name)
81+
Puppet.debug('Loading Config')
82+
# https://<firewall>/api/?type=op&cmd=<load><config><from>file_name</from></config></load>
83+
api.request('op', cmd: "<load><config><from>#{file_name}</from></config></load>")
84+
end
85+
86+
def show_config
87+
Puppet.debug('Retrieving Config')
88+
# https://<firewall>/api/?type=op&cmd=<show><config><running></running></config></show>
89+
api.request('op', cmd: '<show><config><running></running></config></show>')
90+
end
91+
92+
def outstanding_changes?
93+
# /api/?type=op&cmd=<check><pending-changes></pending-changes></check>
94+
result = api.request('op', cmd: '<check><pending-changes></pending-changes></check>')
95+
result.elements['/response/result'].text == 'yes'
96+
end
97+
98+
def validate
99+
Puppet.debug('Validating configuration')
100+
# https://<firewall>/api/?type=op&cmd=<validate><full></full></validate>
101+
api.job_request('op', cmd: '<validate><full></full></validate>')
102+
end
103+
104+
def commit
105+
Puppet.debug('Committing outstanding changes')
106+
# https://<firewall>/api/?type=commit&cmd=<commit></commit>
107+
api.job_request('commit', cmd: '<commit></commit>')
108+
end
109+
110+
private
111+
112+
def api
113+
@api ||= API.new(config)
114+
end
115+
116+
# A simple adaptor to expose the basic PAN-OS XML API operations.
117+
# Having this in a separate class aids with keeping the gnarly HTTP code
118+
# away from the business logic, and helps with testing, too.
119+
# @api private
120+
class API
121+
def initialize(credentials)
122+
@host = credentials['host'] || credentials['address']
123+
@port = credentials.key?('port') ? credentials['port'].to_i : 443
124+
@user = credentials['user'] || credentials['username']
125+
@password = credentials['password']
126+
@apikey = credentials['apikey']
127+
end
128+
129+
def http
130+
@http ||= begin
131+
Puppet.debug('Connecting to https://%{host}:%{port}' % { host: @host, port: @port })
132+
Net::HTTP.start(@host, @port,
133+
use_ssl: true,
134+
verify_mode: OpenSSL::SSL::VERIFY_NONE)
135+
end
136+
end
137+
138+
def fetch_apikey(user, password)
139+
uri = URI::HTTP.build(path: '/api/')
140+
params = { type: 'keygen', user: user, password: password }
141+
uri.query = URI.encode_www_form(params)
142+
143+
res = http.get(uri)
144+
unless res.is_a?(Net::HTTPSuccess)
145+
raise "Error: #{res}: #{res.message}"
146+
end
147+
doc = REXML::Document.new(res.body)
148+
handle_response_errors(doc)
149+
doc.elements['/response/result/key'].text
150+
end
151+
152+
def apikey
153+
@apikey ||= fetch_apikey(@user, @password)
154+
end
155+
156+
def request(type, **options)
157+
params = { type: type, key: apikey }
158+
params.merge!(options)
159+
160+
uri = URI::HTTP.build(path: '/api/')
161+
uri.query = URI.encode_www_form(params)
162+
163+
res = http.get(uri)
164+
unless res.is_a?(Net::HTTPSuccess)
165+
raise "Error: #{res}: #{res.message}"
166+
end
167+
doc = REXML::Document.new(res.body)
168+
handle_response_errors(doc)
169+
doc
170+
end
171+
172+
def upload(type, file, **options)
173+
params = { type: type, key: apikey }
174+
params.merge!(options)
175+
176+
uri = URI::HTTP.build(path: '/api/')
177+
uri.query = URI.encode_www_form(params)
178+
179+
raise Puppet::ResourceError, "File: `#{file}` does not exist" unless File.exist?(file)
180+
181+
# from: http://www.rubyinside.com/nethttp-cheat-sheet-2940.html
182+
# Token used to terminate the file in the post body.
183+
@boundary ||= SecureRandom.hex(25)
184+
185+
post_body = []
186+
post_body << "--#{@boundary}\r\n"
187+
post_body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{CGI.escape(File.basename(file))}\"\r\n"
188+
post_body << "Content-Type: text/plain\r\n"
189+
post_body << "\r\n"
190+
post_body << File.open(file, 'rb') { |f| f.read }
191+
post_body << "\r\n--#{@boundary}--\r\n"
192+
193+
request = Net::HTTP::Post.new(uri.request_uri)
194+
request.body = post_body.join
195+
request.content_type = "multipart/form-data, boundary=#{@boundary}"
196+
197+
res = http.request(request)
198+
unless res.is_a?(Net::HTTPSuccess)
199+
raise "Error: #{res}: #{res.message}"
200+
end
201+
doc = REXML::Document.new(res.body)
202+
handle_response_errors(doc)
203+
doc
204+
end
205+
206+
def job_request(type, **options)
207+
result = request(type, options)
208+
response_message = result.elements['/response/msg']
209+
if response_message
210+
Puppet.debug('api response (no changes): %{msg}' % { msg: response_message.text })
211+
return
212+
end
213+
214+
job_id = result.elements['/response/result/job'].text
215+
job_msg = []
216+
result.elements['/response/result/msg'].each_element_with_text { |e| job_msg << e.text }
217+
Puppet.debug('api response (job queued): %{msg}' % { msg: job_msg.join("\n") })
218+
219+
tries = 0
220+
loop do
221+
# https://<firewall>/api/?type=op&cmd=<show><jobs><id>4</id></jobs></show>
222+
poll_result = request('op', cmd: "<show><jobs><id>#{job_id}</id></jobs></show>")
223+
status = poll_result.elements['/response/result/job/status'].text
224+
result = poll_result.elements['/response/result/job/result'].text
225+
progress = poll_result.elements['/response/result/job/progress'].text
226+
details = []
227+
poll_result.elements['/response/result/job/details'].each_element_with_text { |e| details << e.text }
228+
if status == 'FIN'
229+
# TODO: go to debug
230+
# poll_result.write($stdout, 2)
231+
break if result == 'OK'
232+
raise Puppet::ResourceError, 'job failed. result="%{result}": %{details}' % { result: result, details: details.join("\n") }
233+
end
234+
tries += 1
235+
236+
details.unshift("sleeping for #{tries} seconds")
237+
Puppet.debug('job still in progress (%{progress}%%). result="%{result}": %{details}' % { result: result, progress: progress, details: details.join("\n") })
238+
sleep tries
239+
end
240+
241+
Puppet.debug('job was successful')
242+
end
243+
244+
def message_from_code(code)
245+
message_codes ||= begin
246+
h = Hash.new { |_hash, key| 'Unknown error code %{code}' % { code: key } }
247+
h['1'] = 'Unknown command: The specific config or operational command is not recognized.'
248+
h['2'] = "Internal error: Check with Palo Alto's technical support."
249+
h['3'] = "Internal error: Check with Palo Alto's technical support."
250+
h['4'] = "Internal error: Check with Palo Alto's technical support."
251+
h['5'] = "Internal error: Check with Palo Alto's technical support."
252+
h['11'] = "Internal error: Check with Palo Alto's technical support."
253+
h['21'] = "Internal error: Check with Palo Alto's technical support."
254+
h['6'] = 'Bad XPath: The xpath specified in one or more attributes of the command is invalid.'
255+
h['7'] = "Object not present: Object specified by the xpath is not present. For example, entry[@name='value'] where no object with name 'value' is present."
256+
h['8'] = 'Object not unique: For commands that operate on a single object, the specified object is not unique.'
257+
h['10'] = 'Reference count not zero: Object cannot be deleted as there are other objects that refer to it. For example, address object still in use in policy.'
258+
h['12'] = 'Invalid object: Xpath or element values provided are not complete.'
259+
h['14'] = 'Operation not possible: Operation is allowed but not possible in this case. For example, moving a rule up one position when it is already at the top.'
260+
h['15'] = 'Operation denied: Operation is allowed. For example, Admin not allowed to delete own account, Running a command that is not allowed on a passive device.'
261+
h['16'] = 'Unauthorized: The API role does not have access rights to run this query.'
262+
h['17'] = 'Invalid command: Invalid command or parameters.'
263+
h['18'] = 'Malformed command: The XML is malformed.'
264+
h['19'] = 'Success: Command completed successfully.'
265+
h['20'] = 'Success: Command completed successfully.'
266+
h['22'] = 'Session timed out: The session for this query timed out.'
267+
h
268+
end
269+
message_codes[code]
270+
end
271+
272+
def handle_response_errors(doc)
273+
status = doc.elements['/response'].attributes['status']
274+
code = doc.elements['/response'].attributes['code']
275+
error_message = ('Received "%{status}" with code %{code}: %{message}' % {
276+
status: status,
277+
code: code,
278+
message: message_from_code(code),
279+
})
280+
# require 'pry';binding.pry
281+
if status == 'success'
282+
# Messages without a code require more processing by the caller
283+
Puppet.debug(error_message) if code
284+
else
285+
error_message << "\n"
286+
doc.write(error_message, 2)
287+
raise Puppet::ResourceError, error_message
288+
end
289+
end
290+
end
291+
end
292+
end
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
require 'puppet/resource_api'
2+
3+
Puppet::ResourceApi.register_transport(
4+
name: 'panos',
5+
docs: <<-EOS,
6+
This transport connects to Palo Alto Firewalls using their HTTP XML API.
7+
EOS
8+
features: [],
9+
connection_info: {
10+
address: {
11+
type: 'String',
12+
desc: 'The FQDN or IP address of the firewall to connect to.',
13+
},
14+
port: {
15+
type: 'Optional[Integer]',
16+
desc: 'The port of the firewall to connect to.',
17+
},
18+
username: {
19+
type: 'Optional[String]',
20+
desc: 'The username to use for authenticating all connections to the firewall. Only one of `username`/`password` or `apikey` can be specified.',
21+
},
22+
password: {
23+
type: 'Optional[String]',
24+
desc: 'The password to use for authenticating all connections to the firewall. Only one of `username`/`password` or `apikey` can be specified.',
25+
},
26+
apikey: {
27+
type: 'Optional[String]',
28+
desc: <<-EOS,
29+
The API key to use for authenticating all connections to the firewall.
30+
Only one of `username`/`password` or `apikey` can be specified.
31+
Using the API key is preferred, because it avoids storing a password
32+
in the clear, and is easily revoked by changing the password on the associated user.
33+
EOS
34+
},
35+
},
36+
)

0 commit comments

Comments
 (0)