diff --git a/molecule/firewall_rules/converge.yml b/molecule/firewall_rules/converge.yml new file mode 100644 index 00000000..5255d0f0 --- /dev/null +++ b/molecule/firewall_rules/converge.yml @@ -0,0 +1,1173 @@ +--- +- name: converge + hosts: all + become: true + tasks: + # Test basic functionality with different actions + - name: "Action: Test pass action" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + description: "New Test pass Rule" + source: + destination: + + - name: "Action: Test block action" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'block' + description: "New Test block Rule" + + - name: "Action: Test reject action" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'reject' + description: "New Test reject Rule" + + # Test basic functionality of the disabled button + - name: "Disabled: Test disabled button" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + description: "New Test disabled pass Rule" + disabled: true + + # Test basic functionality of the disabled quick button + - name: "Quick: Test pass Rule with quick disabled" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + quick: false + description: "New Test pass Rule with quick disabled" + + # Test different Interfaces + - name: "Interface: Test pass Rule" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + description: "New Test pass Rule of Interface lan" + + - name: "Interface: Test pass Rule" + puzzle.opnsense.firewall_rules: + interface: 'lo0' + action: 'pass' + description: "New Test pass Rule of Interface Loopback" + + - name: "Interface: Test pass Rule" + puzzle.opnsense.firewall_rules: + interface: 'openvpn' + action: 'pass' + description: "New Test pass Rule of Interface OpenVPN" + + - name: "Interface: Test pass Rule" + puzzle.opnsense.firewall_rules: + interface: 'opt2' + action: 'pass' + description: "New Test pass Rule of Interface VAGRANT" + + # Test different Directions + - name: "Direction: Test pass Rule with Direction in" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + direction: in + description: "New Test pass Rule with Direction in" + + - name: "Direction: Test pass Rule with Direction out" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + direction: out + description: "New Test pass Rule with Direction out" + + # Test different IPProtocols + - name: "IPProtocol: Test pass Rule with IPProtocol IPv4" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + ipprotocol: 'inet' + description: "New Test pass Rule with IPv4" + + - name: "IPProtocol: Test pass Rule with IPProtocol IPv6" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + ipprotocol: 'inet6' + description: "New Test pass Rule with IPProtocol IPv6" + + - name: "IPProtocol: Test pass Rule with IPProtocol IPv4 + IPv6" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + ipprotocol: 'inet46' + description: "New Test pass Rule with IPProtocol IPv4 + IPv6" + + # Test different Protocols + - name: "Protocol: Test pass Rule with Protocol any" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'any' + description: "New Test pass Rule with Protocol any" + + - name: "Protocol: Test pass Rule with Protocol tcp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'tcp' + description: "New Test pass Rule with Protocol tcp" + + - name: "Protocol: Test pass Rule with Protocol udp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'udp' + description: "New Test pass Rule with Protocol udp" + + - name: "Protocol: Test pass Rule with Protocol tcp/udp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'tcp/udp' + description: "New Test pass Rule with Protocol tcp/udp" + + - name: "Protocol: Test pass Rule with Protocol icmp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'icmp' + description: "New Test pass Rule with Protocol icmp" + + - name: "Protocol: Test pass Rule with Protocol esp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'esp' + description: "New Test pass Rule with Protocol esp" + + - name: "Protocol: Test pass Rule with Protocol ah" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'ah' + description: "New Test pass Rule with Protocol ah" + + - name: "Protocol: Test pass Rule with Protocol gre" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'gre' + description: "New Test pass Rule with Protocol gre" + + - name: "Protocol: Test pass Rule with Protocol igmp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'igmp' + description: "New Test pass Rule with Protocol igmp" + + - name: "Protocol: Test pass Rule with Protocol pim" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'pim' + description: "New Test pass Rule with Protocol pim" + + - name: "Protocol: Test pass Rule with Protocol ospf" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'ospf' + description: "New Test pass Rule with Protocol ospf" + + - name: "Protocol: Test pass Rule with Protocol ggp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'ggp' + description: "New Test pass Rule with Protocol ggp" + + - name: "Protocol: Test pass Rule with Protocol ipencap" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'ipencap' + description: "New Test pass Rule with Protocol ipencap" + + - name: "Protocol: Test pass Rule with Protocol st2" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'st2' + description: "New Test pass Rule with Protocol st2" + + - name: "Protocol: Test pass Rule with Protocol cbt" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'cbt' + description: "New Test pass Rule with Protocol cbt" + + - name: "Protocol: Test pass Rule with Protocol egp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'egp' + description: "New Test pass Rule with Protocol egp" + + - name: "Protocol: Test pass Rule with Protocol igp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'igp' + description: "New Test pass Rule with Protocol igp" + + - name: "Protocol: Test pass Rule with Protocol bbn-rcc" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'bbn-rcc' + description: "New Test pass Rule with Protocol bbn-rcc" + + - name: "Protocol: Test pass Rule with Protocol nvp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'nvp' + description: "New Test pass Rule with Protocol nvp" + + - name: "Protocol: Test pass Rule with Protocol pup" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'pup' + description: "New Test pass Rule with Protocol pup" + + - name: "Protocol: Test pass Rule with Protocol argus" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'argus' + description: "New Test pass Rule with Protocol argus" + + - name: "Protocol: Test pass Rule with Protocol emcon" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'emcon' + description: "New Test pass Rule with Protocol emcon" + + - name: "Protocol: Test pass Rule with Protocol xnet" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'xnet' + description: "New Test pass Rule with Protocol xnet" + + - name: "Protocol: Test pass Rule with Protocol chaos" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'chaos' + description: "New Test pass Rule with Protocol chaos" + + - name: "Protocol: Test pass Rule with Protocol mux" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'mux' + description: "New Test pass Rule with Protocol mux" + + - name: "Protocol: Test pass Rule with Protocol dcn" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'dcn' + description: "New Test pass Rule with Protocol dcn" + + - name: "Protocol: Test pass Rule with Protocol hmp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'hmp' + description: "New Test pass Rule with Protocol hmp" + + - name: "Protocol: Test pass Rule with Protocol prm" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'prm' + description: "New Test pass Rule with Protocol prm" + + - name: "Protocol: Test pass Rule with Protocol xns-idp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'xns-idp' + description: "New Test pass Rule with Protocol xns-idp" + + - name: "Protocol: Test pass Rule with Protocol trunk-1" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'trunk-1' + description: "New Test pass Rule with Protocol trunk-1" + + - name: "Protocol: Test pass Rule with Protocol trunk-2" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'trunk-2' + description: "New Test pass Rule with Protocol trunk-2" + + - name: "Protocol: Test pass Rule with Protocol leaf-1" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'leaf-1' + description: "New Test pass Rule with Protocol leaf-1" + + - name: "Protocol: Test pass Rule with Protocol leaf-2" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'leaf-2' + description: "New Test pass Rule with Protocol leaf-2" + + - name: "Protocol: Test pass Rule with Protocol rdp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'rdp' + description: "New Test pass Rule with Protocol rdp" + + - name: "Protocol: Test pass Rule with Protocol irtp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'irtp' + description: "New Test pass Rule with Protocol irtp" + + - name: "Protocol: Test pass Rule with Protocol iso-tp4" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'iso-tp4' + description: "New Test pass Rule with Protocol iso-tp4" + + - name: "Protocol: Test pass Rule with Protocol netblt" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'netblt' + description: "New Test pass Rule with Protocol netblt" + + - name: "Protocol: Test pass Rule with Protocol mfe-nsp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'mfe-nsp' + description: "New Test pass Rule with Protocol mfe-nsp" + + - name: "Protocol: Test pass Rule with Protocol merit-inp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'merit-inp' + description: "New Test pass Rule with Protocol merit-inp" + + - name: "Protocol: Test pass Rule with Protocol dccp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'dccp' + description: "New Test pass Rule with Protocol dccp" + + - name: "Protocol: Test pass Rule with Protocol 3pc" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: '3pc' + description: "New Test pass Rule with Protocol 3pc" + + - name: "Protocol: Test pass Rule with Protocol idpr" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'idpr' + description: "New Test pass Rule with Protocol idpr" + + - name: "Protocol: Test pass Rule with Protocol xtp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'xtp' + description: "New Test pass Rule with Protocol xtp" + + - name: "Protocol: Test pass Rule with Protocol ddp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'ddp' + description: "New Test pass Rule with Protocol ddp" + + - name: "Protocol: Test pass Rule with Protocol idpr-cmtp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'idpr-cmtp' + description: "New Test pass Rule with Protocol idpr-cmtp" + + - name: "Protocol: Test pass Rule with Protocol tp++" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'tp++' + description: "New Test pass Rule with Protocol tp++" + + - name: "Protocol: Test pass Rule with Protocol il" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'il' + description: "New Test pass Rule with Protocol il" + + - name: "Protocol: Test pass Rule with Protocol ipv6" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'ipv6' + description: "New Test pass Rule with Protocol ipv6" + + - name: "Protocol: Test pass Rule with Protocol sdrp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'sdrp' + description: "New Test pass Rule with Protocol sdrp" + + - name: "Protocol: Test pass Rule with Protocol idrp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'idrp' + description: "New Test pass Rule with Protocol idrp" + + - name: "Protocol: Test pass Rule with Protocol rsvp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'rsvp' + description: "New Test pass Rule with Protocol rsvp" + + - name: "Protocol: Test pass Rule with Protocol dsr" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'dsr' + description: "New Test pass Rule with Protocol dsr" + + - name: "Protocol: Test pass Rule with Protocol bna" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'bna' + description: "New Test pass Rule with Protocol bna" + + - name: "Protocol: Test pass Rule with Protocol i-nlsp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'i-nlsp' + description: "New Test pass Rule with Protocol i-nlsp" + + - name: "Protocol: Test pass Rule with Protocol swipe" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'swipe' + description: "New Test pass Rule with Protocol swipe" + + - name: "Protocol: Test pass Rule with Protocol narp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'narp' + description: "New Test pass Rule with Protocol narp" + + - name: "Protocol: Test pass Rule with Protocol mobile" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'mobile' + description: "New Test pass Rule with Protocol mobile" + + - name: "Protocol: Test pass Rule with Protocol tlsp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'tlsp' + description: "New Test pass Rule with Protocol tlsp" + + + - name: "Protocol: Test pass Rule with Protocol skip" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'skip' + description: "New Test pass Rule with Protocol skip" + + - name: "Protocol: Test pass Rule with Protocol ipv6-icmp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'ipv6-icmp' + description: "New Test pass Rule with Protocol ipv6-icmp" + + - name: "Protocol: Test pass Rule with Protocol cftp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'cftp' + description: "New Test pass Rule with Protocol cftp" + + - name: "Protocol: Test pass Rule with Protocol sat-expak" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'sat-expak' + description: "New Test pass Rule with Protocol sat-expak" + + - name: "Protocol: Test pass Rule with Protocol kryptolan" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'kryptolan' + description: "New Test pass Rule with Protocol kryptolan" + + - name: "Protocol: Test pass Rule with Protocol rvd" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'rvd' + description: "New Test pass Rule with Protocol rvd" + + - name: "Protocol: Test pass Rule with Protocol ippc" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'ippc' + description: "New Test pass Rule with Protocol ippc" + + - name: "Protocol: Test pass Rule with Protocol sat-mon" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'sat-mon' + description: "New Test pass Rule with Protocol sat-mon" + + - name: "Protocol: Test pass Rule with Protocol visa" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'visa' + description: "New Test pass Rule with Protocol visa" + + - name: "Protocol: Test pass Rule with Protocol ipcv" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'ipcv' + description: "New Test pass Rule with Protocol ipcv" + + - name: "Protocol: Test pass Rule with Protocol cpnx" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'cpnx' + description: "New Test pass Rule with Protocol cpnx" + + - name: "Protocol: Test pass Rule with Protocol cphb" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'cphb' + description: "New Test pass Rule with Protocol cphb" + + - name: "Protocol: Test pass Rule with Protocol wsn" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'wsn' + description: "New Test pass Rule with Protocol wsn" + + - name: "Protocol: Test pass Rule with Protocol pvp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'pvp' + description: "New Test pass Rule with Protocol pvp" + + - name: "Protocol: Test pass Rule with Protocol br-sat-mon" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'br-sat-mon' + description: "New Test pass Rule with Protocol br-sat-mon" + + - name: "Protocol: Test pass Rule with Protocol sun-nd" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'sun-nd' + description: "New Test pass Rule with Protocol sun-nd" + + - name: "Protocol: Test pass Rule with Protocol wb-mon" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'wb-mon' + description: "New Test pass Rule with Protocol wb-mon" + + - name: "Protocol: Test pass Rule with Protocol wb-expak" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'wb-expak' + description: "New Test pass Rule with Protocol wb-expak" + + - name: "Protocol: Test pass Rule with Protocol iso-ip" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'iso-ip' + description: "New Test pass Rule with Protocol iso-ip" + + - name: "Protocol: Test pass Rule with Protocol vmtp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'vmtp' + description: "New Test pass Rule with Protocol vmtp" + + - name: "Protocol: Test pass Rule with Protocol secure-vmtp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'secure-vmtp' + description: "New Test pass Rule with Protocol secure-vmtp" + + - name: "Protocol: Test pass Rule with Protocol vines" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'vines' + description: "New Test pass Rule with Protocol vines" + + - name: "Protocol: Test pass Rule with Protocol ttp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'ttp' + description: "New Test pass Rule with Protocol ttp" + + - name: "Protocol: Test pass Rule with Protocol nsfnet-igp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'nsfnet-igp' + description: "New Test pass Rule with Protocol nsfnet-igp" + + - name: "Protocol: Test pass Rule with Protocol dgp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'dgp' + description: "New Test pass Rule with Protocol dgp" + + - name: "Protocol: Test pass Rule with Protocol tcf" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'tcf' + description: "New Test pass Rule with Protocol tcf" + + - name: "Protocol: Test pass Rule with Protocol eigrp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'eigrp' + description: "New Test pass Rule with Protocol eigrp" + + - name: "Protocol: Test pass Rule with Protocol sprite-rpc" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'sprite-rpc' + description: "New Test pass Rule with Protocol sprite-rpc" + + - name: "Protocol: Test pass Rule with Protocol larp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'larp' + description: "New Test pass Rule with Protocol larp" + + - name: "Protocol: Test pass Rule with Protocol mtp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'mtp' + description: "New Test pass Rule with Protocol mtp" + + - name: "Protocol: Test pass Rule with Protocol ax.25" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'ax.25' + description: "New Test pass Rule with Protocol ax.25" + + - name: "Protocol: Test pass Rule with Protocol ipip" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'ipip' + description: "New Test pass Rule with Protocol ipip" + + - name: "Protocol: Test pass Rule with Protocol micp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'micp' + description: "New Test pass Rule with Protocol micp" + + - name: "Protocol: Test pass Rule with Protocol scc-sp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'scc-sp' + description: "New Test pass Rule with Protocol scc-sp" + + - name: "Protocol: Test pass Rule with Protocol etherip" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'etherip' + description: "New Test pass Rule with Protocol etherip" + + - name: "Protocol: Test pass Rule with Protocol encap" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'encap' + description: "New Test pass Rule with Protocol encap" + + - name: "Protocol: Test pass Rule with Protocol gmtp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'gmtp' + description: "New Test pass Rule with Protocol gmtp" + + - name: "Protocol: Test pass Rule with Protocol ifmp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'ifmp' + description: "New Test pass Rule with Protocol ifmp" + + - name: "Protocol: Test pass Rule with Protocol pnni" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'pnni' + description: "New Test pass Rule with Protocol pnni" + + - name: "Protocol: Test pass Rule with Protocol aris" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'aris' + description: "New Test pass Rule with Protocol aris" + + - name: "Protocol: Test pass Rule with Protocol scps" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'scps' + description: "New Test pass Rule with Protocol scps" + + - name: "Protocol: Test pass Rule with Protocol qnx" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'qnx' + description: "New Test pass Rule with Protocol qnx" + + - name: "Protocol: Test pass Rule with Protocol a/n" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'a/n' + description: "New Test pass Rule with Protocol a/n" + + - name: "Protocol: Test pass Rule with Protocol ipcomp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'ipcomp' + description: "New Test pass Rule with Protocol ipcomp" + + - name: "Protocol: Test pass Rule with Protocol snp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'snp' + description: "New Test pass Rule with Protocol snp" + + - name: "Protocol: Test pass Rule with Protocol compaq-peer" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'compaq-peer' + description: "New Test pass Rule with Protocol compaq-peer" + + - name: "Protocol: Test pass Rule with Protocol ipx-in-ip" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'ipx-in-ip' + description: "New Test pass Rule with Protocol ipx-in-ip" + + - name: "Protocol: Test pass Rule with Protocol carp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'carp' + description: "New Test pass Rule with Protocol carp" + + - name: "Protocol: Test pass Rule with Protocol pgm" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'pgm' + description: "New Test pass Rule with Protocol pgm" + + - name: "Protocol: Test pass Rule with Protocol l2tp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'l2tp' + description: "New Test pass Rule with Protocol l2tp" + + - name: "Protocol: Test pass Rule with Protocol ddx" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'ddx' + description: "New Test pass Rule with Protocol ddx" + + - name: "Protocol: Test pass Rule with Protocol iatp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'iatp' + description: "New Test pass Rule with Protocol iatp" + + - name: "Protocol: Test pass Rule with Protocol stp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'stp' + description: "New Test pass Rule with Protocol stp" + + - name: "Protocol: Test pass Rule with Protocol srp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'srp' + description: "New Test pass Rule with Protocol srp" + + - name: "Protocol: Test pass Rule with Protocol uti" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'uti' + description: "New Test pass Rule with Protocol uti" + + - name: "Protocol: Test pass Rule with Protocol smp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'smp' + description: "New Test pass Rule with Protocol smp" + + - name: "Protocol: Test pass Rule with Protocol sm" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'sm' + description: "New Test pass Rule with Protocol sm" + + - name: "Protocol: Test pass Rule with Protocol ptp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'ptp' + description: "New Test pass Rule with Protocol ptp" + + - name: "Protocol: Test pass Rule with Protocol isis" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'isis' + description: "New Test pass Rule with Protocol isis" + + - name: "Protocol: Test pass Rule with Protocol crtp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'crtp' + description: "New Test pass Rule with Protocol crtp" + + - name: "Protocol: Test pass Rule with Protocol crudp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'crudp' + description: "New Test pass Rule with Protocol crudp" + + - name: "Protocol: Test pass Rule with Protocol sps" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'sps' + description: "New Test pass Rule with Protocol sps" + + - name: "Protocol: Test pass Rule with Protocol pipe" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'pipe' + description: "New Test pass Rule with Protocol pipe" + + - name: "Protocol: Test pass Rule with Protocol sctp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'sctp' + description: "New Test pass Rule with Protocol sctp" + + - name: "Protocol: Test pass Rule with Protocol fc" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'fc' + description: "New Test pass Rule with Protocol fc" + + - name: "Protocol: Test pass Rule with Protocol rsvp-e2e-ignore" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'rsvp-e2e-ignore' + description: "New Test pass Rule with Protocol rsvp-e2e-ignore" + + - name: "Protocol: Test pass Rule with Protocol udplite" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'udplite' + description: "New Test pass Rule with Protocol udplite" + + - name: "Protocol: Test pass Rule with Protocol mpls-in-ip" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'mpls-in-ip' + description: "New Test pass Rule with Protocol mpls-in-ip" + + - name: "Protocol: Test pass Rule with Protocol manet" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'manet' + description: "New Test pass Rule with Protocol manet" + + - name: "Protocol: Test pass Rule with Protocol hip" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'hip' + description: "New Test pass Rule with Protocol hip" + + - name: "Protocol: Test pass Rule with Protocol shim6" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'shim6' + description: "New Test pass Rule with Protocol shim6" + + - name: "Protocol: Test pass Rule with Protocol wesp" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'wesp' + description: "New Test pass Rule with Protocol wesp" + + - name: "Protocol: Test pass Rule with Protocol rohc" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'rohc' + description: "New Test pass Rule with Protocol rohc" + + - name: "Protocol: Test pass Rule with Protocol pfsync" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'pfsync' + description: "New Test pass Rule with Protocol pfsync" + + - name: "Protocol: Test pass Rule with Protocol divert" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + protocol: 'divert' + description: "New Test pass Rule with Protocol divert" + + # Source / Invert: Test basic functionality of the source/invert button + - name: "Source / Invert: Test basic functionality of the source/invert button" + puzzle.opnsense.firewall_rules: + interface: 'lan' + description: "New Test source/invert enabled rule" + source: + invert: true + + # Source IP: Test Source IP Field + - name: "Source IP: Test Source IP Field with address" + puzzle.opnsense.firewall_rules: + interface: 'lan' + description: "New Test Source IP Field with address" + source: + address: "192.168.0.0/24" + + - name: "Source IP: Test Source IP Field with host" + puzzle.opnsense.firewall_rules: + interface: 'lan' + description: "New Test Source IP Field with host" + source: + address: "8.8.8.8" + + # Source IP and Source Port: Test Source IP and Source Port Field + - name: "Source IP and Source Port: Test Source IP Field with address and Port any" + puzzle.opnsense.firewall_rules: + interface: 'lan' + description: "New Test Source IP and Source Port Field with address and Port any" + source: + address: "192.168.0.0/24" + port: "any" + + - name: "Source IP and Source Port: Test Source IP Field with address and specific Port" + puzzle.opnsense.firewall_rules: + interface: 'lan' + description: "New Test Source IP and Source Port Field with address and specific Port" + source: + address: "192.168.0.0/24" + port: "1921" + + - name: "Source IP and Source Port: Test Source IP and Source Port Field with host and Port any" + puzzle.opnsense.firewall_rules: + interface: 'lan' + description: "New Test Source IP Field with host" + source: + address: "8.8.8.8" + port: "any" + + - name: "Source IP and Source Port: Test Source IP Field with address and specific Port" + puzzle.opnsense.firewall_rules: + interface: 'lan' + description: "New Test Source IP and Source Port Field with address and specific Port" + source: + address: "8.8.8.8" + port: "1921" + + # Target / Invert: Test basic functionality of the target/invert button + - name: "Target / Invert: Test basic functionality of the target/invert button" + puzzle.opnsense.firewall_rules: + interface: 'lan' + description: "New Test target/invert enabled rule" + destination: + invert: true + + # Target IP and Target Port: Test Target IP and Target Port Field + - name: "Target IP and Target Port:Test Target IP Field with address and Port any" + puzzle.opnsense.firewall_rules: + interface: 'lan' + description: "New Test Target IP and Target Port Field with address and Port any" + destination: + address: "192.168.0.0/24" + port: "any" + + - name: "Target IP and Target Port: Test Target IP Field with address and specific Port" + puzzle.opnsense.firewall_rules: + interface: 'lan' + description: "New Test Target IP and Target Port Field with address and specific Port" + destination: + address: "192.168.0.0/24" + port: "1921" + + - name: "Target IP and Target Port: Test Target IP and Target Port Field with host and Port any" + puzzle.opnsense.firewall_rules: + interface: 'lan' + description: "New Test Target IP Field with host" + destination: + address: "8.8.8.8" + port: "any" + + - name: "Target IP and Target Port: Test Target IP Field with address and specific Port" + puzzle.opnsense.firewall_rules: + interface: 'lan' + description: "New Test Target IP and Target Port Field with address and specific Port" + destination: + address: "8.8.8.8" + port: "1921" + + # Test basic functionality of the log button + - name: "Log: Test pass action" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + log: true + description: "New Test pass Rule with log enabled" + + # Test basic functionality of categories + - name: "Categories: Test adding one Category" + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + category: 'TestCategory' + description: "New Test pass Rule with one added Category" + + # TODO add support muliple categories + # TODO add support for Advanced features: No XMLRPC Sync, Schedule and Gateway + # TODO add support for Advanced Options + + # Idempotency test + - name: Apply rule twice and check for idempotency + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + source: + address: '192.168.0.0/16' + register: first_apply + - name: Re-apply same rule + puzzle.opnsense.firewall_rules: + interface: 'lan' + action: 'pass' + source: + address: '192.168.0.0/16' + register: second_apply + - name: Assert no change on second apply + ansible.builtin.assert: + that: + - not second_apply.changed diff --git a/molecule/firewall_rules/molecule.yml b/molecule/firewall_rules/molecule.yml new file mode 100644 index 00000000..136e4bf6 --- /dev/null +++ b/molecule/firewall_rules/molecule.yml @@ -0,0 +1,68 @@ +--- +scenario: + name: firewall_rules + test_sequence: + # - dependency not relevant unless we have requirements + - destroy + - syntax + - create + - converge + - idempotence + - cleanup + - destroy + +driver: + name: vagrant + parallel: true + +platforms: + - name: "22.7" + hostname: false + box: puzzle/opnsense + box_version: "22.7" + memory: 1024 + cpus: 2 + instance_raw_config_args: + - 'vm.guest = :freebsd' + - 'ssh.sudo_command = "%c"' + - 'ssh.shell = "/bin/sh"' + - name: "23.1" + box: puzzle/opnsense + hostname: false + box_version: "23.1" + memory: 1024 + cpus: 2 + instance_raw_config_args: + - 'vm.guest = :freebsd' + - 'ssh.sudo_command = "%c"' + - 'ssh.shell = "/bin/sh"' + - name: "23.7" + box: puzzle/opnsense + hostname: false + box_version: "23.7" + memory: 1024 + cpus: 2 + instance_raw_config_args: + - 'vm.guest = :freebsd' + - 'ssh.sudo_command = "%c"' + - 'ssh.shell = "/bin/sh"' + + - name: "24.1" + box: puzzle/opnsense + hostname: false + box_version: "24.1" + memory: 1024 + cpus: 2 + instance_raw_config_args: + - 'vm.guest = :freebsd' + - 'ssh.sudo_command = "%c"' + - 'ssh.shell = "/bin/sh"' + +provisioner: + name: ansible +# env: +# ANSIBLE_VERBOSITY: 3 +verifier: + name: ansible + options: + become: true diff --git a/plugins/module_utils/config_utils.py b/plugins/module_utils/config_utils.py index d167a8ca..ba34eb12 100644 --- a/plugins/module_utils/config_utils.py +++ b/plugins/module_utils/config_utils.py @@ -102,6 +102,7 @@ def __init__( Args: module_name (str): The name of the module. check_mode (bool): Check mode + config_context_names (List[str]): Names of required config contexts. path (str, optional): The path to the config.xml file. Defaults to "/conf/config.xml". """ self._module_name = module_name @@ -172,7 +173,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): if self.changed and not self._check_mode: raise RuntimeError("Config has changed. Cannot exit without saving.") - def save(self) -> bool: + def save(self, override_changed: bool = False) -> bool: """ Saves the config to the file if changes have been made. @@ -180,14 +181,12 @@ def save(self) -> bool: - bool: True if changes were saved, False if no changes were detected. """ - if self.changed: - tree: ElementTree.ElementTree = ElementTree.ElementTree( - self._config_xml_tree - ) - tree.write(self._config_path, encoding="utf-8", xml_declaration=True) - self._config_xml_tree = self._load_config() - return True - return False + if not self.changed and not override_changed: + return False + tree: ElementTree.ElementTree = ElementTree.ElementTree(self._config_xml_tree) + tree.write(self._config_path, encoding="utf-8", xml_declaration=True) + self._config_xml_tree = self._load_config() + return True @property def changed(self) -> bool: @@ -296,8 +295,9 @@ def _get_configure_functions(self) -> dict: if configure_functions is None: raise MissingConfigDefinitionForModuleError( f"Module '{self._module_name}' has no configure_functions defined in " - f"the ansible_collections.puzzle.opnsense.plugins.module_utils.module_index.VERSION_MAP for given " # pylint: disable=line-too-long - f"OPNsense version '{self.opnsense_version}'." + "the ansible_collections.puzzle.opnsense.plugins.module_utils." + "module_index.VERSION_MAP for given OPNsense version " + f"'{self.opnsense_version}'." ) # ensure configure_functions are defined as a list diff --git a/plugins/module_utils/enum_utils.py b/plugins/module_utils/enum_utils.py new file mode 100644 index 00000000..8204b4fd --- /dev/null +++ b/plugins/module_utils/enum_utils.py @@ -0,0 +1,39 @@ +# Copyright: (c) 2024, Puzzle ITC +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +"""Reusable Enum utilities""" +from enum import Enum +from typing import List + + +class ListEnum(Enum): + """Enum class with some handy utility functions.""" + + @classmethod + def as_list(cls) -> List[str]: + """ + Return a list + Returns + ------- + + """ + return [entry.value for entry in cls] + + @classmethod + def from_string(cls, value: str) -> "ListEnum": + """ + Returns Enum value, from a given String. + If no enum value can be mapped to the input string, + ValueError is raised. + Parameters + ---------- + value: `str` + String to be mapped to enum value + + Returns + ------- + Enum value + """ + for _key, _value in cls.__members__.items(): + if value in (_key, _value.value): + return _value + raise ValueError(f"'{cls.__name__}' enum not found for '{value}'") diff --git a/plugins/module_utils/firewall_rules_utils.py b/plugins/module_utils/firewall_rules_utils.py new file mode 100644 index 00000000..76807ad0 --- /dev/null +++ b/plugins/module_utils/firewall_rules_utils.py @@ -0,0 +1,659 @@ +# Copyright: (c) 2024, Puzzle ITC, Fabio Bertagna +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Utilities for firewall_rules module related operations. +""" +from dataclasses import dataclass, asdict, field +from typing import List, Optional +from xml.etree.ElementTree import Element + +from ansible_collections.puzzle.opnsense.plugins.module_utils import xml_utils +from ansible_collections.puzzle.opnsense.plugins.module_utils.config_utils import ( + OPNsenseModuleConfig, +) + +from ansible_collections.puzzle.opnsense.plugins.module_utils.enum_utils import ListEnum + + +# pylint: disable=too-few-public-methods +class FirewallRuleAction(ListEnum): + """Represents the rule filter policy.""" + + PASS = "pass" + BLOCK = "block" + REJECT = "reject" + + +# pylint: disable=too-few-public-methods +class FirewallRuleDirection(ListEnum): + """Represents the rule direction.""" + + IN = "in" + OUT = "out" + + +# pylint: disable=too-few-public-methods +class FirewallRuleProtocol(ListEnum): + """Represents the protocol to filter in a rule.""" + + ANY = "any" + TCP = "tcp" + UDP = "udp" + TCP_UDP = "tcp/udp" + ICMP = "icmp" + ESP = "esp" + AH = "ah" + GRE = "gre" + IGMP = "igmp" + PIM = "pim" + OSPF = "ospf" + GGP = "ggp" + IPENCAP = "ipencap" + ST2 = "st2" + CBT = "cbt" + EGP = "egp" + IGP = "igp" + BBN_RCC = "bbn-rcc" + NVP = "nvp" + PUP = "pup" + ARGUS = "argus" + EMCON = "emcon" + XNET = "xnet" + CHAOS = "chaos" + MUX = "mux" + DCN = "dcn" + HMP = "hmp" + PRM = "prm" + XNS_IDP = "xns-idp" + TRUNK_1 = "trunk-1" + TRUNK_2 = "trunk-2" + LEAF_1 = "leaf-1" + LEAF_2 = "leaf-2" + RDP = "rdp" + IRTP = "irtp" + ISO_TP4 = "iso-tp4" + NETBLT = "netblt" + MFE_NSP = "mfe-nsp" + MERIT_INP = "merit-inp" + DCCP = "dccp" + PC = "3pc" + IDPR = "idpr" + XTP = "xtp" + DDP = "ddp" + IDPR_CMTP = "idpr-cmtp" + TP_PLUS_PLUS = "tp++" + IL = "il" + IPV6 = "ipv6" + SDRP = "sdrp" + IDRP = "idrp" + RSVP = "rsvp" + DSR = "dsr" + BNA = "bna" + I_NLSP = "i-nlsp" + SWIPE = "swipe" + NARP = "narp" + MOBILE = "mobile" + TLSP = "tlsp" + SKIP = "skip" + IPV6_ICMP = "ipv6-icmp" + CFTP = "cftp" + SAT_EXPAK = "sat-expak" + KRYPTOLAN = "kryptolan" + RVD = "rvd" + IPPC = "ippc" + SAT_MON = "sat-mon" + VISA = "visa" + IPCV = "ipcv" + CPNX = "cpnx" + CPHB = "cphb" + WSN = "wsn" + PVP = "pvp" + BR_SAT_MON = "br-sat-mon" + SUN_ND = "sun-nd" + WB_MON = "wb-mon" + WB_EXPAK = "wb-expak" + ISO_IP = "iso-ip" + VMTP = "vmtp" + SECURE_VMTP = "secure-vmtp" + VINES = "vines" + TTP = "ttp" + NSFNET_IGP = "nsfnet-igp" + DGP = "dgp" + TCF = "tcf" + EIGRP = "eigrp" + SPRITE_RPC = "sprite-rpc" + LARP = "larp" + MTP = "mtp" + AX_25 = "ax.25" + IPIP = "ipip" + MICP = "micp" + SCC_SP = "scc-sp" + ETHERIP = "etherip" + ENCAP = "encap" + GMTP = "gmtp" + IFMP = "ifmp" + PNNI = "pnni" + ARIS = "aris" + SCPS = "scps" + QNX = "qnx" + A_N = "a/n" + IPCOMP = "ipcomp" + SNP = "snp" + COMPAQ_PEER = "compaq-peer" + IPX_IN_IP = "ipx-in-ip" + CARP = "carp" + PGM = "pgm" + L2TP = "l2tp" + DDX = "ddx" + IATP = "iatp" + STP = "stp" + SRP = "srp" + UTI = "uti" + SMP = "smp" + SM = "sm" + PTP = "ptp" + ISIS = "isis" + CRTP = "crtp" + CRUDP = "crudp" + SPS = "sps" + PIPE = "pipe" + SCTP = "sctp" + FC = "fc" + RSVP_E2E_IGNORE = "rsvp-e2e-ignore" + UDPLITE = "udplite" + MPLS_IN_IP = "mpls-in-ip" + MANET = "manet" + HIP = "hip" + SHIM6 = "shim6" + WESP = "wesp" + ROHC = "rohc" + PFSYNC = "pfsync" + DIVERT = "divert" + + +# pylint: disable=invalid-name,too-few-public-methods +class IPProtocol(ListEnum): + """Represents the IPProtocol.""" + + IPv4 = "inet" + IPv6 = "inet6" + IPv4_IPv6 = "inet46" + + +# pylint: disable=too-few-public-methods,fixme +class FirewallRuleStateType(ListEnum): + """Represents the FirewallRuleStateType.""" # TODO not yet in the ansible parameters + + NONE = "none" + KEEP_STATE = "keep state" + SLOPPY_STATE = "sloppy state" + MODULATE_STATE = "modulate state" + SYNPROXY_STATE = "synproxy state" + + +@dataclass +class FirewallRuleTarget: + """Used to represent a source or destination target for a firewall rule.""" + + target: str + address: str = "any" + network: str = "any" + port: str = "any" + invert: bool = False + + @classmethod + def from_ansible_params(cls, target: str, params: dict) -> "FirewallRuleTarget": + """ + Class method to build a FirewallRuleTarget instance from ansible + module parameters. + :param target: Target name as in "source" or "destination". + :param params: Ansible module parameters. + :return: FirewallRuleTarget instance. + """ + # if eg "source_ip" is "any" then we set the flag + ansible_address: Optional[str] = params["address"] + ansible_network: Optional[str] = params["network"] + + ansible_invert: bool = params["invert"] + ansible_port: str = params["port"] + + return cls( + target=target, + address=ansible_address, + network=ansible_network, + port=ansible_port, + invert=ansible_invert, + ) + + @classmethod + def from_xml(cls, target: str, element: Element) -> "FirewallRuleTarget": + """ + Class method to build a FirewallRuleTarget from a given ElementTree + XML element. + :param target: Target name as in "source" or "destination". + :param element: The ElementTree element to parse from. + :return: FirewallRuleTarget instance. + """ + target_data: dict = xml_utils.etree_to_dict(element)[target] + + _any: bool = {"any": None} in target_data.values() + + address: str = "any" if _any else target_data.get("address", "any") + network: str = "any" if _any else target_data.get("network", "any") + invert: bool = "not" in target_data and target_data["not"] in ["1", None] + return FirewallRuleTarget( + target=target, + address=address, + network=network, + port=target_data.get("port", "any"), + invert=invert, + ) + + def as_etree_dict(self) -> dict: + """ + Returns the instance in a dictionary serialized form ready + for conversion to ElementTree elements. + :return: serialized dictionary from instance. + """ + data: dict = {} + + if self.invert: + data["not"] = "1" + + for net_target in ["address", "network"]: + if getattr(self, net_target) != "any": + data[net_target] = getattr(self, net_target) + + if "address" not in data and "network" not in data: + data["any"] = None + + if self.port != "any": + data["port"] = self.port + return data + + +# pylint: disable=too-many-instance-attributes, fixme +@dataclass +class FirewallRule: + """Used to represent a firewall rule.""" + + interface: str + uuid: Optional[str] = None + type: FirewallRuleAction = FirewallRuleAction.PASS + descr: Optional[str] = None + quick: bool = ( + True # If the quick tag is not present, the tag is interpreted as true + ) + ipprotocol: IPProtocol = IPProtocol.IPv4 + direction: Optional[FirewallRuleDirection] = None + protocol: FirewallRuleProtocol = FirewallRuleProtocol.ANY + source: FirewallRuleTarget = field( + default_factory=lambda: FirewallRuleTarget("source") + ) + destination: FirewallRuleTarget = field( + default_factory=lambda: FirewallRuleTarget("destination") + ) + disabled: bool = False + log: bool = False + category: Optional[str] = None + statetype: FirewallRuleStateType = FirewallRuleStateType.KEEP_STATE + + # TODO ChangeLog + + def __post_init__(self): + # Manually define the fields and their expected types + enum_fields = { + "type": FirewallRuleAction, + "ipprotocol": IPProtocol, + "protocol": FirewallRuleProtocol, + "statetype": FirewallRuleStateType, + "direction": FirewallRuleDirection, + } + + for field_name, field_type in enum_fields.items(): + value = getattr(self, field_name) + + # Check if the value is a string and the field_type is a subclass of ListEnum + if isinstance(value, str) and issubclass(field_type, ListEnum): + # Convert string to ListEnum + setattr(self, field_name, field_type.from_string(value)) + + def to_etree(self) -> Element: + """ + Converts the current FirewallRule object to an XML Element. + + This method takes the attributes of the FirewallRule object, represented as a dictionary, + and constructs an XML Element structure. The method primarily focuses on converting + attributes related to 'source' and 'destination' into nested XML tags. + + Attributes in the format "source_any", "destination_port", etc., are transformed into + corresponding XML structures like ``. Boolean attributes are + particularly handled to create empty tags if True (e.g., ``) or are omitted if False. + Non-boolean attributes are converted into standard XML tags with values. + + The 'uuid' attribute of the object, if present, is added as an attribute to the XML element. + Other unnecessary fields are removed during the conversion process. + + Returns: + Element: An XML Element representing the FirewallRule object. + + Example: + Given a FirewallRule object with attributes like {"source_any": "1", "source_port": 22}, + the output will be an XML element structured as: + ```xml + + + + 22 + + + ``` + + Note: The method assumes the presence of a utility function `dict_to_etree` for + converting dictionaries to XML elements. + """ + rule_dict: dict = asdict(self) + del rule_dict["uuid"] + + for rule_key, rule_val in rule_dict.copy().items(): + if rule_key == "quick": + if rule_val: + del rule_dict[rule_key] + continue + rule_dict[rule_key] = "0" + elif rule_key in ["source", "destination"]: + rule_val.pop("target") + rule_dict[rule_key] = getattr(self, rule_key).as_etree_dict() + elif rule_val in [None, False]: + del rule_dict[rule_key] + elif issubclass(type(rule_val), ListEnum): + rule_dict[rule_key] = rule_val.value + elif isinstance(rule_val, bool): + rule_dict[rule_key] = "1" + + element: Element = xml_utils.dict_to_etree("rule", rule_dict)[0] + + if self.uuid: + element.attrib["uuid"] = self.uuid + + return element + + # pylint: disable=too-many-locals + @classmethod + def from_ansible_module_params(cls, params: dict) -> "FirewallRule": + """ + Creates a FirewallRule object from Ansible module parameters. + + This class method constructs a FirewallRule object using parameters typically + provided by an Ansible module. It extracts relevant information such as interface, + action, description, and various source and destination attributes from the + provided `params` dictionary. + + The method handles special cases such as the interpretation of 'any' values for + source and destination IPs and the exclusion of null values in the final dictionary + used to create the FirewallRule object. + + Parameters: + params (dict): A dictionary containing Ansible module parameters. Expected keys include + all module params documented in firewall_rules.py:DOCUMENTATION + + Returns: + FirewallRule: An instance of FirewallRule initialized with the provided parameters. + + Example: + ```python + params = { + "interface": "eth0", + "action": "block", + "source": { + ... + }, + "destination": { + ... + }, + # ... other parameters ... + } + rule = FirewallRule.from_ansible_module_params(params) + ``` + """ + + source_dict: dict = ( + params.get("source") if params.get("source") is not None else {} + ) + destination_dict: dict = ( + params.get("destination", {}) + if params.get("destination") is not None + else {} + ) + rule_dict = { + "interface": params.get("interface"), + "type": params.get("action"), + "descr": params.get("description"), + "quick": params.get("quick"), + "ipprotocol": params.get("ipprotocol"), + "direction": params.get("direction"), + "protocol": params.get("protocol"), + "source": FirewallRuleTarget("source", **source_dict), + "destination": FirewallRuleTarget("destination", **destination_dict), + "log": params.get("log"), + "category": params.get("category"), + "disabled": params.get("disabled"), + } + + rule_dict = { + key: value for key, value in rule_dict.items() if value is not None + } + + return cls(**rule_dict) + + @staticmethod + def from_xml(element: Element) -> "FirewallRule": + """ + Converts an XML element into a FirewallRule object. + + This static method transforms an XML element, expected to have 'rule' as its root, into a + FirewallRule object. It handles elements such as 'source', 'destination', and their + sub-elements like 'address', 'network', 'port', 'any', and 'not'. + + The method processes each direction ('source' or 'destination') and relevant keys. + 'any' and 'not' are converted to booleans, while other values are assigned as is. + Elements not present are skipped. The 'uuid' attribute is also extracted from the XML. + + Changelog elements ('updated', 'created') are currently ignored. + + Parameters: + element (Element): XML element with 'rule' as root. + + Returns: + FirewallRule: Instance populated with data from the XML element. + + Example XML structure: + ```xml + + + 1 + 22 +
192.168.1.1/24
+ 1 + + +
+ """ + + rule_dict: dict = xml_utils.etree_to_dict(element)["rule"] + + rule_dict.update( + disabled=rule_dict.get("disabled", "0") == "1", + quick="quick" not in rule_dict, + log=rule_dict.get("log", "0") == "1", + uuid=element.attrib.get("uuid"), + ) + + # TODO ignore changelog for now + rule_dict.pop("updated", None) + rule_dict.pop("created", None) + + rule_dict.pop("source") + rule_dict.pop("destination") + source: FirewallRuleTarget = FirewallRuleTarget.from_xml( + "source", element.find("./source") + ) + destination: FirewallRuleTarget = FirewallRuleTarget.from_xml( + "destination", element.find("./destination") + ) + + return FirewallRule(source=source, destination=destination, **rule_dict) + + +class FirewallRuleSet(OPNsenseModuleConfig): + """ + Manages a set of firewall rules in an OPNsense configuration. + + This class provides functionality to load, add, update, delete, and find firewall + rules. It also checks for changes and saves the updated ruleset to the + configuration file. The rules are represented as a list of `FirewallRule` objects. + + Attributes: + _rules (List[FirewallRule]): List of firewall rules loaded from the configuration. + + Methods: + __init__(self, path): Initializes the class with a given configuration file path. + _load_rules(self): Loads firewall rules from the configuration file. + changed(self): Returns True if the current rules differ from the loaded ones. + add_or_update(self, rule): Adds a new rule or updates an existing one. + delete(self, rule): Removes a specified rule from the ruleset. + find(self, **kwargs): Finds a rule matching given criteria. + save(self): Saves changes to the configuration file if there are any modifications. + """ + + _rules: List[FirewallRule] + + def __init__(self, path: str = "/conf/config.xml"): + super().__init__( + module_name="firewall_rules", + config_context_names=["firewall_rules"], + path=path, + ) + self._rules = self._load_rules() + + def _load_rules(self) -> List[FirewallRule]: + # /opnsense/filter Element containing a list of + element_tree_rules: Element = self.get("rules") + + return [FirewallRule.from_xml(element) for element in element_tree_rules] + + @property + def changed(self) -> bool: + """ + Checks if the current set of firewall rules has changed compared to the + loaded configuration. + + This property compares the current set of `FirewallRule` objects in `_rules` + with the set loaded from the configuration file. It returns True if there are + differences, indicating that changes have been made to the ruleset which are + not yet saved to the configuration file. + + Returns: + bool: True if the ruleset has changed, False otherwise. + """ + return self._load_rules() != self._rules + + def add_or_update(self, rule: FirewallRule) -> None: + """ + Adds a new firewall rule to the ruleset or updates an existing one. + + This method checks if the provided `rule` already exists in the ruleset. If it + does, the existing rule is updated with the properties of the provided `rule`. + If it does not exist, the new rule is appended to the ruleset. The comparison + to check if a rule exists is based on the equality condition defined in the + `FirewallRule` class. + + Parameters: + rule (FirewallRule): The firewall rule to be added or updated in the ruleset. + + Returns: + None: This method does not return anything. + """ + + existing_rule: Optional[FirewallRule] = next( + (r for r in self._rules if r == rule), None + ) + if existing_rule: + existing_rule.__dict__.update(rule.__dict__) + else: + self._rules.append(rule) + + def delete(self, rule: FirewallRule) -> bool: + """ + Removes a specified firewall rule from the ruleset. + + This method iterates through the current set of firewall rules and removes the rule + that matches the provided `rule` parameter. The comparison for removal is based on + the inequality of the `FirewallRule` objects. If the rule is not found, no action is taken. + + Parameters: + rule (FirewallRule): The firewall rule to be removed from the ruleset. + + Returns: + bool: True if rule was deleted, False if rule was already not present + """ + + if rule in self._rules: + self._rules.remove(rule) + return True + return False + + def find(self, **kwargs) -> Optional[FirewallRule]: + """ + Searches for a firewall rule that matches the given criteria. + + This method iterates through the ruleset and returns the first `FirewallRule` object + that matches all the provided keyword arguments. The comparison is made by checking + if each attribute of the rule (specified as a keyword argument) equals the corresponding + value in `kwargs`. If no matching rule is found, the method returns None. + + Keyword Arguments: + kwargs: Arbitrary keyword arguments used for searching. Each keyword argument + should correspond to an attribute of the `FirewallRule` class. + + Returns: + Optional[FirewallRule]: The first matching rule object, or None if no match is found. + """ + + for rule in self._rules: + match = all( + getattr(rule, key, None) == value for key, value in kwargs.items() + ) + if match: + return rule + return None + + def save(self) -> bool: + """ + Saves the current set of firewall rules to the configuration file. + + This method first checks if there have been any changes to the ruleset using the `changed` + property. If there are no changes, it returns False. Otherwise, it updates the configuration + XML tree with the current set of rules and writes the updated configuration to the file. + It then reloads the configuration from the file to ensure synchronization. + + The saving process involves removing the existing rules from the configuration XML tree, + clearing the filter element, and then extending it with the updated set of rules + converted to XML elements. + + Returns: + bool: True if changes were saved, False if there were no changes to save. + """ + + if not self.changed: + return False + + filter_element: Element = self._config_xml_tree.find( + self._config_maps[self._module_name]["rules"] + ) + + self._config_xml_tree.remove(filter_element) + filter_element.clear() + filter_element.extend([rule.to_etree() for rule in self._rules]) + self._config_xml_tree.append(filter_element) + return super().save(override_changed=True) diff --git a/plugins/module_utils/module_index.py b/plugins/module_utils/module_index.py index b4a8a7c9..52a99106 100644 --- a/plugins/module_utils/module_index.py +++ b/plugins/module_utils/module_index.py @@ -27,7 +27,7 @@ This map is essential for dynamically configuring modules based on the OPNsense version and provides a centralized definition for various configurations across different OPNsense versions. """ - +# pylint: disable=duplicate-code; Since this is rewritten in some tests. VERSION_MAP = { "22.7": { "system_settings_general": { @@ -115,6 +115,26 @@ ], }, }, + "firewall_rules": { + "rules": "filter", + "php_requirements": [ + "/usr/local/etc/inc/config.inc", + "/usr/local/etc/inc/util.inc", + "/usr/local/etc/inc/filter.inc", + "/usr/local/etc/inc/system.inc", + "/usr/local/etc/inc/interfaces.inc", + ], + "configure_functions": { + "system_cron_configure": { + "name": "system_cron_configure", + "configure_params": ["true"], + }, + "filter_configure": { + "name": "filter_configure", + "configure_params": [], + }, + }, + }, }, "23.1": { "system_settings_general": { @@ -202,6 +222,26 @@ ], }, }, + "firewall_rules": { + "rules": "filter", + "php_requirements": [ + "/usr/local/etc/inc/config.inc", + "/usr/local/etc/inc/util.inc", # required for the service_log utility + "/usr/local/etc/inc/interfaces.inc", + "/usr/local/etc/inc/filter.inc", + "/usr/local/etc/inc/system.inc", + ], + "configure_functions": { + "system_cron_configure": { + "name": "system_cron_configure", + "configure_params": ["true"], + }, + "filter_configure": { + "name": "filter_configure", + "configure_params": [], + }, + }, + }, }, "23.7": { "system_settings_general": { @@ -263,6 +303,26 @@ "system_settings_logging": { "name": "system_syslog_start", "configure_params": ["true"], + } + }, + }, + "firewall_rules": { + "rules": "filter", + "php_requirements": [ + "/usr/local/etc/inc/interfaces.inc", + "/usr/local/etc/inc/config.inc", + "/usr/local/etc/inc/util.inc", + "/usr/local/etc/inc/filter.inc", + "/usr/local/etc/inc/system.inc", + ], + "configure_functions": { + "system_cron_configure": { + "name": "system_cron_configure", + "configure_params": ["true"], + }, + "filter_configure": { + "name": "filter_configure", + "configure_params": [], }, }, }, @@ -351,6 +411,26 @@ "system_settings_logging": { "name": "system_syslog_start", "configure_params": ["true"], + } + }, + }, + "firewall_rules": { + "rules": "filter", + "php_requirements": [ + "/usr/local/etc/inc/interfaces.inc", + "/usr/local/etc/inc/config.inc", + "/usr/local/etc/inc/util.inc", + "/usr/local/etc/inc/system.inc", + "/usr/local/etc/inc/filter.inc", + ], + "configure_functions": { + "system_cron_configure": { + "name": "system_cron_configure", + "configure_params": ["true"], + }, + "filter_configure": { + "name": "filter_configure", + "configure_params": [], }, }, }, diff --git a/plugins/module_utils/system_access_users_utils.py b/plugins/module_utils/system_access_users_utils.py index b22bd2f5..6a8454ab 100644 --- a/plugins/module_utils/system_access_users_utils.py +++ b/plugins/module_utils/system_access_users_utils.py @@ -27,7 +27,6 @@ from dataclasses import dataclass, asdict, fields -from enum import Enum from typing import List, Optional import base64 import os @@ -42,6 +41,7 @@ from ansible_collections.puzzle.opnsense.plugins.module_utils.config_utils import ( OPNsenseModuleConfig, ) +from ansible_collections.puzzle.opnsense.plugins.module_utils.enum_utils import ListEnum class OPNSenseGroupNotFoundError(Exception): @@ -62,40 +62,7 @@ class OPNSenseCryptReturnError(Exception): """ -class ListEnum(Enum): - """Enum class with some handy utility functions.""" - - @classmethod - def as_list(cls) -> List[str]: - """ - Return a list - Returns - ------- - - """ - return [entry.value for entry in cls] - - @classmethod - def from_string(cls, value: str) -> "ListEnum": - """ - Returns Enum value, from a given String. - If no enum value can be mapped to the input string, - ValueError is raised. - Parameters - ---------- - value: `str` - String to be mapped to enum value - - Returns - ------- - Enum value - """ - for _key, _value in cls.__members__.items(): - if value in (_key, _value.value): - return _value - raise ValueError(f"'{cls.__name__}' enum not found for '{value}'") - - +# pylint: disable=too-few-public-methods class UserLoginShell(ListEnum): """Represents the user login shell.""" diff --git a/plugins/modules/firewall_rules.py b/plugins/modules/firewall_rules.py new file mode 100644 index 00000000..ad5794b8 --- /dev/null +++ b/plugins/modules/firewall_rules.py @@ -0,0 +1,448 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2024, Puzzle ITC, Fabio Bertagna , +# Kilian Soltermann +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + + +"""Firewall rules module: Read, write, edit operations for firewall rules """ + +__metaclass__ = type + + +# https://docs.ansible.com/ansible/latest/dev_guide/developing_modules_documenting.html +# fmt: off + +DOCUMENTATION = r''' +--- +module: firewall_rules + +short_description: This module is used to manage OPNSense firewall rules + +version_added: "1.0.0" + +description: This module is used to manage OPNSense firewall rules. + +options: + action: + description: Choose what to do with packets that match the criteria specified below. + choices: + - pass + - block + - reject + default: pass + type: str + disabled: + description: Set this option to disable this rule without removing it from the list. + required: false + default: false + type: bool + ipprotocol: + description: IP version + required: false + default: inet + choices: + - inet + - inet6 + - inet46 + type: str + quick: + description: | + If a packet matches a rule specifying quick, then that rule is considered the last matching rule and the specified action is taken. + When a rule does not have quick enabled, the last matching rule wins. + required: false + default: true + type: bool + interface: + description: Choose on which interface packets must come in to match this rule. + required: true + type: str + direction: + description: | + "Direction of the traffic. Traffic IN is coming into the firewall interface, while traffic OUT is going out of the firewall interface. + In visual terms: [Source] -> IN -> [Firewall] -> OUT -> [Destination]. The default policy is to filter inbound traffic, + which means the policy applies to the interface on which the traffic is originally received by the firewall from the source. + This is more efficient from a traffic processing perspective. In most cases, the default policy will be the most appropriate." + choices: + - in + - out + default: in + type: str + protocol: + description: Choose which IP protocol this rule should match. + choices: + - any + - tcp + - udp + - tcp/udp + - icmp + - esp + - ah + - gre + - igmp + - pim + - ospf + - ggp + - ipencap + - st2 + - cbt + - egp + - igp + - bbn-rcc + - nvp + - pup + - argus + - emcon + - xnet + - chaos + - mux + - dcn + - hmp + - prm + - xns-idp + - trunk-1 + - trunk-2 + - leaf-1 + - leaf-2 + - rdp + - irtp + - iso-tp4 + - netblt + - mfe-nsp + - merit-inp + - dccp + - 3pc + - idpr + - xtp + - ddp + - idpr-cmtp + - tp++ + - il + - ipv6 + - sdrp + - idrp + - rsvp + - dsr + - bna + - i-nlsp + - swipe + - narp + - mobile + - tlsp + - skip + - ipv6-icmp + - cftp + - sat-expak + - kryptolan + - rvd + - ippc + - sat-mon + - visa + - ipcv + - cpnx + - cphb + - wsn + - pvp + - br-sat-mon + - sun-nd + - wb-mon + - wb-expak + - iso-ip + - vmtp + - secure-vmtp + - vines + - ttp + - nsfnet-igp + - dgp + - tcf + - eigrp + - sprite-rpc + - larp + - mtp + - ax.25 + - ipip + - micp + - scc-sp + - etherip + - encap + - gmtp + - ifmp + - pnni + - aris + - scps + - qnx + - a/n + - ipcomp + - snp + - compaq-peer + - ipx-in-ip + - carp + - pgm + - l2tp + - ddx + - iatp + - stp + - srp + - uti + - smp + - sm + - ptp + - isis + - crtp + - crudp + - sps + - pipe + - sctp + - fc + - rsvp-e2e-ignore + - udplite + - mpls-in-ip + - manet + - hip + - shim6 + - wesp + - rohc + - pfsync + - divert + required: false + default: any + type: str + source: + description: + - Specifies the source configuration. + type: dict + suboptions: + address: + description: + - The IP address of the source. + default: any + type: str + network: + description: + - The network of the source. + default: any + type: str + port: + description: + - The port of the source. + default: any + type: str + invert: + description: + - Inverts the match logic. + default: false + type: bool + destination: + description: + - Specifies the source configuration. + type: dict + suboptions: + address: + description: + - The IP address of the source. + type: str + default: any + network: + description: + - The network of the source. + type: str + default: any + port: + description: + - The port of the source. + type: str + default: any + invert: + description: + - Inverts the match logic. + default: false + type: bool + log: + description: | + "Log packets that are handled by this rule. Hint: the firewall has limited local log space. Don't turn on logging for everything. + If you want to do a lot of logging, consider using a remote syslog server." + required: false + default: false + type: bool + category: + description: You may enter or select a category here to group firewall rules + required: false + type: str + description: + description: Description for the rule. + required: false + type: str + state: + description: Weather rule should be added or removed. + required: false + type: str + default: present + choices: [present, absent] +author: + - Fabio Bertagna (@dongiovanni83) + - Kilian Soltermann (@killuuuhh) +''' + +EXAMPLES = r''' +- name: Block SSH in LAN Network + puzzle.opnsense.firewall_rules: + interface: lan + source: + destination: + port: 22 + action: block + +- name: Allow all access from RFC1918 networks to this host + puzzle.opnsense.firewall_rules: + interface: lan + action: pass + source: + ip: 192.168.0.0/16 + destination: +''' + +RETURN = ''' +opnsense_configure_output: + description: A List of the executed OPNsense configure function along with their respective stdout, stderr and rc + returned: always + type: list + sample: + - function: "system_cron_configure" + params: ["true"] + rc: 0 + stderr: "" + stderr_lines: [] + stdout: "Configuring CRON...done." + stdout_lines: ["Configuring CRON...done."] + - function: "filter_configure" + params: [] + rc: 0 + stderr: "" + stderr_lines: [] + stdout: "" + stdout_lines: [] +''' +# fmt: on +from typing import Optional + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.puzzle.opnsense.plugins.module_utils.firewall_rules_utils import ( + FirewallRuleSet, + FirewallRule, + FirewallRuleProtocol, +) + +ANSIBLE_MANAGED: str = "[ ANSIBLE ]" + + +def main(): + """Main module execution entry point.""" + + module_args = { + "interface": {"type": "str", "required": True}, + "action": { + "type": "str", + "choices": ["pass", "block", "reject"], + "default": "pass", + }, + "description": {"type": "str", "required": False}, + "category": {"type": "str", "required": False}, + "direction": { + "type": "str", + "default": "in", + "choices": ["in", "out"], + }, + "disabled": {"type": "bool", "default": False}, + "quick": {"type": "bool", "default": True}, + "ipprotocol": { + "type": "str", + "default": "inet", + "choices": ["inet", "inet6", "inet46"], + }, + "protocol": { + "type": "str", + "default": "any", + "choices": FirewallRuleProtocol.as_list(), + }, + "source": { + "type": "dict", + "options": { + "address": {"type": "str", "default": "any"}, + "network": {"type": "str", "default": "any"}, + "port": {"type": "str", "default": "any"}, + "invert": {"type": "bool", "default": False}, + }, + }, + "destination": { + "type": "dict", + "options": { + "address": {"type": "str", "default": "any"}, + "network": {"type": "str", "default": "any"}, + "port": {"type": "str", "default": "any"}, + "invert": {"type": "bool", "default": False}, + }, + }, + "log": {"type": "bool", "required": False, "default": False}, + "state": { + "type": "str", + "required": False, + "default": "present", + "choices": ["present", "absent"], + }, + } + + module: AnsibleModule = AnsibleModule( + argument_spec=module_args, + supports_check_mode=True, + ) + + # https://docs.ansible.com/ansible/latest/reference_appendices/common_return_values.html + # https://docs.ansible.com/ansible/latest/dev_guide/developing_modules_documenting.html#return-block + result = { + "changed": False, + "invocation": module.params, + "diff": None, + } + + # make description ansible-managed + description: Optional[str] = module.params["description"] + + if description and ANSIBLE_MANAGED not in description: + description = f"{ANSIBLE_MANAGED} - {description}" + else: + description = ANSIBLE_MANAGED + + module.params["description"] = description + + ansible_rule: FirewallRule = FirewallRule.from_ansible_module_params(module.params) + + ansible_rule_state: str = module.params.get("state") + + with FirewallRuleSet() as rule_set: + if ansible_rule_state == "present": + rule_set.add_or_update(ansible_rule) + else: + # ansible_rule_state == "absent" since it is the only + # alternative allowed in the module params + rule_set.delete(ansible_rule) + + if rule_set.changed: + result["diff"] = rule_set.diff + result["changed"] = True + rule_set.save() + result["opnsense_configure_output"] = rule_set.apply_settings() + for cmd_result in result["opnsense_configure_output"]: + if cmd_result["rc"] != 0: + module.fail_json( + msg="Apply of the OPNsense settings failed", + details=cmd_result, + ) + + # Return results + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..95f7f48d --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +# Copyright: (c) 2024, Puzzle ITC +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) diff --git a/tests/unit/plugins/module_utils/test_config_utils.py b/tests/unit/plugins/module_utils/test_config_utils.py index 997b349d..917c1aa1 100644 --- a/tests/unit/plugins/module_utils/test_config_utils.py +++ b/tests/unit/plugins/module_utils/test_config_utils.py @@ -101,6 +101,10 @@ + + 1 + 2 + """ diff --git a/tests/unit/plugins/module_utils/test_firewall_rule_target.py b/tests/unit/plugins/module_utils/test_firewall_rule_target.py new file mode 100644 index 00000000..c65c195f --- /dev/null +++ b/tests/unit/plugins/module_utils/test_firewall_rule_target.py @@ -0,0 +1,218 @@ +# Copyright: (c) 2024, Puzzle ITC +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Test suite for the FirewallRuleTarget class. +""" +from xml.etree import ElementTree +from xml.etree.ElementTree import Element + +from ansible_collections.puzzle.opnsense.plugins.module_utils.firewall_rules_utils import ( + FirewallRuleTarget, +) + + +def test_from_ansible_module_params_correct_default_return(): + """ + Test the build class method FirewallRuleTarget.from_ansible_params + when default module params are given. + :return: + """ + # These are the default ansible module params as specified + # in the module DOCUMENTATION + test_params: dict = { + "source": {"address": "any", "port": "any", "network": "any", "invert": False}, + } + + source_target: FirewallRuleTarget = FirewallRuleTarget.from_ansible_params( + "source", test_params["source"] + ) + assert isinstance(source_target, FirewallRuleTarget) + assert source_target.port == "any" + assert source_target.network == "any" + assert source_target.address == "any" + assert not source_target.invert + + +def test_from_ansible_module_params_set_ip(): + """ + Given an IP in the source param 'source_ip' it is expected + to be assigned to the FirewallRuleTarget.address instance attribute. + :return: + """ + test_params: dict = { + "source": { + "address": "192.168.0.1/24", + "network": "any", + "port": "any", + "invert": False, + }, + } + + source_target: FirewallRuleTarget = FirewallRuleTarget.from_ansible_params( + "source", test_params["source"] + ) + assert isinstance(source_target, FirewallRuleTarget) + assert source_target.address == "192.168.0.1/24" + assert source_target.network == "any" + assert source_target.port == "any" + assert not source_target.invert + + +def test_from_ansible_module_params_set_port(): + """ + Given a port input ("source_port" == "22"), the FirewallRuleTarget.port + attribute must be '22' as well. + :return: + """ + test_params: dict = { + "source": { + "address": "any", + "network": "any", + "port": "8000-9000", + "invert": False, + }, + } + + source_target: FirewallRuleTarget = FirewallRuleTarget.from_ansible_params( + "source", test_params["source"] + ) + assert isinstance(source_target, FirewallRuleTarget) + assert source_target.port == "8000-9000" + assert source_target.network == "any" + assert source_target.address == "any" + assert not source_target.invert + + +def test_from_ansible_module_params_set_invert(): + """ + Given an inverted input ("source_invert" == True), the FirewallRuleTarget.invert + attribute must be 'True' as well. + :return: + """ + test_params: dict = { + "source": {"address": "any", "network": "any", "port": "any", "invert": True}, + } + + source_target: FirewallRuleTarget = FirewallRuleTarget.from_ansible_params( + "source", test_params["source"] + ) + assert isinstance(source_target, FirewallRuleTarget) + assert source_target.address == "any" + assert source_target.port == "any" + assert source_target.network == "any" + assert source_target.invert + + +def test_from_xml_basic_source(): + """ + Given a basic source target from the XML test all defaults from the dataclass. + :return: + """ + basic_source_xml: str = """ + + + + """ + test_etree_source: Element = ElementTree.fromstring(basic_source_xml) + + source_target: FirewallRuleTarget = FirewallRuleTarget.from_xml( + "source", test_etree_source + ) + + assert isinstance(source_target, FirewallRuleTarget) + assert source_target.network == "any" + assert source_target.address == "any" + assert source_target.port == "any" + assert not source_target.invert + + +def test_from_xml_test_not(): + """ + Ensure an inverted target is correctly set when loaded from XML. + :return: + """ + basic_source_xml: str = """ + + + + """ + test_etree_source: Element = ElementTree.fromstring(basic_source_xml) + + source_target: FirewallRuleTarget = FirewallRuleTarget.from_xml( + "source", test_etree_source + ) + + assert isinstance(source_target, FirewallRuleTarget) + assert source_target.network == "any" + assert source_target.address == "any" + assert source_target.port == "any" + assert source_target.invert + + +def test_from_xml_test_address(): + """ + Ensure the address is correctly set when loaded from XML. + :return: + """ + basic_source_xml: str = """ + +
10.0.0.1/24
+ + """ + test_etree_source: Element = ElementTree.fromstring(basic_source_xml) + + source_target: FirewallRuleTarget = FirewallRuleTarget.from_xml( + "source", test_etree_source + ) + + assert isinstance(source_target, FirewallRuleTarget) + assert source_target.network == "any" + assert source_target.address == "10.0.0.1/24" + assert source_target.port == "any" + assert not source_target.invert + + +def test_from_xml_test_port(): + """ + Ensure the port is correctly set when loaded from XML. + :return: + """ + basic_source_xml: str = """ + + 22 + + """ + test_etree_source: Element = ElementTree.fromstring(basic_source_xml) + + source_target: FirewallRuleTarget = FirewallRuleTarget.from_xml( + "source", test_etree_source + ) + + assert isinstance(source_target, FirewallRuleTarget) + assert source_target.network == "any" + assert source_target.address == "any" + assert source_target.port == "22" + assert not source_target.invert + + +def test_from_xml_test_invert_1(): + """ + Ensure the target inversion is correctly set when loaded from XML and is set as bool. + :return: + """ + basic_source_xml: str = """ + + 1 + + """ + test_etree_source: Element = ElementTree.fromstring(basic_source_xml) + + source_target: FirewallRuleTarget = FirewallRuleTarget.from_xml( + "source", test_etree_source + ) + + assert isinstance(source_target, FirewallRuleTarget) + assert source_target.network == "any" + assert source_target.address == "any" + assert source_target.port == "any" + assert source_target.invert diff --git a/tests/unit/plugins/module_utils/test_firewall_rules_utils.py b/tests/unit/plugins/module_utils/test_firewall_rules_utils.py new file mode 100644 index 00000000..9ca23e53 --- /dev/null +++ b/tests/unit/plugins/module_utils/test_firewall_rules_utils.py @@ -0,0 +1,632 @@ +# Copyright: (c) 2023, Puzzle ITC +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Test suite for firewall_rules_utils utility +""" +import os +import re +import sys +from tempfile import NamedTemporaryFile +from typing import Optional +from unittest.mock import patch, MagicMock +from xml.etree import ElementTree +from xml.etree.ElementTree import Element + +import pytest + +from ansible_collections.puzzle.opnsense.plugins.module_utils import xml_utils +from ansible_collections.puzzle.opnsense.plugins.module_utils.firewall_rules_utils import ( + FirewallRuleAction, + FirewallRuleSet, + FirewallRule, + IPProtocol, + FirewallRuleProtocol, + FirewallRuleStateType, + FirewallRuleTarget, +) +from ansible_collections.puzzle.opnsense.plugins.module_utils.module_index import ( + VERSION_MAP, +) +from ansible_collections.puzzle.opnsense.plugins.module_utils.xml_utils import ( + elements_equal, +) + +# pylint: disable=redefined-outer-name,unused-argument,protected-access + +# Test version map for OPNsense versions and modules +TEST_VERSION_MAP = { + "OPNsense Test": { + "firewall_rules": { + "rules": "filter", + "php_requirements": [ + "/usr/local/etc/inc/interfaces.inc", + "/usr/local/etc/inc/filter.inc", + "/usr/local/etc/inc/system.inc", + ], + "configure_functions": { + "system_cron_configure": { + "name": "system_cron_configure", + "configure_params": [], + }, + "filter_configure": { + "name": "filter_configure", + "configure_params": [], + }, + }, + }, + } +} + +TEST_XML: str = """ + + + + pass + wan + inet + keep state + Allow SSH access + tcp + + + + + + 22 + + + + pass + wan + inet + keep state + Allow incoming WebGUI access + tcp + + + + + + 443 + + + + pass + opt2 + inet + keep state + allow vagrant management + in + + 1 + + + 1 + + + + pass + lan + inet6 + keep state + "reject and disabled Rule" + in + 1 + + 1 + + + 1 + + + + + """ + + +@pytest.fixture(scope="function") +def sample_config_path(request): + """ + Fixture that creates a temporary file with a test XML configuration. + The file is used in the tests. + + Returns: + - str: The path to the temporary file. + """ + with patch( + "ansible_collections.puzzle.opnsense.plugins.module_utils.version_utils.get_opnsense_version", # pylint: disable=line-too-long + return_value="OPNsense Test", + ), patch.dict(VERSION_MAP, TEST_VERSION_MAP, clear=True): + # Create a temporary file with a name based on the test function + with NamedTemporaryFile(delete=False) as temp_file: + temp_file.write(TEST_XML.encode()) + temp_file.flush() + yield temp_file.name + + # Cleanup after the fixture is used + os.unlink(temp_file.name) + + +def test_firewall_rule_from_xml(): + """ + Test xml parsing to FirewallRule dataclass instance. + :return: + """ + test_etree_opnsense: Element = ElementTree.fromstring(TEST_XML) + + test_etree_rule: Element = list(list(test_etree_opnsense)[0])[0] + test_rule: FirewallRule = FirewallRule.from_xml(test_etree_rule) + + assert test_rule.uuid == "9c7ecb2c-49f3-4750-bc67-d5b666541999" + assert test_rule.type == FirewallRuleAction.PASS + assert test_rule.interface == "wan" + assert test_rule.ipprotocol == IPProtocol.IPv4 + assert test_rule.statetype == FirewallRuleStateType.KEEP_STATE + assert test_rule.descr == "Allow SSH access" + assert test_rule.protocol == FirewallRuleProtocol.TCP + assert test_rule.source.port == "any" + assert test_rule.source.address == "any" + assert test_rule.source.network == "any" + assert not test_rule.source.invert + assert test_rule.destination.port == "22" + assert test_rule.destination.address == "any" + assert test_rule.destination.network == "any" + assert not test_rule.destination.invert + assert test_rule.direction is None + assert not test_rule.disabled + assert not test_rule.log + assert test_rule.category is None + assert test_rule.quick + + +def test_firewall_rule_to_etree(): + """ + Test FirewallRule instance to ElementTree Element conversion. + :return: + """ + test_rule: FirewallRule = FirewallRule( + interface="wan", + uuid="9c7ecb2c-49f3-4750-bc67-d5b666541999", + type=FirewallRuleAction.PASS, + descr="Allow SSH access", + ipprotocol=IPProtocol.IPv4, + protocol=FirewallRuleProtocol.TCP, + source=FirewallRuleTarget("source"), + destination=FirewallRuleTarget("destination", port="22"), + statetype=FirewallRuleStateType.KEEP_STATE, + ) + + test_element = test_rule.to_etree() + + orig_etree: Element = ElementTree.fromstring(TEST_XML) + orig_rule: Element = list(list(orig_etree)[0])[0] + + assert elements_equal(test_element, orig_rule), ( + f"{xml_utils.etree_to_dict(test_element)}\n" + f"{xml_utils.etree_to_dict(orig_rule)}" + ) + + +def test_firewall_rule_from_ansible_module_params_simple(): + """ + Test FirewallRule instantiation form simple Ansible parameters. + :return: + """ + test_params: dict = { + "action": "pass", + "interface": "wan", + "ipprotocol": "inet", + "description": "Allow SSH access", + "protocol": "tcp", + "source": {"address": "any", "network": "any", "port": "any", "invert": False}, + "destination": { + "address": "any", + "network": "any", + "port": "22", + "invert": False, + }, + "disabled": False, + } + + new_rule: FirewallRule = FirewallRule.from_ansible_module_params(test_params) + + assert new_rule.type == FirewallRuleAction.PASS + assert new_rule.interface == "wan" + assert new_rule.ipprotocol == IPProtocol.IPv4 + assert new_rule.descr == "Allow SSH access" + assert new_rule.protocol == FirewallRuleProtocol.TCP + assert new_rule.source.port == "any" + assert new_rule.source.address == "any" + assert new_rule.source.network == "any" + assert not new_rule.source.invert + assert new_rule.destination.port == "22" + assert new_rule.destination.address == "any" + assert new_rule.destination.network == "any" + assert not new_rule.destination.invert + + +@patch( + "ansible_collections.puzzle.opnsense.plugins.module_utils.version_utils.get_opnsense_version", + return_value="OPNsense Test", +) +@patch.dict(in_dict=VERSION_MAP, values=TEST_VERSION_MAP, clear=True) +def test_rule_set_load_simple_rules( + mocked_version_utils: MagicMock, sample_config_path +): + """ + Test correct loading of FirewallRuleSet from XML config without changes. + """ + with FirewallRuleSet(sample_config_path) as rule_set: + assert len(rule_set._rules) == 4 + rule_set.save() + + +@patch( + "ansible_collections.puzzle.opnsense.plugins.module_utils.version_utils.get_opnsense_version", + return_value="OPNsense Test", +) +@patch.dict(in_dict=VERSION_MAP, values=TEST_VERSION_MAP, clear=True) +def test_rule_set_write_rules_back(mocked_version_utils: MagicMock, sample_config_path): + """ + Test that FirewallRuleSet loaded from XML results in same values as in the XML + string directly. + """ + test_etree: Element = ElementTree.fromstring(TEST_XML) + e2 = list(list(test_etree)[0])[0] + with FirewallRuleSet(sample_config_path) as rule_set: + e1 = rule_set._rules[0].to_etree() + + es_args = {"encoding": "utf8"} + if sys.version_info > (3, 8, 0): + es_args["xml_declaration"] = True + + e1s = ElementTree.tostring(element=e1, **es_args).decode().replace("\n", "") + e2s = re.sub( + r">(\s*)<", + "><", + ElementTree.tostring(element=e1, **es_args).decode().replace("\n", ""), + ) + + assert elements_equal(e1, e2), ( + f"Firewall rules not same:\n" f"{e1s}\n" f"{e2s}\n" + ) + rule_set.save() + + +@patch( + "ansible_collections.puzzle.opnsense.plugins.module_utils.version_utils.get_opnsense_version", + return_value="OPNsense Test", +) +@patch.dict(in_dict=VERSION_MAP, values=TEST_VERSION_MAP, clear=True) +def test_rule_set_change_rule_description( + mocked_version_utils: MagicMock, sample_config_path +): + """ + Test simple FirewallRuleSet rule filter and modification. + """ + with FirewallRuleSet(sample_config_path) as rule_set: + ssh_rule: FirewallRule = rule_set.find(descr="Allow SSH access") + ssh_rule.descr = "TEST TEST" + + assert rule_set.changed + + rule_set.save() + + with FirewallRuleSet(sample_config_path) as new_rule_set: + ssh_rule: FirewallRule = rule_set.find(descr="TEST TEST") + + assert ssh_rule.descr == "TEST TEST" + + new_rule_set.save() + + +@patch( + "ansible_collections.puzzle.opnsense.plugins.module_utils.version_utils.get_opnsense_version", + return_value="OPNsense Test", +) +@patch.dict(in_dict=VERSION_MAP, values=TEST_VERSION_MAP, clear=True) +def test_rule_set_create_new_simple_rule( + mocked_version_utils: MagicMock, sample_config_path +): + """ + Test FirewallRuleSet rule creation. + """ + new_test_rule = FirewallRule(interface="wan", descr="New Test Rule") + + with FirewallRuleSet(sample_config_path) as rule_set: + rule_set.add_or_update(new_test_rule) + + assert rule_set.changed + + rule_set.save() + + with FirewallRuleSet(sample_config_path) as new_rule_set: + new_rule: Optional[FirewallRule] = new_rule_set.find( + interface="wan", descr="New Test Rule" + ) + + assert new_rule is not None + assert new_rule.interface == "wan" + assert new_rule.descr == "New Test Rule" + + +@patch( + "ansible_collections.puzzle.opnsense.plugins.module_utils.version_utils.get_opnsense_version", + return_value="OPNsense Test", +) +@patch.dict(in_dict=VERSION_MAP, values=TEST_VERSION_MAP, clear=True) +def test_rule_set_not_changed_after_save( + mocked_version_utils: MagicMock, sample_config_path +): + """ + Test FirewallRuleSet is not changed after save (and inner reload). + """ + new_test_rule = FirewallRule(interface="wan", descr="New Test Rule") + + with FirewallRuleSet(sample_config_path) as rule_set: + rule_set.add_or_update(new_test_rule) + + assert rule_set.changed + + rule_set.save() + assert not rule_set.changed + + +@patch( + "ansible_collections.puzzle.opnsense.plugins.module_utils.version_utils.get_opnsense_version", + return_value="OPNsense Test", +) +@patch.dict(in_dict=VERSION_MAP, values=TEST_VERSION_MAP, clear=True) +def test_rule_set_not_changed_after_duplicate_rule( + mocked_version_utils: MagicMock, sample_config_path +): + """ + Ensure FirewallRuleSet does not add an identical rule twice + """ + new_test_rule = FirewallRule(interface="wan", descr="New Test Rule") + + with FirewallRuleSet(sample_config_path) as rule_set: + rule_set.add_or_update(new_test_rule) + + rule_set.save() + + with FirewallRuleSet(sample_config_path) as same_rule_set: + same_rule_set.add_or_update(new_test_rule) + + assert not same_rule_set.changed + same_rule_set.save() + + +@patch( + "ansible_collections.puzzle.opnsense.plugins.module_utils.version_utils.get_opnsense_version", + return_value="OPNsense Test", +) +@patch.dict(in_dict=VERSION_MAP, values=TEST_VERSION_MAP, clear=True) +def test_fw_rule_from_ansible_is_same_as_default( + mocked_version_utils: MagicMock, sample_config_path +): + """ + Ensure Ansible default Parameters result in the same instance as a minimally initialized + FirewallRule instance. + """ + mock_ansible_module_params: dict = { + "interface": "wan", + "action": "pass", + "description": "New Test Rule", + "category": None, + "direction": None, + "disabled": False, + "quick": True, + "ipprotocol": "inet", + "protocol": "any", + "source": {"address": "any", "network": "any", "port": "any", "invert": False}, + "destination": { + "address": "any", + "network": "any", + "port": "any", + "invert": False, + }, + "log": False, + "state": "present", + } + ansible_rule: FirewallRule = FirewallRule.from_ansible_module_params( + mock_ansible_module_params + ) + + new_test_rule = FirewallRule(interface="wan", descr="New Test Rule") + + assert ansible_rule == new_test_rule + + +@patch( + "ansible_collections.puzzle.opnsense.plugins.module_utils.version_utils.get_opnsense_version", + return_value="OPNsense Test", +) +@patch.dict(in_dict=VERSION_MAP, values=TEST_VERSION_MAP, clear=True) +def test_rule_set_create_new_simple_rule_with_unsupported_action( + mocked_version_utils: MagicMock, sample_config_path +): + """ + Ensure FirewallRule ActionType is validated. + """ + with pytest.raises(ValueError) as excinfo: + _new_test_rule = FirewallRule( + interface="wan", + descr="New Test Rule", + type="NOT_AVAILBLE_FIREWALLRULEACTION", # Intentionally invalid type + ) + + assert "NOT_AVAILBLE_FIREWALLRULEACTION" in str(excinfo.value) + + +@patch( + "ansible_collections.puzzle.opnsense.plugins.module_utils.version_utils.get_opnsense_version", + return_value="OPNsense Test", +) +@patch.dict(in_dict=VERSION_MAP, values=TEST_VERSION_MAP, clear=True) +def test_rule_set_create_new_simple_disabled_rule( + mocked_version_utils: MagicMock, sample_config_path +): + """ + Test the FirewallRule disabled attribute. + """ + new_test_rule = FirewallRule( + interface="wan", + descr="New Test Rule", + type=FirewallRuleAction.PASS, + disabled=True, + ) + + with FirewallRuleSet(sample_config_path) as rule_set: + rule_set.add_or_update(new_test_rule) + + assert rule_set.changed + + rule_set.save() + + with FirewallRuleSet(sample_config_path) as new_rule_set: + new_rule: Optional[FirewallRule] = new_rule_set.find( + interface="wan", descr="New Test Rule" + ) + + assert new_rule is not None + assert new_rule.interface == "wan" + assert new_rule.descr == "New Test Rule" + assert new_rule.disabled == 1 + + +@patch( + "ansible_collections.puzzle.opnsense.plugins.module_utils.version_utils.get_opnsense_version", + return_value="OPNsense Test", +) +@patch.dict(in_dict=VERSION_MAP, values=TEST_VERSION_MAP, clear=True) +def test_rule_set_create_new_simple_quick_rule( + mocked_version_utils: MagicMock, sample_config_path +): + """ + Test the FirewallRule quick attribute. + """ + new_test_rule = FirewallRule( + interface="wan", + descr="New Test Rule", + type=FirewallRuleAction.PASS, + quick=False, + ) + + with FirewallRuleSet(sample_config_path) as rule_set: + rule_set.add_or_update(new_test_rule) + + assert rule_set.changed + + rule_set.save() + + with FirewallRuleSet(sample_config_path) as new_rule_set: + new_rule: Optional[FirewallRule] = new_rule_set.find( + interface="wan", descr="New Test Rule" + ) + + assert new_rule is not None + assert new_rule.interface == "wan" + assert new_rule.descr == "New Test Rule" + assert new_rule.quick == 0 + + +@patch( + "ansible_collections.puzzle.opnsense.plugins.module_utils.version_utils.get_opnsense_version", + return_value="OPNsense Test", +) +@patch.dict(in_dict=VERSION_MAP, values=TEST_VERSION_MAP, clear=True) +def test_rule_set_create_new_simple_quick_enabled_rule( + mocked_version_utils: MagicMock, sample_config_path +): + """ + Create a FirewallRule with quick enabled. + """ + new_test_rule = FirewallRule( + interface="wan", descr="New Test Rule", type=FirewallRuleAction.PASS, quick=True + ) + + with FirewallRuleSet(sample_config_path) as rule_set: + rule_set.add_or_update(new_test_rule) + + assert rule_set.changed + + rule_set.save() + + with FirewallRuleSet(sample_config_path) as new_rule_set: + new_rule: Optional[FirewallRule] = new_rule_set.find( + interface="wan", descr="New Test Rule" + ) + + assert new_rule is not None + assert new_rule.interface == "wan" + assert new_rule.descr == "New Test Rule" + assert new_rule.quick + assert new_rule.quick == 1 + + +@patch( + "ansible_collections.puzzle.opnsense.plugins.module_utils.version_utils.get_opnsense_version", + return_value="OPNsense Test", +) +@patch.dict(in_dict=VERSION_MAP, values=TEST_VERSION_MAP, clear=True) +def test_rule_set_create_new_simple_log_enabled_rule( + mocked_version_utils: MagicMock, sample_config_path +): + """ + Create FirewallRule with logging enabled. + """ + new_test_rule = FirewallRule( + interface="wan", descr="New Test Rule", type=FirewallRuleAction.PASS, log=True + ) + + with FirewallRuleSet(sample_config_path) as rule_set: + rule_set.add_or_update(new_test_rule) + + assert rule_set.changed + + rule_set.save() + + with FirewallRuleSet(sample_config_path) as new_rule_set: + new_rule: Optional[FirewallRule] = new_rule_set.find( + interface="wan", descr="New Test Rule" + ) + + assert new_rule is not None + assert new_rule.interface == "wan" + assert new_rule.descr == "New Test Rule" + assert new_rule.log + assert new_rule.log == 1 + + +@patch( + "ansible_collections.puzzle.opnsense.plugins.module_utils.version_utils.get_opnsense_version", + return_value="OPNsense Test", +) +@patch.dict(in_dict=VERSION_MAP, values=TEST_VERSION_MAP, clear=True) +def test_rule_set_create_new_simple_log_disabled_rule( + mocked_version_utils: MagicMock, sample_config_path +): + """ + Simple FirewallRule with logging disabled. + """ + new_test_rule = FirewallRule( + interface="wan", descr="New Test Rule", type=FirewallRuleAction.PASS, log=False + ) + + with FirewallRuleSet(sample_config_path) as rule_set: + rule_set.add_or_update(new_test_rule) + + assert rule_set.changed + + rule_set.save() + + with FirewallRuleSet(sample_config_path) as new_rule_set: + new_rule: Optional[FirewallRule] = new_rule_set.find( + interface="wan", descr="New Test Rule" + ) + + assert new_rule is not None + assert new_rule.interface == "wan" + assert new_rule.descr == "New Test Rule" + assert not new_rule.log