summaryrefslogtreecommitdiff
path: root/roles/zone_firewall
diff options
context:
space:
mode:
Diffstat (limited to 'roles/zone_firewall')
-rw-r--r--roles/zone_firewall/defaults/main.yml6
-rw-r--r--roles/zone_firewall/meta/argument_specs.yml151
-rw-r--r--roles/zone_firewall/meta/main.yml11
-rw-r--r--roles/zone_firewall/tasks/main.yml34
-rw-r--r--roles/zone_firewall/tasks/verify.yml24
-rw-r--r--roles/zone_firewall/templates/firewall_rules.yml.j247
-rw-r--r--roles/zone_firewall/templates/zones.yml.j228
-rw-r--r--roles/zone_firewall/vars/main.yml17
8 files changed, 318 insertions, 0 deletions
diff --git a/roles/zone_firewall/defaults/main.yml b/roles/zone_firewall/defaults/main.yml
new file mode 100644
index 0000000..6bb4a1a
--- /dev/null
+++ b/roles/zone_firewall/defaults/main.yml
@@ -0,0 +1,6 @@
+---
+zone_firewall_policies: []
+zone_firewall_ipv6: true
+zone_firewall_all_pairs: true
+zone_firewall_base_rules: true
+zone_firewall_log_rules: true
diff --git a/roles/zone_firewall/meta/argument_specs.yml b/roles/zone_firewall/meta/argument_specs.yml
new file mode 100644
index 0000000..fa47473
--- /dev/null
+++ b/roles/zone_firewall/meta/argument_specs.yml
@@ -0,0 +1,151 @@
+---
+argument_specs:
+ main:
+ short_description: Zone-based firewall (docs blueprint "Zone-Policy example")
+ description:
+ - Defines zones, one ruleset per zone-pair-direction named
+ C(<from>-<to>) for IPv4 and C(<from>-<to>-6) for IPv6, and binds the
+ rulesets to the zones, following the practices on the docs page.
+ - Every ruleset gets C(default-action drop), C(default-log) and the two
+ base rules from the page (1 accept established/related, 2 drop and log
+ invalid). Rules 1 and 2 are therefore reserved.
+ - With C(zone_firewall_all_pairs), rulesets are created for every zone
+ pair, including pairs that never communicate, so attempts are logged.
+ - Uses vyos.vyos.vyos_firewall_rules and vyos.vyos.vyos_firewall_global
+ (zone support requires a vyos.vyos release that includes it).
+ - B(Lockout risk) as the page warns - put the interface you manage the
+ router through into a zone with rules allowing SSH to the local zone
+ before running this role.
+ options:
+ zone_firewall_zones:
+ type: list
+ elements: dict
+ required: true
+ description: Zones. Exactly one may be the local zone (the router itself).
+ options:
+ name:
+ type: str
+ required: true
+ description: Zone name, e.g. C(lan).
+ interfaces:
+ type: list
+ elements: str
+ default: []
+ description: Member interfaces, e.g. C([eth0.20]). Not used for the local zone.
+ local:
+ type: bool
+ default: false
+ description: This zone is the router itself.
+ default_action:
+ type: str
+ choices: [drop, reject]
+ default: drop
+ description: Zone default action (zones cannot accept).
+ default_log:
+ type: bool
+ default: true
+ description: Log packets hitting the zone default action.
+ description:
+ type: str
+ description: Zone description.
+ zone_firewall_policies:
+ type: list
+ elements: dict
+ default: []
+ description: Rules per zone-pair-direction (traffic from C(from) to C(to)).
+ options:
+ from:
+ type: str
+ required: true
+ description: Source zone.
+ to:
+ type: str
+ required: true
+ description: Destination zone.
+ default_action:
+ type: str
+ choices: [drop, reject, accept]
+ default: drop
+ description: Ruleset default action, e.g. C(accept) for a management zone.
+ rules:
+ type: list
+ elements: dict
+ default: []
+ description: Rules added after the base rules.
+ options:
+ number:
+ type: int
+ required: true
+ description: Rule number (not 1 or 2 while base rules are on).
+ action:
+ type: str
+ choices: [accept, drop, reject]
+ default: accept
+ description: Rule action.
+ description:
+ type: str
+ description: Rule description.
+ protocol:
+ type: str
+ description:
+ - Protocol, e.g. C(tcp), C(udp), C(tcp_udp), C(icmp).
+ - C(icmp) becomes C(ipv6-icmp) in the IPv6 ruleset.
+ source:
+ type: dict
+ description: Source match.
+ options:
+ address:
+ type: str
+ description: Address or network.
+ port:
+ type: str
+ description: Port(s), e.g. C(22) or C(20,21).
+ destination:
+ type: dict
+ description: Destination match.
+ options:
+ address:
+ type: str
+ description: Address or network.
+ port:
+ type: str
+ description: Port(s), e.g. C(80,443).
+ family:
+ type: str
+ choices: [ipv4, ipv6, both]
+ description:
+ - Rulesets the rule goes into. Inferred when omitted - an
+ IPv6 address means C(ipv6), an IPv4 address C(ipv4), no
+ address C(both).
+ log:
+ type: bool
+ description: Log matches. Defaults to C(zone_firewall_log_rules).
+ zone_firewall_ipv6:
+ type: bool
+ default: true
+ description: Also create IPv6 rulesets and bind them.
+ zone_firewall_all_pairs:
+ type: bool
+ default: true
+ description: Create a (base rules only) ruleset for every zone pair not listed.
+ zone_firewall_base_rules:
+ type: bool
+ default: true
+ description: Add the page's rules 1 (established/related) and 2 (invalid) to every ruleset.
+ zone_firewall_log_rules:
+ type: bool
+ default: true
+ description: Default for C(log) on user rules, as the page recommends.
+ vyos_blueprints_render_only:
+ type: bool
+ default: false
+ description: Collect commands into C(vyos_blueprints_rendered) instead of configuring.
+ verify:
+ short_description: Post-deployment checks for the zone_firewall role
+ description: Run with C(tasks_from=verify). Checks every zone and its member interfaces are active.
+ options:
+ zone_firewall_zones:
+ type: list
+ elements: dict
+ required: true
+ description: Same value as for C(main).
diff --git a/roles/zone_firewall/meta/main.yml b/roles/zone_firewall/meta/main.yml
new file mode 100644
index 0000000..c83c9f4
--- /dev/null
+++ b/roles/zone_firewall/meta/main.yml
@@ -0,0 +1,11 @@
+---
+galaxy_info:
+ author: VyOS maintainers and contributors
+ description: Zone-based firewall with per zone-pair rulesets for IPv4 and IPv6
+ license: GPL-3.0-or-later
+ min_ansible_version: "2.16"
+ platforms:
+ - name: GenericLinux
+ versions: [all]
+ galaxy_tags: [vyos, networking, firewall, zone]
+dependencies: []
diff --git a/roles/zone_firewall/tasks/main.yml b/roles/zone_firewall/tasks/main.yml
new file mode 100644
index 0000000..469dc24
--- /dev/null
+++ b/roles/zone_firewall/tasks/main.yml
@@ -0,0 +1,34 @@
+---
+- name: Check zones and policies
+ ansible.builtin.assert:
+ that:
+ - zone_firewall_zones | selectattr('local', 'defined') | selectattr('local') | list | length <= 1
+ - (zone_firewall_policies | map(attribute='from') | list + zone_firewall_policies | map(attribute='to') | list)
+ | difference(_zone_firewall_names) | length == 0
+ - not (zone_firewall_base_rules | bool)
+ or (zone_firewall_policies | map(attribute='rules', default=[]) | flatten
+ | map(attribute='number') | select('in', [1, 2]) | list | length == 0)
+ fail_msg: >-
+ At most one local zone; every policy must reference defined zones;
+ rule numbers 1 and 2 are reserved for the base rules.
+ quiet: true
+
+- name: Create one ruleset per zone-pair-direction
+ vyos.vyos.vyos_firewall_rules:
+ config: "{{ lookup('ansible.builtin.template', 'firewall_rules.yml.j2') | from_yaml }}"
+ state: "{{ _zone_firewall_state }}"
+ register: _zone_firewall_r_rules
+
+- name: Define zones and bind the rulesets
+ vyos.vyos.vyos_firewall_global:
+ config: "{{ lookup('ansible.builtin.template', 'zones.yml.j2') | from_yaml }}"
+ state: "{{ _zone_firewall_state }}"
+ register: _zone_firewall_r_zones
+
+- name: Collect rendered commands # noqa: var-naming[no-role-prefix] - shared across roles by design
+ ansible.builtin.set_fact:
+ vyos_blueprints_rendered: >-
+ {{ vyos_blueprints_rendered | default([])
+ + ([_zone_firewall_r_rules, _zone_firewall_r_zones]
+ | selectattr('rendered', 'defined') | map(attribute='rendered') | flatten) }}
+ when: vyos_blueprints_render_only | default(false) | bool
diff --git a/roles/zone_firewall/tasks/verify.yml b/roles/zone_firewall/tasks/verify.yml
new file mode 100644
index 0000000..110eeac
--- /dev/null
+++ b/roles/zone_firewall/tasks/verify.yml
@@ -0,0 +1,24 @@
+---
+- name: Read zone policy
+ vyos.vyos.vyos_command:
+ commands:
+ - show firewall zone-policy
+ register: _zone_firewall_v
+
+- name: Every zone is active
+ ansible.builtin.assert:
+ that: _zone_firewall_v.stdout[0] is search('(?m)^\s*' ~ (item.name | regex_escape) ~ '\s')
+ fail_msg: "zone {{ item.name }} is missing from 'show firewall zone-policy'"
+ quiet: true
+ loop: "{{ zone_firewall_zones }}"
+ loop_control:
+ label: "{{ item.name }}"
+
+- name: Member interfaces are bound
+ ansible.builtin.assert:
+ that: _zone_firewall_v.stdout[0] is search('\b' ~ (item.1 | regex_escape) ~ '\b')
+ fail_msg: "interface {{ item.1 }} of zone {{ item.0.name }} is not bound"
+ quiet: true
+ loop: "{{ zone_firewall_zones | subelements('interfaces', skip_missing=true) }}"
+ loop_control:
+ label: "{{ item.0.name }} {{ item.1 }}"
diff --git a/roles/zone_firewall/templates/firewall_rules.yml.j2 b/roles/zone_firewall/templates/firewall_rules.yml.j2
new file mode 100644
index 0000000..0e083f3
--- /dev/null
+++ b/roles/zone_firewall/templates/firewall_rules.yml.j2
@@ -0,0 +1,47 @@
+{%- macro rule_family(r) -%}
+{%- set addrs = [r.source.address | default('') if r.source is defined and r.source else '',
+ r.destination.address | default('') if r.destination is defined and r.destination else ''] | select | list -%}
+{%- if r.family is defined and r.family -%}{{ r.family }}
+{%- elif addrs | select('search', ':') | list -%}ipv6
+{%- elif addrs -%}ipv4
+{%- else -%}both
+{%- endif -%}
+{%- endmacro -%}
+{% for afi in _zone_firewall_families %}
+- afi: {{ afi }}
+ rule_sets:
+{% for p in _zone_firewall_policies %}
+ - name: {{ (p['from'] ~ '-' ~ p['to'] ~ ('-6' if afi == 'ipv6' else '')) | to_json }}
+ default_action: {{ p.default_action | default('drop') }}
+ enable_default_log: true
+ rules:
+{% if zone_firewall_base_rules | bool %}
+ - number: 1
+ action: accept
+ state: {established: true, related: true}
+ - number: 2
+ action: drop
+ log: enable
+ state: {invalid: true}
+{% endif %}
+{% for r in p.rules | default([]) if rule_family(r) in [afi, 'both'] %}
+ - number: {{ r.number | int }}
+ action: {{ r.action | default('accept') }}
+{% if r.description is defined %}
+ description: {{ r.description | to_json }}
+{% endif %}
+{% if r.protocol is defined %}
+ protocol: {{ ('ipv6-icmp' if (afi == 'ipv6' and r.protocol == 'icmp') else r.protocol) | to_json }}
+{% endif %}
+{% if r.log | default(zone_firewall_log_rules) | bool %}
+ log: enable
+{% endif %}
+{% for side in ['source', 'destination'] if r[side] is defined and r[side] %}
+ {{ side }}:
+{% for k in ['address', 'port'] if r[side][k] is defined %}
+ {{ k }}: {{ r[side][k] | string | to_json }}
+{% endfor %}
+{% endfor %}
+{% endfor %}
+{% endfor %}
+{% endfor %}
diff --git a/roles/zone_firewall/templates/zones.yml.j2 b/roles/zone_firewall/templates/zones.yml.j2
new file mode 100644
index 0000000..3af0e99
--- /dev/null
+++ b/roles/zone_firewall/templates/zones.yml.j2
@@ -0,0 +1,28 @@
+zone:
+{% for z in zone_firewall_zones %}
+ - name: {{ z.name | to_json }}
+ default_action: {{ z.default_action | default('drop') }}
+{% if z.default_log | default(true) | bool %}
+ default_log: true
+{% endif %}
+{% if z.description is defined %}
+ description: {{ z.description | to_json }}
+{% endif %}
+{% if z.local | default(false) | bool %}
+ local_zone: true
+{% else %}
+ interfaces: {{ z.interfaces | default([]) | to_json }}
+{% endif %}
+{% set srcs = _zone_firewall_policies | selectattr('to', 'equalto', z.name) | list %}
+{% if srcs %}
+ sources:
+{% for p in srcs %}
+ - zone: {{ p['from'] | to_json }}
+ firewall:
+ name: {{ (p['from'] ~ '-' ~ z.name) | to_json }}
+{% if zone_firewall_ipv6 | bool %}
+ ipv6_name: {{ (p['from'] ~ '-' ~ z.name ~ '-6') | to_json }}
+{% endif %}
+{% endfor %}
+{% endif %}
+{% endfor %}
diff --git a/roles/zone_firewall/vars/main.yml b/roles/zone_firewall/vars/main.yml
new file mode 100644
index 0000000..14ffa31
--- /dev/null
+++ b/roles/zone_firewall/vars/main.yml
@@ -0,0 +1,17 @@
+---
+_zone_firewall_state: "{{ 'rendered' if (vyos_blueprints_render_only | default(false) | bool) else 'merged' }}"
+_zone_firewall_families: "{{ ['ipv4', 'ipv6'] if zone_firewall_ipv6 | bool else ['ipv4'] }}"
+_zone_firewall_names: "{{ zone_firewall_zones | map(attribute='name') | list }}"
+# Listed policies plus, with zone_firewall_all_pairs, an empty policy for every
+# other ordered zone pair, so every zone-pair-direction has a logged ruleset.
+_zone_firewall_policies: >-
+ {%- set out = zone_firewall_policies | list -%}
+ {%- if zone_firewall_all_pairs | bool -%}
+ {%- set listed = zone_firewall_policies | map(attribute='from') | zip(zone_firewall_policies | map(attribute='to')) | map('join', '>') | list -%}
+ {%- for a in _zone_firewall_names -%}
+ {%- for b in _zone_firewall_names if b != a and (a ~ '>' ~ b) not in listed -%}
+ {%- set _ = out.append({'from': a, 'to': b, 'rules': []}) -%}
+ {%- endfor -%}
+ {%- endfor -%}
+ {%- endif -%}
+ {{ out }}