1"""
2Management of firewalld
3
4.. versionadded:: 2015.8.0
5
6The following example applies changes to the public zone, blocks echo-reply
7and echo-request packets, does not set the zone to be the default, enables
8masquerading, and allows ports 22/tcp and 25/tcp.
9It will be applied permanently and directly before restart/reload.
10
11.. code-block:: yaml
12
13    public:
14      firewalld.present:
15        - name: public
16        - block_icmp:
17          - echo-reply
18          - echo-request
19        - default: False
20        - masquerade: True
21        - ports:
22          - 22/tcp
23          - 25/tcp
24
25The following example applies changes to the public zone, enables
26masquerading and configures port forwarding TCP traffic from port 22
27to 2222, and forwards TCP traffic from port 80 to 443 at 192.168.0.1.
28
29.. code-block:: yaml
30
31  my_zone:
32    firewalld.present:
33      - name: public
34      - masquerade: True
35      - port_fwd:
36        - 22:2222:tcp
37        - 80:443:tcp:192.168.0.1
38
39The following example binds the public zone to interface eth0 and to all
40packets coming from the 192.168.1.0/24 subnet. It also removes the zone
41from all other interfaces or sources.
42
43.. code-block:: yaml
44
45  public:
46    firewalld.present:
47      - name: public
48      - interfaces:
49        - eth0
50      - sources:
51        - 192.168.1.0/24
52
53Here, we define a new service that encompasses TCP ports 4505 4506:
54
55.. code-block:: yaml
56
57  saltmaster:
58    firewalld.service:
59      - name: saltmaster
60      - ports:
61        - 4505/tcp
62        - 4506/tcp
63
64To make this new service available in a zone, the following can be used, which
65would allow access to the salt master from the 10.0.0.0/8 subnet:
66
67.. code-block:: yaml
68
69  saltzone:
70    firewalld.present:
71      - name: saltzone
72      - services:
73        - saltmaster
74      - sources:
75        - 10.0.0.0/8
76
77Another way of implementing the same rule above using rich rules is demonstrated
78here:
79
80.. code-block:: yaml
81
82  saltzone:
83    firewalld.present:
84      - name: saltzone
85      - rich_rules:
86        - rule service name="saltmaster" accept
87      - sources:
88        - 10.0.0.0/8
89
90The format of rich rules is the same as:
91
92.. code-block:: shell
93
94  firewall-cmd --list-rich-rules
95
96with an example output of:
97
98.. code-block:: text
99
100  rule protocol value="icmp" accept
101  rule protocol value="ipv6-icmp" accept
102  rule service name="snmp" accept
103"""
104
105
106import logging
107
108import salt.utils.path
109from salt.exceptions import CommandExecutionError
110from salt.output import nested
111
112log = logging.getLogger(__name__)
113
114
115class ForwardingMapping:
116    """
117    Represents a port forwarding statement mapping a local port to a remote
118    port for a specific protocol (TCP or UDP)
119    """
120
121    def __init__(self, srcport, destport, protocol, destaddr):
122        self.srcport = srcport
123        self.destport = destport
124        self.protocol = protocol
125        self.destaddr = destaddr
126
127    def __eq__(self, other):
128        return (
129            self.srcport == other.srcport
130            and self.destport == other.destport
131            and self.protocol == other.protocol
132            and self.destaddr == other.destaddr
133        )
134
135    def __ne__(self, other):
136        return not self.__eq__(other)
137
138    # hash is needed for set operations
139    def __hash__(self):
140        return (
141            hash(self.srcport)
142            ^ hash(self.destport)
143            ^ hash(self.protocol)
144            ^ hash(self.destaddr)
145        )
146
147    def todict(self):
148        """
149        Returns a pretty dictionary meant for command line output.
150        """
151        return {
152            "Source port": self.srcport,
153            "Destination port": self.destport,
154            "Protocol": self.protocol,
155            "Destination address": self.destaddr,
156        }
157
158
159def _parse_forward(mapping):
160    """
161    Parses a port forwarding statement in the form used by this state:
162
163    from_port:to_port:protocol[:destination]
164
165    and returns a ForwardingMapping object
166    """
167    if len(mapping.split(":")) > 3:
168        (srcport, destport, protocol, destaddr) = mapping.split(":")
169    else:
170        (srcport, destport, protocol) = mapping.split(":")
171        destaddr = ""
172    return ForwardingMapping(srcport, destport, protocol, destaddr)
173
174
175def __virtual__():
176    """
177    Ensure the firewall-cmd is available
178    """
179    if salt.utils.path.which("firewall-cmd"):
180        return True
181
182    return (
183        False,
184        "firewall-cmd is not available, firewalld is probably not installed.",
185    )
186
187
188def present(
189    name,
190    block_icmp=None,
191    prune_block_icmp=False,
192    default=None,
193    masquerade=False,
194    ports=None,
195    prune_ports=False,
196    port_fwd=None,
197    prune_port_fwd=False,
198    services=None,
199    prune_services=False,
200    interfaces=None,
201    prune_interfaces=False,
202    sources=None,
203    prune_sources=False,
204    rich_rules=None,
205    prune_rich_rules=False,
206):
207
208    """
209    Ensure a zone has specific attributes.
210
211    name
212        The zone to modify.
213
214    default : None
215        Set this zone as the default zone if ``True``.
216
217    masquerade : False
218        Enable or disable masquerade for a zone.
219
220    block_icmp : None
221        List of ICMP types to block in the zone.
222
223    prune_block_icmp : False
224        If ``True``, remove all but the specified block_icmp from the zone.
225
226    ports : None
227        List of ports to add to the zone.
228
229    prune_ports : False
230        If ``True``, remove all but the specified ports from the zone.
231
232    port_fwd : None
233        List of port forwards to add to the zone.
234
235    prune_port_fwd : False
236        If ``True``, remove all but the specified port_fwd from the zone.
237
238    services : None
239        List of services to add to the zone.
240
241    prune_services : False
242        If ``True``, remove all but the specified services from the zone.
243        .. note:: Currently defaults to True for compatibility, but will be changed to False in a future release.
244
245    interfaces : None
246        List of interfaces to add to the zone.
247
248    prune_interfaces : False
249        If ``True``, remove all but the specified interfaces from the zone.
250
251    sources : None
252        List of sources to add to the zone.
253
254    prune_sources : False
255        If ``True``, remove all but the specified sources from the zone.
256
257    rich_rules : None
258        List of rich rules to add to the zone.
259
260    prune_rich_rules : False
261        If ``True``, remove all but the specified rich rules from the zone.
262    """
263    ret = _present(
264        name,
265        block_icmp,
266        prune_block_icmp,
267        default,
268        masquerade,
269        ports,
270        prune_ports,
271        port_fwd,
272        prune_port_fwd,
273        services,
274        prune_services,
275        interfaces,
276        prune_interfaces,
277        sources,
278        prune_sources,
279        rich_rules,
280        prune_rich_rules,
281    )
282
283    # Reload firewalld service on changes
284    if ret["changes"] != {}:
285        __salt__["firewalld.reload_rules"]()
286
287    return ret
288
289
290def service(name, ports=None, protocols=None):
291    """
292    Ensure the service exists and encompasses the specified ports and
293    protocols.
294
295    .. versionadded:: 2016.11.0
296    """
297    ret = {"name": name, "result": False, "changes": {}, "comment": ""}
298
299    if name not in __salt__["firewalld.get_services"]():
300        __salt__["firewalld.new_service"](name, restart=False)
301
302    ports = ports or []
303
304    try:
305        _current_ports = __salt__["firewalld.get_service_ports"](name)
306    except CommandExecutionError as err:
307        ret["comment"] = "Error: {}".format(err)
308        return ret
309
310    new_ports = set(ports) - set(_current_ports)
311    old_ports = set(_current_ports) - set(ports)
312
313    for port in new_ports:
314        if not __opts__["test"]:
315            try:
316                __salt__["firewalld.add_service_port"](name, port)
317            except CommandExecutionError as err:
318                ret["comment"] = "Error: {}".format(err)
319                return ret
320
321    for port in old_ports:
322        if not __opts__["test"]:
323            try:
324                __salt__["firewalld.remove_service_port"](name, port)
325            except CommandExecutionError as err:
326                ret["comment"] = "Error: {}".format(err)
327                return ret
328
329    if new_ports or old_ports:
330        ret["changes"].update({"ports": {"old": _current_ports, "new": ports}})
331
332    protocols = protocols or []
333
334    try:
335        _current_protocols = __salt__["firewalld.get_service_protocols"](name)
336    except CommandExecutionError as err:
337        ret["comment"] = "Error: {}".format(err)
338        return ret
339
340    new_protocols = set(protocols) - set(_current_protocols)
341    old_protocols = set(_current_protocols) - set(protocols)
342
343    for protocol in new_protocols:
344        if not __opts__["test"]:
345            try:
346                __salt__["firewalld.add_service_protocol"](name, protocol)
347            except CommandExecutionError as err:
348                ret["comment"] = "Error: {}".format(err)
349                return ret
350
351    for protocol in old_protocols:
352        if not __opts__["test"]:
353            try:
354                __salt__["firewalld.remove_service_protocol"](name, protocol)
355            except CommandExecutionError as err:
356                ret["comment"] = "Error: {}".format(err)
357                return ret
358
359    if new_protocols or old_protocols:
360        ret["changes"].update(
361            {"protocols": {"old": _current_protocols, "new": protocols}}
362        )
363
364    if ret["changes"] != {}:
365        __salt__["firewalld.reload_rules"]()
366
367    ret["result"] = True
368    if ret["changes"] == {}:
369        ret["comment"] = "'{}' is already in the desired state.".format(name)
370        return ret
371
372    if __opts__["test"]:
373        ret["result"] = None
374        ret["comment"] = "Configuration for '{}' will change.".format(name)
375        return ret
376
377    ret["comment"] = "'{}' was configured.".format(name)
378    return ret
379
380
381def _present(
382    name,
383    block_icmp=None,
384    prune_block_icmp=False,
385    default=None,
386    masquerade=False,
387    ports=None,
388    prune_ports=False,
389    port_fwd=None,
390    prune_port_fwd=False,
391    services=None,
392    # TODO: prune_services=False in future release
393    # prune_services=False,
394    prune_services=None,
395    interfaces=None,
396    prune_interfaces=False,
397    sources=None,
398    prune_sources=False,
399    rich_rules=None,
400    prune_rich_rules=False,
401):
402    """
403    Ensure a zone has specific attributes.
404    """
405    ret = {"name": name, "result": False, "changes": {}, "comment": ""}
406
407    try:
408        zones = __salt__["firewalld.get_zones"](permanent=True)
409    except CommandExecutionError as err:
410        ret["comment"] = "Error: {}".format(err)
411        return ret
412
413    if name not in zones:
414        if not __opts__["test"]:
415            try:
416                __salt__["firewalld.new_zone"](name)
417            except CommandExecutionError as err:
418                ret["comment"] = "Error: {}".format(err)
419                return ret
420
421        ret["changes"].update({name: {"old": zones, "new": name}})
422
423    if block_icmp or prune_block_icmp:
424        block_icmp = block_icmp or []
425        new_icmp_types = []
426        old_icmp_types = []
427
428        try:
429            _current_icmp_blocks = __salt__["firewalld.list_icmp_block"](
430                name, permanent=True
431            )
432        except CommandExecutionError as err:
433            ret["comment"] = "Error: {}".format(err)
434            return ret
435
436        if block_icmp:
437            try:
438                _valid_icmp_types = __salt__["firewalld.get_icmp_types"](permanent=True)
439            except CommandExecutionError as err:
440                ret["comment"] = "Error: {}".format(err)
441                return ret
442
443            # log errors for invalid ICMP types in block_icmp input
444            for icmp_type in set(block_icmp) - set(_valid_icmp_types):
445                log.error("%s is an invalid ICMP type", icmp_type)
446                block_icmp.remove(icmp_type)
447
448            new_icmp_types = set(block_icmp) - set(_current_icmp_blocks)
449            for icmp_type in new_icmp_types:
450                if not __opts__["test"]:
451                    try:
452                        __salt__["firewalld.block_icmp"](
453                            name, icmp_type, permanent=True
454                        )
455                    except CommandExecutionError as err:
456                        ret["comment"] = "Error: {}".format(err)
457                        return ret
458
459        if prune_block_icmp:
460            old_icmp_types = set(_current_icmp_blocks) - set(block_icmp)
461            for icmp_type in old_icmp_types:
462                # no need to check against _valid_icmp_types here, because all
463                # elements in old_icmp_types are guaranteed to be in
464                # _current_icmp_blocks, whose elements are inherently valid
465                if not __opts__["test"]:
466                    try:
467                        __salt__["firewalld.allow_icmp"](
468                            name, icmp_type, permanent=True
469                        )
470                    except CommandExecutionError as err:
471                        ret["comment"] = "Error: {}".format(err)
472                        return ret
473
474        if new_icmp_types or old_icmp_types:
475            # If we're not pruning, include current items in new output so it's clear
476            # that they're still present
477            if not prune_block_icmp:
478                block_icmp = list(new_icmp_types | set(_current_icmp_blocks))
479            ret["changes"].update(
480                {"icmp_types": {"old": _current_icmp_blocks, "new": block_icmp}}
481            )
482
483    # that's the only parameter that can't be permanent or runtime, it's
484    # directly both
485    if default:
486        try:
487            default_zone = __salt__["firewalld.default_zone"]()
488        except CommandExecutionError as err:
489            ret["comment"] = "Error: {}".format(err)
490            return ret
491        if name != default_zone:
492            if not __opts__["test"]:
493                try:
494                    __salt__["firewalld.set_default_zone"](name)
495                except CommandExecutionError as err:
496                    ret["comment"] = "Error: {}".format(err)
497                    return ret
498            ret["changes"].update({"default": {"old": default_zone, "new": name}})
499
500    try:
501        masquerade_ret = __salt__["firewalld.get_masquerade"](name, permanent=True)
502    except CommandExecutionError as err:
503        ret["comment"] = "Error: {}".format(err)
504        return ret
505
506    if masquerade and not masquerade_ret:
507        if not __opts__["test"]:
508            try:
509                __salt__["firewalld.add_masquerade"](name, permanent=True)
510            except CommandExecutionError as err:
511                ret["comment"] = "Error: {}".format(err)
512                return ret
513        ret["changes"].update(
514            {"masquerade": {"old": "", "new": "Masquerading successfully set."}}
515        )
516    elif not masquerade and masquerade_ret:
517        if not __opts__["test"]:
518            try:
519                __salt__["firewalld.remove_masquerade"](name, permanent=True)
520            except CommandExecutionError as err:
521                ret["comment"] = "Error: {}".format(err)
522                return ret
523        ret["changes"].update(
524            {"masquerade": {"old": "", "new": "Masquerading successfully disabled."}}
525        )
526
527    if ports or prune_ports:
528        ports = ports or []
529        try:
530            _current_ports = __salt__["firewalld.list_ports"](name, permanent=True)
531        except CommandExecutionError as err:
532            ret["comment"] = "Error: {}".format(err)
533            return ret
534
535        new_ports = set(ports) - set(_current_ports)
536        old_ports = []
537
538        for port in new_ports:
539            if not __opts__["test"]:
540                try:
541                    __salt__["firewalld.add_port"](
542                        name, port, permanent=True, force_masquerade=False
543                    )
544                except CommandExecutionError as err:
545                    ret["comment"] = "Error: {}".format(err)
546                    return ret
547
548        if prune_ports:
549            old_ports = set(_current_ports) - set(ports)
550            for port in old_ports:
551                if not __opts__["test"]:
552                    try:
553                        __salt__["firewalld.remove_port"](name, port, permanent=True)
554                    except CommandExecutionError as err:
555                        ret["comment"] = "Error: {}".format(err)
556                        return ret
557
558        if new_ports or old_ports:
559            # If we're not pruning, include current items in new output so it's clear
560            # that they're still present
561            if not prune_ports:
562                ports = list(new_ports | set(_current_ports))
563            ret["changes"].update({"ports": {"old": _current_ports, "new": ports}})
564
565    if port_fwd or prune_port_fwd:
566        port_fwd = port_fwd or []
567        try:
568            _current_port_fwd = __salt__["firewalld.list_port_fwd"](
569                name, permanent=True
570            )
571        except CommandExecutionError as err:
572            ret["comment"] = "Error: {}".format(err)
573            return ret
574
575        port_fwd = [_parse_forward(fwd) for fwd in port_fwd]
576        _current_port_fwd = [
577            ForwardingMapping(
578                srcport=fwd["Source port"],
579                destport=fwd["Destination port"],
580                protocol=fwd["Protocol"],
581                destaddr=fwd["Destination address"],
582            )
583            for fwd in _current_port_fwd
584        ]
585
586        new_port_fwd = set(port_fwd) - set(_current_port_fwd)
587        old_port_fwd = []
588
589        for fwd in new_port_fwd:
590            if not __opts__["test"]:
591                try:
592                    __salt__["firewalld.add_port_fwd"](
593                        name,
594                        fwd.srcport,
595                        fwd.destport,
596                        fwd.protocol,
597                        fwd.destaddr,
598                        permanent=True,
599                        force_masquerade=False,
600                    )
601                except CommandExecutionError as err:
602                    ret["comment"] = "Error: {}".format(err)
603                    return ret
604
605        if prune_port_fwd:
606            old_port_fwd = set(_current_port_fwd) - set(port_fwd)
607            for fwd in old_port_fwd:
608                if not __opts__["test"]:
609                    try:
610                        __salt__["firewalld.remove_port_fwd"](
611                            name,
612                            fwd.srcport,
613                            fwd.destport,
614                            fwd.protocol,
615                            fwd.destaddr,
616                            permanent=True,
617                        )
618                    except CommandExecutionError as err:
619                        ret["comment"] = "Error: {}".format(err)
620                        return ret
621
622        if new_port_fwd or old_port_fwd:
623            # If we're not pruning, include current items in new output so it's clear
624            # that they're still present
625            if not prune_port_fwd:
626                port_fwd = list(new_port_fwd | set(_current_port_fwd))
627            ret["changes"].update(
628                {
629                    "port_fwd": {
630                        "old": [fwd.todict() for fwd in _current_port_fwd],
631                        "new": [fwd.todict() for fwd in port_fwd],
632                    }
633                }
634            )
635
636    if services or prune_services:
637        services = services or []
638        try:
639            _current_services = __salt__["firewalld.list_services"](
640                name, permanent=True
641            )
642        except CommandExecutionError as err:
643            ret["comment"] = "Error: {}".format(err)
644            return ret
645
646        new_services = set(services) - set(_current_services)
647        old_services = []
648
649        for new_service in new_services:
650            if not __opts__["test"]:
651                try:
652                    __salt__["firewalld.add_service"](new_service, name, permanent=True)
653                except CommandExecutionError as err:
654                    ret["comment"] = "Error: {}".format(err)
655                    return ret
656
657        if prune_services:
658            old_services = set(_current_services) - set(services)
659            for old_service in old_services:
660                if not __opts__["test"]:
661                    try:
662                        __salt__["firewalld.remove_service"](
663                            old_service, name, permanent=True
664                        )
665                    except CommandExecutionError as err:
666                        ret["comment"] = "Error: {}".format(err)
667                        return ret
668
669        if new_services or old_services:
670            # If we're not pruning, include current items in new output so it's clear
671            # that they're still present
672            if not prune_services:
673                services = list(new_services | set(_current_services))
674            ret["changes"].update(
675                {"services": {"old": _current_services, "new": services}}
676            )
677
678    if interfaces or prune_interfaces:
679        interfaces = interfaces or []
680        try:
681            _current_interfaces = __salt__["firewalld.get_interfaces"](
682                name, permanent=True
683            )
684        except CommandExecutionError as err:
685            ret["comment"] = "Error: {}".format(err)
686            return ret
687
688        new_interfaces = set(interfaces) - set(_current_interfaces)
689        old_interfaces = []
690
691        for interface in new_interfaces:
692            if not __opts__["test"]:
693                try:
694                    __salt__["firewalld.add_interface"](name, interface, permanent=True)
695                except CommandExecutionError as err:
696                    ret["comment"] = "Error: {}".format(err)
697                    return ret
698
699        if prune_interfaces:
700            old_interfaces = set(_current_interfaces) - set(interfaces)
701            for interface in old_interfaces:
702                if not __opts__["test"]:
703                    try:
704                        __salt__["firewalld.remove_interface"](
705                            name, interface, permanent=True
706                        )
707                    except CommandExecutionError as err:
708                        ret["comment"] = "Error: {}".format(err)
709                        return ret
710
711        if new_interfaces or old_interfaces:
712            # If we're not pruning, include current items in new output so it's clear
713            # that they're still present
714            if not prune_interfaces:
715                interfaces = list(new_interfaces | set(_current_interfaces))
716            ret["changes"].update(
717                {"interfaces": {"old": _current_interfaces, "new": interfaces}}
718            )
719
720    if sources or prune_sources:
721        sources = sources or []
722        try:
723            _current_sources = __salt__["firewalld.get_sources"](name, permanent=True)
724        except CommandExecutionError as err:
725            ret["comment"] = "Error: {}".format(err)
726            return ret
727
728        new_sources = set(sources) - set(_current_sources)
729        old_sources = []
730
731        for source in new_sources:
732            if not __opts__["test"]:
733                try:
734                    __salt__["firewalld.add_source"](name, source, permanent=True)
735                except CommandExecutionError as err:
736                    ret["comment"] = "Error: {}".format(err)
737                    return ret
738
739        if prune_sources:
740            old_sources = set(_current_sources) - set(sources)
741            for source in old_sources:
742                if not __opts__["test"]:
743                    try:
744                        __salt__["firewalld.remove_source"](
745                            name, source, permanent=True
746                        )
747                    except CommandExecutionError as err:
748                        ret["comment"] = "Error: {}".format(err)
749                        return ret
750
751        if new_sources or old_sources:
752            # If we're not pruning, include current items in new output so it's clear
753            # that they're still present
754            if not prune_sources:
755                sources = list(new_sources | set(_current_sources))
756            ret["changes"].update(
757                {"sources": {"old": _current_sources, "new": sources}}
758            )
759
760    if rich_rules or prune_rich_rules:
761        rich_rules = rich_rules or []
762        try:
763            _current_rich_rules = __salt__["firewalld.get_rich_rules"](
764                name, permanent=True
765            )
766        except CommandExecutionError as err:
767            ret["comment"] = "Error: {}".format(err)
768            return ret
769
770        new_rich_rules = set(rich_rules) - set(_current_rich_rules)
771        old_rich_rules = []
772
773        for rich_rule in new_rich_rules:
774            if not __opts__["test"]:
775                try:
776                    __salt__["firewalld.add_rich_rule"](name, rich_rule, permanent=True)
777                except CommandExecutionError as err:
778                    ret["comment"] = "Error: {}".format(err)
779                    return ret
780
781        if prune_rich_rules:
782            old_rich_rules = set(_current_rich_rules) - set(rich_rules)
783            for rich_rule in old_rich_rules:
784                if not __opts__["test"]:
785                    try:
786                        __salt__["firewalld.remove_rich_rule"](
787                            name, rich_rule, permanent=True
788                        )
789                    except CommandExecutionError as err:
790                        ret["comment"] = "Error: {}".format(err)
791                        return ret
792
793        if new_rich_rules or old_rich_rules:
794            # If we're not pruning, include current items in new output so it's clear
795            # that they're still present
796            if not prune_rich_rules:
797                rich_rules = list(new_rich_rules | set(_current_rich_rules))
798            ret["changes"].update(
799                {"rich_rules": {"old": _current_rich_rules, "new": rich_rules}}
800            )
801
802    # No changes
803    if ret["changes"] == {}:
804        ret["result"] = True
805        ret["comment"] = "'{}' is already in the desired state.".format(name)
806        return ret
807
808    # test=True and changes predicted
809    if __opts__["test"]:
810        ret["result"] = None
811        # build comment string
812        nested.__opts__ = __opts__
813        comment = []
814        comment.append("Configuration for '{}' will change:".format(name))
815        comment.append(nested.output(ret["changes"]).rstrip())
816        ret["comment"] = "\n".join(comment)
817        ret["changes"] = {}
818        return ret
819
820    # Changes were made successfully
821    ret["result"] = True
822    ret["comment"] = "'{}' was configured.".format(name)
823    return ret
824