PyPANRestV2 2.1.0__py3-none-any.whl
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.
- pypanrestv2/ApplicationHelper.py +220 -0
- pypanrestv2/Base.py +1772 -0
- pypanrestv2/Device.py +21 -0
- pypanrestv2/Exceptions.py +11 -0
- pypanrestv2/Network.py +1722 -0
- pypanrestv2/Objects.py +1428 -0
- pypanrestv2/Panorama.py +426 -0
- pypanrestv2/Policies.py +755 -0
- pypanrestv2/XDR.py +299 -0
- pypanrestv2/__init__.py +4 -0
- pypanrestv2-2.1.0.dist-info/METADATA +209 -0
- pypanrestv2-2.1.0.dist-info/RECORD +13 -0
- pypanrestv2-2.1.0.dist-info/WHEEL +4 -0
pypanrestv2/Base.py
ADDED
|
@@ -0,0 +1,1772 @@
|
|
|
1
|
+
import getpass
|
|
2
|
+
from typing import Optional, Dict, Any, Tuple, Union, List, Protocol, Set, TypeVar
|
|
3
|
+
from . import ApplicationHelper
|
|
4
|
+
from . import Exceptions
|
|
5
|
+
import pycountry
|
|
6
|
+
import ipaddress
|
|
7
|
+
import builtins
|
|
8
|
+
import time
|
|
9
|
+
import re
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from icecream import ic
|
|
12
|
+
import sys
|
|
13
|
+
import xmltodict
|
|
14
|
+
import dns.resolver
|
|
15
|
+
import requests
|
|
16
|
+
from requests.packages.urllib3.exceptions import InsecureRequestWarning
|
|
17
|
+
import logging
|
|
18
|
+
import xml.etree.ElementTree as ET
|
|
19
|
+
from icecream import ic
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
|
|
23
|
+
|
|
24
|
+
class NetworkRequestError(Exception):
|
|
25
|
+
"""
|
|
26
|
+
Exception raised for errors in the network request.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
message -- explanation of the error
|
|
30
|
+
details -- optional details of the error (default None)
|
|
31
|
+
"""
|
|
32
|
+
def __init__(self, message, details=None):
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.details = details
|
|
35
|
+
|
|
36
|
+
class PAN:
|
|
37
|
+
"""
|
|
38
|
+
Base class for palo alto devices
|
|
39
|
+
The IP address of the device is the only mandatory parameter.
|
|
40
|
+
Either api_key or username and password are needed. The API_KEY can be obtained from using the username and password
|
|
41
|
+
to connect to the device.
|
|
42
|
+
The ver for software version is required for API calls. If this is not provided, it can be obtained by connecting
|
|
43
|
+
to the devide and issuing the OP command "show system info".
|
|
44
|
+
The "show system info" also returns a number of other usefull information that can be used by the class so might
|
|
45
|
+
as well extract it at the same time.
|
|
46
|
+
Since access to the device can be restricted to prevent execuing OP commands, there needs to be another REST call
|
|
47
|
+
that can be made to verify the API_KEY. This can be prompted for, or provided via config.yaml input. Without the
|
|
48
|
+
"show system info", the version must be provided. If the sytem is not able to get the version, raise an exception.
|
|
49
|
+
"""
|
|
50
|
+
yes_no = ['yes', 'no']
|
|
51
|
+
|
|
52
|
+
def __init__(self, base_url: str, **kwargs):
|
|
53
|
+
self.base_url: str = base_url
|
|
54
|
+
self.api_key: str = kwargs.get('api_key')
|
|
55
|
+
self.username: str = kwargs.get('username')
|
|
56
|
+
self.password: str = kwargs.get('password')
|
|
57
|
+
self.force_interactive: bool = kwargs.get('force_interactive', True)
|
|
58
|
+
self.session: requests.Session = requests.Session()
|
|
59
|
+
self.session.verify = False
|
|
60
|
+
if self.api_key:
|
|
61
|
+
try:
|
|
62
|
+
self.session.headers = {'X-PAN-KEY': self.api_key}
|
|
63
|
+
self.SystemInfo: dict = self.op('show system info').get('result', {}).get('system')
|
|
64
|
+
except requests.HTTPError as http_err:
|
|
65
|
+
logger.error(f'HTTP error occurred: {http_err}')
|
|
66
|
+
elif self.username and self.password:
|
|
67
|
+
try:
|
|
68
|
+
self.api_key = self.getkey()
|
|
69
|
+
self.session.headers = {'X-PAN-KEY': self.api_key}
|
|
70
|
+
self.SystemInfo: dict = self.op('show system info').get('result', {}).get('system')
|
|
71
|
+
except requests.HTTPError as http_err:
|
|
72
|
+
logger.error(f'HTTP error occurred: {http_err}')
|
|
73
|
+
else:
|
|
74
|
+
raise ValueError("Must provide either an api_key or both username and password for authentication.")
|
|
75
|
+
|
|
76
|
+
if self.SystemInfo:
|
|
77
|
+
self.ver = self.ver_from_sw_version(self.SystemInfo['sw-version'])
|
|
78
|
+
if self.password:
|
|
79
|
+
# if we used a password, clear it from memory as it is no longer needed now that we have a working API key
|
|
80
|
+
self.password = None
|
|
81
|
+
self.hostname = self.SystemInfo['hostname']
|
|
82
|
+
else:
|
|
83
|
+
raise ValueError(f"Can not determine the PANOS version for {self.base_url}.")
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def valid_name(value: str, max_name_length: int) -> bool:
|
|
87
|
+
"""
|
|
88
|
+
Validates if the provided string is a valid name according to the allowed character set and length.
|
|
89
|
+
|
|
90
|
+
Parameters:
|
|
91
|
+
value (str): The string to validate.
|
|
92
|
+
max_name_length (int): The maximum length of the name to be validated
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
bool: True if the string is valid, otherwise raises a ValueError.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
ValueError: If the string contains invalid characters or exceeds the maximum length.
|
|
99
|
+
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
# Define the allowed character set using a regular expression.
|
|
103
|
+
# The pattern now ensures the entire string consists of the allowed characters.
|
|
104
|
+
|
|
105
|
+
pattern = r"^[ 0-9a-zA-Z._-]+$"
|
|
106
|
+
|
|
107
|
+
allowed = re.compile(pattern, re.IGNORECASE)
|
|
108
|
+
|
|
109
|
+
# Check length first for efficiency
|
|
110
|
+
if len(value) > max_name_length:
|
|
111
|
+
raise ValueError(f"The name exceeds the maximum length of {max_name_length} characters.")
|
|
112
|
+
if not allowed.match(value):
|
|
113
|
+
raise ValueError(
|
|
114
|
+
"The name contains invalid characters. Only alphanumeric characters, spaces, and ._- are allowed.")
|
|
115
|
+
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
def rest_request(self, method: str, **kwargs) -> dict:
|
|
119
|
+
valid_endpoints = ['Objects', 'Policies', 'Network', 'Device', 'Panorama']
|
|
120
|
+
if self.endpoint not in valid_endpoints:
|
|
121
|
+
raise ValueError(f'endpoint attribute must be one of {valid_endpoints}, not {self.endpoint}.')
|
|
122
|
+
if self.endpoint == 'Policies':
|
|
123
|
+
if self.rulebase:
|
|
124
|
+
url = f"{self.base_url}/restapi/{self.ver}/{self.endpoint}/{self.__class__.__name__.split('Rules')[0]}{self.rulebase}Rules"
|
|
125
|
+
else:
|
|
126
|
+
url = f"{self.base_url}/restapi/{self.ver}/{self.endpoint}/{self.__class__.__name__}"
|
|
127
|
+
response = self.session.request(method, url, **kwargs)
|
|
128
|
+
response.raise_for_status()
|
|
129
|
+
return response.json()
|
|
130
|
+
|
|
131
|
+
def xml_request(self, **kwargs) -> dict:
|
|
132
|
+
"""
|
|
133
|
+
xml requests are always GET
|
|
134
|
+
:param kwargs:
|
|
135
|
+
:return:
|
|
136
|
+
"""
|
|
137
|
+
url = f"{self.base_url}/api/"
|
|
138
|
+
response = self.session.get(url, **kwargs)
|
|
139
|
+
response.raise_for_status()
|
|
140
|
+
return xmltodict.parse(response.text)
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def is_interactive_mode() -> bool:
|
|
144
|
+
"""
|
|
145
|
+
Check if the script is running in an interactive mode.
|
|
146
|
+
"""
|
|
147
|
+
return sys.stdin.isatty()
|
|
148
|
+
|
|
149
|
+
def prompt_for_credentials(self) -> None:
|
|
150
|
+
"""
|
|
151
|
+
Prompt the user for credentials if not already set.
|
|
152
|
+
"""
|
|
153
|
+
if not self.username:
|
|
154
|
+
self.username = input('Please enter the admin username: ')
|
|
155
|
+
if not self.password:
|
|
156
|
+
self.password = getpass.getpass('Please enter the admin password: ')
|
|
157
|
+
|
|
158
|
+
def getkey(self) -> Optional[str]:
|
|
159
|
+
"""
|
|
160
|
+
Get the API key from a Palo Alto Networks firewall or Panorama.
|
|
161
|
+
:return: API key as a string or None if an error occurs
|
|
162
|
+
"""
|
|
163
|
+
params: dict[str, str] = {
|
|
164
|
+
'type': 'keygen',
|
|
165
|
+
'user': self.username,
|
|
166
|
+
'password': self.password
|
|
167
|
+
}
|
|
168
|
+
try:
|
|
169
|
+
response = self.xml_request(params=params)
|
|
170
|
+
except requests.exceptions.ConnectionError:
|
|
171
|
+
logger.error('Error connecting to firewall.')
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
# Check for error code in the response
|
|
175
|
+
if response.get('response', {}).get('@code') == '403':
|
|
176
|
+
logger.warning('Access denied. Check your username and password.')
|
|
177
|
+
return None
|
|
178
|
+
else:
|
|
179
|
+
# Extract the API key from the parsed response
|
|
180
|
+
api_key: Optional[str] = response.get('response', {}).get('result', {}).get('key')
|
|
181
|
+
return api_key
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def ver_from_sw_version(sw_version: str) -> Optional[str]:
|
|
185
|
+
"""
|
|
186
|
+
Extracts and formats the version string to include only the major and minor version numbers,
|
|
187
|
+
prefixed with 'v'. If the input string does not contain a version pattern that can be parsed,
|
|
188
|
+
None is returned.
|
|
189
|
+
|
|
190
|
+
:param sw_version: The software version string to parse.
|
|
191
|
+
:return: A formatted version string or None if parsing fails.
|
|
192
|
+
"""
|
|
193
|
+
try:
|
|
194
|
+
# Split the string by decimal point and ensure at least two parts exist
|
|
195
|
+
parts = sw_version.split('.')
|
|
196
|
+
if len(parts) >= 2:
|
|
197
|
+
# Use only the first two parts (major and minor versions)
|
|
198
|
+
result = '.'.join(parts[:2])
|
|
199
|
+
return f'v{result}'
|
|
200
|
+
else:
|
|
201
|
+
return None
|
|
202
|
+
except AttributeError:
|
|
203
|
+
# In case the input is not a string or another error occurs
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def base_url(self):
|
|
208
|
+
return self._base_url
|
|
209
|
+
|
|
210
|
+
@base_url.setter
|
|
211
|
+
def base_url(self, value: str):
|
|
212
|
+
# Normalize the value by stripping the scheme (if present) for validation
|
|
213
|
+
stripped_value = value.split("//")[-1]
|
|
214
|
+
# Split stripped value to isolate the hostname/IP and the rest of the path
|
|
215
|
+
hostname_and_port = stripped_value.split('/')[0]
|
|
216
|
+
|
|
217
|
+
# Extract hostname/IP and port (if present)
|
|
218
|
+
if ':' in hostname_and_port:
|
|
219
|
+
hostname, port = hostname_and_port.split(':', 1)
|
|
220
|
+
else:
|
|
221
|
+
hostname, port = hostname_and_port, None
|
|
222
|
+
|
|
223
|
+
# Validate port if it exists
|
|
224
|
+
if port:
|
|
225
|
+
if not port.isdigit() or not (1 <= int(port) <= 65535):
|
|
226
|
+
raise ValueError(f"Provided port {port} in base_url {value} is invalid.")
|
|
227
|
+
|
|
228
|
+
# Initialize a flag to indicate whether the provided hostname is an IP address
|
|
229
|
+
is_ip_address = False
|
|
230
|
+
|
|
231
|
+
# Check if the hostname is an IP address
|
|
232
|
+
try:
|
|
233
|
+
ipaddress.ip_address(hostname) # Validate as IP address
|
|
234
|
+
is_ip_address = True
|
|
235
|
+
except ValueError:
|
|
236
|
+
is_ip_address = False
|
|
237
|
+
|
|
238
|
+
# If not an IP address, attempt DNS resolution
|
|
239
|
+
if not is_ip_address:
|
|
240
|
+
try:
|
|
241
|
+
# Perform DNS resolution
|
|
242
|
+
dns.resolver.resolve(hostname, 'A')
|
|
243
|
+
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
|
|
244
|
+
raise ValueError(f"Provided base_url {value} is neither a valid IP address nor a resolvable hostname.")
|
|
245
|
+
|
|
246
|
+
# Prepend https:// if missing
|
|
247
|
+
if not value.startswith('https://'):
|
|
248
|
+
value = 'https://' + value
|
|
249
|
+
|
|
250
|
+
# Assign the validated and normalized base_url to the instance attribute
|
|
251
|
+
self._base_url = value
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def hostname(self):
|
|
255
|
+
return self._hostname
|
|
256
|
+
|
|
257
|
+
@hostname.setter
|
|
258
|
+
def hostname(self, value):
|
|
259
|
+
if not isinstance(value, str):
|
|
260
|
+
raise ValueError("Hostname must be a string.")
|
|
261
|
+
if len(value) >= 32:
|
|
262
|
+
raise ValueError("Hostname must be less than 32 characters long.")
|
|
263
|
+
self._hostname = value
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def string_to_xml(string, **kwargs) -> str:
|
|
267
|
+
# Basic function to take a string and convert it to xml format for issuing Operational Commands.
|
|
268
|
+
# Sometimes a value is given, this value is a single string that is placed in the middle of the XML string.
|
|
269
|
+
value = kwargs.get('value')
|
|
270
|
+
data = string.split(' ')
|
|
271
|
+
XML_Right = []
|
|
272
|
+
XML_Left = []
|
|
273
|
+
# Build out the start and end of the XML string
|
|
274
|
+
for item in data:
|
|
275
|
+
left = f'<{item}>'
|
|
276
|
+
right = f'</{item}>'
|
|
277
|
+
XML_Left.append(left)
|
|
278
|
+
XML_Right.insert(0, right)
|
|
279
|
+
# Add in the value by putting it at the end of the left most XML tags.
|
|
280
|
+
if value:
|
|
281
|
+
XML_Left.append(value)
|
|
282
|
+
# Close off the XML by adding in the right most XML tags.
|
|
283
|
+
XML_Left.extend(XML_Right)
|
|
284
|
+
return ''.join(XML_Left)
|
|
285
|
+
|
|
286
|
+
def set_xml(self, xpath: str, element: str) -> dict:
|
|
287
|
+
"""
|
|
288
|
+
Set a config setting using the XML API
|
|
289
|
+
:param xpath: The xpath of the element to be set
|
|
290
|
+
:param element: The element to be set
|
|
291
|
+
:return: Dict with the status of the set command
|
|
292
|
+
"""
|
|
293
|
+
params: dict = {
|
|
294
|
+
'type': 'config',
|
|
295
|
+
'action': 'set',
|
|
296
|
+
'xpath': xpath,
|
|
297
|
+
'element': element
|
|
298
|
+
}
|
|
299
|
+
try:
|
|
300
|
+
parsed_response = self.xml_request(params=params, timeout=8)
|
|
301
|
+
status = parsed_response['response']['@status']
|
|
302
|
+
message = parsed_response['response'].get('msg', 'No message provided')
|
|
303
|
+
|
|
304
|
+
return {'status': status, 'msg': message}
|
|
305
|
+
except requests.exceptions.HTTPError as e:
|
|
306
|
+
logging.error(f"HTTP error occurred: {e}")
|
|
307
|
+
return {'status': 'error', 'msg': str(e)}
|
|
308
|
+
except requests.exceptions.ConnectionError as e:
|
|
309
|
+
logging.error(f"Connection error occurred: {e}")
|
|
310
|
+
return {'status': 'error', 'msg': 'Connection error'}
|
|
311
|
+
except requests.exceptions.Timeout as e:
|
|
312
|
+
logging.error(f"Request timed out: {e}")
|
|
313
|
+
return {'status': 'error', 'msg': 'Timeout'}
|
|
314
|
+
except Exception as e:
|
|
315
|
+
logging.error(f"An unexpected error occurred: {e}")
|
|
316
|
+
return {'status': 'error', 'msg': 'Unexpected error'}
|
|
317
|
+
|
|
318
|
+
def get_xml(self, xpath: str) -> Dict[str, Any]:
|
|
319
|
+
"""
|
|
320
|
+
Fetches configuration from the device using an XML format via a GET request.
|
|
321
|
+
|
|
322
|
+
:param xpath: The XPath specifying the configuration to fetch.
|
|
323
|
+
:return: A dictionary parsed from the XML response.
|
|
324
|
+
"""
|
|
325
|
+
params: Dict[str, str] = {
|
|
326
|
+
'type': 'config',
|
|
327
|
+
'action': 'get',
|
|
328
|
+
'xpath': xpath,
|
|
329
|
+
}
|
|
330
|
+
try:
|
|
331
|
+
response = self.xml_request(params=params, timeout=8)
|
|
332
|
+
return response
|
|
333
|
+
except requests.exceptions.HTTPError as e:
|
|
334
|
+
logging.error(f'HTTP error occurred: {e}') # Specific HTTP errors (e.g., 404, 401, ...)
|
|
335
|
+
except requests.exceptions.ConnectionError as e:
|
|
336
|
+
logging.error(f'Error connecting to the server: {e}') # Problems with the network
|
|
337
|
+
except requests.exceptions.Timeout as e:
|
|
338
|
+
logging.error(f'Timeout error: {e}') # The request timed out
|
|
339
|
+
except requests.exceptions.RequestException as e:
|
|
340
|
+
logging.error(f'Error during requests to {url}: {e}') # Any other requests exception
|
|
341
|
+
except Exception as e:
|
|
342
|
+
logging.error(f'An unexpected error occurred while processing the XML: {e}') # Non-requests related errors
|
|
343
|
+
|
|
344
|
+
# Return an empty dict or a specific error structure if preferred
|
|
345
|
+
return {'error': 'An error occurred while fetching the XML configuration.'}
|
|
346
|
+
|
|
347
|
+
def edit_xml(self, xpath: str, element: str) -> Dict[str, Any]:
|
|
348
|
+
"""
|
|
349
|
+
Edit XML configuration by specifying a path and the element to edit.
|
|
350
|
+
|
|
351
|
+
:param xpath: The XPath to the configuration element to edit.
|
|
352
|
+
:param element: The new XML element to insert.
|
|
353
|
+
:return: A dictionary with the status and message of the operation.
|
|
354
|
+
"""
|
|
355
|
+
params = {
|
|
356
|
+
'type': 'config',
|
|
357
|
+
'action': 'edit',
|
|
358
|
+
'xpath': xpath,
|
|
359
|
+
'element': element
|
|
360
|
+
}
|
|
361
|
+
try:
|
|
362
|
+
response = self.xml_request(params=params, timeout=8)
|
|
363
|
+
return {
|
|
364
|
+
'status': response['response'].get('@status'),
|
|
365
|
+
'msg': response['response'].get('msg', 'No message provided')
|
|
366
|
+
}
|
|
367
|
+
except requests.exceptions.RequestException as e:
|
|
368
|
+
logger.error(f"Request error: {e}")
|
|
369
|
+
return {'status': 'error', 'msg': str(e)}
|
|
370
|
+
except Exception as e: # Generic exception handling
|
|
371
|
+
logger.error(f"Parsing error or other exception: {e}")
|
|
372
|
+
return {'status': 'error', 'msg': 'An unexpected error occurred'}
|
|
373
|
+
|
|
374
|
+
def delete_xml(self, xpath: str) -> Dict[str, Any]:
|
|
375
|
+
"""
|
|
376
|
+
Delete an XML configuration element specified by the XPath.
|
|
377
|
+
|
|
378
|
+
:param xpath: The XPath to the configuration element to delete.
|
|
379
|
+
:return: A dictionary with the status and message of the operation.
|
|
380
|
+
"""
|
|
381
|
+
params = {
|
|
382
|
+
'type': 'config',
|
|
383
|
+
'action': 'delete',
|
|
384
|
+
'xpath': xpath,
|
|
385
|
+
}
|
|
386
|
+
try:
|
|
387
|
+
response = self.xml_request(params=params, timeout=8)
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
'status': response['response'].get('@status'),
|
|
391
|
+
'msg': response['response'].get('msg', 'No message provided')
|
|
392
|
+
}
|
|
393
|
+
except requests.exceptions.RequestException as e:
|
|
394
|
+
logger.error(f"Request error: {e}")
|
|
395
|
+
return {'status': 'error', 'msg': str(e)}
|
|
396
|
+
except Exception as e: # Catching generic exceptions for logging and error reporting
|
|
397
|
+
logger.error(f"Parsing error or other exception: {e}")
|
|
398
|
+
return {'status': 'error', 'msg': 'An unexpected error occurred'}
|
|
399
|
+
|
|
400
|
+
def override(self, xpath: str, element: str) -> dict:
|
|
401
|
+
"""
|
|
402
|
+
Override a configuration object on the firewall using the XML API.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
xpath (str): The XPath of the configuration object to be overridden.
|
|
406
|
+
element (str): The XML element with the new override settings.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
dict: The result of the override operation, typically containing 'status' and 'msg'.
|
|
410
|
+
"""
|
|
411
|
+
|
|
412
|
+
# Base API call for override
|
|
413
|
+
params = {
|
|
414
|
+
"type": "config",
|
|
415
|
+
"action": "override",
|
|
416
|
+
"xpath": xpath,
|
|
417
|
+
"element": element
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
# Make the API call
|
|
421
|
+
try:
|
|
422
|
+
parsed_response = self.xml_request(params=params, timeout=8)
|
|
423
|
+
status = parsed_response['response']['@status']
|
|
424
|
+
message = parsed_response['response'].get('msg', 'No message provided')
|
|
425
|
+
|
|
426
|
+
return {'status': status, 'msg': message}
|
|
427
|
+
except requests.exceptions.HTTPError as e:
|
|
428
|
+
logging.error(f"HTTP error occurred: {e}")
|
|
429
|
+
return {'status': 'error', 'msg': str(e)}
|
|
430
|
+
except requests.exceptions.ConnectionError as e:
|
|
431
|
+
logging.error(f"Connection error occurred: {e}")
|
|
432
|
+
return {'status': 'error', 'msg': 'Connection error'}
|
|
433
|
+
except requests.exceptions.Timeout as e:
|
|
434
|
+
logging.error(f"Request timed out: {e}")
|
|
435
|
+
return {'status': 'error', 'msg': 'Timeout'}
|
|
436
|
+
except Exception as e:
|
|
437
|
+
logging.error(f"An unexpected error occurred: {e}")
|
|
438
|
+
return {'status': 'error', 'msg': 'Unexpected error'}
|
|
439
|
+
|
|
440
|
+
def send_command(self, cmd: str, value: Optional[Any] = None, timeout: int = 60) -> Dict[str, Any]:
|
|
441
|
+
"""
|
|
442
|
+
Send a command to the firewall.
|
|
443
|
+
|
|
444
|
+
:param cmd: Command to be sent.
|
|
445
|
+
:param value: Additional value for the command, if any.
|
|
446
|
+
:param timeout: Timeout for the request in seconds.
|
|
447
|
+
:return: Parsed XML response as a dictionary.
|
|
448
|
+
"""
|
|
449
|
+
|
|
450
|
+
params = {'type': 'op', 'cmd': self.command_to_payload(cmd, value)}
|
|
451
|
+
try:
|
|
452
|
+
response = self.xml_request(params=params, timeout=timeout)
|
|
453
|
+
return response
|
|
454
|
+
except requests.exceptions.RequestException as e:
|
|
455
|
+
logger.error(f"Request error: {e}")
|
|
456
|
+
return {'response': {'@status': 'error', 'msg': str(e)}}
|
|
457
|
+
except Exception as e: # Catching generic exceptions for logging and error reporting
|
|
458
|
+
logger.error(f"Parsing error or other exception: {e}")
|
|
459
|
+
return {'response': {'@status': 'error', 'msg': 'An unexpected error occurred'}}
|
|
460
|
+
|
|
461
|
+
def command_to_payload(self, cmd: str, value: Optional[Any]) -> str:
|
|
462
|
+
"""
|
|
463
|
+
Convert command to payload. This method needs to be implemented based on specific requirements.
|
|
464
|
+
|
|
465
|
+
:param cmd: Command to be sent.
|
|
466
|
+
:param value: Additional value for the command, if any.
|
|
467
|
+
:return: The command converted into a payload string.
|
|
468
|
+
"""
|
|
469
|
+
return self.string_to_xml(cmd, value=value)
|
|
470
|
+
|
|
471
|
+
def wait_for_job_completion(self, job_id: str) -> None:
|
|
472
|
+
"""
|
|
473
|
+
Poll the job status until completion.
|
|
474
|
+
|
|
475
|
+
:param job_id: The ID of the job to poll.
|
|
476
|
+
"""
|
|
477
|
+
params = {'type': 'op', 'cmd': self.string_to_xml('show jobs id', value=job_id)}
|
|
478
|
+
|
|
479
|
+
while True:
|
|
480
|
+
try:
|
|
481
|
+
job = self.xml_request(params=params, timeout=8)
|
|
482
|
+
status = job['response']['result']['job']['status']
|
|
483
|
+
|
|
484
|
+
if status == 'FIN':
|
|
485
|
+
logger.info(f"Job {job_id} is 100% complete.")
|
|
486
|
+
break
|
|
487
|
+
|
|
488
|
+
progress = job['response']['result']['job']['progress']
|
|
489
|
+
logger.info(f"On device {self.hostname}, Job {job_id} is {progress}% complete.")
|
|
490
|
+
|
|
491
|
+
except requests.exceptions.ConnectionError as ce:
|
|
492
|
+
if 'HTTPSConnectionPool' in str(ce):
|
|
493
|
+
logger.warning(f"HTTPS connection pool issue encountered: {ce}. Retrying in 30 seconds...")
|
|
494
|
+
time.sleep(10)
|
|
495
|
+
continue # Try the request again after sleep
|
|
496
|
+
else:
|
|
497
|
+
logger.error(f"Connection error encountered: {ce}")
|
|
498
|
+
break
|
|
499
|
+
except requests.exceptions.RequestException as e:
|
|
500
|
+
logger.error(f"Error polling job status: {e}")
|
|
501
|
+
break
|
|
502
|
+
|
|
503
|
+
time.sleep(5)
|
|
504
|
+
|
|
505
|
+
def op(self, cmd: str, value: str = None, wait: bool = False) -> Dict[str, Any]:
|
|
506
|
+
"""
|
|
507
|
+
Execute a command on the firewall.
|
|
508
|
+
|
|
509
|
+
:param wait: Wait for the job to finish or not
|
|
510
|
+
:param value: Value in to go with the command.
|
|
511
|
+
:param cmd: The command to execute.
|
|
512
|
+
:return: The parsed result of the command execution.
|
|
513
|
+
"""
|
|
514
|
+
|
|
515
|
+
result = self.send_command(cmd, value)
|
|
516
|
+
|
|
517
|
+
if result['response']['@status'] == 'success' and wait:
|
|
518
|
+
job_id = result['response']['result'].get('job')
|
|
519
|
+
if job_id:
|
|
520
|
+
self.wait_for_job_completion(job_id)
|
|
521
|
+
|
|
522
|
+
return self.parse_result(result)
|
|
523
|
+
|
|
524
|
+
@staticmethod
|
|
525
|
+
def parse_result(result: Dict[str, Any]) -> Dict[str, Any]:
|
|
526
|
+
"""
|
|
527
|
+
Parse and return the command execution result.
|
|
528
|
+
|
|
529
|
+
:param result: The raw result from the command execution.
|
|
530
|
+
:return: A dictionary containing the status and result of the command execution.
|
|
531
|
+
"""
|
|
532
|
+
if 'result' in result['response']:
|
|
533
|
+
return {'status': result['response']['@status'], 'result': result['response']['result']}
|
|
534
|
+
else:
|
|
535
|
+
return {'status': result['response']['@status'],
|
|
536
|
+
'result': {k: v for k, v in result['response'].items() if k != '@status'}}
|
|
537
|
+
|
|
538
|
+
def clear_sessions(self):
|
|
539
|
+
"""
|
|
540
|
+
Clear all sessions on the firewall
|
|
541
|
+
:return:
|
|
542
|
+
"""
|
|
543
|
+
self.op('clear session all')
|
|
544
|
+
|
|
545
|
+
def restart_management(self):
|
|
546
|
+
"""
|
|
547
|
+
Restart the management server on the firewall
|
|
548
|
+
:return:
|
|
549
|
+
"""
|
|
550
|
+
self.op('debug software restart process management-server')
|
|
551
|
+
|
|
552
|
+
def commit(self, wait: bool = False) -> Dict:
|
|
553
|
+
"""
|
|
554
|
+
Commit changes to Panorama or Firewall. If "wait" argument is True, then wait for the commit job to finish.
|
|
555
|
+
"""
|
|
556
|
+
if not isinstance(wait, bool):
|
|
557
|
+
raise ValueError('The wait attribute must be True or False')
|
|
558
|
+
|
|
559
|
+
params = {
|
|
560
|
+
'key': self.api_key,
|
|
561
|
+
'type': 'commit',
|
|
562
|
+
'cmd': '<commit></commit>'
|
|
563
|
+
}
|
|
564
|
+
logger.debug(f'Commit changes to {self.hostname}.')
|
|
565
|
+
parsed_response = self.xml_request(params=params)
|
|
566
|
+
|
|
567
|
+
if parsed_response.get('response', {}).get('@status') == 'success':
|
|
568
|
+
job_id = parsed_response.get('response', {}).get('result', {}).get('job')
|
|
569
|
+
if wait and job_id:
|
|
570
|
+
# Waiting for completion
|
|
571
|
+
logger.debug(f'Waiting for commit job id {job_id} to complete.')
|
|
572
|
+
commmit_status = self.wait_for_commit_to_finish(job_id)
|
|
573
|
+
return {**commmit_status, 'job_id': job_id}
|
|
574
|
+
elif job_id:
|
|
575
|
+
# Job ID present but not waiting for completion
|
|
576
|
+
return {'status': 'pending', 'job_id': job_id}
|
|
577
|
+
else:
|
|
578
|
+
# Success but no job ID found
|
|
579
|
+
return {'status': 'success', 'message': 'Commit successful, no job ID provided.'}
|
|
580
|
+
else:
|
|
581
|
+
return {'status': parsed_response.get('response', {}).get('@status'),
|
|
582
|
+
'msg': parsed_response.get('response', {}).get('msg', 'Unknown error')}
|
|
583
|
+
|
|
584
|
+
def wait_for_commit_to_finish(self, job_id) -> dict:
|
|
585
|
+
"""
|
|
586
|
+
Wait for the commit job to finish and return the final status.
|
|
587
|
+
"""
|
|
588
|
+
while True:
|
|
589
|
+
result = self.op('show jobs id', value=job_id)
|
|
590
|
+
job_status = result.get('result', {}).get('job', {}).get('status')
|
|
591
|
+
|
|
592
|
+
if job_status == 'FIN':
|
|
593
|
+
logger.info(f"Commit job {job_id} finished.")
|
|
594
|
+
return {'status': result.get('result', {}).get('job', {}).get('result')}
|
|
595
|
+
time.sleep(10) # Adjust sleep time as needed
|
|
596
|
+
|
|
597
|
+
def get_license_info(self) -> Tuple[List[str], Dict[str, Union[str, int]]]:
|
|
598
|
+
"""
|
|
599
|
+
Get the license info for this device.
|
|
600
|
+
Returns a tuple containing a list of column names and a dictionary with license information.
|
|
601
|
+
"""
|
|
602
|
+
columns = [
|
|
603
|
+
'serial', 'device name', 'support', 'Threat Prevention', 'GlobalProtect Gateway',
|
|
604
|
+
'PAN-DB URL Filtering', 'DNS Security', 'WildFire License', 'Logging Service', 'PA-VM',
|
|
605
|
+
'Advanced URL Filtering', 'Device Management License'
|
|
606
|
+
]
|
|
607
|
+
|
|
608
|
+
try:
|
|
609
|
+
response = self.op('request license info')
|
|
610
|
+
if response.get('status') == 'success' and response.get('result', {}).get('licenses', {}).get('entry',
|
|
611
|
+
None) is not None:
|
|
612
|
+
licenses = {
|
|
613
|
+
'device name': self.hostname,
|
|
614
|
+
'serial': self.serial
|
|
615
|
+
}
|
|
616
|
+
for lic in response['result']['licenses']['entry']:
|
|
617
|
+
if lic.get('expires') and lic.get('expires') != 'Never':
|
|
618
|
+
endDateRAW = datetime.strptime(lic['expires'], '%B %d, %Y')
|
|
619
|
+
endDateRAW = int(endDateRAW.timestamp()) * 1000
|
|
620
|
+
feature_name = lic.get('feature')
|
|
621
|
+
if lic.get('feature') == 'Premium':
|
|
622
|
+
licenses.update({'support': endDateRAW})
|
|
623
|
+
else:
|
|
624
|
+
licenses.update({feature_name: endDateRAW})
|
|
625
|
+
return columns, licenses
|
|
626
|
+
else:
|
|
627
|
+
raise ValueError("Failed to retrieve license information or no licenses found.")
|
|
628
|
+
except Exception as e:
|
|
629
|
+
raise RuntimeError(f"Error retrieving license information: {e}")
|
|
630
|
+
|
|
631
|
+
def refresh_license(self):
|
|
632
|
+
"""
|
|
633
|
+
Fetch the licenses from palo alto networks custom support portal
|
|
634
|
+
:return:
|
|
635
|
+
"""
|
|
636
|
+
return self.op('request license fetch')
|
|
637
|
+
|
|
638
|
+
@staticmethod
|
|
639
|
+
def version_key(version):
|
|
640
|
+
# For finding the newest version of the content
|
|
641
|
+
return tuple(map(int, version.split('-')))
|
|
642
|
+
|
|
643
|
+
def update_content(self):
|
|
644
|
+
"""
|
|
645
|
+
Download the latest version of the Apps & Threats and install it.
|
|
646
|
+
:return: status and result of the operation
|
|
647
|
+
"""
|
|
648
|
+
|
|
649
|
+
download = self.op('request content upgrade download latest', wait=True)
|
|
650
|
+
|
|
651
|
+
if download['status'] == 'success':
|
|
652
|
+
current_info = self.op('request content upgrade info')
|
|
653
|
+
app_version_list = []
|
|
654
|
+
current_verison = ''
|
|
655
|
+
for entry in current_info['result']['content-updates']['entry']:
|
|
656
|
+
app_version_list.append(entry['app-version'])
|
|
657
|
+
if entry['current'] == 'yes':
|
|
658
|
+
current_verison = entry['app-version']
|
|
659
|
+
hightest_version = max(app_version_list, key=self.version_key)
|
|
660
|
+
if current_verison != hightest_version:
|
|
661
|
+
# There is a newer version, upgrade to it.
|
|
662
|
+
install = self.op('request content upgrade install version', value='latest', wait=True)
|
|
663
|
+
return install
|
|
664
|
+
else:
|
|
665
|
+
logging.info(f'Content is already up to date. Nothing to do.')
|
|
666
|
+
return {'status': 'success',
|
|
667
|
+
'msg': 'Content is already up to date. Nothing to do.'}
|
|
668
|
+
|
|
669
|
+
def update_av(self):
|
|
670
|
+
"""
|
|
671
|
+
Downlaod and install the lastest AntiVirus.
|
|
672
|
+
You can only get the Anti-Virus content if the Threat Prevention license is on the firewall
|
|
673
|
+
:return: status and result of the operation
|
|
674
|
+
"""
|
|
675
|
+
download = self.op('request anti-virus upgrade download latest', wait=True)
|
|
676
|
+
if download['status'] == 'success':
|
|
677
|
+
current_info = self.op('request anti-virus upgrade info')
|
|
678
|
+
app_version_list = []
|
|
679
|
+
current_verison = ''
|
|
680
|
+
for entry in current_info['result']['content-updates']['entry']:
|
|
681
|
+
app_version_list.append(entry['app-version'])
|
|
682
|
+
if entry['current'] == 'yes':
|
|
683
|
+
current_verison = entry['app-version']
|
|
684
|
+
hightest_version = max(app_version_list, key=self.version_key)
|
|
685
|
+
if current_verison != hightest_version:
|
|
686
|
+
install = self.op('request anti-virus upgrade install version', value='latest', wait=True)
|
|
687
|
+
return install
|
|
688
|
+
else:
|
|
689
|
+
logging.info(f'Anti-Virus is already up to date. Nothing to do.')
|
|
690
|
+
return {'status': 'success',
|
|
691
|
+
'msg': 'Anti-Virus is already up to date. Nothing to do.'}
|
|
692
|
+
|
|
693
|
+
def update_hostname(self, hostname):
|
|
694
|
+
xpath = "/config/devices/entry[@name='localhost.localdomain']/deviceconfig/system/hostname"
|
|
695
|
+
element = f"<hostname>{hostname}</hostname>"
|
|
696
|
+
self.hostname = hostname
|
|
697
|
+
return self.edit_xml(xpath, element)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
class Firewall(PAN):
|
|
701
|
+
# Firewall object using REST API
|
|
702
|
+
valid_location = ['vsys', 'panorama-pushed', 'predefined', '']
|
|
703
|
+
|
|
704
|
+
def __init__(self, base_url: str, **kwargs):
|
|
705
|
+
super().__init__(base_url, **kwargs)
|
|
706
|
+
self.model: str = self.SystemInfo.get('model')
|
|
707
|
+
self.family: str = self.SystemInfo.get('family')
|
|
708
|
+
self.serial: str = self.SystemInfo.get('serial')
|
|
709
|
+
self.vsys_list: List[str] = self.get_vsys_list()
|
|
710
|
+
|
|
711
|
+
def get_vsys_list(self) -> List[str]:
|
|
712
|
+
"""
|
|
713
|
+
Fetches a list of virtual systems (vsys) configured on the firewall.
|
|
714
|
+
|
|
715
|
+
Returns:
|
|
716
|
+
List[str]: A list of vsys identifiers.
|
|
717
|
+
"""
|
|
718
|
+
if self.SystemInfo.get('multi-vsys', 'off') == 'off':
|
|
719
|
+
return ['vsys1']
|
|
720
|
+
|
|
721
|
+
try:
|
|
722
|
+
cmd_response = self.op('show system setting target-vsys')
|
|
723
|
+
if cmd_response.get('status') == 'success':
|
|
724
|
+
return cmd_response.get('result', [])
|
|
725
|
+
else:
|
|
726
|
+
# Log the error or raise an exception as appropriate
|
|
727
|
+
raise ValueError("Failed to retrieve vsys list.")
|
|
728
|
+
except Exception as e:
|
|
729
|
+
# Log the exception or handle it as needed
|
|
730
|
+
raise RuntimeError(f"Error retrieving vsys list: {e}")
|
|
731
|
+
|
|
732
|
+
def connect_to_panorama(self, authkey: str, panorama_ip: str) -> str:
|
|
733
|
+
"""
|
|
734
|
+
Connects this firewall to a Panorama management server using an auth key and the Panorama's IP address.
|
|
735
|
+
|
|
736
|
+
Args:
|
|
737
|
+
authkey (str): The authentication key for registering with Panorama.
|
|
738
|
+
panorama_ip (str): The IP address of the Panorama server.
|
|
739
|
+
|
|
740
|
+
Returns:
|
|
741
|
+
str: success or error
|
|
742
|
+
"""
|
|
743
|
+
xpath = "/config/devices/entry[@name='localhost.localdomain']/deviceconfig/system/panorama/local-panorama"
|
|
744
|
+
element = f'<panorama-server>{panorama_ip}</panorama-server>'
|
|
745
|
+
|
|
746
|
+
try:
|
|
747
|
+
add_panorama_result = self.set_xml(xpath, element)
|
|
748
|
+
if add_panorama_result.get('status') == 'success':
|
|
749
|
+
op_result = self.op('request authkey set', value=authkey)
|
|
750
|
+
if op_result.get('status') == 'success':
|
|
751
|
+
return {'status': 'success', 'msg': f'Added {self.serial} to Panorama {panorama_ip}.'}
|
|
752
|
+
else:
|
|
753
|
+
error_msg = f"Failed to set authkey on {self.hostname}. Response: {op_result}"
|
|
754
|
+
logger.error(error_msg)
|
|
755
|
+
return {'status': 'error', 'msg': error_msg}
|
|
756
|
+
else:
|
|
757
|
+
error_msg = f"Could not add Panorama server {panorama_ip} to firewall {self.hostname}. Response: {add_panorama_result}"
|
|
758
|
+
logger.error(error_msg)
|
|
759
|
+
return {'status':'error', 'msg': error_msg}
|
|
760
|
+
except Exception as e:
|
|
761
|
+
logger.exception(f"Unexpected error connecting {self.hostname} to Panorama {panorama_ip}: {e}")
|
|
762
|
+
return {'status': 'error', 'msg': f'Unexpected error connecting {self.hostname} to Panorama {panorama_ip}, {e}.'}
|
|
763
|
+
|
|
764
|
+
def upgrade_to_version(self, new_version: str):
|
|
765
|
+
"""
|
|
766
|
+
Perform system upgrade of a firewall device to a specified software version of PAN-OS. The method checks the
|
|
767
|
+
current version, determines if step upgrades are necessary, and handles download, installation, and reboot
|
|
768
|
+
procedures as needed.
|
|
769
|
+
|
|
770
|
+
Arguments:
|
|
771
|
+
new_version (str): Target PAN-OS version to which the device should be upgraded.
|
|
772
|
+
|
|
773
|
+
Returns:
|
|
774
|
+
dict: A dictionary containing the status of the operation ('success' or 'failure') and a message indicating
|
|
775
|
+
the result or any error encountered.
|
|
776
|
+
|
|
777
|
+
"""
|
|
778
|
+
def download_and_install(v2: str):
|
|
779
|
+
"""
|
|
780
|
+
Represents a firewall class that inherits from the PAN class. Provides functionality
|
|
781
|
+
for upgrading the firewall to a specified version. The upgrade process involves
|
|
782
|
+
downloading, installing the software version, and rebooting the device while checking
|
|
783
|
+
the device's status during the reboot process.
|
|
784
|
+
|
|
785
|
+
Attributes:
|
|
786
|
+
None
|
|
787
|
+
"""
|
|
788
|
+
logger.debug(f'Firewall {self.serial} attempting to download version {v2}')
|
|
789
|
+
op_download = self.op('request system software download version', value=v2, wait=True)
|
|
790
|
+
logger.debug(f'Firewall {self.serial} status of download operation: {op_download}')
|
|
791
|
+
if op_download['status'] == 'success':
|
|
792
|
+
op_install = self.op('request system software install version', value=v2, wait=True)
|
|
793
|
+
logger.debug(f'Firewall {self.serial} status of install operation: {op_install}')
|
|
794
|
+
if op_install['status'] == 'success':
|
|
795
|
+
self.op(f'request restart system')
|
|
796
|
+
loop = 0
|
|
797
|
+
while True:
|
|
798
|
+
# Wait for firewall to complete rebooting
|
|
799
|
+
logger.info(f'Waiting for firewall {self.serial} to come back after reboot.')
|
|
800
|
+
if loop == 0:
|
|
801
|
+
time.sleep(300)
|
|
802
|
+
else:
|
|
803
|
+
time.sleep(60)
|
|
804
|
+
try:
|
|
805
|
+
new_sysinfo = self.op('show system info')
|
|
806
|
+
if new_sysinfo.get('result'):
|
|
807
|
+
if new_sysinfo['status'] == 'success':
|
|
808
|
+
# Make sure the current object's version attribute is updated.
|
|
809
|
+
if self.SystemInfo["sw-version"] == v2:
|
|
810
|
+
self.ver = self.ver_from_sw_version(self.SystemInfo['sw-version'])
|
|
811
|
+
|
|
812
|
+
return {'status': 'success',
|
|
813
|
+
'msg': f'Device {self.serial} upgraded to {new_version}.'}
|
|
814
|
+
else:
|
|
815
|
+
return {'status': 'failure',
|
|
816
|
+
'msg': f'Device {self.serial} failed to upgrade to {new_version}.'}
|
|
817
|
+
except requests.exceptions.ConnectionError:
|
|
818
|
+
# while the firewall is down, we will get this error.
|
|
819
|
+
loop += 1
|
|
820
|
+
continue
|
|
821
|
+
except requests.exception.NewConnectionError:
|
|
822
|
+
# while the firewall is down, we will get this error.
|
|
823
|
+
loop += 1
|
|
824
|
+
continue
|
|
825
|
+
except requests.exceptions.ReadTimeout:
|
|
826
|
+
# The firewall is up, but autocommit is not yet done.
|
|
827
|
+
loop += 1
|
|
828
|
+
continue
|
|
829
|
+
loop += 1
|
|
830
|
+
if loop > 20:
|
|
831
|
+
return {'status': 'failure',
|
|
832
|
+
'msg': f'Device {self.serial} failed to come back after reboot.'}
|
|
833
|
+
|
|
834
|
+
# Map the steps needed to upgrade from one major version to the next.
|
|
835
|
+
upgrade_map = {'9.1': '10.0',
|
|
836
|
+
'10.0': '10.1',
|
|
837
|
+
'10.1': '10.2',
|
|
838
|
+
'10.2': '11.0'}
|
|
839
|
+
|
|
840
|
+
if self.SystemInfo['sw-version'] == new_version:
|
|
841
|
+
# the current and requested new version are the same, nothing to do.
|
|
842
|
+
return {'status': 'success',
|
|
843
|
+
'msg': f'The device {self.serial} is already at version {new_version}. Nothing to do.'}
|
|
844
|
+
# Get current list of available images to download
|
|
845
|
+
op_check = self.op('request system software check')
|
|
846
|
+
if op_check['status'] == 'success':
|
|
847
|
+
logger.debug(f'Firewall {self.serial} status of check operation: success')
|
|
848
|
+
else:
|
|
849
|
+
logger.error(f'Firewall {self.serial} status of check operation: {op_check}')
|
|
850
|
+
return {'status': 'failure',
|
|
851
|
+
'msg': f'Device {self.serial} failed to retrieve available software versions.'}
|
|
852
|
+
|
|
853
|
+
v1_components = self.SystemInfo['sw-version'].split('.')
|
|
854
|
+
v2_components = new_version.split('.')
|
|
855
|
+
v1_major = v1_components[0] + '.' + v1_components[1]
|
|
856
|
+
v2_major = v2_components[0] + '.' + v2_components[1]
|
|
857
|
+
logger.debug(f'Attempting to upgrade {self.serial} from {self.SystemInfo["sw-version"]} to {new_version}.')
|
|
858
|
+
if v1_major != v2_major:
|
|
859
|
+
# as of PANOS 10.2 you no longer need to step upgrade.
|
|
860
|
+
if float(v1_major) >= 10.2:
|
|
861
|
+
logger.debug(f'Firewall {self.serial} is at a version greater than or equal to 10.2.')
|
|
862
|
+
v2 = f'{v2_major}.0'
|
|
863
|
+
logger.debug(f'Firewall {self.serial} is attempting to download to {v2}.')
|
|
864
|
+
op_download = self.op('request system software download version', value=v2, wait=True)
|
|
865
|
+
logger.debug(f'Result of download: {op_download}')
|
|
866
|
+
if op_download['status'] == 'success':
|
|
867
|
+
result = download_and_install(new_version)
|
|
868
|
+
return result
|
|
869
|
+
# We have to step upgrade to get to version 2
|
|
870
|
+
while v1_major != v2_major:
|
|
871
|
+
if upgrade_map.get(v1_major) == v2_major:
|
|
872
|
+
# The next version is the next major version, just download the major version, then download and install
|
|
873
|
+
# the minor version.
|
|
874
|
+
v2 = f'{upgrade_map[v1_major]}.0'
|
|
875
|
+
# download the next major version, no need to install it.
|
|
876
|
+
op_download = self.op('request system software download version', value=v2, wait=True)
|
|
877
|
+
if op_download['status'] == 'success':
|
|
878
|
+
# Download and install the new version
|
|
879
|
+
result = download_and_install(new_version)
|
|
880
|
+
return result
|
|
881
|
+
else:
|
|
882
|
+
error_message = 'Could not install new version of OS. Please upgrade manually.'
|
|
883
|
+
logger.error(error_message)
|
|
884
|
+
return {'status': 'failure',
|
|
885
|
+
'msg': error_message}
|
|
886
|
+
else:
|
|
887
|
+
# Need to step upgrade to the new version, so keep downloading and installing major versions until
|
|
888
|
+
# we reach the final major version
|
|
889
|
+
next_version = f'{upgrade_map[v1_major]}.0'
|
|
890
|
+
result = download_and_install(next_version)
|
|
891
|
+
if result['status'] == 'success':
|
|
892
|
+
logger.info(f'Step upgrade to version {next_version} complete.')
|
|
893
|
+
else:
|
|
894
|
+
error_message = 'Could not install new version of OS. Please upgrade manually.'
|
|
895
|
+
logger.error(error_message)
|
|
896
|
+
return {'status': 'failure',
|
|
897
|
+
'msg': error_message}
|
|
898
|
+
v1_major = upgrade_map[v1_major]
|
|
899
|
+
else:
|
|
900
|
+
# The two major version are equal, so just download and install the patch.
|
|
901
|
+
logger.debug(f'Firewall {self.serial} is already at the same major version as {new_version}. ')
|
|
902
|
+
result = download_and_install(new_version)
|
|
903
|
+
return result
|
|
904
|
+
|
|
905
|
+
def sip_disable_alg(self) -> str:
|
|
906
|
+
"""
|
|
907
|
+
Disables the SIP Application Layer Gateway (ALG) on the firewall.
|
|
908
|
+
|
|
909
|
+
Returns:
|
|
910
|
+
str: The status of the operation ('success' or 'error').
|
|
911
|
+
"""
|
|
912
|
+
xpath = "/config/shared/alg-override/application/entry[@name='sip']"
|
|
913
|
+
element = "<alg-disabled>yes</alg-disabled>"
|
|
914
|
+
result = self.set_xml(xpath=xpath, element=element)
|
|
915
|
+
|
|
916
|
+
if result.get('status') == 'success':
|
|
917
|
+
return 'success'
|
|
918
|
+
else:
|
|
919
|
+
logging.error(f"Failed to disable SIP ALG. Response: {result}")
|
|
920
|
+
return 'error'
|
|
921
|
+
|
|
922
|
+
def set_telemetry(self, region: str):
|
|
923
|
+
xpath = "/config/devices/entry[@name='localhost.localdomain']/deviceconfig/system/device-telemetry"
|
|
924
|
+
element = f"<region>{region}</region>"
|
|
925
|
+
result = self.set_xml(xpath=xpath, element=element)
|
|
926
|
+
if result.get('status') == 'success':
|
|
927
|
+
return 'success'
|
|
928
|
+
else:
|
|
929
|
+
if 'set failed, may need to override template object' in result.get('msg', ''):
|
|
930
|
+
override_result = self.override(xpath=xpath, element=element)
|
|
931
|
+
if override_result['status'] == 'success':
|
|
932
|
+
logger.info(
|
|
933
|
+
f"Successfully performed telemetry override for device {self.hostname}. Retrying telemetry set.")
|
|
934
|
+
# Retry setting telemetry after the override
|
|
935
|
+
retry_result = self.set_xml(xpath, element)
|
|
936
|
+
if retry_result['status'] == 'success':
|
|
937
|
+
logger.info(f"Successfully set telemetry for device {self.hostname} after override.")
|
|
938
|
+
mark_step_completed(self.serial, 'telemetry')
|
|
939
|
+
else:
|
|
940
|
+
logger.error(
|
|
941
|
+
f"Failed to set telemetry for device {self.hostname} after override. {retry_result['msg']}")
|
|
942
|
+
else:
|
|
943
|
+
logger.error(f"Failed to override telemetry for device {self.hostname}. {override_result['msg']}")
|
|
944
|
+
|
|
945
|
+
logging.error(f"Failed to set telemetry. Response: {result}")
|
|
946
|
+
return 'error'
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
class Panorama(PAN):
|
|
951
|
+
# Panorama object using REST API
|
|
952
|
+
valid_location = ['shared', 'device-group', 'predefined']
|
|
953
|
+
|
|
954
|
+
def __init__(self, base_url: str, **kwargs):
|
|
955
|
+
super().__init__(base_url, **kwargs)
|
|
956
|
+
self.licensed_device_capacity: st = self.SystemInfo.get('licensed-device-capacity')
|
|
957
|
+
self.model: str = self.SystemInfo.get('model')
|
|
958
|
+
self.device_groups_list: Dict[str, Dict[str, str]] = self.get_device_groups()
|
|
959
|
+
self.templates: Dict[str, str] = self.get_templates(stack=False)
|
|
960
|
+
self.template_stacks: Dict[str, str] = self.get_templates(stack=True)
|
|
961
|
+
self.serial: str = self.SystemInfo.get('serial')
|
|
962
|
+
|
|
963
|
+
def get_device_groups(self) -> Optional[Dict[str, Dict[str, str]]]:
|
|
964
|
+
"""
|
|
965
|
+
Retrieves a list of all device groups and their hierarchy from Panorama using the XML API.
|
|
966
|
+
|
|
967
|
+
Returns:
|
|
968
|
+
Optional[Dict[str, Dict[str, str]]]: A dictionary with the child DG as key and a dictionary containing
|
|
969
|
+
the parent DG as value, or None if an error occurs.
|
|
970
|
+
"""
|
|
971
|
+
cmd = self.op('show dg-hierarchy')
|
|
972
|
+
if cmd.get('status') == 'success':
|
|
973
|
+
dg_groups: Dict[str, Dict[str, str]] = {}
|
|
974
|
+
dg_hierarchy = cmd.get('result', {}).get('dg-hierarchy', {}).get('dg', [])
|
|
975
|
+
|
|
976
|
+
def process_dg(dg, parent='shared'):
|
|
977
|
+
if isinstance(dg, list):
|
|
978
|
+
for child_dg in dg:
|
|
979
|
+
dg_groups[child_dg.get("@name")] = {'parent': parent}
|
|
980
|
+
# Recursive call to process nested device groups
|
|
981
|
+
process_dg(child_dg.get("dg", []), child_dg.get("@name"))
|
|
982
|
+
elif isinstance(dg, dict):
|
|
983
|
+
dg_groups[dg.get("@name")] = {'parent': parent}
|
|
984
|
+
# Recursive call to process nested device groups
|
|
985
|
+
process_dg(dg.get("dg", []), dg.get("@name"))
|
|
986
|
+
|
|
987
|
+
process_dg(dg_hierarchy)
|
|
988
|
+
return dg_groups
|
|
989
|
+
else:
|
|
990
|
+
logging.error(f'Could not get device groups for Panorama.')
|
|
991
|
+
return None
|
|
992
|
+
|
|
993
|
+
def commit_all(self, **kwargs):
|
|
994
|
+
|
|
995
|
+
def build_shared_policy(description, device_group_list, admin, force_template_values, include_template,
|
|
996
|
+
merge_with_candidate_cfg, validate_only):
|
|
997
|
+
"""
|
|
998
|
+
Builds a shared policy XML structure with the given parameters.
|
|
999
|
+
|
|
1000
|
+
Parameters:
|
|
1001
|
+
- description (str): Description of the shared policy.
|
|
1002
|
+
- device_group_list (List[str]): List of device groups to be included.
|
|
1003
|
+
- admin (List[str]): List of admins to be included.
|
|
1004
|
+
- force_template_values (str): Whether to force template values.
|
|
1005
|
+
- include_template (str): Whether to include the template.
|
|
1006
|
+
- merge_with_candidate_cfg (str): Whether to merge with candidate config.
|
|
1007
|
+
- validate_only (str): Whether the operation is validated only.
|
|
1008
|
+
|
|
1009
|
+
Returns:
|
|
1010
|
+
- str: A string representation of the XML structure for the shared policy.
|
|
1011
|
+
"""
|
|
1012
|
+
shared_policy_elem = ET.Element('shared-policy')
|
|
1013
|
+
|
|
1014
|
+
# Add description
|
|
1015
|
+
if description:
|
|
1016
|
+
ET.SubElement(shared_policy_elem, 'description').text = description
|
|
1017
|
+
|
|
1018
|
+
# Dynamically add device groups and their entries
|
|
1019
|
+
device_group_elem = ET.SubElement(shared_policy_elem, 'device-group')
|
|
1020
|
+
for device_group in device_group_list:
|
|
1021
|
+
ET.SubElement(device_group_elem, 'entry', {'name': device_group})
|
|
1022
|
+
|
|
1023
|
+
# Add admin elements
|
|
1024
|
+
admin_elem = ET.SubElement(shared_policy_elem, 'admin')
|
|
1025
|
+
for admin_name in admin:
|
|
1026
|
+
ET.SubElement(admin_elem, 'member').text = admin_name
|
|
1027
|
+
# Add other elements
|
|
1028
|
+
ET.SubElement(shared_policy_elem, 'force-template-values').text = force_template_values
|
|
1029
|
+
ET.SubElement(shared_policy_elem, 'include-template').text = include_template
|
|
1030
|
+
ET.SubElement(shared_policy_elem, 'merge-with-candidate-cfg').text = merge_with_candidate_cfg
|
|
1031
|
+
ET.SubElement(shared_policy_elem, 'validate-only').text = validate_only
|
|
1032
|
+
|
|
1033
|
+
# Convert to string for inclusion in the final XML
|
|
1034
|
+
return ET.tostring(shared_policy_elem, encoding='unicode')
|
|
1035
|
+
|
|
1036
|
+
def build_template_stack(description: str, admin: List[str], force_template_values: str,
|
|
1037
|
+
merge_with_candidate_cfg: str, validate_only: str, device: List[str],
|
|
1038
|
+
name_list: List[str]) -> str:
|
|
1039
|
+
"""
|
|
1040
|
+
Constructs an XML structure representing a template stack configuration for Palo Alto Networks devices.
|
|
1041
|
+
|
|
1042
|
+
This function creates a template stack element with specified details, including description,
|
|
1043
|
+
administrators, device members, and named entries. The resulting XML structure is intended for use
|
|
1044
|
+
in configuring template stacks on Palo Alto Networks firewalls or Panorama.
|
|
1045
|
+
|
|
1046
|
+
Parameters:
|
|
1047
|
+
- description (str): A text description of the template stack.
|
|
1048
|
+
- admin (List[str]): A list of administrator names to be included as members.
|
|
1049
|
+
- force_template_values (str): A string ('yes' or 'no') indicating whether to force template values.
|
|
1050
|
+
- merge_with_candidate_cfg (str): A string ('yes' or 'no') indicating whether to merge this configuration
|
|
1051
|
+
with the candidate configuration.
|
|
1052
|
+
- validate_only (str): A string ('yes' or 'no') indicating whether the operation should only validate
|
|
1053
|
+
the configuration without applying it.
|
|
1054
|
+
- device (List[str]): A list of device names to be included in the template stack.
|
|
1055
|
+
- name_list (List[str]): A list of names for entries within the template stack.
|
|
1056
|
+
|
|
1057
|
+
Returns:
|
|
1058
|
+
- str: A string representation of the XML structure for the template stack configuration.
|
|
1059
|
+
|
|
1060
|
+
Note:
|
|
1061
|
+
- The 'admin' parameter's members are added under the 'admin' element, each as a 'member' sub-element.
|
|
1062
|
+
- Devices are added under a 'device' element, each as a 'member' sub-element.
|
|
1063
|
+
- Named entries are added directly under the root 'template_stack' element as 'entry' elements with a 'name' attribute.
|
|
1064
|
+
"""
|
|
1065
|
+
template_stack_elem = ET.Element('template_stack')
|
|
1066
|
+
|
|
1067
|
+
# Add description
|
|
1068
|
+
if description:
|
|
1069
|
+
ET.SubElement(template_stack_elem, 'description').text = description
|
|
1070
|
+
|
|
1071
|
+
# Add admin elements
|
|
1072
|
+
admin_elem = ET.SubElement(shared_policy_elem, 'admin')
|
|
1073
|
+
for admin_name in admin:
|
|
1074
|
+
ET.SubElement(admin_elem, 'member').text = admin_name
|
|
1075
|
+
|
|
1076
|
+
# Add other elements
|
|
1077
|
+
ET.SubElement(template_stack_elem, 'force-template-values').text = force_template_values
|
|
1078
|
+
ET.SubElement(template_stack_elem, 'merge-with-candidate-cfg').text = merge_with_candidate_cfg
|
|
1079
|
+
ET.SubElement(template_stack_elem, 'validate-only').text = validate_only
|
|
1080
|
+
|
|
1081
|
+
# Dynamically add devices
|
|
1082
|
+
device_elem = ET.SubElement(template_stack_elem, 'device')
|
|
1083
|
+
for device_member in device:
|
|
1084
|
+
ET.SubElement(device_elem, 'member').text = device_member
|
|
1085
|
+
|
|
1086
|
+
# Dynamically add names as entry elements
|
|
1087
|
+
for name in name_list:
|
|
1088
|
+
ET.SubElement(template_stack_elem, 'entry', {'name': name})
|
|
1089
|
+
|
|
1090
|
+
# Convert to string for inclusion in the final XML
|
|
1091
|
+
return ET.tostring(template_stack_elem, encoding='unicode')
|
|
1092
|
+
|
|
1093
|
+
def build_log_collector_config(description, log_collector_group):
|
|
1094
|
+
# Create the root element for the log collector config
|
|
1095
|
+
log_collector_config_elem = ET.Element('log-collector-config')
|
|
1096
|
+
|
|
1097
|
+
# Add description
|
|
1098
|
+
if description:
|
|
1099
|
+
ET.SubElement(log_collector_config_elem, 'description').text = description
|
|
1100
|
+
|
|
1101
|
+
# Add log collector group if specified
|
|
1102
|
+
if log_collector_group:
|
|
1103
|
+
ET.SubElement(log_collector_config_elem, 'log-collector-group').text = log_collector_group
|
|
1104
|
+
|
|
1105
|
+
# Convert to string for inclusion in the final XML
|
|
1106
|
+
return ET.tostring(log_collector_config_elem, encoding='unicode')
|
|
1107
|
+
|
|
1108
|
+
root = ET.Element('commit-all')
|
|
1109
|
+
|
|
1110
|
+
# Check and build log collector config if applicable
|
|
1111
|
+
if kwargs.get('log_collector_config', False):
|
|
1112
|
+
log_collector_xml = build_log_collector_config(
|
|
1113
|
+
description=kwargs.get('description', ''),
|
|
1114
|
+
log_collector_group=kwargs.get('log_collector_group', '')
|
|
1115
|
+
)
|
|
1116
|
+
root.append(ET.fromstring(log_collector_xml))
|
|
1117
|
+
|
|
1118
|
+
# Check and build shared policy if applicable
|
|
1119
|
+
if kwargs.get('shared_policy', False):
|
|
1120
|
+
shared_policy_xml = build_shared_policy(
|
|
1121
|
+
description=kwargs.get('description', ''),
|
|
1122
|
+
device_group_list=kwargs.get('device_group_list', []),
|
|
1123
|
+
admin=kwargs.get('admin', []),
|
|
1124
|
+
force_template_values=kwargs.get('force_template_values', 'no'),
|
|
1125
|
+
include_template=kwargs.get('include_template', 'yes'),
|
|
1126
|
+
merge_with_candidate_cfg=kwargs.get('merge_with_candidate_cfg', 'yes'),
|
|
1127
|
+
validate_only=kwargs.get('validate_only', 'no')
|
|
1128
|
+
)
|
|
1129
|
+
root.append(ET.fromstring(shared_policy_xml))
|
|
1130
|
+
|
|
1131
|
+
# Check and build template stack if applicable
|
|
1132
|
+
if kwargs.get('template_stack', False):
|
|
1133
|
+
template_stack_xml = build_template_stack(
|
|
1134
|
+
description=kwargs.get('description', ''),
|
|
1135
|
+
admin=kwargs.get('admin', []),
|
|
1136
|
+
force_template_values=kwargs.get('force_template_values', 'no'),
|
|
1137
|
+
merge_with_candidate_cfg=kwargs.get('merge_with_candidate_cfg', 'yes'),
|
|
1138
|
+
validate_only=kwargs.get('validate_only', 'no'),
|
|
1139
|
+
device=kwargs.get('device', []),
|
|
1140
|
+
name_list=kwargs.get('name', [])
|
|
1141
|
+
)
|
|
1142
|
+
root.append(ET.fromstring(template_stack_xml))
|
|
1143
|
+
|
|
1144
|
+
params = {
|
|
1145
|
+
'key': self.api_key,
|
|
1146
|
+
'type': 'commit',
|
|
1147
|
+
'action': 'all',
|
|
1148
|
+
'cmd': ET.tostring(root, encoding='unicode')
|
|
1149
|
+
}
|
|
1150
|
+
response = self.xml_request(params=params)
|
|
1151
|
+
if response.get('response', {}).get('@status', {}) == 'success':
|
|
1152
|
+
return response.get('response', {}).get('result', {}).get('job')
|
|
1153
|
+
|
|
1154
|
+
def get_device_license_info(self) -> Tuple[List[str], List[Dict[str, any]]]:
|
|
1155
|
+
"""
|
|
1156
|
+
Retrieves license information for all devices managed by Panorama.
|
|
1157
|
+
|
|
1158
|
+
Returns:
|
|
1159
|
+
Tuple[List[str], List[Dict[str, any]]]: A tuple containing a list of column headers and a list of dictionaries
|
|
1160
|
+
with license information for each device.
|
|
1161
|
+
"""
|
|
1162
|
+
report = []
|
|
1163
|
+
columns = [
|
|
1164
|
+
'serial', 'device name', 'support', 'Threat Prevention', 'GlobalProtect Gateway',
|
|
1165
|
+
'PAN-DB URL Filtering', 'DNS Security', 'WildFire License', 'Advanced WildFire License', 'SD WAN',
|
|
1166
|
+
'Advanced URL Filtering', 'Premium Partner', 'Device Management License'
|
|
1167
|
+
]
|
|
1168
|
+
op_response = self.op('request batch license info')
|
|
1169
|
+
|
|
1170
|
+
if op_response.get('status') == 'success':
|
|
1171
|
+
list_of_devices = op_response.get('result', {}).get('devices', {}).get('entry', [])
|
|
1172
|
+
|
|
1173
|
+
for device in list_of_devices:
|
|
1174
|
+
licenses = {'serial': device.get('serial-no'), 'device name': device.get('devicename')}
|
|
1175
|
+
entry = device.get('licenses', {}).get('entry')
|
|
1176
|
+
# Check if 'entry' is a dictionary and convert it to a list if it is
|
|
1177
|
+
if isinstance(entry, dict):
|
|
1178
|
+
entry = [entry]
|
|
1179
|
+
for pan_license in entry:
|
|
1180
|
+
license_type = pan_license.get('type')
|
|
1181
|
+
expiry_date = pan_license.get('expiry-date')
|
|
1182
|
+
|
|
1183
|
+
if license_type == 'SUP':
|
|
1184
|
+
licenses['support'] = expiry_date
|
|
1185
|
+
elif license_type == 'SUB' or 'RENSUB':
|
|
1186
|
+
licenses[pan_license.get('@name')] = expiry_date
|
|
1187
|
+
report.append(licenses)
|
|
1188
|
+
|
|
1189
|
+
return columns, report
|
|
1190
|
+
|
|
1191
|
+
def get_fw_name_list(self) -> List[str]:
|
|
1192
|
+
"""
|
|
1193
|
+
Retrieves a list of all connected firewall hostnames managed by Panorama.
|
|
1194
|
+
|
|
1195
|
+
Returns:
|
|
1196
|
+
List[str]: A list containing the hostnames of all connected firewalls.
|
|
1197
|
+
"""
|
|
1198
|
+
op_response = self.op('show devices connected')
|
|
1199
|
+
firewall_name_list = []
|
|
1200
|
+
|
|
1201
|
+
if op_response.get('response', {}).get('@status') == 'success':
|
|
1202
|
+
devices = op_response.get('response', {}).get('result', {}).get('devices', {}).get('entry', [])
|
|
1203
|
+
|
|
1204
|
+
for device in devices:
|
|
1205
|
+
hostname = device.get('hostname')
|
|
1206
|
+
if hostname:
|
|
1207
|
+
firewall_name_list.append(hostname)
|
|
1208
|
+
|
|
1209
|
+
return firewall_name_list
|
|
1210
|
+
|
|
1211
|
+
def get_firewall_connected(self) -> List[Dict[str, str]]:
|
|
1212
|
+
"""
|
|
1213
|
+
Retrieves a list of all connected firewalls with their details from Panorama.
|
|
1214
|
+
|
|
1215
|
+
Returns:
|
|
1216
|
+
List[Dict[str, str]]: A list of dictionaries, each containing details of a firewall.
|
|
1217
|
+
"""
|
|
1218
|
+
connected = self.op('show devices connected')
|
|
1219
|
+
if connected.get('status') == 'success':
|
|
1220
|
+
fw_list = connected.get('result', {}).get('devices', {}).get('entry', [])
|
|
1221
|
+
return [
|
|
1222
|
+
{'serial': fw.get('serial', ''),
|
|
1223
|
+
'ip_address': fw.get('ip-address', ''),
|
|
1224
|
+
'hostname': fw.get('hostname', ''),
|
|
1225
|
+
'model': fw.get('model', '')}
|
|
1226
|
+
for fw in fw_list
|
|
1227
|
+
]
|
|
1228
|
+
return []
|
|
1229
|
+
|
|
1230
|
+
def get_panorama_authkey(self, existing_key_name: Optional[str] = None, new_key_name: str = 'key1') -> Optional[str]:
|
|
1231
|
+
"""
|
|
1232
|
+
Retrieves or generates an authorization key for connecting a firewall to Panorama.
|
|
1233
|
+
|
|
1234
|
+
Parameters:
|
|
1235
|
+
existing_key_name (Optional[str]): Name of an existing auth key in Panorama. If provided, the function will
|
|
1236
|
+
try to retrieve this key if it's valid.
|
|
1237
|
+
new_key_name (str): Name of the new key to create. This parameter is used if a new key needs to be generated.
|
|
1238
|
+
|
|
1239
|
+
Returns:
|
|
1240
|
+
Optional[str]: The auth key if successful, None otherwise.
|
|
1241
|
+
"""
|
|
1242
|
+
authkey = None
|
|
1243
|
+
|
|
1244
|
+
def add_key(name: str) -> None:
|
|
1245
|
+
nonlocal authkey
|
|
1246
|
+
op = self.op(f'request authkey add name', value=name)
|
|
1247
|
+
if op.get('status') == 'success':
|
|
1248
|
+
authkey = op.get('result', {}).get('authkey')
|
|
1249
|
+
|
|
1250
|
+
def list_keys() -> list:
|
|
1251
|
+
op = self.op('request authkey list', value='*')
|
|
1252
|
+
if op.get('status') == 'success':
|
|
1253
|
+
authkey = op.get('result', {}).get('authkey', None)
|
|
1254
|
+
if authkey is None:
|
|
1255
|
+
return [] # Handle None case by returning an empty list
|
|
1256
|
+
entry = authkey.get('entry', [])
|
|
1257
|
+
if isinstance(entry, list):
|
|
1258
|
+
return entry
|
|
1259
|
+
else:
|
|
1260
|
+
return [entry]
|
|
1261
|
+
return []
|
|
1262
|
+
|
|
1263
|
+
if existing_key_name:
|
|
1264
|
+
op = self.op(f'request authkey list', value=existing_key_name)
|
|
1265
|
+
if op.get('status') == 'success':
|
|
1266
|
+
authkey_entry = op.get('result', {}).get('authkey', {}).get('entry', {})
|
|
1267
|
+
lifetime = authkey_entry.get('lifetime')
|
|
1268
|
+
count = authkey_entry.get('count')
|
|
1269
|
+
if int(lifetime) > 600 and int(count) > 1:
|
|
1270
|
+
return authkey_entry.get('key')
|
|
1271
|
+
else:
|
|
1272
|
+
self.op(f'request authkey delete', value=existing_key_name)
|
|
1273
|
+
|
|
1274
|
+
add_key(new_key_name if not authkey else new_key_name)
|
|
1275
|
+
|
|
1276
|
+
# After adding a new key or if no existing key was found, validate and return the new key
|
|
1277
|
+
for key in list_keys():
|
|
1278
|
+
if int(key.get('lifetime', 0)) > 600 and int(key.get('count', 0)) > 1 and key.get('name') == new_key_name:
|
|
1279
|
+
op_list_key = self.op(f'request authkey list', value=new_key_name)
|
|
1280
|
+
return op_list_key.get('result', {}).get('authkey', {}).get('entry', {}).get('key')
|
|
1281
|
+
|
|
1282
|
+
return authkey
|
|
1283
|
+
|
|
1284
|
+
def add_device(self, serial: str) -> str:
|
|
1285
|
+
"""
|
|
1286
|
+
Adds a device to Panorama using the specified serial number.
|
|
1287
|
+
|
|
1288
|
+
Parameters:
|
|
1289
|
+
serial (str): Serial number of the device to add to Panorama.
|
|
1290
|
+
|
|
1291
|
+
Returns:
|
|
1292
|
+
str: The status of the operation ('success' or 'error').
|
|
1293
|
+
"""
|
|
1294
|
+
# Ensure the serial number is properly formatted for XML
|
|
1295
|
+
serial_formatted = f"'{serial}'"
|
|
1296
|
+
xpath = '/config/mgt-config/devices'
|
|
1297
|
+
element = f'<entry name={serial_formatted}/>'
|
|
1298
|
+
|
|
1299
|
+
response = self.set_xml(xpath, element)
|
|
1300
|
+
|
|
1301
|
+
# Check the response status and return it
|
|
1302
|
+
return response.get('status', 'error')
|
|
1303
|
+
|
|
1304
|
+
def get_templates(self, stack: bool) -> list:
|
|
1305
|
+
"""
|
|
1306
|
+
Returns a list of template names or template stack names based on the 'stack' parameter.
|
|
1307
|
+
Template Stacks vs Templates are determined if the key 'template-stack' is 'yes' or 'no'.
|
|
1308
|
+
|
|
1309
|
+
:param stack: A boolean indicating whether to return template stack names (True) or template names (False).
|
|
1310
|
+
:return: A list of template names or template stack names.
|
|
1311
|
+
"""
|
|
1312
|
+
result: dict = self.op('show templates')
|
|
1313
|
+
template_list: list = []
|
|
1314
|
+
if result.get('status') == 'success':
|
|
1315
|
+
for entry in result.get('result', {}).get('templates', {}).get('entry', []):
|
|
1316
|
+
if stack and entry.get('template-stack', 'no') == 'yes':
|
|
1317
|
+
# Append the template stack name to the template_list
|
|
1318
|
+
template_list.append(entry.get('@name'))
|
|
1319
|
+
elif not stack and entry.get('template-stack', 'no') == 'no':
|
|
1320
|
+
# Append the template name to the template_list
|
|
1321
|
+
template_list.append(entry.get('@name'))
|
|
1322
|
+
return template_list
|
|
1323
|
+
|
|
1324
|
+
class PanProtocol(Protocol):
|
|
1325
|
+
# Define common interface that all ObjectTab subclasses must have
|
|
1326
|
+
location: str
|
|
1327
|
+
name: str
|
|
1328
|
+
PANDevice: Panorama | Firewall
|
|
1329
|
+
loc: str
|
|
1330
|
+
device_group: str
|
|
1331
|
+
description: str
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
# This creates a type variable that can be any subclass of Object or Policy that fits the PanProtocol
|
|
1335
|
+
T = TypeVar('T', bound='PanProtocol')
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
class Base:
|
|
1339
|
+
def __init__(self, PANDevice: Union[Panorama, Firewall], **kwargs):
|
|
1340
|
+
self.PANDevice: Union[Panorama, Firewall] = PANDevice
|
|
1341
|
+
self.entry: Dict = {}
|
|
1342
|
+
self.max_name_length: int = kwargs.get('max_name_length', 31)
|
|
1343
|
+
self.max_description_length: int = kwargs.get('max_description_length', 255)
|
|
1344
|
+
self.valid_location = self.PANDevice.valid_location
|
|
1345
|
+
self.composite_keys: Set[str] = set() # Stores composite keys for quick existence checks
|
|
1346
|
+
self.location = kwargs.get('location') or self.determine_location(kwargs)
|
|
1347
|
+
self.loc = self.determine_location(kwargs)
|
|
1348
|
+
self.name: str = kwargs.get('name')
|
|
1349
|
+
self.description: str = kwargs.get('description')
|
|
1350
|
+
self.tag: Dict[str, List[str]] = kwargs.get('tag')
|
|
1351
|
+
self.loc = self.determine_location(kwargs)
|
|
1352
|
+
self.device_group: str = kwargs.get('device_group')
|
|
1353
|
+
self.template: str = kwargs.get('template')
|
|
1354
|
+
self.template_stack: str = kwargs.get('template_stack')
|
|
1355
|
+
self.vsys = kwargs.get('vsys')
|
|
1356
|
+
# self.rulebase: str = None
|
|
1357
|
+
|
|
1358
|
+
def __str__(self):
|
|
1359
|
+
return self.name
|
|
1360
|
+
|
|
1361
|
+
def __repr__(self):
|
|
1362
|
+
class_name = self.__class__.__name__
|
|
1363
|
+
return f"{class_name}(name={self.name!r}, location={self.location!r})"
|
|
1364
|
+
|
|
1365
|
+
@property
|
|
1366
|
+
def name(self) -> str:
|
|
1367
|
+
return self._name
|
|
1368
|
+
|
|
1369
|
+
@name.setter
|
|
1370
|
+
def name(self, value: str) -> None:
|
|
1371
|
+
if value:
|
|
1372
|
+
if not self.PANDevice.valid_name(value, self.max_name_length):
|
|
1373
|
+
raise ValueError(f"Provided name '{value}' is invalid.")
|
|
1374
|
+
self._name = value
|
|
1375
|
+
self.entry.update({'@name': self._name})
|
|
1376
|
+
else:
|
|
1377
|
+
self._name = None
|
|
1378
|
+
|
|
1379
|
+
@property
|
|
1380
|
+
def description(self) -> str:
|
|
1381
|
+
return self._description
|
|
1382
|
+
|
|
1383
|
+
@description.setter
|
|
1384
|
+
def description(self, value: str) -> None:
|
|
1385
|
+
if value:
|
|
1386
|
+
if not isinstance(value, str):
|
|
1387
|
+
raise TypeError("Description must be a string.")
|
|
1388
|
+
|
|
1389
|
+
if len(value) > self.max_description_length:
|
|
1390
|
+
raise ValueError(f"Description exceeds the maximum length of {self.max_description_length} characters.")
|
|
1391
|
+
|
|
1392
|
+
self._description = value
|
|
1393
|
+
self.entry.update({'description': self._description})
|
|
1394
|
+
|
|
1395
|
+
@property
|
|
1396
|
+
def location(self) -> str:
|
|
1397
|
+
return self._location
|
|
1398
|
+
|
|
1399
|
+
@location.setter
|
|
1400
|
+
def location(self, value: str) -> None:
|
|
1401
|
+
if isinstance(self.PANDevice, Firewall):
|
|
1402
|
+
self._location = value
|
|
1403
|
+
elif value not in self.valid_location:
|
|
1404
|
+
raise ValueError(f"Invalid location. Must be one of: {self.valid_location}")
|
|
1405
|
+
self._location = value
|
|
1406
|
+
|
|
1407
|
+
@property
|
|
1408
|
+
def tag(self) -> Dict[str, List[str]]:
|
|
1409
|
+
return self._tag
|
|
1410
|
+
|
|
1411
|
+
@tag.setter
|
|
1412
|
+
def tag(self, value: Dict[str, List[str]]) -> None:
|
|
1413
|
+
if value:
|
|
1414
|
+
self.validate_member_dict(value, 'tag')
|
|
1415
|
+
self._tag = value
|
|
1416
|
+
self.entry.update({'tag': value})
|
|
1417
|
+
|
|
1418
|
+
@property
|
|
1419
|
+
def device_group(self) -> str:
|
|
1420
|
+
return self._device_group
|
|
1421
|
+
|
|
1422
|
+
@device_group.setter
|
|
1423
|
+
def device_group(self, value: str) -> None:
|
|
1424
|
+
if value:
|
|
1425
|
+
if not isinstance(self.PANDevice, Panorama):
|
|
1426
|
+
raise TypeError("device_group can only be set for Panorama devices.")
|
|
1427
|
+
|
|
1428
|
+
if self.location != 'device-group':
|
|
1429
|
+
raise ValueError("device_group can only be set when location is 'device-group'.")
|
|
1430
|
+
|
|
1431
|
+
if value not in self.PANDevice.device_groups_list:
|
|
1432
|
+
raise ValueError(f"Invalid device group: {value}. Must be one of: {self.PANDevice.device_groups_list}")
|
|
1433
|
+
|
|
1434
|
+
self._device_group = value
|
|
1435
|
+
|
|
1436
|
+
@property
|
|
1437
|
+
def vsys(self) -> str:
|
|
1438
|
+
return self._vsys
|
|
1439
|
+
|
|
1440
|
+
@vsys.setter
|
|
1441
|
+
def vsys(self, value: str) -> None:
|
|
1442
|
+
if value:
|
|
1443
|
+
# Check if the PANDevice is of type Firewall
|
|
1444
|
+
# if not isinstance(self.PANDevice, Firewall):
|
|
1445
|
+
# raise TypeError("vsys can only be set for Firewall devices.")
|
|
1446
|
+
|
|
1447
|
+
# # Check if the location is 'vsys' or 'panorama-pushed'
|
|
1448
|
+
# if self.location not in ['vsys', 'panorama-pushed']:
|
|
1449
|
+
# raise ValueError("vsys can only be set when location is 'vsys' or 'panorama-pushed'.")
|
|
1450
|
+
|
|
1451
|
+
# Check if the provided vsys value is in the PANDevice.vsys_list
|
|
1452
|
+
# if value not in self.PANDevice.vsys_list:
|
|
1453
|
+
# raise ValueError(f"Invalid vsys: {value}. Must be one of: {self.PANDevice.vsys_list}")
|
|
1454
|
+
|
|
1455
|
+
self._vsys = value
|
|
1456
|
+
self.entry.update({'vsys': value})
|
|
1457
|
+
|
|
1458
|
+
@staticmethod
|
|
1459
|
+
def validate_member_dict(value: Dict[str, List[str]], attribute_name: str) -> None:
|
|
1460
|
+
if not isinstance(value, dict):
|
|
1461
|
+
raise TypeError(f"{attribute_name} must be a dictionary.")
|
|
1462
|
+
|
|
1463
|
+
if 'member' not in value:
|
|
1464
|
+
raise ValueError(f"Dictionary for {attribute_name} must contain the 'member' key.")
|
|
1465
|
+
|
|
1466
|
+
if not isinstance(value['member'], list):
|
|
1467
|
+
raise TypeError(f"'member' key in {attribute_name} should be associated with a list.")
|
|
1468
|
+
|
|
1469
|
+
if not all(isinstance(item, str) for item in value['member']):
|
|
1470
|
+
raise ValueError(f"All items in the 'member' list of {attribute_name} must be strings.")
|
|
1471
|
+
|
|
1472
|
+
def determine_location(self, kwargs: Dict[str, Any]) -> str:
|
|
1473
|
+
"""Determines location based on PANDevice type and kwargs."""
|
|
1474
|
+
if isinstance(self.PANDevice, Panorama):
|
|
1475
|
+
return kwargs.get('device_group', 'shared')
|
|
1476
|
+
elif isinstance(self.PANDevice, Firewall):
|
|
1477
|
+
return kwargs.get('vsys', '')
|
|
1478
|
+
return 'shared'
|
|
1479
|
+
|
|
1480
|
+
@staticmethod
|
|
1481
|
+
def _create_composite_key(location: str, name: str) -> str:
|
|
1482
|
+
"""Creates a composite key based on location and name."""
|
|
1483
|
+
return f"{location}:{name}"
|
|
1484
|
+
|
|
1485
|
+
def add_cache(self, pan_object: T) -> None:
|
|
1486
|
+
"""Adds an object to the cache if not already present, using a composite key."""
|
|
1487
|
+
# Determine the appropriate location for the composite key
|
|
1488
|
+
location_for_key = pan_object.location if self.location == 'shared' else pan_object.loc
|
|
1489
|
+
|
|
1490
|
+
# Create the composite key using the determined location
|
|
1491
|
+
composite_key = self._create_composite_key(location_for_key, pan_object.name)
|
|
1492
|
+
if composite_key not in self.composite_keys:
|
|
1493
|
+
self.pan_objects[composite_key] = pan_object
|
|
1494
|
+
self.composite_keys.add(composite_key)
|
|
1495
|
+
|
|
1496
|
+
def exists_cache(self, location, name):
|
|
1497
|
+
"""Checks if a pan_object exists in the cache based on its composite key."""
|
|
1498
|
+
composite_key = self._create_composite_key(location, name)
|
|
1499
|
+
return composite_key in self.composite_keys
|
|
1500
|
+
|
|
1501
|
+
def get_cache(self, location, name):
|
|
1502
|
+
"""Returns the pan_object object for the given location and name if it exists."""
|
|
1503
|
+
composite_key = self._create_composite_key(location, name)
|
|
1504
|
+
return self.pan_objects.get(composite_key)
|
|
1505
|
+
|
|
1506
|
+
def clear_cache(self):
|
|
1507
|
+
"""Clears the cache."""
|
|
1508
|
+
self.pan_objects.clear()
|
|
1509
|
+
self.composite_keys.clear()
|
|
1510
|
+
|
|
1511
|
+
def refresh_cache(self):
|
|
1512
|
+
"""Fetches all address objects and stores them in the cache."""
|
|
1513
|
+
data = self.get()
|
|
1514
|
+
if data:
|
|
1515
|
+
for entry in data:
|
|
1516
|
+
# Dynamically create an instance of the subclass from which this method was called
|
|
1517
|
+
instance = self.__class__(**entry)
|
|
1518
|
+
self.add_cache(instance)
|
|
1519
|
+
|
|
1520
|
+
def _build_params(self) -> Dict[str, str]:
|
|
1521
|
+
"""
|
|
1522
|
+
Builds the parameter dictionary for the API request based on the object's state.
|
|
1523
|
+
|
|
1524
|
+
Returns:
|
|
1525
|
+
Dict[str, str]: The parameters for the API request.
|
|
1526
|
+
"""
|
|
1527
|
+
params = {'location': self.location} if self.location else {}
|
|
1528
|
+
if self.name:
|
|
1529
|
+
params['name'] = self.name
|
|
1530
|
+
if self.location in ['template', 'vsys']:
|
|
1531
|
+
params.update({self.location: self.loc})
|
|
1532
|
+
if self.location == 'device-group':
|
|
1533
|
+
params.update({self.location: self.device_group})
|
|
1534
|
+
if type(self).__name__ == 'Zones' and hasattr(self, 'vsys'):
|
|
1535
|
+
params['vsys'] = self.vsys
|
|
1536
|
+
|
|
1537
|
+
return params
|
|
1538
|
+
|
|
1539
|
+
def get(self) -> Optional[List[Dict[str, Any]]]:
|
|
1540
|
+
"""
|
|
1541
|
+
Retrieve details for the current tab based on its name and location.
|
|
1542
|
+
|
|
1543
|
+
Returns:
|
|
1544
|
+
Optional[List[Dict[str, Any]]]: A list of entries from the API response or None if an error occurs.
|
|
1545
|
+
"""
|
|
1546
|
+
params = self._build_params()
|
|
1547
|
+
try:
|
|
1548
|
+
response = self.rest_request('GET', params=params)
|
|
1549
|
+
except requests.exceptions.RequestException as e:
|
|
1550
|
+
logging.error(f'Failed to get {self.__class__.__name__}: {e}')
|
|
1551
|
+
raise Exception(f'Network request failed: {e}') from e
|
|
1552
|
+
|
|
1553
|
+
if response.get('@status') != 'success':
|
|
1554
|
+
logging.error(f'API returned error: {response.get("message", "Unknown error")}')
|
|
1555
|
+
return None
|
|
1556
|
+
|
|
1557
|
+
return response.get('result', {}).get('entry')
|
|
1558
|
+
|
|
1559
|
+
def get_all(self) -> List[Dict[str, Any]]:
|
|
1560
|
+
"""
|
|
1561
|
+
Get a list of all elements in this section.
|
|
1562
|
+
|
|
1563
|
+
Returns:
|
|
1564
|
+
List[Dict[str, Any]]: A list of entries from the API response.
|
|
1565
|
+
|
|
1566
|
+
Raises:
|
|
1567
|
+
Exception: If the API request fails or returns an error status.
|
|
1568
|
+
"""
|
|
1569
|
+
params = self._build_params()
|
|
1570
|
+
|
|
1571
|
+
try:
|
|
1572
|
+
response = self.rest_request('GET', params=params)
|
|
1573
|
+
except requests.exceptions.RequestException as e:
|
|
1574
|
+
logging.error(f'Network request failed: {e}')
|
|
1575
|
+
raise Exception(f'Network request failed: {e}') from e
|
|
1576
|
+
|
|
1577
|
+
if response.get('@status') != 'success':
|
|
1578
|
+
error_message = response.get('message', 'Unknown error')
|
|
1579
|
+
logging.error(error_message)
|
|
1580
|
+
raise Exception(f'API request failed: {error_message}')
|
|
1581
|
+
|
|
1582
|
+
return response.get('result', {}).get('entry', [])
|
|
1583
|
+
|
|
1584
|
+
def create(self) -> bool:
|
|
1585
|
+
"""
|
|
1586
|
+
Attempts to create a new entry in the network configuration.
|
|
1587
|
+
|
|
1588
|
+
Returns:
|
|
1589
|
+
bool: True if the entry was successfully created, False otherwise.
|
|
1590
|
+
|
|
1591
|
+
Raises:
|
|
1592
|
+
NetworkRequestError: If there's an issue with the network request.
|
|
1593
|
+
APIResponseError: If the API returns a non-success status.
|
|
1594
|
+
"""
|
|
1595
|
+
params = self._build_params()
|
|
1596
|
+
data = {'entry': self.entry}
|
|
1597
|
+
try:
|
|
1598
|
+
response = self.rest_request('POST', params=params, json=data)
|
|
1599
|
+
except requests.exceptions.RequestException as e:
|
|
1600
|
+
logging.error(f'Failed to create {self.__class__.__name__}: {e}')
|
|
1601
|
+
raise NetworkRequestError(f'Network request failed for {self.PANDevice.base_url}', {'error': str(e)})
|
|
1602
|
+
|
|
1603
|
+
if response.get('@status') != 'success':
|
|
1604
|
+
logging.error(f'API returned error during create: message: {response.get("message", "Unknown error")}, details: {response.get("details", "Unknown error")}')
|
|
1605
|
+
raise APIResponseError('API request did not return success during create', response)
|
|
1606
|
+
|
|
1607
|
+
return True
|
|
1608
|
+
|
|
1609
|
+
def edit(self) -> bool:
|
|
1610
|
+
"""
|
|
1611
|
+
Attempts to edit an existing entry in the network configuration.
|
|
1612
|
+
|
|
1613
|
+
Returns:
|
|
1614
|
+
bool: True if the entry was successfully edited.
|
|
1615
|
+
|
|
1616
|
+
Raises:
|
|
1617
|
+
NetworkRequestError: If there's an issue with the network request.
|
|
1618
|
+
APIResponseError: If the API returns a non-success status.
|
|
1619
|
+
"""
|
|
1620
|
+
params = self._build_params()
|
|
1621
|
+
data = {'entry': self.entry}
|
|
1622
|
+
try:
|
|
1623
|
+
# Log request details for debugging
|
|
1624
|
+
logging.debug(
|
|
1625
|
+
f'Attempting to edit {self.__class__.__name__}. URL: {self.base_url}, Params: {params}, Data: {data}')
|
|
1626
|
+
response = self.rest_request('PUT', params=params, json=data)
|
|
1627
|
+
|
|
1628
|
+
logging.debug(f'Received response: {response}')
|
|
1629
|
+
except requests.exceptions.HTTPError as e:
|
|
1630
|
+
# Log and raise a detailed network error
|
|
1631
|
+
logging.error(f'Network request failed for {self.base_url}. Exception: {e}')
|
|
1632
|
+
# Additional details for debugging
|
|
1633
|
+
raise NetworkRequestError(
|
|
1634
|
+
f"Network request failed for {self.base_url}",
|
|
1635
|
+
{
|
|
1636
|
+
'error': str(e),
|
|
1637
|
+
'url': self.base_url,
|
|
1638
|
+
'params': params,
|
|
1639
|
+
'data': data
|
|
1640
|
+
}
|
|
1641
|
+
)
|
|
1642
|
+
|
|
1643
|
+
# Check response status
|
|
1644
|
+
if response.get('@status') != 'success':
|
|
1645
|
+
# Log detailed error about the API response
|
|
1646
|
+
logging.error(f"API error during edit. Params: {params}, Data: {data}, Response: {response}")
|
|
1647
|
+
raise APIResponseError(
|
|
1648
|
+
"API request did not return success during edit",
|
|
1649
|
+
{
|
|
1650
|
+
'response': response,
|
|
1651
|
+
'params': params,
|
|
1652
|
+
'data': data
|
|
1653
|
+
}
|
|
1654
|
+
)
|
|
1655
|
+
|
|
1656
|
+
logging.debug(f'Edit successful for {self.__class__.__name__}: {self.entry}')
|
|
1657
|
+
return True
|
|
1658
|
+
|
|
1659
|
+
def delete(self) -> bool:
|
|
1660
|
+
"""
|
|
1661
|
+
Attempts to delete an existing entry in the network configuration.
|
|
1662
|
+
|
|
1663
|
+
Returns:
|
|
1664
|
+
bool: True if the entry was successfully deleted.
|
|
1665
|
+
|
|
1666
|
+
Raises:
|
|
1667
|
+
NetworkRequestError: If there's an issue with the network request.
|
|
1668
|
+
APIResponseError: If the API returns a non-success status.
|
|
1669
|
+
"""
|
|
1670
|
+
params = self._build_params()
|
|
1671
|
+
|
|
1672
|
+
try:
|
|
1673
|
+
response = self.rest_request('DELETE', params=params)
|
|
1674
|
+
except requests.exceptions.RequestException as e:
|
|
1675
|
+
logging.error(f'Failed to delete {self.__class__.__name__}: {e}')
|
|
1676
|
+
raise NetworkRequestError(f'Network request failed for {self.base_url}', {'error': str(e)})
|
|
1677
|
+
|
|
1678
|
+
if response.get('@status') != 'success':
|
|
1679
|
+
logging.error(f'API returned error during delete: {data.get("message", "Unknown error")}')
|
|
1680
|
+
raise APIResponseError('API request did not return success during delete', response)
|
|
1681
|
+
|
|
1682
|
+
return True
|
|
1683
|
+
|
|
1684
|
+
def rename(self, newname: str) -> bool:
|
|
1685
|
+
"""
|
|
1686
|
+
Attempts to rename an existing entry in the network configuration.
|
|
1687
|
+
|
|
1688
|
+
Parameters:
|
|
1689
|
+
newname (str): The new name for the item.
|
|
1690
|
+
|
|
1691
|
+
Returns:
|
|
1692
|
+
bool: True if the item was successfully renamed.
|
|
1693
|
+
|
|
1694
|
+
Raises:
|
|
1695
|
+
ValueError: If the new name does not meet validation criteria.
|
|
1696
|
+
NetworkRequestError: If there's an issue with the network request.
|
|
1697
|
+
APIResponseError: If the API returns a non-success status.
|
|
1698
|
+
"""
|
|
1699
|
+
# Validate the new name
|
|
1700
|
+
if not self.valid_name(newname, self.max_name_length):
|
|
1701
|
+
raise ValueError(f'Invalid new name: {newname}')
|
|
1702
|
+
|
|
1703
|
+
params = self._build_params()
|
|
1704
|
+
params.update({'newname': newname})
|
|
1705
|
+
|
|
1706
|
+
try:
|
|
1707
|
+
response = self.rest_request('POST', params=params)
|
|
1708
|
+
except requests.exceptions.RequestException as e:
|
|
1709
|
+
logging.error(f'Failed to rename {self.__class__.__name__} {self.name} to {newname}: {e}')
|
|
1710
|
+
raise NetworkRequestError(f'Network request failed for {self.base_url}', {'error': str(e)})
|
|
1711
|
+
|
|
1712
|
+
if response.get('@status') != 'success':
|
|
1713
|
+
logging.error(f'API returned error during rename: {response.get("message", "Unknown error")}')
|
|
1714
|
+
raise APIResponseError('API request did not return success during rename', response)
|
|
1715
|
+
|
|
1716
|
+
return True
|
|
1717
|
+
|
|
1718
|
+
def refresh(self) -> bool:
|
|
1719
|
+
"""
|
|
1720
|
+
Retrieves live data from a device and updates the instance attributes based on the data.
|
|
1721
|
+
Ensures that only one entry is returned from the data retrieval call and dynamically sets
|
|
1722
|
+
the instance attributes based on the data keys, modifying them if necessary.
|
|
1723
|
+
|
|
1724
|
+
:return: True if the refresh is successful and instance attributes are updated, False otherwise.
|
|
1725
|
+
"""
|
|
1726
|
+
if not self.name:
|
|
1727
|
+
logger.error('The name attribute must be available to do a refresh.')
|
|
1728
|
+
return False
|
|
1729
|
+
|
|
1730
|
+
entry: List[Dict[str, Any]] = self.get()
|
|
1731
|
+
if not entry:
|
|
1732
|
+
return False
|
|
1733
|
+
|
|
1734
|
+
if len(entry) > 1:
|
|
1735
|
+
error_message: str = 'More than one entry returned; cannot refresh.'
|
|
1736
|
+
logger.error(error_message)
|
|
1737
|
+
raise ValueError(error_message)
|
|
1738
|
+
|
|
1739
|
+
updated = False
|
|
1740
|
+
for key, value in entry[0].items():
|
|
1741
|
+
if key == '@name':
|
|
1742
|
+
setattr(self, 'name', value)
|
|
1743
|
+
updated = True
|
|
1744
|
+
continue
|
|
1745
|
+
|
|
1746
|
+
# need to replace any - with _ so it can be used as an attribute
|
|
1747
|
+
modified_key: str = key.replace('-', '_')
|
|
1748
|
+
# Append an underscore if the key is a Python built-in name
|
|
1749
|
+
if modified_key in dir(builtins):
|
|
1750
|
+
modified_key += '_'
|
|
1751
|
+
|
|
1752
|
+
if modified_key.startswith('@'):
|
|
1753
|
+
setattr(self, modified_key.lstrip('@'), value)
|
|
1754
|
+
updated = True
|
|
1755
|
+
continue
|
|
1756
|
+
# Check if the attribute exists in the instance before setting it
|
|
1757
|
+
if hasattr(self, modified_key):
|
|
1758
|
+
setattr(self, modified_key, value)
|
|
1759
|
+
updated = True
|
|
1760
|
+
else:
|
|
1761
|
+
# If the key doesn't match any attribute, update self.entry directly
|
|
1762
|
+
self.entry[key] = value
|
|
1763
|
+
updated = True
|
|
1764
|
+
|
|
1765
|
+
return updated
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
|
|
1771
|
+
|
|
1772
|
+
|