1#!/usr/local/bin/python3.8 2# -*- coding: utf-8 -*- 3 4# copyright: (c) 2016, Loic Blot <loic.blot@unix-experience.fr> 5# Sponsored by Infopro Digital. http://www.infopro-digital.com/ 6# Sponsored by E.T.A.I. http://www.etai.fr/ 7# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 8 9from __future__ import absolute_import, division, print_function 10__metaclass__ = type 11 12DOCUMENTATION = r''' 13--- 14module: omapi_host 15short_description: Setup OMAPI hosts. 16description: Manage OMAPI hosts into compatible DHCPd servers 17requirements: 18 - pypureomapi 19author: 20- Loic Blot (@nerzhul) 21options: 22 state: 23 description: 24 - Create or remove OMAPI host. 25 type: str 26 required: true 27 choices: [ absent, present ] 28 hostname: 29 description: 30 - Sets the host lease hostname (mandatory if state=present). 31 type: str 32 aliases: [ name ] 33 host: 34 description: 35 - Sets OMAPI server host to interact with. 36 type: str 37 default: localhost 38 port: 39 description: 40 - Sets the OMAPI server port to interact with. 41 type: int 42 default: 7911 43 key_name: 44 description: 45 - Sets the TSIG key name for authenticating against OMAPI server. 46 type: str 47 required: true 48 key: 49 description: 50 - Sets the TSIG key content for authenticating against OMAPI server. 51 type: str 52 required: true 53 macaddr: 54 description: 55 - Sets the lease host MAC address. 56 type: str 57 required: true 58 ip: 59 description: 60 - Sets the lease host IP address. 61 type: str 62 statements: 63 description: 64 - Attach a list of OMAPI DHCP statements with host lease (without ending semicolon). 65 type: list 66 elements: str 67 default: [] 68 ddns: 69 description: 70 - Enable dynamic DNS updates for this host. 71 type: bool 72 default: no 73 74''' 75EXAMPLES = r''' 76- name: Add a host using OMAPI 77 community.general.omapi_host: 78 key_name: defomapi 79 key: +bFQtBCta6j2vWkjPkNFtgA== 80 host: 10.98.4.55 81 macaddr: 44:dd:ab:dd:11:44 82 name: server01 83 ip: 192.168.88.99 84 ddns: yes 85 statements: 86 - filename "pxelinux.0" 87 - next-server 1.1.1.1 88 state: present 89 90- name: Remove a host using OMAPI 91 community.general.omapi_host: 92 key_name: defomapi 93 key: +bFQtBCta6j2vWkjPkNFtgA== 94 host: 10.1.1.1 95 macaddr: 00:66:ab:dd:11:44 96 state: absent 97''' 98 99RETURN = r''' 100lease: 101 description: dictionary containing host information 102 returned: success 103 type: complex 104 contains: 105 ip-address: 106 description: IP address, if there is. 107 returned: success 108 type: str 109 sample: '192.168.1.5' 110 hardware-address: 111 description: MAC address 112 returned: success 113 type: str 114 sample: '00:11:22:33:44:55' 115 hardware-type: 116 description: hardware type, generally '1' 117 returned: success 118 type: int 119 sample: 1 120 name: 121 description: hostname 122 returned: success 123 type: str 124 sample: 'mydesktop' 125''' 126 127import binascii 128import socket 129import struct 130import traceback 131 132PUREOMAPI_IMP_ERR = None 133try: 134 from pypureomapi import Omapi, OmapiMessage, OmapiError, OmapiErrorNotFound 135 from pypureomapi import pack_ip, unpack_ip, pack_mac, unpack_mac 136 from pypureomapi import OMAPI_OP_STATUS, OMAPI_OP_UPDATE 137 pureomapi_found = True 138except ImportError: 139 PUREOMAPI_IMP_ERR = traceback.format_exc() 140 pureomapi_found = False 141 142from ansible.module_utils.basic import AnsibleModule, missing_required_lib 143from ansible.module_utils.common.text.converters import to_bytes, to_native 144 145 146class OmapiHostManager: 147 def __init__(self, module): 148 self.module = module 149 self.omapi = None 150 self.connect() 151 152 def connect(self): 153 try: 154 self.omapi = Omapi(self.module.params['host'], self.module.params['port'], to_bytes(self.module.params['key_name']), 155 self.module.params['key']) 156 except binascii.Error: 157 self.module.fail_json(msg="Unable to open OMAPI connection. 'key' is not a valid base64 key.") 158 except OmapiError as e: 159 self.module.fail_json(msg="Unable to open OMAPI connection. Ensure 'host', 'port', 'key' and 'key_name' " 160 "are valid. Exception was: %s" % to_native(e)) 161 except socket.error as e: 162 self.module.fail_json(msg="Unable to connect to OMAPI server: %s" % to_native(e)) 163 164 def get_host(self, macaddr): 165 msg = OmapiMessage.open(to_bytes("host", errors='surrogate_or_strict')) 166 msg.obj.append((to_bytes("hardware-address", errors='surrogate_or_strict'), pack_mac(macaddr))) 167 msg.obj.append((to_bytes("hardware-type", errors='surrogate_or_strict'), struct.pack("!I", 1))) 168 response = self.omapi.query_server(msg) 169 if response.opcode != OMAPI_OP_UPDATE: 170 return None 171 return response 172 173 @staticmethod 174 def unpack_facts(obj): 175 result = dict(obj) 176 if 'hardware-address' in result: 177 result['hardware-address'] = to_native(unpack_mac(result[to_bytes('hardware-address')])) 178 179 if 'ip-address' in result: 180 result['ip-address'] = to_native(unpack_ip(result[to_bytes('ip-address')])) 181 182 if 'hardware-type' in result: 183 result['hardware-type'] = struct.unpack("!I", result[to_bytes('hardware-type')]) 184 185 return result 186 187 def setup_host(self): 188 if self.module.params['hostname'] is None or len(self.module.params['hostname']) == 0: 189 self.module.fail_json(msg="name attribute could not be empty when adding or modifying host.") 190 191 msg = None 192 host_response = self.get_host(self.module.params['macaddr']) 193 # If host was not found using macaddr, add create message 194 if host_response is None: 195 msg = OmapiMessage.open(to_bytes('host', errors='surrogate_or_strict')) 196 msg.message.append((to_bytes('create'), struct.pack('!I', 1))) 197 msg.message.append((to_bytes('exclusive'), struct.pack('!I', 1))) 198 msg.obj.append((to_bytes('hardware-address'), pack_mac(self.module.params['macaddr']))) 199 msg.obj.append((to_bytes('hardware-type'), struct.pack('!I', 1))) 200 msg.obj.append((to_bytes('name'), to_bytes(self.module.params['hostname']))) 201 if self.module.params['ip'] is not None: 202 msg.obj.append((to_bytes("ip-address", errors='surrogate_or_strict'), pack_ip(self.module.params['ip']))) 203 204 stmt_join = "" 205 if self.module.params['ddns']: 206 stmt_join += 'ddns-hostname "{0}"; '.format(self.module.params['hostname']) 207 208 try: 209 if len(self.module.params['statements']) > 0: 210 stmt_join += "; ".join(self.module.params['statements']) 211 stmt_join += "; " 212 except TypeError as e: 213 self.module.fail_json(msg="Invalid statements found: %s" % to_native(e)) 214 215 if len(stmt_join) > 0: 216 msg.obj.append((to_bytes('statements'), to_bytes(stmt_join))) 217 218 try: 219 response = self.omapi.query_server(msg) 220 if response.opcode != OMAPI_OP_UPDATE: 221 self.module.fail_json(msg="Failed to add host, ensure authentication and host parameters " 222 "are valid.") 223 self.module.exit_json(changed=True, lease=self.unpack_facts(response.obj)) 224 except OmapiError as e: 225 self.module.fail_json(msg="OMAPI error: %s" % to_native(e)) 226 # Forge update message 227 else: 228 response_obj = self.unpack_facts(host_response.obj) 229 fields_to_update = {} 230 231 if to_bytes('ip-address', errors='surrogate_or_strict') not in response_obj or \ 232 unpack_ip(response_obj[to_bytes('ip-address', errors='surrogate_or_strict')]) != self.module.params['ip']: 233 fields_to_update['ip-address'] = pack_ip(self.module.params['ip']) 234 235 # Name cannot be changed 236 if 'name' not in response_obj or response_obj['name'] != self.module.params['hostname']: 237 self.module.fail_json(msg="Changing hostname is not supported. Old was %s, new is %s. " 238 "Please delete host and add new." % 239 (response_obj['name'], self.module.params['hostname'])) 240 241 """ 242 # It seems statements are not returned by OMAPI, then we cannot modify them at this moment. 243 if 'statements' not in response_obj and len(self.module.params['statements']) > 0 or \ 244 response_obj['statements'] != self.module.params['statements']: 245 with open('/tmp/omapi', 'w') as fb: 246 for (k,v) in iteritems(response_obj): 247 fb.writelines('statements: %s %s\n' % (k, v)) 248 """ 249 if len(fields_to_update) == 0: 250 self.module.exit_json(changed=False, lease=response_obj) 251 else: 252 msg = OmapiMessage.update(host_response.handle) 253 msg.update_object(fields_to_update) 254 255 try: 256 response = self.omapi.query_server(msg) 257 if response.opcode != OMAPI_OP_STATUS: 258 self.module.fail_json(msg="Failed to modify host, ensure authentication and host parameters " 259 "are valid.") 260 self.module.exit_json(changed=True) 261 except OmapiError as e: 262 self.module.fail_json(msg="OMAPI error: %s" % to_native(e)) 263 264 def remove_host(self): 265 try: 266 self.omapi.del_host(self.module.params['macaddr']) 267 self.module.exit_json(changed=True) 268 except OmapiErrorNotFound: 269 self.module.exit_json() 270 except OmapiError as e: 271 self.module.fail_json(msg="OMAPI error: %s" % to_native(e)) 272 273 274def main(): 275 module = AnsibleModule( 276 argument_spec=dict( 277 state=dict(type='str', required=True, choices=['absent', 'present']), 278 host=dict(type='str', default="localhost"), 279 port=dict(type='int', default=7911), 280 key_name=dict(type='str', required=True), 281 key=dict(type='str', required=True, no_log=True), 282 macaddr=dict(type='str', required=True), 283 hostname=dict(type='str', aliases=['name']), 284 ip=dict(type='str'), 285 ddns=dict(type='bool', default=False), 286 statements=dict(type='list', elements='str', default=[]), 287 ), 288 supports_check_mode=False, 289 ) 290 291 if not pureomapi_found: 292 module.fail_json(msg=missing_required_lib('pypureomapi'), exception=PUREOMAPI_IMP_ERR) 293 294 if module.params['key'] is None or len(module.params["key"]) == 0: 295 module.fail_json(msg="'key' parameter cannot be empty.") 296 297 if module.params['key_name'] is None or len(module.params["key_name"]) == 0: 298 module.fail_json(msg="'key_name' parameter cannot be empty.") 299 300 host_manager = OmapiHostManager(module) 301 try: 302 if module.params['state'] == 'present': 303 host_manager.setup_host() 304 elif module.params['state'] == 'absent': 305 host_manager.remove_host() 306 except ValueError as e: 307 module.fail_json(msg="OMAPI input value error: %s" % to_native(e)) 308 309 310if __name__ == '__main__': 311 main() 312