1#!/usr/local/bin/python3.8 2# -*- coding: utf-8 -*- 3# Copyright: (c) 2018, Ansible Project 4# Copyright: (c) 2018, Anthony Bond <ajbond2005@gmail.com> 5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 7from __future__ import absolute_import, division, print_function 8 9__metaclass__ = type 10 11 12DOCUMENTATION = """ 13--- 14module: digital_ocean_firewall 15short_description: Manage cloud firewalls within DigitalOcean 16description: 17 - This module can be used to add or remove firewalls on the DigitalOcean cloud platform. 18author: 19 - Anthony Bond (@BondAnthony) 20 - Lucas Basquerotto (@lucasbasquerotto) 21version_added: "1.1.0" 22options: 23 name: 24 type: str 25 description: 26 - Name of the firewall rule to create or manage 27 required: true 28 state: 29 type: str 30 choices: ['present', 'absent'] 31 default: present 32 description: 33 - Assert the state of the firewall rule. Set to 'present' to create or update and 'absent' to remove. 34 droplet_ids: 35 type: list 36 elements: str 37 description: 38 - List of droplet ids to be assigned to the firewall 39 required: false 40 tags: 41 type: list 42 elements: str 43 description: 44 - List of tags to be assigned to the firewall 45 required: false 46 inbound_rules: 47 type: list 48 elements: dict 49 description: 50 - Firewall rules specifically targeting inbound network traffic into DigitalOcean 51 required: true 52 suboptions: 53 protocol: 54 type: str 55 choices: ['udp', 'tcp', 'icmp'] 56 default: tcp 57 description: 58 - Network protocol to be accepted. 59 required: false 60 ports: 61 type: str 62 description: 63 - The ports on which traffic will be allowed, single, range, or all 64 required: true 65 sources: 66 type: dict 67 description: 68 - Dictionary of locations from which inbound traffic will be accepted 69 required: true 70 suboptions: 71 addresses: 72 type: list 73 elements: str 74 description: 75 - List of strings containing the IPv4 addresses, IPv6 addresses, IPv4 CIDRs, 76 and/or IPv6 CIDRs to which the firewall will allow traffic 77 required: false 78 droplet_ids: 79 type: list 80 elements: str 81 description: 82 - List of integers containing the IDs of the Droplets to which the firewall will allow traffic 83 required: false 84 load_balancer_uids: 85 type: list 86 elements: str 87 description: 88 - List of strings containing the IDs of the Load Balancers to which the firewall will allow traffic 89 required: false 90 tags: 91 type: list 92 elements: str 93 description: 94 - List of strings containing the names of Tags corresponding to groups of Droplets to 95 which the Firewall will allow traffic 96 required: false 97 outbound_rules: 98 type: list 99 elements: dict 100 description: 101 - Firewall rules specifically targeting outbound network traffic from DigitalOcean 102 required: true 103 suboptions: 104 protocol: 105 type: str 106 choices: ['udp', 'tcp', 'icmp'] 107 default: tcp 108 description: 109 - Network protocol to be accepted. 110 required: false 111 ports: 112 type: str 113 description: 114 - The ports on which traffic will be allowed, single, range, or all 115 required: true 116 destinations: 117 type: dict 118 description: 119 - Dictionary of locations from which outbound traffic will be allowed 120 required: true 121 suboptions: 122 addresses: 123 type: list 124 elements: str 125 description: 126 - List of strings containing the IPv4 addresses, IPv6 addresses, IPv4 CIDRs, 127 and/or IPv6 CIDRs to which the firewall will allow traffic 128 required: false 129 droplet_ids: 130 type: list 131 elements: str 132 description: 133 - List of integers containing the IDs of the Droplets to which the firewall will allow traffic 134 required: false 135 load_balancer_uids: 136 type: list 137 elements: str 138 description: 139 - List of strings containing the IDs of the Load Balancers to which the firewall will allow traffic 140 required: false 141 tags: 142 type: list 143 elements: str 144 description: 145 - List of strings containing the names of Tags corresponding to groups of Droplets to 146 which the Firewall will allow traffic 147 required: false 148extends_documentation_fragment: digital_ocean.documentation 149""" 150 151EXAMPLES = """ 152# Allows tcp connections to port 22 (SSH) from specific sources 153# Allows tcp connections to ports 80 and 443 from any source 154# Allows outbound access to any destination for protocols tcp, udp and icmp 155# The firewall rules will be applied to any droplets with the tag "sample" 156- name: Create a Firewall named my-firewall 157 digital_ocean_firewall: 158 name: my-firewall 159 state: present 160 inbound_rules: 161 - protocol: "tcp" 162 ports: "22" 163 sources: 164 addresses: ["1.2.3.4"] 165 droplet_ids: ["my_droplet_id_1", "my_droplet_id_2"] 166 load_balancer_uids: ["my_lb_id_1", "my_lb_id_2"] 167 tags: ["tag_1", "tag_2"] 168 - protocol: "tcp" 169 ports: "80" 170 sources: 171 addresses: ["0.0.0.0/0", "::/0"] 172 - protocol: "tcp" 173 ports: "443" 174 sources: 175 addresses: ["0.0.0.0/0", "::/0"] 176 outbound_rules: 177 - protocol: "tcp" 178 ports: "1-65535" 179 destinations: 180 addresses: ["0.0.0.0/0", "::/0"] 181 - protocol: "udp" 182 ports: "1-65535" 183 destinations: 184 addresses: ["0.0.0.0/0", "::/0"] 185 - protocol: "icmp" 186 ports: "1-65535" 187 destinations: 188 addresses: ["0.0.0.0/0", "::/0"] 189 droplet_ids: [] 190 tags: ["sample"] 191""" 192 193RETURN = """ 194data: 195 description: DigitalOcean firewall resource 196 returned: success 197 type: dict 198 sample: { 199 "created_at": "2020-08-11T18:41:30Z", 200 "droplet_ids": [], 201 "id": "7acd6ee2-257b-434f-8909-709a5816d4f9", 202 "inbound_rules": [ 203 { 204 "ports": "443", 205 "protocol": "tcp", 206 "sources": { 207 "addresses": [ 208 "1.2.3.4" 209 ], 210 "droplet_ids": [ 211 "my_droplet_id_1", 212 "my_droplet_id_2" 213 ], 214 "load_balancer_uids": [ 215 "my_lb_id_1", 216 "my_lb_id_2" 217 ], 218 "tags": [ 219 "tag_1", 220 "tag_2" 221 ] 222 } 223 }, 224 { 225 "sources": { 226 "addresses": [ 227 "0.0.0.0/0", 228 "::/0" 229 ] 230 }, 231 "ports": "80", 232 "protocol": "tcp" 233 }, 234 { 235 "sources": { 236 "addresses": [ 237 "0.0.0.0/0", 238 "::/0" 239 ] 240 }, 241 "ports": "443", 242 "protocol": "tcp" 243 } 244 ], 245 "name": "my-firewall", 246 "outbound_rules": [ 247 { 248 "destinations": { 249 "addresses": [ 250 "0.0.0.0/0", 251 "::/0" 252 ] 253 }, 254 "ports": "1-65535", 255 "protocol": "tcp" 256 }, 257 { 258 "destinations": { 259 "addresses": [ 260 "0.0.0.0/0", 261 "::/0" 262 ] 263 }, 264 "ports": "1-65535", 265 "protocol": "udp" 266 }, 267 { 268 "destinations": { 269 "addresses": [ 270 "0.0.0.0/0", 271 "::/0" 272 ] 273 }, 274 "ports": "1-65535", 275 "protocol": "icmp" 276 } 277 ], 278 "pending_changes": [], 279 "status": "succeeded", 280 "tags": ["sample"] 281 } 282""" 283 284from traceback import format_exc 285from ansible.module_utils.basic import AnsibleModule 286from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( 287 DigitalOceanHelper, 288) 289from ansible.module_utils._text import to_native 290 291address_spec = dict( 292 addresses=dict(type="list", elements="str", required=False), 293 droplet_ids=dict(type="list", elements="str", required=False), 294 load_balancer_uids=dict(type="list", elements="str", required=False), 295 tags=dict(type="list", elements="str", required=False), 296) 297 298inbound_spec = dict( 299 protocol=dict(type="str", choices=["udp", "tcp", "icmp"], default="tcp"), 300 ports=dict(type="str", required=True), 301 sources=dict(type="dict", required=True, options=address_spec), 302) 303 304outbound_spec = dict( 305 protocol=dict(type="str", choices=["udp", "tcp", "icmp"], default="tcp"), 306 ports=dict(type="str", required=True), 307 destinations=dict(type="dict", required=True, options=address_spec), 308) 309 310 311class DOFirewall(object): 312 def __init__(self, module): 313 self.rest = DigitalOceanHelper(module) 314 self.module = module 315 self.name = self.module.params.get("name") 316 self.baseurl = "firewalls" 317 self.firewalls = self.get_firewalls() 318 319 def get_firewalls(self): 320 base_url = self.baseurl + "?" 321 response = self.rest.get("%s" % base_url) 322 status_code = response.status_code 323 status_code_success = 200 324 325 if status_code != status_code_success: 326 error = response.json 327 info = response.info 328 329 if error: 330 error.update({"status_code": status_code}) 331 error.update({"status_code_success": status_code_success}) 332 self.module.fail_json(msg=error) 333 elif info: 334 info.update({"status_code_success": status_code_success}) 335 self.module.fail_json(msg=info) 336 else: 337 msg_error = "Failed to retrieve firewalls from DigitalOcean" 338 self.module.fail_json( 339 msg=msg_error 340 + " (url=" 341 + self.rest.baseurl 342 + "/" 343 + self.baseurl 344 + ", status=" 345 + str(status_code or "") 346 + " - expected:" 347 + str(status_code_success) 348 + ")" 349 ) 350 351 return self.rest.get_paginated_data( 352 base_url=base_url, data_key_name="firewalls" 353 ) 354 355 def get_firewall_by_name(self): 356 rule = {} 357 for firewall in self.firewalls: 358 if firewall["name"] == self.name: 359 rule.update(firewall) 360 return rule 361 return None 362 363 def ordered(self, obj): 364 if isinstance(obj, dict): 365 return sorted((k, self.ordered(v)) for k, v in obj.items()) 366 if isinstance(obj, list): 367 return sorted(self.ordered(x) for x in obj) 368 else: 369 return obj 370 371 def fill_protocol_defaults(self, obj): 372 if obj.get("protocol") is None: 373 obj["protocol"] = "tcp" 374 375 return obj 376 377 def fill_source_and_destination_defaults_inner(self, obj): 378 addresses = obj.get("addresses") or [] 379 380 droplet_ids = obj.get("droplet_ids") or [] 381 droplet_ids = [str(droplet_id) for droplet_id in droplet_ids] 382 383 load_balancer_uids = obj.get("load_balancer_uids") or [] 384 load_balancer_uids = [str(uid) for uid in load_balancer_uids] 385 386 tags = obj.get("tags") or [] 387 388 data = { 389 "addresses": addresses, 390 "droplet_ids": droplet_ids, 391 "load_balancer_uids": load_balancer_uids, 392 "tags": tags, 393 } 394 395 return data 396 397 def fill_sources_and_destinations_defaults(self, obj, prop): 398 value = obj.get(prop) 399 400 if value is None: 401 value = {} 402 else: 403 value = self.fill_source_and_destination_defaults_inner(value) 404 405 obj[prop] = value 406 407 return obj 408 409 def fill_data_defaults(self, obj): 410 inbound_rules = obj.get("inbound_rules") 411 412 if inbound_rules is None: 413 inbound_rules = [] 414 else: 415 inbound_rules = [self.fill_protocol_defaults(x) for x in inbound_rules] 416 inbound_rules = [ 417 self.fill_sources_and_destinations_defaults(x, "sources") 418 for x in inbound_rules 419 ] 420 421 outbound_rules = obj.get("outbound_rules") 422 423 if outbound_rules is None: 424 outbound_rules = [] 425 else: 426 outbound_rules = [self.fill_protocol_defaults(x) for x in outbound_rules] 427 outbound_rules = [ 428 self.fill_sources_and_destinations_defaults(x, "destinations") 429 for x in outbound_rules 430 ] 431 432 droplet_ids = obj.get("droplet_ids") or [] 433 droplet_ids = [str(droplet_id) for droplet_id in droplet_ids] 434 435 tags = obj.get("tags") or [] 436 437 data = { 438 "name": obj.get("name"), 439 "inbound_rules": inbound_rules, 440 "outbound_rules": outbound_rules, 441 "droplet_ids": droplet_ids, 442 "tags": tags, 443 } 444 445 return data 446 447 def data_to_compare(self, obj): 448 return self.ordered(self.fill_data_defaults(obj)) 449 450 def update(self, obj, id): 451 if id is None: 452 status_code_success = 202 453 resp = self.rest.post(path=self.baseurl, data=obj) 454 else: 455 status_code_success = 200 456 resp = self.rest.put(path=self.baseurl + "/" + id, data=obj) 457 status_code = resp.status_code 458 if status_code != status_code_success: 459 error = resp.json 460 error.update( 461 { 462 "context": "error when trying to " 463 + ("create" if (id is None) else "update") 464 + " firewalls" 465 } 466 ) 467 error.update({"status_code": status_code}) 468 error.update({"status_code_success": status_code_success}) 469 self.module.fail_json(msg=error) 470 self.module.exit_json(changed=True, data=resp.json["firewall"]) 471 472 def create(self): 473 rule = self.get_firewall_by_name() 474 data = { 475 "name": self.module.params.get("name"), 476 "inbound_rules": self.module.params.get("inbound_rules"), 477 "outbound_rules": self.module.params.get("outbound_rules"), 478 "droplet_ids": self.module.params.get("droplet_ids"), 479 "tags": self.module.params.get("tags"), 480 } 481 if rule is None: 482 self.update(data, None) 483 else: 484 rule_data = { 485 "name": rule.get("name"), 486 "inbound_rules": rule.get("inbound_rules"), 487 "outbound_rules": rule.get("outbound_rules"), 488 "droplet_ids": rule.get("droplet_ids"), 489 "tags": rule.get("tags"), 490 } 491 492 user_data = { 493 "name": data.get("name"), 494 "inbound_rules": data.get("inbound_rules"), 495 "outbound_rules": data.get("outbound_rules"), 496 "droplet_ids": data.get("droplet_ids"), 497 "tags": data.get("tags"), 498 } 499 500 if self.data_to_compare(user_data) == self.data_to_compare(rule_data): 501 self.module.exit_json(changed=False, data=rule) 502 else: 503 self.update(data, rule.get("id")) 504 505 def destroy(self): 506 rule = self.get_firewall_by_name() 507 if rule is None: 508 self.module.exit_json(changed=False, data="Firewall does not exist") 509 else: 510 endpoint = self.baseurl + "/" + rule["id"] 511 resp = self.rest.delete(path=endpoint) 512 status_code = resp.status_code 513 if status_code != 204: 514 self.module.fail_json(msg="Failed to delete firewall") 515 self.module.exit_json( 516 changed=True, 517 data="Deleted firewall rule: {0} - {1}".format( 518 rule["name"], rule["id"] 519 ), 520 ) 521 522 523def core(module): 524 state = module.params.get("state") 525 firewall = DOFirewall(module) 526 527 if state == "present": 528 firewall.create() 529 elif state == "absent": 530 firewall.destroy() 531 532 533def main(): 534 argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() 535 argument_spec.update( 536 name=dict(type="str", required=True), 537 state=dict(type="str", choices=["present", "absent"], default="present"), 538 droplet_ids=dict(type="list", elements="str", required=False), 539 tags=dict(type="list", elements="str", required=False), 540 inbound_rules=dict( 541 type="list", elements="dict", options=inbound_spec, required=True 542 ), 543 outbound_rules=dict( 544 type="list", elements="dict", options=outbound_spec, required=True 545 ), 546 ) 547 module = AnsibleModule(argument_spec=argument_spec) 548 549 try: 550 core(module) 551 except Exception as e: 552 module.fail_json(msg=to_native(e), exception=format_exc()) 553 554 555if __name__ == "__main__": 556 main() 557