diff options
| author | omnom62 <omnom62@outlook.com> | 2026-10-05 20:56:05 +1000 |
|---|---|---|
| committer | omnom62 <omnom62@outlook.com> | 2026-10-05 20:56:05 +1000 |
| commit | 8ee802e41374942965b6b93cfb4534725ef45145 (patch) | |
| tree | ef3e5579bd767422caa6fe77cea6a7b5d2d8562c /roles | |
| parent | 748df2bc1d35fa285dd3fe46916e1230408778c7 (diff) | |
| download | vyos.blueprints-main.tar.gz vyos.blueprints-main.zip | |
T9393: new rolesmain
Diffstat (limited to 'roles')
89 files changed, 2790 insertions, 2 deletions
diff --git a/roles/bgp_unnumbered/defaults/main.yml b/roles/bgp_unnumbered/defaults/main.yml new file mode 100644 index 0000000..cadff8d --- /dev/null +++ b/roles/bgp_unnumbered/defaults/main.yml @@ -0,0 +1,7 @@ +--- +bgp_unnumbered_peer_group: fabric +bgp_unnumbered_remote_as: external +bgp_unnumbered_address_families: [ipv4, ipv6] +bgp_unnumbered_redistribute: [connected] +bgp_unnumbered_multipath_relax: true +bgp_unnumbered_group: "" diff --git a/roles/bgp_unnumbered/meta/argument_specs.yml b/roles/bgp_unnumbered/meta/argument_specs.yml new file mode 100644 index 0000000..043e26d --- /dev/null +++ b/roles/bgp_unnumbered/meta/argument_specs.yml @@ -0,0 +1,81 @@ +--- +argument_specs: + main: + short_description: BGP IPv6 unnumbered with extended next-hop (docs blueprint of the same name) + description: + - Peers over IPv6 link-local addresses on each listed interface, carries + IPv4 and IPv6 with extended next-hop, and redistributes connected + routes. Parallel links give ECMP; C(multipath-relax) allows it across + different neighbour ASes. + - Puts the router-id as a /32 on C(lo), as in the page's results. + - AS, router-id and redistribution use vyos.vyos BGP modules. Peer-group, + interface (v6only) neighbours and multipath-relax use + vyos.vyos.vyos_config, because the BGP resource modules cannot express + them yet. + - Works for any number of routers; each router lists only its own links. + options: + bgp_unnumbered_asn: + type: int + required: true + description: Local AS number (C(system-as)). + bgp_unnumbered_router_id: + type: str + required: true + description: BGP router-id; also configured as a /32 on C(lo). + bgp_unnumbered_interfaces: + type: list + elements: str + required: true + description: Interfaces to peer on, e.g. C([eth1, eth2]). They need no IP address. + bgp_unnumbered_peer_group: + type: str + default: fabric + description: Peer-group name shared by all unnumbered neighbours. + bgp_unnumbered_remote_as: + type: str + default: external + description: C(external), C(internal) or an AS number. + bgp_unnumbered_address_families: + type: list + elements: str + choices: [ipv4, ipv6] + default: [ipv4, ipv6] + description: Address families activated on the peer-group. + bgp_unnumbered_redistribute: + type: list + elements: str + default: [connected] + description: Route types redistributed in every listed address family. + bgp_unnumbered_multipath_relax: + type: bool + default: true + description: Set C(bestpath as-path multipath-relax). + bgp_unnumbered_group: + type: str + default: "" + description: Inventory group of all routers in the fabric; used only by C(verify). + 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 bgp_unnumbered role + description: + - Run with C(tasks_from=verify) against all routers in the same play. + - Checks one established session per listed interface and, with + C(bgp_unnumbered_group), a BGP route to every other router-id. When the + group has exactly two routers, also checks one ECMP next hop per link. + options: + bgp_unnumbered_router_id: + type: str + required: true + description: Same value as for C(main). + bgp_unnumbered_interfaces: + type: list + elements: str + required: true + description: Same value as for C(main). + bgp_unnumbered_group: + type: str + default: "" + description: Same value as for C(main). diff --git a/roles/bgp_unnumbered/meta/main.yml b/roles/bgp_unnumbered/meta/main.yml new file mode 100644 index 0000000..af7872b --- /dev/null +++ b/roles/bgp_unnumbered/meta/main.yml @@ -0,0 +1,11 @@ +--- +galaxy_info: + author: VyOS maintainers and contributors + description: eBGP over IPv6 link-local (unnumbered) interfaces with extended next-hop + license: GPL-3.0-or-later + min_ansible_version: "2.16" + platforms: + - name: GenericLinux + versions: [all] + galaxy_tags: [vyos, networking, bgp] +dependencies: [] diff --git a/roles/bgp_unnumbered/tasks/main.yml b/roles/bgp_unnumbered/tasks/main.yml new file mode 100644 index 0000000..1317f4f --- /dev/null +++ b/roles/bgp_unnumbered/tasks/main.yml @@ -0,0 +1,40 @@ +--- +- name: Put the router-id on the loopback + vyos.vyos.vyos_l3_interfaces: + config: + - name: lo + ipv4: + - address: "{{ bgp_unnumbered_router_id }}/32" + state: "{{ _bgp_unnumbered_state }}" + register: _bgp_unnumbered_r_lo + +- name: Configure the BGP process (AS, router-id, best-path) + vyos.vyos.vyos_bgp_global: + config: "{{ lookup('ansible.builtin.template', 'bgp_global.yml.j2') | from_yaml }}" + state: "{{ _bgp_unnumbered_state }}" + register: _bgp_unnumbered_r_global + +- name: Redistribute into each address family + vyos.vyos.vyos_bgp_address_family: + config: "{{ lookup('ansible.builtin.template', 'bgp_address_family.yml.j2') | from_yaml }}" + state: "{{ _bgp_unnumbered_state }}" + register: _bgp_unnumbered_r_af + when: bgp_unnumbered_redistribute | length > 0 + +- name: Configure the peer-group and unnumbered neighbours + # Not expressible with vyos_bgp_global yet (interface v6only neighbours, + # peer-group definitions, remote-as external, multipath-relax). + vyos.vyos.vyos_config: + lines: "{{ _bgp_unnumbered_cli_lines }}" + register: _bgp_unnumbered_r_cli + when: not (vyos_blueprints_render_only | default(false) | bool) + +- 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([]) + + ([_bgp_unnumbered_r_lo, _bgp_unnumbered_r_global, _bgp_unnumbered_r_af] + | selectattr('rendered', 'defined') | map(attribute='rendered') | flatten + | unique) + + _bgp_unnumbered_cli_lines }} + when: vyos_blueprints_render_only | default(false) | bool diff --git a/roles/bgp_unnumbered/tasks/verify.yml b/roles/bgp_unnumbered/tasks/verify.yml new file mode 100644 index 0000000..cbc98b9 --- /dev/null +++ b/roles/bgp_unnumbered/tasks/verify.yml @@ -0,0 +1,44 @@ +--- +- name: Read BGP sessions + vyos.vyos.vyos_command: + commands: + - show bgp summary + register: _bgp_unnumbered_v_sum + +- name: One established session per interface + # An established neighbour shows its Up/Down time followed by a prefix + # count; other states show their name (Active, Connect, ...) instead. + ansible.builtin.assert: + that: _bgp_unnumbered_v_sum.stdout[0] is search('(?m)^' ~ item ~ '\s.*\s(\d{2}:\d{2}:\d{2}|\d+[dwh]\S*)\s+\d+\b') + fail_msg: "BGP session on {{ item }} is not established" + quiet: true + loop: "{{ bgp_unnumbered_interfaces }}" + +- name: Read routes to the other routers + vyos.vyos.vyos_command: + commands: "{{ _bgp_unnumbered_peers | map('regex_replace', '^(.*)$', 'show ip route \\1/32') | list }}" + vars: + _bgp_unnumbered_peers: >- + {{ groups[bgp_unnumbered_group] | difference([inventory_hostname]) + | map('extract', hostvars, 'bgp_unnumbered_router_id') | list }} + register: _bgp_unnumbered_v_routes + when: bgp_unnumbered_group | length > 0 + +- name: Every other router-id is reached via BGP + ansible.builtin.assert: + that: item is search('Known via "bgp"') + fail_msg: "{{ item | regex_search('Routing entry for \\S+') | default('a peer', true) }} is not learned via BGP" + quiet: true + loop: "{{ _bgp_unnumbered_v_routes.stdout | default([]) }}" + loop_control: + label: "{{ item | regex_search('Routing entry for \\S+') | default('route', true) }}" + when: bgp_unnumbered_group | length > 0 + +- name: With two routers, one ECMP next hop per link + ansible.builtin.assert: + that: _bgp_unnumbered_v_routes.stdout[0] | regex_findall('fe80::\S+, via \S+') | length == bgp_unnumbered_interfaces | length + fail_msg: "expected {{ bgp_unnumbered_interfaces | length }} ECMP next hops to the peer" + quiet: true + when: + - bgp_unnumbered_group | length > 0 + - groups[bgp_unnumbered_group] | length == 2 diff --git a/roles/bgp_unnumbered/templates/bgp_address_family.yml.j2 b/roles/bgp_unnumbered/templates/bgp_address_family.yml.j2 new file mode 100644 index 0000000..552f1ae --- /dev/null +++ b/roles/bgp_unnumbered/templates/bgp_address_family.yml.j2 @@ -0,0 +1,11 @@ +as_number: {{ bgp_unnumbered_asn | int }} +address_family: +{% for afi in bgp_unnumbered_address_families %} + - afi: {{ afi }} +{% if bgp_unnumbered_redistribute %} + redistribute: +{% for r in bgp_unnumbered_redistribute %} + - protocol: {{ r }} +{% endfor %} +{% endif %} +{% endfor %} diff --git a/roles/bgp_unnumbered/templates/bgp_global.yml.j2 b/roles/bgp_unnumbered/templates/bgp_global.yml.j2 new file mode 100644 index 0000000..4c9c633 --- /dev/null +++ b/roles/bgp_unnumbered/templates/bgp_global.yml.j2 @@ -0,0 +1,7 @@ +as_number: {{ bgp_unnumbered_asn | int }} +bgp_params: + router_id: {{ bgp_unnumbered_router_id | to_json }} + bestpath: + compare_routerid: true + default: + no_ipv4_unicast: true diff --git a/roles/bgp_unnumbered/templates/bgp_neighbors.j2 b/roles/bgp_unnumbered/templates/bgp_neighbors.j2 new file mode 100644 index 0000000..34565ac --- /dev/null +++ b/roles/bgp_unnumbered/templates/bgp_neighbors.j2 @@ -0,0 +1,14 @@ +{% set pg = bgp_unnumbered_peer_group %} +{% set base = 'set protocols bgp' %} +{% for afi in bgp_unnumbered_address_families %} +{{ base }} peer-group {{ pg }} address-family {{ afi }}-unicast +{% endfor %} +{{ base }} peer-group {{ pg }} capability extended-nexthop +{{ base }} peer-group {{ pg }} remote-as '{{ bgp_unnumbered_remote_as }}' +{% for i in bgp_unnumbered_interfaces %} +{{ base }} neighbor {{ i }} interface v6only +{{ base }} neighbor {{ i }} interface v6only peer-group '{{ pg }}' +{% endfor %} +{% if bgp_unnumbered_multipath_relax | bool %} +{{ base }} parameters bestpath as-path multipath-relax +{% endif %} diff --git a/roles/bgp_unnumbered/vars/main.yml b/roles/bgp_unnumbered/vars/main.yml new file mode 100644 index 0000000..64eb708 --- /dev/null +++ b/roles/bgp_unnumbered/vars/main.yml @@ -0,0 +1,3 @@ +--- +_bgp_unnumbered_state: "{{ 'rendered' if (vyos_blueprints_render_only | default(false) | bool) else 'merged' }}" +_bgp_unnumbered_cli_lines: "{{ (lookup('ansible.builtin.template', 'bgp_neighbors.j2') or '').splitlines() | select | list }}" diff --git a/roles/bridge_firewall/defaults/main.yml b/roles/bridge_firewall/defaults/main.yml new file mode 100644 index 0000000..f656cb5 --- /dev/null +++ b/roles/bridge_firewall/defaults/main.yml @@ -0,0 +1,4 @@ +--- +bridge_firewall_state_rules: true +bridge_firewall_bridge_forward_default_action: drop +bridge_firewall_ipv4_forward_default_action: drop diff --git a/roles/bridge_firewall/meta/argument_specs.yml b/roles/bridge_firewall/meta/argument_specs.yml new file mode 100644 index 0000000..6d5493e --- /dev/null +++ b/roles/bridge_firewall/meta/argument_specs.yml @@ -0,0 +1,163 @@ +--- +argument_specs: + main: + short_description: Bridges with bridge and IPv4 firewall (docs blueprint "Bridge and firewall example") + description: + - Per bridge, creates the bridge and its members, an interface group + C(<bridge>-ifaces), bridge-firewall rulesets C(<bridge>-pre) (prerouting) + and C(<bridge>-fwd) (forward) with the jump rules that select them, and + optionally an IPv4 ruleset C(ip-<bridge>-fwd) for routed traffic and an + input rule for access to the router - the structure used on the docs page. + - Bridges, interface groups and the bridge firewall use + vyos.vyos.vyos_config, because vyos.vyos has no resource modules for + them yet. Member descriptions and the IPv4 firewall use resource modules. + options: + bridge_firewall_bridges: + type: list + elements: dict + required: true + description: Bridges, in order; their position sets the jump rule numbers (10, 20, ... and 110, 120, ...). + options: + name: + type: str + required: true + description: Bridge interface, e.g. C(br1). + description: + type: str + description: Bridge description. + addresses: + type: list + elements: str + default: [] + description: Bridge addresses; omit for a pure layer-2 bridge. + members: + type: list + elements: dict + required: true + description: Member interfaces. + options: + interface: + type: str + required: true + description: Member interface, e.g. C(eth3). + description: + type: str + description: Member interface description. + prerouting: + type: dict + description: Bridge ruleset C(<bridge>-pre), applied in the prerouting chain. + options: &bridge_ruleset + default_action: + type: str + choices: [accept, drop] + description: Ruleset default action (C(accept) for prerouting, C(drop) for forward by default). + rules: + type: list + elements: dict + default: [] + description: Bridge firewall rules. + options: + number: + type: int + required: true + description: Rule number. + action: + type: str + choices: [accept, drop] + default: accept + description: Rule action. + description: + type: str + description: Rule description. + ethernet_type: + type: str + description: Ethernet type, e.g. C(ipv4), C(ipv6), C(arp). + protocol: + type: str + description: IP protocol, e.g. C(udp). + source: + type: dict + description: Source match (C(address), C(port), C(mac_address)). + destination: + type: dict + description: Destination match (C(address), C(port), C(mac_address)). + inbound_interface: + type: str + description: Inbound bridge port, e.g. C(eth6). + state: + type: list + elements: str + default: [] + description: Connection states, e.g. C([new]). + log: + type: bool + default: false + description: Log matches. + forward: + type: dict + description: Bridge ruleset C(<bridge>-fwd), applied in the bridge forward chain. + options: *bridge_ruleset + ip_forward: + type: dict + description: IPv4 ruleset C(ip-<bridge>-fwd) for traffic routed out of the bridge. + options: + default_action: + type: str + choices: [accept, drop, reject] + default: drop + description: Ruleset default action. + rules: + type: list + elements: dict + default: [] + description: IPv4 rules. + options: + number: + type: int + required: true + description: Rule number. + action: + type: str + choices: [accept, drop, reject] + default: accept + description: Rule action. + description: + type: str + description: Rule description. + outbound_interface: + type: str + description: Outbound interface, e.g. the internet uplink. + outbound_bridge: + type: str + description: Another bridge from this list, matched via its interface group. + router_access: + type: str + choices: [accept, drop] + description: Accept or drop IPv4 traffic from this bridge to the router itself. + bridge_firewall_state_rules: + type: bool + default: true + description: Add the page's established/related and invalid rules to the forward and input chains. + bridge_firewall_bridge_forward_default_action: + type: str + choices: [accept, drop] + default: drop + description: Default action of the bridge forward chain. + bridge_firewall_ipv4_forward_default_action: + type: str + choices: [accept, drop, reject] + default: drop + description: Default action of the IPv4 forward chain. + 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 bridge_firewall role + description: Run with C(tasks_from=verify). Checks bridge membership and that the bridge rulesets are active. + options: + bridge_firewall_bridges: + type: list + elements: dict + required: true + description: Same value as for C(main). diff --git a/roles/bridge_firewall/meta/main.yml b/roles/bridge_firewall/meta/main.yml new file mode 100644 index 0000000..4e556cb --- /dev/null +++ b/roles/bridge_firewall/meta/main.yml @@ -0,0 +1,11 @@ +--- +galaxy_info: + author: VyOS maintainers and contributors + description: Bridges with bridge-layer and IPv4 firewall policies per bridge + license: GPL-3.0-or-later + min_ansible_version: "2.16" + platforms: + - name: GenericLinux + versions: [all] + galaxy_tags: [vyos, networking, bridge, firewall] +dependencies: [] diff --git a/roles/bridge_firewall/tasks/main.yml b/roles/bridge_firewall/tasks/main.yml new file mode 100644 index 0000000..d947258 --- /dev/null +++ b/roles/bridge_firewall/tasks/main.yml @@ -0,0 +1,35 @@ +--- +- name: Set member interface descriptions + vyos.vyos.vyos_interfaces: + config: "{{ _bridge_firewall_member_descriptions }}" + state: "{{ _bridge_firewall_state }}" + register: _bridge_firewall_r_ifaces + when: _bridge_firewall_member_descriptions | length > 0 + +- name: Create bridges, interface groups and the bridge firewall + # No vyos.vyos resource module covers bridges, interface groups or the + # bridge firewall yet, so these go through vyos_config. + vyos.vyos.vyos_config: + lines: "{{ _bridge_firewall_cli_lines }}" + register: _bridge_firewall_r_cli + when: not (vyos_blueprints_render_only | default(false) | bool) + +- name: Configure the IPv4 input and forward filters + vyos.vyos.vyos_firewall_rules: + config: "{{ _bridge_firewall_ipv4 }}" + state: "{{ _bridge_firewall_state }}" + vars: + _bridge_firewall_ipv4: "{{ lookup('ansible.builtin.template', 'ipv4_rules.yml.j2') | from_yaml }}" + register: _bridge_firewall_r_ipv4 + when: >- + bridge_firewall_bridges | selectattr('ip_forward', 'defined') | list | length > 0 + or bridge_firewall_bridges | selectattr('router_access', 'defined') | list | length > 0 + +- 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([]) + + (_bridge_firewall_r_ifaces.rendered | default([])) + + _bridge_firewall_cli_lines + + (_bridge_firewall_r_ipv4.rendered | default([])) }} + when: vyos_blueprints_render_only | default(false) | bool diff --git a/roles/bridge_firewall/tasks/verify.yml b/roles/bridge_firewall/tasks/verify.yml new file mode 100644 index 0000000..295b8af --- /dev/null +++ b/roles/bridge_firewall/tasks/verify.yml @@ -0,0 +1,27 @@ +--- +- name: Read bridges and bridge firewall + vyos.vyos.vyos_command: + commands: + - show configuration commands + - show firewall bridge + register: _bridge_firewall_v + +- name: Every member interface is in its bridge + ansible.builtin.assert: + that: >- + _bridge_firewall_v.stdout[0] is search('set interfaces bridge ' ~ (item.0.name | regex_escape) + ~ ' member interface ' ~ (item.1.interface | regex_escape) ~ '\b') + fail_msg: "{{ item.1.interface }} is not a member of {{ item.0.name }}" + quiet: true + loop: "{{ bridge_firewall_bridges | subelements('members') }}" + loop_control: + label: "{{ item.0.name }} {{ item.1.interface }}" + +- name: Every bridge ruleset is active + ansible.builtin.assert: + that: _bridge_firewall_v.stdout[1] is search('name ' ~ item) + fail_msg: "bridge ruleset {{ item }} is not active" + quiet: true + loop: >- + {{ (bridge_firewall_bridges | selectattr('prerouting', 'defined') | map(attribute='name') | map('regex_replace', '$', '-pre') | list) + + (bridge_firewall_bridges | selectattr('forward', 'defined') | map(attribute='name') | map('regex_replace', '$', '-fwd') | list) }} diff --git a/roles/bridge_firewall/templates/bridge_cli.j2 b/roles/bridge_firewall/templates/bridge_cli.j2 new file mode 100644 index 0000000..18cb1f3 --- /dev/null +++ b/roles/bridge_firewall/templates/bridge_cli.j2 @@ -0,0 +1,72 @@ +{#- Bridges, interface groups and the bridge firewall: no vyos.vyos resource + module covers these yet. Rule numbers for the jump rules follow the page. -#} +{%- macro bridge_rule(prefix, r) -%} +{{ prefix }} rule {{ r.number }} action '{{ r.action | default('accept') }}' +{% if r.description is defined %} +{{ prefix }} rule {{ r.number }} description '{{ r.description }}' +{% endif %} +{% for k in ['ethernet_type', 'protocol'] if r[k] is defined %} +{{ prefix }} rule {{ r.number }} {{ k | replace('_', '-') }} '{{ r[k] }}' +{% endfor %} +{% for side in ['source', 'destination'] if r[side] is defined %} +{% for k in ['address', 'port', 'mac_address'] if r[side][k] is defined %} +{{ prefix }} rule {{ r.number }} {{ side }} {{ k | replace('_', '-') }} '{{ r[side][k] }}' +{% endfor %} +{% endfor %} +{% if r.inbound_interface is defined %} +{{ prefix }} rule {{ r.number }} inbound-interface name '{{ r.inbound_interface }}' +{% endif %} +{% for s in r.state | default([]) %} +{{ prefix }} rule {{ r.number }} state '{{ s }}' +{% endfor %} +{% if r.log | default(false) | bool %} +{{ prefix }} rule {{ r.number }} log +{% endif %} +{%- endmacro -%} +{% for b in bridge_firewall_bridges %} +{% set i = loop.index %} +{% set grp = b.name ~ '-ifaces' %} +{% if b.description is defined %} +set interfaces bridge {{ b.name }} description '{{ b.description }}' +{% endif %} +{% for a in b.addresses | default([]) %} +set interfaces bridge {{ b.name }} address '{{ a }}' +{% endfor %} +{% for m in b.members %} +set interfaces bridge {{ b.name }} member interface {{ m.interface }} +{% endfor %} +set firewall group interface-group {{ grp }} interface '{{ b.name }}' +{% for m in b.members %} +set firewall group interface-group {{ grp }} interface '{{ m.interface }}' +{% endfor %} +{% if b.prerouting is defined %} +set firewall bridge prerouting filter rule {{ i * 10 }} action 'jump' +set firewall bridge prerouting filter rule {{ i * 10 }} description '{{ b.name }} traffic' +set firewall bridge prerouting filter rule {{ i * 10 }} inbound-interface group '{{ grp }}' +set firewall bridge prerouting filter rule {{ i * 10 }} jump-target '{{ b.name }}-pre' +set firewall bridge name {{ b.name }}-pre default-action '{{ b.prerouting.default_action | default('accept') }}' +{% for r in b.prerouting.rules | default([]) %} +{{ bridge_rule('set firewall bridge name ' ~ b.name ~ '-pre', r) }} +{% endfor %} +{% endif %} +{% if b.forward is defined %} +set firewall bridge forward filter rule {{ 100 + i * 10 }} action 'jump' +set firewall bridge forward filter rule {{ 100 + i * 10 }} description '{{ b.name }} traffic' +set firewall bridge forward filter rule {{ 100 + i * 10 }} inbound-interface group '{{ grp }}' +set firewall bridge forward filter rule {{ 100 + i * 10 }} jump-target '{{ b.name }}-fwd' +set firewall bridge name {{ b.name }}-fwd default-action '{{ b.forward.default_action | default('drop') }}' +{% for r in b.forward.rules | default([]) %} +{{ bridge_rule('set firewall bridge name ' ~ b.name ~ '-fwd', r) }} +{% endfor %} +{% endif %} +{% endfor %} +{% if bridge_firewall_bridges | selectattr('forward', 'defined') | list %} +{% if bridge_firewall_state_rules | bool %} +set firewall bridge forward filter rule 5 action 'accept' +set firewall bridge forward filter rule 5 state 'established' +set firewall bridge forward filter rule 5 state 'related' +set firewall bridge forward filter rule 10 action 'drop' +set firewall bridge forward filter rule 10 state 'invalid' +{% endif %} +set firewall bridge forward filter default-action '{{ bridge_firewall_bridge_forward_default_action }}' +{% endif %} diff --git a/roles/bridge_firewall/templates/ipv4_rules.yml.j2 b/roles/bridge_firewall/templates/ipv4_rules.yml.j2 new file mode 100644 index 0000000..dec5520 --- /dev/null +++ b/roles/bridge_firewall/templates/ipv4_rules.yml.j2 @@ -0,0 +1,54 @@ +{#- IPv4 firewall for routed traffic and router access, via vyos_firewall_rules. -#} +{% set routed = bridge_firewall_bridges | selectattr('ip_forward', 'defined') | list %} +{% set access = bridge_firewall_bridges | selectattr('router_access', 'defined') | list %} +- afi: ipv4 + rule_sets: +{% if access %} + - filter: input + rules: +{% if bridge_firewall_state_rules | bool %} + - {number: 10, action: accept, state: {established: true, related: true}} + - {number: 20, action: drop, state: {invalid: true}} +{% endif %} +{% for b in access %} + - number: {{ 100 + loop.index * 10 }} + action: {{ 'accept' if b.router_access == 'accept' else 'drop' }} + description: {{ ((b.router_access == 'accept') | ternary('Accept access from ', 'Deny access from ') ~ b.name) | to_json }} + inbound_interface: {group: {{ (b.name ~ '-ifaces') | to_json }}} +{% endfor %} +{% endif %} +{% if routed %} + - filter: forward + default_action: {{ bridge_firewall_ipv4_forward_default_action }} + rules: +{% if bridge_firewall_state_rules | bool %} + - {number: 5, action: accept, state: {established: true, related: true}} + - {number: 10, action: drop, state: {invalid: true}} +{% endif %} +{% for b in routed %} + - number: {{ 100 + loop.index * 10 }} + action: jump + description: {{ (b.name ~ ' traffic') | to_json }} + inbound_interface: {group: {{ (b.name ~ '-ifaces') | to_json }}} + jump_target: {{ ('ip-' ~ b.name ~ '-fwd') | to_json }} +{% endfor %} +{% for b in routed %} + - name: {{ ('ip-' ~ b.name ~ '-fwd') | to_json }} + default_action: {{ b.ip_forward.default_action | default('drop') }} +{% if b.ip_forward.rules | default([]) %} + rules: +{% for r in b.ip_forward.rules %} + - number: {{ r.number | int }} + action: {{ r.action | default('accept') }} +{% if r.description is defined %} + description: {{ r.description | to_json }} +{% endif %} +{% if r.outbound_interface is defined %} + outbound_interface: {name: {{ r.outbound_interface | to_json }}} +{% elif r.outbound_bridge is defined %} + outbound_interface: {group: {{ (r.outbound_bridge ~ '-ifaces') | to_json }}} +{% endif %} +{% endfor %} +{% endif %} +{% endfor %} +{% endif %} diff --git a/roles/bridge_firewall/vars/main.yml b/roles/bridge_firewall/vars/main.yml new file mode 100644 index 0000000..7299574 --- /dev/null +++ b/roles/bridge_firewall/vars/main.yml @@ -0,0 +1,11 @@ +--- +_bridge_firewall_state: "{{ 'rendered' if (vyos_blueprints_render_only | default(false) | bool) else 'merged' }}" +_bridge_firewall_cli_lines: "{{ (lookup('ansible.builtin.template', 'bridge_cli.j2') or '').splitlines() | map('trim') | select | list }}" +_bridge_firewall_member_descriptions: >- + {%- set out = [] -%} + {%- for b in bridge_firewall_bridges -%} + {%- for m in b.members if m.description is defined -%} + {%- set _ = out.append({'name': m.interface, 'description': m.description}) -%} + {%- endfor -%} + {%- endfor -%} + {{ out }} diff --git a/roles/firewall/defaults/main.yml b/roles/firewall/defaults/main.yml new file mode 100644 index 0000000..d5bec3f --- /dev/null +++ b/roles/firewall/defaults/main.yml @@ -0,0 +1,4 @@ +--- +firewall_groups: {} +firewall_ipv4: {} +firewall_ipv6: {} diff --git a/roles/firewall/meta/argument_specs.yml b/roles/firewall/meta/argument_specs.yml new file mode 100644 index 0000000..d25fe5d --- /dev/null +++ b/roles/firewall/meta/argument_specs.yml @@ -0,0 +1,77 @@ +--- +argument_specs: + main: + short_description: Firewall groups and base-chain filters + description: + - Configures firewall network, address and port groups and the + C(input), C(forward) and C(output) filters for IPv4 and IPv6. + - Building block used by blueprints such as "Policy-Based Site-to-Site + VPN and Firewall Configuration"; for zone-based designs use + C(vyos.blueprints.zone_firewall). + - Rules use the rule format of vyos.vyos.vyos_firewall_rules (number, + action, description, protocol, state, source, destination, + inbound_interface, outbound_interface, log, ...), passed through unchanged. + - B(Lockout risk) - a C(drop) input filter must allow the address or + interface you manage the router from. + options: + firewall_groups: + type: dict + default: {} + description: Firewall groups. + options: + network: + type: list + elements: dict + default: [] + description: IPv4 network groups. + options: + name: {type: str, required: true, description: Group name.} + description: {type: str, description: Group description.} + networks: {type: list, elements: str, required: true, description: Member networks.} + address: + type: list + elements: dict + default: [] + description: IPv4 address groups. + options: + name: {type: str, required: true, description: Group name.} + description: {type: str, description: Group description.} + addresses: {type: list, elements: str, required: true, description: Member addresses or ranges.} + port: + type: list + elements: dict + default: [] + description: Port groups. + options: + name: {type: str, required: true, description: Group name.} + description: {type: str, description: Group description.} + ports: {type: list, elements: str, required: true, description: Member ports or ranges.} + firewall_ipv4: + type: dict + default: {} + description: IPv4 filters keyed by base chain (C(input), C(forward), C(output)). + options: &filters + input: &filter + type: dict + description: Filter for this chain. + options: + default_action: {type: str, choices: [accept, drop, reject], description: Default action.} + default_log: {type: bool, default: false, description: Log packets hitting the default action.} + rules: {type: list, elements: dict, default: [], description: Rules in vyos_firewall_rules format.} + forward: *filter + output: *filter + firewall_ipv6: + type: dict + default: {} + description: IPv6 filters keyed by base chain. + options: *filters + 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 firewall role + description: Run with C(tasks_from=verify). Checks every configured filter is active with its rules. + options: + firewall_ipv4: {type: dict, default: {}, description: Same value as for C(main).} + firewall_ipv6: {type: dict, default: {}, description: Same value as for C(main).} diff --git a/roles/firewall/meta/main.yml b/roles/firewall/meta/main.yml new file mode 100644 index 0000000..8c38572 --- /dev/null +++ b/roles/firewall/meta/main.yml @@ -0,0 +1,11 @@ +--- +galaxy_info: + author: VyOS maintainers and contributors + description: Stateful firewall - groups and IPv4/IPv6 input, forward and output filters + license: GPL-3.0-or-later + min_ansible_version: "2.16" + platforms: + - name: GenericLinux + versions: [all] + galaxy_tags: [vyos, networking, firewall] +dependencies: [] diff --git a/roles/firewall/tasks/main.yml b/roles/firewall/tasks/main.yml new file mode 100644 index 0000000..fe99b15 --- /dev/null +++ b/roles/firewall/tasks/main.yml @@ -0,0 +1,22 @@ +--- +- name: Create firewall groups + vyos.vyos.vyos_firewall_global: + config: "{{ lookup('ansible.builtin.template', 'groups.yml.j2') | from_yaml }}" + state: "{{ _firewall_state }}" + register: _firewall_r_groups + when: firewall_groups | dict2items | map(attribute='value') | select | list | length > 0 + +- name: Configure the input, forward and output filters + vyos.vyos.vyos_firewall_rules: + config: "{{ lookup('ansible.builtin.template', 'rules.yml.j2') | from_yaml }}" + state: "{{ _firewall_state }}" + register: _firewall_r_rules + when: (firewall_ipv4 | length > 0) or (firewall_ipv6 | length > 0) + +- 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([]) + + ([_firewall_r_groups, _firewall_r_rules] + | selectattr('rendered', 'defined') | map(attribute='rendered') | flatten) }} + when: vyos_blueprints_render_only | default(false) | bool diff --git a/roles/firewall/tasks/verify.yml b/roles/firewall/tasks/verify.yml new file mode 100644 index 0000000..c2e3281 --- /dev/null +++ b/roles/firewall/tasks/verify.yml @@ -0,0 +1,23 @@ +--- +- name: Read the firewall + vyos.vyos.vyos_command: + commands: + - show firewall + register: _firewall_v + +- name: Every configured filter is active with its rules + ansible.builtin.assert: + that: >- + _firewall_v.stdout[0] is search((item.afi | upper | replace('V', 'v')) ~ ' Firewall "' ~ item.chain ~ ' filter"') + fail_msg: "{{ item.afi }} {{ item.chain }} filter is not active" + quiet: true + loop: >- + {%- set out = [] -%} + {%- for afi, filters in [('ipv4', firewall_ipv4), ('ipv6', firewall_ipv6)] -%} + {%- for chain in ['input', 'forward', 'output'] if filters[chain] is defined and filters[chain] -%} + {%- set _ = out.append({'afi': afi, 'chain': chain}) -%} + {%- endfor -%} + {%- endfor -%} + {{ out }} + loop_control: + label: "{{ item.afi }} {{ item.chain }}" diff --git a/roles/firewall/templates/groups.yml.j2 b/roles/firewall/templates/groups.yml.j2 new file mode 100644 index 0000000..5641e2a --- /dev/null +++ b/roles/firewall/templates/groups.yml.j2 @@ -0,0 +1,42 @@ +group: +{% if firewall_groups.network | default([]) %} + network_group: +{% for g in firewall_groups.network %} + - name: {{ g.name | to_json }} + afi: ipv4 +{% if g.description is defined %} + description: {{ g.description | to_json }} +{% endif %} + members: +{% for n in g.networks %} + - address: {{ n | to_json }} +{% endfor %} +{% endfor %} +{% endif %} +{% if firewall_groups.address | default([]) %} + address_group: +{% for g in firewall_groups.address %} + - name: {{ g.name | to_json }} + afi: ipv4 +{% if g.description is defined %} + description: {{ g.description | to_json }} +{% endif %} + members: +{% for a in g.addresses %} + - address: {{ a | to_json }} +{% endfor %} +{% endfor %} +{% endif %} +{% if firewall_groups.port | default([]) %} + port_group: +{% for g in firewall_groups.port %} + - name: {{ g.name | to_json }} +{% if g.description is defined %} + description: {{ g.description | to_json }} +{% endif %} + members: +{% for p in g.ports %} + - port: {{ p | string | to_json }} +{% endfor %} +{% endfor %} +{% endif %} diff --git a/roles/firewall/templates/rules.yml.j2 b/roles/firewall/templates/rules.yml.j2 new file mode 100644 index 0000000..f746c84 --- /dev/null +++ b/roles/firewall/templates/rules.yml.j2 @@ -0,0 +1,17 @@ +{% for afi, filters in [('ipv4', firewall_ipv4), ('ipv6', firewall_ipv6)] if filters %} +- afi: {{ afi }} + rule_sets: +{% for chain in _firewall_filters if filters[chain] is defined and filters[chain] %} +{% set f = filters[chain] %} + - filter: {{ chain }} +{% if f.default_action is defined %} + default_action: {{ f.default_action }} +{% endif %} +{% if f.default_log | default(false) | bool %} + enable_default_log: true +{% endif %} +{% if f.rules | default([]) %} + rules: {{ f.rules | to_json }} +{% endif %} +{% endfor %} +{% endfor %} diff --git a/roles/firewall/vars/main.yml b/roles/firewall/vars/main.yml new file mode 100644 index 0000000..eebd515 --- /dev/null +++ b/roles/firewall/vars/main.yml @@ -0,0 +1,3 @@ +--- +_firewall_state: "{{ 'rendered' if (vyos_blueprints_render_only | default(false) | bool) else 'merged' }}" +_firewall_filters: [input, forward, output] diff --git a/roles/gre_tunnel/defaults/main.yml b/roles/gre_tunnel/defaults/main.yml new file mode 100644 index 0000000..2bb0e43 --- /dev/null +++ b/roles/gre_tunnel/defaults/main.yml @@ -0,0 +1,2 @@ +--- +gre_tunnel_interfaces: [] diff --git a/roles/gre_tunnel/meta/argument_specs.yml b/roles/gre_tunnel/meta/argument_specs.yml new file mode 100644 index 0000000..7916ceb --- /dev/null +++ b/roles/gre_tunnel/meta/argument_specs.yml @@ -0,0 +1,37 @@ +--- +argument_specs: + main: + short_description: Tunnel interfaces (GRE and other encapsulations) + description: + - Creates C(interfaces tunnel) entries with their encapsulation, + endpoints, MTU and TCP MSS clamping, and addresses them. + - Building block used by "Site-to-Site IPSec VPN to Cisco using FlexVPN" + (GRE protected by IPsec); also usable for plain GRE. + - The tunnel itself is created with vyos.vyos.vyos_config in one commit, + because vyos_interfaces cannot set encapsulation or endpoints; + addresses use vyos_l3_interfaces. + options: + gre_tunnel_interfaces: + type: list + elements: dict + required: true + description: Tunnel interfaces. + options: + name: {type: str, required: true, description: "Interface name, e.g. C(tun1)."} + encapsulation: {type: str, default: gre, description: "Encapsulation, e.g. C(gre), C(gretap), C(ipip)."} + source_address: {type: str, description: Local endpoint address.} + source_interface: {type: str, description: Local endpoint interface (instead of C(source_address)).} + remote: {type: str, description: Remote endpoint address.} + mtu: {type: int, description: Interface MTU.} + adjust_mss: {type: str, description: "TCP MSS clamping value, e.g. C(1336) or C(clamp-mss-to-pmtu)."} + description: {type: str, description: Interface description.} + addresses: {type: list, elements: str, default: [], description: IPv4 addresses in CIDR notation.} + 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 gre_tunnel role + description: Run with C(tasks_from=verify). Checks every tunnel interface is up. + options: + gre_tunnel_interfaces: {type: list, elements: dict, required: true, description: Same value as for C(main).} diff --git a/roles/gre_tunnel/meta/main.yml b/roles/gre_tunnel/meta/main.yml new file mode 100644 index 0000000..e4408b4 --- /dev/null +++ b/roles/gre_tunnel/meta/main.yml @@ -0,0 +1,11 @@ +--- +galaxy_info: + author: VyOS maintainers and contributors + description: GRE (and other) tunnel interfaces + license: GPL-3.0-or-later + min_ansible_version: "2.16" + platforms: + - name: GenericLinux + versions: [all] + galaxy_tags: [vyos, networking, gre, tunnel] +dependencies: [] diff --git a/roles/gre_tunnel/tasks/main.yml b/roles/gre_tunnel/tasks/main.yml new file mode 100644 index 0000000..5f8a90a --- /dev/null +++ b/roles/gre_tunnel/tasks/main.yml @@ -0,0 +1,20 @@ +--- +- name: Create the tunnel interfaces + # vyos_interfaces cannot set encapsulation or endpoints, and a tunnel must + # be created with them in one commit, so this goes through vyos_config. + vyos.vyos.vyos_config: + lines: "{{ _gre_tunnel_cli_lines }}" + register: _gre_tunnel_r_cli + when: not (vyos_blueprints_render_only | default(false) | bool) + +- name: Address the tunnel interfaces + vyos.vyos.vyos_l3_interfaces: + config: "{{ _gre_tunnel_l3 }}" + state: "{{ _gre_tunnel_state }}" + register: _gre_tunnel_r_l3 + when: _gre_tunnel_l3 | length > 0 + +- 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([]) + _gre_tunnel_cli_lines + (_gre_tunnel_r_l3.rendered | default([])) }}" + when: vyos_blueprints_render_only | default(false) | bool diff --git a/roles/gre_tunnel/tasks/verify.yml b/roles/gre_tunnel/tasks/verify.yml new file mode 100644 index 0000000..52cb45f --- /dev/null +++ b/roles/gre_tunnel/tasks/verify.yml @@ -0,0 +1,15 @@ +--- +- name: Read interfaces + vyos.vyos.vyos_command: + commands: + - show interfaces + register: _gre_tunnel_v + +- name: Every tunnel interface is up + ansible.builtin.assert: + that: _gre_tunnel_v.stdout[0] is search('(?m)^' ~ (item.name | regex_escape) ~ '\s.*\bu/u\b') + fail_msg: "tunnel {{ item.name }} is not up" + quiet: true + loop: "{{ gre_tunnel_interfaces }}" + loop_control: + label: "{{ item.name }}" diff --git a/roles/gre_tunnel/templates/tunnels.j2 b/roles/gre_tunnel/templates/tunnels.j2 new file mode 100644 index 0000000..fb23487 --- /dev/null +++ b/roles/gre_tunnel/templates/tunnels.j2 @@ -0,0 +1,22 @@ +{% for t in gre_tunnel_interfaces %} +{% set b = 'set interfaces tunnel ' ~ t.name %} +{{ b }} encapsulation '{{ t.encapsulation | default('gre') }}' +{% if t.description is defined %} +{{ b }} description '{{ t.description }}' +{% endif %} +{% if t.adjust_mss is defined %} +{{ b }} ip adjust-mss '{{ t.adjust_mss }}' +{% endif %} +{% if t.mtu is defined %} +{{ b }} mtu '{{ t.mtu }}' +{% endif %} +{% if t.remote is defined %} +{{ b }} remote '{{ t.remote }}' +{% endif %} +{% if t.source_address is defined %} +{{ b }} source-address '{{ t.source_address }}' +{% endif %} +{% if t.source_interface is defined %} +{{ b }} source-interface '{{ t.source_interface }}' +{% endif %} +{% endfor %} diff --git a/roles/gre_tunnel/vars/main.yml b/roles/gre_tunnel/vars/main.yml new file mode 100644 index 0000000..87be41e --- /dev/null +++ b/roles/gre_tunnel/vars/main.yml @@ -0,0 +1,11 @@ +--- +_gre_tunnel_state: "{{ 'rendered' if (vyos_blueprints_render_only | default(false) | bool) else 'merged' }}" +_gre_tunnel_cli_lines: "{{ (lookup('ansible.builtin.template', 'tunnels.j2') or '').splitlines() | select | list }}" +_gre_tunnel_l3: >- + {%- set out = [] -%} + {%- for t in gre_tunnel_interfaces if t.addresses | default([]) -%} + {%- set addrs = [] -%} + {%- for a in t.addresses -%}{%- set _ = addrs.append({'address': a}) -%}{%- endfor -%} + {%- set _ = out.append({'name': t.name, 'ipv4': addrs}) -%} + {%- endfor -%} + {{ out }} diff --git a/roles/ha_vrrp/tasks/main.yml b/roles/ha_vrrp/tasks/main.yml index 5df97b9..7f0b692 100644 --- a/roles/ha_vrrp/tasks/main.yml +++ b/roles/ha_vrrp/tasks/main.yml @@ -21,7 +21,7 @@ vyos.vyos.vyos_config: lines: "{{ _ha_vrrp_conntrack_lines }}" vars: - _ha_vrrp_conntrack_lines: "{{ lookup('ansible.builtin.template', 'conntrack_sync.j2').splitlines() | select | list }}" + _ha_vrrp_conntrack_lines: "{{ (lookup('ansible.builtin.template', 'conntrack_sync.j2') or '').splitlines() | select | list }}" register: _ha_vrrp_r_conntrack when: - _ha_vrrp_cs | length > 0 @@ -32,6 +32,6 @@ vyos_blueprints_rendered: >- {{ vyos_blueprints_rendered | default([]) + _ha_vrrp_r_vrrp.rendered | default([]) - + ((lookup('ansible.builtin.template', 'conntrack_sync.j2').splitlines() | select | list) + + (((lookup('ansible.builtin.template', 'conntrack_sync.j2') or '').splitlines() | select | list) if _ha_vrrp_cs | length > 0 else []) }} when: vyos_blueprints_render_only | default(false) | bool diff --git a/roles/ipsec_policy_based/defaults/main.yml b/roles/ipsec_policy_based/defaults/main.yml new file mode 100644 index 0000000..eeb3961 --- /dev/null +++ b/roles/ipsec_policy_based/defaults/main.yml @@ -0,0 +1,19 @@ +--- +ipsec_policy_based_ike_group: + name: IKE-GROUP + key_exchange: ikev2 + lifetime: 28800 + dh_group: 14 + encryption: aes256 + hash: sha256 + close_action: start + dead_peer_detection: {action: restart, interval: 10, timeout: 30} +ipsec_policy_based_esp_group: + name: ESP-GROUP + lifetime: 3600 + pfs: dh-group14 + encryption: aes256 + hash: sha256 +ipsec_policy_based_default_gateway: "" +ipsec_policy_based_interfaces: [] +ipsec_policy_based_options: {} diff --git a/roles/ipsec_policy_based/meta/argument_specs.yml b/roles/ipsec_policy_based/meta/argument_specs.yml new file mode 100644 index 0000000..48d3341 --- /dev/null +++ b/roles/ipsec_policy_based/meta/argument_specs.yml @@ -0,0 +1,145 @@ +--- +argument_specs: + main: + short_description: Policy-based site-to-site IPsec (docs blueprint "Policy-based Site-to-Site VPN IPsec between VyOS and Cisco") + description: + - Configures one IKE group, one ESP group, a pre-shared key per peer and + site-to-site peers whose tunnels are defined by traffic selectors + (local and remote prefixes). Optionally adds a default route towards + the WAN gateway. + - Uses vyos.vyos resource modules only (vyos_vpn_ipsec, vyos_vpn_ipsec_s2s, + vyos_static_routes). + - The far end can be any IKE peer; match its proposals and mirror the + traffic selectors there. + - WAN and LAN addressing belongs to C(vyos.blueprints.base). + options: + ipsec_policy_based_peers: + type: list + elements: dict + required: true + description: Remote peers. + options: + name: + type: str + required: true + description: Peer name, e.g. C(CISCO). + local_address: + type: str + required: true + description: Local WAN address used for IKE. + remote_address: + type: str + required: true + description: Peer's WAN address. + local_id: + type: str + description: Local IKE id. Defaults to C(local_address). + remote_id: + type: str + description: Remote IKE id. Defaults to C(remote_address). + psk: + type: str + required: true + no_log: true + description: Pre-shared key. + psk_type: + type: str + choices: [plaintext, base64, hex] + description: Encoding of C(psk). Not set when omitted (VyOS default, plaintext). + psk_name: + type: str + description: Name of the PSK entry. Defaults to C(<name>-PSK). + connection_type: + type: str + choices: [initiate, trap, none] + default: initiate + description: C(initiate) brings the tunnels up from this side; C(none) only responds. + virtual_address: + type: str + description: Request a virtual IP from the peer, e.g. C(0.0.0.0) for FlexVPN. + authentication_ids: + type: bool + default: true + description: Set C(local-id)/C(remote-id) on the peer (the PSK ids are always set). + tunnels: + type: list + elements: dict + required: true + description: Traffic selectors, one IPsec SA each. + options: + id: + type: int + description: Tunnel number. Defaults to the position in the list (1, 2, ...). + local_prefix: + type: str + required: true + description: Local network, e.g. C(192.168.0.0/24). + remote_prefix: + type: str + required: true + description: Remote network, e.g. C(192.168.10.0/24). + protocol: + type: str + description: Protect only this protocol, e.g. C(gre) for GRE over IPsec. + ipsec_policy_based_ike_group: + type: dict + description: + - IKE (phase 1) settings shared by all peers. + - Settings without a default here are configured only when given. + options: + name: {type: str, default: IKE-GROUP, description: Group name.} + proposal_id: {type: int, default: 10, description: Proposal number.} + key_exchange: {type: str, choices: [ikev1, ikev2], default: ikev2, description: IKE version.} + lifetime: {type: int, description: Lifetime in seconds.} + dh_group: {type: int, default: 14, description: Diffie-Hellman group.} + encryption: {type: str, default: aes256, description: "Encryption, e.g. C(aes128)."} + hash: {type: str, default: sha256, description: "Hash, e.g. C(sha1)."} + close_action: {type: str, choices: [none, trap, start], description: Action when the peer closes the SA.} + dead_peer_detection: + type: dict + description: DPD settings; only the keys given are configured. + options: + action: {type: str, choices: [trap, clear, restart], description: DPD action.} + interval: {type: int, description: Interval in seconds.} + timeout: {type: int, description: Timeout in seconds.} + ipsec_policy_based_esp_group: + type: dict + description: + - ESP (phase 2) settings shared by all peers. + - Settings without a default here are configured only when given. + options: + name: {type: str, default: ESP-GROUP, description: Group name.} + proposal_id: {type: int, default: 10, description: Proposal number.} + mode: {type: str, choices: [tunnel, transport], description: ESP mode.} + lifetime: {type: int, description: Lifetime in seconds.} + pfs: {type: str, description: PFS group or C(disable).} + encryption: {type: str, default: aes256, description: Encryption.} + hash: {type: str, default: sha256, description: Hash.} + ipsec_policy_based_interfaces: + type: list + elements: str + default: [] + description: Interfaces IPsec listens on (C(vpn ipsec interface)), e.g. C([eth0]). + ipsec_policy_based_options: + type: dict + default: {} + description: + - C(vpn ipsec options), passed through as the C(options) of vyos.vyos.vyos_vpn_ipsec. + - "For FlexVPN, e.g. C({flexvpn: true, virtual_ip: true, interface: tun1, disable_route_autoinstall: true})." + ipsec_policy_based_default_gateway: + type: str + default: "" + description: Adds C(0.0.0.0/0) via this next hop (the WAN gateway). + 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 ipsec_policy_based role + description: Run with C(tasks_from=verify). Checks the IPsec SA of every tunnel of every peer is up. + options: + ipsec_policy_based_peers: + type: list + elements: dict + required: true + description: Same value as for C(main). diff --git a/roles/ipsec_policy_based/meta/main.yml b/roles/ipsec_policy_based/meta/main.yml new file mode 100644 index 0000000..316e8e1 --- /dev/null +++ b/roles/ipsec_policy_based/meta/main.yml @@ -0,0 +1,11 @@ +--- +galaxy_info: + author: VyOS maintainers and contributors + description: Policy-based site-to-site IPsec with traffic selectors + license: GPL-3.0-or-later + min_ansible_version: "2.16" + platforms: + - name: GenericLinux + versions: [all] + galaxy_tags: [vyos, networking, ipsec, vpn] +dependencies: [] diff --git a/roles/ipsec_policy_based/tasks/main.yml b/roles/ipsec_policy_based/tasks/main.yml new file mode 100644 index 0000000..c9c7329 --- /dev/null +++ b/roles/ipsec_policy_based/tasks/main.yml @@ -0,0 +1,34 @@ +--- +- name: Configure IKE/ESP groups and pre-shared keys + vyos.vyos.vyos_vpn_ipsec: + config: "{{ lookup('ansible.builtin.template', 'vpn_ipsec.yml.j2') | from_yaml }}" + state: "{{ _ipsec_policy_based_state }}" + register: _ipsec_policy_based_r_ipsec + no_log: true + +- name: Configure the site-to-site peers and their traffic selectors + vyos.vyos.vyos_vpn_ipsec_s2s: + config: "{{ lookup('ansible.builtin.template', 'vpn_ipsec_s2s.yml.j2') | from_yaml }}" + state: "{{ _ipsec_policy_based_state }}" + register: _ipsec_policy_based_r_s2s + +- name: Add the default route towards the WAN gateway + vyos.vyos.vyos_static_routes: + config: + - address_families: + - afi: ipv4 + routes: + - dest: 0.0.0.0/0 + next_hops: + - forward_router_address: "{{ ipsec_policy_based_default_gateway }}" + state: "{{ _ipsec_policy_based_state }}" + register: _ipsec_policy_based_r_route + when: ipsec_policy_based_default_gateway | length > 0 + +- 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([]) + + ([_ipsec_policy_based_r_ipsec, _ipsec_policy_based_r_s2s, _ipsec_policy_based_r_route] + | selectattr('rendered', 'defined') | map(attribute='rendered') | flatten) }} + when: vyos_blueprints_render_only | default(false) | bool diff --git a/roles/ipsec_policy_based/tasks/verify.yml b/roles/ipsec_policy_based/tasks/verify.yml new file mode 100644 index 0000000..ec85d8d --- /dev/null +++ b/roles/ipsec_policy_based/tasks/verify.yml @@ -0,0 +1,23 @@ +--- +- name: Read IPsec SAs + vyos.vyos.vyos_command: + commands: + - show vpn ipsec sa + register: _ipsec_policy_based_v_sa + +- name: The IPsec SA of every tunnel is up + ansible.builtin.assert: + that: _ipsec_policy_based_v_sa.stdout[0] is search('(?m)^' ~ (item | regex_escape) ~ '\s+up\b') + fail_msg: "IPsec SA {{ item }} is not up" + quiet: true + loop: "{{ _ipsec_policy_based_sa_names }}" + vars: + # SA names as VyOS shows them: <peer>-tunnel-<id> + _ipsec_policy_based_sa_names: >- + {%- set out = [] -%} + {%- for p in ipsec_policy_based_peers -%} + {%- for t in p.tunnels -%} + {%- set _ = out.append(p.name ~ '-tunnel-' ~ (t.id | default(loop.index))) -%} + {%- endfor -%} + {%- endfor -%} + {{ out }} diff --git a/roles/ipsec_policy_based/templates/vpn_ipsec.yml.j2 b/roles/ipsec_policy_based/templates/vpn_ipsec.yml.j2 new file mode 100644 index 0000000..faa824e --- /dev/null +++ b/roles/ipsec_policy_based/templates/vpn_ipsec.yml.j2 @@ -0,0 +1,58 @@ +{#- Optional settings are rendered only when given, so a page that leaves + something at the VyOS default (lifetime, PFS, DPD, ...) renders as written. -#} +{% set ike = ipsec_policy_based_ike_group %} +{% set esp = ipsec_policy_based_esp_group %} +{% set dpd = ike.dead_peer_detection | default({}, true) %} +ike_group: + - name: {{ ike.name | default('IKE-GROUP') | to_json }} + key_exchange: {{ ike.key_exchange | default('ikev2') }} +{% for k in ['lifetime'] if ike[k] is defined and ike[k] is not none %} + {{ k }}: {{ ike[k] | int }} +{% endfor %} +{% if ike.close_action is defined and ike.close_action %} + close_action: {{ ike.close_action }} +{% endif %} +{% if dpd %} + dead_peer_detection: +{% for k in ['action', 'interval', 'timeout'] if dpd[k] is defined and dpd[k] is not none %} + {{ k }}: {{ dpd[k] }} +{% endfor %} +{% endif %} + proposal: + - proposal_id: {{ ike.proposal_id | default(10) | int }} + dh_group: {{ ike.dh_group | default(14) | int }} + encryption: {{ ike.encryption | default('aes256') | to_json }} + hash: {{ ike.hash | default('sha256') | to_json }} +esp_group: + - name: {{ esp.name | default('ESP-GROUP') | to_json }} +{% if esp.mode is defined and esp.mode %} + mode: {{ esp.mode }} +{% endif %} +{% if esp.lifetime is defined and esp.lifetime is not none %} + lifetime: {{ esp.lifetime | int }} +{% endif %} +{% if esp.pfs is defined and esp.pfs %} + pfs: {{ esp.pfs | to_json }} +{% endif %} + proposal: + - proposal_id: {{ esp.proposal_id | default(10) | int }} + encryption: {{ esp.encryption | default('aes256') | to_json }} + hash: {{ esp.hash | default('sha256') | to_json }} +authentication: + psk: +{% for p in ipsec_policy_based_peers %} + - name: {{ p.psk_name | default(p.name ~ '-PSK') | to_json }} + id: + - {{ p.local_id | default(p.local_address) | to_json }} + - {{ p.remote_id | default(p.remote_address) | to_json }} + secret: {{ p.psk | to_json }} +{% if p.psk_type is defined %} + secret_type: {{ p.psk_type }} +{% endif %} +{% endfor %} +{% if ipsec_policy_based_interfaces %} +interface: {{ ipsec_policy_based_interfaces | to_json }} +{% endif %} +{% if ipsec_policy_based_options %} +options: {{ ipsec_policy_based_options | to_json }} +{% endif %} diff --git a/roles/ipsec_policy_based/templates/vpn_ipsec_s2s.yml.j2 b/roles/ipsec_policy_based/templates/vpn_ipsec_s2s.yml.j2 new file mode 100644 index 0000000..cb2277c --- /dev/null +++ b/roles/ipsec_policy_based/templates/vpn_ipsec_s2s.yml.j2 @@ -0,0 +1,29 @@ +peer: +{% for p in ipsec_policy_based_peers %} + - name: {{ p.name | to_json }} + authentication: + mode: pre-shared-secret +{% if p.authentication_ids | default(true) | bool %} + local_id: {{ p.local_id | default(p.local_address) | to_json }} + remote_id: {{ p.remote_id | default(p.remote_address) | to_json }} +{% endif %} + connection_type: {{ p.connection_type | default('initiate') }} + default_esp_group: {{ ipsec_policy_based_esp_group.name | default('ESP-GROUP') | to_json }} + ike_group: {{ ipsec_policy_based_ike_group.name | default('IKE-GROUP') | to_json }} + local_address: {{ p.local_address | to_json }} + remote_address: [{{ p.remote_address | to_json }}] +{% if p.virtual_address is defined %} + virtual_address: [{{ p.virtual_address | to_json }}] +{% endif %} + tunnel: +{% for t in p.tunnels %} + - tunnel_id: {{ t.id | default(loop.index) | int }} + local: + prefix: [{{ t.local_prefix | to_json }}] + remote: + prefix: [{{ t.remote_prefix | to_json }}] +{% if t.protocol is defined %} + protocol: {{ t.protocol | to_json }} +{% endif %} +{% endfor %} +{% endfor %} diff --git a/roles/ipsec_policy_based/vars/main.yml b/roles/ipsec_policy_based/vars/main.yml new file mode 100644 index 0000000..f077122 --- /dev/null +++ b/roles/ipsec_policy_based/vars/main.yml @@ -0,0 +1,2 @@ +--- +_ipsec_policy_based_state: "{{ 'rendered' if (vyos_blueprints_render_only | default(false) | bool) else 'merged' }}" diff --git a/roles/ipsec_route_based/defaults/main.yml b/roles/ipsec_route_based/defaults/main.yml new file mode 100644 index 0000000..238cd54 --- /dev/null +++ b/roles/ipsec_route_based/defaults/main.yml @@ -0,0 +1,22 @@ +--- +ipsec_route_based_ike_group: + name: IKE-GROUP + key_exchange: ikev2 + lifetime: 28800 + dh_group: 14 + encryption: aes256 + hash: sha256 + close_action: start + dead_peer_detection: {action: restart, interval: 10, timeout: 30} +ipsec_route_based_esp_group: + name: ESP-GROUP + lifetime: 3600 + pfs: dh-group14 + encryption: aes256 + hash: sha256 +ipsec_route_based_ospf: null +ipsec_route_based_default_gateway: "" +ipsec_route_based_interfaces: [] +ipsec_route_based_disable_route_autoinstall: true +ipsec_route_based_interface_routes: [] +ipsec_route_based_bgp: null diff --git a/roles/ipsec_route_based/meta/argument_specs.yml b/roles/ipsec_route_based/meta/argument_specs.yml new file mode 100644 index 0000000..f683376 --- /dev/null +++ b/roles/ipsec_route_based/meta/argument_specs.yml @@ -0,0 +1,209 @@ +--- +argument_specs: + main: + short_description: Route-based site-to-site IPsec over VTI, with OSPF or BGP (Cisco, Palo Alto and Azure docs blueprints) + description: + - Configures one IKE group, one ESP group, a pre-shared key and a + site-to-site peer bound to a VTI per peer, with + C(disable-route-autoinstall) as route-based VPNs need. Optionally runs + OSPF over the VTIs and adds a default route towards the WAN gateway. + - Uses vyos.vyos resource modules only (vyos_vpn_ipsec, vyos_vpn_ipsec_s2s, + vyos_interfaces, vyos_l3_interfaces, vyos_ospfv2, vyos_ospf_interfaces, + vyos_static_routes). + - The far end can be any IKE peer (Cisco, Palo Alto, another VyOS, a cloud + gateway); match its proposals in the IKE/ESP group inputs. + - WAN and LAN addressing belongs to C(vyos.blueprints.base). + options: + ipsec_route_based_peers: + type: list + elements: dict + required: true + description: Remote peers, one VTI each. + options: + name: + type: str + required: true + description: Peer name, e.g. C(CISCO). + local_address: + type: str + required: true + description: Local WAN address used for IKE. + remote_address: + type: str + required: true + description: Peer's WAN address. + local_id: + type: str + description: Local IKE id. Defaults to C(local_address). + remote_id: + type: str + description: Remote IKE id. Defaults to C(remote_address). + psk: + type: str + required: true + no_log: true + description: Pre-shared key. + psk_type: + type: str + choices: [plaintext, base64, hex] + description: Encoding of C(psk). Not set when omitted (VyOS default, plaintext). + psk_name: + type: str + description: + - Name of the PSK entry. Defaults to C(<name>-PSK). + - Peers with the same C(psk_name) share one entry carrying all their ids (they must use the same C(psk)). + connection_type: + type: str + choices: [initiate, trap, none] + default: initiate + description: + - C(initiate) brings the tunnel up from this side (recommended by + the page when the far end only initiates on traffic); C(none) + only responds. + description: + type: str + description: Peer description. + ikev2_reauth: + type: str + choices: ['yes', 'no', inherit] + description: Peer-level IKEv2 re-authentication. + esp_group_on_vti: + type: bool + default: false + description: Bind the ESP group on the VTI (C(vti esp-group)) instead of C(default-esp-group), as Azure and GCP pages do. + vti: + type: dict + required: true + description: Tunnel interface for this peer. + options: + interface: + type: str + required: true + description: VTI name, e.g. C(vti1). + address: + type: str + required: true + description: Tunnel address in CIDR notation. + mtu: + type: int + description: VTI MTU, e.g. C(1438). + description: + type: str + description: VTI description. + adjust_mss: + type: str + description: TCP MSS clamping, e.g. C(1350) (configured with vyos_config; vyos_interfaces has no option for it). + ipsec_route_based_ike_group: + type: dict + description: + - IKE (phase 1) settings shared by all peers. + - Settings without a default here are configured only when given. + options: + name: {type: str, default: IKE-GROUP, description: Group name.} + proposal_id: {type: int, default: 10, description: Proposal number.} + key_exchange: {type: str, choices: [ikev1, ikev2], default: ikev2, description: IKE version.} + ikev2_reauth: {type: bool, default: false, description: Re-authenticate on IKEv2 rekey.} + lifetime: {type: int, description: Lifetime in seconds.} + dh_group: {type: int, default: 14, description: Diffie-Hellman group.} + encryption: {type: str, default: aes256, description: "Encryption, e.g. C(aes128)."} + hash: {type: str, default: sha256, description: "Hash, e.g. C(sha1)."} + close_action: {type: str, choices: [none, trap, start], description: Action when the peer closes the SA.} + dead_peer_detection: + type: dict + description: DPD settings; only the keys given are configured. + options: + action: {type: str, choices: [trap, clear, restart], description: DPD action.} + interval: {type: int, description: Interval in seconds.} + timeout: {type: int, description: Timeout in seconds.} + ipsec_route_based_esp_group: + type: dict + description: + - ESP (phase 2) settings shared by all peers. + - Settings without a default here are configured only when given. + options: + name: {type: str, default: ESP-GROUP, description: Group name.} + proposal_id: {type: int, default: 10, description: Proposal number.} + mode: {type: str, choices: [tunnel, transport], description: ESP mode.} + lifetime: {type: int, description: Lifetime in seconds.} + pfs: {type: str, description: PFS group or C(disable).} + encryption: {type: str, default: aes256, description: Encryption.} + hash: {type: str, default: sha256, description: Hash.} + ipsec_route_based_ospf: + type: dict + description: OSPF over the VTIs. Omit to configure routing yourself. + options: + router_id: {type: str, required: true, description: OSPF router-id.} + area: {type: str, default: "0", description: OSPF area.} + networks: + type: list + elements: str + required: true + description: Networks to advertise, including the VTI network(s), as on the docs page. + passive_interfaces: + type: list + elements: str + default: [] + description: LAN interfaces that should not form adjacencies. + ipsec_route_based_interfaces: + type: list + elements: str + default: [] + description: Interfaces IPsec listens on (C(vpn ipsec interface)), e.g. C([eth0]). + ipsec_route_based_disable_route_autoinstall: + type: bool + default: true + description: Set C(vpn ipsec options disable-route-autoinstall) (most route-based setups need it). + ipsec_route_based_interface_routes: + type: list + elements: dict + default: [] + description: Static routes out of a VTI, e.g. to the peer's BGP listener. + options: + dest: {type: str, required: true, description: "Destination prefix, e.g. C(10.0.0.4/32)."} + interface: {type: str, required: true, description: "VTI, e.g. C(vti1)."} + ipsec_route_based_bgp: + type: dict + description: eBGP over the VTIs. Omit to configure routing yourself. + options: + asn: {type: int, required: true, description: Local AS number.} + router_id: {type: str, description: BGP router-id.} + networks: {type: list, elements: str, default: [], description: IPv4 prefixes to announce.} + neighbors: + type: list + elements: dict + required: true + description: BGP neighbours reached through the tunnels. + options: + address: {type: str, required: true, description: Neighbour address.} + remote_as: {type: int, required: true, description: Neighbour AS number.} + holdtime: {type: int, description: Hold time in seconds.} + keepalive: {type: int, description: Keepalive in seconds.} + disable_connected_check: {type: bool, default: true, description: Needed when the neighbour is not on the VTI subnet (cloud BGP listeners).} + ebgp_multihop: {type: int, description: eBGP multihop TTL.} + update_source: {type: str, description: Source address or interface.} + soft_reconfiguration_inbound: {type: bool, default: false, description: Keep received routes for soft reconfiguration.} + ipsec_route_based_default_gateway: + type: str + default: "" + description: Adds C(0.0.0.0/0) via this next hop (the WAN gateway). + 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 ipsec_route_based role + description: + - Run with C(tasks_from=verify). Checks the IPsec SA of every peer is up and, + with OSPF, a Full adjacency on every VTI or, with BGP, every session established. + options: + ipsec_route_based_peers: + type: list + elements: dict + required: true + description: Same value as for C(main). + ipsec_route_based_ospf: + type: dict + description: Same value as for C(main). + ipsec_route_based_bgp: + type: dict + description: Same value as for C(main). diff --git a/roles/ipsec_route_based/meta/main.yml b/roles/ipsec_route_based/meta/main.yml new file mode 100644 index 0000000..95831bc --- /dev/null +++ b/roles/ipsec_route_based/meta/main.yml @@ -0,0 +1,11 @@ +--- +galaxy_info: + author: VyOS maintainers and contributors + description: Route-based site-to-site IPsec over VTI, with optional OSPF inside the tunnel + license: GPL-3.0-or-later + min_ansible_version: "2.16" + platforms: + - name: GenericLinux + versions: [all] + galaxy_tags: [vyos, networking, ipsec, vpn] +dependencies: [] diff --git a/roles/ipsec_route_based/tasks/main.yml b/roles/ipsec_route_based/tasks/main.yml new file mode 100644 index 0000000..a3b4c16 --- /dev/null +++ b/roles/ipsec_route_based/tasks/main.yml @@ -0,0 +1,113 @@ +--- +- name: Peers sharing a PSK entry use the same key + ansible.builtin.assert: + that: >- + ipsec_route_based_peers | selectattr('psk_name', 'defined') | selectattr('psk_name', 'equalto', _ipsec_route_based_psk_name) + | map(attribute='psk') | unique | length == 1 + fail_msg: "peers with psk_name {{ _ipsec_route_based_psk_name }} have different psk values" + quiet: true + loop: "{{ ipsec_route_based_peers | selectattr('psk_name', 'defined') | map(attribute='psk_name') | unique | list }}" + loop_control: + loop_var: _ipsec_route_based_psk_name + no_log: true + +- name: Create the VTIs + vyos.vyos.vyos_interfaces: + config: "{{ lookup('ansible.builtin.template', 'vti.yml.j2') | from_yaml }}" + state: "{{ _ipsec_route_based_state }}" + register: _ipsec_route_based_r_vti + +- name: Clamp TCP MSS on the VTIs + # vyos_interfaces has no option for ip adjust-mss. + vyos.vyos.vyos_config: + lines: "{{ _ipsec_route_based_cli_lines }}" + register: _ipsec_route_based_r_cli + when: + - _ipsec_route_based_cli_lines | length > 0 + - not (vyos_blueprints_render_only | default(false) | bool) + +- name: Address the VTIs + vyos.vyos.vyos_l3_interfaces: + config: "{{ lookup('ansible.builtin.template', 'vti_l3.yml.j2') | from_yaml }}" + state: "{{ _ipsec_route_based_state }}" + register: _ipsec_route_based_r_vti_l3 + +- name: Configure IKE/ESP groups, pre-shared keys and options + vyos.vyos.vyos_vpn_ipsec: + config: "{{ lookup('ansible.builtin.template', 'vpn_ipsec.yml.j2') | from_yaml }}" + state: "{{ _ipsec_route_based_state }}" + register: _ipsec_route_based_r_ipsec + no_log: true + +- name: Configure the site-to-site peers + vyos.vyos.vyos_vpn_ipsec_s2s: + config: "{{ lookup('ansible.builtin.template', 'vpn_ipsec_s2s.yml.j2') | from_yaml }}" + state: "{{ _ipsec_route_based_state }}" + register: _ipsec_route_based_r_s2s + +- name: Add the default route towards the WAN gateway + vyos.vyos.vyos_static_routes: + config: + - address_families: + - afi: ipv4 + routes: + - dest: 0.0.0.0/0 + next_hops: + - forward_router_address: "{{ ipsec_route_based_default_gateway }}" + state: "{{ _ipsec_route_based_state }}" + register: _ipsec_route_based_r_route + when: ipsec_route_based_default_gateway | length > 0 + +- name: Add interface routes through the VTIs (e.g. to the peer's BGP listener) + vyos.vyos.vyos_static_routes: + config: + - address_families: + - afi: ipv4 + routes: "{{ lookup('ansible.builtin.template', 'interface_routes.yml.j2') | from_yaml }}" + state: "{{ _ipsec_route_based_state }}" + register: _ipsec_route_based_r_ifroutes + when: ipsec_route_based_interface_routes | length > 0 + +- name: Run BGP over the VTIs + vyos.vyos.vyos_bgp_global: + config: "{{ lookup('ansible.builtin.template', 'bgp_global.yml.j2') | from_yaml }}" + state: "{{ _ipsec_route_based_state }}" + register: _ipsec_route_based_r_bgp + when: _ipsec_route_based_bgp | length > 0 + +- name: Set BGP address-family options (networks, soft-reconfiguration) + vyos.vyos.vyos_bgp_address_family: + config: "{{ _ipsec_route_based_bgp_af }}" + state: "{{ _ipsec_route_based_state }}" + vars: + _ipsec_route_based_bgp_af: "{{ lookup('ansible.builtin.template', 'bgp_address_family.yml.j2') | from_yaml }}" + register: _ipsec_route_based_r_bgp_af + when: + - _ipsec_route_based_bgp | length > 0 + - _ipsec_route_based_bgp_af | length > 1 + +- name: Run OSPF over the VTIs + vyos.vyos.vyos_ospfv2: + config: "{{ lookup('ansible.builtin.template', 'ospfv2.yml.j2') | from_yaml }}" + state: "{{ _ipsec_route_based_state }}" + register: _ipsec_route_based_r_ospf + when: _ipsec_route_based_ospf | length > 0 + +- name: Set OSPF interface options (point-to-point VTIs, passive LANs) + vyos.vyos.vyos_ospf_interfaces: + config: "{{ lookup('ansible.builtin.template', 'ospf_interfaces.yml.j2') | from_yaml }}" + state: "{{ _ipsec_route_based_state }}" + register: _ipsec_route_based_r_ospf_if + when: _ipsec_route_based_ospf | length > 0 + +- 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([]) + + (_ipsec_route_based_r_vti.rendered | default([])) + + _ipsec_route_based_cli_lines + + ([_ipsec_route_based_r_vti_l3, _ipsec_route_based_r_ipsec, _ipsec_route_based_r_s2s, + _ipsec_route_based_r_route, _ipsec_route_based_r_ifroutes, _ipsec_route_based_r_bgp, + _ipsec_route_based_r_bgp_af, _ipsec_route_based_r_ospf, _ipsec_route_based_r_ospf_if] + | selectattr('rendered', 'defined') | map(attribute='rendered') | flatten | unique) }} + when: vyos_blueprints_render_only | default(false) | bool diff --git a/roles/ipsec_route_based/tasks/verify.yml b/roles/ipsec_route_based/tasks/verify.yml new file mode 100644 index 0000000..42edd24 --- /dev/null +++ b/roles/ipsec_route_based/tasks/verify.yml @@ -0,0 +1,52 @@ +--- +- name: Read IPsec SAs + vyos.vyos.vyos_command: + commands: + - show vpn ipsec sa + register: _ipsec_route_based_v_sa + +- name: The IPsec SA of every peer is up + ansible.builtin.assert: + that: _ipsec_route_based_v_sa.stdout[0] is search('(?m)^' ~ (item.name | regex_escape) ~ '-vti\s+up\b') + fail_msg: "IPsec SA {{ item.name }}-vti is not up" + quiet: true + loop: "{{ ipsec_route_based_peers }}" + loop_control: + label: "{{ item.name }}" + +- name: Read OSPF neighbours + vyos.vyos.vyos_command: + commands: + - show ip ospf neighbor + register: _ipsec_route_based_v_ospf + when: ipsec_route_based_ospf | default({}, true) | length > 0 + +- name: A Full OSPF adjacency on every VTI + ansible.builtin.assert: + that: _ipsec_route_based_v_ospf.stdout[0] is search('Full.*\s' ~ (item.vti.interface | regex_escape) ~ ':') + fail_msg: "no Full OSPF adjacency on {{ item.vti.interface }}" + quiet: true + loop: "{{ ipsec_route_based_peers }}" + loop_control: + label: "{{ item.vti.interface }}" + when: ipsec_route_based_ospf | default({}, true) | length > 0 + +- name: Read BGP neighbours + vyos.vyos.vyos_command: + commands: + - show bgp summary + register: _ipsec_route_based_v_bgp + when: ipsec_route_based_bgp | default({}, true) | length > 0 + +- name: Every BGP session is established + # An established neighbour shows its Up/Down time followed by a prefix count. + ansible.builtin.assert: + that: >- + _ipsec_route_based_v_bgp.stdout[0] is search('(?m)^' ~ (item.address | regex_escape) + ~ '\s.*\s(\d{2}:\d{2}:\d{2}|\d+[dwh]\S*)\s+\d+\b') + fail_msg: "BGP session to {{ item.address }} is not established" + quiet: true + loop: "{{ (ipsec_route_based_bgp | default({}, true)).neighbors | default([]) }}" + loop_control: + label: "{{ item.address }}" + when: ipsec_route_based_bgp | default({}, true) | length > 0 diff --git a/roles/ipsec_route_based/templates/bgp_address_family.yml.j2 b/roles/ipsec_route_based/templates/bgp_address_family.yml.j2 new file mode 100644 index 0000000..97e88cd --- /dev/null +++ b/roles/ipsec_route_based/templates/bgp_address_family.yml.j2 @@ -0,0 +1,20 @@ +{% set b = _ipsec_route_based_bgp %} +as_number: {{ b.asn | int }} +{% if b.networks | default([]) %} +address_family: + - afi: ipv4 + networks: +{% for n in b.networks %} + - prefix: {{ n | to_json }} +{% endfor %} +{% endif %} +{% set soft = b.neighbors | selectattr('soft_reconfiguration_inbound', 'defined') | selectattr('soft_reconfiguration_inbound') | list %} +{% if soft %} +neighbors: +{% for n in soft %} + - neighbor_address: {{ n.address | to_json }} + address_family: + - afi: ipv4 + soft_reconfiguration: true +{% endfor %} +{% endif %} diff --git a/roles/ipsec_route_based/templates/bgp_global.yml.j2 b/roles/ipsec_route_based/templates/bgp_global.yml.j2 new file mode 100644 index 0000000..f4bcf33 --- /dev/null +++ b/roles/ipsec_route_based/templates/bgp_global.yml.j2 @@ -0,0 +1,29 @@ +{% set b = _ipsec_route_based_bgp %} +as_number: {{ b.asn | int }} +{% if b.router_id is defined %} +bgp_params: + router_id: {{ b.router_id | to_json }} +{% endif %} +neighbor: +{% for n in b.neighbors %} + - address: {{ n.address | to_json }} + remote_as: {{ n.remote_as | int }} +{% if n.holdtime is defined or n.keepalive is defined %} + timers: +{% if n.holdtime is defined %} + holdtime: {{ n.holdtime | int }} +{% endif %} +{% if n.keepalive is defined %} + keepalive: {{ n.keepalive | int }} +{% endif %} +{% endif %} +{% if n.disable_connected_check | default(true) | bool %} + disable_connected_check: true +{% endif %} +{% if n.ebgp_multihop is defined %} + ebgp_multihop: {{ n.ebgp_multihop | int }} +{% endif %} +{% if n.update_source is defined %} + update_source: {{ n.update_source | to_json }} +{% endif %} +{% endfor %} diff --git a/roles/ipsec_route_based/templates/interface_routes.yml.j2 b/roles/ipsec_route_based/templates/interface_routes.yml.j2 new file mode 100644 index 0000000..8039e19 --- /dev/null +++ b/roles/ipsec_route_based/templates/interface_routes.yml.j2 @@ -0,0 +1,5 @@ +{% for r in ipsec_route_based_interface_routes %} +- dest: {{ r.dest | to_json }} + next_hops: + - interface: {{ r.interface | to_json }} +{% endfor %} diff --git a/roles/ipsec_route_based/templates/ospf_interfaces.yml.j2 b/roles/ipsec_route_based/templates/ospf_interfaces.yml.j2 new file mode 100644 index 0000000..09452ea --- /dev/null +++ b/roles/ipsec_route_based/templates/ospf_interfaces.yml.j2 @@ -0,0 +1,12 @@ +{% for i in _ipsec_route_based_ospf.passive_interfaces | default([]) %} +- name: {{ i }} + address_family: + - afi: ipv4 + passive: true +{% endfor %} +{% for p in ipsec_route_based_peers %} +- name: {{ p.vti.interface }} + address_family: + - afi: ipv4 + network: point-to-point +{% endfor %} diff --git a/roles/ipsec_route_based/templates/ospfv2.yml.j2 b/roles/ipsec_route_based/templates/ospfv2.yml.j2 new file mode 100644 index 0000000..93ee2ff --- /dev/null +++ b/roles/ipsec_route_based/templates/ospfv2.yml.j2 @@ -0,0 +1,9 @@ +{% set o = _ipsec_route_based_ospf %} +parameters: + router_id: {{ o.router_id | to_json }} +areas: + - area_id: {{ o.area | default('0') | string | to_json }} + network: +{% for n in o.networks %} + - address: {{ n | to_json }} +{% endfor %} diff --git a/roles/ipsec_route_based/templates/vpn_ipsec.yml.j2 b/roles/ipsec_route_based/templates/vpn_ipsec.yml.j2 new file mode 100644 index 0000000..a9eab25 --- /dev/null +++ b/roles/ipsec_route_based/templates/vpn_ipsec.yml.j2 @@ -0,0 +1,51 @@ +{#- Optional settings are rendered only when given. -#} +{% set ike = ipsec_route_based_ike_group %} +{% set esp = ipsec_route_based_esp_group %} +{% set dpd = ike.dead_peer_detection | default({}, true) %} +ike_group: + - name: {{ ike.name | default('IKE-GROUP') | to_json }} + key_exchange: {{ ike.key_exchange | default('ikev2') }} +{% if ike.lifetime is defined and ike.lifetime is not none %} + lifetime: {{ ike.lifetime | int }} +{% endif %} +{% if ike.close_action is defined and ike.close_action %} + close_action: {{ ike.close_action }} +{% endif %} +{% if ike.ikev2_reauth | default(false) | bool %} + ikev2_reauth: true +{% endif %} +{% if dpd %} + dead_peer_detection: +{% for k in ['action', 'interval', 'timeout'] if dpd[k] is defined and dpd[k] is not none %} + {{ k }}: {{ dpd[k] }} +{% endfor %} +{% endif %} + proposal: + - proposal_id: {{ ike.proposal_id | default(10) | int }} + dh_group: {{ ike.dh_group | default(14) | int }} + encryption: {{ ike.encryption | default('aes256') | to_json }} + hash: {{ ike.hash | default('sha256') | to_json }} +esp_group: + - name: {{ esp.name | default('ESP-GROUP') | to_json }} +{% if esp.mode is defined and esp.mode %} + mode: {{ esp.mode }} +{% endif %} +{% if esp.lifetime is defined and esp.lifetime is not none %} + lifetime: {{ esp.lifetime | int }} +{% endif %} +{% if esp.pfs is defined and esp.pfs %} + pfs: {{ esp.pfs | to_json }} +{% endif %} + proposal: + - proposal_id: {{ esp.proposal_id | default(10) | int }} + encryption: {{ esp.encryption | default('aes256') | to_json }} + hash: {{ esp.hash | default('sha256') | to_json }} +authentication: + psk: {{ _ipsec_route_based_psks | to_json }} +{% if ipsec_route_based_interfaces %} +interface: {{ ipsec_route_based_interfaces | to_json }} +{% endif %} +{% if ipsec_route_based_disable_route_autoinstall | bool %} +options: + disable_route_autoinstall: true +{% endif %} diff --git a/roles/ipsec_route_based/templates/vpn_ipsec_s2s.yml.j2 b/roles/ipsec_route_based/templates/vpn_ipsec_s2s.yml.j2 new file mode 100644 index 0000000..2dba193 --- /dev/null +++ b/roles/ipsec_route_based/templates/vpn_ipsec_s2s.yml.j2 @@ -0,0 +1,27 @@ +{% set esp_name = ipsec_route_based_esp_group.name | default('ESP-GROUP') %} +peer: +{% for p in ipsec_route_based_peers %} + - name: {{ p.name | to_json }} +{% if p.description is defined %} + description: {{ p.description | to_json }} +{% endif %} + authentication: + mode: pre-shared-secret + local_id: {{ p.local_id | default(p.local_address) | to_json }} + remote_id: {{ p.remote_id | default(p.remote_address) | to_json }} + connection_type: {{ p.connection_type | default('initiate') }} +{% if not (p.esp_group_on_vti | default(false) | bool) %} + default_esp_group: {{ esp_name | to_json }} +{% endif %} + ike_group: {{ ipsec_route_based_ike_group.name | default('IKE-GROUP') | to_json }} +{% if p.ikev2_reauth is defined %} + ikev2_reauth: {{ p.ikev2_reauth | to_json }} +{% endif %} + local_address: {{ p.local_address | to_json }} + remote_address: [{{ p.remote_address | to_json }}] + vti: + bind: {{ p.vti.interface | to_json }} +{% if p.esp_group_on_vti | default(false) | bool %} + esp_group: {{ esp_name | to_json }} +{% endif %} +{% endfor %} diff --git a/roles/ipsec_route_based/templates/vti.yml.j2 b/roles/ipsec_route_based/templates/vti.yml.j2 new file mode 100644 index 0000000..2047055 --- /dev/null +++ b/roles/ipsec_route_based/templates/vti.yml.j2 @@ -0,0 +1,9 @@ +{% for p in ipsec_route_based_peers %} +- name: {{ p.vti.interface }} +{% if p.vti.mtu is defined %} + mtu: {{ p.vti.mtu | int }} +{% endif %} +{% if p.vti.description is defined %} + description: {{ p.vti.description | to_json }} +{% endif %} +{% endfor %} diff --git a/roles/ipsec_route_based/templates/vti_cli.j2 b/roles/ipsec_route_based/templates/vti_cli.j2 new file mode 100644 index 0000000..8dddeb0 --- /dev/null +++ b/roles/ipsec_route_based/templates/vti_cli.j2 @@ -0,0 +1,4 @@ +{# MSS clamping on VTIs: vyos_interfaces has no option for it. #} +{% for p in ipsec_route_based_peers if p.vti.adjust_mss is defined %} +set interfaces vti {{ p.vti.interface }} ip adjust-mss '{{ p.vti.adjust_mss }}' +{% endfor %} diff --git a/roles/ipsec_route_based/templates/vti_l3.yml.j2 b/roles/ipsec_route_based/templates/vti_l3.yml.j2 new file mode 100644 index 0000000..a54b5de --- /dev/null +++ b/roles/ipsec_route_based/templates/vti_l3.yml.j2 @@ -0,0 +1,5 @@ +{% for p in ipsec_route_based_peers %} +- name: {{ p.vti.interface }} + ipv4: + - address: {{ p.vti.address | to_json }} +{% endfor %} diff --git a/roles/ipsec_route_based/vars/main.yml b/roles/ipsec_route_based/vars/main.yml new file mode 100644 index 0000000..2db5c62 --- /dev/null +++ b/roles/ipsec_route_based/vars/main.yml @@ -0,0 +1,23 @@ +--- +_ipsec_route_based_state: "{{ 'rendered' if (vyos_blueprints_render_only | default(false) | bool) else 'merged' }}" +_ipsec_route_based_ospf: "{{ ipsec_route_based_ospf | default({}, true) }}" +_ipsec_route_based_bgp: "{{ ipsec_route_based_bgp | default({}, true) }}" +_ipsec_route_based_cli_lines: "{{ (lookup('ansible.builtin.template', 'vti_cli.j2') or '').splitlines() | select | list }}" +# One PSK entry per psk_name; peers sharing a psk_name contribute all their ids. +_ipsec_route_based_psks: >- + {%- set out = [] -%} + {%- set names = [] -%} + {%- for p in ipsec_route_based_peers -%} + {%- set n = p.psk_name | default(p.name ~ '-PSK') -%} + {%- set ids = [p.local_id | default(p.local_address), p.remote_id | default(p.remote_address)] -%} + {%- if n in names -%} + {%- set e = out[names.index(n)] -%} + {%- for i in ids if i not in e.id -%}{%- set _ = e.id.append(i) -%}{%- endfor -%} + {%- else -%} + {%- set e = {'name': n, 'id': ids | unique | list, 'secret': p.psk} -%} + {%- if p.psk_type is defined -%}{%- set _ = e.update({'secret_type': p.psk_type}) -%}{%- endif -%} + {%- set _ = names.append(n) -%} + {%- set _ = out.append(e) -%} + {%- endif -%} + {%- endfor -%} + {{ out }} diff --git a/roles/nat/defaults/main.yml b/roles/nat/defaults/main.yml new file mode 100644 index 0000000..6ba6f0a --- /dev/null +++ b/roles/nat/defaults/main.yml @@ -0,0 +1,3 @@ +--- +nat_source_rules: [] +nat_destination_rules: [] diff --git a/roles/nat/meta/argument_specs.yml b/roles/nat/meta/argument_specs.yml new file mode 100644 index 0000000..43c260e --- /dev/null +++ b/roles/nat/meta/argument_specs.yml @@ -0,0 +1,33 @@ +--- +argument_specs: + main: + short_description: Source and destination NAT + description: + - Configures source NAT (masquerade, SNAT, exclusions such as + traffic into a VPN) and destination NAT (port forwarding). + - Rules use the rule format of vyos.vyos.vyos_nat (id, description, + protocol, exclude, outbound_interface / inbound_interface, source, + destination, translation, ...), passed through unchanged, so every + option of the module is available. + - For a simple small-office edge, C(vyos.blueprints.edge_nat) is shorter. + options: + nat_source_rules: + type: list + elements: dict + default: [] + description: "Source NAT rules in vyos_nat format, e.g. C({id: 20, outbound_interface: {name: eth0}, translation: {address: masquerade}})." + nat_destination_rules: + type: list + elements: dict + default: [] + description: Destination NAT rules in vyos_nat format. + 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 nat role + description: Run with C(tasks_from=verify). Checks every rule is installed. + options: + nat_source_rules: {type: list, elements: dict, default: [], description: Same value as for C(main).} + nat_destination_rules: {type: list, elements: dict, default: [], description: Same value as for C(main).} diff --git a/roles/nat/meta/main.yml b/roles/nat/meta/main.yml new file mode 100644 index 0000000..682f983 --- /dev/null +++ b/roles/nat/meta/main.yml @@ -0,0 +1,11 @@ +--- +galaxy_info: + author: VyOS maintainers and contributors + description: Source and destination NAT rules + license: GPL-3.0-or-later + min_ansible_version: "2.16" + platforms: + - name: GenericLinux + versions: [all] + galaxy_tags: [vyos, networking, nat] +dependencies: [] diff --git a/roles/nat/tasks/main.yml b/roles/nat/tasks/main.yml new file mode 100644 index 0000000..6f5007b --- /dev/null +++ b/roles/nat/tasks/main.yml @@ -0,0 +1,15 @@ +--- +- name: Configure source and destination NAT + vyos.vyos.vyos_nat: + config: + nat: >- + {{ ({'source': {'rule': nat_source_rules}} if nat_source_rules else {}) + | combine({'destination': {'rule': nat_destination_rules}} if nat_destination_rules else {}) }} + state: "{{ _nat_state }}" + register: _nat_r + when: (nat_source_rules | length > 0) or (nat_destination_rules | length > 0) + +- 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([]) + (_nat_r.rendered | default([])) }}" + when: vyos_blueprints_render_only | default(false) | bool diff --git a/roles/nat/tasks/verify.yml b/roles/nat/tasks/verify.yml new file mode 100644 index 0000000..a100d3a --- /dev/null +++ b/roles/nat/tasks/verify.yml @@ -0,0 +1,25 @@ +--- +- name: Read NAT rules + vyos.vyos.vyos_command: + commands: + - show nat source rules + - show nat destination rules + register: _nat_v + +- name: Every source NAT rule is installed + ansible.builtin.assert: + that: _nat_v.stdout[0] is search('(?m)^\s*' ~ item.id ~ '\b') + fail_msg: "source NAT rule {{ item.id }} is not installed" + quiet: true + loop: "{{ nat_source_rules }}" + loop_control: + label: "{{ item.id }}" + +- name: Every destination NAT rule is installed + ansible.builtin.assert: + that: _nat_v.stdout[1] is search('(?m)^\s*' ~ item.id ~ '\b') + fail_msg: "destination NAT rule {{ item.id }} is not installed" + quiet: true + loop: "{{ nat_destination_rules }}" + loop_control: + label: "{{ item.id }}" diff --git a/roles/nat/vars/main.yml b/roles/nat/vars/main.yml new file mode 100644 index 0000000..a354487 --- /dev/null +++ b/roles/nat/vars/main.yml @@ -0,0 +1,2 @@ +--- +_nat_state: "{{ 'rendered' if (vyos_blueprints_render_only | default(false) | bool) else 'merged' }}" diff --git a/roles/ospf_unnumbered/defaults/main.yml b/roles/ospf_unnumbered/defaults/main.yml new file mode 100644 index 0000000..b1ed553 --- /dev/null +++ b/roles/ospf_unnumbered/defaults/main.yml @@ -0,0 +1,6 @@ +--- +ospf_unnumbered_area: 0.0.0.0 +ospf_unnumbered_md5_key: "" +ospf_unnumbered_md5_key_id: 1 +ospf_unnumbered_redistribute: [connected] +ospf_unnumbered_group: "" diff --git a/roles/ospf_unnumbered/meta/argument_specs.yml b/roles/ospf_unnumbered/meta/argument_specs.yml new file mode 100644 index 0000000..c5c5407 --- /dev/null +++ b/roles/ospf_unnumbered/meta/argument_specs.yml @@ -0,0 +1,68 @@ +--- +argument_specs: + main: + short_description: OSPF unnumbered with ECMP (docs blueprint "OSPF unnumbered with ECMP") + description: + - Puts the router-id as a /32 on the loopback and on every listed link, + runs OSPF point-to-point over those links and redistributes connected + routes. Parallel links to the same neighbour give ECMP. + - Works for any number of routers; each router lists only its own links. + - LAN addressing (C(eth0) on the docs page) belongs to C(vyos.blueprints.base). + options: + ospf_unnumbered_router_id: + type: str + required: true + description: OSPF router-id; also used as the /32 address on C(lo) and on each link. + ospf_unnumbered_interfaces: + type: list + elements: str + required: true + description: Unnumbered point-to-point links, e.g. C([eth1, eth2]). + ospf_unnumbered_area: + type: str + default: 0.0.0.0 + description: OSPF area. + ospf_unnumbered_md5_key: + type: str + default: "" + no_log: true + description: MD5 key for OSPF authentication. Empty disables authentication. + ospf_unnumbered_md5_key_id: + type: int + default: 1 + description: MD5 key id. + ospf_unnumbered_redistribute: + type: list + elements: str + default: [connected] + description: Route types redistributed into OSPF. + ospf_unnumbered_group: + type: str + default: "" + description: Inventory group of all routers in the fabric; used only by C(verify). + 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 ospf_unnumbered role + description: + - Run with C(tasks_from=verify) against all routers in the same play. + - Checks one Full adjacency per listed link and, with + C(ospf_unnumbered_group), an OSPF route to every other router-id. + When the group has exactly two routers, also checks one ECMP next + hop per link. + options: + ospf_unnumbered_router_id: + type: str + required: true + description: Same value as for C(main). + ospf_unnumbered_interfaces: + type: list + elements: str + required: true + description: Same value as for C(main). + ospf_unnumbered_group: + type: str + default: "" + description: Same value as for C(main). diff --git a/roles/ospf_unnumbered/meta/main.yml b/roles/ospf_unnumbered/meta/main.yml new file mode 100644 index 0000000..c24d6e2 --- /dev/null +++ b/roles/ospf_unnumbered/meta/main.yml @@ -0,0 +1,11 @@ +--- +galaxy_info: + author: VyOS maintainers and contributors + description: OSPF over unnumbered point-to-point links with ECMP + license: GPL-3.0-or-later + min_ansible_version: "2.16" + platforms: + - name: GenericLinux + versions: [all] + galaxy_tags: [vyos, networking, ospf] +dependencies: [] diff --git a/roles/ospf_unnumbered/tasks/main.yml b/roles/ospf_unnumbered/tasks/main.yml new file mode 100644 index 0000000..1ac2c5c --- /dev/null +++ b/roles/ospf_unnumbered/tasks/main.yml @@ -0,0 +1,28 @@ +--- +- name: Put the router-id on the loopback and on every unnumbered link + vyos.vyos.vyos_l3_interfaces: + config: "{{ lookup('ansible.builtin.template', 'l3_interfaces.yml.j2') | from_yaml }}" + state: "{{ _ospf_unnumbered_state }}" + register: _ospf_unnumbered_r_l3 + +- name: Run OSPF point-to-point on the links + vyos.vyos.vyos_ospf_interfaces: + config: "{{ lookup('ansible.builtin.template', 'ospf_interfaces.yml.j2') | from_yaml }}" + state: "{{ _ospf_unnumbered_state }}" + register: _ospf_unnumbered_r_ifaces + no_log: "{{ ospf_unnumbered_md5_key | length > 0 }}" + +- name: Configure the OSPF process + vyos.vyos.vyos_ospfv2: + config: "{{ lookup('ansible.builtin.template', 'ospfv2.yml.j2') | from_yaml }}" + state: "{{ _ospf_unnumbered_state }}" + register: _ospf_unnumbered_r_ospf + +- 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([]) + + ([_ospf_unnumbered_r_l3, _ospf_unnumbered_r_ifaces, _ospf_unnumbered_r_ospf] + | selectattr('rendered', 'defined') | map(attribute='rendered') | flatten + | map('regex_replace', '(md5-key) \S+', "\1 '********'")) }} + when: vyos_blueprints_render_only | default(false) | bool diff --git a/roles/ospf_unnumbered/tasks/verify.yml b/roles/ospf_unnumbered/tasks/verify.yml new file mode 100644 index 0000000..50b96fd --- /dev/null +++ b/roles/ospf_unnumbered/tasks/verify.yml @@ -0,0 +1,42 @@ +--- +- name: Read OSPF neighbours + vyos.vyos.vyos_command: + commands: + - show ip ospf neighbor + register: _ospf_unnumbered_v_nbr + +- name: One Full adjacency per unnumbered link + ansible.builtin.assert: + that: _ospf_unnumbered_v_nbr.stdout[0] is search('Full.*\s' ~ item ~ ':') + fail_msg: "no Full OSPF adjacency on {{ item }}" + quiet: true + loop: "{{ ospf_unnumbered_interfaces }}" + +- name: Read routes to the other routers + vyos.vyos.vyos_command: + commands: "{{ _ospf_unnumbered_peers | map('regex_replace', '^(.*)$', 'show ip route \\1/32') | list }}" + vars: + _ospf_unnumbered_peers: >- + {{ groups[ospf_unnumbered_group] | difference([inventory_hostname]) + | map('extract', hostvars, 'ospf_unnumbered_router_id') | list }} + register: _ospf_unnumbered_v_routes + when: ospf_unnumbered_group | length > 0 + +- name: Every other router-id is reached via OSPF + ansible.builtin.assert: + that: item is search('Known via "ospf"') + fail_msg: "{{ item | regex_search('Routing entry for \\S+') | default('a peer', true) }} is not learned via OSPF" + quiet: true + loop: "{{ _ospf_unnumbered_v_routes.stdout | default([]) }}" + loop_control: + label: "{{ item | regex_search('Routing entry for \\S+') | default('route', true) }}" + when: ospf_unnumbered_group | length > 0 + +- name: With two routers, one ECMP next hop per link + ansible.builtin.assert: + that: _ospf_unnumbered_v_routes.stdout[0] | regex_findall('via \S+ onlink') | length == ospf_unnumbered_interfaces | length + fail_msg: "expected {{ ospf_unnumbered_interfaces | length }} ECMP next hops to the peer" + quiet: true + when: + - ospf_unnumbered_group | length > 0 + - groups[ospf_unnumbered_group] | length == 2 diff --git a/roles/ospf_unnumbered/templates/l3_interfaces.yml.j2 b/roles/ospf_unnumbered/templates/l3_interfaces.yml.j2 new file mode 100644 index 0000000..ccd1962 --- /dev/null +++ b/roles/ospf_unnumbered/templates/l3_interfaces.yml.j2 @@ -0,0 +1,8 @@ +- name: lo + ipv4: + - address: {{ _ospf_unnumbered_prefix | to_json }} +{% for i in ospf_unnumbered_interfaces %} +- name: {{ i }} + ipv4: + - address: {{ _ospf_unnumbered_prefix | to_json }} +{% endfor %} diff --git a/roles/ospf_unnumbered/templates/ospf_interfaces.yml.j2 b/roles/ospf_unnumbered/templates/ospf_interfaces.yml.j2 new file mode 100644 index 0000000..c119640 --- /dev/null +++ b/roles/ospf_unnumbered/templates/ospf_interfaces.yml.j2 @@ -0,0 +1,12 @@ +{% for i in ospf_unnumbered_interfaces %} +- name: {{ i }} + address_family: + - afi: ipv4 + network: point-to-point +{% if ospf_unnumbered_md5_key %} + authentication: + md5_key: + key_id: {{ ospf_unnumbered_md5_key_id | int }} + key: {{ ospf_unnumbered_md5_key | to_json }} +{% endif %} +{% endfor %} diff --git a/roles/ospf_unnumbered/templates/ospfv2.yml.j2 b/roles/ospf_unnumbered/templates/ospfv2.yml.j2 new file mode 100644 index 0000000..0b24f2b --- /dev/null +++ b/roles/ospf_unnumbered/templates/ospfv2.yml.j2 @@ -0,0 +1,15 @@ +parameters: + router_id: {{ ospf_unnumbered_router_id | to_json }} +areas: + - area_id: {{ ospf_unnumbered_area | string | to_json }} +{% if ospf_unnumbered_md5_key %} + authentication: md5 +{% endif %} + network: + - address: {{ _ospf_unnumbered_prefix | to_json }} +{% if ospf_unnumbered_redistribute %} +redistribute: +{% for r in ospf_unnumbered_redistribute %} + - route_type: {{ r }} +{% endfor %} +{% endif %} diff --git a/roles/ospf_unnumbered/vars/main.yml b/roles/ospf_unnumbered/vars/main.yml new file mode 100644 index 0000000..3bf0497 --- /dev/null +++ b/roles/ospf_unnumbered/vars/main.yml @@ -0,0 +1,3 @@ +--- +_ospf_unnumbered_state: "{{ 'rendered' if (vyos_blueprints_render_only | default(false) | bool) else 'merged' }}" +_ospf_unnumbered_prefix: "{{ ospf_unnumbered_router_id }}/32" diff --git a/roles/vrf_firewall/defaults/main.yml b/roles/vrf_firewall/defaults/main.yml new file mode 100644 index 0000000..204728e --- /dev/null +++ b/roles/vrf_firewall/defaults/main.yml @@ -0,0 +1,11 @@ +--- +vrf_firewall_bind_to_all: true +vrf_firewall_forward_rules: [] +vrf_firewall_forward_default_action: drop +vrf_firewall_input_rules: [] +vrf_firewall_input_default_action: drop +vrf_firewall_default_log: true +vrf_firewall_state_policy: + established: accept + related: accept + invalid: drop diff --git a/roles/vrf_firewall/meta/argument_specs.yml b/roles/vrf_firewall/meta/argument_specs.yml new file mode 100644 index 0000000..91f569d --- /dev/null +++ b/roles/vrf_firewall/meta/argument_specs.yml @@ -0,0 +1,139 @@ +--- +argument_specs: + main: + short_description: VRFs with route leaking and firewall (docs blueprint "VRF and firewall example") + description: + - Creates VRFs with their routing tables, puts interfaces (including + VLANs) into them, adds static routes including routes into another VRF + (route leaking), sets the global state policy and configures the IPv4 + forward and input filters. + - Interface addressing belongs to C(vyos.blueprints.base); PPPoE is not + configured here. + - VLAN, PPPoE and other non-ethernet VRF bindings and inter-VRF routes + use vyos.vyos.vyos_config, because vyos_interfaces and vyos_vrf cannot + express them yet. Everything else uses resource modules. + - In firewall rules, match an interface in a non-default VRF by the VRF + name for C(inbound_interface) and by the interface name for + C(outbound_interface), as the docs page explains. + - B(Lockout risk) - the input filter defaults to drop. Allow the + interface or VRF you manage the router through before running the role. + options: + vrf_firewall_vrfs: + type: list + elements: dict + required: true + description: VRFs. + options: + name: + type: str + required: true + description: VRF name, e.g. C(MGMT). + table: + type: int + required: true + description: Routing table id. + description: + type: str + description: VRF description. + interfaces: + type: list + elements: str + default: [] + description: Member interfaces, e.g. C(eth1), C(eth2.150), C(pppoe0). + routes: + type: list + elements: dict + default: [] + description: IPv4 static routes in this VRF. + options: + dest: + type: str + required: true + description: Destination prefix. + interface: + type: str + description: Outgoing interface. + vrf: + type: str + description: VRF the interface belongs to, for routes into another VRF. + next_hop: + type: str + description: Next-hop address (routes within this VRF only). + vrf_firewall_bind_to_all: + type: bool + default: true + description: Set C(vrf bind-to-all), as on the docs page. + vrf_firewall_forward_rules: + type: list + elements: dict + default: [] + description: Rules for traffic between VRFs (C(firewall ipv4 forward filter)). + options: &rule + number: + type: int + required: true + description: Rule number. + action: + type: str + choices: [accept, drop, reject] + default: accept + description: Rule action. + description: + type: str + description: Rule description. + inbound_interface: + type: str + description: Inbound interface or VRF name; wildcards like C(eth2*) allowed. + outbound_interface: + type: str + description: Outbound interface name; wildcards allowed. + protocol: + type: str + description: Protocol. + source: + type: dict + description: Source match (C(address), C(port)). + destination: + type: dict + description: Destination match (C(address), C(port)). + log: + type: bool + default: false + description: Log matches. + vrf_firewall_forward_default_action: + type: str + choices: [accept, drop, reject] + default: drop + description: Forward filter default action. + vrf_firewall_input_rules: + type: list + elements: dict + default: [] + description: Rules for traffic to the router itself (C(firewall ipv4 input filter)). + options: *rule + vrf_firewall_input_default_action: + type: str + choices: [accept, drop, reject] + default: drop + description: Input filter default action. + vrf_firewall_default_log: + type: bool + default: true + description: Log packets hitting the filters' default action. + vrf_firewall_state_policy: + type: dict + default: {established: accept, related: accept, invalid: drop} + description: Global state policy, connection type to action. + 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 vrf_firewall role + description: Run with C(tasks_from=verify). Checks every VRF exists with its member interfaces. + options: + vrf_firewall_vrfs: + type: list + elements: dict + required: true + description: Same value as for C(main). diff --git a/roles/vrf_firewall/meta/main.yml b/roles/vrf_firewall/meta/main.yml new file mode 100644 index 0000000..63f2c52 --- /dev/null +++ b/roles/vrf_firewall/meta/main.yml @@ -0,0 +1,11 @@ +--- +galaxy_info: + author: VyOS maintainers and contributors + description: VRFs with inter-VRF route leaking and forward/input firewall filters + license: GPL-3.0-or-later + min_ansible_version: "2.16" + platforms: + - name: GenericLinux + versions: [all] + galaxy_tags: [vyos, networking, vrf, firewall] +dependencies: [] diff --git a/roles/vrf_firewall/tasks/main.yml b/roles/vrf_firewall/tasks/main.yml new file mode 100644 index 0000000..2e38f04 --- /dev/null +++ b/roles/vrf_firewall/tasks/main.yml @@ -0,0 +1,60 @@ +--- +- name: Check interface names + ansible.builtin.assert: + that: item.1 is match('^(' ~ (_vrf_firewall_iftypes.keys() | join('|')) ~ ')\d+(\.\d+)?$') + fail_msg: >- + {{ item.1 }} (VRF {{ item.0.name }}) is not a supported interface name; + expected one of {{ _vrf_firewall_iftypes.keys() | join(', ') }} followed by a number, optionally .VLAN + quiet: true + loop: "{{ vrf_firewall_vrfs | subelements('interfaces', skip_missing=true) }}" + loop_control: + label: "{{ item.0.name }} {{ item.1 }}" + +- name: Create VRFs and their tables + vyos.vyos.vyos_vrf: + config: "{{ lookup('ansible.builtin.template', 'vrf.yml.j2') | from_yaml }}" + state: "{{ _vrf_firewall_state }}" + register: _vrf_firewall_r_vrf + +- name: Put ethernet interfaces into their VRFs + vyos.vyos.vyos_interfaces: + config: "{{ _vrf_firewall_eth_bindings }}" + state: "{{ _vrf_firewall_state }}" + register: _vrf_firewall_r_eth + when: _vrf_firewall_eth_bindings | length > 0 + +- name: Bind VLAN and other interfaces, add inter-VRF routes + # vyos_interfaces cannot set a VRF on a VLAN, and vyos_vrf static routes + # cannot point into another VRF, so these go through vyos_config. + vyos.vyos.vyos_config: + lines: "{{ _vrf_firewall_cli_lines }}" + register: _vrf_firewall_r_cli + when: + - _vrf_firewall_cli_lines | length > 0 + - not (vyos_blueprints_render_only | default(false) | bool) + +- name: Set the global state policy + vyos.vyos.vyos_firewall_global: + config: + state_policy: >- + {{ vrf_firewall_state_policy | dict2items(key_name='connection_type', value_name='action') }} + state: "{{ _vrf_firewall_state }}" + register: _vrf_firewall_r_state + when: vrf_firewall_state_policy | length > 0 + +- name: Configure the forward and input filters + vyos.vyos.vyos_firewall_rules: + config: "{{ lookup('ansible.builtin.template', 'firewall_rules.yml.j2') | from_yaml }}" + state: "{{ _vrf_firewall_state }}" + register: _vrf_firewall_r_fw + +- 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([]) + + ([_vrf_firewall_r_vrf, _vrf_firewall_r_eth] + | selectattr('rendered', 'defined') | map(attribute='rendered') | flatten) + + _vrf_firewall_cli_lines + + ([_vrf_firewall_r_state, _vrf_firewall_r_fw] + | selectattr('rendered', 'defined') | map(attribute='rendered') | flatten) }} + when: vyos_blueprints_render_only | default(false) | bool diff --git a/roles/vrf_firewall/tasks/verify.yml b/roles/vrf_firewall/tasks/verify.yml new file mode 100644 index 0000000..1732ced --- /dev/null +++ b/roles/vrf_firewall/tasks/verify.yml @@ -0,0 +1,26 @@ +--- +- name: Read VRFs + vyos.vyos.vyos_command: + commands: + - show vrf + register: _vrf_firewall_v + +- name: Every VRF exists + ansible.builtin.assert: + that: _vrf_firewall_v.stdout[0] is search('(?m)^\s*' ~ (item.name | regex_escape) ~ '\s') + fail_msg: "VRF {{ item.name }} is missing from 'show vrf'" + quiet: true + loop: "{{ vrf_firewall_vrfs }}" + loop_control: + label: "{{ item.name }}" + +- name: Every interface is in its VRF + ansible.builtin.assert: + that: >- + _vrf_firewall_v.stdout[0] is search('(?m)^\s*' ~ (item.0.name | regex_escape) + ~ '\s.*\b' ~ (item.1 | regex_escape) ~ '\b') + fail_msg: "{{ item.1 }} is not a member of VRF {{ item.0.name }}" + quiet: true + loop: "{{ vrf_firewall_vrfs | subelements('interfaces', skip_missing=true) }}" + loop_control: + label: "{{ item.0.name }} {{ item.1 }}" diff --git a/roles/vrf_firewall/templates/firewall_rules.yml.j2 b/roles/vrf_firewall/templates/firewall_rules.yml.j2 new file mode 100644 index 0000000..71f5ae0 --- /dev/null +++ b/roles/vrf_firewall/templates/firewall_rules.yml.j2 @@ -0,0 +1,45 @@ +{% macro rules(lst) %} +{% for r in lst %} + - 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: {{ r.protocol | to_json }} +{% endif %} +{% if r.log | default(false) | bool %} + log: enable +{% endif %} +{% for side in ['inbound_interface', 'outbound_interface'] if r[side] is defined %} + {{ side }}: + name: {{ r[side] | to_json }} +{% endfor %} +{% 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 %} +{% endmacro %} +- afi: ipv4 + rule_sets: + - filter: forward + default_action: {{ vrf_firewall_forward_default_action }} +{% if vrf_firewall_default_log | bool %} + enable_default_log: true +{% endif %} +{% if vrf_firewall_forward_rules %} + rules: +{{ rules(vrf_firewall_forward_rules) -}} +{% endif %} + - filter: input + default_action: {{ vrf_firewall_input_default_action }} +{% if vrf_firewall_default_log | bool %} + enable_default_log: true +{% endif %} +{% if vrf_firewall_input_rules %} + rules: +{{ rules(vrf_firewall_input_rules) -}} +{% endif %} diff --git a/roles/vrf_firewall/templates/vrf.yml.j2 b/roles/vrf_firewall/templates/vrf.yml.j2 new file mode 100644 index 0000000..808dde2 --- /dev/null +++ b/roles/vrf_firewall/templates/vrf.yml.j2 @@ -0,0 +1,23 @@ +bind_to_all: {{ vrf_firewall_bind_to_all | bool }} +instances: +{% for v in vrf_firewall_vrfs %} + - name: {{ v.name | to_json }} + table_id: {{ v.table | int }} +{% if v.description is defined %} + description: {{ v.description | to_json }} +{% endif %} +{% set plain = v.routes | default([]) | rejectattr('vrf', 'defined') | list %} +{% if plain %} + protocols: + static: + - address_families: + - afi: ipv4 + routes: +{% for r in plain %} + - dest: {{ r.dest | to_json }} + next_hops: + - {% if r.next_hop is defined %}forward_router_address: {{ r.next_hop | to_json }}{% else %}interface: {{ r.interface | to_json }}{% endif %} + +{% endfor %} +{% endif %} +{% endfor %} diff --git a/roles/vrf_firewall/templates/vrf_cli.j2 b/roles/vrf_firewall/templates/vrf_cli.j2 new file mode 100644 index 0000000..2470e4b --- /dev/null +++ b/roles/vrf_firewall/templates/vrf_cli.j2 @@ -0,0 +1,15 @@ +{# VRF bindings for non-plain-ethernet interfaces, and inter-VRF leak routes. #} +{% for v in vrf_firewall_vrfs %} +{% for i in v.interfaces | default([]) if i is not match('^eth\d+$') %} +{% set m = i | regex_search('^([a-z]+)(\d+)(?:\.(\d+))?$', '\\1', '\\2', '\\3') %} +{% set kind = _vrf_firewall_iftypes[m[0]] %} +{% if m[2] %} +set interfaces {{ kind }} {{ m[0] ~ m[1] }} vif {{ m[2] }} vrf '{{ v.name }}' +{% else %} +set interfaces {{ kind }} {{ i }} vrf '{{ v.name }}' +{% endif %} +{% endfor %} +{% for r in v.routes | default([]) if r.vrf is defined %} +set vrf name {{ v.name }} protocols static route {{ r.dest }} interface {{ r.interface }} vrf '{{ r.vrf }}' +{% endfor %} +{% endfor %} diff --git a/roles/vrf_firewall/vars/main.yml b/roles/vrf_firewall/vars/main.yml new file mode 100644 index 0000000..eb31c3d --- /dev/null +++ b/roles/vrf_firewall/vars/main.yml @@ -0,0 +1,24 @@ +--- +_vrf_firewall_state: "{{ 'rendered' if (vyos_blueprints_render_only | default(false) | bool) else 'merged' }}" +# Interface name prefix -> CLI interface type, for VRF bindings the resource +# modules cannot express (VLANs, PPPoE, tunnels, ...). +_vrf_firewall_iftypes: + eth: ethernet + bond: bonding + br: bridge + dum: dummy + pppoe: pppoe + vtun: openvpn + wg: wireguard + tun: tunnel + vti: vti +# Ethernet interfaces without a VLAN go through vyos_interfaces; the rest via CLI. +_vrf_firewall_eth_bindings: >- + {%- set out = [] -%} + {%- for v in vrf_firewall_vrfs -%} + {%- for i in v.interfaces | default([]) if i is match('^eth\\d+$') -%} + {%- set _ = out.append({'name': i, 'vrf': v.name}) -%} + {%- endfor -%} + {%- endfor -%} + {{ out }} +_vrf_firewall_cli_lines: "{{ (lookup('ansible.builtin.template', 'vrf_cli.j2') or '').splitlines() | select | list }}" 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 }} |
