1 #!@PYTHON@ -tt
2
3 # AHV Fence agent
4 # Compatible with Nutanix v4 API
5
6
7 import atexit
8 import logging
9 import sys
10 import time
11 import uuid
12 import requests
13 from requests.adapters import HTTPAdapter
14 from requests.packages.urllib3.util.retry import Retry
15
16 sys.path.append("@FENCEAGENTSLIBDIR@")
17 from fencing import *
18 from fencing import fail, EC_LOGIN_DENIED, EC_GENERIC_ERROR, EC_TIMED_OUT, run_delay, EC_BAD_ARGS
19
20
21 V4_VERSION = '4.0'
22 MIN_TIMEOUT = 60
23 PC_PORT = 9440
24 POWER_STATES = {"ON": "on", "OFF": "off", "PAUSED": "off", "UNKNOWN": "unknown"}
25 MAX_RETRIES = 5
26
27
28 class NutanixClientException(Exception):
29 pass
30
31
32 class AHVFenceAgentException(Exception):
33 pass
34
35
36 class TaskTimedOutException(Exception):
37 pass
38
39
40 class InvalidArgsException(Exception):
41 pass
42
43
44 class NutanixClient:
45 def __init__(self, username, password, disable_warnings=False):
46 self.username = username
47 self.password = password
48 self.valid_status_codes = [200, 202]
49 self.disable_warnings = disable_warnings
50 self.session = requests.Session()
51 self.session.auth = (self.username, self.password)
52
53 retry_strategy = Retry(total=MAX_RETRIES,
54 backoff_factor=1,
55 status_forcelist=[429, 500, 503])
56
57 self.session.mount("https://", HTTPAdapter(max_retries=retry_strategy))
58
59 def request(self, url, method='GET', headers=None, **kwargs):
60
61 if self.disable_warnings:
62 requests.packages.urllib3.disable_warnings()
63
64 if headers:
65 self.session.headers.update(headers)
66
67 response = None
68
69 try:
70 logging.debug("Sending %s request to %s", method, url)
|
(1) Event Sigma main event: |
The `timeout` attribute is undefined or is set to `None`, which disables the timeouts on streaming connections. This makes it easier for an attacker to launch a Denial-of-Service (DoS) attack. Other problems can include large numbers of inactive connections that aren't being closed and running out of ephemeral ports. |
|
(2) Event remediation: |
Explicitly set the `timeout` attribute to a value greater than 0. |
71 response = self.session.request(method, url, **kwargs)
72 response.raise_for_status()
73 except requests.exceptions.SSLError as err:
74 logging.error("Secure connection failed, verify SSL certificate")
75 logging.error("Error message: %s", err)
76 raise NutanixClientException("Secure connection failed") from err
77 except requests.exceptions.RequestException as err:
78 logging.error("API call failed: %s", response.text)
79 logging.error("Error message: %s", err)
80 raise NutanixClientException(f"API call failed: {err}") from err
81 except Exception as err:
82 logging.error("API call failed: %s", response.text)
83 logging.error("Unknown error %s", err)
84 raise NutanixClientException(f"API call failed: {err}") from err
85
86 if response.status_code not in self.valid_status_codes:
87 logging.error("API call returned status code %s", response.status_code)
88 raise NutanixClientException(f"API call failed: {response}")
89
90 return response
91
92
93 class NutanixV4Client(NutanixClient):
94 def __init__(self, host=None, username=None, password=None,
95 verify=True, disable_warnings=False):
96 self.host = host
97 self.username = username
98 self.password = password
99 self.verify = verify
100 self.base_url = f"https://{self.host}:{PC_PORT}/api"
101 self.vm_url = f"{self.base_url}/vmm/v{V4_VERSION}/ahv/config/vms"
102 self.task_url = f"{self.base_url}/prism/v{V4_VERSION}/config/tasks"
103 super().__init__(username, password, disable_warnings)
104
105 def _get_headers(self, vm_uuid=None):
106 resp = None
107 headers = {'Accept':'application/json',
108 'Content-Type': 'application/json'}
109
110 if vm_uuid:
111 try:
112 resp = self._get_vm(vm_uuid)
113 except AHVFenceAgentException as err:
114 logging.error("Unable to retrieve etag")
115 raise AHVFenceAgentException from err
116
117 etag_str = resp.headers['Etag']
118 request_id = str(uuid.uuid1())
119 headers['If-Match'] = etag_str
120 headers['Ntnx-Request-Id'] = request_id
121
122 return headers
123
124 def _get_all_vms(self, filter_str=None, limit=None):
125 vm_url = self.vm_url
126
127 if filter_str and limit:
128 vm_url = f"{vm_url}?$filter={filter_str}&$limit={limit}"
129 elif filter_str and not limit:
130 vm_url = f"{vm_url}?$filter={filter_str}"
131 elif limit and not filter_str:
132 vm_url = f"{vm_url}?$limit={limit}"
133
134 logging.debug("Getting info for all VMs, %s", vm_url)
135 header_str = self._get_headers()
136
137 try:
138 resp = self.request(url=vm_url, method='GET',
139 headers=header_str, verify=self.verify)
140 except NutanixClientException as err:
141 logging.error("Unable to retrieve VM info")
142 raise AHVFenceAgentException from err
143
144 vms = resp.json()
145 return vms
146
147 def _get_vm_uuid(self, vm_name):
148 vm_uuid = None
149 resp = None
150
151 if not vm_name:
152 logging.error("VM name was not provided")
153 raise AHVFenceAgentException("VM name not provided")
154
155 try:
156 filter_str = f"name eq '{vm_name}'"
157 resp = self._get_all_vms(filter_str=filter_str)
158 except AHVFenceAgentException as err:
159 logging.error("Failed to get VM info for VM %s", vm_name)
160 raise AHVFenceAgentException from err
161
162 if not resp or not isinstance(resp, dict):
163 logging.error("Failed to retrieve VM UUID for VM %s", vm_name)
164 raise AHVFenceAgentException(f"Failed to get VM UUID for {vm_name}")
165
166 if 'data' not in resp:
167 err = f"Error: Unsuccessful match for VM name: {vm_name}"
168 logging.error("Failed to retrieve VM UUID for VM %s", vm_name)
169 raise AHVFenceAgentException(err)
170
171 for vm in resp['data']:
172 if vm['name'] == vm_name:
173 vm_uuid = vm['extId']
174 break
175
176 return vm_uuid
177
178 def _get_vm(self, vm_uuid):
179 if not vm_uuid:
180 logging.error("VM UUID was not provided")
181 raise AHVFenceAgentException("VM UUID not provided")
182
183 vm_url = self.vm_url + f"/{vm_uuid}"
184 logging.debug("Getting config information for VM, %s", vm_uuid)
185
186 try:
187 header_str = self._get_headers()
188 resp = self.request(url=vm_url, method='GET',
189 headers=header_str, verify=self.verify)
190 except NutanixClientException as err:
191 logging.error("Failed to retrieve VM details "
192 "for VM UUID: %s", vm_uuid)
193 raise AHVFenceAgentException from err
194 except AHVFenceAgentException as err:
195 logging.error("Failed to retrieve etag from headers")
196 raise AHVFenceAgentException from err
197
198 return resp
199
200 def _power_on_off_vm(self, power_state=None, vm_uuid=None):
201 resp = None
202 vm_url = None
203
204 if not vm_uuid:
205 logging.error("VM UUID was not provided")
206 raise AHVFenceAgentException("VM UUID not provided")
207 if not power_state:
208 logging.error("Requested VM power state is None")
209 raise InvalidArgsException
210
211 power_state = power_state.lower()
212
213 if power_state == 'on':
214 vm_url = self.vm_url + f"/{vm_uuid}/$actions/power-on"
215 logging.debug("Sending request to power on VM, %s", vm_uuid)
216 elif power_state == 'off':
217 vm_url = self.vm_url + f"/{vm_uuid}/$actions/power-off"
218 logging.debug("Sending request to power off VM, %s", vm_uuid)
219 else:
220 logging.error("Invalid power state specified: %s", power_state)
221 raise InvalidArgsException
222
223 try:
224 headers_str = self._get_headers(vm_uuid)
225 resp = self.request(url=vm_url, method='POST',
226 headers=headers_str, verify=self.verify)
227 except NutanixClientException as err:
228 logging.error("Failed to power off VM %s", vm_uuid)
229 raise AHVFenceAgentException from err
230 except AHVFenceAgentException as err:
231 logging.error("Failed to retrieve etag from headers")
232 raise AHVFenceAgentException from err
233
234 return resp
235
236 def _power_cycle_vm(self, vm_uuid):
237 if not vm_uuid:
238 logging.error("VM UUID was not provided")
239 raise AHVFenceAgentException("VM UUID not provided")
240
241 resp = None
242 vm_url = self.vm_url + f"/{vm_uuid}/$actions/power-cycle"
243 logging.debug("Sending request to power cycle VM, %s", vm_uuid)
244
245 try:
246 header_str = self._get_headers(vm_uuid)
247 resp = self.request(url=vm_url, method='POST',
248 headers=header_str, verify=self.verify)
249 except NutanixClientException as err:
250 logging.error("Failed to power on VM %s", vm_uuid)
251 raise AHVFenceAgentException from err
252 except AHVFenceAgentException as err:
253 logging.error("Failed to retrieve etag from headers")
254 raise AHVFenceAgentException from err
255
256 return resp
257
258 def _wait_for_task(self, task_uuid, timeout=None):
259 if not task_uuid:
260 logging.error("Task UUID was not provided")
261 raise AHVFenceAgentException("Task UUID not provided")
262
263 task_url = f"{self.task_url}/{task_uuid}"
264 header_str = self._get_headers()
265 task_resp = None
266 interval = 5
267 task_status = None
268
269 if not timeout:
270 timeout = MIN_TIMEOUT
271 else:
272 try:
273 timeout = int(timeout)
274 except ValueError:
275 timeout = MIN_TIMEOUT
276
277 while task_status != 'SUCCEEDED':
278 if timeout <= 0:
279 raise TaskTimedOutException(f"Task timed out: {task_uuid}")
280
281 time.sleep(interval)
282 timeout = timeout - interval
283
284 try:
285 task_resp = self.request(url=task_url, method='GET',
286 headers=header_str, verify=self.verify)
287 task_status = task_resp.json()['data']['status']
288 except NutanixClientException as err:
289 logging.error("Unable to retrieve task status")
290 raise AHVFenceAgentException from err
291 except Exception as err:
292 logging.error("Unknown error")
293 raise AHVFenceAgentException from err
294
295 if task_status == 'FAILED':
296 raise AHVFenceAgentException(f"Task failed, task uuid: {task_uuid}")
297
298 def list_vms(self, filter_str=None, limit=None):
299 vms = None
300 vm_list = {}
301
302 try:
303 vms = self._get_all_vms(filter_str, limit)
304 except NutanixClientException as err:
305 logging.error("Failed to retrieve VM list")
306 raise AHVFenceAgentException from err
307
308 if not vms or not isinstance(vms, dict):
309 logging.error("Failed to retrieve VM list")
310 raise AHVFenceAgentException("Unable to get VM list")
311
312 if 'data' not in vms:
313 err = "Got invalid or empty VM list"
314 logging.debug(err)
315 else:
316 for vm in vms['data']:
317 vm_name = vm['name']
318 ext_id = vm['extId']
319 power_state = vm['powerState']
320 vm_list[vm_name] = (ext_id, power_state)
321
322 return vm_list
323
324 def get_power_state(self, vm_name=None, vm_uuid=None):
325 resp = None
326 power_state = None
327
328 if not vm_name and not vm_uuid:
329 logging.error("Require at least one of VM name or VM UUID")
330 raise InvalidArgsException("No arguments provided")
331
332 if not vm_uuid:
333 try:
334 vm_uuid = self._get_vm_uuid(vm_name)
335 except AHVFenceAgentException as err:
336 logging.error("Unable to retrieve UUID of VM, %s", vm_name)
337 raise AHVFenceAgentException from err
338
339 try:
340 resp = self._get_vm(vm_uuid)
341 except AHVFenceAgentException as err:
342 logging.error("Unable to retrieve power state of VM %s", vm_uuid)
343 raise AHVFenceAgentException from err
344
345 try:
346 power_state = resp.json()['data']['powerState']
347 except AHVFenceAgentException as err:
348 logging.error("Failed to retrieve power state of VM %s", vm_uuid)
349 raise AHVFenceAgentException from err
350
351 return POWER_STATES[power_state]
352
353 def set_power_state(self, vm_name=None, vm_uuid=None,
354 power_state='off', timeout=None):
355 resp = None
356 current_power_state = None
357 power_state = power_state.lower()
358
359 if not timeout:
360 timeout = MIN_TIMEOUT
361
362 if not vm_name and not vm_uuid:
363 logging.error("Require at least one of VM name or VM UUID")
364 raise InvalidArgsException("No arguments provided")
365
366 if not vm_uuid:
367 vm_uuid = self._get_vm_uuid(vm_name)
368
369 try:
370 current_power_state = self.get_power_state(vm_uuid=vm_uuid)
371 except AHVFenceAgentException as err:
372 raise AHVFenceAgentException from err
373
374 if current_power_state.lower() == power_state.lower():
375 logging.debug("VM already powered %s", power_state.lower())
376 return
377
378 if power_state.lower() == 'on':
379 resp = self._power_on_off_vm(power_state, vm_uuid)
380 elif power_state.lower() == 'off':
381 resp = self._power_on_off_vm(power_state, vm_uuid)
382
383 task_id = resp.json()['data']['extId']
384
385 try:
386 self._wait_for_task(task_id, timeout)
387 except AHVFenceAgentException as err:
388 logging.error("Failed to power %s VM", power_state.lower())
389 logging.error("VM power %s task failed", power_state.lower())
390 raise AHVFenceAgentException from err
391 except TaskTimedOutException as err:
392 logging.error("Timed out powering %s VM %s",
393 power_state.lower(), vm_uuid)
394 raise TaskTimedOutException from err
395
396 logging.debug("Powered %s VM, %s successfully",
397 power_state.lower(), vm_uuid)
398
399 def power_cycle_vm(self, vm_name=None, vm_uuid=None, timeout=None):
400 resp = None
401 status = None
402
403 if not timeout:
404 timeout = MIN_TIMEOUT
405
406 if not vm_name and not vm_uuid:
407 logging.error("Require at least one of VM name or VM UUID")
408 raise InvalidArgsException("No arguments provided")
409
410 if not vm_uuid:
411 vm_uuid = self._get_vm_uuid(vm_name)
412
413 resp = self._power_cycle_vm(vm_uuid)
414 task_id = resp.json()['data']['extId']
415
416 try:
417 self._wait_for_task(task_id, timeout)
418 except AHVFenceAgentException as err:
419 logging.error("Failed to power-cycle VM %s", vm_uuid)
420 logging.error("VM power-cycle task failed with status, %s", status)
421 raise AHVFenceAgentException from err
422 except TaskTimedOutException as err:
423 logging.error("Timed out power-cycling VM %s", vm_uuid)
424 raise TaskTimedOutException from err
425
426
427 logging.debug("Power-cycled VM, %s", vm_uuid)
428
429
430 def connect(options):
431 host = options["--ip"]
432 username = options["--username"]
433 password = options["--password"]
434 verify_ssl = True
435 disable_warnings = False
436
437 if "--ssl-insecure" in options:
438 verify_ssl = False
439 disable_warnings = True
440
441 client = NutanixV4Client(host, username, password,
442 verify_ssl, disable_warnings)
443
444 try:
445 client.list_vms(limit=1)
446 except AHVFenceAgentException as err:
447 logging.error("Connection to Prism Central Failed")
448 logging.error(err)
449 fail(EC_LOGIN_DENIED)
450
451 return client
452
453 def get_list(client, options):
454 vm_list = None
455
456 filter_str = options.get("--filter", None)
457 limit = options.get("--limit", None)
458
459 try:
460 vm_list = client.list_vms(filter_str, limit)
461 except AHVFenceAgentException as err:
462 logging.error("Failed to list VMs")
463 logging.error(err)
464 fail(EC_GENERIC_ERROR)
465
466 return vm_list
467
468 def get_power_status(client, options):
469 vmid = None
470 name = None
471 power_state = None
472
473 vmid = options.get("--uuid", None)
474 name = options.get("--plug", None)
475
476 if not vmid and not name:
477 logging.error("Need VM name or VM UUID for power op")
478 fail(EC_BAD_ARGS)
479 try:
480 power_state = client.get_power_state(vm_name=name, vm_uuid=vmid)
481 except AHVFenceAgentException:
482 fail(EC_GENERIC_ERROR)
483 except InvalidArgsException:
484 fail(EC_BAD_ARGS)
485
486 return power_state
487
488 def set_power_status(client, options):
489 action = options["--action"].lower()
490 timeout = options.get("--power-timeout", None)
491 vmid = options.get("--uuid", None)
492 name = options.get("--plug", None)
493
494 if not name and not vmid:
495 logging.error("Need VM name or VM UUID to set power state of a VM")
496 fail(EC_BAD_ARGS)
497
498 try:
499 client.set_power_state(vm_name=name, vm_uuid=vmid,
500 power_state=action, timeout=timeout)
501 except AHVFenceAgentException as err:
502 logging.error(err)
503 fail(EC_GENERIC_ERROR)
504 except TaskTimedOutException as err:
505 logging.error(err)
506 fail(EC_TIMED_OUT)
507 except InvalidArgsException:
508 fail(EC_BAD_ARGS)
509
510 def power_cycle(client, options):
511 timeout = options.get("--power-timeout", None)
512 vmid = options.get("--uuid", None)
513 name = options.get("--plug", None)
514
515 if not name and not vmid:
516 logging.error("Need VM name or VM UUID to set power cycling a VM")
517 fail(EC_BAD_ARGS)
518
519 try:
520 client.power_cycle_vm(vm_name=name, vm_uuid=vmid, timeout=timeout)
521 except AHVFenceAgentException as err:
522 logging.error(err)
523 fail(EC_GENERIC_ERROR)
524 except TaskTimedOutException as err:
525 logging.error(err)
526 fail(EC_TIMED_OUT)
527 except InvalidArgsException:
528 fail(EC_BAD_ARGS)
529
530 def define_new_opts():
531 all_opt["filter"] = {
532 "getopt": ":",
533 "longopt": "filter",
534 "help": """
535 --filter=[filter] Filter list, list VMs actions.
536 --filter=\"name eq 'node1-vm'\"
537 --filter=\"startswith(name,'node')\"
538 --filter=\"name in ('node1-vm','node-3-vm')\" """,
539 "required": "0",
540 "shortdesc": "Filter list, get_list"
541 "e.g: \"name eq 'node1-vm'\"",
542 "order": 2
543 }
544
545 def main():
546 device_opt = [
547 "ipaddr",
548 "login",
549 "passwd",
550 "ssl",
551 "notls",
552 "web",
553 "port",
554 "filter",
555 "method",
556 "disable_timeout",
557 "power_timeout"
558 ]
559
560 atexit.register(atexit_handler)
561 define_new_opts()
562
563 all_opt["power_timeout"]["default"] = str(MIN_TIMEOUT)
564 options = check_input(device_opt, process_input(device_opt))
565 docs = {}
566 docs["shortdesc"] = "Fence agent for Nutanix AHV Cluster VMs."
567 docs["longdesc"] = """fence_nutanix_ahv is a Power Fencing agent for \
568 virtual machines deployed on Nutanix AHV cluster with the AHV cluster \
569 being managed by Prism Central."""
570 docs["vendorurl"] = "https://www.nutanix.com"
571 show_docs(options, docs)
572 run_delay(options)
573 client = connect(options)
574
575 result = fence_action(client, options, set_power_status, get_power_status,
576 get_list, reboot_cycle_fn=power_cycle
577 )
578
579 sys.exit(result)
580
581
582 if __name__ == "__main__":
583 main()
584