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