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