diff options
65 files changed, 2075 insertions, 372 deletions
diff --git a/.github/workflows/package-smoketest.yml b/.github/workflows/package-smoketest.yml index 824cd64b1..0a8208b87 100644 --- a/.github/workflows/package-smoketest.yml +++ b/.github/workflows/package-smoketest.yml @@ -1,7 +1,7 @@ name: VyOS ISO integration Test on: - pull_request_target: + pull_request: branches: - current paths: diff --git a/.github/workflows/trigger-pr.yml b/.github/workflows/trigger-pr.yml index 0e28b460f..f88458a81 100644 --- a/.github/workflows/trigger-pr.yml +++ b/.github/workflows/trigger-pr.yml @@ -5,13 +5,13 @@ on: types: - closed branches: - - current - + - circinus + jobs: trigger-PR: uses: vyos/.github/.github/workflows/trigger-pr.yml@current with: - source_branch: 'current' + source_branch: 'circinus' target_branch: 'circinus' secrets: REMOTE_REPO: ${{ secrets.REMOTE_REPO }} diff --git a/data/templates/firewall/nftables-bridge.j2 b/data/templates/firewall/nftables-bridge.j2 index dec027bf9..1975fb9b0 100644 --- a/data/templates/firewall/nftables-bridge.j2 +++ b/data/templates/firewall/nftables-bridge.j2 @@ -1,9 +1,13 @@ +{% import 'firewall/nftables-defines.j2' as group_tmpl %} {% macro bridge(bridge) %} {% set ns = namespace(sets=[]) %} {% if bridge.forward is vyos_defined %} {% for prior, conf in bridge.forward.items() %} chain VYOS_FORWARD_{{ prior }} { type filter hook forward priority {{ prior }}; policy accept; +{% if global_options.state_policy is vyos_defined %} + jump VYOS_STATE_POLICY +{% endif %} {% if conf.rule is vyos_defined %} {% for rule_id, rule_conf in conf.rule.items() if rule_conf.disable is not vyos_defined %} {{ rule_conf | nft_rule('FWD', prior, rule_id, 'bri') }} @@ -17,6 +21,46 @@ {% endfor %} {% endif %} +{% if bridge.input is vyos_defined %} +{% for prior, conf in bridge.input.items() %} + chain VYOS_INPUT_{{ prior }} { + type filter hook input priority {{ prior }}; policy accept; +{% if global_options.state_policy is vyos_defined %} + jump VYOS_STATE_POLICY +{% endif %} +{% if conf.rule is vyos_defined %} +{% for rule_id, rule_conf in conf.rule.items() if rule_conf.disable is not vyos_defined %} + {{ rule_conf | nft_rule('INP', prior, rule_id, 'bri') }} +{% if rule_conf.recent is vyos_defined %} +{% set ns.sets = ns.sets + ['INP_' + prior + '_' + rule_id] %} +{% endif %} +{% endfor %} +{% endif %} + {{ conf | nft_default_rule('INP-filter', 'bri') }} + } +{% endfor %} +{% endif %} + +{% if bridge.output is vyos_defined %} +{% for prior, conf in bridge.output.items() %} + chain VYOS_OUTUT_{{ prior }} { + type filter hook output priority {{ prior }}; policy accept; +{% if global_options.state_policy is vyos_defined %} + jump VYOS_STATE_POLICY +{% endif %} +{% if conf.rule is vyos_defined %} +{% for rule_id, rule_conf in conf.rule.items() if rule_conf.disable is not vyos_defined %} + {{ rule_conf | nft_rule('OUT', prior, rule_id, 'bri') }} +{% if rule_conf.recent is vyos_defined %} +{% set ns.sets = ns.sets + ['OUT_' + prior + '_' + rule_id] %} +{% endif %} +{% endfor %} +{% endif %} + {{ conf | nft_default_rule('OUT-filter', 'bri') }} + } +{% endfor %} +{% endif %} + {% if bridge.name is vyos_defined %} {% for name_text, conf in bridge.name.items() %} chain NAME_{{ name_text }} { @@ -32,4 +76,45 @@ } {% endfor %} {% endif %} + +{% for set_name in ns.sets %} + set RECENT_{{ set_name }} { + type ipv4_addr + size 65535 + flags dynamic + } +{% endfor %} +{% for set_name in ip_fqdn %} + set FQDN_{{ set_name }} { + type ipv4_addr + flags interval + } +{% endfor %} +{% if geoip_updated.name is vyos_defined %} +{% for setname in geoip_updated.name %} + set {{ setname }} { + type ipv4_addr + flags interval + } +{% endfor %} +{% endif %} + +{{ group_tmpl.groups(group, False, True) }} +{{ group_tmpl.groups(group, True, True) }} + +{% if global_options.state_policy is vyos_defined %} + chain VYOS_STATE_POLICY { +{% if global_options.state_policy.established is vyos_defined %} + {{ global_options.state_policy.established | nft_state_policy('established') }} +{% endif %} +{% if global_options.state_policy.invalid is vyos_defined %} + {{ global_options.state_policy.invalid | nft_state_policy('invalid') }} +{% endif %} +{% if global_options.state_policy.related is vyos_defined %} + {{ global_options.state_policy.related | nft_state_policy('related') }} +{% endif %} + return + } +{% endif %} + {% endmacro %} diff --git a/data/templates/firewall/nftables-defines.j2 b/data/templates/firewall/nftables-defines.j2 index 8a75ab2d6..fa6cd74c0 100644 --- a/data/templates/firewall/nftables-defines.j2 +++ b/data/templates/firewall/nftables-defines.j2 @@ -1,7 +1,7 @@ {% macro groups(group, is_ipv6, is_l3) %} {% if group is vyos_defined %} {% set ip_type = 'ipv6_addr' if is_ipv6 else 'ipv4_addr' %} -{% if group.address_group is vyos_defined and not is_ipv6 and is_l3 %} +{% if group.address_group is vyos_defined and not is_ipv6 %} {% for group_name, group_conf in group.address_group.items() %} {% set includes = group_conf.include if group_conf.include is vyos_defined else [] %} set A_{{ group_name }} { @@ -14,7 +14,7 @@ } {% endfor %} {% endif %} -{% if group.ipv6_address_group is vyos_defined and is_ipv6 and is_l3 %} +{% if group.ipv6_address_group is vyos_defined and is_ipv6 %} {% for group_name, group_conf in group.ipv6_address_group.items() %} {% set includes = group_conf.include if group_conf.include is vyos_defined else [] %} set A6_{{ group_name }} { @@ -46,7 +46,7 @@ } {% endfor %} {% endif %} -{% if group.network_group is vyos_defined and not is_ipv6 and is_l3 %} +{% if group.network_group is vyos_defined and not is_ipv6 %} {% for group_name, group_conf in group.network_group.items() %} {% set includes = group_conf.include if group_conf.include is vyos_defined else [] %} set N_{{ group_name }} { @@ -59,7 +59,7 @@ } {% endfor %} {% endif %} -{% if group.ipv6_network_group is vyos_defined and is_ipv6 and is_l3 %} +{% if group.ipv6_network_group is vyos_defined and is_ipv6 %} {% for group_name, group_conf in group.ipv6_network_group.items() %} {% set includes = group_conf.include if group_conf.include is vyos_defined else [] %} set N6_{{ group_name }} { @@ -72,7 +72,7 @@ } {% endfor %} {% endif %} -{% if group.port_group is vyos_defined and is_l3 %} +{% if group.port_group is vyos_defined %} {% for group_name, group_conf in group.port_group.items() %} {% set includes = group_conf.include if group_conf.include is vyos_defined else [] %} set P_{{ group_name }} { diff --git a/data/templates/firewall/nftables.j2 b/data/templates/firewall/nftables.j2 index 68a3bfd87..82dcefac0 100644 --- a/data/templates/firewall/nftables.j2 +++ b/data/templates/firewall/nftables.j2 @@ -339,7 +339,104 @@ table ip6 vyos_filter { delete table bridge vyos_filter {% endif %} table bridge vyos_filter { -{{ bridge_tmpl.bridge(bridge) }} +{% if bridge is vyos_defined %} +{% if bridge.forward is vyos_defined %} +{% for prior, conf in bridge.forward.items() %} + chain VYOS_FORWARD_{{ prior }} { + type filter hook forward priority {{ prior }}; policy accept; +{% if global_options.state_policy is vyos_defined %} + jump VYOS_STATE_POLICY +{% endif %} +{% if conf.rule is vyos_defined %} +{% for rule_id, rule_conf in conf.rule.items() if rule_conf.disable is not vyos_defined %} + {{ rule_conf | nft_rule('FWD', prior, rule_id, 'bri') }} +{% endfor %} +{% endif %} + {{ conf | nft_default_rule('FWD-' + prior, 'bri') }} + } +{% endfor %} +{% endif %} + +{% if bridge.input is vyos_defined %} +{% for prior, conf in bridge.input.items() %} + chain VYOS_INPUT_{{ prior }} { + type filter hook input priority {{ prior }}; policy accept; +{% if global_options.state_policy is vyos_defined %} + jump VYOS_STATE_POLICY +{% endif %} +{% if conf.rule is vyos_defined %} +{% for rule_id, rule_conf in conf.rule.items() if rule_conf.disable is not vyos_defined %} + {{ rule_conf | nft_rule('INP', prior, rule_id, 'bri') }} +{% endfor %} +{% endif %} + {{ conf | nft_default_rule('INP-' + prior, 'bri') }} + } +{% endfor %} +{% endif %} + +{% if bridge.output is vyos_defined %} +{% for prior, conf in bridge.output.items() %} + chain VYOS_OUTUT_{{ prior }} { + type filter hook output priority {{ prior }}; policy accept; +{% if global_options.state_policy is vyos_defined %} + jump VYOS_STATE_POLICY +{% endif %} +{% if conf.rule is vyos_defined %} +{% for rule_id, rule_conf in conf.rule.items() if rule_conf.disable is not vyos_defined %} + {{ rule_conf | nft_rule('OUT', prior, rule_id, 'bri') }} +{% endfor %} +{% endif %} + {{ conf | nft_default_rule('OUT-' + prior, 'bri') }} + } +{% endfor %} +{% endif %} + +{% if bridge.prerouting is vyos_defined %} +{% for prior, conf in bridge.prerouting.items() %} + chain VYOS_PREROUTING_{{ prior }} { + type filter hook prerouting priority {{ prior }}; policy accept; +{% if conf.rule is vyos_defined %} +{% for rule_id, rule_conf in conf.rule.items() if rule_conf.disable is not vyos_defined %} + {{ rule_conf | nft_rule('PRE', prior, rule_id, 'bri') }} +{% endfor %} +{% endif %} + {{ conf | nft_default_rule('PRE-' + prior, 'bri') }} + } +{% endfor %} +{% endif %} + +{% if bridge.name is vyos_defined %} +{% for name_text, conf in bridge.name.items() %} + chain NAME_{{ name_text }} { +{% if conf.rule is vyos_defined %} +{% for rule_id, rule_conf in conf.rule.items() if rule_conf.disable is not vyos_defined %} + {{ rule_conf | nft_rule('NAM', name_text, rule_id, 'bri') }} +{% if rule_conf.recent is vyos_defined %} +{% set ns.sets = ns.sets + ['NAM_' + name_text + '_' + rule_id] %} +{% endif %} +{% endfor %} +{% endif %} + {{ conf | nft_default_rule(name_text, 'bri') }} + } +{% endfor %} +{% endif %} + +{% endif %} {{ group_tmpl.groups(group, False, False) }} +{{ group_tmpl.groups(group, True, False) }} -} +{% if global_options.state_policy is vyos_defined %} + chain VYOS_STATE_POLICY { +{% if global_options.state_policy.established is vyos_defined %} + {{ global_options.state_policy.established | nft_state_policy('established') }} +{% endif %} +{% if global_options.state_policy.invalid is vyos_defined %} + {{ global_options.state_policy.invalid | nft_state_policy('invalid') }} +{% endif %} +{% if global_options.state_policy.related is vyos_defined %} + {{ global_options.state_policy.related | nft_state_policy('related') }} +{% endif %} + return + } +{% endif %} +}
\ No newline at end of file diff --git a/data/templates/firewall/sysctl-firewall.conf.j2 b/data/templates/firewall/sysctl-firewall.conf.j2 index b9c3311e2..6c33ffdc8 100644 --- a/data/templates/firewall/sysctl-firewall.conf.j2 +++ b/data/templates/firewall/sysctl-firewall.conf.j2 @@ -13,6 +13,14 @@ net.ipv4.conf.*.send_redirects = {{ 1 if global_options.send_redirects == 'enabl net.ipv4.tcp_syncookies = {{ 1 if global_options.syn_cookies == 'enable' else 0 }} net.ipv4.tcp_rfc1337 = {{ 1 if global_options.twa_hazards_protection == 'enable' else 0 }} +{% if global_options.apply_to_bridged_traffic is vyos_defined %} +net.bridge.bridge-nf-call-iptables = {{ 1 if global_options.apply_to_bridged_traffic.ipv4 is vyos_defined else 0 }} +net.bridge.bridge-nf-call-ip6tables = {{ 1 if global_options.apply_to_bridged_traffic.ipv6 is vyos_defined else 0 }} +{% else %} +net.bridge.bridge-nf-call-iptables = 0 +net.bridge.bridge-nf-call-ip6tables = 0 +{% endif %} + ## Timeout values: net.netfilter.nf_conntrack_icmp_timeout = {{ global_options.timeout.icmp }} net.netfilter.nf_conntrack_generic_timeout = {{ global_options.timeout.other }} diff --git a/data/templates/frr/static_mcast.frr.j2 b/data/templates/frr/static_mcast.frr.j2 index 491d4b54a..54b2790b0 100644 --- a/data/templates/frr/static_mcast.frr.j2 +++ b/data/templates/frr/static_mcast.frr.j2 @@ -1,13 +1,4 @@ ! -{% for route_gr in old_mroute %} -{% for nh in old_mroute[route_gr] %} -{% if old_mroute[route_gr][nh] %} -no ip mroute {{ route_gr }} {{ nh }} {{ old_mroute[route_gr][nh] }} -{% else %} -no ip mroute {{ route_gr }} {{ nh }} -{% endif %} -{% endfor %} -{% endfor %} {% for route_gr in mroute %} {% for nh in mroute[route_gr] %} {% if mroute[route_gr][nh] %} diff --git a/data/templates/ipsec/ios_profile.j2 b/data/templates/ipsec/ios_profile.j2 index 935acbf8e..966fad433 100644 --- a/data/templates/ipsec/ios_profile.j2 +++ b/data/templates/ipsec/ios_profile.j2 @@ -55,9 +55,11 @@ <!-- The server is authenticated using a certificate --> <key>AuthenticationMethod</key> <string>Certificate</string> +{% if authentication.client_mode.startswith("eap") %} <!-- The client uses EAP to authenticate --> <key>ExtendedAuthEnabled</key> <integer>1</integer> +{% endif %} <!-- The next two dictionaries are optional (as are the keys in them), but it is recommended to specify them as the default is to use 3DES. IMPORTANT: Because only one proposal is sent (even if nothing is configured here) it must match the server configuration --> <key>IKESecurityAssociationParameters</key> @@ -78,9 +80,14 @@ <string>{{ esp_encryption.encryption }}</string> <key>IntegrityAlgorithm</key> <string>{{ esp_encryption.hash }}</string> +{% if esp_encryption.pfs is vyos_defined %} <key>DiffieHellmanGroup</key> - <integer>{{ ike_encryption.dh_group }}</integer> + <integer>{{ esp_encryption.pfs }}</integer> +{% endif %} </dict> + <!-- Controls whether the client offers Perfect Forward Secrecy (PFS). This should be set to match the server. --> + <key>EnablePFS</key> + <integer>{{ '1' if esp_encryption.pfs is vyos_defined else '0' }}</integer> </dict> </dict> {% if ca_certificates is vyos_defined %} diff --git a/data/templates/ipsec/swanctl.conf.j2 b/data/templates/ipsec/swanctl.conf.j2 index d44d0f5e4..698a9135e 100644 --- a/data/templates/ipsec/swanctl.conf.j2 +++ b/data/templates/ipsec/swanctl.conf.j2 @@ -31,6 +31,8 @@ pools { {{ pool }} { {% if pool_config.prefix is vyos_defined %} addrs = {{ pool_config.prefix }} +{% elif pool_config.range is vyos_defined %} + addrs = {{ pool_config.range.start }}-{{ pool_config.range.stop }} {% endif %} {% if pool_config.name_server is vyos_defined %} dns = {{ pool_config.name_server | join(',') }} diff --git a/data/templates/ipsec/swanctl/remote_access.j2 b/data/templates/ipsec/swanctl/remote_access.j2 index e384ae972..c79f292b4 100644 --- a/data/templates/ipsec/swanctl/remote_access.j2 +++ b/data/templates/ipsec/swanctl/remote_access.j2 @@ -46,7 +46,7 @@ {% endif %} } children { - ikev2-vpn { + {{ name }}-client { esp_proposals = {{ esp | get_esp_ike_cipher(ike) | join(',') }} {% if esp.life_bytes is vyos_defined %} life_bytes = {{ esp.life_bytes }} @@ -69,6 +69,13 @@ {% set local_port = rw_conf.local.port if rw_conf.local.port is vyos_defined else '' %} {% set local_suffix = '[%any/{1}]'.format(local_port) if local_port else '' %} local_ts = {{ local_prefix | join(local_suffix + ",") }}{{ local_suffix }} +{% if rw_conf.bind is vyos_defined %} +{# The key defaults to 0 and will match any policies which similarly do not have a lookup key configuration. #} +{# Thus we simply shift the key by one to also support a vti0 interface #} +{% set if_id = rw_conf.bind | replace('vti', '') | int + 1 %} + if_id_in = {{ if_id }} + if_id_out = {{ if_id }} +{% endif %} } } } diff --git a/data/templates/ipsec/windows_profile.j2 b/data/templates/ipsec/windows_profile.j2 index 8c26944be..b5042f987 100644 --- a/data/templates/ipsec/windows_profile.j2 +++ b/data/templates/ipsec/windows_profile.j2 @@ -1,4 +1,4 @@ Remove-VpnConnection -Name "{{ vpn_name }}" -Force -PassThru Add-VpnConnection -Name "{{ vpn_name }}" -ServerAddress "{{ remote }}" -TunnelType "Ikev2" -Set-VpnConnectionIPsecConfiguration -ConnectionName "{{ vpn_name }}" -AuthenticationTransformConstants {{ ike_encryption.encryption }} -CipherTransformConstants {{ ike_encryption.encryption }} -EncryptionMethod {{ esp_encryption.encryption }} -IntegrityCheckMethod {{ esp_encryption.hash }} -PfsGroup None -DHGroup "Group{{ ike_encryption.dh_group }}" -PassThru -Force +Set-VpnConnectionIPsecConfiguration -ConnectionName "{{ vpn_name }}" -AuthenticationTransformConstants {{ ike_encryption.encryption }} -CipherTransformConstants {{ ike_encryption.encryption }} -EncryptionMethod {{ esp_encryption.encryption }} -IntegrityCheckMethod {{ esp_encryption.hash }} -PfsGroup {{ esp_encryption.pfs }} -DHGroup {{ ike_encryption.dh_group }} -PassThru -Force diff --git a/data/templates/zabbix-agent/10-override.conf.j2 b/data/templates/zabbix-agent/10-override.conf.j2 index 7c296e8fd..f6bd6500d 100644 --- a/data/templates/zabbix-agent/10-override.conf.j2 +++ b/data/templates/zabbix-agent/10-override.conf.j2 @@ -1,3 +1,4 @@ +{% set zabbix_command = 'ip vrf exec ' ~ vrf ~ ' ' if vrf is vyos_defined else '' %} [Unit] After= After=vyos-router.service @@ -5,9 +6,11 @@ ConditionPathExists= ConditionPathExists=/run/zabbix/zabbix-agent2.conf [Service] +User= +User=root EnvironmentFile= ExecStart= -ExecStart=/usr/sbin/zabbix_agent2 --config /run/zabbix/zabbix-agent2.conf --foreground +ExecStart={{ zabbix_command }}/usr/sbin/zabbix_agent2 --config /run/zabbix/zabbix-agent2.conf --foreground WorkingDirectory= WorkingDirectory=/run/zabbix Restart=always diff --git a/interface-definitions/firewall.xml.in b/interface-definitions/firewall.xml.in index dc4625af0..816dd1855 100644 --- a/interface-definitions/firewall.xml.in +++ b/interface-definitions/firewall.xml.in @@ -367,6 +367,9 @@ </properties> <children> #include <include/firewall/bridge-hook-forward.xml.i> + #include <include/firewall/bridge-hook-input.xml.i> + #include <include/firewall/bridge-hook-output.xml.i> + #include <include/firewall/bridge-hook-prerouting.xml.i> #include <include/firewall/bridge-custom-name.xml.i> </children> </node> diff --git a/interface-definitions/include/firewall/address-inet.xml.i b/interface-definitions/include/firewall/address-inet.xml.i new file mode 100644 index 000000000..02ed8f6e4 --- /dev/null +++ b/interface-definitions/include/firewall/address-inet.xml.i @@ -0,0 +1,63 @@ +<!-- include start from firewall/address-inet.xml.i --> +<leafNode name="address"> + <properties> + <help>IP address, subnet, or range</help> + <valueHelp> + <format>ipv4</format> + <description>IPv4 address to match</description> + </valueHelp> + <valueHelp> + <format>ipv4net</format> + <description>IPv4 prefix to match</description> + </valueHelp> + <valueHelp> + <format>ipv4range</format> + <description>IPv4 address range to match</description> + </valueHelp> + <valueHelp> + <format>!ipv4</format> + <description>Match everything except the specified address</description> + </valueHelp> + <valueHelp> + <format>!ipv4net</format> + <description>Match everything except the specified prefix</description> + </valueHelp> + <valueHelp> + <format>!ipv4range</format> + <description>Match everything except the specified range</description> + </valueHelp> + <valueHelp> + <format>ipv6net</format> + <description>Subnet to match</description> + </valueHelp> + <valueHelp> + <format>ipv6range</format> + <description>IP range to match</description> + </valueHelp> + <valueHelp> + <format>!ipv6</format> + <description>Match everything except the specified address</description> + </valueHelp> + <valueHelp> + <format>!ipv6net</format> + <description>Match everything except the specified prefix</description> + </valueHelp> + <valueHelp> + <format>!ipv6range</format> + <description>Match everything except the specified range</description> + </valueHelp> + <constraint> + <validator name="ipv4-address"/> + <validator name="ipv4-prefix"/> + <validator name="ipv4-range"/> + <validator name="ipv4-address-exclude"/> + <validator name="ipv4-prefix-exclude"/> + <validator name="ipv4-range-exclude"/> + <validator name="ipv6"/> + <validator name="ipv6-exclude"/> + <validator name="ipv6-range"/> + <validator name="ipv6-range-exclude"/> + </constraint> + </properties> +</leafNode> +<!-- include end -->
\ No newline at end of file diff --git a/interface-definitions/include/firewall/address-mask-inet.xml.i b/interface-definitions/include/firewall/address-mask-inet.xml.i new file mode 100644 index 000000000..e2a5927ab --- /dev/null +++ b/interface-definitions/include/firewall/address-mask-inet.xml.i @@ -0,0 +1,19 @@ +<!-- include start from firewall/address-mask-inet.xml.i --> +<leafNode name="address-mask"> + <properties> + <help>IP mask</help> + <valueHelp> + <format>ipv4</format> + <description>IPv4 mask to apply</description> + </valueHelp> + <valueHelp> + <format>ipv6</format> + <description>IP mask to apply</description> + </valueHelp> + <constraint> + <validator name="ipv4-address"/> + <validator name="ipv6"/> + </constraint> + </properties> +</leafNode> +<!-- include end -->
\ No newline at end of file diff --git a/interface-definitions/include/firewall/bridge-custom-name.xml.i b/interface-definitions/include/firewall/bridge-custom-name.xml.i index 654493c0e..9a2a829d0 100644 --- a/interface-definitions/include/firewall/bridge-custom-name.xml.i +++ b/interface-definitions/include/firewall/bridge-custom-name.xml.i @@ -32,6 +32,12 @@ </properties> <children> #include <include/firewall/common-rule-bridge.xml.i> + #include <include/firewall/action-l2.xml.i> + #include <include/firewall/connection-mark.xml.i> + #include <include/firewall/connection-status.xml.i> + #include <include/firewall/state.xml.i> + #include <include/firewall/inbound-interface.xml.i> + #include <include/firewall/outbound-interface.xml.i> </children> </tagNode> </children> diff --git a/interface-definitions/include/firewall/bridge-hook-forward.xml.i b/interface-definitions/include/firewall/bridge-hook-forward.xml.i index 99f66ec77..fcc981925 100644 --- a/interface-definitions/include/firewall/bridge-hook-forward.xml.i +++ b/interface-definitions/include/firewall/bridge-hook-forward.xml.i @@ -26,6 +26,12 @@ </properties> <children> #include <include/firewall/common-rule-bridge.xml.i> + #include <include/firewall/action-l2.xml.i> + #include <include/firewall/connection-mark.xml.i> + #include <include/firewall/connection-status.xml.i> + #include <include/firewall/state.xml.i> + #include <include/firewall/inbound-interface.xml.i> + #include <include/firewall/outbound-interface.xml.i> </children> </tagNode> </children> diff --git a/interface-definitions/include/firewall/bridge-hook-input.xml.i b/interface-definitions/include/firewall/bridge-hook-input.xml.i new file mode 100644 index 000000000..f6a11f8da --- /dev/null +++ b/interface-definitions/include/firewall/bridge-hook-input.xml.i @@ -0,0 +1,40 @@ +<!-- include start from firewall/bridge-hook-input.xml.i --> +<node name="input"> + <properties> + <help>Bridge input firewall</help> + </properties> + <children> + <node name="filter"> + <properties> + <help>Bridge firewall input filter</help> + </properties> + <children> + #include <include/firewall/default-action-base-chains.xml.i> + #include <include/firewall/default-log.xml.i> + #include <include/generic-description.xml.i> + <tagNode name="rule"> + <properties> + <help>Bridge Firewall input filter rule number</help> + <valueHelp> + <format>u32:1-999999</format> + <description>Number for this firewall rule</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 1-999999"/> + </constraint> + <constraintErrorMessage>Firewall rule number must be between 1 and 999999</constraintErrorMessage> + </properties> + <children> + #include <include/firewall/common-rule-bridge.xml.i> + #include <include/firewall/action-l2.xml.i> + #include <include/firewall/connection-mark.xml.i> + #include <include/firewall/connection-status.xml.i> + #include <include/firewall/state.xml.i> + #include <include/firewall/inbound-interface.xml.i> + </children> + </tagNode> + </children> + </node> + </children> +</node> +<!-- include end --> diff --git a/interface-definitions/include/firewall/bridge-hook-output.xml.i b/interface-definitions/include/firewall/bridge-hook-output.xml.i new file mode 100644 index 000000000..38b8b08ca --- /dev/null +++ b/interface-definitions/include/firewall/bridge-hook-output.xml.i @@ -0,0 +1,40 @@ +<!-- include start from firewall/bridge-hook-output.xml.i --> +<node name="output"> + <properties> + <help>Bridge output firewall</help> + </properties> + <children> + <node name="filter"> + <properties> + <help>Bridge firewall output filter</help> + </properties> + <children> + #include <include/firewall/default-action-base-chains.xml.i> + #include <include/firewall/default-log.xml.i> + #include <include/generic-description.xml.i> + <tagNode name="rule"> + <properties> + <help>Bridge Firewall output filter rule number</help> + <valueHelp> + <format>u32:1-999999</format> + <description>Number for this firewall rule</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 1-999999"/> + </constraint> + <constraintErrorMessage>Firewall rule number must be between 1 and 999999</constraintErrorMessage> + </properties> + <children> + #include <include/firewall/common-rule-bridge.xml.i> + #include <include/firewall/action-l2.xml.i> + #include <include/firewall/connection-mark.xml.i> + #include <include/firewall/connection-status.xml.i> + #include <include/firewall/state.xml.i> + #include <include/firewall/outbound-interface.xml.i> + </children> + </tagNode> + </children> + </node> + </children> +</node> +<!-- include end --> diff --git a/interface-definitions/include/firewall/bridge-hook-prerouting.xml.i b/interface-definitions/include/firewall/bridge-hook-prerouting.xml.i new file mode 100644 index 000000000..ea567644f --- /dev/null +++ b/interface-definitions/include/firewall/bridge-hook-prerouting.xml.i @@ -0,0 +1,37 @@ +<!-- include start from firewall/bridge-hook-prerouting.xml.i --> +<node name="prerouting"> + <properties> + <help>Bridge prerouting firewall</help> + </properties> + <children> + <node name="filter"> + <properties> + <help>Bridge firewall prerouting filter</help> + </properties> + <children> + #include <include/firewall/default-action-base-chains.xml.i> + #include <include/firewall/default-log.xml.i> + #include <include/generic-description.xml.i> + <tagNode name="rule"> + <properties> + <help>Bridge firewall prerouting filter rule number</help> + <valueHelp> + <format>u32:1-999999</format> + <description>Number for this firewall rule</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 1-999999"/> + </constraint> + <constraintErrorMessage>Firewall rule number must be between 1 and 999999</constraintErrorMessage> + </properties> + <children> + #include <include/firewall/common-rule-bridge.xml.i> + #include <include/firewall/action-and-notrack.xml.i> + #include <include/firewall/inbound-interface.xml.i> + </children> + </tagNode> + </children> + </node> + </children> +</node> +<!-- include end --> diff --git a/interface-definitions/include/firewall/common-rule-bridge.xml.i b/interface-definitions/include/firewall/common-rule-bridge.xml.i index dcdd970ac..9ae28f7be 100644 --- a/interface-definitions/include/firewall/common-rule-bridge.xml.i +++ b/interface-definitions/include/firewall/common-rule-bridge.xml.i @@ -1,15 +1,36 @@ <!-- include start from firewall/common-rule-bridge.xml.i --> -#include <include/firewall/action-l2.xml.i> +#include <include/generic-description.xml.i> +#include <include/generic-disable-node.xml.i> +#include <include/firewall/dscp.xml.i> +#include <include/firewall/firewall-mark.xml.i> +#include <include/firewall/fragment.xml.i> +#include <include/firewall/hop-limit.xml.i> +#include <include/firewall/icmp.xml.i> +#include <include/firewall/icmpv6.xml.i> +#include <include/firewall/limit.xml.i> +#include <include/firewall/log.xml.i> +#include <include/firewall/log-options.xml.i> +#include <include/firewall/match-ipsec.xml.i> +#include <include/firewall/match-vlan.xml.i> #include <include/firewall/nft-queue.xml.i> +#include <include/firewall/packet-options.xml.i> +#include <include/firewall/protocol.xml.i> +#include <include/firewall/tcp-flags.xml.i> +#include <include/firewall/tcp-mss.xml.i> +#include <include/firewall/time.xml.i> +#include <include/firewall/ttl.xml.i> <node name="destination"> <properties> <help>Destination parameters</help> </properties> <children> #include <include/firewall/mac-address.xml.i> + #include <include/firewall/address-inet.xml.i> + #include <include/firewall/address-mask-inet.xml.i> + #include <include/firewall/port.xml.i> + #include <include/firewall/source-destination-group-inet.xml.i> </children> </node> -#include <include/generic-disable-node.xml.i> <leafNode name="jump-target"> <properties> <help>Set jump target. Action jump must be defined to use this setting</help> @@ -18,17 +39,16 @@ </completionHelp> </properties> </leafNode> -#include <include/firewall/log.xml.i> -#include <include/firewall/log-options.xml.i> <node name="source"> <properties> <help>Source parameters</help> </properties> <children> #include <include/firewall/mac-address.xml.i> + #include <include/firewall/address-inet.xml.i> + #include <include/firewall/address-mask-inet.xml.i> + #include <include/firewall/port.xml.i> + #include <include/firewall/source-destination-group-inet.xml.i> </children> </node> -#include <include/firewall/inbound-interface.xml.i> -#include <include/firewall/outbound-interface.xml.i> -#include <include/firewall/match-vlan.xml.i> <!-- include end --> diff --git a/interface-definitions/include/firewall/global-options.xml.i b/interface-definitions/include/firewall/global-options.xml.i index 9039b76fd..cee8f1854 100644 --- a/interface-definitions/include/firewall/global-options.xml.i +++ b/interface-definitions/include/firewall/global-options.xml.i @@ -44,6 +44,25 @@ </properties> <defaultValue>disable</defaultValue> </leafNode> + <node name="apply-to-bridged-traffic"> + <properties> + <help>Apply configured firewall rules to traffic switched by bridges</help> + </properties> + <children> + <leafNode name="ipv4"> + <properties> + <help>Apply configured IPv4 firewall rules</help> + <valueless/> + </properties> + </leafNode> + <leafNode name="ipv6"> + <properties> + <help>Apply configured IPv6 firewall rules</help> + <valueless/> + </properties> + </leafNode> + </children> + </node> <leafNode name="directed-broadcast"> <properties> <help>Policy for handling IPv4 directed broadcast forwarding on all interfaces</help> diff --git a/interface-definitions/include/firewall/set-packet-modifications.xml.i b/interface-definitions/include/firewall/set-packet-modifications.xml.i new file mode 100644 index 000000000..ee019b64e --- /dev/null +++ b/interface-definitions/include/firewall/set-packet-modifications.xml.i @@ -0,0 +1,96 @@ +<!-- include start from firewall/set-packet-modifications.xml.i --> +<node name="set"> + <properties> + <help>Packet modifications</help> + </properties> + <children> + <leafNode name="connection-mark"> + <properties> + <help>Set connection mark</help> + <valueHelp> + <format>u32:0-2147483647</format> + <description>Connection mark</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 0-2147483647"/> + </constraint> + </properties> + </leafNode> + <leafNode name="dscp"> + <properties> + <help>Set DSCP (Packet Differentiated Services Codepoint) bits</help> + <valueHelp> + <format>u32:0-63</format> + <description>DSCP number</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 0-63"/> + </constraint> + </properties> + </leafNode> + <leafNode name="mark"> + <properties> + <help>Set packet mark</help> + <valueHelp> + <format>u32:1-2147483647</format> + <description>Packet mark</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 1-2147483647"/> + </constraint> + </properties> + </leafNode> + <leafNode name="table"> + <properties> + <help>Set the routing table for matched packets</help> + <valueHelp> + <format>u32:1-200</format> + <description>Table number</description> + </valueHelp> + <valueHelp> + <format>main</format> + <description>Main table</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 1-200"/> + <regex>(main)</regex> + </constraint> + <completionHelp> + <list>main</list> + <path>protocols static table</path> + </completionHelp> + </properties> + </leafNode> + <leafNode name="vrf"> + <properties> + <help>VRF to forward packet with</help> + <valueHelp> + <format>txt</format> + <description>VRF instance name</description> + </valueHelp> + <valueHelp> + <format>default</format> + <description>Forward into default global VRF</description> + </valueHelp> + <completionHelp> + <list>default</list> + <path>vrf name</path> + </completionHelp> + #include <include/constraint/vrf.xml.i> + </properties> + </leafNode> + <leafNode name="tcp-mss"> + <properties> + <help>Set TCP Maximum Segment Size</help> + <valueHelp> + <format>u32:500-1460</format> + <description>Explicitly set TCP MSS value</description> + </valueHelp> + <constraint> + <validator name="numeric" argument="--range 500-1460"/> + </constraint> + </properties> + </leafNode> + </children> +</node> +<!-- include end -->
\ No newline at end of file diff --git a/interface-definitions/include/firewall/source-destination-group-inet.xml.i b/interface-definitions/include/firewall/source-destination-group-inet.xml.i new file mode 100644 index 000000000..174051624 --- /dev/null +++ b/interface-definitions/include/firewall/source-destination-group-inet.xml.i @@ -0,0 +1,50 @@ +<!-- include start from firewall/source-destination-group-inet.xml.i --> +<node name="group"> + <properties> + <help>Group</help> + </properties> + <children> + <leafNode name="ipv4-address-group"> + <properties> + <help>Group of IPv4 addresses</help> + <completionHelp> + <path>firewall group address-group</path> + </completionHelp> + </properties> + </leafNode> + <leafNode name="ipv6-address-group"> + <properties> + <help>Group of IPv6 addresses</help> + <completionHelp> + <path>firewall group ipv6-address-group</path> + </completionHelp> + </properties> + </leafNode> + #include <include/firewall/mac-group.xml.i> + <leafNode name="ipv4-network-group"> + <properties> + <help>Group of IPv4 networks</help> + <completionHelp> + <path>firewall group network-group</path> + </completionHelp> + </properties> + </leafNode> + <leafNode name="ipv6-network-group"> + <properties> + <help>Group of IPv6 networks</help> + <completionHelp> + <path>firewall group ipv6-network-group</path> + </completionHelp> + </properties> + </leafNode> + <leafNode name="port-group"> + <properties> + <help>Group of ports</help> + <completionHelp> + <path>firewall group port-group</path> + </completionHelp> + </properties> + </leafNode> + </children> +</node> +<!-- include end --> diff --git a/interface-definitions/include/ipsec/bind.xml.i b/interface-definitions/include/ipsec/bind.xml.i new file mode 100644 index 000000000..edc46d403 --- /dev/null +++ b/interface-definitions/include/ipsec/bind.xml.i @@ -0,0 +1,10 @@ +<!-- include start from ipsec/bind.xml.i --> +<leafNode name="bind"> + <properties> + <help>VTI tunnel interface associated with this configuration</help> + <completionHelp> + <path>interfaces vti</path> + </completionHelp> + </properties> +</leafNode> +<!-- include end --> diff --git a/interface-definitions/include/policy/route-common.xml.i b/interface-definitions/include/policy/route-common.xml.i index 97795601e..19ffc0506 100644 --- a/interface-definitions/include/policy/route-common.xml.i +++ b/interface-definitions/include/policy/route-common.xml.i @@ -66,82 +66,7 @@ </leafNode> </children> </node> -<node name="set"> - <properties> - <help>Packet modifications</help> - </properties> - <children> - <leafNode name="connection-mark"> - <properties> - <help>Connection marking</help> - <valueHelp> - <format>u32:0-2147483647</format> - <description>Connection marking</description> - </valueHelp> - <constraint> - <validator name="numeric" argument="--range 0-2147483647"/> - </constraint> - </properties> - </leafNode> - <leafNode name="dscp"> - <properties> - <help>Packet Differentiated Services Codepoint (DSCP)</help> - <valueHelp> - <format>u32:0-63</format> - <description>DSCP number</description> - </valueHelp> - <constraint> - <validator name="numeric" argument="--range 0-63"/> - </constraint> - </properties> - </leafNode> - <leafNode name="mark"> - <properties> - <help>Packet marking</help> - <valueHelp> - <format>u32:1-2147483647</format> - <description>Packet marking</description> - </valueHelp> - <constraint> - <validator name="numeric" argument="--range 1-2147483647"/> - </constraint> - </properties> - </leafNode> - <leafNode name="table"> - <properties> - <help>Routing table to forward packet with</help> - <valueHelp> - <format>u32:1-200</format> - <description>Table number</description> - </valueHelp> - <valueHelp> - <format>main</format> - <description>Main table</description> - </valueHelp> - <constraint> - <validator name="numeric" argument="--range 1-200"/> - <regex>(main)</regex> - </constraint> - <completionHelp> - <list>main</list> - <path>protocols static table</path> - </completionHelp> - </properties> - </leafNode> - <leafNode name="tcp-mss"> - <properties> - <help>TCP Maximum Segment Size</help> - <valueHelp> - <format>u32:500-1460</format> - <description>Explicitly set TCP MSS value</description> - </valueHelp> - <constraint> - <validator name="numeric" argument="--range 500-1460"/> - </constraint> - </properties> - </leafNode> - </children> -</node> +#include <include/firewall/set-packet-modifications.xml.i> #include <include/firewall/state.xml.i> #include <include/firewall/tcp-flags.xml.i> #include <include/firewall/tcp-mss.xml.i> diff --git a/interface-definitions/service_monitoring_zabbix-agent.xml.in b/interface-definitions/service_monitoring_zabbix-agent.xml.in index 3754e9145..e44b31312 100644 --- a/interface-definitions/service_monitoring_zabbix-agent.xml.in +++ b/interface-definitions/service_monitoring_zabbix-agent.xml.in @@ -185,6 +185,7 @@ </properties> <defaultValue>3</defaultValue> </leafNode> + #include <include/interface/vrf.xml.i> </children> </node> </children> diff --git a/interface-definitions/vpn_ipsec.xml.in b/interface-definitions/vpn_ipsec.xml.in index 4a7fde75b..d9d6fd93b 100644 --- a/interface-definitions/vpn_ipsec.xml.in +++ b/interface-definitions/vpn_ipsec.xml.in @@ -854,6 +854,7 @@ #include <include/dhcp-interface.xml.i> #include <include/ipsec/local-traffic-selector.xml.i> #include <include/ipsec/replay-window.xml.i> + #include <include/ipsec/bind.xml.i> <leafNode name="timeout"> <properties> <help>Timeout to close connection if no data is transmitted</help> @@ -978,6 +979,45 @@ </constraint> </properties> </leafNode> + <node name="range"> + <properties> + <help>Local IPv4 or IPv6 pool range</help> + </properties> + <children> + <leafNode name="start"> + <properties> + <help>First IP address for local pool range</help> + <valueHelp> + <format>ipv4</format> + <description>IPv4 start address of pool</description> + </valueHelp> + <valueHelp> + <format>ipv6</format> + <description>IPv6 start address of pool</description> + </valueHelp> + <constraint> + <validator name="ip-address"/> + </constraint> + </properties> + </leafNode> + <leafNode name="stop"> + <properties> + <help>Last IP address for local pool range</help> + <valueHelp> + <format>ipv4</format> + <description>IPv4 end address of pool</description> + </valueHelp> + <valueHelp> + <format>ipv6</format> + <description>IPv6 end address of pool</description> + </valueHelp> + <constraint> + <validator name="ip-address"/> + </constraint> + </properties> + </leafNode> + </children> + </node> #include <include/name-server-ipv4-ipv6.xml.i> </children> </tagNode> @@ -1201,14 +1241,7 @@ <help>Virtual tunnel interface</help> </properties> <children> - <leafNode name="bind"> - <properties> - <help>VTI tunnel interface associated with this configuration</help> - <completionHelp> - <path>interfaces vti</path> - </completionHelp> - </properties> - </leafNode> + #include <include/ipsec/bind.xml.i> #include <include/ipsec/esp-group.xml.i> </children> </node> diff --git a/op-mode-definitions/restart-serial.xml.in b/op-mode-definitions/restart-serial.xml.in new file mode 100644 index 000000000..4d8a03633 --- /dev/null +++ b/op-mode-definitions/restart-serial.xml.in @@ -0,0 +1,31 @@ +<?xml version="1.0"?> +<interfaceDefinition> + <node name="restart"> + <children> + <node name="serial"> + <properties> + <help>Restart services on serial ports</help> + </properties> + <children> + <node name="console"> + <properties> + <help>Restart serial console service for login TTYs</help> + </properties> + <command>sudo ${vyos_op_scripts_dir}/serial.py restart_console</command> + <children> + <tagNode name="device"> + <properties> + <help>Restart specific TTY device</help> + <completionHelp> + <script>${vyos_completion_dir}/list_login_ttys.py</script> + </completionHelp> + </properties> + <command>sudo ${vyos_op_scripts_dir}/serial.py restart_console --device-name "$5"</command> + </tagNode> + </children> + </node> + </children> + </node> + </children> + </node> +</interfaceDefinition> diff --git a/python/vyos/configtree.py b/python/vyos/configtree.py index 5775070e2..bd77ab899 100644 --- a/python/vyos/configtree.py +++ b/python/vyos/configtree.py @@ -1,5 +1,5 @@ # configtree -- a standalone VyOS config file manipulation library (Python bindings) -# Copyright (C) 2018-2022 VyOS maintainers and contributors +# Copyright (C) 2018-2024 VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or modify it under the terms of # the GNU Lesser General Public License as published by the Free Software Foundation; @@ -290,7 +290,7 @@ class ConfigTree(object): else: return True - def list_nodes(self, path): + def list_nodes(self, path, path_must_exist=True): check_path(path) path_str = " ".join(map(str, path)).encode() @@ -298,7 +298,10 @@ class ConfigTree(object): res = json.loads(res_json) if res is None: - raise ConfigTreeError("Path [{}] doesn't exist".format(path_str)) + if path_must_exist: + raise ConfigTreeError("Path [{}] doesn't exist".format(path_str)) + else: + return [] else: return res diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index 9ccd925ce..25ee45391 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -50,3 +50,13 @@ commit_lock = os.path.join(directories['vyos_configdir'], '.lock') component_version_json = os.path.join(directories['data'], 'component-versions.json') config_default = os.path.join(directories['data'], 'config.boot.default') + +rt_symbolic_names = { + # Standard routing tables for Linux & reserved IDs for VyOS + 'default': 253, # Confusingly, a final fallthru, not the default. + 'main': 254, # The actual global table used by iproute2 unless told otherwise. + 'local': 255, # Special kernel loopback table. +} + +rt_global_vrf = rt_symbolic_names['main'] +rt_global_table = rt_symbolic_names['main'] diff --git a/python/vyos/firewall.py b/python/vyos/firewall.py index 40399f481..cac6d2953 100644 --- a/python/vyos/firewall.py +++ b/python/vyos/firewall.py @@ -30,6 +30,9 @@ from vyos.utils.dict import dict_search_args from vyos.utils.dict import dict_search_recursive from vyos.utils.process import cmd from vyos.utils.process import run +from vyos.utils.network import get_vrf_tableid +from vyos.defaults import rt_global_table +from vyos.defaults import rt_global_vrf # Conntrack def conntrack_required(conf): @@ -164,7 +167,10 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): if address_mask: operator = '!=' if exclude else '==' operator = f'& {address_mask} {operator} ' - output.append(f'{ip_name} {prefix}addr {operator}{suffix}') + if is_ipv4(suffix): + output.append(f'ip {prefix}addr {operator}{suffix}') + else: + output.append(f'ip6 {prefix}addr {operator}{suffix}') if 'fqdn' in side_conf: fqdn = side_conf['fqdn'] @@ -233,22 +239,38 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): if 'group' in side_conf: group = side_conf['group'] - if 'address_group' in group: - group_name = group['address_group'] - operator = '' - exclude = group_name[0] == "!" - if exclude: - operator = '!=' - group_name = group_name[1:] - if address_mask: - operator = '!=' if exclude else '==' - operator = f'& {address_mask} {operator}' - output.append(f'{ip_name} {prefix}addr {operator} @A{def_suffix}_{group_name}') - elif 'dynamic_address_group' in group: + for ipvx_address_group in ['address_group', 'ipv4_address_group', 'ipv6_address_group']: + if ipvx_address_group in group: + group_name = group[ipvx_address_group] + operator = '' + exclude = group_name[0] == "!" + if exclude: + operator = '!=' + group_name = group_name[1:] + if address_mask: + operator = '!=' if exclude else '==' + operator = f'& {address_mask} {operator}' + # for bridge, change ip_name + if ip_name == 'bri': + ip_name = 'ip' if ipvx_address_group == 'ipv4_address_group' else 'ip6' + def_suffix = '6' if ipvx_address_group == 'ipv6_address_group' else '' + output.append(f'{ip_name} {prefix}addr {operator} @A{def_suffix}_{group_name}') + for ipvx_network_group in ['network_group', 'ipv4_network_group', 'ipv6_network_group']: + if ipvx_network_group in group: + group_name = group[ipvx_network_group] + operator = '' + if group_name[0] == "!": + operator = '!=' + group_name = group_name[1:] + # for bridge, change ip_name + if ip_name == 'bri': + ip_name = 'ip' if ipvx_network_group == 'ipv4_network_group' else 'ip6' + def_suffix = '6' if ipvx_network_group == 'ipv6_network_group' else '' + output.append(f'{ip_name} {prefix}addr {operator} @N{def_suffix}_{group_name}') + if 'dynamic_address_group' in group: group_name = group['dynamic_address_group'] operator = '' - exclude = group_name[0] == "!" - if exclude: + if group_name[0] == "!": operator = '!=' group_name = group_name[1:] output.append(f'{ip_name} {prefix}addr {operator} @DA{def_suffix}_{group_name}') @@ -260,13 +282,6 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): operator = '!=' group_name = group_name[1:] output.append(f'{ip_name} {prefix}addr {operator} @D_{group_name}') - elif 'network_group' in group: - group_name = group['network_group'] - operator = '' - if group_name[0] == '!': - operator = '!=' - group_name = group_name[1:] - output.append(f'{ip_name} {prefix}addr {operator} @N{def_suffix}_{group_name}') if 'mac_group' in group: group_name = group['mac_group'] operator = '' @@ -473,11 +488,20 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): if 'mark' in rule_conf['set']: mark = rule_conf['set']['mark'] output.append(f'meta mark set {mark}') + if 'vrf' in rule_conf['set']: + set_table = True + vrf_name = rule_conf['set']['vrf'] + if vrf_name == 'default': + table = rt_global_vrf + else: + # NOTE: VRF->table ID lookup depends on the VRF iface already existing. + table = get_vrf_tableid(vrf_name) if 'table' in rule_conf['set']: set_table = True table = rule_conf['set']['table'] if table == 'main': - table = '254' + table = rt_global_table + if set_table: mark = 0x7FFFFFFF - int(table) output.append(f'meta mark set {mark}') if 'tcp_mss' in rule_conf['set']: diff --git a/python/vyos/ifconfig/vti.py b/python/vyos/ifconfig/vti.py index 9511386f4..251cbeb36 100644 --- a/python/vyos/ifconfig/vti.py +++ b/python/vyos/ifconfig/vti.py @@ -15,6 +15,7 @@ from vyos.ifconfig.interface import Interface from vyos.utils.dict import dict_search +from vyos.utils.vti_updown_db import vti_updown_db_exists, open_vti_updown_db_readonly @Interface.register class VTIIf(Interface): @@ -27,6 +28,10 @@ class VTIIf(Interface): }, } + def __init__(self, ifname, **kwargs): + self.bypass_vti_updown_db = kwargs.pop("bypass_vti_updown_db", False) + super().__init__(ifname, **kwargs) + def _create(self): # This table represents a mapping from VyOS internal config dict to # arguments used by iproute2. For more information please refer to: @@ -57,8 +62,18 @@ class VTIIf(Interface): self.set_interface('admin_state', 'down') def set_admin_state(self, state): - """ Handled outside by /etc/ipsec.d/vti-up-down """ - pass + """ + Set interface administrative state to be 'up' or 'down'. + + The interface will only be brought 'up' if ith is attached to an + active ipsec site-to-site connection or remote access connection. + """ + if state == 'down' or self.bypass_vti_updown_db: + super().set_admin_state(state) + elif vti_updown_db_exists(): + with open_vti_updown_db_readonly() as db: + if db.wantsInterfaceUp(self.ifname): + super().set_admin_state(state) def get_mac(self): """ Get a synthetic MAC address. """ diff --git a/python/vyos/utils/serial.py b/python/vyos/utils/serial.py new file mode 100644 index 000000000..b646f881e --- /dev/null +++ b/python/vyos/utils/serial.py @@ -0,0 +1,118 @@ +# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see <http://www.gnu.org/licenses/>. + +import os, re, json +from typing import List + +from vyos.base import Warning +from vyos.utils.io import ask_yes_no +from vyos.utils.process import cmd + +GLOB_GETTY_UNITS = 'serial-getty@*.service' +RE_GETTY_DEVICES = re.compile(r'.+@(.+).service$') + +SD_UNIT_PATH = '/run/systemd/system' +UTMP_PATH = '/run/utmp' + +def get_serial_units(include_devices=[]): + # Since we cannot depend on the current config for decommissioned ports, + # we just grab everything that systemd knows about. + tmp = cmd(f'systemctl list-units {GLOB_GETTY_UNITS} --all --output json --no-pager') + getty_units = json.loads(tmp) + for sdunit in getty_units: + m = RE_GETTY_DEVICES.search(sdunit['unit']) + if m is None: + Warning(f'Serial console unit name "{sdunit["unit"]}" is malformed and cannot be checked for activity!') + continue + + getty_device = m.group(1) + if include_devices and getty_device not in include_devices: + continue + + sdunit['device'] = getty_device + + return getty_units + +def get_authenticated_ports(units): + connected = [] + ports = [ x['device'] for x in units if 'device' in x ] + # + # utmpdump just gives us an easily parseable dump of currently logged-in sessions, for eg: + # $ utmpdump /run/utmp + # Utmp dump of /run/utmp + # [2] [00000] [~~ ] [reboot ] [~ ] [6.6.31-amd64-vyos ] [0.0.0.0 ] [2024-06-18T13:56:53,958484+00:00] + # [1] [00051] [~~ ] [runlevel] [~ ] [6.6.31-amd64-vyos ] [0.0.0.0 ] [2024-06-18T13:57:01,790808+00:00] + # [6] [03178] [tty1] [LOGIN ] [tty1 ] [ ] [0.0.0.0 ] [2024-06-18T13:57:31,015392+00:00] + # [7] [37151] [ts/0] [vyos ] [pts/0 ] [10.9.8.7 ] [10.9.8.7 ] [2024-07-04T13:42:08,760892+00:00] + # [8] [24812] [ts/1] [ ] [pts/1 ] [10.9.8.7 ] [10.9.8.7 ] [2024-06-20T18:10:07,309365+00:00] + # + # We can safely skip blank or LOGIN sessions with valid device names. + # + for line in cmd(f'utmpdump {UTMP_PATH}').splitlines(): + row = line.split('] [') + user_name = row[3].strip() + user_term = row[4].strip() + if user_name and user_name != 'LOGIN' and user_term in ports: + connected.append(user_term) + + return connected + +def restart_login_consoles(prompt_user=False, quiet=True, devices: List[str]=[]): + # restart_login_consoles() is called from both conf- and op-mode scripts, including + # the warning messages and user prompts common to both. + # + # The default case, called with no arguments, is a simple serial-getty restart & + # cleanup wrapper with no output or prompts that can be used from anywhere. + # + # quiet and prompt_user args have been split from an original "no_prompt", in + # order to support the completely silent default use case. "no_prompt" would + # only suppress the user interactive prompt. + # + # quiet intentionally does not suppress a vyos.base.Warning() for malformed + # device names in _get_serial_units(). + # + cmd('systemctl daemon-reload') + + units = get_serial_units(devices) + connected = get_authenticated_ports(units) + + if connected: + if not quiet: + Warning('There are user sessions connected via serial console that '\ + 'will be terminated when serial console settings are changed!') + if not prompt_user: + # This flag is used by conf_mode/system_console.py to reset things, if there's + # a problem, the user should issue a manual restart for serial-getty. + Warning('Please ensure all settings are committed and saved before issuing a ' \ + '"restart serial console" command to apply new configuration!') + if not prompt_user: + return False + if not ask_yes_no('Any uncommitted changes from these sessions will be lost\n' \ + 'and in-progress actions may be left in an inconsistent state.\n'\ + '\nContinue?'): + return False + + for unit in units: + if 'device' not in unit: + continue # malformed or filtered. + unit_name = unit['unit'] + unit_device = unit['device'] + if os.path.exists(os.path.join(SD_UNIT_PATH, unit_name)): + cmd(f'systemctl restart {unit_name}') + else: + # Deleted stubs don't need to be restarted, just shut them down. + cmd(f'systemctl stop {unit_name}') + + return True diff --git a/python/vyos/utils/vti_updown_db.py b/python/vyos/utils/vti_updown_db.py new file mode 100644 index 000000000..b491fc6f2 --- /dev/null +++ b/python/vyos/utils/vti_updown_db.py @@ -0,0 +1,194 @@ +# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see <http://www.gnu.org/licenses/>. + +import os + +from contextlib import contextmanager +from syslog import syslog + +VTI_WANT_UP_IFLIST = '/tmp/ipsec_vti_interfaces' + +def vti_updown_db_exists(): + """ Returns true if the database exists """ + return os.path.exists(VTI_WANT_UP_IFLIST) + +@contextmanager +def open_vti_updown_db_for_create_or_update(): + """ Opens the database for reading and writing, creating the database if it does not exist """ + if vti_updown_db_exists(): + f = open(VTI_WANT_UP_IFLIST, 'r+') + else: + f = open(VTI_WANT_UP_IFLIST, 'x+') + try: + db = VTIUpDownDB(f) + yield db + finally: + f.close() + +@contextmanager +def open_vti_updown_db_for_update(): + """ Opens the database for reading and writing, returning an error if it does not exist """ + f = open(VTI_WANT_UP_IFLIST, 'r+') + try: + db = VTIUpDownDB(f) + yield db + finally: + f.close() + +@contextmanager +def open_vti_updown_db_readonly(): + """ Opens the database for reading, returning an error if it does not exist """ + f = open(VTI_WANT_UP_IFLIST, 'r') + try: + db = VTIUpDownDB(f) + yield db + finally: + f.close() + +def remove_vti_updown_db(): + """ Brings down any interfaces referenced by the database and removes the database """ + # We need to process the DB first to bring down any interfaces still up + with open_vti_updown_db_for_update() as db: + db.removeAllOtherInterfaces([]) + # this usage of commit will only ever bring down interfaces, + # do not need to provide a functional interface dict supplier + db.commit(lambda _: None) + + os.unlink(VTI_WANT_UP_IFLIST) + +class VTIUpDownDB: + # The VTI Up-Down DB is a text-based database of space-separated "ifspecs". + # + # ifspecs can come in one of the two following formats: + # + # persistent format: <interface name> + # indicates the named interface should always be up. + # + # connection format: <interface name>:<connection name>:<protocol> + # indicates the named interface wants to be up due to an established + # connection <connection name> using the <protocol> protocol. + # + # The configuration tree and ipsec daemon connection up-down hook + # modify this file as needed and use it to determine when a + # particular event or configuration change should lead to changing + # the interface state. + + def __init__(self, f): + self._fileHandle = f + self._ifspecs = set([entry.strip() for entry in f.read().split(" ") if entry and not entry.isspace()]) + self._ifsUp = set() + self._ifsDown = set() + + def add(self, interface, connection = None, protocol = None): + """ + Adds a new entry to the DB. + + If an interface name, connection name, and protocol are supplied, + creates a connection entry. + + If only an interface name is specified, creates a persistent entry + for the given interface. + """ + ifspec = f"{interface}:{connection}:{protocol}" if (connection is not None and protocol is not None) else interface + if ifspec not in self._ifspecs: + self._ifspecs.add(ifspec) + self._ifsUp.add(interface) + self._ifsDown.discard(interface) + + def remove(self, interface, connection = None, protocol = None): + """ + Removes a matching entry from the DB. + + If no matching entry can be fonud, the operation returns successfully. + """ + ifspec = f"{interface}:{connection}:{protocol}" if (connection is not None and protocol is not None) else interface + if ifspec in self._ifspecs: + self._ifspecs.remove(ifspec) + interface_remains = False + for ifspec in self._ifspecs: + if ifspec.split(':')[0] == interface: + interface_remains = True + + if not interface_remains: + self._ifsDown.add(interface) + self._ifsUp.discard(interface) + + def wantsInterfaceUp(self, interface): + """ Returns whether the DB contains at least one entry referencing the given interface """ + for ifspec in self._ifspecs: + if ifspec.split(':')[0] == interface: + return True + + return False + + def removeAllOtherInterfaces(self, interface_list): + """ Removes all interfaces not included in the given list from the DB """ + updated_ifspecs = set([ifspec for ifspec in self._ifspecs if ifspec.split(':')[0] in interface_list]) + removed_ifspecs = self._ifspecs - updated_ifspecs + self._ifspecs = updated_ifspecs + interfaces_to_bring_down = [ifspec.split(':')[0] for ifspec in removed_ifspecs] + self._ifsDown.update(interfaces_to_bring_down) + self._ifsUp.difference_update(interfaces_to_bring_down) + + def setPersistentInterfaces(self, interface_list): + """ Updates the set of persistently up interfaces to match the given list """ + new_presistent_interfaces = set(interface_list) + current_presistent_interfaces = set([ifspec for ifspec in self._ifspecs if ':' not in ifspec]) + added_presistent_interfaces = new_presistent_interfaces - current_presistent_interfaces + removed_presistent_interfaces = current_presistent_interfaces - new_presistent_interfaces + + for interface in added_presistent_interfaces: + self.add(interface) + + for interface in removed_presistent_interfaces: + self.remove(interface) + + def commit(self, interface_dict_supplier): + """ + Writes the DB to disk and brings interfaces up and down as needed. + + Only interfaces referenced by entries modified in this DB session + are manipulated. If an interface is called to be brought up, the + provided interface_config_supplier function is invoked and expected + to return the config dictionary for the interface. + """ + from vyos.ifconfig import VTIIf + from vyos.utils.process import call + from vyos.utils.network import get_interface_config + + self._fileHandle.seek(0) + self._fileHandle.write(' '.join(self._ifspecs)) + self._fileHandle.truncate() + + for interface in self._ifsDown: + vti_link = get_interface_config(interface) + vti_link_up = (vti_link['operstate'] != 'DOWN' if 'operstate' in vti_link else False) + if vti_link_up: + call(f'sudo ip link set {interface} down') + syslog(f'Interface {interface} is admin down ...') + + self._ifsDown.clear() + + for interface in self._ifsUp: + vti_link = get_interface_config(interface) + vti_link_up = (vti_link['operstate'] != 'DOWN' if 'operstate' in vti_link else False) + if not vti_link_up: + vti = interface_dict_supplier(interface) + if 'disable' not in vti: + tmp = VTIIf(interface, bypass_vti_updown_db = True) + tmp.update(vti) + syslog(f'Interface {interface} is admin up ...') + + self._ifsUp.clear() diff --git a/smoketest/scripts/cli/test_firewall.py b/smoketest/scripts/cli/test_firewall.py index e6317050c..551f8ce65 100755 --- a/smoketest/scripts/cli/test_firewall.py +++ b/smoketest/scripts/cli/test_firewall.py @@ -695,13 +695,21 @@ class TestFirewall(VyOSUnitTestSHIM.TestCase): self.verify_nftables_chain([['accept']], 'ip vyos_conntrack', 'FW_CONNTRACK') self.verify_nftables_chain([['return']], 'ip6 vyos_conntrack', 'FW_CONNTRACK') - def test_bridge_basic_rules(self): + def test_bridge_firewall(self): name = 'smoketest' interface_in = 'eth0' mac_address = '00:53:00:00:00:01' vlan_id = '12' vlan_prior = '3' + # Check bridge-nf-call-iptables default value: 0 + self.assertEqual(get_sysctl('net.bridge.bridge-nf-call-iptables'), '0') + self.assertEqual(get_sysctl('net.bridge.bridge-nf-call-ip6tables'), '0') + + self.cli_set(['firewall', 'group', 'ipv6-address-group', 'AGV6', 'address', '2001:db1::1']) + self.cli_set(['firewall', 'global-options', 'state-policy', 'established', 'action', 'accept']) + self.cli_set(['firewall', 'global-options', 'apply-to-bridged-traffic', 'ipv4']) + self.cli_set(['firewall', 'bridge', 'name', name, 'default-action', 'accept']) self.cli_set(['firewall', 'bridge', 'name', name, 'default-log']) self.cli_set(['firewall', 'bridge', 'name', name, 'rule', '1', 'action', 'accept']) @@ -718,20 +726,41 @@ class TestFirewall(VyOSUnitTestSHIM.TestCase): self.cli_set(['firewall', 'bridge', 'forward', 'filter', 'rule', '2', 'jump-target', name]) self.cli_set(['firewall', 'bridge', 'forward', 'filter', 'rule', '2', 'vlan', 'priority', vlan_prior]) + self.cli_set(['firewall', 'bridge', 'input', 'filter', 'rule', '1', 'action', 'accept']) + self.cli_set(['firewall', 'bridge', 'input', 'filter', 'rule', '1', 'inbound-interface', 'name', interface_in]) + self.cli_set(['firewall', 'bridge', 'input', 'filter', 'rule', '1', 'source', 'address', '192.0.2.2']) + self.cli_set(['firewall', 'bridge', 'input', 'filter', 'rule', '1', 'state', 'new']) + + self.cli_set(['firewall', 'bridge', 'prerouting', 'filter', 'rule', '1', 'action', 'notrack']) + self.cli_set(['firewall', 'bridge', 'prerouting', 'filter', 'rule', '1', 'destination', 'group', 'ipv6-address-group', 'AGV6']) + self.cli_commit() nftables_search = [ + ['set A6_AGV6'], + ['type ipv6_addr'], + ['elements', '2001:db1::1'], ['chain VYOS_FORWARD_filter'], ['type filter hook forward priority filter; policy accept;'], + ['jump VYOS_STATE_POLICY'], [f'vlan id {vlan_id}', 'accept'], [f'vlan pcp {vlan_prior}', f'jump NAME_{name}'], ['log prefix "[bri-FWD-filter-default-D]"', 'drop', 'FWD-filter default-action drop'], [f'chain NAME_{name}'], [f'ether saddr {mac_address}', f'iifname "{interface_in}"', f'log prefix "[bri-NAM-{name}-1-A]" log level crit', 'accept'], - ['accept', f'{name} default-action accept'] + ['accept', f'{name} default-action accept'], + ['chain VYOS_INPUT_filter'], + ['type filter hook input priority filter; policy accept;'], + ['ct state new', 'ip saddr 192.0.2.2', f'iifname "{interface_in}"', 'accept'], + ['chain VYOS_PREROUTING_filter'], + ['type filter hook prerouting priority filter; policy accept;'], + ['ip6 daddr @A6_AGV6', 'notrack'] ] self.verify_nftables(nftables_search, 'bridge vyos_filter') + ## Check bridge-nf-call-iptables is set to 1, and for ipv6 remains on default 0 + self.assertEqual(get_sysctl('net.bridge.bridge-nf-call-iptables'), '1') + self.assertEqual(get_sysctl('net.bridge.bridge-nf-call-ip6tables'), '0') def test_source_validation(self): # Strict diff --git a/smoketest/scripts/cli/test_interfaces_vti.py b/smoketest/scripts/cli/test_interfaces_vti.py index 871ac650b..8d90ca5ad 100755 --- a/smoketest/scripts/cli/test_interfaces_vti.py +++ b/smoketest/scripts/cli/test_interfaces_vti.py @@ -39,7 +39,8 @@ class VTIInterfaceTest(BasicInterfaceTest.TestCase): self.cli_commit() - # VTI interface are always down and only brought up by IPSec + # VTI interfaces are default down and only brought up when an + # IPSec connection is configured to use them for intf in self._interfaces: self.assertTrue(is_intf_addr_assigned(intf, addr)) self.assertEqual(Interface(intf).get_admin_state(), 'down') diff --git a/smoketest/scripts/cli/test_policy_route.py b/smoketest/scripts/cli/test_policy_route.py index 462fc24d0..797ab9770 100755 --- a/smoketest/scripts/cli/test_policy_route.py +++ b/smoketest/scripts/cli/test_policy_route.py @@ -25,6 +25,8 @@ conn_mark = '555' conn_mark_set = '111' table_mark_offset = 0x7fffffff table_id = '101' +vrf = 'PBRVRF' +vrf_table_id = '102' interface = 'eth0' interface_wc = 'ppp*' interface_ip = '172.16.10.1/24' @@ -39,11 +41,14 @@ class TestPolicyRoute(VyOSUnitTestSHIM.TestCase): cls.cli_set(cls, ['interfaces', 'ethernet', interface, 'address', interface_ip]) cls.cli_set(cls, ['protocols', 'static', 'table', table_id, 'route', '0.0.0.0/0', 'interface', interface]) + + cls.cli_set(cls, ['vrf', 'name', vrf, 'table', vrf_table_id]) @classmethod def tearDownClass(cls): cls.cli_delete(cls, ['interfaces', 'ethernet', interface, 'address', interface_ip]) cls.cli_delete(cls, ['protocols', 'static', 'table', table_id]) + cls.cli_delete(cls, ['vrf', 'name', vrf]) super(TestPolicyRoute, cls).tearDownClass() @@ -180,6 +185,50 @@ class TestPolicyRoute(VyOSUnitTestSHIM.TestCase): self.verify_rules(ip_rule_search) + def test_pbr_vrf(self): + self.cli_set(['policy', 'route', 'smoketest', 'rule', '1', 'protocol', 'tcp']) + self.cli_set(['policy', 'route', 'smoketest', 'rule', '1', 'destination', 'port', '8888']) + self.cli_set(['policy', 'route', 'smoketest', 'rule', '1', 'tcp', 'flags', 'syn']) + self.cli_set(['policy', 'route', 'smoketest', 'rule', '1', 'tcp', 'flags', 'not', 'ack']) + self.cli_set(['policy', 'route', 'smoketest', 'rule', '1', 'set', 'vrf', vrf]) + self.cli_set(['policy', 'route6', 'smoketest6', 'rule', '1', 'protocol', 'tcp_udp']) + self.cli_set(['policy', 'route6', 'smoketest6', 'rule', '1', 'destination', 'port', '8888']) + self.cli_set(['policy', 'route6', 'smoketest6', 'rule', '1', 'set', 'vrf', vrf]) + + self.cli_set(['policy', 'route', 'smoketest', 'interface', interface]) + self.cli_set(['policy', 'route6', 'smoketest6', 'interface', interface]) + + self.cli_commit() + + mark_hex = "{0:#010x}".format(table_mark_offset - int(vrf_table_id)) + + # IPv4 + + nftables_search = [ + [f'iifname "{interface}"', 'jump VYOS_PBR_UD_smoketest'], + ['tcp flags syn / syn,ack', 'tcp dport 8888', 'meta mark set ' + mark_hex] + ] + + self.verify_nftables(nftables_search, 'ip vyos_mangle') + + # IPv6 + + nftables6_search = [ + [f'iifname "{interface}"', 'jump VYOS_PBR6_UD_smoketest'], + ['meta l4proto { tcp, udp }', 'th dport 8888', 'meta mark set ' + mark_hex] + ] + + self.verify_nftables(nftables6_search, 'ip6 vyos_mangle') + + # IP rule fwmark -> table + + ip_rule_search = [ + ['fwmark ' + hex(table_mark_offset - int(vrf_table_id)), 'lookup ' + vrf] + ] + + self.verify_rules(ip_rule_search) + + def test_pbr_matching_criteria(self): self.cli_set(['policy', 'route', 'smoketest', 'default-log']) self.cli_set(['policy', 'route', 'smoketest', 'rule', '1', 'protocol', 'udp']) diff --git a/smoketest/scripts/cli/test_protocols_static_multicast.py b/smoketest/scripts/cli/test_protocols_static_multicast.py new file mode 100755 index 000000000..9fdda236f --- /dev/null +++ b/smoketest/scripts/cli/test_protocols_static_multicast.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import unittest + +from base_vyostest_shim import VyOSUnitTestSHIM + + +base_path = ['protocols', 'static', 'multicast'] + + +class TestProtocolsStaticMulticast(VyOSUnitTestSHIM.TestCase): + + def tearDown(self): + self.cli_delete(base_path) + self.cli_commit() + + mroute = self.getFRRconfig('ip mroute', end='') + self.assertFalse(mroute) + + def test_01_static_multicast(self): + + self.cli_set(base_path + ['route', '224.202.0.0/24', 'next-hop', '224.203.0.1']) + self.cli_set(base_path + ['interface-route', '224.203.0.0/24', 'next-hop-interface', 'eth0']) + + self.cli_commit() + + # Verify FRR bgpd configuration + frrconfig = self.getFRRconfig('ip mroute', end='') + + self.assertIn('ip mroute 224.202.0.0/24 224.203.0.1', frrconfig) + self.assertIn('ip mroute 224.203.0.0/24 eth0', frrconfig) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/smoketest/scripts/cli/test_vpn_ipsec.py b/smoketest/scripts/cli/test_vpn_ipsec.py index 2dc66485b..3b8687b93 100755 --- a/smoketest/scripts/cli/test_vpn_ipsec.py +++ b/smoketest/scripts/cli/test_vpn_ipsec.py @@ -20,6 +20,7 @@ import unittest from base_vyostest_shim import VyOSUnitTestSHIM from vyos.configsession import ConfigSessionError +from vyos.ifconfig import Interface from vyos.utils.process import process_named_running from vyos.utils.file import read_file @@ -140,6 +141,7 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase): self.cli_delete(base_path) self.cli_delete(tunnel_path) + self.cli_delete(vti_path) self.cli_commit() # Check for no longer running process @@ -342,6 +344,12 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase): for line in swanctl_secrets_lines: self.assertRegex(swanctl_conf, fr'{line}') + # Site-to-site interfaces should start out as 'down' + self.assertEqual(Interface(vti).get_admin_state(), 'down') + + # Disable PKI + self.tearDownPKI() + def test_dmvpn(self): tunnel_if = 'tun100' @@ -478,9 +486,6 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase): self.assertTrue(os.path.exists(os.path.join(CA_PATH, f'{int_ca_name}.pem'))) self.assertTrue(os.path.exists(os.path.join(CERT_PATH, f'{peer_name}.pem'))) - # There is only one VTI test so no need to delete this globally in tearDown() - self.cli_delete(vti_path) - # Disable PKI self.tearDownPKI() @@ -1086,5 +1091,269 @@ class TestVPNIPsec(VyOSUnitTestSHIM.TestCase): self.tearDownPKI() + def test_remote_access_pool_range(self): + # Same as test_remote_access but using an IP pool range instead of prefix + self.setupPKI() + + ike_group = 'IKE-RW' + esp_group = 'ESP-RW' + + conn_name = 'vyos-rw' + local_address = '192.0.2.1' + ip_pool_name = 'ra-rw-ipv4' + username = 'vyos' + password = 'secret' + ike_lifetime = '7200' + eap_lifetime = '3600' + local_id = 'ipsec.vyos.net' + + name_servers = ['172.16.254.100', '172.16.254.101'] + range_start = '172.16.250.2' + range_stop = '172.16.250.254' + + # IKE + self.cli_set(base_path + ['ike-group', ike_group, 'key-exchange', 'ikev2']) + self.cli_set(base_path + ['ike-group', ike_group, 'lifetime', ike_lifetime]) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '1', 'dh-group', '14']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '1', 'encryption', 'aes256']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '1', 'hash', 'sha512']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '2', 'dh-group', '14']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '2', 'encryption', 'aes256']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '2', 'hash', 'sha256']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '3', 'dh-group', '2']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '3', 'encryption', 'aes256']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '3', 'hash', 'sha256']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '10', 'dh-group', '14']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '10', 'encryption', 'aes128gcm128']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '10', 'hash', 'sha256']) + + # ESP + self.cli_set(base_path + ['esp-group', esp_group, 'lifetime', eap_lifetime]) + self.cli_set(base_path + ['esp-group', esp_group, 'pfs', 'disable']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '1', 'encryption', 'aes256']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '1', 'hash', 'sha512']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '2', 'encryption', 'aes256']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '2', 'hash', 'sha384']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '3', 'encryption', 'aes256']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '3', 'hash', 'sha256']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '4', 'encryption', 'aes256']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '4', 'hash', 'sha1']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '10', 'encryption', 'aes128gcm128']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '10', 'hash', 'sha256']) + + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'local-id', local_id]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'local-users', 'username', username, 'password', password]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'server-mode', 'x509']) + + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'x509', 'certificate', peer_name]) + # verify() - CA cert required for x509 auth + with self.assertRaises(ConfigSessionError): + self.cli_commit() + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'x509', 'ca-certificate', ca_name]) + + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'esp-group', esp_group]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'ike-group', ike_group]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'local-address', local_address]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'pool', ip_pool_name]) + + for ns in name_servers: + self.cli_set(base_path + ['remote-access', 'pool', ip_pool_name, 'name-server', ns]) + self.cli_set(base_path + ['remote-access', 'pool', ip_pool_name, 'range', 'start', range_start]) + self.cli_set(base_path + ['remote-access', 'pool', ip_pool_name, 'range', 'stop', range_stop]) + + self.cli_commit() + + # verify applied configuration + swanctl_conf = read_file(swanctl_file) + swanctl_lines = [ + f'{conn_name}', + f'remote_addrs = %any', + f'local_addrs = {local_address}', + f'proposals = aes256-sha512-modp2048,aes256-sha256-modp2048,aes256-sha256-modp1024,aes128gcm128-sha256-modp2048', + f'version = 2', + f'send_certreq = no', + f'rekey_time = {ike_lifetime}s', + f'keyingtries = 0', + f'pools = {ip_pool_name}', + f'id = "{local_id}"', + f'auth = pubkey', + f'certs = peer1.pem', + f'auth = eap-mschapv2', + f'eap_id = %any', + f'esp_proposals = aes256-sha512,aes256-sha384,aes256-sha256,aes256-sha1,aes128gcm128-sha256', + f'life_time = {eap_lifetime}s', + f'dpd_action = clear', + f'replay_window = 32', + f'inactivity = 28800', + f'local_ts = 0.0.0.0/0,::/0', + ] + for line in swanctl_lines: + self.assertIn(line, swanctl_conf) + + swanctl_secrets_lines = [ + f'eap-{conn_name}-{username}', + f'secret = "{password}"', + f'id-{conn_name}-{username} = "{username}"', + ] + for line in swanctl_secrets_lines: + self.assertIn(line, swanctl_conf) + + swanctl_pool_lines = [ + f'{ip_pool_name}', + f'addrs = {range_start}-{range_stop}', + f'dns = {",".join(name_servers)}', + ] + for line in swanctl_pool_lines: + self.assertIn(line, swanctl_conf) + + # Check Root CA, Intermediate CA and Peer cert/key pair is present + self.assertTrue(os.path.exists(os.path.join(CA_PATH, f'{ca_name}.pem'))) + self.assertTrue(os.path.exists(os.path.join(CERT_PATH, f'{peer_name}.pem'))) + + self.tearDownPKI() + + def test_remote_access_vti(self): + # Set up and use a VTI interface for the remote access VPN + self.setupPKI() + + ike_group = 'IKE-RW' + esp_group = 'ESP-RW' + + conn_name = 'vyos-rw' + local_address = '192.0.2.1' + vti = 'vti10' + ip_pool_name = 'ra-rw-ipv4' + username = 'vyos' + password = 'secret' + ike_lifetime = '7200' + eap_lifetime = '3600' + local_id = 'ipsec.vyos.net' + + name_servers = ['10.1.1.1'] + range_start = '10.1.1.10' + range_stop = '10.1.1.254' + + # VTI interface + self.cli_set(vti_path + [vti, 'address', '10.1.1.1/24']) + + # IKE + self.cli_set(base_path + ['ike-group', ike_group, 'key-exchange', 'ikev2']) + self.cli_set(base_path + ['ike-group', ike_group, 'lifetime', ike_lifetime]) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '1', 'dh-group', '14']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '1', 'encryption', 'aes256']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '1', 'hash', 'sha512']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '2', 'dh-group', '14']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '2', 'encryption', 'aes256']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '2', 'hash', 'sha256']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '3', 'dh-group', '2']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '3', 'encryption', 'aes256']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '3', 'hash', 'sha256']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '10', 'dh-group', '14']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '10', 'encryption', 'aes128gcm128']) + self.cli_set(base_path + ['ike-group', ike_group, 'proposal', '10', 'hash', 'sha256']) + + # ESP + self.cli_set(base_path + ['esp-group', esp_group, 'lifetime', eap_lifetime]) + self.cli_set(base_path + ['esp-group', esp_group, 'pfs', 'disable']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '1', 'encryption', 'aes256']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '1', 'hash', 'sha512']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '2', 'encryption', 'aes256']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '2', 'hash', 'sha384']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '3', 'encryption', 'aes256']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '3', 'hash', 'sha256']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '4', 'encryption', 'aes256']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '4', 'hash', 'sha1']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '10', 'encryption', 'aes128gcm128']) + self.cli_set(base_path + ['esp-group', esp_group, 'proposal', '10', 'hash', 'sha256']) + + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'local-id', local_id]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'local-users', 'username', username, 'password', password]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'server-mode', 'x509']) + + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'x509', 'certificate', peer_name]) + # verify() - CA cert required for x509 auth + with self.assertRaises(ConfigSessionError): + self.cli_commit() + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'authentication', 'x509', 'ca-certificate', ca_name]) + + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'esp-group', esp_group]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'ike-group', ike_group]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'bind', vti]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'local-address', local_address]) + self.cli_set(base_path + ['remote-access', 'connection', conn_name, 'pool', ip_pool_name]) + + for ns in name_servers: + self.cli_set(base_path + ['remote-access', 'pool', ip_pool_name, 'name-server', ns]) + self.cli_set(base_path + ['remote-access', 'pool', ip_pool_name, 'range', 'start', range_start]) + self.cli_set(base_path + ['remote-access', 'pool', ip_pool_name, 'range', 'stop', range_stop]) + + self.cli_commit() + + # verify applied configuration + swanctl_conf = read_file(swanctl_file) + + if_id = vti.lstrip('vti') + # The key defaults to 0 and will match any policies which similarly do + # not have a lookup key configuration - thus we shift the key by one + # to also support a vti0 interface + if_id = str(int(if_id) +1) + + swanctl_lines = [ + f'{conn_name}', + f'remote_addrs = %any', + f'local_addrs = {local_address}', + f'proposals = aes256-sha512-modp2048,aes256-sha256-modp2048,aes256-sha256-modp1024,aes128gcm128-sha256-modp2048', + f'version = 2', + f'send_certreq = no', + f'rekey_time = {ike_lifetime}s', + f'keyingtries = 0', + f'pools = {ip_pool_name}', + f'id = "{local_id}"', + f'auth = pubkey', + f'certs = peer1.pem', + f'auth = eap-mschapv2', + f'eap_id = %any', + f'esp_proposals = aes256-sha512,aes256-sha384,aes256-sha256,aes256-sha1,aes128gcm128-sha256', + f'life_time = {eap_lifetime}s', + f'dpd_action = clear', + f'replay_window = 32', + f'if_id_in = {if_id}', # will be 11 for vti10 - shifted by one + f'if_id_out = {if_id}', + f'inactivity = 28800', + f'local_ts = 0.0.0.0/0,::/0', + ] + for line in swanctl_lines: + self.assertIn(line, swanctl_conf) + + swanctl_secrets_lines = [ + f'eap-{conn_name}-{username}', + f'secret = "{password}"', + f'id-{conn_name}-{username} = "{username}"', + ] + for line in swanctl_secrets_lines: + self.assertIn(line, swanctl_conf) + + swanctl_pool_lines = [ + f'{ip_pool_name}', + f'addrs = {range_start}-{range_stop}', + f'dns = {",".join(name_servers)}', + ] + for line in swanctl_pool_lines: + self.assertIn(line, swanctl_conf) + + # Check Root CA, Intermediate CA and Peer cert/key pair is present + self.assertTrue(os.path.exists(os.path.join(CA_PATH, f'{ca_name}.pem'))) + self.assertTrue(os.path.exists(os.path.join(CERT_PATH, f'{peer_name}.pem'))) + + # Remote access interfaces should be set to 'up' during configure + self.assertEqual(Interface(vti).get_admin_state(), 'up') + + # Delete the connection to verify the VTI interfaces is taken down + self.cli_delete(base_path + ['remote-access', 'connection', conn_name]) + self.cli_commit() + self.assertEqual(Interface(vti).get_admin_state(), 'down') + + self.tearDownPKI() + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/smoketest/scripts/cli/test_vrf.py b/smoketest/scripts/cli/test_vrf.py index 176882ca5..2bb6c91c1 100755 --- a/smoketest/scripts/cli/test_vrf.py +++ b/smoketest/scripts/cli/test_vrf.py @@ -19,6 +19,8 @@ import os import unittest from base_vyostest_shim import VyOSUnitTestSHIM +from json import loads +from jmespath import search from vyos.configsession import ConfigSessionError from vyos.ifconfig import Interface @@ -28,6 +30,7 @@ from vyos.utils.network import get_interface_config from vyos.utils.network import get_vrf_tableid from vyos.utils.network import is_intf_addr_assigned from vyos.utils.network import interface_exists +from vyos.utils.process import cmd from vyos.utils.system import sysctl_read base_path = ['vrf'] @@ -557,26 +560,39 @@ class VRFTest(VyOSUnitTestSHIM.TestCase): self.assertNotIn(f' no ipv6 nht resolve-via-default', frrconfig) def test_vrf_conntrack(self): - table = '1000' + table = '8710' nftables_rules = { 'vrf_zones_ct_in': ['ct original zone set iifname map @ct_iface_map'], 'vrf_zones_ct_out': ['ct original zone set oifname map @ct_iface_map'] } - self.cli_set(base_path + ['name', 'blue', 'table', table]) + self.cli_set(base_path + ['name', 'randomVRF', 'table', '1000']) self.cli_commit() # Conntrack rules should not be present for chain, rule in nftables_rules.items(): self.verify_nftables_chain(rule, 'inet vrf_zones', chain, inverse=True) + # conntrack is only enabled once NAT, NAT66 or firewalling is enabled self.cli_set(['nat']) - self.cli_commit() + + for vrf in vrfs: + base = base_path + ['name', vrf] + self.cli_set(base + ['table', table]) + table = str(int(table) + 1) + # We need the commit inside the loop to trigger the bug in T6603 + self.cli_commit() # Conntrack rules should now be present for chain, rule in nftables_rules.items(): self.verify_nftables_chain(rule, 'inet vrf_zones', chain, inverse=False) + # T6603: there should be only ONE entry for the iifname/oifname in the chains + tmp = loads(cmd('sudo nft -j list table inet vrf_zones')) + num_rules = len(search("nftables[].rule[].chain", tmp)) + # ['vrf_zones_ct_in', 'vrf_zones_ct_out'] + self.assertEqual(num_rules, 2) + self.cli_delete(['nat']) if __name__ == '__main__': diff --git a/src/completion/list_login_ttys.py b/src/completion/list_login_ttys.py new file mode 100644 index 000000000..4d77a1b8b --- /dev/null +++ b/src/completion/list_login_ttys.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from vyos.utils.serial import get_serial_units + +if __name__ == '__main__': + # Autocomplete uses runtime state rather than the config tree, as a manual + # restart/cleanup may be needed for deleted devices. + tty_completions = [ '<text>' ] + [ x['device'] for x in get_serial_units() if 'device' in x ] + print(' '.join(tty_completions)) + + diff --git a/src/conf_mode/firewall.py b/src/conf_mode/firewall.py index 352d5cbb1..02bf00bcc 100755 --- a/src/conf_mode/firewall.py +++ b/src/conf_mode/firewall.py @@ -36,6 +36,7 @@ from vyos.utils.process import cmd from vyos.utils.process import rc_cmd from vyos import ConfigError from vyos import airbag +from subprocess import run as subp_run airbag.enable() @@ -47,7 +48,12 @@ valid_groups = [ 'domain_group', 'network_group', 'port_group', - 'interface_group' + 'interface_group', + ## Added for group ussage in bridge firewall + 'ipv4_address_group', + 'ipv6_address_group', + 'ipv4_network_group', + 'ipv6_network_group' ] nested_group_types = [ @@ -128,41 +134,32 @@ def get_config(config=None): return firewall -def verify_jump_target(firewall, root_chain, jump_target, ipv6, recursive=False): +def verify_jump_target(firewall, hook, jump_target, family, recursive=False): targets_seen = [] targets_pending = [jump_target] while targets_pending: target = targets_pending.pop() - if not ipv6: - if target not in dict_search_args(firewall, 'ipv4', 'name'): - raise ConfigError(f'Invalid jump-target. Firewall name {target} does not exist on the system') - target_rules = dict_search_args(firewall, 'ipv4', 'name', target, 'rule') - else: - if target not in dict_search_args(firewall, 'ipv6', 'name'): - raise ConfigError(f'Invalid jump-target. Firewall ipv6 name {target} does not exist on the system') - target_rules = dict_search_args(firewall, 'ipv6', 'name', target, 'rule') + if 'name' not in firewall[family]: + raise ConfigError(f'Invalid jump-target. Firewall {family} name {target} does not exist on the system') + elif target not in dict_search_args(firewall, family, 'name'): + raise ConfigError(f'Invalid jump-target. Firewall {family} name {target} does not exist on the system') - no_ipsec_in = root_chain in ('output', ) + target_rules = dict_search_args(firewall, family, 'name', target, 'rule') + no_ipsec_in = hook in ('output', ) if target_rules: for target_rule_conf in target_rules.values(): # Output hook types will not tolerate 'meta ipsec exists' matches even in jump targets: if no_ipsec_in and (dict_search_args(target_rule_conf, 'ipsec', 'match_ipsec_in') is not None \ or dict_search_args(target_rule_conf, 'ipsec', 'match_none_in') is not None): - if not ipv6: - raise ConfigError(f'Invalid jump-target for {root_chain}. Firewall name {target} rules contain incompatible ipsec inbound matches') - else: - raise ConfigError(f'Invalid jump-target for {root_chain}. Firewall ipv6 name {target} rules contain incompatible ipsec inbound matches') + raise ConfigError(f'Invalid jump-target for {hook}. Firewall {family} name {target} rules contain incompatible ipsec inbound matches') # Make sure we're not looping back on ourselves somewhere: if recursive and 'jump_target' in target_rule_conf: child_target = target_rule_conf['jump_target'] if child_target in targets_seen: - if not ipv6: - raise ConfigError(f'Loop detected in jump-targets, firewall name {target} refers to previously traversed name {child_target}') - else: - raise ConfigError(f'Loop detected in jump-targets, firewall ipv6 name {target} refers to previously traversed ipv6 name {child_target}') + raise ConfigError(f'Loop detected in jump-targets, firewall {family} name {target} refers to previously traversed {family} name {child_target}') targets_pending.append(child_target) if len(targets_seen) == 7: path_txt = ' -> '.join(targets_seen) @@ -170,7 +167,7 @@ def verify_jump_target(firewall, root_chain, jump_target, ipv6, recursive=False) targets_seen.append(target) -def verify_rule(firewall, chain_name, rule_conf, ipv6): +def verify_rule(firewall, family, hook, priority, rule_id, rule_conf): if 'action' not in rule_conf: raise ConfigError('Rule action must be defined') @@ -181,10 +178,10 @@ def verify_rule(firewall, chain_name, rule_conf, ipv6): if 'jump' not in rule_conf['action']: raise ConfigError('jump-target defined, but action jump needed and it is not defined') target = rule_conf['jump_target'] - if chain_name != 'name': # This is a bit clumsy, but consolidates a chunk of code. - verify_jump_target(firewall, chain_name, target, ipv6, recursive=True) + if hook != 'name': # This is a bit clumsy, but consolidates a chunk of code. + verify_jump_target(firewall, hook, target, family, recursive=True) else: - verify_jump_target(firewall, chain_name, target, ipv6, recursive=False) + verify_jump_target(firewall, hook, target, family, recursive=False) if rule_conf['action'] == 'offload': if 'offload_target' not in rule_conf: @@ -246,9 +243,9 @@ def verify_rule(firewall, chain_name, rule_conf, ipv6): raise ConfigError(f'Cannot match a tcp flag as set and not set') if 'protocol' in rule_conf: - if rule_conf['protocol'] == 'icmp' and ipv6: + if rule_conf['protocol'] == 'icmp' and family == 'ipv6': raise ConfigError(f'Cannot match IPv4 ICMP protocol on IPv6, use ipv6-icmp') - if rule_conf['protocol'] == 'ipv6-icmp' and not ipv6: + if rule_conf['protocol'] == 'ipv6-icmp' and family == 'ipv4': raise ConfigError(f'Cannot match IPv6 ICMP protocol on IPv4, use icmp') for side in ['destination', 'source']: @@ -266,7 +263,18 @@ def verify_rule(firewall, chain_name, rule_conf, ipv6): if group in side_conf['group']: group_name = side_conf['group'][group] - fw_group = f'ipv6_{group}' if ipv6 and group in ['address_group', 'network_group'] else group + if family == 'ipv6' and group in ['address_group', 'network_group']: + fw_group = f'ipv6_{group}' + elif family == 'bridge': + if group =='ipv4_address_group': + fw_group = 'address_group' + elif group == 'ipv4_network_group': + fw_group = 'network_group' + else: + fw_group = group + else: + fw_group = group + error_group = fw_group.replace("_", "-") if group in ['address_group', 'network_group', 'domain_group']: @@ -302,7 +310,7 @@ def verify_rule(firewall, chain_name, rule_conf, ipv6): raise ConfigError(f'Dynamic address group must be defined.') else: target = rule_conf['add_address_to_group'][type]['address_group'] - fwall_group = 'ipv6_address_group' if ipv6 else 'address_group' + fwall_group = 'ipv6_address_group' if family == 'ipv6' else 'address_group' group_obj = dict_search_args(firewall, 'group', 'dynamic_group', fwall_group, target) if group_obj is None: raise ConfigError(f'Invalid dynamic address group on firewall rule') @@ -379,43 +387,25 @@ def verify(firewall): for group_name, group in groups.items(): verify_nested_group(group_name, group, groups, []) - if 'ipv4' in firewall: - for name in ['name','forward','input','output', 'prerouting']: - if name in firewall['ipv4']: - for name_id, name_conf in firewall['ipv4'][name].items(): - if 'jump' in name_conf['default_action'] and 'default_jump_target' not in name_conf: - raise ConfigError('default-action set to jump, but no default-jump-target specified') - if 'default_jump_target' in name_conf: - target = name_conf['default_jump_target'] - if 'jump' not in name_conf['default_action']: - raise ConfigError('default-jump-target defined, but default-action jump needed and it is not defined') - if name_conf['default_jump_target'] == name_id: - raise ConfigError(f'Loop detected on default-jump-target.') - verify_jump_target(firewall, name, target, False, recursive=True) - - if 'rule' in name_conf: - for rule_id, rule_conf in name_conf['rule'].items(): - verify_rule(firewall, name, rule_conf, False) - - if 'ipv6' in firewall: - for name in ['name','forward','input','output', 'prerouting']: - if name in firewall['ipv6']: - for name_id, name_conf in firewall['ipv6'][name].items(): - if 'jump' in name_conf['default_action'] and 'default_jump_target' not in name_conf: - raise ConfigError('default-action set to jump, but no default-jump-target specified') - if 'default_jump_target' in name_conf: - target = name_conf['default_jump_target'] - if 'jump' not in name_conf['default_action']: - raise ConfigError('default-jump-target defined, but default-action jump needed and it is not defined') - if name_conf['default_jump_target'] == name_id: - raise ConfigError(f'Loop detected on default-jump-target.') - verify_jump_target(firewall, name, target, True, recursive=True) - - if 'rule' in name_conf: - for rule_id, rule_conf in name_conf['rule'].items(): - verify_rule(firewall, name, rule_conf, True) - - #### ZONESSSS + for family in ['ipv4', 'ipv6', 'bridge']: + if family in firewall: + for chain in ['name','forward','input','output', 'prerouting']: + if chain in firewall[family]: + for priority, priority_conf in firewall[family][chain].items(): + if 'jump' in priority_conf['default_action'] and 'default_jump_target' not in priority_conf: + raise ConfigError('default-action set to jump, but no default-jump-target specified') + if 'default_jump_target' in priority_conf: + target = priority_conf['default_jump_target'] + if 'jump' not in priority_conf['default_action']: + raise ConfigError('default-jump-target defined, but default-action jump needed and it is not defined') + if priority_conf['default_jump_target'] == priority: + raise ConfigError(f'Loop detected on default-jump-target.') + if target not in dict_search_args(firewall[family], 'name'): + raise ConfigError(f'Invalid jump-target. Firewall name {target} does not exist on the system') + if 'rule' in priority_conf: + for rule_id, rule_conf in priority_conf['rule'].items(): + verify_rule(firewall, family, chain, priority, rule_id, rule_conf) + local_zone = False zone_interfaces = [] @@ -495,8 +485,53 @@ def generate(firewall): render(sysctl_file, 'firewall/sysctl-firewall.conf.j2', firewall) return None +def parse_firewall_error(output): + # Define the regex patterns to extract the error message and the comment + error_pattern = re.compile(r'Error:\s*(.*?)\n') + comment_pattern = re.compile(r'comment\s+"([^"]+)"') + error_output = [] + + # Find all error messages in the output + error_matches = error_pattern.findall(output) + # Find all comment matches in the output + comment_matches = comment_pattern.findall(output) + + if not error_matches or not comment_matches: + raise ConfigError(f'Unknown firewall error detected: {output}') + + error_output.append('Fail to apply firewall') + # Loop over the matches and process them + for error_message, comment in zip(error_matches, comment_matches): + # Parse the comment + parsed_entries = comment.split('-') + family = 'bridge' if parsed_entries[0] == 'bri' else parsed_entries[0] + if parsed_entries[1] == 'NAM': + chain = 'name' + elif parsed_entries[1] == 'FWD': + chain = 'forward' + elif parsed_entries[1] == 'INP': + chain = 'input' + elif parsed_entries[1] == 'OUT': + chain = 'output' + elif parsed_entries[1] == 'PRE': + chain = 'prerouting' + error_output.append(f'Error found on: firewall {family} {chain} {parsed_entries[2]} rule {parsed_entries[3]}') + error_output.append(f'\tError message: {error_message.strip()}') + + raise ConfigError('\n'.join(error_output)) + def apply(firewall): + # Use nft -c option to check current configuration file + completed_process = subp_run(['nft', '-c', '--file', nftables_conf], capture_output=True) + install_result = completed_process.returncode + if install_result == 1: + # We need to handle firewall error + output = completed_process.stderr + parse_firewall_error(output.decode()) + + # No error detected during check, we can apply the new configuration install_result, output = rc_cmd(f'nft --file {nftables_conf}') + # Double check just in case if install_result == 1: raise ConfigError(f'Failed to apply firewall: {output}') diff --git a/src/conf_mode/interfaces_l2tpv3.py b/src/conf_mode/interfaces_l2tpv3.py index b9f827bee..f0a70436e 100755 --- a/src/conf_mode/interfaces_l2tpv3.py +++ b/src/conf_mode/interfaces_l2tpv3.py @@ -86,6 +86,8 @@ def generate(l2tpv3): return None def apply(l2tpv3): + check_kmod(k_mod) + # Check if L2TPv3 interface already exists if interface_exists(l2tpv3['ifname']): # L2TPv3 is picky when changing tunnels/sessions, thus we can simply @@ -102,7 +104,6 @@ def apply(l2tpv3): if __name__ == '__main__': try: - check_kmod(k_mod) c = get_config() verify(c) generate(c) diff --git a/src/conf_mode/interfaces_wireguard.py b/src/conf_mode/interfaces_wireguard.py index 0e0b77877..7abdfdbfa 100755 --- a/src/conf_mode/interfaces_wireguard.py +++ b/src/conf_mode/interfaces_wireguard.py @@ -103,7 +103,12 @@ def verify(wireguard): public_keys.append(peer['public_key']) +def generate(wireguard): + return None + def apply(wireguard): + check_kmod('wireguard') + if 'rebuild_required' in wireguard or 'deleted' in wireguard: wg = WireGuardIf(**wireguard) # WireGuard only supports peer removal based on the configured public-key, @@ -123,7 +128,6 @@ def apply(wireguard): if __name__ == '__main__': try: - check_kmod('wireguard') c = get_config() verify(c) apply(c) diff --git a/src/conf_mode/interfaces_wireless.py b/src/conf_mode/interfaces_wireless.py index f35a250cb..d24675ee6 100755 --- a/src/conf_mode/interfaces_wireless.py +++ b/src/conf_mode/interfaces_wireless.py @@ -239,6 +239,8 @@ def verify(wifi): return None def generate(wifi): + check_kmod('mac80211') + interface = wifi['ifname'] # Delete config files if interface is removed @@ -333,7 +335,6 @@ def apply(wifi): if __name__ == '__main__': try: - check_kmod('mac80211') c = get_config() verify(c) generate(c) diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index f74bb217e..39803fa02 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -240,6 +240,8 @@ def generate(nat): return None def apply(nat): + check_kmod(k_mod) + cmd(f'nft --file {nftables_nat_config}') cmd(f'nft --file {nftables_static_nat_conf}') @@ -253,7 +255,6 @@ def apply(nat): if __name__ == '__main__': try: - check_kmod(k_mod) c = get_config() verify(c) generate(c) diff --git a/src/conf_mode/nat64.py b/src/conf_mode/nat64.py index 32a1c47d1..df501ce7f 100755 --- a/src/conf_mode/nat64.py +++ b/src/conf_mode/nat64.py @@ -46,7 +46,12 @@ def get_config(config: Config | None = None) -> None: base = ["nat64"] nat64 = config.get_config_dict(base, key_mangling=("-", "_"), get_first_key=True) - base_src = base + ["source", "rule"] + return nat64 + + +def verify(nat64) -> None: + check_kmod(["jool"]) + base_src = ["nat64", "source", "rule"] # Load in existing instances so we can destroy any unknown lines = cmd("jool instance display --csv").splitlines() @@ -76,12 +81,8 @@ def get_config(config: Config | None = None) -> None: ): rules[num]["recreate"] = True - return nat64 - - -def verify(nat64) -> None: if not nat64: - # no need to verify the CLI as nat64 is going to be deactivated + # nothing left to do return if dict_search("source.rule", nat64): @@ -128,6 +129,9 @@ def verify(nat64) -> None: def generate(nat64) -> None: + if not nat64: + return + os.makedirs(JOOL_CONFIG_DIR, exist_ok=True) if dict_search("source.rule", nat64): @@ -184,6 +188,7 @@ def generate(nat64) -> None: def apply(nat64) -> None: if not nat64: + unload_kmod(['jool']) return if dict_search("source.rule", nat64): @@ -211,7 +216,6 @@ def apply(nat64) -> None: if __name__ == "__main__": try: - check_kmod(["jool"]) c = get_config() verify(c) generate(c) diff --git a/src/conf_mode/nat66.py b/src/conf_mode/nat66.py index 075738dad..c44320f36 100755 --- a/src/conf_mode/nat66.py +++ b/src/conf_mode/nat66.py @@ -112,6 +112,8 @@ def apply(nat): if not nat: return None + check_kmod(k_mod) + cmd(f'nft --file {nftables_nat66_config}') call_dependents() @@ -119,7 +121,6 @@ def apply(nat): if __name__ == '__main__': try: - check_kmod(k_mod) c = get_config() verify(c) generate(c) diff --git a/src/conf_mode/policy_route.py b/src/conf_mode/policy_route.py index c58fe1bce..223175b8a 100755 --- a/src/conf_mode/policy_route.py +++ b/src/conf_mode/policy_route.py @@ -25,6 +25,9 @@ from vyos.template import render from vyos.utils.dict import dict_search_args from vyos.utils.process import cmd from vyos.utils.process import run +from vyos.utils.network import get_vrf_tableid +from vyos.defaults import rt_global_table +from vyos.defaults import rt_global_vrf from vyos import ConfigError from vyos import airbag airbag.enable() @@ -83,6 +86,9 @@ def verify_rule(policy, name, rule_conf, ipv6, rule_id): if not tcp_flags or 'syn' not in tcp_flags: raise ConfigError(f'{name} rule {rule_id}: TCP SYN flag must be set to modify TCP-MSS') + if 'vrf' in rule_conf['set'] and 'table' in rule_conf['set']: + raise ConfigError(f'{name} rule {rule_id}: Cannot set both forwarding route table and VRF') + tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') if tcp_flags: if dict_search_args(rule_conf, 'protocol') != 'tcp': @@ -152,15 +158,26 @@ def apply_table_marks(policy): for name, pol_conf in policy[route].items(): if 'rule' in pol_conf: for rule_id, rule_conf in pol_conf['rule'].items(): + vrf_table_id = None set_table = dict_search_args(rule_conf, 'set', 'table') - if set_table: + set_vrf = dict_search_args(rule_conf, 'set', 'vrf') + if set_vrf: + if set_vrf == 'default': + vrf_table_id = rt_global_vrf + else: + vrf_table_id = get_vrf_tableid(set_vrf) + elif set_table: if set_table == 'main': - set_table = '254' - if set_table in tables: + vrf_table_id = rt_global_table + else: + vrf_table_id = set_table + if vrf_table_id is not None: + vrf_table_id = int(vrf_table_id) + if vrf_table_id in tables: continue - tables.append(set_table) - table_mark = mark_offset - int(set_table) - cmd(f'{cmd_str} rule add pref {set_table} fwmark {table_mark} table {set_table}') + tables.append(vrf_table_id) + table_mark = mark_offset - vrf_table_id + cmd(f'{cmd_str} rule add pref {vrf_table_id} fwmark {table_mark} table {vrf_table_id}') def cleanup_table_marks(): for cmd_str in ['ip', 'ip -6']: diff --git a/src/conf_mode/protocols_static_multicast.py b/src/conf_mode/protocols_static_multicast.py index 7f6ae3680..d323ceb4f 100755 --- a/src/conf_mode/protocols_static_multicast.py +++ b/src/conf_mode/protocols_static_multicast.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright (C) 2020-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -14,15 +14,14 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -import os from ipaddress import IPv4Address from sys import exit from vyos import ConfigError +from vyos import frr from vyos.config import Config -from vyos.utils.process import call -from vyos.template import render +from vyos.template import render_to_string from vyos import airbag airbag.enable() @@ -92,23 +91,39 @@ def verify(mroute): if IPv4Address(route[0]) < IPv4Address('224.0.0.0'): raise ConfigError(route + " not a multicast network") + def generate(mroute): if mroute is None: return None - render(config_file, 'frr/static_mcast.frr.j2', mroute) + mroute['new_frr_config'] = render_to_string('frr/static_mcast.frr.j2', mroute) return None + def apply(mroute): if mroute is None: return None + static_daemon = 'staticd' + + frr_cfg = frr.FRRConfig() + frr_cfg.load_configuration(static_daemon) - if os.path.exists(config_file): - call(f'vtysh -d staticd -f {config_file}') - os.remove(config_file) + if 'old_mroute' in mroute: + for route_gr in mroute['old_mroute']: + for nh in mroute['old_mroute'][route_gr]: + if mroute['old_mroute'][route_gr][nh]: + frr_cfg.modify_section(f'^ip mroute {route_gr} {nh} {mroute["old_mroute"][route_gr][nh]}') + else: + frr_cfg.modify_section(f'^ip mroute {route_gr} {nh}') + + if 'new_frr_config' in mroute: + frr_cfg.add_before(frr.default_add_before, mroute['new_frr_config']) + + frr_cfg.commit_configuration(static_daemon) return None + if __name__ == '__main__': try: c = get_config() diff --git a/src/conf_mode/system_acceleration.py b/src/conf_mode/system_acceleration.py index e4b248675..d2cf44ff0 100755 --- a/src/conf_mode/system_acceleration.py +++ b/src/conf_mode/system_acceleration.py @@ -79,6 +79,9 @@ def verify(qat): if not data: raise ConfigError('No QAT acceleration device found') +def generate(qat): + return + def apply(qat): # Shutdown VPN service which can use QAT if 'ipsec' in qat: diff --git a/src/conf_mode/system_console.py b/src/conf_mode/system_console.py index 19bbb8875..b380e0521 100755 --- a/src/conf_mode/system_console.py +++ b/src/conf_mode/system_console.py @@ -19,6 +19,7 @@ from pathlib import Path from vyos.config import Config from vyos.utils.process import call +from vyos.utils.serial import restart_login_consoles from vyos.system import grub_util from vyos.template import render from vyos import ConfigError @@ -74,7 +75,6 @@ def generate(console): for root, dirs, files in os.walk(base_dir): for basename in files: if 'serial-getty' in basename: - call(f'systemctl stop {basename}') os.unlink(os.path.join(root, basename)) if not console or 'device' not in console: @@ -122,6 +122,11 @@ def apply(console): # Reload systemd manager configuration call('systemctl daemon-reload') + # Service control moved to vyos.utils.serial to unify checks and prompts. + # If users are connected, we want to show an informational message on completing + # the process, but not halt configuration processing with an interactive prompt. + restart_login_consoles(prompt_user=False, quiet=False) + if not console: return None @@ -129,13 +134,6 @@ def apply(console): # Configure screen blank powersaving on VGA console call('/usr/bin/setterm -blank 15 -powersave powerdown -powerdown 60 -term linux </dev/tty1 >/dev/tty1 2>&1') - # Start getty process on configured serial interfaces - for device in console['device']: - # Only start console if it exists on the running system. If a user - # detaches a USB serial console and reboots - it should not fail! - if os.path.exists(f'/dev/{device}'): - call(f'systemctl restart serial-getty@{device}.service') - return None if __name__ == '__main__': diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py index cf82b767f..b3e05a814 100755 --- a/src/conf_mode/vpn_ipsec.py +++ b/src/conf_mode/vpn_ipsec.py @@ -21,12 +21,15 @@ import jmespath from sys import exit from time import sleep +from ipaddress import ip_address +from netaddr import IPNetwork +from netaddr import IPRange -from vyos.base import Warning from vyos.config import Config from vyos.config import config_dict_merge from vyos.configdep import set_dependents from vyos.configdep import call_dependents +from vyos.configdict import get_interface_dict from vyos.configdict import leaf_node_changed from vyos.configverify import verify_interface_exists from vyos.configverify import dynamic_interface_pattern @@ -47,6 +50,9 @@ from vyos.utils.network import interface_exists from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args from vyos.utils.process import call +from vyos.utils.vti_updown_db import vti_updown_db_exists +from vyos.utils.vti_updown_db import open_vti_updown_db_for_create_or_update +from vyos.utils.vti_updown_db import remove_vti_updown_db from vyos import ConfigError from vyos import airbag airbag.enable() @@ -104,6 +110,8 @@ def get_config(config=None): ipsec = config_dict_merge(default_values, ipsec) ipsec['dhcp_interfaces'] = set() + ipsec['enabled_vti_interfaces'] = set() + ipsec['persistent_vti_interfaces'] = set() ipsec['dhcp_no_address'] = {} ipsec['install_routes'] = 'no' if conf.exists(base + ["options", "disable-route-autoinstall"]) else default_install_routes ipsec['interface_change'] = leaf_node_changed(conf, base + ['interface']) @@ -121,6 +129,28 @@ def get_config(config=None): ipsec['l2tp_ike_default'] = 'aes256-sha1-modp1024,3des-sha1-modp1024' ipsec['l2tp_esp_default'] = 'aes256-sha1,3des-sha1' + # Collect the interface dicts for any refernced VTI interfaces in + # case we need to bring the interface up + ipsec['vti_interface_dicts'] = {} + + if 'site_to_site' in ipsec and 'peer' in ipsec['site_to_site']: + for peer, peer_conf in ipsec['site_to_site']['peer'].items(): + if 'vti' in peer_conf: + if 'bind' in peer_conf['vti']: + vti_interface = peer_conf['vti']['bind'] + if vti_interface not in ipsec['vti_interface_dicts']: + _, vti = get_interface_dict(conf, ['interfaces', 'vti'], vti_interface) + ipsec['vti_interface_dicts'][vti_interface] = vti + + if 'remote_access' in ipsec: + if 'connection' in ipsec['remote_access']: + for name, ra_conf in ipsec['remote_access']['connection'].items(): + if 'bind' in ra_conf: + vti_interface = ra_conf['bind'] + if vti_interface not in ipsec['vti_interface_dicts']: + _, vti = get_interface_dict(conf, ['interfaces', 'vti'], vti_interface) + ipsec['vti_interface_dicts'][vti_interface] = vti + return ipsec def get_dhcp_address(iface): @@ -249,7 +279,8 @@ def verify(ipsec): if not os.path.exists(f'{dhcp_base}/dhclient_{dhcp_interface}.conf'): raise ConfigError(f"Invalid dhcp-interface on remote-access connection {name}") - ipsec['dhcp_interfaces'].add(dhcp_interface) + if 'disable' not in ra_conf: + ipsec['dhcp_interfaces'].add(dhcp_interface) address = get_dhcp_address(dhcp_interface) count = 0 @@ -304,6 +335,16 @@ def verify(ipsec): if dict_search('remote_access.radius.server', ipsec) == None: raise ConfigError('RADIUS authentication requires at least one server') + if 'bind' in ra_conf: + vti_interface = ra_conf['bind'] + if not interface_exists(vti_interface): + raise ConfigError(f'VTI interface {vti_interface} for remote-access connection {name} does not exist!') + + if 'disable' not in ra_conf: + ipsec['enabled_vti_interfaces'].add(vti_interface) + # remote access VPN interfaces are always up regardless of whether clients are connected + ipsec['persistent_vti_interfaces'].add(vti_interface) + if 'pool' in ra_conf: if {'dhcp', 'radius'} <= set(ra_conf['pool']): raise ConfigError(f'Can not use both DHCP and RADIUS for address allocation '\ @@ -330,26 +371,73 @@ def verify(ipsec): raise ConfigError(f'Requested pool "{pool}" does not exist!') if 'pool' in ipsec['remote_access']: + pool_networks = [] for pool, pool_config in ipsec['remote_access']['pool'].items(): - if 'prefix' not in pool_config: - raise ConfigError(f'Missing madatory prefix option for pool "{pool}"!') + if 'prefix' not in pool_config and 'range' not in pool_config: + raise ConfigError(f'Mandatory prefix or range must be specified for pool "{pool}"!') + + if 'prefix' in pool_config and 'range' in pool_config: + raise ConfigError(f'Only one of prefix or range can be specified for pool "{pool}"!') + + if 'prefix' in pool_config: + range_is_ipv4 = is_ipv4(pool_config['prefix']) + range_is_ipv6 = is_ipv6(pool_config['prefix']) + + net = IPNetwork(pool_config['prefix']) + start = net.first + stop = net.last + for network in pool_networks: + if start in network or stop in network: + raise ConfigError(f'Prefix for pool "{pool}" is already part of another pool\'s range!') + + tmp = IPRange(start, stop) + pool_networks.append(tmp) + + if 'range' in pool_config: + range_config = pool_config['range'] + if not {'start', 'stop'} <= set(range_config.keys()): + raise ConfigError(f'Range start and stop address must be defined for pool "{pool}"!') + + range_both_ipv4 = is_ipv4(range_config['start']) and is_ipv4(range_config['stop']) + range_both_ipv6 = is_ipv6(range_config['start']) and is_ipv6(range_config['stop']) + + if not (range_both_ipv4 or range_both_ipv6): + raise ConfigError(f'Range start and stop must be of the same address family for pool "{pool}"!') + + if ip_address(range_config['stop']) < ip_address(range_config['start']): + raise ConfigError(f'Range stop address must be greater or equal\n' \ + 'to the range\'s start address for pool "{pool}"!') + + range_is_ipv4 = is_ipv4(range_config['start']) + range_is_ipv6 = is_ipv6(range_config['start']) + + start = range_config['start'] + stop = range_config['stop'] + for network in pool_networks: + if start in network: + raise ConfigError(f'Range "{range}" start address "{start}" already part of another pool\'s range!') + if stop in network: + raise ConfigError(f'Range "{range}" stop address "{stop}" already part of another pool\'s range!') + + tmp = IPRange(start, stop) + pool_networks.append(tmp) if 'name_server' in pool_config: if len(pool_config['name_server']) > 2: raise ConfigError(f'Only two name-servers are supported for remote-access pool "{pool}"!') for ns in pool_config['name_server']: - v4_addr_and_ns = is_ipv4(ns) and not is_ipv4(pool_config['prefix']) - v6_addr_and_ns = is_ipv6(ns) and not is_ipv6(pool_config['prefix']) + v4_addr_and_ns = is_ipv4(ns) and not range_is_ipv4 + v6_addr_and_ns = is_ipv6(ns) and not range_is_ipv6 if v4_addr_and_ns or v6_addr_and_ns: - raise ConfigError('Must use both IPv4 or IPv6 addresses for pool prefix and name-server adresses!') + raise ConfigError('Must use both IPv4 or IPv6 addresses for pool prefix/range and name-server addresses!') if 'exclude' in pool_config: for exclude in pool_config['exclude']: - v4_addr_and_exclude = is_ipv4(exclude) and not is_ipv4(pool_config['prefix']) - v6_addr_and_exclude = is_ipv6(exclude) and not is_ipv6(pool_config['prefix']) + v4_addr_and_exclude = is_ipv4(exclude) and not range_is_ipv4 + v6_addr_and_exclude = is_ipv6(exclude) and not range_is_ipv6 if v4_addr_and_exclude or v6_addr_and_exclude: - raise ConfigError('Must use both IPv4 or IPv6 addresses for pool prefix and exclude prefixes!') + raise ConfigError('Must use both IPv4 or IPv6 addresses for pool prefix/range and exclude prefixes!') if 'radius' in ipsec['remote_access'] and 'server' in ipsec['remote_access']['radius']: for server, server_config in ipsec['remote_access']['radius']['server'].items(): @@ -420,7 +508,8 @@ def verify(ipsec): if not os.path.exists(f'{dhcp_base}/dhclient_{dhcp_interface}.conf'): raise ConfigError(f"Invalid dhcp-interface on site-to-site peer {peer}") - ipsec['dhcp_interfaces'].add(dhcp_interface) + if 'disable' not in peer_conf: + ipsec['dhcp_interfaces'].add(dhcp_interface) address = get_dhcp_address(dhcp_interface) count = 0 @@ -438,14 +527,12 @@ def verify(ipsec): if 'local_address' in peer_conf and 'dhcp_interface' in peer_conf: raise ConfigError(f"A single local-address or dhcp-interface is required when using VTI on site-to-site peer {peer}") - if dict_search('options.disable_route_autoinstall', - ipsec) == None: - Warning('It\'s recommended to use ipsec vti with the next command\n[set vpn ipsec option disable-route-autoinstall]') - if 'bind' in peer_conf['vti']: vti_interface = peer_conf['vti']['bind'] if not interface_exists(vti_interface): raise ConfigError(f'VTI interface {vti_interface} for site-to-site peer {peer} does not exist!') + if 'disable' not in peer_conf: + ipsec['enabled_vti_interfaces'].add(vti_interface) if 'vti' not in peer_conf and 'tunnel' not in peer_conf: raise ConfigError(f"No VTI or tunnel specified on site-to-site peer {peer}") @@ -623,9 +710,21 @@ def apply(ipsec): systemd_service = 'strongswan.service' if not ipsec: call(f'systemctl stop {systemd_service}') + + if vti_updown_db_exists(): + remove_vti_updown_db() + else: call(f'systemctl reload-or-restart {systemd_service}') + if ipsec['enabled_vti_interfaces']: + with open_vti_updown_db_for_create_or_update() as db: + db.removeAllOtherInterfaces(ipsec['enabled_vti_interfaces']) + db.setPersistentInterfaces(ipsec['persistent_vti_interfaces']) + db.commit(lambda interface: ipsec['vti_interface_dicts'][interface]) + elif vti_updown_db_exists(): + remove_vti_updown_db() + if ipsec.get('nhrp_exists', False): try: call_dependents() diff --git a/src/conf_mode/vrf.py b/src/conf_mode/vrf.py index 184725573..72b178c89 100755 --- a/src/conf_mode/vrf.py +++ b/src/conf_mode/vrf.py @@ -15,6 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from sys import exit +from jmespath import search from json import loads from vyos.config import Config @@ -70,6 +71,14 @@ def has_rule(af : str, priority : int, table : str=None): return True return False +def is_nft_vrf_zone_rule_setup() -> bool: + """ + Check if an nftables connection tracking rule already exists + """ + tmp = loads(cmd('sudo nft -j list table inet vrf_zones')) + num_rules = len(search("nftables[].rule[].chain", tmp)) + return bool(num_rules) + def vrf_interfaces(c, match): matched = [] old_level = c.get_level() @@ -264,6 +273,7 @@ def apply(vrf): if not has_rule(afi, 2000, 'l3mdev'): call(f'ip {afi} rule add pref 2000 l3mdev unreachable') + nft_vrf_zone_rule_setup = False for name, config in vrf['name'].items(): table = config['table'] if not interface_exists(name): @@ -302,7 +312,12 @@ def apply(vrf): nft_add_element = f'add element inet vrf_zones ct_iface_map {{ "{name}" : {table} }}' cmd(f'nft {nft_add_element}') - if vrf['conntrack']: + # Only call into nftables as long as there is nothing setup to avoid wasting + # CPU time and thus lenghten the commit process + if not nft_vrf_zone_rule_setup: + nft_vrf_zone_rule_setup = is_nft_vrf_zone_rule_setup() + # Install nftables conntrack rules only once + if vrf['conntrack'] and not nft_vrf_zone_rule_setup: for chain, rule in nftables_rules.items(): cmd(f'nft add rule inet vrf_zones {chain} {rule}') diff --git a/src/etc/ipsec.d/vti-up-down b/src/etc/ipsec.d/vti-up-down index 01e9543c9..e1765ae85 100755 --- a/src/etc/ipsec.d/vti-up-down +++ b/src/etc/ipsec.d/vti-up-down @@ -27,40 +27,41 @@ from syslog import LOG_INFO from vyos.configquery import ConfigTreeQuery from vyos.configdict import get_interface_dict -from vyos.ifconfig import VTIIf +from vyos.utils.commit import wait_for_commit_lock from vyos.utils.process import call -from vyos.utils.network import get_interface_config +from vyos.utils.vti_updown_db import open_vti_updown_db_for_update + +def supply_interface_dict(interface): + # Lazy-load the running config on first invocation + try: + conf = supply_interface_dict.cached_config + except AttributeError: + conf = ConfigTreeQuery() + supply_interface_dict.cached_config = conf + + _, vti = get_interface_dict(conf.config, ['interfaces', 'vti'], interface) + return vti if __name__ == '__main__': verb = os.getenv('PLUTO_VERB') connection = os.getenv('PLUTO_CONNECTION') interface = sys.argv[1] + if verb.endswith('-v6'): + protocol = 'v6' + else: + protocol = 'v4' + openlog(ident=f'vti-up-down', logoption=LOG_PID, facility=LOG_INFO) syslog(f'Interface {interface} {verb} {connection}') - if verb in ['up-client', 'up-host']: - call('sudo ip route delete default table 220') - - vti_link = get_interface_config(interface) - - if not vti_link: - syslog(f'Interface {interface} not found') - sys.exit(0) - - vti_link_up = (vti_link['operstate'] != 'DOWN' if 'operstate' in vti_link else False) + wait_for_commit_lock() - if verb in ['up-client', 'up-host']: - if not vti_link_up: - conf = ConfigTreeQuery() - _, vti = get_interface_dict(conf.config, ['interfaces', 'vti'], interface) - if 'disable' not in vti: - tmp = VTIIf(interface) - tmp.update(vti) - call(f'sudo ip link set {interface} up') - else: - call(f'sudo ip link set {interface} down') - syslog(f'Interface {interface} is admin down ...') - elif verb in ['down-client', 'down-host']: - if vti_link_up: - call(f'sudo ip link set {interface} down') + if verb in ['up-client', 'up-client-v6', 'up-host', 'up-host-v6']: + with open_vti_updown_db_for_update() as db: + db.add(interface, connection, protocol) + db.commit(supply_interface_dict) + elif verb in ['down-client', 'down-client-v6', 'down-host', 'down-host-v6']: + with open_vti_updown_db_for_update() as db: + db.remove(interface, connection, protocol) + db.commit(supply_interface_dict) diff --git a/src/etc/sysctl.d/30-vyos-router.conf b/src/etc/sysctl.d/30-vyos-router.conf index c9b8ef8fe..76be41ddc 100644 --- a/src/etc/sysctl.d/30-vyos-router.conf +++ b/src/etc/sysctl.d/30-vyos-router.conf @@ -110,3 +110,8 @@ net.ipv6.conf.all.seg6_enabled = 0 net.ipv6.conf.default.seg6_enabled = 0 net.vrf.strict_mode = 1 + +# https://vyos.dev/T6570 +# By default, do not forward traffic from bridge to IPvX layer +net.bridge.bridge-nf-call-iptables = 0 +net.bridge.bridge-nf-call-ip6tables = 0
\ No newline at end of file diff --git a/src/migration-scripts/openvpn/1-to-2 b/src/migration-scripts/openvpn/1-to-2 index b7b7d4c77..2baa7302c 100644 --- a/src/migration-scripts/openvpn/1-to-2 +++ b/src/migration-scripts/openvpn/1-to-2 @@ -20,12 +20,8 @@ from vyos.configtree import ConfigTree def migrate(config: ConfigTree) -> None: - if not config.exists(['interfaces', 'openvpn']): - # Nothing to do - return - - ovpn_intfs = config.list_nodes(['interfaces', 'openvpn']) - for i in ovpn_intfs: + ovpn_intfs = config.list_nodes(['interfaces', 'openvpn'], path_must_exist=False) + for i in ovpn_intfs: # Remove 'encryption cipher' and add this value to 'encryption ncp-ciphers' # for server and client mode. # Site-to-site mode still can use --cipher option diff --git a/src/migration-scripts/openvpn/2-to-3 b/src/migration-scripts/openvpn/2-to-3 index 0b9073ae6..4e6b3c8b7 100644 --- a/src/migration-scripts/openvpn/2-to-3 +++ b/src/migration-scripts/openvpn/2-to-3 @@ -20,12 +20,8 @@ from vyos.configtree import ConfigTree def migrate(config: ConfigTree) -> None: - if not config.exists(['interfaces', 'openvpn']): - # Nothing to do - return - - ovpn_intfs = config.list_nodes(['interfaces', 'openvpn']) - for i in ovpn_intfs: + ovpn_intfs = config.list_nodes(['interfaces', 'openvpn'], path_must_exist=False) + for i in ovpn_intfs: mode = config.return_value(['interfaces', 'openvpn', i, 'mode']) if mode != 'server': # If it's a client or a site-to-site OpenVPN interface, diff --git a/src/migration-scripts/openvpn/3-to-4 b/src/migration-scripts/openvpn/3-to-4 index d3c76c7d3..0529491c1 100644 --- a/src/migration-scripts/openvpn/3-to-4 +++ b/src/migration-scripts/openvpn/3-to-4 @@ -18,11 +18,7 @@ from vyos.configtree import ConfigTree def migrate(config: ConfigTree) -> None: - if not config.exists(['interfaces', 'openvpn']): - # Nothing to do - return - - ovpn_intfs = config.list_nodes(['interfaces', 'openvpn']) + ovpn_intfs = config.list_nodes(['interfaces', 'openvpn'], path_must_exist=False) for i in ovpn_intfs: #Rename 'encryption ncp-ciphers' with 'encryption data-ciphers' ncp_cipher_path = ['interfaces', 'openvpn', i, 'encryption', 'ncp-ciphers'] diff --git a/src/op_mode/generate_ovpn_client_file.py b/src/op_mode/generate_ovpn_client_file.py index 2d96fe217..1d2f1067a 100755 --- a/src/op_mode/generate_ovpn_client_file.py +++ b/src/op_mode/generate_ovpn_client_file.py @@ -19,42 +19,53 @@ import argparse from jinja2 import Template from textwrap import fill -from vyos.configquery import ConfigTreeQuery +from vyos.config import Config from vyos.ifconfig import Section client_config = """ client nobind -remote {{ remote_host }} {{ port }} +remote {{ local_host if local_host else 'x.x.x.x' }} {{ port }} remote-cert-tls server -proto {{ 'tcp-client' if protocol == 'tcp-active' else 'udp' }} -dev {{ device }} -dev-type {{ device }} +proto {{ 'tcp-client' if protocol == 'tcp-passive' else 'udp' }} +dev {{ device_type }} +dev-type {{ device_type }} persist-key persist-tun verb 3 # Encryption options +{# Define the encryption map #} +{% set encryption_map = { + 'des': 'DES-CBC', + '3des': 'DES-EDE3-CBC', + 'bf128': 'BF-CBC', + 'bf256': 'BF-CBC', + 'aes128gcm': 'AES-128-GCM', + 'aes128': 'AES-128-CBC', + 'aes192gcm': 'AES-192-GCM', + 'aes192': 'AES-192-CBC', + 'aes256gcm': 'AES-256-GCM', + 'aes256': 'AES-256-CBC' +} %} + {% if encryption is defined and encryption is not none %} -{% if encryption.cipher is defined and encryption.cipher is not none %} -cipher {{ encryption.cipher }} -{% if encryption.cipher == 'bf128' %} -keysize 128 -{% elif encryption.cipher == 'bf256' %} -keysize 256 +{% if encryption.data_ciphers is defined and encryption.data_ciphers is not none %} +cipher {% for algo in encryption.data_ciphers %} +{{ encryption_map[algo] if algo in encryption_map.keys() else algo }}{% if not loop.last %}:{% endif %} +{% endfor %} + +data-ciphers {% for algo in encryption.data_ciphers %} +{{ encryption_map[algo] if algo in encryption_map.keys() else algo }}{% if not loop.last %}:{% endif %} +{% endfor %} {% endif %} -{% endif %} -{% if encryption.ncp_ciphers is defined and encryption.ncp_ciphers is not none %} -data-ciphers {{ encryption.ncp_ciphers }} -{% endif %} {% endif %} {% if hash is defined and hash is not none %} auth {{ hash }} {% endif %} -keysize 256 -comp-lzo {{ '' if use_lzo_compression is defined else 'no' }} +{{ 'comp-lzo' if use_lzo_compression is defined else '' }} <ca> -----BEGIN CERTIFICATE----- @@ -79,7 +90,7 @@ comp-lzo {{ '' if use_lzo_compression is defined else 'no' }} """ -config = ConfigTreeQuery() +config = Config() base = ['interfaces', 'openvpn'] if not config.exists(base): @@ -89,10 +100,22 @@ if not config.exists(base): if __name__ == '__main__': parser = argparse.ArgumentParser() - parser.add_argument("-i", "--interface", type=str, help='OpenVPN interface the client is connecting to', required=True) - parser.add_argument("-a", "--ca", type=str, help='OpenVPN CA cerificate', required=True) - parser.add_argument("-c", "--cert", type=str, help='OpenVPN client cerificate', required=True) - parser.add_argument("-k", "--key", type=str, help='OpenVPN client cerificate key', action="store") + parser.add_argument( + "-i", + "--interface", + type=str, + help='OpenVPN interface the client is connecting to', + required=True, + ) + parser.add_argument( + "-a", "--ca", type=str, help='OpenVPN CA cerificate', required=True + ) + parser.add_argument( + "-c", "--cert", type=str, help='OpenVPN client cerificate', required=True + ) + parser.add_argument( + "-k", "--key", type=str, help='OpenVPN client cerificate key', action="store" + ) args = parser.parse_args() interface = args.interface @@ -114,33 +137,25 @@ if __name__ == '__main__': if not config.exists(['pki', 'certificate', cert, 'private', 'key']): exit(f'OpenVPN certificate key "{key}" does not exist!') - ca = config.value(['pki', 'ca', ca, 'certificate']) + config = config.get_config_dict( + base + [interface], + key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True, + with_pki=True, + ) + + ca = config['pki']['ca'][ca]['certificate'] ca = fill(ca, width=64) - cert = config.value(['pki', 'certificate', cert, 'certificate']) + cert = config['pki']['certificate'][cert]['certificate'] cert = fill(cert, width=64) - key = config.value(['pki', 'certificate', key, 'private', 'key']) + key = config['pki']['certificate'][key]['private']['key'] key = fill(key, width=64) - remote_host = config.value(base + [interface, 'local-host']) - - ovpn_conf = config.get_config_dict(base + [interface], key_mangling=('-', '_'), get_first_key=True) - - port = '1194' if 'local_port' not in ovpn_conf else ovpn_conf['local_port'] - proto = 'udp' if 'protocol' not in ovpn_conf else ovpn_conf['protocol'] - device = 'tun' if 'device_type' not in ovpn_conf else ovpn_conf['device_type'] - - config = { - 'interface' : interface, - 'ca' : ca, - 'cert' : cert, - 'key' : key, - 'device' : device, - 'port' : port, - 'proto' : proto, - 'remote_host' : remote_host, - 'address' : [], - } - -# Clear out terminal first -print('\x1b[2J\x1b[H') -client = Template(client_config, trim_blocks=True).render(config) -print(client) + + config['ca'] = ca + config['cert'] = cert + config['key'] = key + config['port'] = '1194' if 'local_port' not in config else config['local_port'] + + client = Template(client_config, trim_blocks=True).render(config) + print(client) diff --git a/src/op_mode/ikev2_profile_generator.py b/src/op_mode/ikev2_profile_generator.py index b193d8109..cf2bc6d5c 100755 --- a/src/op_mode/ikev2_profile_generator.py +++ b/src/op_mode/ikev2_profile_generator.py @@ -105,10 +105,39 @@ vyos2windows_integrity = { } # IOS 14.2 and later do no support dh-group 1,2 and 5. Supported DH groups would -# be: 14, 15, 16, 17, 18, 19, 20, 21, 31 -ios_supported_dh_groups = ['14', '15', '16', '17', '18', '19', '20', '21', '31'] -# Windows 10 only allows a limited set of DH groups -windows_supported_dh_groups = ['1', '2', '14', '24'] +# be: 14, 15, 16, 17, 18, 19, 20, 21, 31, 32 +vyos2apple_dh_group = { + '14' : '14', + '15' : '15', + '16' : '16', + '17' : '17', + '18' : '18', + '19' : '19', + '20' : '20', + '21' : '21', + '31' : '31', + '32' : '32' +} + +# Newer versions of Windows support groups 19 and 20, albeit under a different naming convention +vyos2windows_dh_group = { + '1' : 'Group1', + '2' : 'Group2', + '14' : 'Group14', + '19' : 'ECP256', + '20' : 'ECP384', + '24' : 'Group24' +} + +# For PFS, Windows also has its own inconsistent naming scheme for each group +vyos2windows_pfs_group = { + '1' : 'PFS1', + '2' : 'PFS2', + '14' : 'PFS2048', + '19' : 'ECP256', + '20' : 'ECP384', + '24' : 'PFS24' +} parser = argparse.ArgumentParser() parser.add_argument('--os', const='all', nargs='?', choices=['ios', 'windows'], help='Operating system used for config generation', required=True) @@ -181,7 +210,7 @@ if args.os == 'ios': # https://stackoverflow.com/a/9427216 data['ca_certificates'] = [dict(t) for t in {tuple(d.items()) for d in data['ca_certificates']}] -esp_proposals = conf.get_config_dict(ipsec_base + ['esp-group', data['esp_group'], 'proposal'], +esp_group = conf.get_config_dict(ipsec_base + ['esp-group', data['esp_group']], key_mangling=('-', '_'), get_first_key=True) ike_proposal = conf.get_config_dict(ipsec_base + ['ike-group', data['ike_group'], 'proposal'], key_mangling=('-', '_'), get_first_key=True) @@ -192,7 +221,29 @@ ike_proposal = conf.get_config_dict(ipsec_base + ['ike-group', data['ike_group'] vyos2client_cipher = vyos2apple_cipher if args.os == 'ios' else vyos2windows_cipher; vyos2client_integrity = vyos2apple_integrity if args.os == 'ios' else vyos2windows_integrity; -supported_dh_groups = ios_supported_dh_groups if args.os == 'ios' else windows_supported_dh_groups; +vyos2client_dh_group = vyos2apple_dh_group if args.os == 'ios' else vyos2windows_dh_group + +def transform_pfs(pfs, ike_dh_group): + pfs_enabled = (pfs != 'disable') + if pfs == 'enable': + pfs_dh_group = ike_dh_group + elif pfs.startswith('dh-group'): + pfs_dh_group = pfs.removeprefix('dh-group') + + if args.os == 'ios': + if pfs_enabled: + if pfs_dh_group not in set(vyos2apple_dh_group): + exit(f'The PFS group configured for "{args.connection}" is not supported by the client!') + return pfs_dh_group + else: + return None + else: + if pfs_enabled: + if pfs_dh_group not in set(vyos2windows_pfs_group): + exit(f'The PFS group configured for "{args.connection}" is not supported by the client!') + return vyos2windows_pfs_group[ pfs_dh_group ] + else: + return 'None' # Create a dictionary containing client conform IKE settings ike = {} @@ -201,24 +252,28 @@ for _, proposal in ike_proposal.items(): if {'dh_group', 'encryption', 'hash'} <= set(proposal): if (proposal['encryption'] in set(vyos2client_cipher) and proposal['hash'] in set(vyos2client_integrity) and - proposal['dh_group'] in set(supported_dh_groups)): + proposal['dh_group'] in set(vyos2client_dh_group)): # We 're-code' from the VyOS IPsec proposals to the Apple naming scheme proposal['encryption'] = vyos2client_cipher[ proposal['encryption'] ] proposal['hash'] = vyos2client_integrity[ proposal['hash'] ] + # DH group will need to be transformed later after we calculate PFS group ike.update( { str(count) : proposal } ) count += 1 -# Create a dictionary containing Apple conform ESP settings +# Create a dictionary containing client conform ESP settings esp = {} count = 1 -for _, proposal in esp_proposals.items(): +for _, proposal in esp_group['proposal'].items(): if {'encryption', 'hash'} <= set(proposal): if proposal['encryption'] in set(vyos2client_cipher) and proposal['hash'] in set(vyos2client_integrity): # We 're-code' from the VyOS IPsec proposals to the Apple naming scheme proposal['encryption'] = vyos2client_cipher[ proposal['encryption'] ] proposal['hash'] = vyos2client_integrity[ proposal['hash'] ] + # Copy PFS setting from the group, if present (we will need to + # transform this later once the IKE group is selected) + proposal['pfs'] = esp_group.get('pfs', 'enable') esp.update( { str(count) : proposal } ) count += 1 @@ -230,8 +285,10 @@ try: tmp += f'({number}) Encryption {options["encryption"]}, Integrity {options["hash"]}, DH group {options["dh_group"]}\n' tmp += '\nSelect one of the above IKE groups: ' data['ike_encryption'] = ike[ ask_input(tmp, valid_responses=list(ike)) ] - else: + elif len(ike) == 1: data['ike_encryption'] = ike['1'] + else: + exit(f'None of the configured IKE proposals for "{args.connection}" are supported by the client!') if len(esp) > 1: tmp = '\n' @@ -239,12 +296,18 @@ try: tmp += f'({number}) Encryption {options["encryption"]}, Integrity {options["hash"]}\n' tmp += '\nSelect one of the above ESP groups: ' data['esp_encryption'] = esp[ ask_input(tmp, valid_responses=list(esp)) ] - else: + elif len(esp) == 1: data['esp_encryption'] = esp['1'] + else: + exit(f'None of the configured ESP proposals for "{args.connection}" are supported by the client!') except KeyboardInterrupt: exit("Interrupted") +# Transform the DH and PFS groups now that all selections are known +data['esp_encryption']['pfs'] = transform_pfs(data['esp_encryption']['pfs'], data['ike_encryption']['dh_group']) +data['ike_encryption']['dh_group'] = vyos2client_dh_group[ data['ike_encryption']['dh_group'] ] + print('\n\n==== <snip> ====') if args.os == 'ios': print(render_to_string('ipsec/ios_profile.j2', data)) diff --git a/src/op_mode/ipsec.py b/src/op_mode/ipsec.py index c8f5072da..02ba126b4 100755 --- a/src/op_mode/ipsec.py +++ b/src/op_mode/ipsec.py @@ -13,7 +13,6 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -import pprint import re import sys import typing diff --git a/src/op_mode/serial.py b/src/op_mode/serial.py new file mode 100644 index 000000000..a5864872b --- /dev/null +++ b/src/op_mode/serial.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import sys, typing + +import vyos.opmode +from vyos.utils.serial import restart_login_consoles as _restart_login_consoles + +def restart_console(device_name: typing.Optional[str]): + # Service control moved to vyos.utils.serial to unify checks and prompts. + # If users are connected, we want to show an informational message and a prompt + # to continue, verifying that the user acknowledges possible interruptions. + if device_name: + _restart_login_consoles(prompt_user=True, quiet=False, devices=[device_name]) + else: + _restart_login_consoles(prompt_user=True, quiet=False) + +if __name__ == '__main__': + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, vyos.opmode.Error) as e: + print(e) + sys.exit(1) diff --git a/src/tests/test_configd_inspect.py b/src/tests/test_configd_inspect.py index 98552c8f3..ccd631893 100644 --- a/src/tests/test_configd_inspect.py +++ b/src/tests/test_configd_inspect.py @@ -56,8 +56,7 @@ class TestConfigdInspect(TestCase): m = import_script(s) for i in f_list: f = getattr(m, i, None) - if not f: - continue + self.assertIsNotNone(f, f"'{s}': missing function '{i}'") sig = signature(f) par = sig.parameters l = len(par) |