diff options
Diffstat (limited to 'src')
702 files changed, 19653 insertions, 3966 deletions
diff --git a/src/activation-scripts/00-first-installed-boot.py b/src/activation-scripts/00-first-installed-boot.py new file mode 100644 index 000000000..ca02c2642 --- /dev/null +++ b/src/activation-scripts/00-first-installed-boot.py @@ -0,0 +1,35 @@ +# Copyright (C) VyOS Inc. +# +# 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/>. + + +from vyos.configtree import ConfigTree +from vyos.system.image import is_live_boot +from vyos.utils.activate import set_activation +from vyos.utils.activate import set_first_installed_boot +from vyos.utils.activate import is_first_installed_boot + + +def pre_condition() -> bool: + return not is_live_boot() + + +def activate(_config: ConfigTree) -> None: + pass + + +def post_condition() -> None: + set_first_installed_boot() + if is_first_installed_boot(): + set_activation(__file__, 'never') diff --git a/src/activation-scripts/01-set-config-path-hint.py b/src/activation-scripts/01-set-config-path-hint.py new file mode 100644 index 000000000..3146a7905 --- /dev/null +++ b/src/activation-scripts/01-set-config-path-hint.py @@ -0,0 +1,35 @@ +# Copyright (C) VyOS Inc. +# +# 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/>. + + +from vyos.configtree import ConfigTree +from vyos.system.image import is_live_boot +from vyos.utils.activate import set_activation +from vyos.utils.activate import set_config_path_hint +from vyos.utils.activate import is_first_installed_boot + + +def pre_condition() -> bool: + return not is_live_boot() + + +def activate(_config: ConfigTree) -> None: + pass + + +def post_condition() -> None: + if is_first_installed_boot(): + set_config_path_hint() + set_activation(__file__, 'never') diff --git a/src/activation-scripts/20-ethernet_offload.py b/src/activation-scripts/20-ethernet-offload.py index ca7213512..9f61f6d8f 100755..100644 --- a/src/activation-scripts/20-ethernet_offload.py +++ b/src/activation-scripts/20-ethernet-offload.py @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -22,9 +22,12 @@ from vyos.ethtool import Ethtool from vyos.configtree import ConfigTree -from vyos.system.image import is_live_boot +from vyos.utils.activate import set_activation +from vyos.utils.activate import is_first_installed_boot + def activate(config: ConfigTree): + # pylint: disable=too-many-branches base = ['interfaces', 'ethernet'] if not config.exists(base): @@ -39,7 +42,7 @@ def activate(config: ConfigTree): enabled, fixed = eth.get_generic_receive_offload() if configured and fixed: config.delete(base + [ifname, 'offload', 'gro']) - elif is_live_boot() and enabled and not fixed: + elif enabled and not fixed: config.set(base + [ifname, 'offload', 'gro']) # If GSO is enabled by the Kernel - we reflect this on the CLI. If GSO is @@ -48,7 +51,7 @@ def activate(config: ConfigTree): enabled, fixed = eth.get_generic_segmentation_offload() if configured and fixed: config.delete(base + [ifname, 'offload', 'gso']) - elif is_live_boot() and enabled and not fixed: + elif enabled and not fixed: config.set(base + [ifname, 'offload', 'gso']) # If LRO is enabled by the Kernel - we reflect this on the CLI. If LRO is @@ -57,7 +60,7 @@ def activate(config: ConfigTree): enabled, fixed = eth.get_large_receive_offload() if configured and fixed: config.delete(base + [ifname, 'offload', 'lro']) - elif is_live_boot() and enabled and not fixed: + elif enabled and not fixed: config.set(base + [ifname, 'offload', 'lro']) # If SG is enabled by the Kernel - we reflect this on the CLI. If SG is @@ -66,7 +69,7 @@ def activate(config: ConfigTree): enabled, fixed = eth.get_scatter_gather() if configured and fixed: config.delete(base + [ifname, 'offload', 'sg']) - elif is_live_boot() and enabled and not fixed: + elif enabled and not fixed: config.set(base + [ifname, 'offload', 'sg']) # If TSO is enabled by the Kernel - we reflect this on the CLI. If TSO is @@ -75,7 +78,7 @@ def activate(config: ConfigTree): enabled, fixed = eth.get_tcp_segmentation_offload() if configured and fixed: config.delete(base + [ifname, 'offload', 'tso']) - elif is_live_boot() and enabled and not fixed: + elif enabled and not fixed: config.set(base + [ifname, 'offload', 'tso']) # Remove deprecated UDP fragmentation offloading option @@ -104,3 +107,8 @@ def activate(config: ConfigTree): if config.exists(flow_control_path): if not eth.check_flow_control(): config.delete(flow_control_path) + + +def post_condition() -> None: + if is_first_installed_boot(): + set_activation(__file__, 'off') diff --git a/src/activation-scripts/example.py b/src/activation-scripts/example.py new file mode 100644 index 000000000..74beedf80 --- /dev/null +++ b/src/activation-scripts/example.py @@ -0,0 +1,42 @@ +# Copyright 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/>. + + +from vyos.configtree import ConfigTree + + +# pylint: disable=anomalous-backslash-in-string,pointless-string-statement +"""Activation scripts must be named '^\\d+\\-.+.py$' to be included by the +activation script runner. They are run in ascending order of prefix.""" + + +def pre_condition() -> bool: + """This function is not required. + If not present, or pre_condition returns True, the function activate + will be called on the config.""" + + +def activate(_config: ConfigTree) -> None: + """This function is expected. + If not present, the script is ignored. The function itself can be a + no-op.""" + + +def post_condition() -> None: + """This function is not required. + If present, and application of 'activate' succeeds, post_condition will + be called. + Commonly used to set activation 'off' for the script, after first run on + an installed system.""" diff --git a/src/completion/list_bgp_neighbors.sh b/src/completion/list_bgp_neighbors.sh index 869a7ab0a..7342813ba 100755 --- a/src/completion/list_bgp_neighbors.sh +++ b/src/completion/list_bgp_neighbors.sh @@ -1,5 +1,5 @@ -#!/bin/sh -# Copyright (C) 2021-2022 VyOS maintainers and contributors +#!/bin/bash +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -13,55 +13,152 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -# Return BGP neighbor addresses from CLI, can either request IPv4 only, IPv6 -# only or both address-family neighbors +# Return BGP neighbor identifiers from CLI. Selectors: +# --ipv4 IPv4 address peers +# --ipv6 IPv6 address peers +# --interfaces peers that are neither IPv4 nor IPv6 addresses (interfaces) +# --peer-groups configured peer-group names +# Selectors are additive and may be combined freely. With --all-vrfs, neighbors +# from the default VRF and every configured VRF are merged and deduplicated. ipv4=0 ipv6=0 -vrf="" +interfaces=0 +vrf_name="" +all_vrfs=0 +peer_groups=0 while [[ "$#" -gt 0 ]]; do case $1 in -4|--ipv4) ipv4=1 ;; -6|--ipv6) ipv6=1 ;; - -b|--both) ipv4=1; ipv6=1 ;; - --vrf) vrf="vrf name $2"; shift ;; - *) echo "Unknown parameter passed: $1" ;; + -b|--both) + # Deprecated: alias for --ipv4 --ipv6 --interfaces + ipv4=1; ipv6=1; interfaces=1 + ;; + --interfaces) interfaces=1 ;; + --vrf) vrf_name=$2; shift ;; + --all-vrfs) all_vrfs=1 ;; + --peer-groups) peer_groups=1 ;; + *) echo "Unknown parameter passed: $1" >&2 ;; esac shift done -declare -a vals -eval "vals=($(cli-shell-api listActiveNodes $vrf protocols bgp neighbor))" - -if [ $ipv4 -eq 1 ] && [ $ipv6 -eq 1 ]; then - echo -n '<x.x.x.x>' '<h:h:h:h:h:h:h:h>' ${vals[@]} -elif [ $ipv4 -eq 1 ] ; then - echo -n '<x.x.x.x> ' - for peer in "${vals[@]}" - do - ipaddrcheck --is-ipv4-single $peer - if [ $? -eq "0" ]; then - echo -n "$peer " - fi - done -elif [ $ipv6 -eq 1 ] ; then - echo -n '<h:h:h:h:h:h:h:h> ' - for peer in "${vals[@]}" - do - ipaddrcheck --is-ipv6-single $peer - if [ $? -eq "0" ]; then - echo -n "$peer " +if [[ $all_vrfs -eq 1 && -n $vrf_name ]]; then + echo "Error: --all-vrfs and --vrf are mutually exclusive" >&2 + exit 1 +fi + +# Wrap `cli-shell-api listActiveNodes` and append its (shell-quoted) output +# to the named array. The API is trusted to return safely quoted tokens, +# which is why `eval` is acceptable here -- it is the documented contract +# of cli-shell-api. Keeping the eval in one place makes the trust boundary +# explicit and easy to audit. +# +# Usage: _list_active_nodes <out_array_name> <path...> +_list_active_nodes() { + local _out=$1; shift + local _raw + if ! _raw=$(cli-shell-api listActiveNodes "$@" 2>/dev/null); then + return 0 # node missing or no config session -- treat as empty + fi + + eval "${_out}+=(${_raw})" +} + +declare -a vals=() +declare -a pg_vals=() + +# Build the list of VRFs to traverse. The empty string represents the default +# VRF (no `vrf name <X>` prefix); any other value is the name of a configured +# VRF. This unifies the three cases (--all-vrfs, --vrf <name>, neither) into a +# single loop and avoids duplicating the collection logic. +declare -a _vrf_list=("") # default VRF is always included + +if (( all_vrfs )); then + _list_active_nodes _vrf_list vrf name +elif [[ -n $vrf_name ]]; then + _vrf_list=("$vrf_name") +fi + +# Collect neighbors -- and optionally peer-groups -- from every VRF in the list. +for _vrf in "${_vrf_list[@]}"; do + declare -a _path=() + [[ -n $_vrf ]] && _path=(vrf name "$_vrf") + _list_active_nodes vals "${_path[@]}" protocols bgp neighbor + if (( peer_groups )); then + _list_active_nodes pg_vals "${_path[@]}" protocols bgp peer-group + fi +done + +# Deduplicate when multiple VRFs may have contributed entries. A single source +# cannot produce duplicates, so the sort pipe is skipped in that case. +if (( ${#_vrf_list[@]} > 1 )); then + if (( ${#vals[@]} > 1 )); then + mapfile -t vals < <(printf '%s\n' "${vals[@]}" | LC_ALL=C sort -u) + fi + if (( ${#pg_vals[@]} > 1 )); then + mapfile -t pg_vals < <(printf '%s\n' "${pg_vals[@]}" | LC_ALL=C sort -u) + fi +fi + +# Print neighbors from `vals` matching the requested selectors. The fast path +# below avoids any per-token filtering when all three neighbor categories +# (--ipv4, --ipv6, --interfaces) are requested -- in that case every entry in +# `vals` matches by definition. +_print_neighbors() { + # Fast path: every neighbor is either v4, v6 or an interface, so when all + # three are requested no classification is needed. + if (( ipv4 && ipv6 && interfaces )); then + (( ${#vals[@]} )) && printf '%s ' "${vals[@]}" + return + fi + + local peer + for peer in "${vals[@]}"; do + if ipaddrcheck --is-ipv4-single "$peer" >/dev/null 2>&1; then + (( ipv4 )) && printf '%s ' "$peer" + elif ipaddrcheck --is-ipv6-single "$peer" >/dev/null 2>&1; then + (( ipv6 )) && printf '%s ' "$peer" + else + # Anything that is neither an IPv4 nor an IPv6 address is treated + # as an interface name. + (( interfaces )) && printf '%s ' "$peer" fi - done -else - echo "Usage:" - echo "-4|--ipv4 list only IPv4 peers" - echo "-6|--ipv6 list only IPv6 peers" - echo "--both list both IP4 and IPv6 peers" - echo "--vrf <name> apply command to given VRF (optional)" - echo "" + done +} + +# Require at least one selector. +if (( ipv4 == 0 && ipv6 == 0 && interfaces == 0 && peer_groups == 0 )); then + cat >&2 <<'EOF' +Usage: + -4|--ipv4 list IPv4 address peers + -6|--ipv6 list IPv6 address peers + --interfaces list interface peers (peers that are not IP addresses) + --peer-groups list configured peer-group names + -b|--both deprecated -- alias for --ipv4 --ipv6 --interfaces + --vrf <name> apply command to given VRF (optional) + --all-vrfs list neighbors across all VRFs (deduplicated) +EOF exit 1 fi +# Build the leading completion-help placeholders shown to the user. +declare -a _hdr=() +(( ipv4 )) && _hdr+=('<x.x.x.x>') +(( ipv6 )) && _hdr+=('<h:h:h:h:h:h:h:h>') +(( interfaces )) && _hdr+=('<interface>') +(( peer_groups )) && _hdr+=('<text>') + +(( ${#_hdr[@]} )) && printf '%s ' "${_hdr[@]}" + +if (( ipv4 || ipv6 || interfaces )); then + _print_neighbors +fi + +if (( peer_groups )) && (( ${#pg_vals[@]} )); then + printf '%s ' "${pg_vals[@]}" +fi + exit 0 diff --git a/src/completion/list_container_sysctl_parameters.sh b/src/completion/list_container_sysctl_parameters.sh index cf8d006e5..6b1402d69 100755 --- a/src/completion/list_container_sysctl_parameters.sh +++ b/src/completion/list_container_sysctl_parameters.sh @@ -1,6 +1,6 @@ #!/bin/sh # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/completion/list_ddclient_protocols.sh b/src/completion/list_ddclient_protocols.sh index 634981660..0c8c2712d 100755 --- a/src/completion/list_ddclient_protocols.sh +++ b/src/completion/list_ddclient_protocols.sh @@ -1,6 +1,6 @@ #!/bin/sh # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/completion/list_disks.py b/src/completion/list_disks.py index 0aa872abb..034f7344c 100755 --- a/src/completion/list_disks.py +++ b/src/completion/list_disks.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/completion/list_esi.sh b/src/completion/list_esi.sh index b8373fa57..e57013672 100755 --- a/src/completion/list_esi.sh +++ b/src/completion/list_esi.sh @@ -1,6 +1,6 @@ #!/bin/bash # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/completion/list_images.py b/src/completion/list_images.py index eae29c084..c545fb882 100755 --- a/src/completion/list_images.py +++ b/src/completion/list_images.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/completion/list_ipoe.py b/src/completion/list_ipoe.py index 5a8f4b0c5..d328816c9 100755 --- a/src/completion/list_ipoe.py +++ b/src/completion/list_ipoe.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright 2020-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/completion/list_ipsec_profile_tunnels.py b/src/completion/list_ipsec_profile_tunnels.py index 95a4ca3ce..1f7241699 100644 --- a/src/completion/list_ipsec_profile_tunnels.py +++ b/src/completion/list_ipsec_profile_tunnels.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/completion/list_login_ttys.py b/src/completion/list_login_ttys.py index 4d77a1b8b..dae217aec 100644 --- a/src/completion/list_login_ttys.py +++ b/src/completion/list_login_ttys.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/completion/list_mem_page_size.py b/src/completion/list_mem_page_size.py new file mode 100644 index 000000000..4d2cb11c6 --- /dev/null +++ b/src/completion/list_mem_page_size.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# 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 argparse +from vyos.vpp.utils import ( + get_hugepage_sizes, + get_default_hugepage_size, + get_default_page_size, + bytes_to_human_memory, +) + + +def get_default_page_sizes() -> list[int]: + """ + Retrieve the system's default page sizes, including huge pages. + :return: A list of page sizes in bytes. + """ + page_sizes = [] + # default system page size + page_size = get_default_page_size() + if page_size: + page_sizes.append(page_size) + + # default huge page size + page_size = get_default_hugepage_size() + if page_size: + page_sizes.append(page_size) + + return page_sizes + + +def list_mem_page_size(hugepage_only=None) -> list[str]: + result = [] + page_sizes = get_hugepage_sizes() + + if not hugepage_only: + page_sizes += get_default_page_sizes() + + page_sizes = set(page_sizes) + for unit in ['K', 'M', 'G']: + for size in page_sizes: + if val := bytes_to_human_memory(size, unit): + result.append(val) + + return result + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument( + '--hugepage_only', type=str, help='List only available hugepage sizes.' + ) + args = parser.parse_args() + + result = list_mem_page_size(args.hugepage_only) + print(' '.join(result)) diff --git a/src/completion/list_openconnect_users.py b/src/completion/list_openconnect_users.py index db2f4b4da..81952c613 100755 --- a/src/completion/list_openconnect_users.py +++ b/src/completion/list_openconnect_users.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/completion/list_openvpn_clients.py b/src/completion/list_openvpn_clients.py index c1d8eaeb3..24b73f13b 100755 --- a/src/completion/list_openvpn_clients.py +++ b/src/completion/list_openvpn_clients.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/completion/list_openvpn_users.py b/src/completion/list_openvpn_users.py index f2c648476..992926793 100755 --- a/src/completion/list_openvpn_users.py +++ b/src/completion/list_openvpn_users.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/completion/list_srv6_locators.sh b/src/completion/list_srv6_locators.sh new file mode 100644 index 000000000..03ca499d8 --- /dev/null +++ b/src/completion/list_srv6_locators.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# +# Copyright (C) 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/>. +# +# This script is completion helper to list all configured SRv6 locators that +# are visible to FRR + +seg6LocatorsJson=$(vtysh -c 'show segment-routing srv6 locator json') +echo "$(echo "$seg6LocatorsJson" | jq -r '[.locators[].name][]')" diff --git a/src/completion/list_sysctl_parameters.sh b/src/completion/list_sysctl_parameters.sh index c111716bb..edabeac6d 100755 --- a/src/completion/list_sysctl_parameters.sh +++ b/src/completion/list_sysctl_parameters.sh @@ -1,6 +1,6 @@ #!/bin/sh # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/completion/list_vni.sh b/src/completion/list_vni.sh index f8bd4a993..487313538 100755 --- a/src/completion/list_vni.sh +++ b/src/completion/list_vni.sh @@ -1,6 +1,6 @@ #!/bin/bash # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/helpers/vyos-sudo.py b/src/completion/list_vpp_interfaces.py index 75dd7f29d..e5751119c 100755..100644 --- a/src/helpers/vyos-sudo.py +++ b/src/completion/list_vpp_interfaces.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 - -# Copyright 2019 VyOS maintainers and contributors <maintainers@vyos.io> +# +# Copyright (C) VyOS Inc. # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -15,19 +15,25 @@ # 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 -import sys +from vyos.configquery import ConfigTreeQuery + +from vyos.vpp import VPPControl +from vyos.vpp.utils import vpp_ifaces_list + -from vyos.utils.permission import is_admin +def get_vpp_ifaces_names(): + config = ConfigTreeQuery() + if not config.exists('vpp settings interface'): + return [] + vpp = VPPControl() + vpp_ifaces = vpp_ifaces_list(vpp.api) + ifaces_names = [iface['interface_name'] for iface in vpp_ifaces] -if __name__ == '__main__': - if len(sys.argv) < 2: - print('Missing command argument') - sys.exit(1) + return sorted(ifaces_names) - if not is_admin(): - print('This account is not authorized to run this command') - sys.exit(1) - os.execvp('sudo', ['sudo'] + sys.argv[1:]) +if __name__ == "__main__": + ifaces = [] + ifaces = get_vpp_ifaces_names() + print(" ".join(ifaces)) diff --git a/src/completion/qos/list_traffic_match_group.py b/src/completion/qos/list_traffic_match_group.py index 015d7ada9..0d0eef77b 100644 --- a/src/completion/qos/list_traffic_match_group.py +++ b/src/completion/qos/list_traffic_match_group.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/container.py b/src/conf_mode/container.py index 18d660a4e..19ff0da34 100755 --- a/src/conf_mode/container.py +++ b/src/conf_mode/container.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -29,12 +29,17 @@ from vyos.configdict import dict_merge from vyos.configdict import node_changed from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf -from vyos.ifconfig import Interface +from vyos.container import restart_network +from vyos.utils.configfs import delete_cli_node +from vyos.utils.configfs import add_cli_node from vyos.utils.cpu import get_core_count from vyos.utils.file import write_file +from vyos.utils.dict import dict_search from vyos.utils.process import call from vyos.utils.process import cmd from vyos.utils.process import run +from vyos.utils.network import gen_mac +from vyos.utils.network import get_host_identity from vyos.utils.network import interface_exists from vyos.template import bracketize_ipv6 from vyos.template import inc_ip @@ -114,6 +119,10 @@ def verify(container): # Add new container if 'name' in container: + net_dict = {} + net_dict['mac'] = {} + net_dict['address'] = {} + for name, container_config in container['name'].items(): # Container image is a mandatory option if 'image' not in container_config: @@ -121,7 +130,7 @@ def verify(container): # Check if requested container image exists locally. If it does not # exist locally - inform the user. This is required as there is a - # shared container image storage accross all VyOS images. A user can + # shared container image storage across all VyOS images. A user can # delete a container image from the system, boot into another version # of VyOS and then it would fail to boot. This is to prevent any # configuration error when container images are deleted from the @@ -152,6 +161,13 @@ def verify(container): if 'name_server' in container_config and 'no_name_server' not in container['network'][network_name]: raise ConfigError(f'Setting name server has no effect when attached container network has DNS enabled!') + mac = dict_search(f'network.{network_name}.mac', container_config) + if mac: + if mac in net_dict['mac'].keys(): + raise ConfigError(f'MAC address "{mac}" is already used by container "{net_dict["mac"][mac]}"!') + if mac != 'auto': + net_dict['mac'][mac] = name + if 'address' in container_config['network'][network_name]: cnt_ipv4 = 0 cnt_ipv6 = 0 @@ -161,13 +177,13 @@ def verify(container): try: network = [x for x in container['network'][network_name]['prefix'] if is_ipv4(x)][0] cnt_ipv4 += 1 - except: + except Exception: raise ConfigError(f'Network "{network_name}" does not contain an IPv4 prefix!') elif is_ipv6(address): try: network = [x for x in container['network'][network_name]['prefix'] if is_ipv6(x)][0] cnt_ipv6 += 1 - except: + except Exception: raise ConfigError(f'Network "{network_name}" does not contain an IPv6 prefix!') # Specified container IP address must belong to network prefix @@ -179,6 +195,10 @@ def verify(container): raise ConfigError(f'IP address "{address}" can not be used for a container, ' \ 'reserved for the container engine!') + if address in net_dict['address'].keys(): + raise ConfigError(f'IP address "{address}" is already used by container "{net_dict["address"][address]}"!') + net_dict['address'][address] = name + if cnt_ipv4 > 1 or cnt_ipv6 > 1: raise ConfigError(f'Only one IP address per address family can be used for ' \ f'container "{name}". {cnt_ipv4} IPv4 and {cnt_ipv6} IPv6 address(es)!') @@ -260,22 +280,59 @@ def verify(container): # Add new network if 'network' in container: for network, network_config in container['network'].items(): - v4_prefix = 0 - v6_prefix = 0 + net_dict = {'ipv4_pfx_len': 0, 'ipv6_pfx_len': 0, 'ipv4_gateway_len': 0, 'ipv6_gateway_len': 0} + # If ipv4-prefix not defined for user-defined network if 'prefix' not in network_config: raise ConfigError(f'prefix for network "{network}" must be defined!') for prefix in network_config['prefix']: if is_ipv4(prefix): - v4_prefix += 1 + net_dict['ipv4_pfx_len'] += 1 + net_dict['ipv4_prefix'] = prefix elif is_ipv6(prefix): - v6_prefix += 1 - - if v4_prefix > 1: + net_dict['ipv6_pfx_len'] += 1 + net_dict['ipv6_prefix'] = prefix + + for gateway in network_config.get('gateway', []): + if is_ipv4(gateway): + net_dict['ipv4_gateway_len'] += 1 + net_dict['ipv4_gateway'] = gateway + elif is_ipv6(gateway): + net_dict['ipv6_gateway_len'] += 1 + net_dict['ipv6_gateway'] = gateway + + if net_dict['ipv4_pfx_len'] > 1: raise ConfigError(f'Only one IPv4 prefix can be defined for network "{network}"!') - if v6_prefix > 1: + if net_dict['ipv6_pfx_len'] > 1: raise ConfigError(f'Only one IPv6 prefix can be defined for network "{network}"!') + if net_dict['ipv4_gateway_len'] > 1: + raise ConfigError(f'Only one IPv4 gateway can be defined for network "{network}"!') + if net_dict['ipv6_gateway_len'] > 1: + raise ConfigError(f'Only one IPv6 gateway can be defined for network "{network}"!') + + if net_dict.get('ipv4_prefix') and net_dict.get('ipv4_gateway'): + if ip_address(net_dict['ipv4_gateway']) not in ip_network(net_dict['ipv4_prefix']): + raise ConfigError(f'IPv4 gateway "{net_dict["ipv4_gateway"]}" is not in the IPv4 prefix "{net_dict["ipv4_prefix"]}"!') + if net_dict.get('ipv6_prefix') and net_dict.get('ipv6_gateway'): + if ip_address(net_dict['ipv6_gateway']) not in ip_network(net_dict['ipv6_prefix']): + raise ConfigError(f'IPv6 gateway "{net_dict["ipv6_gateway"]}" is not in the IPv6 prefix "{net_dict["ipv6_prefix"]}"!') + if net_dict.get('ipv4_gateway') and not net_dict.get('ipv4_prefix'): + raise ConfigError(f'IPv4 gateway configured but no IPv4 prefix defined for network "{network}"!') + if net_dict.get('ipv6_gateway') and not net_dict.get('ipv6_prefix'): + raise ConfigError(f'IPv6 gateway configured but no IPv6 prefix defined for network "{network}"!') + + type_config = dict_search('type', network_config) + if dict_search('macvlan', type_config): + parent = dict_search('macvlan.parent', type_config) + if not parent: + raise ConfigError(f'MACVLAN networks must have a parent interface!') + if not interface_exists(parent): + raise ConfigError(f'MACVLAN parent interface "{parent}" does not exist!') + if not dict_search('macvlan.mode', type_config): + raise ConfigError(f'MACVLAN networks must have a mode configured!') + if dict_search('vrf', network_config): + raise ConfigError(f'MACVLAN networks do not support direct VRF assignment!') # Verify VRF exists verify_vrf(network_config) @@ -304,18 +361,19 @@ def verify(container): return None -def generate_run_arguments(name, container_config): +def generate_run_arguments(name, container_config, host_ident): image = container_config['image'] cpu_quota = container_config['cpu_quota'] memory = container_config['memory'] shared_memory = container_config['shared_memory'] restart = container_config['restart'] + log_driver = container_config['log_driver'] # Add sysctl options sysctl_opt = '' if 'sysctl' in container_config and 'parameter' in container_config['sysctl']: for k, v in container_config['sysctl']['parameter'].items(): - sysctl_opt += f" --sysctl {k}={v['value']}" + sysctl_opt += f" --sysctl \"{k}={v['value']}\"" # Add capability options. Should be in uppercase capabilities = '' @@ -324,6 +382,11 @@ def generate_run_arguments(name, container_config): cap = cap.upper().replace('-', '_') capabilities += f' --cap-add={cap}' + # Grant root capabilities to the container + privileged = '' + if 'privileged' in container_config: + privileged = '--privileged' + # Add a host device to the container /dev/x:/dev/x device = '' if 'device' in container_config: @@ -397,13 +460,17 @@ def generate_run_arguments(name, container_config): if 'allow_host_pid' in container_config: host_pid = '--pid host' - name_server = '' + name_server = [] if 'name_server' in container_config: for ns in container_config['name_server']: - name_server += f'--dns {ns}' + name_server.append(f'--dns {ns}') + if name_server: + name_server = ' '.join(name_server) + else: + name_server = '' - container_base_cmd = f'--detach --interactive --tty --replace {capabilities} --cpus {cpu_quota} {sysctl_opt} ' \ - f'--memory {memory}m --shm-size {shared_memory}m --memory-swap 0 --restart {restart} ' \ + container_base_cmd = f'--detach --interactive --tty --replace {capabilities} {privileged} --cpus {cpu_quota} {sysctl_opt} ' \ + f'--memory {memory}m --shm-size {shared_memory}m --memory-swap 0 --restart {restart} --log-driver={log_driver} ' \ f'--name {name} {hostname} {device} {port} {name_server} {volume} {tmpfs} {env_opt} {label} {uid} {host_pid}' entrypoint = '' @@ -412,6 +479,24 @@ def generate_run_arguments(name, container_config): entrypoint = json_write(container_config['entrypoint'].split()).replace('"', """) entrypoint = f'--entrypoint '{entrypoint}'' + healthcheck = ' --no-healthcheck' + if 'health_check' in container_config: + healthcheck = '' + if 'command' in container_config['health_check']: + health_cmd = container_config['health_check']['command'] + healthcheck += f' --health-cmd="{health_cmd}"' + if 'interval' in container_config['health_check']: + health_int = container_config['health_check']['interval'] + if health_int != 'disable': + health_int = f'{health_int}s' + healthcheck += f' --health-interval={health_int}' + if 'timeout' in container_config['health_check']: + health_to = container_config['health_check']['timeout'] + healthcheck += f' --health-timeout={health_to}s' + if 'retry' in container_config['health_check']: + health_rt = container_config['health_check']['retry'] + healthcheck += f' --health-retries={health_rt}' + command = '' if 'command' in container_config: command = container_config['command'].strip() @@ -420,21 +505,50 @@ def generate_run_arguments(name, container_config): if 'arguments' in container_config: command_arguments = container_config['arguments'].strip() + net = '' if 'allow_host_networks' in container_config: - return f'{container_base_cmd} --net host {entrypoint} {image} {command} {command_arguments}'.strip() - - ip_param = '' - networks = ",".join(container_config['network']) - for network in container_config['network']: - if 'address' not in container_config['network'][network]: - continue - for address in container_config['network'][network]['address']: - if is_ipv6(address): - ip_param += f' --ip6 {address}' - else: - ip_param += f' --ip {address}' + net = '--net host' + else: + ip_param = '' + addr_info = '' + networks = ",".join(container_config['network']) + for network in container_config['network']: + network_name = network + if 'address' not in container_config['network'][network]: + continue + for address in container_config['network'][network]['address']: + if is_ipv6(address): + ip_param += f' --ip6 {address}' + else: + ip_param += f' --ip {address}' + + addr_info = ''.join(container_config['network'][network]['address']) - return f'{container_base_cmd} --no-healthcheck --net {networks} {ip_param} {entrypoint} {image} {command} {command_arguments}'.strip() + get_mac = dict_search(f'network.{network_name}.mac', container_config) + if get_mac == 'auto' or get_mac is None: + mac_add = gen_mac(name, addr_info, host_ident) + else: + mac_add = get_mac + + mac_address = f'--mac-address {mac_add}' + + # Replace mac-auto with the generated mac address + if get_mac == 'auto': + mac_config_path = [ + 'container', + 'name', + name, + 'network', + network_name, + 'mac', + ] + + delete_cli_node(mac_config_path) + add_cli_node(mac_config_path, value=mac_add) + + net = f'--net {networks} {ip_param} {mac_address}' + + return f'{container_base_cmd} {healthcheck} {net} {entrypoint} {image} {command} {command_arguments}'.strip() def generate(container): @@ -447,11 +561,22 @@ def generate(container): if 'network' in container: for network, network_config in container['network'].items(): + type_config = dict_search('type', network_config) + if dict_search('macvlan', type_config): + net_interface = dict_search('macvlan.parent', type_config) + driver = 'macvlan' + mode = dict_search('macvlan.mode', type_config) + elif dict_search('bridge', type_config) is not None: + net_interface = f'pod-{network}' + driver = 'bridge' + else: + net_interface = f'pod-{network}' + driver = 'bridge' tmp = { 'name': network, 'id': sha256(f'{network}'.encode()).hexdigest(), - 'driver': 'bridge', - 'network_interface': f'pod-{network}', + 'driver': driver, + 'network_interface': net_interface, 'subnets': [], 'ipv6_enabled': False, 'internal': False, @@ -460,6 +585,7 @@ def generate(container): 'driver': 'host-local' }, 'options': { + **({'mode': mode} if driver == 'macvlan' else {}), 'mtu': '1500' } } @@ -471,11 +597,26 @@ def generate(container): tmp['options']['mtu'] = network_config['mtu'] for prefix in network_config['prefix']: - net = {'subnet': prefix, 'gateway': inc_ip(prefix, 1)} - tmp['subnets'].append(net) + gateway4, gateway6 = None, None + if dict_search('gateway', network_config): + for gw in network_config['gateway']: + if is_ipv6(gw): + gateway6 = gw + else: + gateway4 = gw + + if is_ipv6(prefix) and not gateway6: + gateway6 = inc_ip(prefix, 1) + elif not gateway4: + gateway4 = inc_ip(prefix, 1) if is_ipv6(prefix): tmp['ipv6_enabled'] = True + net = {'subnet': prefix, 'gateway': gateway6} + else: + net = {'subnet': prefix, 'gateway': gateway4} + + tmp['subnets'].append(net) write_file(f'/etc/containers/networks/{network}.json', json_write(tmp, indent=2)) @@ -484,12 +625,13 @@ def generate(container): render(config_storage, 'container/storage.conf.j2', container) if 'name' in container: + host_ident = get_host_identity() for name, container_config in container['name'].items(): if 'disable' in container_config: continue file_path = os.path.join(systemd_unit_path, f'vyos-container-{name}.service') - run_args = generate_run_arguments(name, container_config) + run_args = generate_run_arguments(name, container_config, host_ident) render(file_path, 'container/systemd-unit.j2', {'name': name, 'run_args': run_args, }, formater=lambda _: _.replace(""", '"').replace("'", "'")) @@ -521,7 +663,7 @@ def apply(container): if run(f'podman image exists {image}') != 0: # container image does not exist locally - user already got - # informed by a WARNING in verfiy() - bail out early + # informed by a WARNING in verify() - bail out early continue if 'disable' in container_config: @@ -541,21 +683,8 @@ def apply(container): if disabled_new: call('systemctl daemon-reload') - # Start network and assign it to given VRF if requested. this can only be done - # after the containers got started as the podman network interface will - # only be enabled by the first container and yet I do not know how to enable - # the network interface in advance - if 'network' in container: - for network, network_config in container['network'].items(): - network_name = f'pod-{network}' - # T5147: Networks are started only as soon as there is a consumer. - # If only a network is created in the first place, no need to assign - # it to a VRF as there's no consumer, yet. - if interface_exists(network_name): - tmp = Interface(network_name) - tmp.set_vrf(network_config.get('vrf', '')) - tmp.add_ipv6_eui64_address('fe80::/64') - + # Re-Start network and assign it to given VRF if requested. + restart_network(container) return None diff --git a/src/conf_mode/firewall.py b/src/conf_mode/firewall.py index cebe57092..4a2706a03 100755 --- a/src/conf_mode/firewall.py +++ b/src/conf_mode/firewall.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -17,19 +17,23 @@ import os import re +from glob import glob + from sys import exit from vyos.base import Warning from vyos.config import Config from vyos.configdict import is_node_changed -from vyos.configdiff import get_config_diff, Diff +from vyos.configdiff import Diff, get_config_diff from vyos.configdep import set_dependents, call_dependents from vyos.configverify import verify_interface_exists from vyos.ethtool import Ethtool from vyos.firewall import fqdn_config_parse -from vyos.firewall import geoip_update +from vyos.geoip import geoip_refresh, geoip_update from vyos.template import render +from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args from vyos.utils.dict import dict_search_recursive +from vyos.utils.file import write_file from vyos.utils.process import call from vyos.utils.process import cmd from vyos.utils.process import rc_cmd @@ -37,7 +41,6 @@ from vyos.utils.network import get_vrf_members from vyos.utils.network import get_interface_vrf from vyos import ConfigError from vyos import airbag -from pathlib import Path from subprocess import run as subp_run airbag.enable() @@ -77,42 +80,24 @@ snmp_event_source = 1 snmp_trap_mib = 'VYATTA-TRAP-MIB' snmp_trap_name = 'mgmtEventTrap' -def geoip_updated(conf, firewall): - diff = get_config_diff(conf) - node_diff = diff.get_child_nodes_diff(['firewall'], expand_nodes=Diff.DELETE, recursive=True) - - out = { - 'name': [], - 'ipv6_name': [], - 'deleted_name': [], - 'deleted_ipv6_name': [] - } - updated = False +def geoip_sets(firewall): + out = {'name': [], 'ipv6_name': []} - for key, path in dict_search_recursive(firewall, 'geoip'): - set_name = f'GEOIP_CC_{path[1]}_{path[2]}_{path[4]}' + for _, path in dict_search_recursive(firewall, 'geoip'): if (path[0] == 'ipv4'): - out['name'].append(set_name) + out['name'].append(f'GEOIP_CC_{path[1]}_{path[2]}_{path[4]}') elif (path[0] == 'ipv6'): - set_name = f'GEOIP_CC6_{path[1]}_{path[2]}_{path[4]}' - out['ipv6_name'].append(set_name) - - updated = True + out['ipv6_name'].append(f'GEOIP_CC6_{path[1]}_{path[2]}_{path[4]}') - if 'delete' in node_diff: - for key, path in dict_search_recursive(node_diff['delete'], 'geoip'): - set_name = f'GEOIP_CC_{path[1]}_{path[2]}_{path[4]}' - if (path[0] == 'ipv4'): - out['deleted_name'].append(set_name) - elif (path[0] == 'ipv6'): - set_name = f'GEOIP_CC_{path[1]}_{path[2]}_{path[4]}' - out['deleted_ipv6_name'].append(set_name) - updated = True + return out - if updated: - return out - - return False +def geoip_updated(conf): + D = get_config_diff(conf, key_mangling=('-', '_')) + diff = D.get_child_nodes_diff(['firewall'], + expand_nodes=Diff.ADD | Diff.DELETE, + recursive=True) + return any(any(dict_search_recursive(diff.get(section, {}), 'geoip')) + for section in ('add', 'delete')) def get_config(config=None): if config: @@ -132,7 +117,11 @@ def get_config(config=None): # Update nat and policy-route as firewall groups were updated set_dependents('group_resync', conf) - firewall['geoip_updated'] = geoip_updated(conf, firewall) + firewall['geoip_sets'] = geoip_sets(firewall) + firewall['geoip_updated'] = geoip_updated(conf) + firewall['policy'] = conf.get_config_dict( + ['policy'], key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) fqdn_config_parse(firewall, 'firewall') @@ -143,19 +132,23 @@ def get_config(config=None): for local_zone, local_zone_conf in firewall['zone'].items(): if 'local_zone' not in local_zone_conf: # Get physical interfaces assigned to the zone if vrf is used: - if 'vrf' in local_zone_conf['member']: + local_zone_member = local_zone_conf.get('member', {}) + if 'vrf' in local_zone_member: local_zone_conf['vrf_interfaces'] = {} - for vrf_name in local_zone_conf['member']['vrf']: + for vrf_name in local_zone_member['vrf']: local_zone_conf['vrf_interfaces'][vrf_name] = ','.join(get_vrf_members(vrf_name)) continue local_zone_conf['from_local'] = {} + local_zone_conf['default_local'] = {} for zone, zone_conf in firewall['zone'].items(): - if zone == local_zone or 'from' not in zone_conf: + if zone == local_zone: continue - if local_zone in zone_conf['from']: + if 'from' in zone_conf and local_zone in zone_conf['from']: local_zone_conf['from_local'][zone] = zone_conf['from'][local_zone] + elif 'default_firewall' in zone_conf: + local_zone_conf['default_local'][zone] = zone_conf['default_firewall'] set_dependents('conntrack', conf) @@ -194,6 +187,42 @@ def verify_jump_target(firewall, hook, jump_target, family, recursive=False): targets_seen.append(target) +def is_node_empty(rule_conf): + is_empty_list = [] + is_empty_list.append([ + ['add_address_to_group'], + ['connection_status'], + ['destination'], + ['destination', 'group'], + ['destination', 'geoip'], + ['fragment'], + ['gre'], + ['gre', 'flags'], + ['hop_limit'], + ['icmp'], + ['icmpv6'], + ['inbound_interface'], + ['ipsec'], + ['limit'], + ['log_options'], + ['outbound_interface'], + ['set'], + ['source'], + ['source', 'group'], + ['source', 'geoip'], + ['tcp'], + ['tcp', 'flags'], + ['time'], + ['ttl'], + ['vlan'] + ]) + + for node in is_empty_list[0]: + if dict_search_args(rule_conf, *node) == {}: + return True, node + + return False, None + def verify_rule(firewall, family, hook, priority, rule_id, rule_conf): if 'action' not in rule_conf: raise ConfigError('Rule action must be defined') @@ -205,7 +234,7 @@ def verify_rule(firewall, family, hook, priority, rule_id, rule_conf): 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 hook != 'name': # This is a bit clumsy, but consolidates a chunk of code. + 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, hook, target, family, recursive=False) @@ -218,6 +247,8 @@ def verify_rule(firewall, family, hook, priority, rule_id, rule_conf): if not dict_search_args(firewall, 'flowtable', offload_target): raise ConfigError(f'Invalid offload-target. Flowtable "{offload_target}" does not exist on the system') + elif 'offload_target' in rule_conf: + Warning('offload-target is specified but action is not set to "offload"') if rule_conf['action'] != 'synproxy' and 'synproxy' in rule_conf: raise ConfigError('"synproxy" option allowed only for action synproxy') @@ -229,6 +260,24 @@ def verify_rule(firewall, family, hook, priority, rule_id, rule_conf): if rule_conf.get('protocol', {}) != 'tcp': raise ConfigError('For action "synproxy" the protocol must be set to TCP') + if 'state' in rule_conf: + disable_conntrack = dict_search(f'{family}.{hook}.{priority}.disable_conntrack', firewall) + conntrack_disabled_list = [] + + # Check if conntrack is disabled in the input or output chain + for nft_chain in ['input', 'output']: + if dict_search(f'{family}.{nft_chain}.filter.disable_conntrack', firewall) == {}: + conntrack_disabled_list.append(nft_chain) + + # If conntrack is disabled in the input or output chain, + # state cannot be matched in the input or output chain + if hook in ['input', 'output'] and conntrack_disabled_list: + raise ConfigError(f'state cannot be matched in {hook} when conntrack is disabled in input or output chains') + # If conntrack is disabled in the forward chain, + # state cannot be matched in the forward chain + if hook == 'forward' and disable_conntrack == {}: + raise ConfigError(f'state cannot be matched in {hook} when conntrack is disabled in {hook} chain') + if 'queue_options' in rule_conf: if 'queue' not in rule_conf['action']: raise ConfigError('queue-options defined, but action queue needed and it is not defined') @@ -242,6 +291,11 @@ def verify_rule(firewall, family, hook, priority, rule_id, rule_conf): if {'match_frag', 'match_non_frag'} <= set(rule_conf['fragment']): raise ConfigError('Cannot specify both "match-frag" and "match-non-frag"') + node_empty, node_name = is_node_empty(rule_conf) + if node_empty: + tmp = ' '.join(node_name).replace('_', '-') + raise ConfigError(f'Configuration node {tmp} may not be empty') + if 'limit' in rule_conf: if 'rate' in rule_conf['limit']: rate_int = re.sub(r'\D', '', rule_conf['limit']['rate']) @@ -268,12 +322,12 @@ def verify_rule(firewall, family, hook, priority, rule_id, rule_conf): if dict_search_args(rule_conf, 'gre', 'flags', 'checksum') is None: # There is no builtin match in nftables for the GRE key, so we need to do a raw lookup. - # The offset of the key within the packet shifts depending on the C-flag. - # 99% of the time, nobody will have checksums enabled - it's usually a manual config option. - # We can either assume it is unset unless otherwise directed + # The offset of the key within the packet shifts depending on the C-flag. + # 99% of the time, nobody will have checksums enabled - it's usually a manual config option. + # We can either assume it is unset unless otherwise directed # (confusing, requires doco to explain why it doesn't work sometimes) - # or, demand an explicit selection to be made for this specific match rule. - # This check enforces the latter. The user is free to create rules for both cases. + # or, demand an explicit selection to be made for this specific match rule. + # This check enforces the latter. The user is free to create rules for both cases. raise ConfigError('Matching GRE tunnel key requires an explicit checksum flag match. For most cases, use "gre flags checksum unset"') if dict_search_args(rule_conf, 'gre', 'flags', 'key', 'unset') is not None: @@ -286,7 +340,7 @@ def verify_rule(firewall, family, hook, priority, rule_id, rule_conf): if gre_inner_value < 0 or gre_inner_value > 65535: raise ConfigError('inner-proto outside valid ethertype range 0-65535') except ValueError: - pass # Symbolic constant, pre-validated before reaching here. + pass # Symbolic constant, pre-validated before reaching here. tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') if tcp_flags: @@ -437,6 +491,35 @@ def verify(firewall): for ifname in interfaces: verify_hardware_offload(ifname) + if dict_search_args(firewall, 'global_options', 'geoip', 'provider') == 'maxmind': + geoip_options = dict_search_args(firewall, 'global_options', 'geoip') + required_keys = ['maxmind_account_id', 'maxmind_license_key'] + if not all(key in geoip_options for key in required_keys): + raise ConfigError('MaxMind GeoIP provider requires maxmind-account-id and maxmind-license-key') + + if dict_search('global_options.state_policy', firewall) is not None: + # Generate list of chains where conntrack is disabled + conntrack_disabled_list = [] + for inet_family in ['ipv4', 'ipv6']: + for nft_chain in ['input', 'forward', 'output']: + if dict_search(f'{inet_family}.{nft_chain}.filter.disable_conntrack', firewall) == {}: + conntrack_disabled_list.append(f'{inet_family}-{nft_chain}') + + # If conntrack is disabled in any chain, + # print a warning message + if conntrack_disabled_list: + Warning(f'global-state: conntrack is disabled in the following chains: {", ".join(conntrack_disabled_list)}') + + if 'offload' in firewall.get('global_options', {}).get('state_policy', {}): + offload_path = firewall['global_options']['state_policy']['offload'] + if 'offload_target' not in offload_path: + raise ConfigError('offload-target must be specified') + + offload_target = offload_path['offload_target'] + + if not dict_search_args(firewall, 'flowtable', offload_target): + raise ConfigError(f'Invalid offload-target. Flowtable "{offload_target}" does not exist on the system') + if 'group' in firewall: for group_type in nested_group_types: if group_type in firewall['group']: @@ -449,6 +532,9 @@ def verify(firewall): if 'url' not in group: raise ConfigError(f'remote-group {group_name} must have a url configured') + offload_chains_v4 = set() + offload_chains_v6 = set() + for family in ['ipv4', 'ipv6', 'bridge']: if family in firewall: for chain in ['name','forward','input','output', 'prerouting']: @@ -468,6 +554,12 @@ def verify(firewall): for rule_id, rule_conf in priority_conf['rule'].items(): verify_rule(firewall, family, chain, priority, rule_id, rule_conf) + if chain == 'name' and rule_conf['action'] == 'offload': + if family == 'ipv4': + offload_chains_v4.add(priority) + elif family == 'ipv6': + offload_chains_v6.add(priority) + local_zone = False zone_interfaces = [] zone_vrf = [] @@ -541,6 +633,27 @@ def verify(firewall): if v6_name and not dict_search_args(firewall, 'ipv6', 'name', v6_name): raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist') + if 'local_zone' in zone_conf or 'local_zone' in firewall['zone'][from_zone]: + if (v4_name and v4_name in offload_chains_v4) or \ + (v6_name and v6_name in offload_chains_v6): + raise ConfigError('Cannot use a firewall chain with offloading on local zone') + + if 'default_firewall' in zone_conf: + v4_name = dict_search_args(zone_conf, 'default_firewall', 'name') + if v4_name and not dict_search_args(firewall, 'ipv4', 'name', v4_name): + raise ConfigError(f'Firewall name "{v4_name}" does not exist') + + v6_name = dict_search_args(zone_conf, 'default_firewall', 'ipv6_name') + if v6_name and not dict_search_args(firewall, 'ipv6', 'name', v6_name): + raise ConfigError(f'Firewall ipv6-name "{v6_name}" does not exist') + + if not v4_name and not v6_name: + raise ConfigError('No firewall names specified for default-firewall') + + if (v4_name and v4_name in offload_chains_v4) or \ + (v6_name and v6_name in offload_chains_v6): + raise ConfigError('Cannot use a chain with offloading for zone default-firewall') + return None def generate(firewall): @@ -616,18 +729,18 @@ def apply(firewall): domain_action = 'restart' if dict_search_args(firewall, 'group', 'remote_group') or dict_search_args(firewall, 'group', 'domain_group') or firewall['ip_fqdn'].items() or firewall['ip6_fqdn'].items(): text = f'# Automatically generated by firewall.py\nThis file indicates that vyos-domain-resolver service is used by the firewall.\n' - Path(domain_resolver_usage).write_text(text) + write_file(domain_resolver_usage, text) else: - Path(domain_resolver_usage).unlink(missing_ok=True) - if not Path('/run').glob('use-vyos-domain-resolver*'): + if os.path.exists(domain_resolver_usage): + os.unlink(domain_resolver_usage) + if not glob('/run/use-vyos-domain-resolver*'): domain_action = 'stop' call(f'systemctl {domain_action} vyos-domain-resolver.service') - if firewall['geoip_updated']: - # Call helper script to Update set contents - if 'name' in firewall['geoip_updated'] or 'ipv6_name' in firewall['geoip_updated']: + if firewall['geoip_sets']['name'] or firewall['geoip_sets']['ipv6_name']: + if firewall['geoip_updated'] or not geoip_refresh(): print('Updating GeoIP. Please wait...') - geoip_update(firewall) + geoip_update(firewall=firewall, policy=firewall['policy']) return None diff --git a/src/conf_mode/high-availability.py b/src/conf_mode/high-availability.py index c726db8b2..175929547 100755 --- a/src/conf_mode/high-availability.py +++ b/src/conf_mode/high-availability.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -25,7 +25,7 @@ from ipaddress import IPv6Interface from vyos.base import Warning from vyos.config import Config -from vyos.configdict import leaf_node_changed +from vyos.configdict import node_changed from vyos.ifconfig.vrrp import VRRP from vyos.template import render from vyos.template import is_ipv4 @@ -59,7 +59,7 @@ def get_config(config=None): if conf.exists(conntrack_path): ha['conntrack_sync_group'] = conf.return_value(conntrack_path) - if leaf_node_changed(conf, base + ['vrrp', 'snmp']): + if node_changed(conf, base + ['vrrp', 'snmp']): ha.update({'restart_required': {}}) return ha @@ -188,6 +188,15 @@ def _validate_health_check(group, group_config): # to avoid generating useless config statements in keepalived.conf del group_config["health_check"] + if 'timeout' in group_config.get('health_check', {}): + interval = int(group_config['health_check']['interval']) + timeout = int(group_config['health_check']['timeout']) + if timeout < interval: + Warning( + f'Health check timeout ({timeout}s) is less than interval ({interval}s) ' + f'for VRRP group "{group}", script may be killed before completion' + ) + def generate(ha): if not ha or 'disable' in ha: diff --git a/src/conf_mode/interfaces_bonding.py b/src/conf_mode/interfaces_bonding.py index 84316c16e..55581d6ad 100755 --- a/src/conf_mode/interfaces_bonding.py +++ b/src/conf_mode/interfaces_bonding.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -30,11 +30,11 @@ from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_mtu_ipv6 from vyos.configverify import verify_vlan_config from vyos.configverify import verify_vrf +from vyos.ethtool import Ethtool from vyos.frrender import FRRender from vyos.frrender import get_frrender_dict from vyos.ifconfig import BondIf from vyos.ifconfig.ethernet import EthernetIf -from vyos.ifconfig import Section from vyos.utils.assertion import assert_mac from vyos.utils.dict import dict_search from vyos.utils.dict import dict_to_paths_values @@ -44,6 +44,7 @@ from vyos.configdict import has_address_configured from vyos.configdict import has_vrf_configured from vyos.configdep import set_dependents from vyos.configdep import call_dependents +from vyos.vpp.utils import cli_ifaces_list from vyos import ConfigError from vyos import airbag airbag.enable() @@ -68,7 +69,7 @@ def get_bond_mode(mode): def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -78,7 +79,7 @@ def get_config(config=None): base = ['interfaces', 'bonding'] ifname, bond = get_interface_dict(conf, base, with_pki=True) - # To make our own life easier transfor the list of member interfaces + # To make our own life easier transform the list of member interfaces # into a dictionary - we will use this to add additional information # later on for each member if 'member' in bond and 'interface' in bond['member']: @@ -104,7 +105,6 @@ def get_config(config=None): conf.set_level(['interfaces']) if interfaces_removed: - bond['shutdown_required'] = {} if 'member' not in bond: bond['member'] = {} @@ -114,8 +114,7 @@ def get_config(config=None): # ethernet commit again in apply function # to apply options under ethernet section set_dependents('ethernet', conf, interface) - section = Section.section(interface) # this will be 'ethernet' for 'eth0' - if conf.exists([section, interface, 'disable']): + if conf.exists(['ethernet', interface, 'disable']): tmp[interface] = {'disable': ''} else: tmp[interface] = {} @@ -141,17 +140,10 @@ def get_config(config=None): # Check if member interface is a new member if not conf.exists_effective(base + [ifname, 'member', 'interface', interface]): - bond['shutdown_required'] = {} bond['member']['interface'][interface].update({'new_added' : {}}) - # Check if member interface is disabled - conf.set_level(['interfaces']) - - section = Section.section(interface) # this will be 'ethernet' for 'eth0' - if conf.exists([section, interface, 'disable']): - if tmp: bond['member']['interface'][interface].update({'disable': ''}) - - conf.set_level(old_level) + if 'disable' in interface_ethernet_config: + bond['member']['interface'][interface].update({'disable': ''}) # Check if member interface is already member of another bridge tmp = is_member(conf, interface, 'bridge') @@ -175,6 +167,12 @@ def get_config(config=None): tmp = has_vrf_configured(conf, interface) if tmp: bond['member']['interface'][interface].update({'has_vrf' : ''}) + # Protocols static arp dependency + if 'static_arp' in bond: + set_dependents('static_arp', conf) + + bond['vpp_ifaces'] = cli_ifaces_list(conf) + return bond @@ -210,7 +208,7 @@ def verify(bond): bond_name = bond['ifname'] if dict_search('member.interface', bond): for interface, interface_config in bond['member']['interface'].items(): - error_msg = f'Can not add interface "{interface}" to bond, ' + error_msg = f'Cannot add interface "{interface}" to bond, ' if interface == 'lo': raise ConfigError('Loopback interface "lo" can not be added to a bond') @@ -244,6 +242,27 @@ def verify(bond): continue raise ConfigError(error_msg + f'it has a "{option_path.replace(".", " ")}" assigned!') + iface_base = interface.split('.')[0] # get the parent interface name + if iface_base in bond['vpp_ifaces']: + raise ConfigError( + error_msg + 'it is already configured as VPP interface' + ) + + if mtu := bond.get('mtu'): + mtu = int(mtu) + max_mtu = int(EthernetIf(interface).get_max_mtu()) + min_mtu = int(EthernetIf(interface).get_min_mtu()) + if mtu > max_mtu: + raise ConfigError('Configured MTU is greater then member '\ + f'interface "{interface}" maximum of {max_mtu}!') + if mtu < min_mtu: + raise ConfigError('Configured MTU is less then member '\ + f'interface "{interface}" minimum of {min_mtu}!') + + # not all ethernet drivers support interface bonding + if not Ethtool(interface).check_bonding(): + raise ConfigError(error_msg + 'driver is not supported!') + if 'primary' in bond: if bond['primary'] not in bond['member']['interface']: raise ConfigError(f'Primary interface of bond "{bond_name}" must be a member interface') @@ -279,7 +298,7 @@ def apply(bond): else: b.update(bond) - if dict_search('member.interface_remove', bond): + if dict_search('member.interface_remove', bond) or 'static_arp' in bond: try: call_dependents() except ConfigError: diff --git a/src/conf_mode/interfaces_bridge.py b/src/conf_mode/interfaces_bridge.py index aff93af2a..206d89d84 100755 --- a/src/conf_mode/interfaces_bridge.py +++ b/src/conf_mode/interfaces_bridge.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -25,6 +25,7 @@ from vyos.configdict import has_vlan_subinterface_configured from vyos.configverify import verify_dhcpv6 from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_vrf +from vyos.configverify import verify_mtu_ipv6 from vyos.ifconfig import BridgeIf from vyos.configdict import has_address_configured from vyos.configdict import has_vrf_configured @@ -32,6 +33,7 @@ from vyos.configdep import set_dependents from vyos.configdep import call_dependents from vyos.utils.dict import dict_search from vyos.utils.network import interface_exists +from vyos.vpp.utils import cli_ifaces_list from vyos import ConfigError from vyos import airbag @@ -39,7 +41,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -110,6 +112,11 @@ def get_config(config=None): elif interface.startswith('wlan') and interface_exists(interface): set_dependents('wlan', conf, interface) + if interface.startswith('vtun'): + _, tmp_config = get_interface_dict(conf, ['interfaces', 'openvpn'], interface) + tmp = tmp_config.get('device_type') == 'tap' + bridge['member']['interface'][interface].update({'valid_ovpn' : tmp}) + # delete empty dictionary keys - no need to run code paths if nothing is there to do if 'member' in bridge: if 'interface' in bridge['member'] and len(bridge['member']['interface']) == 0: @@ -118,6 +125,12 @@ def get_config(config=None): if len(bridge['member']) == 0: del bridge['member'] + # Protocols static arp dependency + if 'static_arp' in bridge: + set_dependents('static_arp', conf) + + bridge['vpp_ifaces'] = cli_ifaces_list(conf) + return bridge def verify(bridge): @@ -136,13 +149,14 @@ def verify(bridge): verify_dhcpv6(bridge) verify_vrf(bridge) + verify_mtu_ipv6(bridge) verify_mirror_redirect(bridge) ifname = bridge['ifname'] if dict_search('member.interface', bridge): for interface, interface_config in bridge['member']['interface'].items(): - error_msg = f'Can not add interface "{interface}" to bridge, ' + error_msg = f'Cannot add interface "{interface}" to bridge, ' if interface == 'lo': raise ConfigError('Loopback interface "lo" can not be added to a bridge') @@ -165,6 +179,9 @@ def verify(bridge): if 'has_vrf' in interface_config: raise ConfigError(error_msg + 'it has a VRF assigned!') + if 'bpdu_guard' in interface_config and 'root_guard' in interface_config: + raise ConfigError(error_msg + 'bpdu-guard and root-guard cannot be configured at the same time!') + if 'enable_vlan' in bridge: if 'has_vlan' in interface_config: raise ConfigError(error_msg + 'it has VLAN subinterface(s) assigned!') @@ -173,6 +190,15 @@ def verify(bridge): if option in interface_config: raise ConfigError('Can not use VLAN options on non VLAN aware bridge') + if interface.startswith('vtun') and not interface_config['valid_ovpn']: + raise ConfigError(error_msg + 'OpenVPN device-type must be set to "tap"') + + iface_base = interface.split('.')[0] # get the parent interface name + if iface_base in bridge['vpp_ifaces']: + raise ConfigError( + error_msg + 'it is already configured as VPP interface' + ) + if 'enable_vlan' in bridge: if dict_search('vif.1', bridge): raise ConfigError(f'VLAN 1 sub interface cannot be set for VLAN aware bridge {ifname}, and VLAN 1 is always the parent interface') @@ -200,12 +226,20 @@ def apply(bridge): if 'interface' in bridge['member']: tmp.extend(bridge['member']['interface']) - for interface in tmp: - if interface.startswith(tuple(['vxlan', 'wlan'])) and interface_exists(interface): - try: - call_dependents() - except ConfigError: - raise ConfigError(f'Error updating member interface {interface} configuration after changing bridge!') + # collect member interfaces that require dependent updates + interfaces_need_update = [ + iface + for iface in tmp + if iface.startswith(('vxlan', 'wlan')) and interface_exists(iface) + ] + + if interfaces_need_update or 'static_arp' in bridge: + try: + call_dependents() + except ConfigError: + raise ConfigError( + 'Error updating member interface configuration after changing bridge!' + ) return None diff --git a/src/conf_mode/interfaces_dummy.py b/src/conf_mode/interfaces_dummy.py index db768b94d..c35511199 100755 --- a/src/conf_mode/interfaces_dummy.py +++ b/src/conf_mode/interfaces_dummy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -29,7 +29,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: diff --git a/src/conf_mode/interfaces_ethernet.py b/src/conf_mode/interfaces_ethernet.py index 41c89fdf8..10b778eea 100755 --- a/src/conf_mode/interfaces_ethernet.py +++ b/src/conf_mode/interfaces_ethernet.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -20,8 +20,11 @@ from sys import exit from vyos.base import Warning from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.configdict import get_interface_dict from vyos.configdict import is_node_changed +from vyos.configdict import get_flowtable_interfaces from vyos.configverify import verify_address from vyos.configverify import verify_dhcpv6 from vyos.configverify import verify_interface_exists @@ -33,6 +36,7 @@ from vyos.configverify import verify_vrf from vyos.configverify import verify_bond_bridge_member from vyos.configverify import verify_eapol from vyos.ethtool import Ethtool +from vyos.netlink import coalesce from vyos.frrender import FRRender from vyos.frrender import get_frrender_dict from vyos.ifconfig import EthernetIf @@ -42,6 +46,8 @@ from vyos.utils.dict import dict_to_paths_values from vyos.utils.dict import dict_set from vyos.utils.dict import dict_delete from vyos.utils.process import is_systemd_service_running +from vyos.vpp.config_verify import verify_vpp_remove_interface +from vyos.vpp.control_vpp import VPPControl from vyos import ConfigError from vyos import airbag airbag.enable() @@ -132,7 +138,7 @@ def update_bond_options(conf: Config, eth_conf: dict) -> list: def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -153,7 +159,7 @@ def get_config(config=None): max_mtu = EthernetIf(ifname).get_max_mtu() if max_mtu < int(ethernet['mtu']): ethernet['mtu'] = str(max_mtu) - except: + except Exception: pass if 'is_bond_member' in ethernet: @@ -168,6 +174,27 @@ def get_config(config=None): tmp = is_node_changed(conf, base + [ifname, 'evpn']) if tmp: ethernet.update({'frr_dict' : get_frrender_dict(conf)}) + ethernet['flowtable_interfaces'] = get_flowtable_interfaces(conf) + + vpp_config = conf.get_config_dict( + ['vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + if vpp_config: + ethernet['vpp'] = vpp_config + ethernet['vpp']['interfaces_vpp'] = conf.get_config_dict( + ['interfaces', 'vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + # Protocols static arp dependency + if 'static_arp' in ethernet: + set_dependents('static_arp', conf) + return ethernet def verify_speed_duplex(ethernet: dict, ethtool: Ethtool): @@ -181,11 +208,11 @@ def verify_speed_duplex(ethernet: dict, ethtool: Ethtool): if ((ethernet['speed'] == 'auto' and ethernet['duplex'] != 'auto') or (ethernet['speed'] != 'auto' and ethernet['duplex'] == 'auto')): raise ConfigError( - 'Speed/Duplex missmatch. Must be both auto or manually configured') + 'Speed/Duplex mismatch. Must be both auto or manually configured') if ethernet['speed'] != 'auto' and ethernet['duplex'] != 'auto': # We need to verify if the requested speed and duplex setting is - # supported by the underlaying NIC. + # supported by the underlying NIC. speed = ethernet['speed'] duplex = ethernet['duplex'] if not ethtool.check_speed_duplex(speed, duplex): @@ -238,6 +265,26 @@ def verify_ring_buffer(ethernet: dict, ethtool: Ethtool): f'size of "{max_tx}" bytes!') +def verify_coalesce(ethernet: dict, ethtool: Ethtool): + """ + Verify coalesce settings + :param ethernet: dictionary which is received from get_interface_dict + :type ethernet: dict + :param ethtool: Ethernet object + :type ethtool: Ethtool + """ + if 'interrupt_coalescing' in ethernet: + if not ethtool.check_coalesce(): + raise ConfigError('Driver does not fully support coalesce configuration!') + + for param in coalesce.get_all_params(): + if param in ethernet['interrupt_coalescing']: + if not ethtool.check_coalesce(param): + param_name = param.replace('_', '-') + msg = f'Driver does not support "{param_name}" coalesce setting!' + raise ConfigError(msg) + + def verify_offload(ethernet: dict, ethtool: Ethtool): """ Verify offloading capabilities @@ -248,7 +295,7 @@ def verify_offload(ethernet: dict, ethtool: Ethtool): """ if dict_search('offload.rps', ethernet) != None: if not os.path.exists(f'/sys/class/net/{ethernet["ifname"]}/queues/rx-0/rps_cpus'): - raise ConfigError('Interface does not suport RPS!') + raise ConfigError('Interface does not support RPS!') driver = ethtool.get_driver_name() # T3342 - Xen driver requires special treatment if driver == 'vif': @@ -256,6 +303,20 @@ def verify_offload(ethernet: dict, ethtool: Ethtool): raise ConfigError('Xen netback drivers requires scatter-gatter offloading '\ 'for MTU size larger then 1500 bytes') +def verify_mac_change(ethernet: dict, ethtool: Ethtool): + """ + Verify if ethernet card driver supports changing the interface MAC address. + AWS ENA driver has no support for MAC address changes. + + :param ethernet: dictionary which is received from get_interface_dict + :type ethernet: dict + :param ethtool: Ethernet object + :type ethtool: Ethtool + """ + if 'mac' not in ethernet: + return None + if not ethtool.check_mac_change(): + raise ConfigError(f'Driver does not support changing MAC address!') def verify_allowedbond_changes(ethernet: dict): """ @@ -269,54 +330,99 @@ def verify_allowedbond_changes(ethernet: dict): f' on interface "{ethernet["ifname"]}".' \ f' Interface is a bond member') +def verify_flowtable(ethernet: dict): + ifname = ethernet['ifname'] + + if 'deleted' in ethernet and ifname in ethernet['flowtable_interfaces']: + raise ConfigError(f'Cannot delete interface "{ifname}", still referenced on a flowtable') + + if 'vif_remove' in ethernet: + for vif in ethernet['vif_remove']: + vifname = f'{ifname}.{vif}' + + if vifname in ethernet['flowtable_interfaces']: + raise ConfigError(f'Cannot delete interface "{vifname}", still referenced on a flowtable') + + if 'vif_s_remove' in ethernet: + for vifs in ethernet['vif_s_remove']: + vifsname = f'{ifname}.{vifs}' + + if vifsname in ethernet['flowtable_interfaces']: + raise ConfigError(f'Cannot delete interface "{vifsname}", still referenced on a flowtable') + + if 'vif_s' in ethernet: + for vifs, vifs_conf in ethernet['vif_s'].items(): + if 'vif_c_delete' in vifs_conf: + for vifc in vifs_conf['vif_c_delete']: + vifcname = f'{ifname}.{vifs}.{vifc}' + + if vifcname in ethernet['flowtable_interfaces']: + raise ConfigError(f'Cannot delete interface "{vifcname}", still referenced on a flowtable') + +def verify_vpp_remove_vif(ethernet: dict): + """Ensure that VIF interfaces being removed are not used by VPP features""" + ifname = ethernet['ifname'] + vpp_config = ethernet.get('vpp') + + if not vpp_config: + return + + vlan_names = [ + f'{ifname}.{vif_id}' + for vif_group in ['vif_remove', 'vif_s_remove'] + for vif_id in ethernet.get(vif_group, []) + ] + + for vlan in vlan_names: + verify_vpp_remove_interface(vlan, vpp_config) + def verify(ethernet): + verify_flowtable(ethernet) + verify_vpp_remove_vif(ethernet) + if 'deleted' in ethernet: return None - if 'is_bond_member' in ethernet: - verify_bond_member(ethernet) - else: - verify_ethernet(ethernet) - -def verify_bond_member(ethernet): - """ - Verification function for ethernet interface which is in bonding - :param ethernet: dictionary which is received from get_interface_dict - :type ethernet: dict - """ ifname = ethernet['ifname'] - verify_interface_exists(ethernet, ifname) + verify_interface_exists(ethernet, ifname, state_required=True) verify_eapol(ethernet) verify_mirror_redirect(ethernet) + # No need to check speed and duplex keys as both have default values ethtool = Ethtool(ifname) verify_speed_duplex(ethernet, ethtool) verify_flow_control(ethernet, ethtool) verify_ring_buffer(ethernet, ethtool) verify_offload(ethernet, ethtool) + verify_mac_change(ethernet, ethtool) + verify_coalesce(ethernet, ethtool) + + if 'is_bond_member' in ethernet: + verify_bond_member(ethernet, ethtool) + else: + verify_ethernet(ethernet, ethtool) + + +def verify_bond_member(ethernet: dict, ethtool: Ethtool) -> None: + """ + Verification function for ethernet interface which is in bonding + :param ethernet: dictionary which is received from get_interface_dict + :type ethernet: dict + """ verify_allowedbond_changes(ethernet) + return None -def verify_ethernet(ethernet): +def verify_ethernet(ethernet: dict, ethtool: Ethtool) -> None: """ Verification function for simple ethernet interface :param ethernet: dictionary which is received from get_interface_dict :type ethernet: dict """ - ifname = ethernet['ifname'] - verify_interface_exists(ethernet, ifname) verify_mtu(ethernet) verify_mtu_ipv6(ethernet) verify_dhcpv6(ethernet) verify_address(ethernet) verify_vrf(ethernet) verify_bond_bridge_member(ethernet) - verify_eapol(ethernet) - verify_mirror_redirect(ethernet) - ethtool = Ethtool(ifname) - # No need to check speed and duplex keys as both have default values. - verify_speed_duplex(ethernet, ethtool) - verify_flow_control(ethernet, ethtool) - verify_ring_buffer(ethernet, ethtool) - verify_offload(ethernet, ethtool) # use common function to verify VLAN configuration verify_vlan_config(ethernet) return None @@ -329,11 +435,44 @@ def generate(ethernet): def apply(ethernet): if 'frr_dict' in ethernet and not is_systemd_service_running('vyos-configd.service'): FRRender().apply() - e = EthernetIf(ethernet['ifname']) + ifname = ethernet['ifname'] + e = EthernetIf(ifname) if 'deleted' in ethernet: e.remove() else: e.update(ethernet) + if 'static_arp' in ethernet: + call_dependents() + + vpp_iface_config = dict_search(f'vpp.settings.interface.{ifname}', ethernet) + if vpp_iface_config is not None and is_systemd_service_running('vpp.service'): + vpp_api = VPPControl() + + # Enable ip4-dhcp-client-detect feature for DHCP-configured interfaces. + # This feature is required for VPP to process DHCP packets and assign addresses. + if 'dhcp' in ethernet.get('address', []): + vpp_api.enable_dhcp_client(ifname) + else: + vpp_api.disable_dhcp_client(ifname) + + # Enable ip6-icmp-ra-punt feature for DHCPv6-configured interfaces. + if 'dhcpv6' in ethernet.get('address', []) or ( + 'autoconf' in ethernet.get('ipv6', {}).get('address', {}) + ): + vpp_api.enable_icmpv6_ra_punt(ifname) + else: + vpp_api.disable_icmpv6_ra_punt(ifname) + + # If the interface is managed by the VPP DPDK driver, synchronize runtime + # parameters between Linux and the corresponding VPP LCP interface + # Find LCP pair + lcp_pair = vpp_api.lcp_pair_find(vpp_name_hw=ifname) + # Sync MTU to VPP LCP pair interface + if lcp_pair: + lcp_name = lcp_pair.get('vpp_name_kernel') + mtu = e.get_mtu() + vpp_api.set_iface_mtu(lcp_name, mtu) + return None if __name__ == '__main__': diff --git a/src/conf_mode/interfaces_geneve.py b/src/conf_mode/interfaces_geneve.py index 1c5b4d0e7..faaa7b848 100755 --- a/src/conf_mode/interfaces_geneve.py +++ b/src/conf_mode/interfaces_geneve.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -17,6 +17,8 @@ from sys import exit from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.configdict import get_interface_dict from vyos.configdict import is_node_changed from vyos.configverify import verify_address @@ -34,7 +36,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -51,6 +53,10 @@ def get_config(config=None): if is_node_changed(conf, base + [ifname, cli_option]): geneve.update({'rebuild_required': {}}) + # Protocols static arp dependency + if 'static_arp' in geneve: + set_dependents('static_arp', conf) + return geneve def verify(geneve): @@ -90,6 +96,9 @@ def apply(geneve): g = GeneveIf(**geneve) g.update(geneve) + if 'static_arp' in geneve: + call_dependents() + return None diff --git a/src/conf_mode/interfaces_input.py b/src/conf_mode/interfaces_input.py index ad248843d..d41610b6d 100755 --- a/src/conf_mode/interfaces_input.py +++ b/src/conf_mode/interfaces_input.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -26,7 +26,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: diff --git a/src/conf_mode/interfaces_l2tpv3.py b/src/conf_mode/interfaces_l2tpv3.py index f0a70436e..85438b6c5 100755 --- a/src/conf_mode/interfaces_l2tpv3.py +++ b/src/conf_mode/interfaces_l2tpv3.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -17,6 +17,8 @@ from sys import exit from vyos.config import Config +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_address @@ -37,7 +39,7 @@ k_mod = ['l2tp_eth', 'l2tp_netlink', 'l2tp_ip', 'l2tp_ip6'] def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -56,6 +58,10 @@ def get_config(config=None): tmp = leaf_node_changed(conf, base + [ifname, 'session-id']) l2tpv3.update({'session_id': tmp[0]}) + # Protocols static arp dependency + if 'static_arp' in l2tpv3: + set_dependents('static_arp', conf) + return l2tpv3 def verify(l2tpv3): @@ -100,6 +106,9 @@ def apply(l2tpv3): l = L2TPv3If(**l2tpv3) l.update(l2tpv3) + if 'static_arp' in l2tpv3: + call_dependents() + return None if __name__ == '__main__': diff --git a/src/conf_mode/interfaces_loopback.py b/src/conf_mode/interfaces_loopback.py index a784e9ec2..c19ea162e 100755 --- a/src/conf_mode/interfaces_loopback.py +++ b/src/conf_mode/interfaces_loopback.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -26,7 +26,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: diff --git a/src/conf_mode/interfaces_macsec.py b/src/conf_mode/interfaces_macsec.py index 3ede4377a..3c043e11e 100755 --- a/src/conf_mode/interfaces_macsec.py +++ b/src/conf_mode/interfaces_macsec.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -19,6 +19,8 @@ import os from sys import exit from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.configdict import get_interface_dict from vyos.configdict import is_node_changed from vyos.configdict import is_source_interface @@ -53,7 +55,7 @@ GCM_256_KEY_ERROR = 'gcm-aes-256 requires a 256bit long key!' def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -71,13 +73,17 @@ def get_config(config=None): if is_node_changed(conf, base + [ifname, 'security']): macsec.update({'shutdown_required': {}}) - if is_node_changed(conf, base + [ifname, 'source_interface']): + if is_node_changed(conf, base + [ifname, 'source-interface']): macsec.update({'shutdown_required': {}}) if 'source_interface' in macsec: tmp = is_source_interface(conf, macsec['source_interface'], ['macsec', 'pseudo-ethernet']) if tmp and tmp != ifname: macsec.update({'is_source_interface' : tmp}) + # Protocols static arp dependency + if 'static_arp' in macsec: + set_dependents('static_arp', conf) + return macsec @@ -148,11 +154,11 @@ def verify(macsec): if 'source_interface' in macsec: # MACsec adds a 40 byte overhead (32 byte MACsec + 8 bytes VLAN 802.1ad - # and 802.1q) - we need to check the underlaying MTU if our configured + # and 802.1q) - we need to check the underlying MTU if our configured # MTU is at least 40 bytes less then the MTU of our physical interface. lower_mtu = Interface(macsec['source_interface']).get_mtu() if lower_mtu < (int(macsec['mtu']) + 40): - raise ConfigError('MACsec overhead does not fit into underlaying device MTU,\n' \ + raise ConfigError('MACsec overhead does not fit into underlying device MTU,\n' \ f'{lower_mtu} bytes is too small!') return None @@ -193,6 +199,9 @@ def apply(macsec): if not is_systemd_service_running(systemd_service) or 'shutdown_required' in macsec: call(f'systemctl reload-or-restart {systemd_service}') + if 'static_arp' in macsec: + call_dependents() + return None diff --git a/src/conf_mode/interfaces_openvpn.py b/src/conf_mode/interfaces_openvpn.py index a9b4e570d..d6b63ae2a 100755 --- a/src/conf_mode/interfaces_openvpn.py +++ b/src/conf_mode/interfaces_openvpn.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -78,9 +78,31 @@ otp_file = '/config/auth/openvpn/{ifname}-otp-secrets' secret_chars = list('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567') service_file = '/run/systemd/system/openvpn@{ifname}.service.d/20-override.conf' +def _only_client_config_changed(conf, base, ifname): + """ + Return True when the sole diff under this interface is a change to + `server.client` entries (i.e. CCD files). + """ + + iface_path = base + [ifname] + diff = get_config_diff(conf) + + def _has_only_changes(path, node): + changes = diff.node_changed_children(path) + return len(changes) == 1 and changes[0] == node + + # Something outside of 'server' also changed - not a CCD-only change + if _has_only_changes(iface_path, 'server'): + # Something outside of 'server.client' also changed - not a CCD-only change + if _has_only_changes(iface_path + ['server'], 'client'): + return True + + return False + + def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -117,6 +139,12 @@ def get_config(config=None): if is_node_changed(conf, base + [ifname, 'enable-dco']): openvpn.update({'restart_required': {}}) + # Detect changes that are limited to per-client CCD entries (T6478). + # OpenVPN reads client-config-dir files at connect time, so adding or + # updating them requires neither a SIGHUP nor a service restart. + if 'restart_required' not in openvpn and openvpn['mode'] == 'server': + openvpn['client_only_changed'] = _only_client_config_changed(conf, base, ifname) + # We have to get the dict using 'get_config_dict' instead of 'get_interface_dict' # as 'get_interface_dict' merges the defaults in, so we can not check for defaults in there. tmp = conf.get_config_dict(base + [openvpn['ifname']], get_first_key=True) @@ -168,6 +196,12 @@ def is_ec_private_key(pki, cert_name): key = load_private_key(pki_cert['private']['key']) return isinstance(key, ec.EllipticCurvePrivateKey) + +def verify_data_ciphers_fallback(openvpn): + if openvpn['mode'] != 'site-to-site': + if dict_search('encryption.data_ciphers_fallback', openvpn): + raise ConfigError('Cipher fallback is valid only in site-to-site mode') + def verify_pki(openvpn): pki = openvpn['pki'] interface = openvpn['ifname'] @@ -361,6 +395,11 @@ def verify(openvpn): if dict_search('encryption.data_ciphers', openvpn): raise ConfigError('Cipher negotiation can only be used in client or server mode') + if not dict_search('encryption.cipher', openvpn) and \ + not dict_search('encryption.data_ciphers_fallback', openvpn): + raise ConfigError('Must define "encryption cipher" or "encryption ' \ + 'data-ciphers-fallback" for site-to-site encryption!') + else: # checks for client-server or site-to-site bridged if 'local_address' in openvpn or 'remote_address' in openvpn: @@ -615,6 +654,8 @@ def verify(openvpn): verify_bond_bridge_member(openvpn) verify_mirror_redirect(openvpn) + verify_data_ciphers_fallback(openvpn) + return None def generate_pki_files(openvpn): @@ -734,7 +775,7 @@ def generate(openvpn): # create client config directory on demand makedir(ccd_dir, user, group) - # Fix file permissons for keys + # Fix file permissions for keys generate_pki_files(openvpn) # Generate User/Password authentication file @@ -785,7 +826,7 @@ def apply(openvpn): VTunIf(interface).remove() # dynamically load/unload DCO Kernel extension if requested - dco_module = 'ovpn_dco_v2' + dco_module = 'ovpn' if 'module_load_dco' in openvpn: check_kmod(dco_module) else: @@ -805,7 +846,7 @@ def apply(openvpn): # No matching OpenVPN process running - maybe it got killed or none # existed - nevertheless, spawn new OpenVPN process - if not openvpn.get('no_restart_crl'): + if not openvpn.get('no_restart_crl') and not openvpn.get('client_only_changed'): action = 'reload-or-restart' if 'restart_required' in openvpn: action = 'restart' diff --git a/src/conf_mode/interfaces_pppoe.py b/src/conf_mode/interfaces_pppoe.py index 412676c7d..1fb2b7278 100755 --- a/src/conf_mode/interfaces_pppoe.py +++ b/src/conf_mode/interfaces_pppoe.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -36,7 +36,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -49,9 +49,18 @@ def get_config(config=None): # We should only terminate the PPPoE session if critical parameters change. # All parameters that can be changed on-the-fly (like interface description) # should not lead to a reconnect! - for options in ['access-concentrator', 'connect-on-demand', 'service-name', - 'source-interface', 'vrf', 'no-default-route', - 'authentication', 'host_uniq']: + for options in [ + 'access-concentrator', + 'connect-on-demand', + 'service-name', + 'source-interface', + 'vrf', + 'no-default-route', + 'authentication', + 'host-uniq', + 'dhcpv6-options', + 'ipv6', + ]: if is_node_changed(conf, base + [ifname, options]): pppoe.update({'shutdown_required': {}}) # bail out early - no need to further process other nodes diff --git a/src/conf_mode/interfaces_pseudo-ethernet.py b/src/conf_mode/interfaces_pseudo-ethernet.py index 446beffd3..6a4219343 100755 --- a/src/conf_mode/interfaces_pseudo-ethernet.py +++ b/src/conf_mode/interfaces_pseudo-ethernet.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -17,8 +17,9 @@ from sys import exit from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.configdict import get_interface_dict -from vyos.configdict import is_node_changed from vyos.configdict import is_source_interface from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf @@ -27,6 +28,7 @@ from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_source_interface from vyos.configverify import verify_vlan_config from vyos.configverify import verify_mtu_parent +from vyos.configverify import verify_mtu_ipv6 from vyos.configverify import verify_mirror_redirect from vyos.ifconfig import MACVLANIf from vyos.utils.network import interface_exists @@ -37,7 +39,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -60,6 +62,10 @@ def get_config(config=None): tmp = is_source_interface(conf, peth['source_interface'], ['macsec']) if tmp and tmp != ifname: peth.update({'is_source_interface' : tmp}) + # Protocols static arp dependency + if 'static_arp' in peth: + set_dependents('static_arp', conf) + return peth def verify(peth): @@ -71,6 +77,7 @@ def verify(peth): verify_vrf(peth) verify_address(peth) verify_mtu_parent(peth, peth['parent']) + verify_mtu_ipv6(peth) verify_mirror_redirect(peth) # use common function to verify VLAN configuration verify_vlan_config(peth) @@ -93,6 +100,9 @@ def apply(peth): p = MACVLANIf(**peth) p.update(peth) + if 'static_arp' in peth: + call_dependents() + return None if __name__ == '__main__': diff --git a/src/conf_mode/interfaces_sstpc.py b/src/conf_mode/interfaces_sstpc.py index b9d7a74fb..50d3d1cb3 100755 --- a/src/conf_mode/interfaces_sstpc.py +++ b/src/conf_mode/interfaces_sstpc.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -37,7 +37,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: diff --git a/src/conf_mode/interfaces_tunnel.py b/src/conf_mode/interfaces_tunnel.py index ee1436e49..053c831d1 100755 --- a/src/conf_mode/interfaces_tunnel.py +++ b/src/conf_mode/interfaces_tunnel.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -37,7 +37,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: diff --git a/src/conf_mode/interfaces_virtual-ethernet.py b/src/conf_mode/interfaces_virtual-ethernet.py index cb6104f59..00fc9cce9 100755 --- a/src/conf_mode/interfaces_virtual-ethernet.py +++ b/src/conf_mode/interfaces_virtual-ethernet.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -19,17 +19,21 @@ from sys import exit from vyos import ConfigError from vyos import airbag from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.configdict import get_interface_dict from vyos.configverify import verify_address from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_vrf +from vyos.configverify import verify_mtu_ipv6 from vyos.ifconfig import VethIf +from vyos.utils.dict import dict_search from vyos.utils.network import interface_exists airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -42,10 +46,14 @@ def get_config(config=None): # We need to know all other veth related interfaces as veth requires a 1:1 # mapping for the peer-names. The Linux kernel automatically creates both # interfaces, the local one and the peer-name, but VyOS also needs a peer - # interfaces configrued on the CLI so we can assign proper IP addresses etc. + # interfaces configured on the CLI so we can assign proper IP addresses etc. veth['other_interfaces'] = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) + # Protocols static arp dependency + if 'static_arp' in veth: + set_dependents('static_arp', conf) + return veth @@ -62,6 +70,7 @@ def verify(veth): return None verify_vrf(veth) + verify_mtu_ipv6(veth) verify_address(veth) if 'peer_name' not in veth: @@ -74,7 +83,7 @@ def verify(veth): raise ConfigError(f'Used peer-name "{peer_name}" on interface "{ifname}" ' \ 'is not configured!') - if veth['other_interfaces'][peer_name]['peer_name'] != ifname: + if dict_search(f'other_interfaces.{peer_name}.peer_name', veth) != ifname: raise ConfigError( f'Configuration mismatch between "{ifname}" and "{peer_name}"!') @@ -99,6 +108,9 @@ def apply(veth): p = VethIf(**veth) p.update(veth) + if 'static_arp' in veth: + call_dependents() + return None diff --git a/src/conf_mode/interfaces_vti.py b/src/conf_mode/interfaces_vti.py index 20629c6c1..b4652d727 100755 --- a/src/conf_mode/interfaces_vti.py +++ b/src/conf_mode/interfaces_vti.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -20,6 +20,7 @@ from vyos.config import Config from vyos.configdict import get_interface_dict from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_vrf +from vyos.configverify import verify_mtu_ipv6 from vyos.ifconfig import VTIIf from vyos import ConfigError from vyos import airbag @@ -27,7 +28,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -40,6 +41,7 @@ def get_config(config=None): def verify(vti): verify_vrf(vti) + verify_mtu_ipv6(vti) verify_mirror_redirect(vti) return None diff --git a/src/conf_mode/interfaces_vxlan.py b/src/conf_mode/interfaces_vxlan.py index 256b65708..819920009 100755 --- a/src/conf_mode/interfaces_vxlan.py +++ b/src/conf_mode/interfaces_vxlan.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -18,6 +18,8 @@ from sys import exit from vyos.base import Warning from vyos.config import Config +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.configdict import is_node_changed @@ -40,7 +42,7 @@ airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -66,7 +68,8 @@ def get_config(config=None): vxlan.update({'vlan_to_vni_removed': {}}) for vlan in tmp: vni = leaf_node_changed(conf, base + [ifname, 'vlan-to-vni', vlan, 'vni']) - vxlan['vlan_to_vni_removed'].update({vlan : {'vni' : vni[0]}}) + if vni: + vxlan['vlan_to_vni_removed'].update({vlan : {'vni' : vni[0]}}) # We need to verify that no other VXLAN tunnel is configured when external # mode is in use - Linux Kernel limitation @@ -82,6 +85,10 @@ def get_config(config=None): if len(vxlan['other_tunnels']) == 0: del vxlan['other_tunnels'] + # Protocols static arp dependency + if 'static_arp' in vxlan: + set_dependents('static_arp', conf) + return vxlan def verify(vxlan): @@ -94,7 +101,7 @@ def verify(vxlan): if 'group' in vxlan: if 'source_interface' not in vxlan: - raise ConfigError('Multicast VXLAN requires an underlaying interface') + raise ConfigError('Multicast VXLAN requires an underlying interface') if 'remote' in vxlan: raise ConfigError('Both group and remote cannot be specified') verify_source_interface(vxlan) @@ -118,7 +125,7 @@ def verify(vxlan): if dict_search('parameters.vni_filter', tunnel_config) != None: other_vni_filter = True break - # eqivalent of the C foo ? 'a' : 'b' statement + # equivalent of the C foo ? 'a' : 'b' statement vni_filter = True and (dict_search('parameters.vni_filter', vxlan) != None) or False # If either one is enabled, so must be the other. Both can be off and both can be on if (vni_filter and not other_vni_filter) or (not vni_filter and other_vni_filter): @@ -137,7 +144,7 @@ def verify(vxlan): if 'source_interface' in vxlan: # VXLAN adds at least an overhead of 50 byte - we need to check the - # underlaying device if our VXLAN package is not going to be fragmented! + # underlying device if our VXLAN package is not going to be fragmented! vxlan_overhead = 50 if 'source_address' in vxlan and is_ipv6(vxlan['source_address']): # IPv6 adds an extra 20 bytes overhead because the IPv6 header is 20 @@ -152,8 +159,10 @@ def verify(vxlan): lower_mtu = Interface(vxlan['source_interface']).get_mtu() if lower_mtu < (int(vxlan['mtu']) + vxlan_overhead): - raise ConfigError(f'Underlaying device MTU is to small ({lower_mtu} '\ - f'bytes) for VXLAN overhead ({vxlan_overhead} bytes!)') + Warning( + f'Underlying device MTU is too small ({lower_mtu} ' + f'bytes) for VXLAN overhead ({vxlan_overhead} bytes!)' + ) # Check for mixed IPv4 and IPv6 addresses protocol = None @@ -248,6 +257,9 @@ def apply(vxlan): v = VXLANIf(**vxlan) v.update(vxlan) + if 'static_arp' in vxlan: + call_dependents() + return None if __name__ == '__main__': diff --git a/src/conf_mode/interfaces_wireguard.py b/src/conf_mode/interfaces_wireguard.py index 192937dba..92e3a239d 100755 --- a/src/conf_mode/interfaces_wireguard.py +++ b/src/conf_mode/interfaces_wireguard.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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,6 +14,9 @@ # 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 glob import glob from sys import exit from vyos.config import Config @@ -31,17 +34,17 @@ from vyos.configverify import verify_bond_bridge_member from vyos.ifconfig import WireGuardIf from vyos.utils.kernel import check_kmod from vyos.utils.network import check_port_availability +from vyos.utils.network import get_vrf_tableid from vyos.utils.network import is_wireguard_key_pair from vyos.utils.process import call from vyos import ConfigError from vyos import airbag -from pathlib import Path airbag.enable() def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -73,6 +76,16 @@ def get_config(config=None): else: wireguard['is_source_interface'] = tmp + if is_node_changed(conf, base + [ifname, 'fwmark']) or is_node_changed( + conf, base + [ifname, 'vrf'] + ): + wireguard['fwmark_vrf_changed'] = {} + prev = conf.get_config_dict( + base + [ifname], effective=True, key_mangling=('-', '_'), get_first_key=True + ) + wireguard['prev_fwmark'] = prev.get('fwmark') + wireguard['prev_vrf'] = prev.get('vrf') + return wireguard @@ -97,7 +110,7 @@ def verify(wireguard): if 'port' in wireguard and 'port_changed' in wireguard: listen_port = int(wireguard['port']) - if check_port_availability('0.0.0.0', listen_port, 'udp') is not True: + if check_port_availability(None, listen_port, protocol='udp') is not True: raise ConfigError(f'UDP port {listen_port} is busy or unavailable and ' 'cannot be used for the interface!') @@ -145,21 +158,36 @@ def generate(wireguard): 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, - # by deleting the entire interface this is the shortcut instead of parsing - # out all peers and removing them one by one. - # - # Peer reconfiguration will always come with a short downtime while the - # WireGuard interface is recreated (see below) - wg.remove() + wg = WireGuardIf(**wireguard) - # Create the new interface if required - if 'deleted' not in wireguard: - wg = WireGuardIf(**wireguard) + if 'deleted' in wireguard: + wg.remove() + else: wg.update(wireguard) + # delete old fwmark-based ip rule if fwmark or VRF was changed + if 'fwmark_vrf_changed' in wireguard or 'deleted' in wireguard: + prev_fwmark = wireguard.get('prev_fwmark') + prev_vrf = wireguard.get('prev_vrf') + if prev_fwmark is not None and prev_vrf is not None: + table_id = get_vrf_tableid(prev_vrf) + if table_id is not None: + for afi in ['-4', '-6']: + call( + f'ip {afi} rule del pref 1998 fwmark {prev_fwmark} table {table_id}' + ) + + # Add ip rule to route fwmark-marked WireGuard tunnel packets into the + # correct VRF routing table. This is required for VRF-bound WireGuard + # interfaces with fwmark set, so that outgoing encapsulated packets use the + # proper VRF routes (otherwise, they may be unroutable or use the main table). + if wireguard.get('fwmark', '0') != '0' and 'vrf' in wireguard: + table_id = get_vrf_tableid(wireguard['vrf']) + for afi in ['-4', '-6']: + call( + f'ip {afi} rule add pref 1998 fwmark {wireguard["fwmark"]} table {table_id}' + ) + domain_resolver_usage = '/run/use-vyos-domain-resolver-interfaces-wireguard-' + wireguard['ifname'] ## DOMAIN RESOLVER @@ -168,12 +196,12 @@ def apply(wireguard): from vyos.utils.file import write_file text = f'# Automatically generated by interfaces_wireguard.py\nThis file indicates that vyos-domain-resolver service is used by the interfaces_wireguard.\n' - text += "intefaces:\n" + "".join([f" - {peer}\n" for peer in wireguard['peers_need_resolve']]) - Path(domain_resolver_usage).write_text(text) + text += "interfaces:\n" + "".join([f" - {peer}\n" for peer in wireguard['peers_need_resolve']]) write_file(domain_resolver_usage, text) else: - Path(domain_resolver_usage).unlink(missing_ok=True) - if not Path('/run').glob('use-vyos-domain-resolver*'): + if os.path.exists(domain_resolver_usage): + os.unlink(domain_resolver_usage) + if not glob('/run/use-vyos-domain-resolver*'): domain_action = 'stop' call(f'systemctl {domain_action} vyos-domain-resolver.service') diff --git a/src/conf_mode/interfaces_wireless.py b/src/conf_mode/interfaces_wireless.py index d24675ee6..68aa71474 100755 --- a/src/conf_mode/interfaces_wireless.py +++ b/src/conf_mode/interfaces_wireless.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -22,6 +22,8 @@ from netaddr import EUI, mac_unix_expanded from time import sleep from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.configdict import get_interface_dict from vyos.configdict import dict_merge from vyos.configverify import verify_address @@ -34,10 +36,14 @@ from vyos.ifconfig import WiFiIf from vyos.template import render from vyos.utils.dict import dict_search from vyos.utils.kernel import check_kmod +from vyos.utils.kernel import is_module_loaded +from vyos.utils.file import read_file +from vyos.utils.file import write_file from vyos.utils.process import call from vyos.utils.process import is_systemd_service_active from vyos.utils.process import is_systemd_service_running from vyos.utils.network import interface_exists +from vyos.base import Warning from vyos import ConfigError from vyos import airbag airbag.enable() @@ -48,6 +54,8 @@ hostapd_conf = '/run/hostapd/{ifname}.conf' hostapd_accept_station_conf = '/run/hostapd/{ifname}_station_accept.conf' hostapd_deny_station_conf = '/run/hostapd/{ifname}_station_deny.conf' +mt7915e_conf = f'/etc/modprobe.d/mt7915e.conf' + country_code_path = ['system', 'wireless', 'country-code'] def find_other_stations(conf, base, ifname): @@ -75,7 +83,7 @@ def find_other_stations(conf, base, ifname): def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -133,6 +141,10 @@ def get_config(config=None): wifi['hostapd_accept_station_conf'] = hostapd_accept_station_conf.format(**wifi) wifi['hostapd_deny_station_conf'] = hostapd_deny_station_conf.format(**wifi) + # Protocols static arp dependency + if 'static_arp' in wifi: + set_dependents('static_arp', conf) + return wifi def verify(wifi): @@ -191,7 +203,7 @@ def verify(wifi): elif 'wpa' in wifi['security']: wpa = wifi['security']['wpa'] if not any(i in ['passphrase', 'radius'] for i in wpa): - raise ConfigError('Misssing WPA key or RADIUS server') + raise ConfigError('Missing WPA key or RADIUS server') if 'username' in wpa: if 'passphrase' not in wpa: @@ -226,7 +238,9 @@ def verify(wifi): phy = wifi['physical_device'] if phy in wifi['station_interfaces']: if len(wifi['station_interfaces'][phy]) > 0: - raise ConfigError('Only one station per wireless physical interface possible!') + raise ConfigError( + 'Only one station per wireless physical interface possible!' + ) verify_address(wifi) verify_vrf(wifi) @@ -314,6 +328,54 @@ def apply(wifi): w = WiFiIf(**wifi) w.update(wifi) + # Set up the mt7915e module according to new wifi configuration. + # For this card, the decision is made for the 5GHz/6GHz-capable phy: + # 5GHz uses VHT (802.11ac) op_modes and 6GHz uses HE (802.11ax) + # op_modes. The card does not support WiFi-7 (802.11be). + # There is a race condition in the order the two phys on that card + # are configured. Sometimes, the 5/6GHz phy is configured first and + # the 2.4GHz phy is configured last, which would overwrite the module + # parameter definition. Only the Wi-Fi configuration for the 5/6GHz phy + # must write the file! + # + # Only if the mt7915e module is loaded (card present)... + if is_module_loaded('mt7915e'): + # op_modes as configured in interfaces_wireless.xml.in + five_ghz_op_modes_vht = ['0', '1', '2', '3'] + six_ghz_op_modes_he = ['131', '132', '133', '134', '135'] + # Make sure to act only when VHT or HE modes are used + module_options = '' + if 'capabilities' in wifi: + mt7915e_options_string = 'options mt7915e' + if 'he' in wifi['capabilities']: + if 'channel_set_width' in wifi['capabilities']['he']: + if wifi['capabilities']['he']['channel_set_width'] in six_ghz_op_modes_he: + # 6GHz band required (802.11ax, WiFi-6e) + module_options = f'{mt7915e_options_string} enable_6ghz=1' + if 'vht' in wifi['capabilities']: + if 'channel_set_width' in wifi['capabilities']['vht']: + if wifi['capabilities']['vht']['channel_set_width'] in five_ghz_op_modes_vht: + # 5GHz band required... + module_options = f'{mt7915e_options_string} enable_6ghz=0' + + tmp = None + if os.path.isfile(mt7915e_conf): + tmp = read_file(mt7915e_conf) + + # Write the module config, so that there always is a valid module + # config which is mandatory to load the mt7916 firmware. + write_file(mt7915e_conf, module_options, mode=0o644) + # Issue warning if module options have changed. Warning is necessary + # even if this is the first time this module is configured, + # firmware must be reloaded. + if tmp != module_options: + Warning('Change to firmware 5GHz/6GHz configuration detected. '\ + 'The system must be rebooted to correctly reload the ' \ + 'mt7916 firmware. The card will not work otherwise!') + # Instead of reboot - can we unload and re-load the driver? + elif os.path.isfile(mt7915e_conf): + os.remove(mt7915e_conf) + # Enable/Disable interface - interface is always placed in # administrative down state in WiFiIf class if 'disable' not in wifi: @@ -331,6 +393,9 @@ def apply(wifi): elif wifi['type'] == 'station': call(f'systemctl start wpa_supplicant@{interface}.service') + if 'static_arp' in wifi: + call_dependents() + return None if __name__ == '__main__': diff --git a/src/conf_mode/interfaces_wwan.py b/src/conf_mode/interfaces_wwan.py index 230eb14d6..ad6c806ad 100755 --- a/src/conf_mode/interfaces_wwan.py +++ b/src/conf_mode/interfaces_wwan.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -20,14 +20,18 @@ from sys import exit from time import sleep from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.configdict import get_interface_dict from vyos.configdict import is_node_changed from vyos.configverify import verify_authentication from vyos.configverify import verify_interface_exists from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_vrf +from vyos.configverify import verify_mtu_ipv6 from vyos.ifconfig import WWANIf from vyos.utils.dict import dict_search +from vyos.utils.network import is_wwan_connected from vyos.utils.process import cmd from vyos.utils.process import call from vyos.utils.process import DEVNULL @@ -42,7 +46,7 @@ cron_script = '/etc/cron.d/vyos-wwan' def get_config(config=None): """ - Retrive CLI config as dictionary. Dictionary can never be empty, as at least the + Retrieve CLI config as dictionary. Dictionary can never be empty, as at least the interface name will be added or a deleted flag """ if config: @@ -85,6 +89,10 @@ def get_config(config=None): if len(wwan['other_interfaces']) == 0: del wwan['other_interfaces'] + # Protocols static arp dependency + if 'static_arp' in wwan: + set_dependents('static_arp', conf) + return wwan def verify(wwan): @@ -98,6 +106,7 @@ def verify(wwan): verify_interface_exists(wwan, ifname) verify_authentication(wwan) verify_vrf(wwan) + verify_mtu_ipv6(wwan) verify_mirror_redirect(wwan) return None @@ -135,14 +144,20 @@ def apply(wwan): break sleep(0.250) - if 'shutdown_required' in wwan: + if 'shutdown_required' in wwan or (not is_wwan_connected(wwan['ifname'])): # we only need the modem number. wwan0 -> 0, wwan1 -> 1 modem = wwan['ifname'].lstrip('wwan') base_cmd = f'mmcli --modem {modem}' # Number of bearers is limited - always disconnect first - cmd(f'{base_cmd} --simple-disconnect') + call(f'{base_cmd} --simple-disconnect') w = WWANIf(wwan['ifname']) + + # We cannot proceed with the configuration if the modem is not detected - so we bail out + # and wait for the next cronjob run to re-apply the configuration. + if not w.exists(wwan['ifname']): + return None + if 'deleted' in wwan or 'disable' in wwan: w.remove() @@ -157,7 +172,7 @@ def apply(wwan): return None - if 'shutdown_required' in wwan: + if 'shutdown_required' in wwan or (not is_wwan_connected(wwan['ifname'])): ip_type = 'ipv4' slaac = dict_search('ipv6.address.autoconf', wwan) != None if 'address' in wwan: @@ -176,6 +191,10 @@ def apply(wwan): call(command, stdout=DEVNULL) w.update(wwan) + + if 'static_arp' in wwan: + call_dependents() + return None if __name__ == '__main__': diff --git a/src/conf_mode/load-balancing_haproxy.py b/src/conf_mode/load-balancing_haproxy.py index 5fd1beec9..2a4f206f5 100644 --- a/src/conf_mode/load-balancing_haproxy.py +++ b/src/conf_mode/load-balancing_haproxy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -19,6 +19,7 @@ import os from sys import exit from shutil import rmtree +from vyos.defaults import systemd_services from vyos.config import Config from vyos.configverify import verify_pki_certificate from vyos.configverify import verify_pki_ca_certificate @@ -26,6 +27,7 @@ from vyos.utils.dict import dict_search from vyos.utils.process import call from vyos.utils.network import check_port_availability from vyos.utils.network import is_listen_port_bind_service +from vyos.utils.network import is_addr_assigned from vyos.pki import find_chain from vyos.pki import load_certificate from vyos.pki import load_private_key @@ -39,7 +41,6 @@ airbag.enable() load_balancing_dir = '/run/haproxy' load_balancing_conf_file = f'{load_balancing_dir}/haproxy.cfg' -systemd_service = 'haproxy.service' systemd_override = '/run/systemd/system/haproxy.service.d/10-override.conf' def get_config(config=None): @@ -65,18 +66,44 @@ def verify(lb): return None if 'backend' not in lb or 'service' not in lb: - raise ConfigError(f'"service" and "backend" must be configured!') + raise ConfigError('Both "service" and "backend" must be configured!') for front, front_config in lb['service'].items(): if 'port' not in front_config: raise ConfigError(f'"{front} service port" must be configured!') - # Check if bind address:port are used by another service - tmp_address = front_config.get('address', '0.0.0.0') - tmp_port = front_config['port'] - if check_port_availability(tmp_address, int(tmp_port), 'tcp') is not True and \ - not is_listen_port_bind_service(int(tmp_port), 'haproxy'): - raise ConfigError(f'"TCP" port "{tmp_port}" is used by another service') + # Check if bind 'listen-address:port' are used by another service + listen_addresses = front_config.get('listen_address') or {} + listen_port = int(front_config['port']) + if listen_addresses: + for listen_address in listen_addresses: + # Remove the interface name if present in the listen address + if '%' in listen_address: + listen_address, *_ = listen_address.split('%', maxsplit=1) + + if not is_addr_assigned(listen_address): + raise ConfigError( + f'listen-address "{listen_address}" not assigned on any interface!' + ) + + port_availability = check_port_availability( + listen_address, listen_port, 'tcp' + ) + port_bind_service = is_listen_port_bind_service( + listen_port, 'haproxy', address=listen_address + ) + if not port_availability and not port_bind_service: + raise ConfigError( + f'TCP port "{listen_port}" on address "{listen_address}" is used by another service' + ) + else: + # Verify listen port for all IP addresses + port_availability = check_port_availability(None, listen_port, 'tcp') + port_bind_service = is_listen_port_bind_service(listen_port, 'haproxy') + if not port_availability and not port_bind_service: + raise ConfigError( + f'TCP port "{listen_port}" is used by another service' + ) if 'http_compression' in front_config: if front_config['mode'] != 'http': @@ -85,16 +112,19 @@ def verify(lb): raise ConfigError(f'service {front} must have at least one mime-type configured to use' f'http_compression!') + for cert in dict_search('ssl.certificate', front_config) or []: + verify_pki_certificate(lb, cert) + for back, back_config in lb['backend'].items(): if 'http_check' in back_config: http_check = back_config['http_check'] if 'expect' in http_check and 'status' in http_check['expect'] and 'string' in http_check['expect']: - raise ConfigError(f'"expect status" and "expect string" can not be configured together!') + raise ConfigError('"expect status" and "expect string" can not be configured together!') if 'health_check' in back_config: if back_config['mode'] != 'tcp': raise ConfigError(f'backend "{back}" can only be configured with {back_config["health_check"]} ' + - f'health-check whilst in TCP mode!') + 'health-check whilst in TCP mode!') if 'http_check' in back_config: raise ConfigError(f'backend "{back}" cannot be configured with both http-check and health-check!') @@ -112,24 +142,19 @@ def verify(lb): if {'no_verify', 'ca_certificate'} <= set(back_config['ssl']): raise ConfigError(f'backend {back} cannot have both ssl options no-verify and ca-certificate set!') + tmp = dict_search('ssl.ca_certificate', back_config) + if tmp: verify_pki_ca_certificate(lb, tmp) + # Check if http-response-headers are configured in any frontend/backend where mode != http for group in ['service', 'backend']: for config_name, config in lb[group].items(): if 'http_response_headers' in config and config['mode'] != 'http': raise ConfigError(f'{group} {config_name} must be set to http mode to use http_response_headers!') - for front, front_config in lb['service'].items(): - for cert in dict_search('ssl.certificate', front_config) or []: - verify_pki_certificate(lb, cert) - - for back, back_config in lb['backend'].items(): - tmp = dict_search('ssl.ca_certificate', back_config) - if tmp: verify_pki_ca_certificate(lb, tmp) - def generate(lb): if not lb: - # Delete /run/haproxy/haproxy.cfg + # Delete generated config files config_files = [load_balancing_conf_file, systemd_override] for file in config_files: if os.path.isfile(file): @@ -144,8 +169,8 @@ def generate(lb): if not os.path.isdir(load_balancing_dir): os.mkdir(load_balancing_dir) - loaded_ca_certs = {load_certificate(c['certificate']) - for c in lb['pki']['ca'].values()} if 'ca' in lb['pki'] else {} + loaded_ca_certs = {load_certificate(cert_data['certificate']) + for _, cert_data in dict_search('pki.ca', lb, default={}).items()} # SSL Certificates for frontend for front, front_config in lb['service'].items(): @@ -193,12 +218,11 @@ def generate(lb): return None def apply(lb): + action = 'stop' + if lb: + action = 'reload-or-restart' call('systemctl daemon-reload') - if not lb: - call(f'systemctl stop {systemd_service}') - else: - call(f'systemctl reload-or-restart {systemd_service}') - + call(f'systemctl {action} {systemd_services["haproxy"]}') return None diff --git a/src/conf_mode/load-balancing_wan.py b/src/conf_mode/load-balancing_wan.py index 92d9acfba..3f2433fa1 100755 --- a/src/conf_mode/load-balancing_wan.py +++ b/src/conf_mode/load-balancing_wan.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -18,6 +18,7 @@ from sys import exit from vyos.config import Config from vyos.configdep import set_dependents, call_dependents +from vyos.utils.dict import dict_search_args from vyos.utils.process import cmd from vyos import ConfigError from vyos import airbag @@ -25,6 +26,13 @@ airbag.enable() service = 'vyos-wan-load-balance.service' +valid_groups = [ + 'address_group', + 'domain_group', + 'network_group', + 'port_group' +] + def get_config(config=None): if config: conf = config @@ -38,6 +46,10 @@ def get_config(config=None): get_first_key=True, with_recursive_defaults=True) + if lb: + lb['firewall_group'] = conf.get_config_dict(['firewall', 'group'], key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) + # prune limit key if not set by user for rule in lb.get('rule', []): if lb.from_defaults(['rule', rule, 'limit']): @@ -89,6 +101,43 @@ def verify(lb): for direction in ['source', 'destination']: if direction in rule_conf: + side_conf = rule_conf[direction] + + if 'group' in side_conf: + if len({'address_group', 'network_group', 'domain_group'} & set(side_conf['group'])) > 1: + raise ConfigError('Only one address-group, network-group or domain-group can be specified') + + for group in valid_groups: + if group in side_conf['group']: + group_name = side_conf['group'][group] + error_group = group.replace("_", "-") + + if group in ['address_group', 'network_group', 'domain_group']: + if 'address' in side_conf: + raise ConfigError(f'{error_group} and address cannot both be defined') + + if group in ['port_group']: + if 'port' in side_conf: + raise ConfigError(f'{error_group} and port cannot both be defined') + + if group_name and group_name[0] == '!': + group_name = group_name[1:] + + group_obj = dict_search_args(lb['firewall_group'], group, group_name) + + if group_obj is None: + raise ConfigError(f'Invalid {error_group} "{group_name}" on load-balancing wan rule') + + if not group_obj: + Warning(f'{error_group} "{group_name}" has no members!') + + if dict_search_args(side_conf, 'group', 'port_group'): + if 'protocol' not in rule_conf: + raise ConfigError('Protocol must be defined if specifying a port-group') + + if rule_conf['protocol'] not in ['tcp', 'udp', 'tcp_udp']: + raise ConfigError('Protocol must be tcp, udp, or tcp_udp when specifying a port-group') + if 'port' in rule_conf[direction]: if 'protocol' not in rule_conf: raise ConfigError(f'Protocol required to specify port on load-balancing wan rule {rule_id}') @@ -101,9 +150,9 @@ def generate(lb): def apply(lb): if not lb: - cmd(f'sudo systemctl stop {service}') + cmd(f'systemctl stop {service}') else: - cmd(f'sudo systemctl restart {service}') + cmd(f'systemctl restart {service}') call_dependents() diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index 504b3e82a..8763da886 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -16,14 +16,13 @@ import os +from glob import glob from sys import exit -from pathlib import Path from vyos.base import Warning from vyos.config import Config from vyos.configdep import set_dependents, call_dependents from vyos.template import render -from vyos.template import is_ip_network from vyos.utils.kernel import check_kmod from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args @@ -31,7 +30,6 @@ from vyos.utils.file import write_file from vyos.utils.process import cmd from vyos.utils.process import run from vyos.utils.process import call -from vyos.utils.network import is_addr_assigned from vyos.utils.network import interface_exists from vyos.firewall import fqdn_config_parse from vyos import ConfigError @@ -176,12 +174,6 @@ def verify(nat): if 'exclude' not in config and 'backend' not in config['load_balance']: raise ConfigError(f'{err_msg} translation requires address and/or port') - addr = dict_search('translation.address', config) - if addr != None and addr != 'masquerade' and not is_ip_network(addr): - for ip in addr.split('-'): - if not is_addr_assigned(ip): - Warning(f'IP address {ip} does not exist on the system!') - # common rule verification verify_rule(config, err_msg, nat['firewall_group']) @@ -265,9 +257,9 @@ def apply(nat): text = f'# Automatically generated by nat.py\nThis file indicates that vyos-domain-resolver service is used by nat.\n' write_file(domain_resolver_usage, text) elif os.path.exists(domain_resolver_usage): - Path(domain_resolver_usage).unlink(missing_ok=True) + os.unlink(domain_resolver_usage) - if not Path('/run').glob('use-vyos-domain-resolver*'): + if not glob('/run/use-vyos-domain-resolver*'): domain_action = 'stop' call(f'systemctl {domain_action} vyos-domain-resolver.service') diff --git a/src/conf_mode/nat64.py b/src/conf_mode/nat64.py index df501ce7f..b7d0b586d 100755 --- a/src/conf_mode/nat64.py +++ b/src/conf_mode/nat64.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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,174 +14,199 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -# pylint: disable=empty-docstring,missing-module-docstring - import csv import os import re +import sys -from ipaddress import IPv6Network, IPv6Address +from ipaddress import IPv6Network +from ipaddress import IPv6Address from json import dumps as json_write from vyos import ConfigError from vyos import airbag from vyos.config import Config -from vyos.configdict import is_node_changed +from vyos.config import ConfigDict +from vyos.configdiff import get_config_diff from vyos.utils.dict import dict_search from vyos.utils.file import write_file from vyos.utils.kernel import check_kmod +from vyos.utils.kernel import unload_kmod from vyos.utils.process import cmd from vyos.utils.process import run +from vyos.utils.system import sysctl_read airbag.enable() -INSTANCE_REGEX = re.compile(r"instance-(\d+)") -JOOL_CONFIG_DIR = "/run/jool" - +INSTANCE_REGEX = re.compile(r'instance-(\d+)') +JOOL_CONFIG_DIR = '/run/jool' +base = ['nat64'] -def get_config(config: Config | None = None) -> None: +def get_config(config: Config | None = None) -> ConfigDict: if config is None: config = Config() - base = ["nat64"] - nat64 = config.get_config_dict(base, key_mangling=("-", "_"), get_first_key=True) + nat64 = config.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True) + + config_diff = get_config_diff(config) + # get_config_dict returns an instance of ConfigDict + setattr(nat64, 'config_diff', config_diff) return nat64 def verify(nat64) -> None: - check_kmod(["jool"]) - base_src = ["nat64", "source", "rule"] + check_kmod(['jool']) + config_diff = getattr(nat64, 'config_diff') + + base_rule = base + ['source', 'rule'] # Load in existing instances so we can destroy any unknown - lines = cmd("jool instance display --csv").splitlines() + lines = cmd('jool instance display --csv').splitlines() for _, instance, _ in csv.reader(lines): match = INSTANCE_REGEX.fullmatch(instance) if not match: - # FIXME: Instances that don't match should be ignored but WARN'ed to the user + # to fix: Instances that don't match should be ignored but WARN'ed to the user continue num = match.group(1) - rules = nat64.setdefault("source", {}).setdefault("rule", {}) + rules = nat64.setdefault('source', {}).setdefault('rule', {}) # Mark it for deletion if num not in rules: - rules[num] = {"deleted": True} + rules[num] = {'deleted': True} continue # If the user changes the mode, recreate the instance else Jool fails with: # Jool error: Sorry; you can't change an instance's framework for now. - if is_node_changed(config, base_src + [f"instance-{num}", "mode"]): - rules[num]["recreate"] = True + if config_diff.is_node_changed(base_rule + [f'instance-{num}', 'mode']): + rules[num]['recreate'] = True # If the user changes the pool6, recreate the instance else Jool fails with: # Jool error: Sorry; you can't change a NAT64 instance's pool6 for now. - if dict_search("source.prefix", rules[num]) and is_node_changed( - config, - base_src + [num, "source", "prefix"], + if dict_search('source.prefix', rules[num]) and config_diff.is_node_changed( + base_rule + [num, 'source', 'prefix'], ): - rules[num]["recreate"] = True + rules[num]['recreate'] = True if not nat64: # nothing left to do return - if dict_search("source.rule", nat64): + # https://nicmx.github.io/Jool/en/usr-flags-pool4.html#port-range + # Jool is incapable of ensuring pool4 does not intersect with other defined + # port ranges; this validation is the operator’s responsibility. + tmp = sysctl_read(['net', 'ipv4', 'ip_local_port_range']) + ephemeral_port_min, ephemeral_port_max = map(int, tmp.split()) + + if dict_search('source.rule', nat64): # Ensure only 1 netfilter instance per namespace nf_rules = filter( - lambda i: "deleted" not in i and i.get('mode') == "netfilter", - nat64["source"]["rule"].values(), + lambda i: 'deleted' not in i and i.get('mode') == 'netfilter', + nat64['source']['rule'].values(), ) next(nf_rules, None) # Discard the first element if next(nf_rules, None) is not None: raise ConfigError( - "Jool permits only 1 NAT64 netfilter instance (per network namespace)" + 'Jool permits only 1 NAT64 netfilter instance (per network namespace)' ) - for rule, instance in nat64["source"]["rule"].items(): - if "deleted" in instance: + for rule, instance in nat64['source']['rule'].items(): + if 'deleted' in instance: continue # Verify that source.prefix is set and is a /96 - if not dict_search("source.prefix", instance): - raise ConfigError(f"Source NAT64 rule {rule} missing source prefix") - src_prefix = IPv6Network(instance["source"]["prefix"]) + if not dict_search('source.prefix', instance): + raise ConfigError(f'Source NAT64 rule {rule} missing source prefix') + src_prefix = IPv6Network(instance['source']['prefix']) if src_prefix.prefixlen != 96: - raise ConfigError(f"Source NAT64 rule {rule} source prefix must be /96") + raise ConfigError(f'Source NAT64 rule {rule} source prefix must be /96') if (int(src_prefix[0]) & int(IPv6Address('0:0:0:0:ff00::'))) != 0: raise ConfigError( f'Source NAT64 rule {rule} source prefix is not RFC6052-compliant: ' 'bits 64 to 71 (9th octet) must be zeroed' ) - pools = dict_search("translation.pool", instance) + pools = dict_search('translation.pool', instance) if pools: for num, pool in pools.items(): - if "address" not in pool: - raise ConfigError( - f"Source NAT64 rule {rule} translation pool " - f"{num} missing address/prefix" - ) - if "port" not in pool: + error_msg = f'Source NAT64 rule {rule} translation pool {num}' + if 'address' not in pool: + raise ConfigError(f'{error_msg} missing address/prefix') + if 'port' not in pool: + raise ConfigError(f'{error_msg} missing port(-range)') + # Split the provided ports, it's either a single port or start-end + tmp = pool['port'].split('-') + if len(tmp) == 1: # single port + pool_min = pool_max = int(tmp[0]) + elif len(tmp) == 2: # port range with start-stop + pool_min, pool_max = map(int, tmp) + else: + raise ConfigError('Invalid port range, this should not happen!') + + # Inclusive overlap check between nat64 translation ports and + # the Linux Kernel ephemeral port range + # overlap if pool_min <= ephemeral_port_max and ephemeral_port_min <= pool_max + if pool_min <= ephemeral_port_max and ephemeral_port_min <= pool_max: raise ConfigError( - f"Source NAT64 rule {rule} translation pool " - f"{num} missing port(-range)" + f'{error_msg} port range {pool_min}-{pool_max} overlaps with ' + f'local ephemeral range {ephemeral_port_min}-{ephemeral_port_max}' ) - def generate(nat64) -> None: if not nat64: return os.makedirs(JOOL_CONFIG_DIR, exist_ok=True) - if dict_search("source.rule", nat64): - for rule, instance in nat64["source"]["rule"].items(): - if "deleted" in instance: + if dict_search('source.rule', nat64): + for rule, instance in nat64['source']['rule'].items(): + if 'deleted' in instance: # Delete the unused instance file - os.unlink(os.path.join(JOOL_CONFIG_DIR, f"instance-{rule}.json")) + os.unlink(os.path.join(JOOL_CONFIG_DIR, f'instance-{rule}.json')) continue - name = f"instance-{rule}" + name = f'instance-{rule}' config = { - "instance": name, - "framework": "netfilter", - "global": { - "pool6": instance["source"]["prefix"], - "manually-enabled": "disable" not in instance, + 'instance': name, + 'framework': 'netfilter', + 'global': { + 'pool6': instance['source']['prefix'], + 'manually-enabled': 'disable' not in instance, }, # "bib": [], } - if "description" in instance: - config["comment"] = instance["description"] + if 'description' in instance: + config['comment'] = instance['description'] - if dict_search("translation.pool", instance): + if dict_search('translation.pool', instance): pool4 = [] # mark mark = '' - if dict_search("match.mark", instance): - mark = instance["match"]["mark"] + if dict_search('match.mark', instance): + mark = instance['match']['mark'] - for pool in instance["translation"]["pool"].values(): - if "disable" in pool: + for pool in instance['translation']['pool'].values(): + if 'disable' in pool: continue - protos = pool.get("protocol", {}).keys() or ("tcp", "udp", "icmp") + protos = pool.get('protocol', {}).keys() or ('tcp', 'udp', 'icmp') for proto in protos: obj = { - "protocol": proto.upper(), - "prefix": pool["address"], - "port range": pool["port"], + 'protocol': proto.upper(), + 'prefix': pool['address'], + 'port range': pool['port'], } if mark: - obj["mark"] = int(mark) - if "description" in pool: - obj["comment"] = pool["description"] + obj['mark'] = int(mark) + if 'description' in pool: + obj['comment'] = pool['description'] pool4.append(obj) if pool4: - config["pool4"] = pool4 + config['pool4'] = pool4 write_file(f'{JOOL_CONFIG_DIR}/{name}.json', json_write(config, indent=2)) @@ -191,30 +216,30 @@ def apply(nat64) -> None: unload_kmod(['jool']) return - if dict_search("source.rule", nat64): + if dict_search('source.rule', nat64): # Deletions first to avoid conflicts - for rule, instance in nat64["source"]["rule"].items(): - if not any(k in instance for k in ("deleted", "recreate")): + for rule, instance in nat64['source']['rule'].items(): + if not any(k in instance for k in ('deleted', 'recreate')): continue - ret = run(f"jool instance remove instance-{rule}") + ret = run(f'jool instance remove instance-{rule}') if ret != 0: raise ConfigError( - f"Failed to remove nat64 source rule {rule} (jool instance instance-{rule})" + f'Failed to remove nat64 source rule {rule} (jool instance instance-{rule})' ) # Now creations - for rule, instance in nat64["source"]["rule"].items(): - if "deleted" in instance: + for rule, instance in nat64['source']['rule'].items(): + if 'deleted' in instance: continue - name = f"instance-{rule}" - ret = run(f"jool -i {name} file handle {JOOL_CONFIG_DIR}/{name}.json") + name = f'instance-{rule}' + ret = run(f'jool -i {name} file handle {JOOL_CONFIG_DIR}/{name}.json') if ret != 0: - raise ConfigError(f"Failed to set jool instance {name}") + raise ConfigError(f'Failed to set jool instance {name}') -if __name__ == "__main__": +if __name__ == '__main__': try: c = get_config() verify(c) @@ -222,4 +247,4 @@ if __name__ == "__main__": apply(c) except ConfigError as e: print(e) - exit(1) + sys.exit(1) diff --git a/src/conf_mode/nat66.py b/src/conf_mode/nat66.py index 95dfae3a5..c3637c6b9 100755 --- a/src/conf_mode/nat66.py +++ b/src/conf_mode/nat66.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -93,6 +93,14 @@ def verify(nat): if not is_ipv6(prefix): raise ConfigError(f'{err_msg} source-prefix not specified') + if 'source' in config and 'group' in config['source']: + if len({'address_group', 'network_group', 'domain_group'} & set(config['source']['group'])) > 1: + raise ConfigError('Only one source address-group, network-group or domain-group can be specified') + + if 'destination' in config and 'group' in config['destination']: + if len({'address_group', 'network_group', 'domain_group'} & set(config['destination']['group'])) > 1: + raise ConfigError('Only one destination address-group, network-group or domain-group can be specified') + if dict_search('destination.rule', nat): for rule, config in dict_search('destination.rule', nat).items(): err_msg = f'Destination NAT66 configuration error in rule {rule}:' @@ -108,9 +116,13 @@ def verify(nat): if not interface_exists(interface_name): Warning(f'Interface "{interface_name}" for destination NAT66 rule "{rule}" does not exist!') + if 'source' in config and 'group' in config['source']: + if len({'address_group', 'network_group', 'domain_group'} & set(config['source']['group'])) > 1: + raise ConfigError('Only one source address-group, network-group or domain-group can be specified') + if 'destination' in config and 'group' in config['destination']: if len({'address_group', 'network_group', 'domain_group'} & set(config['destination']['group'])) > 1: - raise ConfigError('Only one address-group, network-group or domain-group can be specified') + raise ConfigError('Only one destination address-group, network-group or domain-group can be specified') return None diff --git a/src/conf_mode/nat_cgnat.py b/src/conf_mode/nat_cgnat.py index 3484e5873..312688b53 100755 --- a/src/conf_mode/nat_cgnat.py +++ b/src/conf_mode/nat_cgnat.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/netns.py b/src/conf_mode/netns.py index b57e46a0d..5a3c4e7fa 100755 --- a/src/conf_mode/netns.py +++ b/src/conf_mode/netns.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py index acea2c9be..356a8dd89 100755 --- a/src/conf_mode/pki.py +++ b/src/conf_mode/pki.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -19,6 +19,7 @@ import os from sys import argv from sys import exit +from vyos.base import Message from vyos.config import Config from vyos.config import config_dict_merge from vyos.configdep import set_dependents @@ -27,6 +28,8 @@ from vyos.configdict import node_changed from vyos.configdiff import Diff from vyos.configdiff import get_config_diff from vyos.defaults import directories +from vyos.defaults import internal_ports +from vyos.defaults import systemd_services from vyos.pki import encode_certificate from vyos.pki import is_ca_certificate from vyos.pki import load_certificate @@ -41,10 +44,13 @@ from vyos.utils.configfs import add_cli_node from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args from vyos.utils.dict import dict_search_recursive +from vyos.utils.dict import dict_set_nested from vyos.utils.file import read_file +from vyos.utils.network import check_port_availability from vyos.utils.process import call from vyos.utils.process import cmd from vyos.utils.process import is_systemd_service_active +from vyos.utils.process import is_systemd_service_running from vyos import ConfigError from vyos import airbag airbag.enable() @@ -59,6 +65,10 @@ sync_search = [ 'path': ['service', 'https'], }, { + 'keys': ['key'], + 'path': ['service', 'ssh'], + }, + { 'keys': ['certificate', 'ca_certificate'], 'path': ['interfaces', 'ethernet'], }, @@ -73,6 +83,7 @@ sync_search = [ { 'keys': ['certificate', 'ca_certificate'], 'path': ['load_balancing', 'haproxy'], + 'orig_path': ['load-balancing', 'haproxy'], }, { 'keys': ['key'], @@ -115,26 +126,65 @@ def certbot_delete(certificate): if os.path.exists(f'{vyos_certbot_dir}/renewal/{certificate}.conf'): cmd(f'certbot delete --non-interactive --config-dir {vyos_certbot_dir} --cert-name {certificate}') -def certbot_request(name: str, config: dict, dry_run: bool=True): +def certbot_request(name: str, config: dict, dry_run: bool=True) -> None: # We do not call certbot when booting the system - there is no need to do so and # request new certificates during boot/image upgrade as the certbot configuration # is stored persistent under /config - thus we do not open the door to transient # errors if not boot_configuration_complete(): - return + return None domains = '--domains ' + ' --domains '.join(config['domain_name']) tmp = f'certbot certonly --non-interactive --config-dir {vyos_certbot_dir} --cert-name {name} '\ f'--standalone --agree-tos --no-eff-email --expand --server {config["url"]} '\ f'--email {config["email"]} --key-type rsa --rsa-key-size {config["rsa_key_size"]} '\ f'{domains}' + + listen_address = None if 'listen_address' in config: - tmp += f' --http-01-address {config["listen_address"]}' - # verify() does not need to actually request a cert but only test for plausability + listen_address = config['listen_address'] + + # When ACME is used behind a reverse proxy, we always bind to localhost + # whatever the CLI listen-address is configured for. + if ('used_by' in config and 'haproxy' in config['used_by'] and + is_systemd_service_running(systemd_services['haproxy']) and + not check_port_availability(listen_address, 80)): + tmp += f' --http-01-address 127.0.0.1 --http-01-port {internal_ports["certbot_haproxy"]}' + elif listen_address: + tmp += f' --http-01-address {listen_address}' + + # verify() does not need to actually request a cert but only test for plausibility if dry_run: tmp += ' --dry-run' - cmd(tmp, raising=ConfigError, message=f'ACME certbot request failed for "{name}"!') + cmd(tmp, raising=ConfigError, message=f'Certbot request failed for "{name}"!') + return None + +def certbot_renew(config: dict, force: bool=False) -> None: + """ Renew all certificates managed via certbot """ + tmp = f'certbot renew --no-random-sleep-on-renew ' \ + f'--config-dir {vyos_certbot_dir}' + + # Determine services using ACME based certificates + pre_hook_services = [] + for used_by, _ in dict_search_recursive(config, 'used_by'): + pre_hook_services.extend(used_by) + # Remove duplicate items from list + pre_hook_services = list(set(pre_hook_services)) + # Automatically add services in use to pre_hook_services depending on service + # name in vyos.defaults.systemd_services + if pre_hook_services: + services = [] + for service in pre_hook_services: + if service in systemd_services: + services.append(systemd_services[service]) + tmp += ' --pre-hook "systemctl stop ' + ' '.join(services) + '"' + + if force: + tmp += ' --force-renewal' + + print(cmd(tmp, raising=ConfigError, message=f'Certbot renew failed!')) + return None def get_config(config=None): if config: @@ -149,21 +199,25 @@ def get_config(config=None): if len(argv) > 1 and argv[1] == 'certbot_renew': pki['certbot_renew'] = {} - - changed_keys = ['ca', 'certificate', 'dh', 'key-pair', 'openssh', 'openvpn'] - + elif len(argv) > 1 and argv[1] == 'certbot_renew_force': + pki['certbot_renew'] = {'force': {}} + + # Walk through the list of sync_translate mapping and build a list + # which is later used to check if the node was changed in the CLI config + changed_keys = [] + for value in sync_translate.values(): + if value not in changed_keys: + changed_keys.append(value) + # Check for changes to said given keys in the CLI config for key in changed_keys: tmp = node_changed(conf, base + [key], recursive=True, expand_nodes=Diff.DELETE | Diff.ADD) + if tmp: + dict_set_nested(f'changed.{key.replace("-", "_")}', tmp, pki) - if 'changed' not in pki: - pki.update({'changed':{}}) - - pki['changed'].update({key.replace('-', '_') : tmp}) - - # We only merge on the defaults of there is a configuration at all + # We only merge on the defaults if there is a configuration at all if conf.exists(base): # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. + # options which we need to update into the dictionary retrieved. default_values = conf.get_config_defaults(**pki.kwargs, recursive=True) # remove ACME default configuration if unused by CLI if 'certificate' in pki: @@ -183,9 +237,14 @@ def get_config(config=None): for name, cert_config in pki['certificate'].items(): if 'acme' in cert_config: renew.append(name) - # If triggered externally by certbot, certificate key is not present in changed - if 'changed' not in pki: pki.update({'changed':{}}) - pki['changed'].update({'certificate' : renew}) + if renew: + # Get the current list of changed certificates + tmp = pki.get('changed', {}).get('certificate', []) + # and extend it with the list of ACME based certificates + tmp += renew + # remove any duplicates if necessary + tmp = set(tmp) + dict_set_nested('changed.certificate', tmp, pki) # We need to get the entire system configuration to verify that we are not # deleting a certificate that is still referenced somewhere! @@ -218,9 +277,13 @@ def get_config(config=None): if isinstance(found_name, str) and found_name != item_name: continue - path = search['path'] - path_str = ' '.join(path + found_path) - #print(f'PKI: Updating config: {path_str} {item_name}') + # prefer orig_path over path when unmangling is needed + path = search.get('orig_path', search.get('path')) + # Only enable this for debug purposes - otherwise we will always + # print this message for ACME certificates during renew tests - + # even if they are not due for renew! + # path_str = ' '.join(path + found_path) + # print(f'Updating configuration: "{path_str} {item_name}"') if path[0] == 'interfaces': ifname = found_path[0] @@ -230,6 +293,34 @@ def get_config(config=None): if not D.node_changed_presence(path): set_dependents(path[1], conf) + # Check PKI certificates if they are auto-generated by ACME. If they are, + # traverse the current configuration and determine the service where the + # certificate is used by. + # Required to check if we might need to run certbot behind a reverse proxy. + if 'certificate' in pki: + for name, cert_config in pki['certificate'].items(): + if 'acme' not in cert_config: + continue + if not dict_search('system.load_balancing.haproxy', pki): + continue + # Determine which service depends on ACME issued certificates + # We only need to add services blocking the default certbot ports + # 80 and 443. For instance there won't be a conflict with strongSwan + # as it runs on different ports. + used_by = [] + # We start with HAProxy + for cert_list, _ in dict_search_recursive( + pki['system']['load_balancing']['haproxy'], 'certificate'): + if name in cert_list: + used_by.append('haproxy') + # Check if OpenConnect consumes an ACME certificate + tmp = dict_search('system.vpn.openconnect.ssl.certificate', pki) + if tmp and tmp in cert_list: + used_by.append('openconnect') + + if used_by: + pki['certificate'][name]['acme'].update({'used_by': used_by}) + return pki def is_valid_certificate(raw_data): @@ -321,9 +412,23 @@ def verify(pki): raise ConfigError(f'An email address is required to request '\ f'certificate for "{name}" via ACME!') + listen_address = None + if 'listen_address' in cert_conf['acme']: + listen_address = cert_conf['acme']['listen_address'] + + if 'used_by' not in cert_conf['acme']: + # A call to check_port_availability() will always fail during system + # boot when listen_address is set and the address is not yet assigned + # to an interface. This happens b/c PKI subsystem is called prior + # to any interface - e.g. ethernet - and thus the OS will always + # be unable to bind() a socket() to a non existing IP address. + if boot_configuration_complete() and not check_port_availability(listen_address, 80): + raise ConfigError('Port 80 is already in use and not available '\ + f'to provide ACME challenge for "{name}"!') + + # Only run the ACME command if something on this entity changed, + # as this is time intensive if 'certbot_renew' not in pki: - # Only run the ACME command if something on this entity changed, - # as this is time intensive tmp = dict_search('changed.certificate', pki) if tmp != None and name in tmp: certbot_request(name, cert_conf['acme']) @@ -366,7 +471,8 @@ def verify(pki): if 'country' in default_values: country = default_values['country'] if len(country) != 2 or not country.isalpha(): - raise ConfigError(f'Invalid default country value. Value must be 2 alpha characters.') + raise ConfigError('Invalid default country value. '\ + 'Value must be 2 alpha characters.') if 'changed' in pki: # if the list is getting longer, we can move to a dict() and also embed the @@ -374,27 +480,35 @@ def verify(pki): for search in sync_search: for key in search['keys']: changed_key = sync_translate[key] - if changed_key not in pki['changed']: continue - for item_name in pki['changed'][changed_key]: node_present = False if changed_key == 'openvpn': node_present = dict_search_args(pki, 'openvpn', 'shared_secret', item_name) else: node_present = dict_search_args(pki, changed_key, item_name) + # If the node is still present, we can skip the check + # as we are not deleting it + if node_present: + continue - if not node_present: - search_dict = dict_search_args(pki['system'], *search['path']) - - if not search_dict: - continue + search_dict = dict_search_args(pki['system'], *search['path']) + if not search_dict: + continue - for found_name, found_path in dict_search_recursive(search_dict, key): - if found_name == item_name: - path_str = " ".join(search['path'] + found_path) - raise ConfigError(f'PKI object "{item_name}" still in use by "{path_str}"') + for found_name, found_path in dict_search_recursive(search_dict, key): + # Check if the name matches either by string compare, or being + # part of a list + if ((isinstance(found_name, str) and found_name == item_name) or + (isinstance(found_name, list) and item_name in found_name)): + # We do not support _ in CLI paths - this is only a convenience + # as we mangle all - to _, now it's time to reverse this! + path_str = ' '.join(search['path'] + found_path).replace('_','-') + object = changed_key.replace('_','-') + tmp = f'Embedded PKI {object} with name "{item_name}" is still '\ + f'in use by CLI path "{path_str}"' + raise ConfigError(tmp) return None @@ -428,7 +542,8 @@ def generate(pki): # Certbot renewal only needs to re-trigger the services to load up the # new PEM file if 'certbot_renew' in pki: - return None + force = 'force' in pki['certbot_renew'] + return certbot_renew(config=pki, force=force) certbot_list = [] certbot_list_on_disk = [] @@ -440,13 +555,21 @@ def generate(pki): for name, cert_conf in pki['certificate'].items(): if 'acme' in cert_conf: certbot_list.append(name) - # generate certificate if not found on disk + # There is no ACME/certbot managed certificate presend on the + # system, generate it if name not in certbot_list_on_disk: certbot_request(name, cert_conf['acme'], dry_run=False) + # Now that the certificate was properly generated we have + # the PEM files on disk. We need to add the certificate to + # certbot_list_on_disk to automatically import the CA chain + certbot_list_on_disk.append(name) + # We already had an ACME managed certificate on the system, but + # something changed in the configuration elif changed_certificates != None and name in changed_certificates: - # when something for the certificate changed, we should delete it + # Delete old ACME certificate first if name in certbot_list_on_disk: certbot_delete(name) + # Request new certificate via certbot certbot_request(name, cert_conf['acme'], dry_run=False) # Cleanup certbot configuration and certificates if no longer in use by CLI @@ -482,7 +605,7 @@ def generate(pki): if not ca_cert_present: tmp = dict_search_args(pki, 'ca', f'{autochain_prefix}{cert}', 'certificate') if not bool(tmp) or tmp != cert_chain_base64: - print(f'Adding/replacing automatically imported CA certificate for "{cert}" ...') + Message(f'Add/replace automatically imported CA certificate for "{cert}" ...') add_cli_node(['pki', 'ca', f'{autochain_prefix}{cert}', 'certificate'], value=cert_chain_base64) return None diff --git a/src/conf_mode/policy.py b/src/conf_mode/policy.py index a90e33e81..171ef23fd 100755 --- a/src/conf_mode/policy.py +++ b/src/conf_mode/policy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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,6 +14,7 @@ # 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 re from sys import exit from vyos.config import Config @@ -24,9 +25,20 @@ from vyos.frrender import get_frrender_dict from vyos.utils.dict import dict_search from vyos.utils.process import is_systemd_service_running from vyos import ConfigError +from vyos.base import Warning from vyos import airbag airbag.enable() +# Sanity checks for large-community-list regex: +# * Require complete 3-tuples, no blank members. Catch missed & doubled colons. +# * Permit appropriate community separators (whitespace, underscore) +# * Permit common regex between tuples while requiring at least one separator +# (eg, "1:1:1_.*_4:4:4", matching "1:1:1 4:4:4" and "1:1:1 2:2:2 4:4:4", +# but not "1:1:13 24:4:4") +# Best practice: stick with basic patterns, mind your wildcards and whitespace. +# Regex that doesn't match this pattern will be allowed with a warning. +large_community_regex_pattern = r'([^: _]+):([^: _]+):([^: _]+)([ _]([^:]+):([^: _]+):([^: _]+))*' + def community_action_compatibility(actions: dict) -> bool: """ Check compatibility of values in community and large community sections @@ -119,7 +131,7 @@ def verify(config_dict): if 'rule' not in instance_config: continue - # human readable instance name (hypen instead of underscore) + # human readable instance name (hyphen instead of underscore) policy_hr = policy_type.replace('_', '-') entries = [] for rule, rule_config in instance_config['rule'].items(): @@ -147,10 +159,34 @@ def verify(config_dict): if 'regex' not in rule_config: raise ConfigError(f'A regex {mandatory_error}') + if policy_type == 'large_community_list': + if not re.fullmatch(large_community_regex_pattern, rule_config['regex']): + Warning(f'"policy large-community-list {instance} rule {rule} regex" does not follow expected form and may not match as expected.') + if policy_type in ['prefix_list', 'prefix_list6']: if 'prefix' not in rule_config: raise ConfigError(f'A prefix {mandatory_error}') + mask_len = int(rule_config['prefix'].split('/')[1]) + ge = dict_search('ge', rule_config) + le = dict_search('le', rule_config) + + if ge and int(ge) < mask_len: + raise ConfigError( + f'{policy_hr} {instance} rule {rule}: "ge" ({ge}) must be >= ' + f'prefix length ({mask_len})' + ) + if le and int(le) < mask_len: + raise ConfigError( + f'{policy_hr} {instance} rule {rule}: "le" ({le}) must be >= ' + f'prefix length ({mask_len})' + ) + if ge and le and int(ge) > int(le): + raise ConfigError( + f'{policy_hr} {instance} rule {rule}: "ge" ({ge}) must be <= ' + f'"le" ({le})' + ) + if rule_config in entries: raise ConfigError( f'Rule "{rule}" contains a duplicate prefix definition!') diff --git a/src/conf_mode/policy_local-route.py b/src/conf_mode/policy_local-route.py index 9be2bc227..23aadfade 100755 --- a/src/conf_mode/policy_local-route.py +++ b/src/conf_mode/policy_local-route.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -299,8 +299,8 @@ def apply(pbr): if 'rule' in pbr_route: for rule, rule_config in pbr_route['rule'].items(): - # VRFs get configred as route table alias names for iproute2 and only - # one 'set' can get past validation. Either can be fed to lookup. + # VRFs get configured as route table alias names for iproute2 and only + # one 'set' can get past validation. Either can be fed to lookup. vrf = rule_config['set'].get('vrf', '') if vrf == 'default': table_or_vrf = 'main' diff --git a/src/conf_mode/policy_route.py b/src/conf_mode/policy_route.py index 223175b8a..3cfdad913 100755 --- a/src/conf_mode/policy_route.py +++ b/src/conf_mode/policy_route.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -21,13 +21,17 @@ from sys import exit from vyos.base import Warning from vyos.config import Config +from vyos.configdiff import Diff, get_config_diff from vyos.template import render 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.utils.network import interface_exists from vyos.defaults import rt_global_table from vyos.defaults import rt_global_vrf +from vyos.geoip import geoip_refresh, geoip_update from vyos import ConfigError from vyos import airbag airbag.enable() @@ -43,6 +47,28 @@ valid_groups = [ 'interface_group' ] +def geoip_updated(conf): + D = get_config_diff(conf, key_mangling=('-', '_')) + for path in (['policy', 'route'], ['policy', 'route6']): + diff = D.get_child_nodes_diff(path, + expand_nodes=Diff.ADD | Diff.DELETE, + recursive=True) + if any(any(dict_search_recursive(diff.get(section, {}), 'geoip')) + for section in ('add', 'delete')): + return True + return False + +def geoip_sets(policy): + out = {'name': [], 'ipv6_name': []} + + for _, path in dict_search_recursive(policy, 'geoip'): + if (path[0] == 'route'): + out['name'].append(f'GEOIP_CC_{path[0]}_{path[1]}_{path[3]}') + elif (path[0] == 'route6'): + out['ipv6_name'].append(f'GEOIP_CC6_{path[0]}_{path[1]}_{path[3]}') + + return out + def get_config(config=None): if config: conf = config @@ -60,6 +86,12 @@ def get_config(config=None): if 'dynamic_group' in policy['firewall_group']: del policy['firewall_group']['dynamic_group'] + policy['geoip_sets'] = geoip_sets(policy) + policy['geoip_updated'] = geoip_updated(conf) + policy['firewall'] = conf.get_config_dict( + ['firewall'], key_mangling=('-', '_'), + no_tag_node_value_mangle=True, get_first_key=True) + return policy def verify_rule(policy, name, rule_conf, ipv6, rule_id): @@ -89,6 +121,11 @@ def verify_rule(policy, name, rule_conf, ipv6, rule_id): 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') + if 'vrf' in rule_conf['set']: + vrf = rule_conf['set']['vrf'] + if vrf != 'default' and not interface_exists(vrf): + raise ConfigError(f'{name} rule {rule_id}: VRF "{vrf}" does not exist') + tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags') if tcp_flags: if dict_search_args(rule_conf, 'protocol') != 'tcp': @@ -203,6 +240,11 @@ def apply(policy): apply_table_marks(policy) + if policy['geoip_sets']['name'] or policy['geoip_sets']['ipv6_name']: + if policy['geoip_updated'] or not geoip_refresh(): + print('Updating GeoIP. Please wait...') + geoip_update(firewall=policy['firewall'], policy=policy) + return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_babel.py b/src/conf_mode/protocols_babel.py index 80a847af8..a683031bd 100755 --- a/src/conf_mode/protocols_babel.py +++ b/src/conf_mode/protocols_babel.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/protocols_bfd.py b/src/conf_mode/protocols_bfd.py index d3bc3e961..953611f24 100755 --- a/src/conf_mode/protocols_bfd.py +++ b/src/conf_mode/protocols_bfd.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/protocols_bgp.py b/src/conf_mode/protocols_bgp.py index 53e83c3b4..53561a9a3 100755 --- a/src/conf_mode/protocols_bgp.py +++ b/src/conf_mode/protocols_bgp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -52,7 +52,7 @@ def verify_vrf_as_import(search_vrf_name: str, afi_name: str, vrfs_config: dict) :type afi_name: str :param vrfs_config: configuration dependents vrfs :type vrfs_config: dict - :return: if vrf in import list retrun true else false + :return: if vrf in import list return true else false :rtype: bool """ for vrf_name, vrf_config in vrfs_config.items(): @@ -155,7 +155,7 @@ def verify_remote_as(peer_config, bgp_config): return None def verify_afi(peer_config, bgp_config): - # If address_family configured under neighboor + # If address_family configured under neighbor if 'address_family' in peer_config: return True @@ -183,8 +183,9 @@ def verify(config_dict): if 'vrf_context' in config_dict: vrf = config_dict['vrf_context'] - # eqivalent of the C foo ? 'a' : 'b' statement - bgp = vrf and config_dict['vrf']['name'][vrf]['protocols']['bgp'] or config_dict['bgp'] + # equivalent of the C foo ? 'a' : 'b' statement + bgp = vrf and dict_search(f'vrf.name.{vrf}.protocols.bgp', + config_dict) or config_dict['bgp'] bgp['policy'] = config_dict['policy'] if 'deleted' in bgp: @@ -276,7 +277,7 @@ def verify(config_dict): raise ConfigError(f'Only one local-as number can be specified for peer "{peer}"!') # Neighbor local-as override can not be the same as the local-as - # we use for this BGP instane! + # we use for this BGP instance! asn = list(peer_config['local_as'].keys())[0] if asn == bgp['system_as']: raise ConfigError('Cannot have local-as same as system-as number') @@ -286,11 +287,11 @@ def verify(config_dict): raise ConfigError(f'Neighbor "{peer}" has local-as specified which is '\ 'the same as remote-as, this is not allowed!') - # ttl-security and ebgp-multihop can't be used in the same configration + # ttl-security and ebgp-multihop can't be used in the same configuration if 'ebgp_multihop' in peer_config and 'ttl_security' in peer_config: raise ConfigError('You can not set both ebgp-multihop and ttl-security hops') - # interface and ebgp-multihop can't be used in the same configration + # interface and ebgp-multihop can't be used in the same configuration if 'ebgp_multihop' in peer_config and 'interface' in peer_config: raise ConfigError(f'Ebgp-multihop can not be used with directly connected '\ f'neighbor "{peer}"') @@ -316,6 +317,7 @@ def verify(config_dict): Warning(f'BGP neighbor "{peer}" requires address-family!') # Peer-group member cannot override remote-as of peer-group + peer_group = None if 'peer_group' in peer_config: peer_group = peer_config['peer_group'] if 'remote_as' in peer_config and 'remote_as' in bgp['peer_group'][peer_group]: @@ -331,6 +333,27 @@ def verify(config_dict): if 'remote_as' in peer_config['interface']['v6only'] and 'remote_as' in bgp['peer_group'][peer_group]: raise ConfigError(f'Peer-group member "{peer}" cannot override remote-as of peer-group "{peer_group}"!') + for afi in ['ipv4_unicast', 'ipv4_multicast', 'ipv4_labeled_unicast', 'ipv4_flowspec', + 'ipv6_unicast', 'ipv6_multicast', 'ipv6_labeled_unicast', 'ipv6_flowspec', + 'l2vpn_evpn']: + if dict_search( + f'address_family.{afi}.route_reflector_client', + peer_config, + ) == {} or ( + peer_group + and dict_search( + f'peer_group.{peer_group}.address_family.{afi}.route_reflector_client', + bgp, + ) + == {} + ): + peer_as = verify_remote_as(peer_config, bgp) + if peer_as != 'internal' and peer_as != bgp['system_as']: + raise ConfigError('route-reflector-client only supported for iBGP peers') + else: + # It doesn’t make sense to check the remote-as of a peer group. + pass + # Only checks for ipv4 and ipv6 neighbors # Check if neighbor address is assigned as system interface address vrf_error_msg = f' in default VRF!' @@ -372,13 +395,13 @@ def verify(config_dict): if 'conditionally_advertise' in afi_config: if 'advertise_map' not in afi_config['conditionally_advertise']: - raise ConfigError('Must speficy advertise-map when conditionally-advertise is in use!') + raise ConfigError('Must specify advertise-map when conditionally-advertise is in use!') # Verify advertise-map (which is a route-map) exists verify_route_map(afi_config['conditionally_advertise']['advertise_map'], bgp) if ('exist_map' not in afi_config['conditionally_advertise'] and 'non_exist_map' not in afi_config['conditionally_advertise']): - raise ConfigError('Must either speficy exist-map or non-exist-map when ' \ + raise ConfigError('Must either specify exist-map or non-exist-map when ' \ 'conditionally-advertise is in use!') if {'exist_map', 'non_exist_map'} <= set(afi_config['conditionally_advertise']): @@ -394,7 +417,7 @@ def verify(config_dict): # T4332: bgp deterministic-med cannot be disabled while addpath-tx-bestpath-per-AS is in use if 'addpath_tx_per_as' in afi_config: if dict_search('parameters.deterministic_med', bgp) == None: - raise ConfigError('addpath-tx-per-as requires BGP deterministic-med paramtere to be set!') + raise ConfigError('addpath-tx-per-as requires BGP deterministic-med parameter to be set!') # Validate if configured Prefix list exists if 'prefix_list' in afi_config: @@ -412,16 +435,7 @@ def verify(config_dict): if tmp in afi_config['route_map']: verify_route_map(afi_config['route_map'][tmp], bgp) - if 'route_reflector_client' in afi_config: - peer_group_as = peer_config.get('remote_as') - - if peer_group_as is None or (peer_group_as != 'internal' and peer_group_as != bgp['system_as']): - raise ConfigError('route-reflector-client only supported for iBGP peers') - else: - if 'peer_group' in peer_config: - peer_group_as = dict_search(f'peer_group.{peer_group}.remote_as', bgp) - if peer_group_as is None or (peer_group_as != 'internal' and peer_group_as != bgp['system_as']): - raise ConfigError('route-reflector-client only supported for iBGP peers') + # route-reflector-client verification has been moved to neighbor-only part # T5833 not all AFIs are supported for VRF if 'vrf' in bgp and 'address_family' in peer_config: @@ -464,6 +478,20 @@ def verify(config_dict): if not {'idle', 'interval', 'probes'} <= set(bgp['parameters']['tcp_keepalive']): raise ConfigError('TCP keepalive incomplete - idle, keepalive and probes must be set') + # Validate BGP update-delay: 'establish-wait' requires 'max-delay' and must not exceed it + if dict_search('parameters.update_delay', bgp) != None: + update_delay = dict_search('parameters.update_delay.max_delay', bgp) + establish_wait = dict_search('parameters.update_delay.establish_wait', bgp) + if establish_wait is not None: + if update_delay is None: + raise ConfigError( + 'BGP update-delay establish-wait requires max-delay to be set!' + ) + if int(establish_wait) > int(update_delay): + raise ConfigError( + 'BGP update-delay establish-wait cannot be greater than max-delay!' + ) + # Address Family specific validation if 'address_family' in bgp: for afi, afi_config in bgp['address_family'].items(): @@ -523,11 +551,15 @@ def verify(config_dict): raise ConfigError( 'Please unconfigure import vrf commands before using vpn commands in dependent VRFs!') + # Verify if the route-map exists + if dict_search('route_map.vrf.import', afi_config) is not None: + verify_route_map(afi_config['route_map']['vrf']['import'], bgp) + if (dict_search('route_map.vrf.import', afi_config) is not None or dict_search('import.vrf', afi_config) is not None): # FRR error: please unconfigure vpn to vrf commands before # using import vrf commands - if ('vpn' in afi_config['import'] + if (dict_search('import.vpn', afi_config) is not None or dict_search('export.vpn', afi_config) is not None): raise ConfigError('Please unconfigure VPN to VRF commands before '\ 'using "import vrf" commands!') @@ -537,7 +569,6 @@ def verify(config_dict): raise ConfigError('Please unconfigure route-map VPN to VRF commands before '\ 'using "import vrf" commands!') - # Verify that the export/import route-maps do exist for export_import in ['export', 'import']: tmp = dict_search(f'route_map.vpn.{export_import}', afi_config) diff --git a/src/conf_mode/protocols_eigrp.py b/src/conf_mode/protocols_eigrp.py index 324ff883f..92e34237c 100755 --- a/src/conf_mode/protocols_eigrp.py +++ b/src/conf_mode/protocols_eigrp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -20,6 +20,7 @@ from sys import argv from vyos.config import Config from vyos.configverify import has_frr_protocol_in_dict from vyos.configverify import verify_vrf +from vyos.utils.dict import dict_search from vyos.utils.process import is_systemd_service_running from vyos.frrender import FRRender from vyos.frrender import get_frrender_dict @@ -43,8 +44,9 @@ def verify(config_dict): if 'vrf_context' in config_dict: vrf = config_dict['vrf_context'] - # eqivalent of the C foo ? 'a' : 'b' statement - eigrp = vrf and config_dict['vrf']['name'][vrf]['protocols']['eigrp'] or config_dict['eigrp'] + # equivalent of the C foo ? 'a' : 'b' statement + eigrp = vrf and dict_search(f'vrf.name.{vrf}.protocols.eigrp', + config_dict) or config_dict['eigrp'] eigrp['policy'] = config_dict['policy'] if 'system_as' not in eigrp: diff --git a/src/conf_mode/protocols_failover.py b/src/conf_mode/protocols_failover.py index e7e44db84..752bd6011 100755 --- a/src/conf_mode/protocols_failover.py +++ b/src/conf_mode/protocols_failover.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -15,12 +15,15 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import json +import os from pathlib import Path +from sys import argv from vyos.config import Config from vyos.template import render from vyos.utils.process import call +from vyos.utils.process import is_systemd_service_running from vyos import ConfigError from vyos import airbag @@ -28,9 +31,24 @@ airbag.enable() service_name = 'vyos-failover' -service_conf = Path(f'/run/{service_name}.conf') +service_conf_dir = Path(f'/run/{service_name}.conf.d/') systemd_service = '/run/systemd/system/vyos-failover.service' -rt_proto_failover = '/etc/iproute2/rt_protos.d/failover.conf' +rt_proto_failover = Path('/etc/iproute2/rt_protos.d/failover.conf') + + +def get_vrf_name(): + if argv and len(argv) > 1: + return argv[1] + return None + + +def get_service_conf_path(): + vrf_name = get_vrf_name() + if vrf_name: + filename = f'vrf-{vrf_name}.conf' + else: + filename = 'default.conf' + return service_conf_dir / filename def get_config(config=None): @@ -39,7 +57,14 @@ def get_config(config=None): else: conf = Config() - base = ['protocols', 'failover'] + vrf_name = get_vrf_name() + if vrf_name: + base = ['vrf', 'name', vrf_name] + else: + base = [] + + base += ['protocols', 'failover'] + failover = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) @@ -47,6 +72,9 @@ def get_config(config=None): if failover.get('route') is not None: failover = conf.merge_defaults(failover, recursive=True) + if failover: + failover['vrf_context'] = vrf_name + return failover def verify(failover): @@ -57,51 +85,106 @@ def verify(failover): if 'route' not in failover: raise ConfigError(f'Failover "route" is mandatory!') - for route, route_config in failover['route'].items(): - if not route_config.get('next_hop'): - raise ConfigError(f'Next-hop for "{route}" is mandatory!') - - for next_hop, next_hop_config in route_config.get('next_hop').items(): - if 'interface' not in next_hop_config: - raise ConfigError(f'Interface for route "{route}" next-hop "{next_hop}" is mandatory!') - - if not next_hop_config.get('check'): - raise ConfigError(f'Check target for next-hop "{next_hop}" is mandatory!') + def _verify_route_item(item_config, item_description, interface_mandatory): + if interface_mandatory and 'interface' not in item_config: + raise ConfigError( + f'Interface for route "{route}" {item_description} is mandatory!' + ) + + if not item_config.get('check'): + raise ConfigError(f'Check target for {item_description} is mandatory!') + + if 'target' not in item_config['check']: + raise ConfigError(f'Check target for {item_description} is mandatory!') + + check_type = item_config['check']['type'] + if check_type == 'tcp' and 'port' not in item_config['check']: + raise ConfigError( + f'Check port for {item_description} and type TCP is mandatory!' + ) + + errors = { + 'icmp': {}, + 'tcp': { + 'interface': 'Check target "interface" option does nothing for type TCP. Use "vrf" if needed', + }, + 'arp': { + 'vrf': 'Check target "vrf" option is incompatible with type ARP, use "interface" option if needed', + }, + } + + for target, target_config in item_config['check']['target'].items(): + for key, msg in errors[check_type].items(): + if key in target_config: + raise ConfigError(msg) - if 'target' not in next_hop_config['check']: - raise ConfigError(f'Check target for next-hop "{next_hop}" is mandatory!') - - check_type = next_hop_config['check']['type'] - if check_type == 'tcp' and 'port' not in next_hop_config['check']: - raise ConfigError(f'Check port for next-hop "{next_hop}" and type TCP is mandatory!') + for route, route_config in failover['route'].items(): + if not route_config.get('next_hop') and not route_config.get('dhcp_interface'): + raise ConfigError( + f'Either next-hop or dhcp-interface for "{route}" is mandatory!' + ) + + if route_config.get('next_hop'): + for next_hop, next_hop_config in route_config.get('next_hop').items(): + _verify_route_item( + next_hop_config, f'next-hop "{next_hop}"', interface_mandatory=True + ) + + if route_config.get('dhcp_interface'): + for dhcp_interface, dhcp_interface_config in route_config.get( + 'dhcp_interface' + ).items(): + _verify_route_item( + dhcp_interface_config, + f'dhcp-interface "{dhcp_interface}"', + interface_mandatory=False, + ) return None + def generate(failover): + service_conf = get_service_conf_path() if not failover: service_conf.unlink(missing_ok=True) + try: + os.rmdir(service_conf_dir) + # Ignore if directory doesn't exist + # or not empty (probably configs for other VRFs are there) + except (FileNotFoundError, OSError): + pass return None # Add own rt_proto 'failover' # Helps to detect all own routes 'proto failover' - with open(rt_proto_failover, 'w') as f: - f.write('111 failover\n') + rt_proto_failover.write_text('111 failover\n') + + service_conf_dir.mkdir(exist_ok=True) # Write configuration file conf_json = json.dumps(failover, indent=4) service_conf.write_text(conf_json) - render(systemd_service, 'protocols/systemd_vyos_failover_service.j2', failover) + render( + systemd_service, + 'protocols/systemd_vyos_failover_service.j2', + {'config_dir': str(service_conf_dir)}, + ) return None def apply(failover): - if not failover: + # If directory is removed - we can stop the service + if not service_conf_dir.is_dir(): call(f'systemctl stop {service_name}.service') - call('ip route flush protocol failover') - else: call('systemctl daemon-reload') - call(f'systemctl restart {service_name}.service') - call(f'ip route flush protocol failover') + # Otherwise even if `failover` is False, service is + # still needed for other VRFs. + else: + # Daemon watches for configuration updates, so we need only + # to start it if it is not started yet + if not is_systemd_service_running(service_name): + call('systemctl daemon-reload') + call(f'systemctl start {service_name}.service') return None diff --git a/src/conf_mode/protocols_igmp-proxy.py b/src/conf_mode/protocols_igmp-proxy.py index 9a07adf05..7f53882b0 100755 --- a/src/conf_mode/protocols_igmp-proxy.py +++ b/src/conf_mode/protocols_igmp-proxy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -21,6 +21,7 @@ from sys import exit from vyos.base import Warning from vyos.config import Config from vyos.configverify import verify_interface_exists +from vyos.defaults import config_files from vyos.template import render from vyos.utils.process import call from vyos.utils.dict import dict_search @@ -28,7 +29,7 @@ from vyos import ConfigError from vyos import airbag airbag.enable() -config_file = r'/etc/igmpproxy.conf' +config_file = config_files['igmp_proxy'] def get_config(config=None): if config: @@ -87,18 +88,18 @@ def generate(igmp_proxy): return None render(config_file, 'igmp-proxy/igmpproxy.conf.j2', igmp_proxy) - return None def apply(igmp_proxy): + service_name = 'igmpproxy.service' if not igmp_proxy or 'disable' in igmp_proxy: - # IGMP Proxy support is removed in the commit - call('systemctl stop igmpproxy.service') - if os.path.exists(config_file): - os.unlink(config_file) - else: - call('systemctl restart igmpproxy.service') + # IGMP Proxy support is removed in the commit + call(f'systemctl stop {service_name}') + if os.path.exists(config_file): + os.unlink(config_file) + return None + call(f'systemctl restart {service_name}') return None if __name__ == '__main__': diff --git a/src/conf_mode/protocols_isis.py b/src/conf_mode/protocols_isis.py index 1c994492e..3812515a1 100755 --- a/src/conf_mode/protocols_isis.py +++ b/src/conf_mode/protocols_isis.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -47,8 +47,9 @@ def verify(config_dict): if 'vrf_context' in config_dict: vrf = config_dict['vrf_context'] - # eqivalent of the C foo ? 'a' : 'b' statement - isis = vrf and config_dict['vrf']['name'][vrf]['protocols']['isis'] or config_dict['isis'] + # equivalent of the C foo ? 'a' : 'b' statement + isis = vrf and dict_search(f'vrf.name.{vrf}.protocols.isis', + config_dict) or config_dict['isis'] isis['policy'] = config_dict['policy'] if 'deleted' in isis: @@ -68,7 +69,7 @@ def verify(config_dict): if 'interface' not in isis: raise ConfigError('Interface used for routing updates is mandatory!') - for interface in isis['interface']: + for interface, interface_config in isis['interface'].items(): verify_interface_exists(isis, interface) # Interface MTU must be >= configured lsp-mtu mtu = Interface(interface).get_mtu() @@ -90,6 +91,27 @@ def verify(config_dict): if 'master' not in tmp or tmp['master'] != vrf: raise ConfigError(f'Interface "{interface}" is not a member of VRF "{vrf}"!') + # Fast reroute validation + # LFA and TI-LFA of the same level can not be configured on the same interface + # To configure Remote LFA, LFA of the same level should be configured on this interface. + if 'fast_reroute' in interface_config: + isis_frr_config = interface_config['fast_reroute'] + levels = ['level_1', 'level_2'] + if 'lfa' and 'ti_lfa' in isis_frr_config: + for isis_level in levels: + if ((dict_search(f'lfa.{isis_level}.enable', isis_frr_config) is not None) + and (dict_search(f'ti_lfa.{isis_level}', isis_frr_config) is not None)): + raise ConfigError( + f'LFA and TI-LFA at the "{str(isis_level).replace("_","-")}" ' + f'can not be configured on the same interface "{interface}"!') + if 'remote_lfa' in isis_frr_config: + for isis_level in levels: + if ((dict_search(f'remote_lfa.{isis_level}', isis_frr_config) is not None) + and (dict_search(f'lfa.{isis_level}.enable', isis_frr_config) is None)): + raise ConfigError( + f'To configure Remote LFA, LFA at the same level ' + f'should be configured on interface "{interface}"!') + # If md5 and plaintext-password set at the same time for password in ['area_password', 'domain_password']: if password in isis: @@ -230,6 +252,22 @@ def verify(config_dict): if int(len(isis['fast_reroute']['lfa']['remote']['prefix_list'].items())) > 1: raise ConfigError(f'LFA remote prefix-list has more than one configured. Cannot have more than one configured.') + # Check for lsp-timers violations + # Must be in sync with FRR yang limitations in yang/frr-isisd.yang + if int(isis['lsp_gen_interval']) >= int(isis['lsp_refresh_interval']): + raise ConfigError(f'lsp-gen-interval must be less then lsp-refresh-interval') + if int(isis['max_lsp_lifetime']) < int(isis['lsp_refresh_interval']) + 300: + raise ConfigError( + f'max-lsp-lifetime must be greater or equal to lsp-refresh-interval + 300' + ) + + # Check IS-IS SRv6 + if dict_search('segment_routing.srv6', isis): + # The interface used to install SRv6 SIDs in the Linux data plane. + # https://docs.frrouting.org/en/stable-10.2/isisd.html#clicmd-interface-NAME + if not dict_search('segment_routing.srv6.interface', isis): + raise ConfigError('Missing interface used for installing SRv6 SIDs') + return None def generate(config_dict): diff --git a/src/conf_mode/protocols_mpls.py b/src/conf_mode/protocols_mpls.py index 33d9a6dae..841e5406f 100755 --- a/src/conf_mode/protocols_mpls.py +++ b/src/conf_mode/protocols_mpls.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -85,21 +85,21 @@ def apply(config_dict): labels = '0' if 'interface' in mpls: labels = '1048575' - sysctl_write('net.mpls.platform_labels', labels) + sysctl_write(['net', 'mpls', 'platform_labels'], labels) # Check for changes in global MPLS options if 'parameters' in mpls: # Choose whether to copy IP TTL to MPLS header TTL if 'no_propagate_ttl' in mpls['parameters']: - sysctl_write('net.mpls.ip_ttl_propagate', 0) + sysctl_write(['net', 'mpls', 'ip_ttl_propagate'], 0) # Choose whether to limit maximum MPLS header TTL if 'maximum_ttl' in mpls['parameters']: ttl = mpls['parameters']['maximum_ttl'] - sysctl_write('net.mpls.default_ttl', ttl) + sysctl_write(['net', 'mpls', 'default_ttl'], ttl) else: # Set default global MPLS options if not defined. - sysctl_write('net.mpls.ip_ttl_propagate', 1) - sysctl_write('net.mpls.default_ttl', 255) + sysctl_write(['net', 'mpls', 'ip_ttl_propagate'], 1) + sysctl_write(['net', 'mpls', 'default_ttl'], 255) # Enable and disable MPLS processing on interfaces per configuration if 'interface' in mpls: @@ -112,20 +112,17 @@ def apply(config_dict): interface_state = read_file(f'/proc/sys/net/mpls/conf/{system_interface}/input') if '1' in interface_state: if system_interface not in mpls['interface']: - system_interface = system_interface.replace('.', '/') - sysctl_write(f'net.mpls.conf.{system_interface}.input', 0) + sysctl_write(['net', 'mpls', 'conf', system_interface, 'input'], 0) elif '0' in interface_state: if system_interface in mpls['interface']: - system_interface = system_interface.replace('.', '/') - sysctl_write(f'net.mpls.conf.{system_interface}.input', 1) + sysctl_write(['net', 'mpls', 'conf', system_interface, 'input'], 1) else: system_interfaces = [] # If MPLS interfaces are not configured, set MPLS processing disabled for interface in glob('/proc/sys/net/mpls/conf/*'): system_interfaces.append(os.path.basename(interface)) for system_interface in system_interfaces: - system_interface = system_interface.replace('.', '/') - sysctl_write(f'net.mpls.conf.{system_interface}.input', 0) + sysctl_write(['net', 'mpls', 'conf', system_interface, 'input'], 0) return None diff --git a/src/conf_mode/protocols_nhrp.py b/src/conf_mode/protocols_nhrp.py index ac92c9d99..3901b20ba 100755 --- a/src/conf_mode/protocols_nhrp.py +++ b/src/conf_mode/protocols_nhrp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -92,7 +92,7 @@ def verify(config_dict): nbma_list.append(nbma_ip) else: raise ConfigError( - f'Nbma address {nbma_ip} cannot be maped to several tunnel-ip') + f'Nbma address {nbma_ip} cannot be mapped to several tunnel-ip') return None diff --git a/src/conf_mode/protocols_openfabric.py b/src/conf_mode/protocols_openfabric.py index 7df11fb20..f490d28bf 100644 --- a/src/conf_mode/protocols_openfabric.py +++ b/src/conf_mode/protocols_openfabric.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/protocols_ospf.py b/src/conf_mode/protocols_ospf.py index c06c0aafc..b20cea25a 100755 --- a/src/conf_mode/protocols_ospf.py +++ b/src/conf_mode/protocols_ospf.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -17,6 +17,7 @@ from sys import exit from sys import argv +from vyos.base import Warning from vyos.config import Config from vyos.configverify import verify_common_route_maps from vyos.configverify import verify_route_map @@ -48,8 +49,9 @@ def verify(config_dict): if 'vrf_context' in config_dict: vrf = config_dict['vrf_context'] - # eqivalent of the C foo ? 'a' : 'b' statement - ospf = vrf and config_dict['vrf']['name'][vrf]['protocols']['ospf'] or config_dict['ospf'] + # equivalent of the C foo ? 'a' : 'b' statement + ospf = vrf and dict_search(f'vrf.name.{vrf}.protocols.ospf', + config_dict) or config_dict['ospf'] ospf['policy'] = config_dict['policy'] verify_common_route_maps(ospf) @@ -60,20 +62,30 @@ def verify(config_dict): # Validate if configured Access-list exists if 'area' in ospf: - networks = [] - for area, area_config in ospf['area'].items(): - if 'import_list' in area_config: - acl_import = area_config['import_list'] - if acl_import: verify_access_list(acl_import, ospf) - if 'export_list' in area_config: - acl_export = area_config['export_list'] - if acl_export: verify_access_list(acl_export, ospf) - - if 'network' in area_config: - for network in area_config['network']: - if network in networks: - raise ConfigError(f'Network "{network}" already defined in different area!') - networks.append(network) + networks = [] + for area, area_config in ospf['area'].items(): + # Implemented as warning to not break existing configurations + if area == '0' and dict_search('area_type.nssa', area_config) != None: + Warning('You cannot configure NSSA to backbone!') + # Implemented as warning to not break existing configurations + if area == '0' and dict_search('area_type.stub', area_config) != None: + Warning('You cannot configure STUB to backbone!') + # Implemented as warning to not break existing configurations + if len(area_config['area_type']) > 1: + Warning(f'Only one area-type is supported for area "{area}"!') + + if 'import_list' in area_config: + if acl_import := area_config['import_list']: + verify_access_list(acl_import, ospf) + if 'export_list' in area_config: + if acl_export := area_config['export_list']: + verify_access_list(acl_export, ospf) + + if 'network' in area_config: + for network in area_config['network']: + if network in networks: + raise ConfigError(f'Network "{network}" already defined in different area!') + networks.append(network) if 'interface' in ospf: for interface, interface_config in ospf['interface'].items(): @@ -90,8 +102,17 @@ def verify(config_dict): if 'area' in ospf and 'area' in interface_config: for area, area_config in ospf['area'].items(): if 'network' in area_config: - raise ConfigError('Can not use OSPF interface area and area ' \ - 'network configuration at the same time!') + raise ConfigError('Can not use OSPF "interface area" and ' \ + '"area network" configuration at the same time!') + + # FRR only allows a single authentication mode (MD5, NULL or plaintext) + # at a time. Prevent users from defining more than one authentication mode. + if 'authentication' in interface_config: + auth_keys = set(interface_config['authentication']) + exclusive_auth_keys = {'md5', 'null', 'plaintext_password'} + if len(auth_keys & exclusive_auth_keys) >= 2: + raise ConfigError('Can not use multiple authentication modes ' + f'simultaneously for interface "{interface}"!') # If interface specific options are set, we must ensure that the # interface is bound to our requesting VRF. Due to the VyOS diff --git a/src/conf_mode/protocols_ospfv3.py b/src/conf_mode/protocols_ospfv3.py index 2563eb7d5..acf6cadfb 100755 --- a/src/conf_mode/protocols_ospfv3.py +++ b/src/conf_mode/protocols_ospfv3.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -48,8 +48,9 @@ def verify(config_dict): if 'vrf_context' in config_dict: vrf = config_dict['vrf_context'] - # eqivalent of the C foo ? 'a' : 'b' statement - ospfv3 = vrf and config_dict['vrf']['name'][vrf]['protocols']['ospfv3'] or config_dict['ospfv3'] + # equivalent of the C foo ? 'a' : 'b' statement + ospfv3 = vrf and dict_search(f'vrf.name.{vrf}.protocols.ospfv3', + config_dict) or config_dict['ospfv3'] ospfv3['policy'] = config_dict['policy'] verify_common_route_maps(ospfv3) diff --git a/src/conf_mode/protocols_pim.py b/src/conf_mode/protocols_pim.py index 632099964..bb55aada0 100755 --- a/src/conf_mode/protocols_pim.py +++ b/src/conf_mode/protocols_pim.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/protocols_pim6.py b/src/conf_mode/protocols_pim6.py index 03a79139a..f7803246a 100755 --- a/src/conf_mode/protocols_pim6.py +++ b/src/conf_mode/protocols_pim6.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/protocols_rip.py b/src/conf_mode/protocols_rip.py index ec9dfbb8b..c6adcde5b 100755 --- a/src/conf_mode/protocols_rip.py +++ b/src/conf_mode/protocols_rip.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/protocols_ripng.py b/src/conf_mode/protocols_ripng.py index 9a9ac8ec8..e5babf2e8 100755 --- a/src/conf_mode/protocols_ripng.py +++ b/src/conf_mode/protocols_ripng.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/protocols_rpki.py b/src/conf_mode/protocols_rpki.py index ef0250e3d..81039d3da 100755 --- a/src/conf_mode/protocols_rpki.py +++ b/src/conf_mode/protocols_rpki.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -18,6 +18,7 @@ import os from glob import glob from sys import exit +from sys import argv from vyos.config import Config from vyos.configverify import has_frr_protocol_in_dict @@ -25,6 +26,7 @@ from vyos.frrender import FRRender from vyos.frrender import get_frrender_dict from vyos.pki import wrap_openssh_public_key from vyos.pki import wrap_openssh_private_key +from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args from vyos.utils.file import write_file from vyos.utils.process import is_systemd_service_running @@ -39,13 +41,19 @@ def get_config(config=None): conf = config else: conf = Config() - return get_frrender_dict(conf) + return get_frrender_dict(conf, argv) def verify(config_dict): if not has_frr_protocol_in_dict(config_dict, 'rpki'): return None - rpki = config_dict['rpki'] + vrf = None + if 'vrf_context' in config_dict: + vrf = config_dict['vrf_context'] + + # equivalent of the C foo ? 'a' : 'b' statement + rpki = vrf and dict_search(f'vrf.name.{vrf}.protocols.rpki', + config_dict) or config_dict['rpki'] if 'cache' in rpki: preferences = [] @@ -79,7 +87,13 @@ def generate(config_dict): if not has_frr_protocol_in_dict(config_dict, 'rpki'): return None - rpki = config_dict['rpki'] + vrf = None + if 'vrf_context' in config_dict: + vrf = config_dict['vrf_context'] + + # equivalent of the C foo ? 'a' : 'b' statement + rpki = vrf and dict_search(f'vrf.name.{vrf}.protocols.rpki', + config_dict) or config_dict['rpki'] if 'cache' in rpki: for cache, cache_config in rpki['cache'].items(): diff --git a/src/conf_mode/protocols_segment-routing.py b/src/conf_mode/protocols_segment-routing.py index f2bd42a79..b9689557e 100755 --- a/src/conf_mode/protocols_segment-routing.py +++ b/src/conf_mode/protocols_segment-routing.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -15,6 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from sys import exit +from sys import argv from vyos.config import Config from vyos.configdict import list_diff @@ -35,7 +36,7 @@ def get_config(config=None): else: conf = Config() - return get_frrender_dict(conf) + return get_frrender_dict(conf, argv) def verify(config_dict): if not has_frr_protocol_in_dict(config_dict, 'segment_routing'): @@ -45,13 +46,68 @@ def verify(config_dict): if 'srv6' in sr: srv6_enable = False - if 'interface' in sr: - for interface, interface_config in sr['interface'].items(): - if 'srv6' in interface_config: - srv6_enable = True - break + for _, interface_config in dict_search('interface', sr, {}).items(): + if 'srv6' in interface_config: + srv6_enable = True + break if not srv6_enable: raise ConfigError('SRv6 should be enabled on at least one interface!') + + # Check for database import having more than one protocol + if tmp := dict_search('traffic_engineering.database_import_protocol', sr): + if {'isis', 'ospf'} <= set(tmp.keys()): + raise ConfigError('SR-TE database import: IS-IS and OSPF are mutually exclusive!') + + for segment_list in dict_search('traffic_engineering.segment_list', sr, []): + sl_data = dict_search(f'traffic_engineering.segment_list.{segment_list}', sr) + indices = sl_data.get('index') if sl_data else None + + if indices is None: + raise ConfigError(f'SR-TE segment list "{segment_list}": '\ + 'at least one index is required!') + + for index, index_data in indices.items(): + error_msg = f'SR-TE segment list "{segment_list}", index "{index}"' + nai = index_data.get('nai') + mpls = index_data.get('mpls') + if not nai and not mpls: + raise ConfigError(f'{error_msg}: "mpls" or "nai" is required!') + + if nai: + if 'adjacency' in nai and 'prefix' in nai: + raise ConfigError(f'{error_msg}: "prefix" and "adjacency" are mutually exclusive!') + + for nai_type in ('adjacency', 'prefix'): + nai_data = nai.get(nai_type) + if not nai_data: + continue + + if 'ipv4' in nai_data and 'ipv6' in nai_data: + raise ConfigError(f'{error_msg}, nai {nai_type}: "ipv4" and "ipv6" are ' + 'mutually exclusive!') + + for af, af_config in nai_data.items(): + af_ctx = f'{error_msg}, nai {nai_type} {af}' + if nai_type == 'adjacency': + has_src = 'source_identifier' in af_config + has_dst = 'destination_identifier' in af_config + if has_src != has_dst: + missing = 'destination-identifier' if has_src else 'source-identifier' + raise ConfigError(f'{af_ctx}: "{missing}" is required!') + else: + if 'prefix_identifier' not in af_config: + raise ConfigError(f'{af_ctx}: "prefix-identifier" is required!') + + for pfx, pfx_data in af_config['prefix_identifier'].items(): + pfx_ctx = f'{af_ctx}, prefix "{pfx}"' + if 'algorithm' not in pfx_data: + raise ConfigError(f'{pfx_ctx}: "algorithm" is required!') + + if alg := pfx_data.get('algorithm'): + if {'spf', 'strict_spf'} <= set(alg.keys()): + raise ConfigError(f'{pfx_ctx}: "spf" and "strict-spf" ' + 'are mutually exclusive!') + return None def generate(config_dict): @@ -70,24 +126,24 @@ def apply(config_dict): for interface in list_diff(current_interfaces, sr_interfaces): # Disable processing of IPv6-SR packets - sysctl_write(f'net.ipv6.conf.{interface}.seg6_enabled', '0') + sysctl_write(['net', 'ipv6', 'conf', interface, 'seg6_enabled'], '0') for interface, interface_config in sr.get('interface', {}).items(): # Accept or drop SR-enabled IPv6 packets on this interface if 'srv6' in interface_config: - sysctl_write(f'net.ipv6.conf.{interface}.seg6_enabled', '1') + sysctl_write(['net', 'ipv6', 'conf', interface, 'seg6_enabled'], '1') # Define HMAC policy for ingress SR-enabled packets on this interface # It's a redundant check as HMAC has a default value - but better safe # then sorry tmp = dict_search('srv6.hmac', interface_config) if tmp == 'accept': - sysctl_write(f'net.ipv6.conf.{interface}.seg6_require_hmac', '0') + sysctl_write(['net', 'ipv6', 'conf', interface, 'seg6_require_hmac'], '0') elif tmp == 'drop': - sysctl_write(f'net.ipv6.conf.{interface}.seg6_require_hmac', '1') + sysctl_write(['net', 'ipv6', 'conf', interface, 'seg6_require_hmac'], '1') elif tmp == 'ignore': - sysctl_write(f'net.ipv6.conf.{interface}.seg6_require_hmac', '-1') + sysctl_write(['net', 'ipv6', 'conf', interface, 'seg6_require_hmac'], '-1') else: - sysctl_write(f'net.ipv6.conf.{interface}.seg6_enabled', '0') + sysctl_write(['net', 'ipv6', 'conf', interface, 'seg6_enabled'], '0') if config_dict and not is_systemd_service_running('vyos-configd.service'): FRRender().apply() diff --git a/src/conf_mode/protocols_static.py b/src/conf_mode/protocols_static.py index 1b9e51167..d84cfd77f 100755 --- a/src/conf_mode/protocols_static.py +++ b/src/conf_mode/protocols_static.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -17,6 +17,7 @@ from ipaddress import IPv4Network from sys import exit from sys import argv +import os from vyos.config import Config from vyos.configverify import has_frr_protocol_in_dict @@ -24,13 +25,17 @@ from vyos.configverify import verify_common_route_maps from vyos.configverify import verify_vrf from vyos.frrender import FRRender from vyos.frrender import get_frrender_dict +from vyos.utils.dict import dict_search +from vyos.utils.file import write_file from vyos.utils.process import is_systemd_service_running from vyos.template import render from vyos import ConfigError from vyos import airbag +from vyos import defaults airbag.enable() config_file = '/etc/iproute2/rt_tables.d/vyos-static.conf' +DHCP_HOOK_IFLIST = defaults.static_route_dhcp_interfaces_path def get_config(config=None): if config: @@ -48,8 +53,9 @@ def verify(config_dict): if 'vrf_context' in config_dict: vrf = config_dict['vrf_context'] - # eqivalent of the C foo ? 'a' : 'b' statement - static = vrf and config_dict['vrf']['name'][vrf]['protocols']['static'] or config_dict['static'] + # equivalent of the C foo ? 'a' : 'b' statement + static = vrf and dict_search(f'vrf.name.{vrf}.protocols.static', + config_dict) or config_dict['static'] static['policy'] = config_dict['policy'] verify_common_route_maps(static) @@ -89,8 +95,25 @@ def generate(config_dict): if 'vrf_context' in config_dict: vrf = config_dict['vrf_context'] - # eqivalent of the C foo ? 'a' : 'b' statement - static = vrf and config_dict['vrf']['name'][vrf]['protocols']['static'] or config_dict['static'] + # equivalent of the C foo ? 'a' : 'b' statement + static = vrf and dict_search(f'vrf.name.{vrf}.protocols.static', + config_dict) or config_dict['static'] + + # Collect interfaces that have DHCP configuration for DHCP hooks + dhcp_interfaces = set() + + # Check for DHCP interfaces in route configurations + if 'route' in static: + for prefix, prefix_options in static['route'].items(): + if 'dhcp_interface' in prefix_options: + for interface_name in prefix_options['dhcp_interface']: + dhcp_interfaces.add(interface_name) + + # Write the interface list for DHCP hooks or clean up if empty + if dhcp_interfaces: + write_file(DHCP_HOOK_IFLIST, " ".join(dhcp_interfaces)) + elif os.path.exists(DHCP_HOOK_IFLIST): + os.unlink(DHCP_HOOK_IFLIST) # Put routing table names in /etc/iproute2/rt_tables render(config_file, 'iproute2/static.conf.j2', static) diff --git a/src/conf_mode/protocols_static_arp.py b/src/conf_mode/protocols_static_arp.py index b141f1141..87dc5229e 100755 --- a/src/conf_mode/protocols_static_arp.py +++ b/src/conf_mode/protocols_static_arp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/protocols_static_neighbor-proxy.py b/src/conf_mode/protocols_static_neighbor-proxy.py index 8a1ea1df9..bda737e75 100755 --- a/src/conf_mode/protocols_static_neighbor-proxy.py +++ b/src/conf_mode/protocols_static_neighbor-proxy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/protocols_traffic_engineering.py b/src/conf_mode/protocols_traffic_engineering.py new file mode 100755 index 000000000..925585158 --- /dev/null +++ b/src/conf_mode/protocols_traffic_engineering.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 sys import exit + +from vyos.config import Config +from vyos.configverify import has_frr_protocol_in_dict +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict +from vyos.utils.process import is_systemd_service_running +from vyos import ConfigError +from vyos import airbag + +airbag.enable() + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + return get_frrender_dict(conf) + + +def verify(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'traffic_engineering'): + return None + + te = config_dict['traffic_engineering'] + + group_by_bit_position = {} + if 'admin_group' in te: + for admin_group, admin_group_data in te['admin_group'].items(): + if 'bit_position' not in admin_group_data: + raise ConfigError( + f'Missing required "bit-position" in group {admin_group}' + ) + if admin_group_data['bit_position'] in group_by_bit_position: + other = group_by_bit_position[admin_group_data['bit_position']] + raise ConfigError( + f'Two admin-groups cannot have same bit positions! Conflicting groups: {admin_group} and {other}' + ) + group_by_bit_position[admin_group_data['bit_position']] = admin_group + + all_groups = group_by_bit_position.values() + + if 'interface' in te: + for interface, interface_data in te['interface'].items(): + if 'admin_group' not in interface_data: + continue + for grp in interface_data['admin_group']: + if grp not in all_groups: + raise ConfigError( + f'Unknown admin-group "{grp}" set for interface "{interface}"' + ) + + return None + + +def generate(config_dict): + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(config_dict) + return None + + +def apply(config_dict): + if not has_frr_protocol_in_dict(config_dict, 'traffic_engineering'): + return None + + if config_dict and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/qos.py b/src/conf_mode/qos.py index 59e307a39..35b9c0aa2 100755 --- a/src/conf_mode/qos.py +++ b/src/conf_mode/qos.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -15,7 +15,8 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from sys import exit -from netifaces import interfaces + +from netifaces import interfaces # pylint: disable = no-name-in-module from vyos.base import Warning from vyos.config import Config @@ -85,7 +86,13 @@ def _clean_conf_dict(conf): } """ if isinstance(conf, dict): - return {node: _clean_conf_dict(val) for node, val in conf.items() if val != {} and _clean_conf_dict(val) != {}} + preserve_empty_nodes = {'syn', 'ack'} + + return { + node: _clean_conf_dict(val) + for node, val in conf.items() + if (val != {} and _clean_conf_dict(val) != {}) or node in preserve_empty_nodes + } else: return conf @@ -357,7 +364,7 @@ def apply(qos): for interface, interface_config in qos['interface'].items(): if not verify_interface_exists(qos, interface, state_required=True, warning_only=True): # When shaper is bound to a dialup (e.g. PPPoE) interface it is - # possible that it is yet not availbale when to QoS code runs. + # possible that it is yet not available when to QoS code runs. # Skip the configuration and inform the user via warning_only=True continue diff --git a/src/conf_mode/service_aws_glb.py b/src/conf_mode/service_aws_glb.py index d1ed5a07b..aa5ec5ebe 100755 --- a/src/conf_mode/service_aws_glb.py +++ b/src/conf_mode/service_aws_glb.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/service_broadcast-relay.py b/src/conf_mode/service_broadcast-relay.py index d35954718..b3f38dd21 100755 --- a/src/conf_mode/service_broadcast-relay.py +++ b/src/conf_mode/service_broadcast-relay.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2017-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -17,7 +17,7 @@ import os from glob import glob -from netifaces import AF_INET +from socket import AF_INET from sys import exit from vyos.config import Config diff --git a/src/conf_mode/service_config-sync.py b/src/conf_mode/service_config-sync.py index 4b8a7f6ee..32001ce57 100755 --- a/src/conf_mode/service_config-sync.py +++ b/src/conf_mode/service_config-sync.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/service_conntrack-sync.py b/src/conf_mode/service_conntrack-sync.py index 3a233a172..5eb4ca0e5 100755 --- a/src/conf_mode/service_conntrack-sync.py +++ b/src/conf_mode/service_conntrack-sync.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/service_console-server.py b/src/conf_mode/service_console-server.py index b83c6dfb1..595d7888a 100755 --- a/src/conf_mode/service_console-server.py +++ b/src/conf_mode/service_console-server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -46,7 +46,7 @@ def get_config(config=None): # 'stop_bits': '2'}}} # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. + # options which we need to update into the dictionary retrieved. proxy = conf.merge_defaults(proxy, recursive=True) return proxy diff --git a/src/conf_mode/service_dhcp-relay.py b/src/conf_mode/service_dhcp-relay.py index 37d708847..255e2b143 100755 --- a/src/conf_mode/service_dhcp-relay.py +++ b/src/conf_mode/service_dhcp-relay.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -20,8 +20,8 @@ from sys import exit from vyos.base import Warning from vyos.config import Config +from vyos.configverify import verify_interface_exists from vyos.template import render -from vyos.base import Warning from vyos.utils.process import call from vyos.utils.dict import dict_search from vyos import ConfigError @@ -61,15 +61,19 @@ def verify(relay): Warning('DHCP relay interface is DEPRECATED - please use upstream-interface and listen-interface instead!') if 'upstream_interface' in relay or 'listen_interface' in relay: raise ConfigError('<interface> configuration is not compatible with upstream/listen interface') - else: - Warning('<interface> is going to be deprecated.\n' \ - 'Please use <listen-interface> and <upstream-interface>') + + for interface in relay['interface']: + verify_interface_exists(relay, interface, warning_only=True) if 'upstream_interface' in relay and 'listen_interface' not in relay: raise ConfigError('No listen-interface configured') if 'listen_interface' in relay and 'upstream_interface' not in relay: raise ConfigError('No upstream-interface configured') + for iface_type in ['upstream_interface', 'listen_interface']: + for interface in relay.get(iface_type, []): + verify_interface_exists(relay, interface, warning_only=True) + return None def generate(relay): diff --git a/src/conf_mode/service_dhcp-server.py b/src/conf_mode/service_dhcp-server.py index 5a729af74..24df20bb7 100755 --- a/src/conf_mode/service_dhcp-server.py +++ b/src/conf_mode/service_dhcp-server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -15,23 +15,28 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os +import re + +from sys import exit +from sys import argv from glob import glob from ipaddress import ip_address from ipaddress import ip_network from netaddr import IPRange -from sys import exit from vyos.config import Config +from vyos.kea import kea_test_config from vyos.pki import wrap_certificate from vyos.pki import wrap_private_key from vyos.template import render from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args +from vyos.utils.dict import dict_search_recursive from vyos.utils.file import chmod_775 -from vyos.utils.file import chown from vyos.utils.file import makedir from vyos.utils.file import write_file +from vyos.utils.permission import chown from vyos.utils.process import call from vyos.utils.network import interface_exists from vyos.utils.network import is_subnet_connected @@ -41,16 +46,53 @@ from vyos import airbag airbag.enable() -ctrl_config_file = '/run/kea/kea-ctrl-agent.conf' -ctrl_socket = '/run/kea/dhcp4-ctrl-socket' -config_file = '/run/kea/kea-dhcp4.conf' -lease_file = '/config/dhcp/dhcp4-leases.csv' -lease_file_glob = '/config/dhcp/dhcp4-leases*' +ctrl_socket = '' +config_file = '' +config_file_d2 = '' +lease_file = '' +lease_file_glob = '' + +ca_cert_file = '' +cert_file = '' +cert_key_file = '' + user_group = '_kea' -ca_cert_file = '/run/kea/kea-failover-ca.pem' -cert_file = '/run/kea/kea-failover.pem' -cert_key_file = '/run/kea/kea-failover-key.pem' + +def _override_for_vrf(vrf_name): + """ + This function is intended to override global vars when vrf is enabled + """ + global ctrl_socket, config_file, config_file_d2, lease_file, lease_file_glob + global ca_cert_file, cert_file, cert_key_file + + ctrl_socket = f'/run/kea/dhcp4-{vrf_name}-ctrl-socket' + config_file = f'/run/kea/kea-{vrf_name}-dhcp4.conf' + config_file_d2 = f'/run/kea/kea-{vrf_name}-dhcp-ddns.conf' + lease_file = f'/config/dhcp/dhcp4-{vrf_name}-leases.csv' + lease_file_glob = f'/config/dhcp/dhcp4-{vrf_name}-leases*' + + ca_cert_file = f'/run/kea/kea-{vrf_name}-failover-ca.pem' + cert_file = f'/run/kea/kea-{vrf_name}-failover.pem' + cert_key_file = f'/run/kea/kea-{vrf_name}-failover-key.pem' + + +def _reset_vars(): + """ + This function is intended to reset global vars when vrf is not enabled + """ + global ctrl_socket, config_file, config_file_d2, lease_file, lease_file_glob + global ca_cert_file, cert_file, cert_key_file + + ctrl_socket = '/run/kea/dhcp4-ctrl-socket' + config_file = '/run/kea/kea-dhcp4.conf' + config_file_d2 = '/run/kea/kea-dhcp-ddns.conf' + lease_file = '/config/dhcp/dhcp4-leases.csv' + lease_file_glob = '/config/dhcp/dhcp4-leases*' + + ca_cert_file = '/run/kea/kea-failover-ca.pem' + cert_file = '/run/kea/kea-failover.pem' + cert_key_file = '/run/kea/kea-failover-key.pem' def dhcp_slice_range(exclude_list, range_dict): @@ -125,7 +167,19 @@ def get_config(config=None): conf = config else: conf = Config() - base = ['service', 'dhcp-server'] + + # if running in vrf, set base differently + if argv and len(argv) > 1: + vrf_name = argv[1] + base = ['vrf', 'name', vrf_name, 'service', 'dhcp-server'] + + # vrf is defined, override other vars aswell + _override_for_vrf(vrf_name) + else: + base = ['service', 'dhcp-server'] + + # vrf is not defined reset vars + _reset_vars() if not conf.exists(base): return None @@ -137,6 +191,10 @@ def get_config(config=None): with_recursive_defaults=True, ) + # add vrf context if present + if argv and len(argv) > 1: + dhcp['vrf_context'] = argv[1] + if 'shared_network_name' in dhcp: for network, network_config in dhcp['shared_network_name'].items(): if 'subnet' in network_config: @@ -169,8 +227,20 @@ def get_config(config=None): no_tag_node_value_mangle=True, ) + if bool(list(dict_search_recursive(dhcp, 'ping_check'))): + dhcp['any_ping_check'] = True + return dhcp +def verify_ddns_domain_servers(domain_type, domain): + if 'dns_server' in domain: + invalid_servers = [] + for server_no, server_config in domain['dns_server'].items(): + if 'address' not in server_config: + invalid_servers.append(server_no) + if len(invalid_servers) > 0: + raise ConfigError(f'{domain_type} DNS servers {", ".join(invalid_servers)} in DDNS configuration need to have an IP address') + return None def verify(dhcp): # bail out early - looks like removal from running config @@ -222,6 +292,12 @@ def verify(dhcp): f'DHCP static-route "{route}" requires router to be defined!' ) + # If a client class has been specified then it must exist + if 'client_class' in subnet_config: + client_class = subnet_config['client_class'] + if client_class not in dhcp.get('client_class', {}): + raise ConfigError(f'Client class "{client_class}" set in subnet "{subnet}" but does not exist') + # Check if DHCP address range is inside configured subnet declaration if 'range' in subnet_config: networks = [] @@ -231,6 +307,12 @@ def verify(dhcp): f'DHCP range "{range}" start and stop address must be defined!' ) + # If a client class has been specified then it must exist + if 'client_class' in range_config: + client_class = range_config['client_class'] + if client_class not in dhcp.get('client_class', {}): + raise ConfigError(f'Client class "{client_class}" set in range "{range}" but does not exist') + # Start/Stop address must be inside network for key in ['start', 'stop']: if ip_address(range_config[key]) not in ip_network(subnet): @@ -423,6 +505,42 @@ def verify(dhcp): if not interface_exists(interface): raise ConfigError(f'listen-interface "{interface}" does not exist') + if 'dynamic_dns_update' in dhcp: + ddns = dhcp['dynamic_dns_update'] + if 'tsig_key' in ddns: + invalid_keys = [] + for tsig_key_name, tsig_key_config in ddns['tsig_key'].items(): + if not ('algorithm' in tsig_key_config and 'secret' in tsig_key_config): + invalid_keys.append(tsig_key_name) + if len(invalid_keys) > 0: + raise ConfigError(f'Both algorithm and secret need to be set for TSIG keys: {", ".join(invalid_keys)}') + + if 'forward_domain' in ddns: + verify_ddns_domain_servers('Forward', ddns['forward_domain']) + + if 'reverse_domain' in ddns: + verify_ddns_domain_servers('Reverse', ddns['reverse_domain']) + + if 'client_class' in dhcp: + # Check client class values are valid + for class_name, class_config in dhcp['client_class'].items(): + if 'relay_agent_information' in class_config: + relay_agent_information_config = class_config['relay_agent_information'] + # Compile a regex that will scan for valid inputs. Input can be + # either hex in the form 0x0123456789ABCDEF or a string that + # does *not* start with 0x. i.e. 0xHELLOWORLD is bad + pattern = re.compile(r'^(?:0x[0-9A-Fa-f]+|(?!0x).+)$') + + if 'circuit_id' in relay_agent_information_config: + circuit_id = relay_agent_information_config['circuit_id'] + if not pattern.match(circuit_id): + raise ConfigError(f'Invalid circuit-id "{circuit_id}" must be either text literal or hex string starting with 0x') + + if 'remote_id' in relay_agent_information_config: + remote_id = relay_agent_information_config['remote_id'] + if not pattern.match(remote_id): + raise ConfigError(f'Invalid remote-id "{remote_id}" must be either text literal or hex string starting with 0x') + return None @@ -480,25 +598,31 @@ def generate(dhcp): dhcp['high_availability']['ca_cert_file'] = ca_cert_file render( - ctrl_config_file, - 'dhcp-server/kea-ctrl-agent.conf.j2', - dhcp, - user=user_group, - group=user_group, - ) - render( config_file, 'dhcp-server/kea-dhcp4.conf.j2', dhcp, user=user_group, group=user_group, ) + if 'dynamic_dns_update' in dhcp: + render( + config_file_d2, + 'dhcp-server/kea-dhcp-ddns.conf.j2', + dhcp, + user=user_group, + group=user_group + ) return None def apply(dhcp): - services = ['kea-ctrl-agent', 'kea-dhcp4-server', 'kea-dhcp-ddns-server'] + # if running in vrf, set base differently + if argv and len(argv) > 1: + vrf_name = argv[1] + services = [f'isc-kea-dhcp4-server@{vrf_name}', f'isc-kea-dhcp-ddns-server@{vrf_name}'] + else: + services = ['isc-kea-dhcp4-server', 'isc-kea-dhcp-ddns-server'] if not dhcp or 'disable' in dhcp: for service in services: @@ -509,13 +633,14 @@ def apply(dhcp): return None + result, output = kea_test_config('kea-dhcp4', config_file) + if not result: + raise ConfigError(f'Unexpected error with Kea configuration:\n{output}') + for service in services: action = 'restart' - if service == 'kea-dhcp-ddns-server' and 'dynamic_dns_update' not in dhcp: - action = 'stop' - - if service == 'kea-ctrl-agent' and 'high_availability' not in dhcp: + if 'isc-kea-dhcp-ddns-server' in service and 'dynamic_dns_update' not in dhcp: action = 'stop' call(f'systemctl {action} {service}.service') diff --git a/src/conf_mode/service_dhcpv6-relay.py b/src/conf_mode/service_dhcpv6-relay.py index 6537ca3c2..4547b608c 100755 --- a/src/conf_mode/service_dhcpv6-relay.py +++ b/src/conf_mode/service_dhcpv6-relay.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/service_dhcpv6-server.py b/src/conf_mode/service_dhcpv6-server.py index 7af88007c..01bbf3096 100755 --- a/src/conf_mode/service_dhcpv6-server.py +++ b/src/conf_mode/service_dhcpv6-server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -16,42 +16,94 @@ import os +from sys import exit +from sys import argv + from glob import glob from ipaddress import ip_address from ipaddress import ip_network -from sys import exit from vyos.config import Config +from vyos.kea import kea_test_config from vyos.template import render from vyos.utils.process import call from vyos.utils.file import chmod_775 -from vyos.utils.file import chown from vyos.utils.file import makedir from vyos.utils.file import write_file from vyos.utils.dict import dict_search from vyos.utils.network import is_subnet_connected +from vyos.utils.permission import chown from vyos import ConfigError from vyos import airbag + airbag.enable() -config_file = '/run/kea/kea-dhcp6.conf' -ctrl_socket = '/run/kea/dhcp6-ctrl-socket' -lease_file = '/config/dhcp/dhcp6-leases.csv' -lease_file_glob = '/config/dhcp/dhcp6-leases*' + +config_file = '' +ctrl_socket = '' +lease_file = '' +lease_file_glob = '' + user_group = '_kea' + +def _override_for_vrf(vrf_name): + """ + This function is intended to override some of the global vars + """ + global ctrl_socket, config_file, lease_file, lease_file_glob + + config_file = f'/run/kea/kea-{vrf_name}-dhcp6.conf' + ctrl_socket = f'/run/kea/dhcp6-{vrf_name}-ctrl-socket' + lease_file = f'/config/dhcp/dhcp6-{vrf_name}-leases.csv' + lease_file_glob = f'/config/dhcp/dhcp6-{vrf_name}-leases*' + + +def _reset_vars(): + """ + This function is intended to reset global vars when vrf is not enabled + """ + global ctrl_socket, config_file, lease_file, lease_file_glob + + config_file = '/run/kea/kea-dhcp6.conf' + ctrl_socket = '/run/kea/dhcp6-ctrl-socket' + lease_file = '/config/dhcp/dhcp6-leases.csv' + lease_file_glob = '/config/dhcp/dhcp6-leases*' + + def get_config(config=None): if config: conf = config else: conf = Config() - base = ['service', 'dhcpv6-server'] + + # if running in vrf, set base differently + if argv and len(argv) > 1: + vrf_name = argv[1] + base = ['vrf', 'name', vrf_name, 'service', 'dhcpv6-server'] + + # vrf is defined, override other vars aswell + _override_for_vrf(vrf_name) + else: + base = ['service', 'dhcpv6-server'] + + # vrf is not defined reset vars + _reset_vars() if not conf.exists(base): return None - dhcpv6 = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) + dhcpv6 = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_recursive_defaults=True, + ) + + # add vrf context if present + if argv and len(argv) > 1: + dhcpv6['vrf_context'] = argv[1] + return dhcpv6 def verify(dhcpv6): @@ -144,16 +196,21 @@ def verify(dhcpv6): if 'prefix_length' not in prefix_config: raise ConfigError('Length of delegated IPv6 prefix must be configured') - if prefix_config['prefix_length'] > prefix_config['delegated_length']: + prefix_len = prefix_config['prefix_length'] + prefix_obj = None + + if prefix_len > prefix_config['delegated_length']: raise ConfigError('Length of delegated IPv6 prefix must be within parent prefix') + try: + prefix_obj = ip_network(f'{prefix}/{prefix_len}') + except ValueError: + raise ConfigError('Invalid prefix-length for delegated prefix') + if 'excluded_prefix' in prefix_config: if 'excluded_prefix_length' not in prefix_config: raise ConfigError('Length of excluded IPv6 prefix must be configured') - prefix_len = prefix_config['prefix_length'] - prefix_obj = ip_network(f'{prefix}/{prefix_len}') - excluded_prefix = prefix_config['excluded_prefix'] excluded_len = prefix_config['excluded_prefix_length'] excluded_obj = ip_network(f'{excluded_prefix}/{excluded_len}') @@ -169,13 +226,18 @@ def verify(dhcpv6): for mapping, mapping_config in subnet_config['static_mapping'].items(): if 'ipv6_address' in mapping_config: # Static address must be in subnet - if ip_address(mapping_config['ipv6_address']) not in ip_network(subnet): - raise ConfigError(f'static-mapping address for mapping "{mapping}" is not in subnet "{subnet}"!') + for address in mapping_config['ipv6_address']: + if ip_address(address) not in ip_network(subnet): + raise ConfigError(f'static-mapping address for mapping "{mapping}" is not in subnet "{subnet}"!') + + if ('ipv6_address' not in mapping_config and 'ipv6_prefix' not in mapping_config): + raise ConfigError('Either IPv6 address or IPv6 prefix must be set for static mapping ' + f'"{mapping}" within shared-network "{network}, {subnet}"!') - if ('mac' not in mapping_config and 'duid' not in mapping_config) or \ - ('mac' in mapping_config and 'duid' in mapping_config): - raise ConfigError(f'Either MAC address or Client identifier (DUID) is required for ' - f'static mapping "{mapping}" within shared-network "{network}, {subnet}"!') + if ('mac' not in mapping_config and 'duid' not in mapping_config) or \ + ('mac' in mapping_config and 'duid' in mapping_config): + raise ConfigError('Either MAC address or Client identifier (DUID) is required for ' + f'static mapping "{mapping}" within shared-network "{network}, {subnet}"!') if 'option' in subnet_config: if 'vendor_option' in subnet_config['option']: @@ -188,22 +250,22 @@ def verify(dhcpv6): subnets.append(subnet) - # DHCPv6 requires at least one configured address range or one static mapping - # (FIXME: is not actually checked right now?) + # DHCPv6 requires at least one configured address range or one static mapping + # (FIXME: is not actually checked right now?) - # There must be one subnet connected to a listen interface if network is not disabled. - if 'disable' not in network_config: - if is_subnet_connected(subnet): - listen_ok = True + # There must be one subnet connected to a listen interface if network is not disabled. + if 'disable' not in network_config: + if is_subnet_connected(subnet): + listen_ok = True - # DHCPv6 subnet must not overlap. ISC DHCP also complains about overlapping - # subnets: "Warning: subnet 2001:db8::/32 overlaps subnet 2001:db8:1::/32" - net = ip_network(subnet) - for n in subnets: - net2 = ip_network(n) - if (net != net2): - if net.overlaps(net2): - raise ConfigError('DHCPv6 conflicting subnet ranges: {0} overlaps {1}'.format(net, net2)) + # DHCPv6 subnet must not overlap. ISC DHCP also complains about overlapping + # subnets: "Warning: subnet 2001:db8::/32 overlaps subnet 2001:db8:1::/32" + net = ip_network(subnet) + for n in subnets: + net2 = ip_network(n) + if (net != net2): + if net.overlaps(net2): + raise ConfigError('DHCPv6 conflicting subnet ranges: {0} overlaps {1}'.format(net, net2)) if not listen_ok: raise ConfigError('None of the DHCPv6 subnets are connected to a subnet6 on '\ @@ -239,8 +301,14 @@ def generate(dhcpv6): return None def apply(dhcpv6): + # if running in vrf, set base differently + if argv and len(argv) > 1: + vrf_name = argv[1] + service_name = f'isc-kea-dhcp6-server@{vrf_name}.service' + else: + service_name = 'isc-kea-dhcp6-server.service' + # bail out early - looks like removal from running config - service_name = 'kea-dhcp6-server.service' if not dhcpv6 or 'disable' in dhcpv6: # DHCP server is removed in the commit call(f'systemctl stop {service_name}') @@ -248,6 +316,10 @@ def apply(dhcpv6): os.unlink(config_file) return None + result, output = kea_test_config('kea-dhcp6', config_file) + if not result: + raise ConfigError(f'Unexpected error with Kea configuration:\n{output}') + call(f'systemctl restart {service_name}') return None diff --git a/src/conf_mode/service_dns_dynamic.py b/src/conf_mode/service_dns_dynamic.py index 5f5303856..b321d5f51 100755 --- a/src/conf_mode/service_dns_dynamic.py +++ b/src/conf_mode/service_dns_dynamic.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/service_dns_forwarding.py b/src/conf_mode/service_dns_forwarding.py index e3bdbc9f8..cd0c6a38a 100755 --- a/src/conf_mode/service_dns_forwarding.py +++ b/src/conf_mode/service_dns_forwarding.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -366,6 +366,13 @@ def apply(dns): hc.add_name_server_tags_recursor(['dhcp-' + interface, 'dhcpv6-' + interface ]) + # add dhcp interfaces + if 'dhcp' in dns: + for interface in dns['dhcp']: + if interface_exists(interface): + hc.add_name_server_tags_recursor(['dhcp-' + interface, + 'dhcpv6-' + interface ]) + # hostsd will generate the forward-zones file # the list and keys() are required as get returns a dict, not list hc.delete_forward_zones(list(hc.get_forward_zones().keys())) diff --git a/src/conf_mode/service_event-handler.py b/src/conf_mode/service_event-handler.py index 5028ef52f..1b9e7ff53 100755 --- a/src/conf_mode/service_event-handler.py +++ b/src/conf_mode/service_event-handler.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/service_https.py b/src/conf_mode/service_https.py index 9e58b4c72..13a4930fd 100755 --- a/src/conf_mode/service_https.py +++ b/src/conf_mode/service_https.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -28,6 +28,7 @@ from vyos.configverify import verify_vrf from vyos.configverify import verify_pki_certificate from vyos.configverify import verify_pki_ca_certificate from vyos.configverify import verify_pki_dh_parameters +from vyos.configdiff import get_config_diff from vyos.defaults import api_config_state from vyos.pki import wrap_certificate from vyos.pki import wrap_private_key @@ -68,17 +69,25 @@ def get_config(config=None): # store path to API config file for later use in templates https['api_config_state'] = api_config_state - # get fully qualified system hsotname + # get fully qualified system hostname https['hostname'] = socket.getfqdn() # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. + # options which we need to update into the dictionary retrieved. default_values = conf.get_config_defaults(**https.kwargs, recursive=True) if 'api' not in https or 'graphql' not in https['api']: del default_values['api'] # merge CLI and default dictionary https = config_dict_merge(default_values, https) + + # some settings affecting nginx will require a restart: + # for example, a reload will not suffice when binding the listen address + # after nginx has started and dropped privileges; add flag here + diff = get_config_diff(conf) + children_changed = diff.node_changed_children(base) + https['nginx_restart_required'] = bool(set(children_changed) != set(['api'])) + return https def verify(https): @@ -98,18 +107,24 @@ def verify(https): Warning('No certificate specified, using build-in self-signed certificates. '\ 'Do not use them in a production environment!') - # Check if server port is already in use by a different appliaction + # Check if server port is already in use by a different application listen_address = ['0.0.0.0'] port = int(https['port']) if 'listen_address' in https: listen_address = https['listen_address'] - for address in listen_address: - if not check_port_availability(address, port, 'tcp') and not is_listen_port_bind_service(port, 'nginx'): - raise ConfigError(f'TCP port "{port}" is used by another service!') - verify_vrf(https) + vrf = https.get('vrf', None) + for address in listen_address: + if (not check_port_availability(address, port, 'tcp', vrf=vrf) + and not is_listen_port_bind_service(port, 'nginx')): + vrf_error_msg = '' + if vrf: + vrf_error_msg = f' in vrf "{vrf}"' + raise ConfigError(f'TCP port "{port}"{vrf_error_msg} is already ' \ + 'used by another service!') + # Verify API server settings, if present if 'api' in https: keys = dict_search('api.keys.id', https) @@ -208,7 +223,10 @@ def apply(https): elif is_systemd_service_active(http_api_service_name): call(f'systemctl stop {http_api_service_name}') - call(f'systemctl reload-or-restart {https_service_name}') + if https['nginx_restart_required']: + call(f'systemctl restart {https_service_name}') + else: + call(f'systemctl reload-or-restart {https_service_name}') if __name__ == '__main__': try: diff --git a/src/conf_mode/service_ids_ddos-protection.py b/src/conf_mode/service_ids_ddos-protection.py deleted file mode 100755 index 276a71fcb..000000000 --- a/src/conf_mode/service_ids_ddos-protection.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2018-2023 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 os - -from sys import exit - -from vyos.config import Config -from vyos.template import render -from vyos.utils.process import call -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -config_file = r'/run/fastnetmon/fastnetmon.conf' -networks_list = r'/run/fastnetmon/networks_list' -excluded_networks_list = r'/run/fastnetmon/excluded_networks_list' -attack_dir = '/var/log/fastnetmon_attacks' - -def get_config(config=None): - if config: - conf = config - else: - conf = Config() - base = ['service', 'ids', 'ddos-protection'] - if not conf.exists(base): - return None - - fastnetmon = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - with_recursive_defaults=True) - - return fastnetmon - -def verify(fastnetmon): - if not fastnetmon: - return None - - if 'mode' not in fastnetmon: - raise ConfigError('Specify operating mode!') - - if fastnetmon.get('mode') == 'mirror' and 'listen_interface' not in fastnetmon: - raise ConfigError("Incorrect settings for 'mode mirror': must specify interface(s) for traffic mirroring") - - if fastnetmon.get('mode') == 'sflow' and 'listen_address' not in fastnetmon.get('sflow', {}): - raise ConfigError("Incorrect settings for 'mode sflow': must specify sFlow 'listen-address'") - - if 'alert_script' in fastnetmon: - if os.path.isfile(fastnetmon['alert_script']): - # Check script permissions - if not os.access(fastnetmon['alert_script'], os.X_OK): - raise ConfigError('Script "{alert_script}" is not executable!'.format(fastnetmon['alert_script'])) - else: - raise ConfigError('File "{alert_script}" does not exists!'.format(fastnetmon)) - -def generate(fastnetmon): - if not fastnetmon: - for file in [config_file, networks_list]: - if os.path.isfile(file): - os.unlink(file) - - return None - - # Create dir for log attack details - if not os.path.exists(attack_dir): - os.mkdir(attack_dir) - - render(config_file, 'ids/fastnetmon.j2', fastnetmon) - render(networks_list, 'ids/fastnetmon_networks_list.j2', fastnetmon) - render(excluded_networks_list, 'ids/fastnetmon_excluded_networks_list.j2', fastnetmon) - return None - -def apply(fastnetmon): - systemd_service = 'fastnetmon.service' - if not fastnetmon: - # Stop fastnetmon service if removed - call(f'systemctl stop {systemd_service}') - else: - call(f'systemctl reload-or-restart {systemd_service}') - - return None - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/service_ipoe-server.py b/src/conf_mode/service_ipoe-server.py index a14d4b5b6..360254828 100755 --- a/src/conf_mode/service_ipoe-server.py +++ b/src/conf_mode/service_ipoe-server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -29,6 +29,7 @@ from vyos.accel_ppp_util import verify_accel_ppp_name_servers from vyos.accel_ppp_util import verify_accel_ppp_wins_servers from vyos.accel_ppp_util import verify_accel_ppp_ip_pool from vyos.accel_ppp_util import verify_accel_ppp_authentication +from vyos.vpp.utils import cli_ifaces_list from vyos import ConfigError from vyos import airbag @@ -58,6 +59,9 @@ def get_config(config=None): ) ipoe['server_type'] = 'ipoe' + + ipoe['vpp_ifaces'] = cli_ifaces_list(conf) + return ipoe @@ -69,6 +73,13 @@ def verify(ipoe): raise ConfigError('No IPoE interface configured') for interface, iface_config in ipoe['interface'].items(): + if ipoe.get('vpp_ifaces'): + base_interface = interface.split('.')[0] + if base_interface in ipoe['vpp_ifaces']: + raise ConfigError( + f'{interface} is a VPP interface and cannot be used for IPoE!' + ) + verify_interface_exists(ipoe, interface, warning_only=True) if 'client_subnet' in iface_config and 'vlan' in iface_config: raise ConfigError( @@ -88,6 +99,12 @@ def verify(ipoe): 'Can configure username with Lua script only for RADIUS authentication' ) + if dict_search('external_dhcp.dhcp_relay', iface_config): + if not dict_search('external_dhcp.giaddr', iface_config): + raise ConfigError( + f'"external-dhcp dhcp-relay" requires "giaddr" to be set for interface {interface}' + ) + verify_accel_ppp_authentication(ipoe, local_users=False) verify_accel_ppp_ip_pool(ipoe) verify_accel_ppp_name_servers(ipoe) diff --git a/src/conf_mode/service_lldp.py b/src/conf_mode/service_lldp.py index 04b1db880..50e9a49e6 100755 --- a/src/conf_mode/service_lldp.py +++ b/src/conf_mode/service_lldp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2017-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/service_mdns_repeater.py b/src/conf_mode/service_mdns_repeater.py index b0ece031c..a6d9d0224 100755 --- a/src/conf_mode/service_mdns_repeater.py +++ b/src/conf_mode/service_mdns_repeater.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2017-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -18,7 +18,9 @@ import os from json import loads from sys import exit -from netifaces import ifaddresses, AF_INET, AF_INET6 +from socket import AF_INET +from socket import AF_INET6 +from netifaces import ifaddresses # pylint: disable = no-name-in-module from vyos.config import Config from vyos.configverify import verify_interface_exists @@ -58,7 +60,7 @@ def verify(mdns): if not mdns or 'disable' in mdns: return None - # We need at least two interfaces to repeat mDNS advertisments + # We need at least two interfaces to repeat mDNS advertisements if 'interface' not in mdns or len(mdns['interface']) < 2: raise ConfigError('mDNS repeater requires at least 2 configured interfaces!') diff --git a/src/conf_mode/service_monitoring_network_event.py b/src/conf_mode/service_monitoring_network_event.py index 104e6ce23..8ae831b66 100644 --- a/src/conf_mode/service_monitoring_network_event.py +++ b/src/conf_mode/service_monitoring_network_event.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -43,7 +43,7 @@ def get_config(config=None): no_tag_node_value_mangle=True) # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. + # options which we need to update into the dictionary retrieved. monitoring = conf.merge_defaults(monitoring, recursive=True) return monitoring diff --git a/src/conf_mode/service_monitoring_prometheus.py b/src/conf_mode/service_monitoring_prometheus.py index 9a07d8593..b02f9f154 100755 --- a/src/conf_mode/service_monitoring_prometheus.py +++ b/src/conf_mode/service_monitoring_prometheus.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -23,6 +23,7 @@ from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf from vyos.template import render from vyos.utils.process import call +from vyos.utils.process import is_systemd_service_active from vyos import ConfigError from vyos import airbag @@ -48,9 +49,21 @@ def get_config(config=None): if not conf.exists(base): return None - monitoring = conf.get_config_dict( - base, key_mangling=('-', '_'), get_first_key=True, with_recursive_defaults=True - ) + monitoring = {} + exporters = { + 'node_exporter': base + ['node-exporter'], + 'frr_exporter': base + ['frr-exporter'], + 'blackbox_exporter': base + ['blackbox-exporter'], + } + + for exporter_name, exporter_base in exporters.items(): + if conf.exists(exporter_base): + monitoring[exporter_name] = conf.get_config_dict( + exporter_base, + key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True, + ) tmp = is_node_changed(conf, base + ['node-exporter', 'vrf']) if tmp: @@ -161,11 +174,14 @@ def apply(monitoring): # Reload systemd manager configuration call('systemctl daemon-reload') if not monitoring or 'node_exporter' not in monitoring: - call(f'systemctl stop {node_exporter_systemd_service}') + if is_systemd_service_active(node_exporter_systemd_service): + call(f'systemctl stop {node_exporter_systemd_service}') if not monitoring or 'frr_exporter' not in monitoring: - call(f'systemctl stop {frr_exporter_systemd_service}') + if is_systemd_service_active(frr_exporter_systemd_service): + call(f'systemctl stop {frr_exporter_systemd_service}') if not monitoring or 'blackbox_exporter' not in monitoring: - call(f'systemctl stop {blackbox_exporter_systemd_service}') + if is_systemd_service_active(blackbox_exporter_systemd_service): + call(f'systemctl stop {blackbox_exporter_systemd_service}') if not monitoring: return diff --git a/src/conf_mode/service_monitoring_telegraf.py b/src/conf_mode/service_monitoring_telegraf.py index db870aae5..2271f240f 100755 --- a/src/conf_mode/service_monitoring_telegraf.py +++ b/src/conf_mode/service_monitoring_telegraf.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -80,7 +80,7 @@ def get_config(config=None): if tmp: monitoring.update({'restart_required': {}}) # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. + # options which we need to update into the dictionary retrieved. monitoring = conf.merge_defaults(monitoring, recursive=True) monitoring['custom_scripts_dir'] = custom_scripts_dir @@ -198,7 +198,7 @@ def generate(monitoring): chown(cache_dir, 'telegraf', 'telegraf') - # Create custome scripts dir + # Create custom scripts dir if not os.path.exists(custom_scripts_dir): os.mkdir(custom_scripts_dir) diff --git a/src/conf_mode/service_monitoring_zabbix-agent.py b/src/conf_mode/service_monitoring_zabbix-agent.py index f17146a8d..5f3a8d4b5 100755 --- a/src/conf_mode/service_monitoring_zabbix-agent.py +++ b/src/conf_mode/service_monitoring_zabbix-agent.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/service_ndp-proxy.py b/src/conf_mode/service_ndp-proxy.py index 024ad79f2..672f98c71 100755 --- a/src/conf_mode/service_ndp-proxy.py +++ b/src/conf_mode/service_ndp-proxy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -48,18 +48,33 @@ def verify(ndpp): if not ndpp: return None - if 'interface' in ndpp: - for interface, interface_config in ndpp['interface'].items(): - verify_interface_exists(ndpp, interface) + if 'interface' not in ndpp: + return None + + for interface, interface_config in ndpp['interface'].items(): + if 'disable' in interface_config: + continue + + verify_interface_exists(ndpp, interface) + + if 'prefix' not in interface_config: + continue + + for prefix, prefix_config in interface_config['prefix'].items(): + if 'disable' in prefix_config: + continue + + mode = prefix_config.get('mode') + prefix_interface = prefix_config.get('interface') - if 'rule' in interface_config: - for rule, rule_config in interface_config['rule'].items(): - if rule_config['mode'] == 'interface' and 'interface' not in rule_config: - raise ConfigError(f'Rule "{rule}" uses interface mode but no interface defined!') + if mode == 'interface': + if not prefix_interface: + raise ConfigError(f'Prefix "{prefix}" uses interface mode but no interface defined!') + verify_interface_exists(ndpp, prefix_interface) + continue - if rule_config['mode'] != 'interface' and 'interface' in rule_config: - if interface_config['mode'] != 'interface' and 'interface' in interface_config: - raise ConfigError(f'Rule "{rule}" does not use interface mode, thus interface can not be defined!') + if prefix_interface: + raise ConfigError(f'Prefix "{prefix}" does not use interface mode, thus interface can not be defined!') return None diff --git a/src/conf_mode/service_ntp.py b/src/conf_mode/service_ntp.py index 32563aa0e..e734eeb76 100755 --- a/src/conf_mode/service_ntp.py +++ b/src/conf_mode/service_ntp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -21,6 +21,7 @@ from vyos.config import config_dict_merge from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf from vyos.configverify import verify_interface_exists +from vyos.netlink import timestamp from vyos.utils.process import call from vyos.utils.permission import chmod_750 from vyos.utils.network import get_interface_config @@ -51,7 +52,7 @@ def get_config(config=None): if tmp: ntp.update({'restart_required': {}}) # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. + # options which we need to update into the dictionary retrieved. default_values = conf.get_config_defaults(**ntp.kwargs, recursive=True) # Only defined PTP default port, if PTP feature is in use if 'ptp' not in ntp: @@ -65,9 +66,6 @@ def verify(ntp): if not ntp: return None - if 'server' not in ntp: - raise ConfigError('NTP server not configured') - verify_vrf(ntp) if 'interface' in ntp: @@ -105,6 +103,35 @@ def verify(ntp): else: break + if 'timestamp' in ntp: + for iface, iface_config in ntp['timestamp'].get('interface', {}).items(): + rx_filter = iface_config.get('receive_filter') + if iface != 'all': + verify_interface_exists(ntp, iface) + if rx_filter and rx_filter != 'none': + if iface == 'all': + any_supported = False + for real_iface in os.listdir('/sys/class/net'): + supported = timestamp.get_hw_timestamp_filters(real_iface) + if rx_filter in supported or 'all' in supported: + any_supported = True + break + if not any_supported: + raise ConfigError( + f'No interface supports hardware timestamp receive-filter "{rx_filter}"' + ) + else: + supported = timestamp.get_hw_timestamp_filters(iface) + if not supported: + raise ConfigError( + f'Interface "{iface}" does not support hardware timestamping' + ) + if rx_filter not in supported and 'all' not in supported: + raise ConfigError( + f'Interface "{iface}" does not support hardware timestamp ' + f'receive-filter "{rx_filter}", supported: {", ".join(sorted(supported))}' + ) + return None def generate(ntp): diff --git a/src/conf_mode/service_pppoe-server.py b/src/conf_mode/service_pppoe-server.py index ac697c509..ab9f8421c 100755 --- a/src/conf_mode/service_pppoe-server.py +++ b/src/conf_mode/service_pppoe-server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -20,10 +20,13 @@ from sys import exit from vyos.config import Config from vyos.configdict import get_accel_dict -from vyos.configdict import is_node_changed +from vyos.configdict import is_node_changed, node_changed +from vyos.configdiff import Diff from vyos.configverify import verify_interface_exists +from vyos.configverify import verify_virtual_interface_exists from vyos.template import render from vyos.utils.process import call +from vyos.utils.process import is_systemd_service_active from vyos.utils.dict import dict_search from vyos.accel_ppp_util import verify_accel_ppp_name_servers from vyos.accel_ppp_util import verify_accel_ppp_wins_servers @@ -32,12 +35,19 @@ from vyos.accel_ppp_util import verify_accel_ppp_ip_pool from vyos.accel_ppp_util import get_pools_in_order from vyos import ConfigError from vyos import airbag +from vyos.vpp.control_vpp import VPPControl airbag.enable() pppoe_conf = r'/run/accel-pppd/pppoe.conf' pppoe_chap_secrets = r'/run/accel-pppd/pppoe.chap-secrets' + +def base_ifname(ifname): + # Get the base interface name without VLAN + return ifname.split('.')[0] + + def convert_pado_delay(pado_delay): new_pado_delay = {'delays_without_sessions': [], 'delays_with_sessions': []} @@ -54,12 +64,41 @@ def get_config(config=None): else: conf = Config() base = ['service', 'pppoe-server'] - if not conf.exists(base): - return None # retrieve common dictionary keys pppoe = get_accel_dict(conf, base, pppoe_chap_secrets) + vpp_interface_base = ['vpp', 'settings', 'interface'] + vpp_bond_interface_base = ['interfaces', 'vpp', 'bonding'] + if conf.exists(vpp_interface_base) and is_systemd_service_active('vpp.service'): + vpp_ifaces = conf.get_config_dict( + vpp_interface_base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + vpp_bond_ifaces = conf.get_config_dict( + vpp_bond_interface_base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + vpp_ifaces = vpp_ifaces | vpp_bond_ifaces + pppoe['vpp_ifaces'] = vpp_ifaces + for interface in pppoe.get('interface', {}): + if base_ifname(interface) in vpp_ifaces: + pppoe['interface'][interface]['vpp_cp'] = {} + + pppoe['vpp_cp_interfaces'] = [ + ifname + for ifname, iface_conf in pppoe.get('interface', {}).items() + if 'vpp_cp' in iface_conf + ] + + if not conf.exists(base): + pppoe['remove'] = True + return pppoe + if dict_search('client_ip_pool', pppoe): # Multiple named pools require ordered values T5099 pppoe['ordered_named_pools'] = get_pools_in_order(dict_search('client_ip_pool', pppoe)) @@ -68,12 +107,30 @@ def get_config(config=None): pado_delay = dict_search('pado_delay', pppoe) pppoe['pado_delay'] = convert_pado_delay(pado_delay) - # reload-or-restart does not implemented in accel-ppp + # reload-or-restart is not implemented in accel-ppp # use this workaround until it will be implemented # https://phabricator.accel-ppp.org/T3 - conditions = [is_node_changed(conf, base + ['client-ip-pool']), - is_node_changed(conf, base + ['client-ipv6-pool']), - is_node_changed(conf, base + ['interface'])] + changed_vpp_ifaces = node_changed( + conf, vpp_interface_base, expand_nodes=Diff.DELETE | Diff.ADD + ) + changed_vpp_bond_ifaces = node_changed( + conf, + vpp_bond_interface_base, + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + all_changed_vpp_ifaces = set(changed_vpp_ifaces) | set(changed_vpp_bond_ifaces) + conditions = [ + is_node_changed(conf, base + ['client-ip-pool']), + is_node_changed(conf, base + ['client-ipv6-pool']), + is_node_changed(conf, base + ['interface']), + is_node_changed(conf, base + ['authentication', 'radius']), + is_node_changed(conf, base + ['authentication', 'mode']), + any( + base_ifname(iface) in all_changed_vpp_ifaces + for iface in pppoe.get('interface', {}) + ), + ] if any(conditions): pppoe.update({'restart_required': {}}) pppoe['server_type'] = 'pppoe' @@ -108,7 +165,7 @@ def verify_pado_delay(pppoe): ) def verify(pppoe): - if not pppoe: + if 'remove' in pppoe: return None verify_accel_ppp_authentication(pppoe) @@ -122,7 +179,20 @@ def verify(pppoe): # Check is interface exists in the system for interface, interface_config in pppoe['interface'].items(): - verify_interface_exists(pppoe, interface, warning_only=True) + # Interfaces integrated with the control-plane in VPP must exist in the system + warning_only = 'vpp_cp' not in interface_config + if '.' in interface: + verify_interface_func = verify_virtual_interface_exists + else: + verify_interface_func = verify_interface_exists + verify_interface_func(pppoe, interface, warning_only=warning_only) + + if 'vlan_mon' in interface_config and base_ifname(interface) in pppoe.get( + 'vpp_ifaces', {} + ): + raise ConfigError( + f'Cannot set option "vlan-mon": interface {interface} is integrated with control-plane!' + ) if 'vlan_mon' in interface_config and not 'vlan' in interface_config: raise ConfigError('Option "vlan-mon" requires "vlan" to be set!') @@ -131,7 +201,7 @@ def verify(pppoe): def generate(pppoe): - if not pppoe: + if 'remove' in pppoe: return None render(pppoe_conf, 'accel-ppp/pppoe.config.j2', pppoe) @@ -144,7 +214,15 @@ def generate(pppoe): def apply(pppoe): systemd_service = 'accel-ppp@pppoe.service' - if not pppoe: + + # delete pppoe mapping in vpp + if 'vpp_ifaces' in pppoe: + vpp = VPPControl() + mapping = vpp.get_pppoe_interface_mapping() + for dp_index, cp_index in mapping.items(): + vpp.delete_pppoe_mapping(dp_index, cp_index) + + if 'remove' in pppoe: call(f'systemctl stop {systemd_service}') for file in [pppoe_conf, pppoe_chap_secrets]: if os.path.exists(file): @@ -156,6 +234,14 @@ def apply(pppoe): else: call(f'systemctl reload-or-restart {systemd_service}') + # add pppoe mapping in vpp + vpp_cp_ifaces_add = pppoe.get('vpp_cp_interfaces', []) + if vpp_cp_ifaces_add: + vpp = VPPControl() + for iface in vpp_cp_ifaces_add: + vpp.map_pppoe_interface(iface) + + if __name__ == '__main__': try: c = get_config() diff --git a/src/conf_mode/service_router-advert.py b/src/conf_mode/service_router-advert.py index 88d767bb8..86b0f8dd5 100755 --- a/src/conf_mode/service_router-advert.py +++ b/src/conf_mode/service_router-advert.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -64,6 +64,9 @@ def verify(rtradv): if not (int(valid_lifetime) >= int(preferred_lifetime)): raise ConfigError('Prefix valid-lifetime must be greater then or equal to preferred-lifetime') + if 'base_interface' in prefix_config and prefix != '::/64': + raise ConfigError('Prefix base-interface can only be used together with the wildcard prefix "::/64"') + if 'nat64prefix' in interface_config: nat64_supported_lengths = [32, 40, 48, 56, 64, 96] for prefix, prefix_config in interface_config['nat64prefix'].items(): diff --git a/src/conf_mode/service_salt-minion.py b/src/conf_mode/service_salt-minion.py index edf74b0c0..f035485d3 100755 --- a/src/conf_mode/service_salt-minion.py +++ b/src/conf_mode/service_salt-minion.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -20,7 +20,7 @@ from socket import gethostname from sys import exit from urllib3 import PoolManager -from vyos.base import Warning +from vyos.base import Warning, DeprecationWarning from vyos.config import Config from vyos.configverify import verify_interface_exists from vyos.template import render @@ -52,7 +52,7 @@ def get_config(config=None): if 'id' not in salt: salt['id'] = gethostname() # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. + # options which we need to update into the dictionary retrieved. salt = conf.merge_defaults(salt, recursive=True) if not conf.exists(base): @@ -66,6 +66,8 @@ def verify(salt): if not salt: return None + DeprecationWarning('Salt minion integration is deprecated and will be removed in future VyOS versions') + if 'hash' in salt and salt['hash'] == 'sha1': Warning('Do not use sha1 hashing algorithm, upgrade to sha256 or later!') diff --git a/src/conf_mode/service_sla.py b/src/conf_mode/service_sla.py index ba5e645f0..0a7b81073 100755 --- a/src/conf_mode/service_sla.py +++ b/src/conf_mode/service_sla.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/service_snmp.py b/src/conf_mode/service_snmp.py index c64c59af7..00993d269 100755 --- a/src/conf_mode/service_snmp.py +++ b/src/conf_mode/service_snmp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -15,12 +15,14 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os +import contextlib from sys import exit from vyos.base import Warning from vyos.config import Config from vyos.configdict import dict_merge +from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf from vyos.defaults import systemd_services from vyos.snmpv3_hashgen import plaintext_to_md5 @@ -33,6 +35,8 @@ from vyos.utils.dict import dict_search from vyos.utils.network import is_addr_assigned from vyos.utils.process import call from vyos.utils.permission import chmod_755 +from vyos.utils.file import read_file +from vyos.utils.file import write_file from vyos.version import get_version_data from vyos import ConfigError from vyos import airbag @@ -46,6 +50,34 @@ default_script_dir = r'/config/user-data/' systemd_override = r'/run/systemd/system/snmpd.service.d/override.conf' systemd_service = systemd_services['snmpd'] + +def _get_engine_boots_and_bump(reset=False): + """ + Read, increment, persist, and return engineBoots counter. + Uses /config/snmp/engineboots.count as persistent storage + across reboots. + + If the 'reset' flag is set, zero will be stored without reading the current state. + """ + persist_count_file = '/config/snmp/engineboots.count' + + # Ensure directory exists atomically + os.makedirs(os.path.dirname(persist_count_file), exist_ok=True) + + count = 0 + + if not reset: + # Read current count, default to 0 on first run or corruption + raw = read_file(persist_count_file, defaultonfailure=str(count)) + with contextlib.suppress(ValueError): + count = int(raw) + + # Persist new value with increment immediately because snmpd will increase + # it automatically after restart the service + write_file(persist_count_file, str(count + 1)) + + return count + def get_config(config=None): if config: conf = config @@ -72,7 +104,7 @@ def get_config(config=None): snmp['vyos_user_pass'] = random(16) # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. + # options which we need to update into the dictionary retrieved. snmp = conf.merge_defaults(snmp, recursive=True) if 'listen_address' in snmp: @@ -98,6 +130,26 @@ def get_config(config=None): snmp['script_extensions']['extension_name'][key]['script'] = script_path + # Per RFC 3414 section 2.3 we should reset the engineID to 0: + # > Note, that whenever the local value of snmpEngineID is + # > changed (e.g., through discovery) or when secure communications are + # > first established with an authoritative SNMP engine, the local values + # > of snmpEngineBoots and latestReceivedEngineTime should be set to + # > zero. + # It requires to track changing of this value and reset engineBoots. + if is_node_changed(conf, base + ['v3', 'engineid']): + effective_config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + effective=True, + ) + current_engineid = dict_search('v3.engineid', snmp) + prev_engineid = dict_search('v3.engineid', effective_config) + if prev_engineid and current_engineid != prev_engineid: + snmp.update({'engineid_changed': {}}) + return snmp @@ -210,6 +262,12 @@ def generate(snmp): if 'deleted' in snmp: return None + # RFC 3414 compliant: + # - increments by 1 on every snmpd start + # - reset to zero if engineID was changed + with_reset = 'engineid_changed' in snmp + snmp['engine_boots'] = _get_engine_boots_and_bump(reset=with_reset) + if 'v3' in snmp: # SNMPv3 uses a hashed password. If CLI defines a plaintext password, # we will hash it in the background and replace the CLI node! diff --git a/src/conf_mode/service_ssh.py b/src/conf_mode/service_ssh.py index 759f87bb2..15d9d37ba 100755 --- a/src/conf_mode/service_ssh.py +++ b/src/conf_mode/service_ssh.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -16,21 +16,26 @@ import os +from copy import deepcopy from sys import exit from syslog import syslog from syslog import LOG_INFO +from vyos.base import DeprecationWarning from vyos.config import Config from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf -from vyos.configverify import verify_pki_ca_certificate +from vyos.configverify import verify_pki_openssh_key +from vyos.defaults import config_files +from vyos.defaults import SSH_DSA_DEPRECATION_WARNING from vyos.utils.process import call +from vyos.utils.process import rc_cmd from vyos.template import render from vyos import ConfigError from vyos import airbag -from vyos.pki import find_chain -from vyos.pki import encode_certificate -from vyos.pki import load_certificate +from vyos.pki import encode_public_key +from vyos.pki import load_openssh_public_key +from vyos.utils.dict import dict_search_recursive from vyos.utils.file import write_file airbag.enable() @@ -44,8 +49,14 @@ key_rsa = '/etc/ssh/ssh_host_rsa_key' key_dsa = '/etc/ssh/ssh_host_dsa_key' key_ed25519 = '/etc/ssh/ssh_host_ed25519_key' -trusted_user_ca_key = '/etc/ssh/trusted_user_ca_key' +trusted_user_ca = config_files['sshd_user_ca'] +login_motd_dsa_warning = r'/run/motd.d/91-vyos-ssh-dsa-deprecation-warning' + +# As of OpenSSH 9.8p1 in Debian trixie, DSA keys are no longer supported +deprecated_algos = ['ssh-dss', 'ssh-dss-cert-v01@openssh.com'] +SSH_DSA_DEPRECATION_WARNING: str = f'{SSH_DSA_DEPRECATION_WARNING} '\ +'The following hostkey-algorithms are in use:' def get_config(config=None): if config: @@ -55,27 +66,38 @@ def get_config(config=None): base = ['service', 'ssh'] if not conf.exists(base): return None - - ssh = conf.get_config_dict( - base, key_mangling=('-', '_'), get_first_key=True, with_pki=True - ) + ssh = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, with_pki=True) tmp = is_node_changed(conf, base + ['vrf']) if tmp: ssh.update({'restart_required': {}}) # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. + # options which we need to update into the dictionary retrieved. ssh = conf.merge_defaults(ssh, recursive=True) - # pass config file path - used in override template - ssh['config_file'] = config_file - # Ignore default XML values if config doesn't exists # Delete key from dict if not conf.exists(base + ['dynamic-protection']): del ssh['dynamic_protection'] + # See if any user has specified a list of principal names that are accepted + # for certificate authentication. + tmp = conf.get_config_dict(['system', 'login', 'user'], + key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True) + + for value, _ in dict_search_recursive(tmp, 'principal'): + # Only enable principal handling if SSH trusted-user-ca is set + if 'trusted_user_ca' in ssh: + ssh['has_principals'] = {} + # We do only need to execute this code path once as we need to know + # if any one of the local users has a principal set or not - this + # accounts for the entire system. + break + return ssh @@ -86,15 +108,12 @@ def verify(ssh): if 'rekey' in ssh and 'data' not in ssh['rekey']: raise ConfigError('Rekey data is required!') - if 'trusted_user_ca_key' in ssh: - if 'ca_certificate' not in ssh['trusted_user_ca_key']: - raise ConfigError('CA certificate is required for TrustedUserCAKey') + if 'trusted_user_ca' in ssh: + verify_pki_openssh_key(ssh, ssh['trusted_user_ca']) - ca_key_name = ssh['trusted_user_ca_key']['ca_certificate'] - verify_pki_ca_certificate(ssh, ca_key_name) - pki_ca_cert = ssh['pki']['ca'][ca_key_name] - if 'certificate' not in pki_ca_cert or not pki_ca_cert['certificate']: - raise ConfigError(f"CA certificate '{ca_key_name}' is not valid or missing") + if 'hostkey_algorithm' in ssh: + tmp = [algo for algo in ssh['hostkey_algorithm'] if algo in deprecated_algos] + if tmp: DeprecationWarning(f'{SSH_DSA_DEPRECATION_WARNING} {", ".join(tmp)}') verify_vrf(ssh) return None @@ -108,7 +127,7 @@ def generate(ssh): return None # This usually happens only once on a fresh system, SSH keys need to be - # freshly generted, one per every system! + # freshly generated, one per every system! if not os.path.isfile(key_rsa): syslog(LOG_INFO, 'SSH RSA host key not found, generating new key!') call(f'ssh-keygen -q -N "" -t rsa -f {key_rsa}') @@ -119,26 +138,27 @@ def generate(ssh): syslog(LOG_INFO, 'SSH ed25519 host key not found, generating new key!') call(f'ssh-keygen -q -N "" -t ed25519 -f {key_ed25519}') - if 'trusted_user_ca_key' in ssh: - ca_key_name = ssh['trusted_user_ca_key']['ca_certificate'] - pki_ca_cert = ssh['pki']['ca'][ca_key_name] - - loaded_ca_cert = load_certificate(pki_ca_cert['certificate']) - loaded_ca_certs = { - load_certificate(c['certificate']) - for c in ssh['pki']['ca'].values() - if 'certificate' in c - } - - ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs) - write_file( - trusted_user_ca_key, '\n'.join(encode_certificate(c) for c in ca_full_chain) - ) - elif os.path.exists(trusted_user_ca_key): - os.unlink(trusted_user_ca_key) + if 'trusted_user_ca' in ssh: + key_name = ssh['trusted_user_ca'] + openssh_cert = ssh['pki']['openssh'][key_name] + loaded_ca_cert = load_openssh_public_key(openssh_cert['public']['key'], + openssh_cert['public']['type']) + tmp = encode_public_key(loaded_ca_cert, encoding='OpenSSH', + key_format='OpenSSH') + write_file(trusted_user_ca, tmp, trailing_newline=True) + else: + if os.path.exists(trusted_user_ca): + os.unlink(trusted_user_ca) render(config_file, 'ssh/sshd_config.j2', ssh) + # Generate MOTD informing the user(s) for possible deprecated SSH hostkey-algorithm + tmp = deepcopy(ssh) + tmp['ssh_dsa_deprecation_warning'] = f'DEPRECATION WARNING: {SSH_DSA_DEPRECATION_WARNING}' + tmp['deprecated_algos'] = deprecated_algos + render(login_motd_dsa_warning, 'ssh/motd_ssh_dsa_warning.j2', tmp, + permission=0o644, user='root', group='root') + if 'dynamic_protection' in ssh: render(sshguard_config_file, 'ssh/sshguard_config.j2', ssh) render(sshguard_whitelist, 'ssh/sshguard_whitelist.j2', ssh) @@ -154,6 +174,11 @@ def apply(ssh): call(f'systemctl stop {systemd_service_sshguard}') return None + # Verify generated sshd configuration is correct + rc, out = rc_cmd(f'/usr/sbin/sshd -t -f {config_file}') + if rc: + raise ConfigError(f'Unexpected error with SSH configuration! {out}') + if 'dynamic_protection' not in ssh: call(f'systemctl stop {systemd_service_sshguard}') else: diff --git a/src/conf_mode/service_stunnel.py b/src/conf_mode/service_stunnel.py index 8ec762548..5ea5b88b4 100644 --- a/src/conf_mode/service_stunnel.py +++ b/src/conf_mode/service_stunnel.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -19,7 +19,7 @@ from shutil import rmtree from sys import exit -from netifaces import AF_INET +from socket import AF_INET from psutil import net_if_addrs from vyos.config import Config diff --git a/src/conf_mode/service_suricata.py b/src/conf_mode/service_suricata.py index 1ce170145..728c5607e 100755 --- a/src/conf_mode/service_suricata.py +++ b/src/conf_mode/service_suricata.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/service_tftp-server.py b/src/conf_mode/service_tftp-server.py index 5b7303c40..dc5ec5674 100755 --- a/src/conf_mode/service_tftp-server.py +++ b/src/conf_mode/service_tftp-server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/service_webproxy.py b/src/conf_mode/service_webproxy.py index 12ae4135e..eb45f8fcb 100755 --- a/src/conf_mode/service_webproxy.py +++ b/src/conf_mode/service_webproxy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -123,7 +123,7 @@ def get_config(config=None): proxy = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) # We have gathered the dict representation of the CLI, but there are default - # options which we need to update into the dictionary retrived. + # options which we need to update into the dictionary retrieved. default_values = conf.get_config_defaults(**proxy.kwargs, recursive=True) diff --git a/src/conf_mode/system_acceleration.py b/src/conf_mode/system_acceleration.py index d2cf44ff0..3e7a06465 100755 --- a/src/conf_mode/system_acceleration.py +++ b/src/conf_mode/system_acceleration.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -70,11 +70,12 @@ def verify(qat): # PCI id | Chipset # 19e2 -> C3xx # 37c8 -> C62x + # 37c9 -> C62xvf # 0435 -> DH895 # 6f54 -> D15xx # 18ee -> QAT_200XX data = re.findall( - '(8086:19e2)|(8086:37c8)|(8086:0435)|(8086:6f54)|(8086:18ee)', output) + '(8086:19e2)|(8086:37c[8-9])|(8086:0435)|(8086:6f54)|(8086:18ee)', output) # If QAT devices found if not data: raise ConfigError('No QAT acceleration device found') diff --git a/src/conf_mode/system_config-management.py b/src/conf_mode/system_config-management.py index a3ce66512..81a48ea50 100755 --- a/src/conf_mode/system_config-management.py +++ b/src/conf_mode/system_config-management.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -19,9 +19,10 @@ import sys from vyos import ConfigError from vyos.config import Config +from vyos.configverify import verify_vrf from vyos.config_mgmt import ConfigMgmt -from vyos.config_mgmt import commit_post_hook_dir, commit_hooks - +from vyos.config_mgmt import commit_post_hook_dir +from vyos.config_mgmt import commit_hooks def get_config(config=None): if config: @@ -34,10 +35,8 @@ def get_config(config=None): return None mgmt = ConfigMgmt(config=conf) - return mgmt - def verify(mgmt): if mgmt is None: return @@ -47,16 +46,16 @@ def verify(mgmt): if confirm.get('action', '') == 'reload' and 'commit_revisions' not in d: raise ConfigError('commit-confirm reload requires non-zero commit-revisions') - return + if 'commit_archive' in d: + verify_vrf(d['commit_archive']) + return def generate(mgmt): if mgmt is None: return - mgmt.initialize_revision() - def apply(mgmt): if mgmt is None: return diff --git a/src/conf_mode/system_conntrack.py b/src/conf_mode/system_conntrack.py index f25ed8d10..e6710223a 100755 --- a/src/conf_mode/system_conntrack.py +++ b/src/conf_mode/system_conntrack.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -32,7 +32,6 @@ from vyos import ConfigError from vyos import airbag airbag.enable() -conntrack_config = r'/etc/modprobe.d/vyatta_nf_conntrack.conf' sysctl_file = r'/run/sysctl/10-vyos-conntrack.conf' nftables_ct_file = r'/run/nftables-ct.conf' vyos_conntrack_logger_config = r'/run/vyos-conntrack-logger.conf' @@ -169,7 +168,7 @@ def verify(conntrack): if not group_obj: Warning(f'{error_group} "{group_name}" has no members!') - Warning(f'It is prefered to define {inet} conntrack ignore rules in <firewall {inet} prerouting raw> section') + Warning(f'It is preferred to define {inet} conntrack ignore rules in <firewall {inet} prerouting raw> section') if dict_search_args(conntrack, 'timeout', 'custom', inet, 'rule') != None: for rule, rule_config in conntrack['timeout']['custom'][inet]['rule'].items(): @@ -204,7 +203,6 @@ def generate(conntrack): elif path[0] == 'ipv6': conntrack['ipv6_firewall_action'] = 'accept' - render(conntrack_config, 'conntrack/vyos_nf_conntrack.conf.j2', conntrack) render(sysctl_file, 'conntrack/sysctl.conf.j2', conntrack) render(nftables_ct_file, 'conntrack/nftables-ct.j2', conntrack) diff --git a/src/conf_mode/system_console.py b/src/conf_mode/system_console.py index b380e0521..51c95fcac 100755 --- a/src/conf_mode/system_console.py +++ b/src/conf_mode/system_console.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -17,9 +17,11 @@ import os from pathlib import Path +from vyos.base import Warning from vyos.config import Config from vyos.utils.process import call from vyos.utils.serial import restart_login_consoles +from vyos.utils.serial import is_tty from vyos.system import grub_util from vyos.template import render from vyos import ConfigError @@ -55,7 +57,8 @@ def verify(console): if not console or 'device' not in console: return None - for device in console['device']: + kernel_consoles: list = [] + for device, device_config in console['device'].items(): if device.startswith('usb'): # It is much easiert to work with the native ttyUSBn name when using # getty, but that name may change across reboots - depending on the @@ -65,7 +68,16 @@ def verify(console): # If the device name still starts with usbXXX no matching tty was found # and it can not be used as a serial interface if not os.path.isdir(by_bus_dir) or not os.path.exists(by_bus_device): - raise ConfigError(f'Device {device} does not support beeing used as tty') + raise ConfigError(f'Device "{device}" does not support being used as tty') + if not is_tty(device, warning=True): + Warning(f'Device "{device}" used for console is not a TTY!') + if 'kernel' in device_config: + if not (device.startswith('ttyS') or device.startswith('ttyAMA')): + raise ConfigError(f'Device "{device}" unsupported for Kernel boot console') + kernel_consoles.append(device) + + if len(kernel_consoles) > 1: + raise ConfigError('Only one device can be used as Kernel output console!') return None @@ -77,7 +89,10 @@ def generate(console): if 'serial-getty' in basename: os.unlink(os.path.join(root, basename)) + # Define a default console on a tty framebuffer + default_tty_console = ('tty', '0', '') if not console or 'device' not in console: + grub_util.update_serial_console(*default_tty_console) return None # replace keys in the config for ttyUSB items to use them in `apply()` later @@ -95,9 +110,12 @@ def generate(console): console['device'][device_updated] = console['device'][device] del console['device'][device] else: - raise ConfigError(f'Device {device} does not support beeing used as tty') + raise ConfigError(f'Device {device} does not support being used as tty') for device, device_config in console['device'].items(): + # Do not render getty configuration if specified device is not a TTY. + if not is_tty(device): + continue config_file = base_dir + f'/serial-getty@{device}.service' Path(f'{base_dir}/getty.target.wants').mkdir(exist_ok=True) getty_wants_symlink = base_dir + f'/getty.target.wants/serial-getty@{device}.service' @@ -105,26 +123,22 @@ def generate(console): render(config_file, 'getty/serial-getty.service.j2', device_config) os.symlink(config_file, getty_wants_symlink) - # GRUB - # For existing serial line change speed (if necessary) - # Only applys to ttyS0 - if 'ttyS0' not in console['device']: - return None - - speed = console['device']['ttyS0']['speed'] - grub_util.update_console_speed(speed) + if 'kernel' in device_config: + # get console type ("ttyS" or "ttyAMA") from device (e.g. "ttyS0") + console_type = device.rstrip('0123456789') + console_num = device[len(console_type):] + default_tty_console = (console_type, console_num, device_config['speed']) + grub_util.update_serial_console(*default_tty_console) return None def apply(console): # Reset screen blanking call('/usr/bin/setterm -blank 0 -powersave off -powerdown 0 -term linux </dev/tty1 >/dev/tty1 2>&1') - # 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. + # 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: diff --git a/src/conf_mode/system_flow-accounting.py b/src/conf_mode/system_flow-accounting.py index 925c4a562..3318ad465 100755 --- a/src/conf_mode/system_flow-accounting.py +++ b/src/conf_mode/system_flow-accounting.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -17,6 +17,7 @@ import os import re +from ipaddress import ip_interface from sys import exit from vyos.config import Config @@ -24,119 +25,19 @@ from vyos.config import config_dict_merge from vyos.configverify import verify_vrf from vyos.configverify import verify_interface_exists from vyos.template import render -from vyos.utils.process import call -from vyos.utils.process import cmd -from vyos.utils.process import run +from vyos.utils.file import read_file from vyos.utils.network import is_addr_assigned from vyos import ConfigError from vyos import airbag +from vyos import ipt_netflow airbag.enable() -uacctd_conf_path = '/run/pmacct/uacctd.conf' -systemd_service = 'uacctd.service' -systemd_override = f'/run/systemd/system/{systemd_service}.d/override.conf' -nftables_nflog_table = 'raw' -nftables_nflog_chain = 'VYOS_PREROUTING_HOOK' -egress_nftables_nflog_table = 'inet mangle' -egress_nftables_nflog_chain = 'FORWARD' - -# get nftables rule dict for chain in table -def _nftables_get_nflog(chain, table): - # define list with rules - rules = [] - - # prepare regex for parsing rules - rule_pattern = '[io]ifname "(?P<interface>[\w\.\*\-]+)".*handle (?P<handle>[\d]+)' - rule_re = re.compile(rule_pattern) - - # run nftables, save output and split it by lines - nftables_command = f'nft -a list chain {table} {chain}' - tmp = cmd(nftables_command, message='Failed to get flows list') - # parse each line and add information to list - for current_rule in tmp.splitlines(): - if 'FLOW_ACCOUNTING_RULE' not in current_rule: - continue - current_rule_parsed = rule_re.search(current_rule) - if current_rule_parsed: - groups = current_rule_parsed.groupdict() - rules.append({ 'interface': groups["interface"], 'table': table, 'handle': groups["handle"] }) - - # return list with rules - return rules - -def _nftables_config(configured_ifaces, direction, length=None): - # define list of nftables commands to modify settings - nftable_commands = [] - nftables_chain = nftables_nflog_chain - nftables_table = nftables_nflog_table - - if direction == "egress": - nftables_chain = egress_nftables_nflog_chain - nftables_table = egress_nftables_nflog_table - - # prepare extended list with configured interfaces - configured_ifaces_extended = [] - for iface in configured_ifaces: - configured_ifaces_extended.append({ 'iface': iface }) - - # get currently configured interfaces with nftables rules - active_nflog_rules = _nftables_get_nflog(nftables_chain, nftables_table) - - # compare current active list with configured one and delete excessive interfaces, add missed - active_nflog_ifaces = [] - for rule in active_nflog_rules: - interface = rule['interface'] - if interface not in configured_ifaces: - table = rule['table'] - handle = rule['handle'] - nftable_commands.append(f'nft delete rule {table} {nftables_chain} handle {handle}') - else: - active_nflog_ifaces.append({ - 'iface': interface, - }) - - # do not create new rules for already configured interfaces - for iface in active_nflog_ifaces: - if iface in active_nflog_ifaces and iface in configured_ifaces_extended: - configured_ifaces_extended.remove(iface) - - # create missed rules - for iface_extended in configured_ifaces_extended: - iface = iface_extended['iface'] - iface_prefix = "o" if direction == "egress" else "i" - rule_definition = f'{iface_prefix}ifname "{iface}" counter log group 2 snaplen {length} queue-threshold 100 comment "FLOW_ACCOUNTING_RULE"' - nftable_commands.append(f'nft insert rule {nftables_table} {nftables_chain} {rule_definition}') - # Also add IPv6 ingres logging - if nftables_table == nftables_nflog_table: - nftable_commands.append(f'nft insert rule ip6 {nftables_table} {nftables_chain} {rule_definition}') - - # change nftables - for command in nftable_commands: - cmd(command, raising=ConfigError) - - -def _nftables_trigger_setup(operation: str) -> None: - """Add a dummy rule to unlock the main pmacct loop with a packet-trigger - - Args: - operation (str): 'add' or 'delete' a trigger - """ - # check if a chain exists - table_exists = False - if run('nft -snj list table ip pmacct') == 0: - table_exists = True - - if operation == 'delete' and table_exists: - nft_cmd: str = 'nft delete table ip pmacct' - cmd(nft_cmd, raising=ConfigError) - if operation == 'add' and not table_exists: - nft_cmds: list[str] = [ - 'nft add table ip pmacct', - 'nft add chain ip pmacct pmacct_out { type filter hook output priority raw - 50 \\; policy accept \\; }', - 'nft add rule ip pmacct pmacct_out oif lo ip daddr 127.0.254.0 counter log group 2 snaplen 1 queue-threshold 0 comment NFLOG_TRIGGER' - ] - for nft_cmd in nft_cmds: - cmd(nft_cmd, raising=ConfigError) +ipt_netflow_conf_path = '/etc/modprobe.d/ipt_NETFLOW.conf' + +# Variable to store between generate and apply +# whether module configuration was changed +# and module reload is needed +need_reload = True def get_config(config=None): @@ -166,104 +67,129 @@ def get_config(config=None): return flow_accounting + def verify(flow_config): if not flow_config: return None - # check if collector is enabled - if 'netflow' not in flow_config and 'disable_imt' in flow_config: - raise ConfigError('You need to configure NetFlow, ' \ - 'or not set "disable-imt" for flow-accounting!') - # Check if at least one interface is configured - if 'interface' not in flow_config: + if 'netflow' not in flow_config or 'interface' not in flow_config['netflow']: raise ConfigError('Flow accounting requires at least one interface to ' \ 'be configured!') # check that all configured interfaces exists in the system - for interface in flow_config['interface']: + for interface in flow_config['netflow']['interface']: verify_interface_exists(flow_config, interface, warning_only=True) + # check if at least one NetFlow collector is configured + if 'server' not in flow_config['netflow']: + raise ConfigError('You need to configure at least one NetFlow server!') verify_vrf(flow_config) - # check NetFlow configuration - if 'netflow' in flow_config: - # check if vrf is defined for netflow - netflow_vrf = None - if 'vrf' in flow_config: - netflow_vrf = flow_config['vrf'] - - # check if at least one NetFlow collector is configured if NetFlow configuration is presented - if 'server' not in flow_config['netflow']: - raise ConfigError('You need to configure at least one NetFlow server!') - - # Check if configured netflow source-address exist in the system - if 'source_address' in flow_config['netflow']: - if not is_addr_assigned(flow_config['netflow']['source_address'], netflow_vrf): - tmp = flow_config['netflow']['source_address'] - raise ConfigError(f'Configured "netflow source-address {tmp}" does not exist on the system!') - - # Check if engine-id compatible with selected protocol version - if 'engine_id' in flow_config['netflow']: - v5_filter = '^(\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5]):(\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])$' - v9v10_filter = '^(\d|[1-9]\d{1,8}|[1-3]\d{9}|4[01]\d{8}|42[0-8]\d{7}|429[0-3]\d{6}|4294[0-8]\d{5}|42949[0-5]\d{4}|429496[0-6]\d{3}|4294967[01]\d{2}|42949672[0-8]\d|429496729[0-5])$' - engine_id = flow_config['netflow']['engine_id'] - version = flow_config['netflow']['version'] - - if flow_config['netflow']['version'] == '5': - regex_filter = re.compile(v5_filter) - if not regex_filter.search(engine_id): - raise ConfigError(f'You cannot use NetFlow engine-id "{engine_id}" '\ - f'together with NetFlow protocol version "{version}"!') - else: - regex_filter = re.compile(v9v10_filter) - if not regex_filter.search(flow_config['netflow']['engine_id']): - raise ConfigError(f'Can not use NetFlow engine-id "{engine_id}" together '\ - f'with NetFlow protocol version "{version}"!') + # check if vrf is defined for netflow + netflow_vrf = None + if 'vrf' in flow_config: + netflow_vrf = flow_config['vrf'] + + # Check if configured netflow server source-address exist in the system + # Check if configured netflow server source-address matches protocol of server + # Check if configured netflow server source-interface exists + for server, data in flow_config['netflow']['server'].items(): + if 'source_address' in data and 'source_interface' in data: + raise ConfigError( + f'Configured "netflow server {server}" cannot have both "source-address" and "source-interface" fields' + ) + + if 'source_address' in data: + if not is_addr_assigned(data['source_address'], netflow_vrf): + raise ConfigError( + f'Configured "netflow server {server} source-address {data["source_address"]}" does not exist on the system!' + ) + if ( + ip_interface(server).version + != ip_interface(data['source_address']).version + ): + raise ConfigError( + f'Configured "netflow server {server} source-address {data["source_address"]}" protocol doesn\'t match server protocol' + ) + + if 'source_interface' in data: + verify_interface_exists( + flow_config, data['source_interface'], warning_only=True + ) + + # Check if engine-id compatible with selected protocol version + if 'engine_id' in flow_config['netflow']: + v5_filter = '^(\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5]):(\d|[1-9]\d|1\d{2}|2[0-4]\d|25[0-5])$' + v9v10_filter = '^(\d|[1-9]\d{1,8}|[1-3]\d{9}|4[01]\d{8}|42[0-8]\d{7}|429[0-3]\d{6}|4294[0-8]\d{5}|42949[0-5]\d{4}|429496[0-6]\d{3}|4294967[01]\d{2}|42949672[0-8]\d|429496729[0-5])$' + engine_id = flow_config['netflow']['engine_id'] + version = flow_config['netflow']['version'] + + if flow_config['netflow']['version'] == '5': + regex_filter = re.compile(v5_filter) + if not regex_filter.search(engine_id): + raise ConfigError( + f'You cannot use NetFlow engine-id "{engine_id}" ' + f'together with NetFlow protocol version "{version}"!' + ) + else: + regex_filter = re.compile(v9v10_filter) + if not regex_filter.search(flow_config['netflow']['engine_id']): + raise ConfigError( + f'Can not use NetFlow engine-id "{engine_id}" together ' + f'with NetFlow protocol version "{version}"!' + ) # return True if all checks were passed return True + def generate(flow_config): if not flow_config: + if os.path.exists(ipt_netflow_conf_path): + os.unlink(ipt_netflow_conf_path) return None - render(uacctd_conf_path, 'pmacct/uacctd.conf.j2', flow_config) - render(systemd_override, 'pmacct/override.conf.j2', flow_config) - # Reload systemd manager configuration - call('systemctl daemon-reload') + prev_config = read_file(ipt_netflow_conf_path, defaultonfailure='') -def apply(flow_config): - # Check if flow-accounting was removed and define command - if not flow_config: - _nftables_config([], 'ingress') - _nftables_config([], 'egress') + render(ipt_netflow_conf_path, 'ipt-netflow/ipt_NETFLOW.conf.j2', flow_config) + + new_config = read_file(ipt_netflow_conf_path, defaultonfailure='') - # Stop flow-accounting daemon and remove configuration file - call(f'systemctl stop {systemd_service}') - if os.path.exists(uacctd_conf_path): - os.unlink(uacctd_conf_path) + global need_reload + need_reload = prev_config != new_config - # must be done after systemctl - _nftables_trigger_setup('delete') +def apply(flow_config): + # When reloading module we need to first remove + # all iptables usage of ipt_NETFLOW + # When flow_config is disabled everything should be cleaned-up too + if need_reload or not flow_config: + ipt_netflow.stop() + + if not flow_config: + if os.path.exists(ipt_netflow_conf_path): + os.unlink(ipt_netflow_conf_path) return - # Start/reload flow-accounting daemon - call(f'systemctl restart {systemd_service}') + ingress_interfaces = [] + egress_interfaces = [] - # configure nftables rules for defined interfaces - if 'interface' in flow_config: - _nftables_config(flow_config['interface'], 'ingress', flow_config['packet_length']) + # configure iptables for defined interfaces + if 'interface' in flow_config['netflow']: + ingress_interfaces = flow_config['netflow']['interface'] # configure egress the same way if configured otherwise remove it if 'enable_egress' in flow_config: - _nftables_config(flow_config['interface'], 'egress', flow_config['packet_length']) - else: - _nftables_config([], 'egress') + egress_interfaces = ingress_interfaces - # add a trigger for signal processing - _nftables_trigger_setup('add') + enable_ipv6 = flow_config['netflow']['version'] != '5' + if need_reload: + ipt_netflow.start(ingress_interfaces, egress_interfaces, ipv6=enable_ipv6) + else: + ipt_netflow.set_watched_iptables_interfaces( + ingress_interfaces, egress_interfaces, ipv6=enable_ipv6 + ) if __name__ == '__main__': diff --git a/src/conf_mode/system_frr.py b/src/conf_mode/system_frr.py index d9ac543d0..5365ac294 100755 --- a/src/conf_mode/system_frr.py +++ b/src/conf_mode/system_frr.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -19,12 +19,15 @@ from sys import exit from vyos import ConfigError from vyos.base import Warning from vyos.config import Config +from vyos.frrender import FRRender +from vyos.frrender import get_frrender_dict from vyos.logger import syslog from vyos.template import render_to_string from vyos.utils.boot import boot_configuration_complete from vyos.utils.file import read_file from vyos.utils.file import write_file from vyos.utils.process import call +from vyos.utils.process import is_systemd_service_running from vyos import airbag airbag.enable() @@ -42,7 +45,8 @@ def get_config(config=None): frr_config = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, with_recursive_defaults=True) - + # get FRR configuration + frr_config['frr_dict'] = get_frrender_dict(conf) return frr_config def verify(frr_config): @@ -60,7 +64,17 @@ def generate(frr_config): write_file(config_file, daemons_config_new) frr_config['config_file_changed'] = True + # profile could be automatically generated by frr in frr.conf + # and needs to be updated as it is taking precedence + if 'frr_dict' in frr_config and not is_systemd_service_running('vyos-configd.service'): + FRRender().generate(frr_config['frr_dict']) + return None + def apply(frr_config): + # applying the profile configuration if necessary + if 'frr_dict' in frr_config and not is_systemd_service_running('vyos-configd.service'): + FRRender().apply() + # display warning to user if boot_configuration_complete() and frr_config.get('config_file_changed'): # Since FRR restart is not safe thing, better to give diff --git a/src/conf_mode/system_host-name.py b/src/conf_mode/system_host-name.py index fef034d1c..5a9265eba 100755 --- a/src/conf_mode/system_host-name.py +++ b/src/conf_mode/system_host-name.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -119,7 +119,7 @@ def verify(hosts): raise ConfigError(f'Invalid alias "{a}" in static-host-mapping "{host}"') for interface, interface_config in hosts['nameservers_dhcp_interfaces'].items(): - # Warnin user if interface does not have DHCP or DHCPv6 configured + # Warning user if interface does not have DHCP or DHCPv6 configured if not set(interface_config).intersection(['dhcp', 'dhcpv6']): Warning(f'"{interface}" is not a DHCP interface but uses DHCP name-server option!') @@ -175,7 +175,7 @@ def apply(config): # Restart services that use the hostname if hostname_new != hostname_old: - tmp = systemd_services['rsyslog'] + tmp = systemd_services['syslog'] call(f'systemctl restart {tmp}') # If SNMP is running, restart it too diff --git a/src/conf_mode/system_ip.py b/src/conf_mode/system_ip.py index 7f3796168..6aff982d7 100755 --- a/src/conf_mode/system_ip.py +++ b/src/conf_mode/system_ip.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -53,6 +53,11 @@ def verify(config_dict): for protocol, protocol_options in opt['protocol'].items(): if 'route_map' in protocol_options: verify_route_map(protocol_options['route_map'], opt) + + if dict_search('import_table', opt): + for table_num, import_config in opt['import_table'].items(): + if dict_search('route_map', import_config): + verify_route_map(import_config['route_map'], opt) return def generate(config_dict): @@ -70,20 +75,20 @@ def apply(config_dict): # table_size has a default value - thus the key always exists size = int(dict_search('arp.table_size', opt)) # Amount upon reaching which the records begin to be cleared immediately - sysctl_write('net.ipv4.neigh.default.gc_thresh3', size) + sysctl_write(['net', 'ipv4', 'neigh', 'default', 'gc_thresh3'], size) # Amount after which the records begin to be cleaned after 5 seconds - sysctl_write('net.ipv4.neigh.default.gc_thresh2', size // 2) + sysctl_write(['net', 'ipv4', 'neigh', 'default', 'gc_thresh2'], size // 2) # Minimum number of stored records is indicated which is not cleared - sysctl_write('net.ipv4.neigh.default.gc_thresh1', size // 8) + sysctl_write(['net', 'ipv4', 'neigh', 'default', 'gc_thresh1'], size // 8) # configure multipath tmp = dict_search('multipath.ignore_unreachable_nexthops', opt) value = '1' if (tmp != None) else '0' - sysctl_write('net.ipv4.fib_multipath_use_neigh', value) + sysctl_write(['net', 'ipv4', 'fib_multipath_use_neigh'], value) tmp = dict_search('multipath.layer4_hashing', opt) value = '1' if (tmp != None) else '0' - sysctl_write('net.ipv4.fib_multipath_hash_policy', value) + sysctl_write(['net', 'ipv4', 'fib_multipath_hash_policy'], value) # configure TCP options (defaults as of Linux 6.4) tmp = dict_search('tcp.mss.probing', opt) @@ -96,15 +101,15 @@ def apply(config_dict): else: # Shouldn't happen raise ValueError("TCP MSS probing is neither 'on-icmp-black-hole' nor 'force'!") - sysctl_write('net.ipv4.tcp_mtu_probing', value) + sysctl_write(['net', 'ipv4', 'tcp_mtu_probing'], value) tmp = dict_search('tcp.mss.base', opt) value = '1024' if (tmp is None) else tmp - sysctl_write('net.ipv4.tcp_base_mss', value) + sysctl_write(['net', 'ipv4', 'tcp_base_mss'], value) tmp = dict_search('tcp.mss.floor', opt) value = '48' if (tmp is None) else tmp - sysctl_write('net.ipv4.tcp_mtu_probe_floor', value) + sysctl_write(['net', 'ipv4', 'tcp_mtu_probe_floor'], value) # During startup of vyos-router that brings up FRR, the service is not yet # running when this script is called first. Skip this part and wait for initial diff --git a/src/conf_mode/system_ipv6.py b/src/conf_mode/system_ipv6.py index 309869b2f..80a7a386a 100755 --- a/src/conf_mode/system_ipv6.py +++ b/src/conf_mode/system_ipv6.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -70,17 +70,17 @@ def apply(config_dict): # configure multipath tmp = dict_search('multipath.layer4_hashing', opt) value = '1' if (tmp != None) else '0' - sysctl_write('net.ipv6.fib_multipath_hash_policy', value) + sysctl_write(['net', 'ipv6', 'fib_multipath_hash_policy'], value) # Apply ND threshold values # table_size has a default value - thus the key always exists size = int(dict_search('neighbor.table_size', opt)) # Amount upon reaching which the records begin to be cleared immediately - sysctl_write('net.ipv6.neigh.default.gc_thresh3', size) + sysctl_write(['net', 'ipv6', 'neigh', 'default', 'gc_thresh3'], size) # Amount after which the records begin to be cleaned after 5 seconds - sysctl_write('net.ipv6.neigh.default.gc_thresh2', size // 2) + sysctl_write(['net', 'ipv6', 'neigh', 'default', 'gc_thresh2'], size // 2) # Minimum number of stored records is indicated which is not cleared - sysctl_write('net.ipv6.neigh.default.gc_thresh1', size // 8) + sysctl_write(['net', 'ipv6', 'neigh', 'default', 'gc_thresh1'], size // 8) # configure IPv6 strict-dad tmp = dict_search('strict_dad', opt) diff --git a/src/conf_mode/system_lcd.py b/src/conf_mode/system_lcd.py index eb88224d1..1e97414dc 100755 --- a/src/conf_mode/system_lcd.py +++ b/src/conf_mode/system_lcd.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2020-2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/system_login.py b/src/conf_mode/system_login.py index 3fed6d273..537a87ae9 100755 --- a/src/conf_mode/system_login.py +++ b/src/conf_mode/system_login.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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,32 +14,39 @@ # 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 re import os -import warnings +import json +from copy import deepcopy from passlib.hosts import linux_context from psutil import users -from pwd import getpwall -from pwd import getpwnam -from pwd import getpwuid from sys import exit from time import sleep from vyos.base import Warning +from vyos.base import DeprecationWarning from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.configverify import verify_vrf +from vyos.defaults import SSH_DSA_DEPRECATION_WARNING from vyos.template import render from vyos.template import is_ipv4 -from vyos.utils.auth import ( - DEFAULT_PASSWORD, - EPasswdStrength, - evaluate_strength, - get_current_user -) +from vyos.utils.auth import DEFAULT_PASSWORD +from vyos.utils.auth import EPasswdStrength +from vyos.utils.auth import evaluate_strength +from vyos.utils.auth import get_current_user +from vyos.utils.auth import get_local_passwd_entries +from vyos.utils.auth import get_local_users +from vyos.utils.auth import get_user_home_dir +from vyos.utils.auth import MIN_USER_UID from vyos.utils.configfs import delete_cli_node from vyos.utils.configfs import add_cli_node from vyos.utils.dict import dict_search -from vyos.utils.file import chown +from vyos.utils.file import move_recursive +from vyos.utils.network import is_addr_assigned +from vyos.utils.permission import chown from vyos.utils.process import cmd from vyos.utils.process import call from vyos.utils.process import run @@ -54,38 +61,21 @@ radius_config_file = "/etc/pam_radius_auth.conf" tacacs_pam_config_file = "/etc/tacplus_servers" tacacs_nss_config_file = "/etc/tacplus_nss.conf" nss_config_file = "/etc/nsswitch.conf" +login_motd_dsa_warning = r'/run/motd.d/92-vyos-user-dsa-deprecation-warning' -# Minimum UID used when adding system users -MIN_USER_UID: int = 1000 -# Maximim UID used when adding system users -MAX_USER_UID: int = 59999 # LOGIN_TIMEOUT from /etc/loign.defs minus 10 sec MAX_RADIUS_TIMEOUT: int = 50 -# MAX_RADIUS_TIMEOUT divided by 2 sec (minimum recomended timeout) +# MAX_RADIUS_TIMEOUT divided by 2 sec (minimum recommended timeout) MAX_RADIUS_COUNT: int = 8 # Maximum number of supported TACACS servers MAX_TACACS_COUNT: int = 8 # Minimum USER id for TACACS users MIN_TACACS_UID = 900 -# List of local user accounts that must be preserved -SYSTEM_USER_SKIP_LIST: list = ['radius_user', 'radius_priv_user', 'tacacs0', 'tacacs1', - 'tacacs2', 'tacacs3', 'tacacs4', 'tacacs5', 'tacacs6', - 'tacacs7', 'tacacs8', 'tacacs9', 'tacacs10',' tacacs11', - 'tacacs12', 'tacacs13', 'tacacs14', 'tacacs15'] - -def get_local_users(min_uid=MIN_USER_UID, max_uid=MAX_USER_UID): - """Return list of dynamically allocated users (see Debian Policy Manual)""" - local_users = [] - for s_user in getpwall(): - if getpwnam(s_user.pw_name).pw_uid < min_uid: - continue - if getpwnam(s_user.pw_name).pw_uid > max_uid: - continue - if s_user.pw_name in SYSTEM_USER_SKIP_LIST: - continue - local_users.append(s_user.pw_name) - - return local_users + +# As of OpenSSH 9.8p1 in Debian trixie, DSA keys are no longer supported +SSH_DSA_DEPRECATION_WARNING: str = f'{SSH_DSA_DEPRECATION_WARNING} '\ +'The following users are using SSH-DSS keys for authentication.' + def get_shadow_password(username): with open('/etc/shadow') as f: @@ -133,6 +123,7 @@ def get_config(config=None): max_uid=MIN_TACACS_UID) + cli_users login['tacacs_min_uid'] = MIN_TACACS_UID + set_dependents('ssh', conf) return login def verify(login): @@ -145,7 +136,7 @@ def verify(login): raise ConfigError(f'Attempting to delete current user: {tmp}') if 'user' in login: - system_users = getpwall() + system_users = get_local_passwd_entries() for user, user_config in login['user'].items(): # Linux system users range up until UID 1000, we can not create a # VyOS CLI user which already exists as system user @@ -153,25 +144,46 @@ def verify(login): if s_user.pw_name == user and s_user.pw_uid < MIN_USER_UID: raise ConfigError(f'User "{user}" can not be created, conflict with local system account!') + plaintext_password = dict_search('authentication.plaintext_password', user_config) + if plaintext_password == DEFAULT_PASSWORD: + Warning(f'Default password used for user "{user}" - consider changing it') + # T6353: Check password for complexity using cracklib. # A user password should be sufficiently complex - plaintext_password = dict_search( - path='authentication.plaintext_password', - dict_object=user_config - ) or None - failed_check_status = [EPasswdStrength.WEAK, EPasswdStrength.ERROR] - if plaintext_password is not None: + if plaintext_password and len(plaintext_password) > 0: result = evaluate_strength(plaintext_password) if result['strength'] in failed_check_status: - Warning(result['error']) + tmp = result['error'] + Warning(f'User "{user}" - {tmp}') - for pubkey, pubkey_options in (dict_search('authentication.public_keys', user_config) or {}).items(): + for pubkey, pubkey_options in dict_search('authentication.public_keys', user_config, + default={}).items(): if 'type' not in pubkey_options: raise ConfigError(f'Missing type for public-key "{pubkey}"!') if 'key' not in pubkey_options: raise ConfigError(f'Missing key for public-key "{pubkey}"!') + if 'operator' in user_config: + op_groups = dict_search('operator.group', user_config) + if op_groups: + for og in op_groups: + if dict_search(f'operator_group.{og}', login) is None: + raise ConfigError(f'Operator group {og} does not exist') + else: + raise ConfigError(f'User {user} is configured as an operator but is not assigned to any operator groups') + + # Deprecation Warning for SSH DSS keys. + gen_header = True + if 'user' in login: + for user, user_config in login['user'].items(): + for pubkey, pubkey_options in (dict_search('authentication.public_keys', user_config) or {}).items(): + if 'type' in pubkey_options and pubkey_options['type'] == 'ssh-dss': + if gen_header: + gen_header = False + DeprecationWarning(SSH_DSA_DEPRECATION_WARNING) + print(f'User "{user}" with deprecated public-key named: {pubkey}') + if {'radius', 'tacacs'} <= set(login): raise ConfigError('Using both RADIUS and TACACS at the same time is not supported!') @@ -202,13 +214,17 @@ def verify(login): verify_vrf(login['radius']) - if 'source_address' in login['radius']: + if addresses := dict_search('radius.source_address', login): ipv4_count = 0 ipv6_count = 0 - for address in login['radius']['source_address']: + radius_vrf = dict_search('radius.vrf', login) + for address in addresses: if is_ipv4(address): ipv4_count += 1 else: ipv6_count += 1 + if not is_addr_assigned(address, vrf=radius_vrf): + Warning(f'Specified RADIUS source-address "{address}" is not assigned!') + if ipv4_count > 1: raise ConfigError('Only one IPv4 source-address can be set!') if ipv6_count > 1: @@ -225,13 +241,18 @@ def verify(login): fail = False if fail: - raise ConfigError('All RADIUS servers are disabled') + raise ConfigError('All TACACS servers are disabled') if tacacs_servers_count > MAX_TACACS_COUNT: raise ConfigError(f'Number of TACACS servers exceeded maximum of {MAX_TACACS_COUNT}!') verify_vrf(login['tacacs']) + if tmp := dict_search('tacacs.source_address', login): + tacacs_vrf = dict_search('tacacs.vrf', login) + if not is_addr_assigned(tmp, vrf=tacacs_vrf): + Warning(f'Specified TACACS source-address "{tmp}" is not assigned!') + if 'max_login_session' in login and 'timeout' not in login: raise ConfigError('"login timeout" must be configured!') @@ -307,6 +328,34 @@ def generate(login): if os.path.isfile(autologout_file): os.unlink(autologout_file) + # Operator groups and group membership + operator_config = {'users': {}, 'groups': {}} + if 'user' in login: + for user, user_config in login['user'].items(): + op_groups = dict_search('operator.group', user_config) + if op_groups: + operator_config['users'][user] = op_groups + + if 'operator_group' in login: + operator_config['groups'] = login['operator_group'] + + # Convert permissions strings to list + # so that the operational command runner doesn't have to + for g in operator_config['groups']: + policy = dict_search(f'command_policy.allow', operator_config['groups'][g]) + if policy is not None: + policy = list(map(lambda s: re.split(r'\s+', s), policy)) + operator_config['groups'][g]['command_policy']['allow'] = policy + + # Generate MOTD informing the user(s) for possible deprecated SSH keys + tmp = deepcopy(login) + tmp['ssh_dsa_deprecation_warning'] = f'DEPRECATION WARNING: {SSH_DSA_DEPRECATION_WARNING}' + render(login_motd_dsa_warning, 'login/motd_user_dsa_warning.j2', tmp, + permission=0o644, user='root', group='root') + + with open('/etc/vyos/operators.json', 'w') as of: + json.dump(operator_config, of) + return None @@ -332,32 +381,59 @@ def apply(login): tmp = dict_search('full_name', user_config) if tmp: command += f" --comment '{tmp}'" - tmp = dict_search('home_directory', user_config) - if tmp: command += f" --home '{tmp}'" - else: command += f" --home '/home/{user}'" + home_directory = dict_search('home_directory', user_config) + if not home_directory: + home_directory = f'/home/{user}' + command += f" --home '{home_directory}'" + + if 'operator' not in user_config: + command += f' --groups frr,frrvty,vyattacfg,sudo,adm,dip,disk,_kea,vpp' + + command += f' {user}' - command += f' --groups frr,frrvty,vyattacfg,sudo,adm,dip,disk,_kea {user}' try: cmd(command) # we should not rely on the value stored in user_config['home_directory'], as a # crazy user will choose username root or any other system user which will fail. # # XXX: Should we deny using root at all? - home_dir = getpwnam(user).pw_dir + home_dir = get_user_home_dir(user) # always re-render SSH keys with appropriate permissions render(f'{home_dir}/.ssh/authorized_keys', 'login/authorized_keys.j2', user_config, permission=0o600, formater=lambda _: _.replace(""", '"'), user=user, group='users') + + principals_file = f'{home_dir}/.ssh/authorized_principals' + if dict_search('authentication.principal', user_config): + render(principals_file, 'login/authorized_principals.j2', + user_config, permission=0o600, + formater=lambda _: _.replace(""", '"'), + user=user, group='users') + else: + if os.path.exists(principals_file): + os.unlink(principals_file) + except Exception as e: raise ConfigError(f'Adding user "{user}" raised exception: "{e}"') + # After invoking 'useradd' for each user, if /var/.users_backups/{user} exists, restore the + # backed up files to the newly created home directory. This reinstates the user's + # SSH environment and avoids loss of access or trust relationships due to the user + # creation process, which does not copy such custom files by default. + # + # More details: https://github.com/vyos/vyos-1x/pull/4678#pullrequestreview-3169648265 + backup_directory = f"/var/.users_backups/{user}" + if command.startswith('useradd') and os.path.exists(backup_directory): + move_recursive(backup_directory, home_dir) + chown(home_dir, user=user, group='users', recursive=True) + # T5875: ensure UID is properly set on home directory if user is re-added # the home directory will always exist, as it's created above by --create-home, # retrieve current owner of home directory and adjust on demand dir_owner = None try: - dir_owner = getpwuid(os.stat(home_dir).st_uid).pw_name + dir_owner = get_local_passwd_entries(os.stat(home_dir).st_uid).pw_name except: pass @@ -365,14 +441,15 @@ def apply(login): chown(home_dir, user=user, recursive=True) # Generate 2FA/MFA One-Time-Pad configuration + google_auth_file = f'{home_dir}/.google_authenticator' if dict_search('authentication.otp.key', user_config): enable_otp = True - render(f'{home_dir}/.google_authenticator', 'login/pam_otp_ga.conf.j2', + render(google_auth_file, 'login/pam_otp_ga.conf.j2', user_config, permission=0o400, user=user, group='users') else: # delete configuration as it's not enabled for the user - if os.path.exists(f'{home_dir}/.google_authenticator'): - os.remove(f'{home_dir}/.google_authenticator') + if os.path.exists(google_auth_file): + os.unlink(google_auth_file) # Lock/Unlock local user account lock_unlock = '--unlock' @@ -386,6 +463,22 @@ def apply(login): # Disable user to prevent re-login call(f'usermod -s /sbin/nologin {user}') + home_dir = get_user_home_dir(user) + # Remove SSH authorized keys file + authorized_keys_file = f'{home_dir}/.ssh/authorized_keys' + if os.path.exists(authorized_keys_file): + os.unlink(authorized_keys_file) + + # Remove SSH authorized principals file + principals_file = f'{home_dir}/.ssh/authorized_principals' + if os.path.exists(principals_file): + os.unlink(principals_file) + + # Remove Google Authenticator file + google_auth_file = f'{home_dir}/.google_authenticator' + if os.path.exists(google_auth_file): + os.unlink(google_auth_file) + # Logout user if he is still logged in if user in list(set([tmp[0] for tmp in users()])): print(f'{user} is logged in, forcing logout!') @@ -424,8 +517,9 @@ def apply(login): # Enable/disable Google authenticator cmd('pam-auth-update --disable mfa-google-authenticator') if enable_otp: - cmd(f'pam-auth-update --enable mfa-google-authenticator') + cmd('pam-auth-update --enable mfa-google-authenticator') + call_dependents() return None diff --git a/src/conf_mode/system_login_banner.py b/src/conf_mode/system_login_banner.py index cdd066649..9d5fba65f 100755 --- a/src/conf_mode/system_login_banner.py +++ b/src/conf_mode/system_login_banner.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/system_logs.py b/src/conf_mode/system_logs.py index 8ad4875d4..f31986034 100755 --- a/src/conf_mode/system_logs.py +++ b/src/conf_mode/system_logs.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -19,6 +19,8 @@ from sys import exit from vyos import ConfigError from vyos import airbag from vyos.config import Config +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents from vyos.logger import syslog from vyos.template import render from vyos.utils.dict import dict_search @@ -35,6 +37,8 @@ def get_config(config=None): else: conf = Config() + set_dependents('syslog', conf) + base = ['system', 'logs'] logs_config = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, @@ -64,8 +68,8 @@ def generate(logs_config): def apply(logs_config): - # No further actions needed - pass + # Ensure dependent config scripts (e.g., syslog) are re-run + call_dependents() if __name__ == '__main__': diff --git a/src/conf_mode/system_option.py b/src/conf_mode/system_option.py index 064a1aa91..c1a62e7e7 100755 --- a/src/conf_mode/system_option.py +++ b/src/conf_mode/system_option.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -15,29 +15,40 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os +import psutil +import re from sys import exit from time import sleep - +from vyos.base import Warning from vyos.config import Config from vyos.configverify import verify_source_interface from vyos.configverify import verify_interface_exists from vyos.system import grub_util from vyos.template import render +from vyos.utils.boot import boot_configuration_complete +from vyos.utils.convert import range_str_to_list +from vyos.utils.convert import list_to_range_str from vyos.utils.cpu import get_cpus +from vyos.utils.cpu import get_available_cpus from vyos.utils.dict import dict_search from vyos.utils.file import write_file +from vyos.utils.file import read_file from vyos.utils.kernel import check_kmod from vyos.utils.process import cmd from vyos.utils.process import is_systemd_service_running from vyos.utils.network import is_addr_assigned from vyos.utils.network import is_intf_addr_assigned +from vyos.utils.system import sysctl_write from vyos.configdep import set_dependents from vyos.configdep import call_dependents from vyos import ConfigError from vyos import airbag +from vyos.vpp.config_resource_checks import memory as mem_check +from vyos.vpp.config_resource_checks.resource_defaults import default_resource_map + airbag.enable() curlrc_config = r'/etc/curlrc' @@ -54,6 +65,123 @@ tuned_profiles = { 'virtual-host': 'virtual-host', } +MANAGED_PARAMS = { + 'hugepages1g': { + 'parse': r'hugepagesz=1[Gg]\s+hugepages=(?P<hugepages1g>\d+)', + 'clean': r'hugepagesz=1[Gg](?:\s+hugepages=\d+)?', + 'build': lambda v: f'hugepagesz=1G hugepages={v}', + 'type': int, + }, + 'hugepages2m': { + 'parse': r'hugepagesz=2[Mm]\s+hugepages=(?P<hugepages2m>\d+)', + 'clean': r'hugepagesz=2[Mm](?:\s+hugepages=\d+)?', + 'build': lambda v: f'hugepagesz=2M hugepages={v}', + 'type': int, + }, + 'default_hugepagesz': { + 'parse': r'default_hugepagesz=(?P<default_hugepagesz>\S+)', + 'clean': r'default_hugepagesz=\S+', + 'type': str, + }, + 'mitigations': { + 'parse': r'mitigations=(?P<mitigations>\S+)', + 'clean': r'mitigations=\S+', + 'type': str, + }, + 'intel_idle.max_cstate': { + 'parse': r'intel_idle\.max_cstate=(?P<intel_idle_max_cstate>\d+)', + 'clean': r'intel_idle\.max_cstate=\d+', + 'build': lambda v: f'intel_idle.max_cstate={v}', + 'type': int, + }, + 'processor.max_cstate': { + 'parse': r'processor\.max_cstate=(?P<processor_max_cstate>\d+)', + 'clean': r'processor\.max_cstate=\d+', + 'build': lambda v: f'processor.max_cstate={v}', + 'type': int, + }, + 'initcall_blacklist': { + 'parse': r'initcall_blacklist=(?P<initcall_blacklist>\S+)', + 'clean': r'initcall_blacklist=\S+', + 'type': str, + }, + 'amd_pstate': { + 'parse': r'amd_pstate=(?P<amd_pstate>\S+)', + 'clean': r'amd_pstate=\S+', + 'type': str, + }, + 'quiet': { + 'parse': r'(?P<quiet>\bquiet\b)', + 'clean': r'\bquiet\b', + 'type': bool, + }, + 'nosoftlockup': { + 'parse': r'(?P<nosoftlockup>\bnosoftlockup\b)', + 'clean': r'\bnosoftlockup\b', + 'type': bool, + }, + 'panic': { + 'parse': r'panic=(?P<panic>\d+)', + 'clean': r'panic=\d+', + 'type': int, + }, + 'mce': { + 'parse': r'mce=(?P<mce>\S+)', + 'clean': r'mce=\S+', + 'type': str, + }, + 'hpet': { + 'parse': r'hpet=(?P<hpet>\S+)', + 'clean': r'hpet=\S+', + 'type': str, + }, + 'nmi_watchdog': { + 'parse': r'nmi_watchdog=(?P<nmi_watchdog>\d+)', + 'clean': r'nmi_watchdog=\d+', + 'type': int, + }, + 'isolcpus': { + 'parse': r'isolcpus=(?P<isolcpus>\S+)', + 'clean': r'isolcpus=\S+', + 'type': str, + }, + 'nohz_full': { + 'parse': r'nohz_full=(?P<nohz_full>\S+)', + 'clean': r'nohz_full=\S+', + 'type': str, + }, + 'rcu_nocbs': { + 'parse': r'rcu_nocbs=(?P<rcu_nocbs>\S+)', + 'clean': r'rcu_nocbs=\S+', + 'type': str, + }, + 'numa_balancing': { + 'parse': r'numa_balancing=(?P<numa_balancing>\S+)', + 'clean': r'numa_balancing=\S+', + 'type': str, + }, +} + +# Compiled regex pattern for parsing command line options +_parse_cmdline_pattern = re.compile( + '|'.join(v['parse'] for v in MANAGED_PARAMS.values()) +) + + +def _get_total_hugepages_and_memory(config): + unit_map = {'M': 1 << 20, 'G': 1 << 30} + + total_pages = 0 + total_bytes = 0 + + hp_sizes = config.get('hugepage_size', {}) + for size_str, hp_config in hp_sizes.items(): + pages = int(hp_config.get('hugepage_count', 0)) + total_pages += pages + total_bytes += pages * int(size_str[:-1]) * unit_map[size_str[-1]] + + return total_pages, total_bytes + def get_config(config=None): if config: @@ -68,6 +196,7 @@ def get_config(config=None): if 'performance' in options: # Update IPv4/IPv6 and sysctl options after tuned applied it's settings set_dependents('ip_ipv6', conf) + set_dependents('firewall', conf) set_dependents('sysctl', conf) return options @@ -93,10 +222,10 @@ def verify(options): if 'source_address' in config: address = config['source_address'] if not is_addr_assigned(config['source_address']): - raise ConfigError('No interface with address "{address}" configured!') + raise ConfigError(f'No interface with address "{address}" configured!') if 'source_interface' in config: - # verify_source_interface reuires key 'ifname' + # verify_source_interface requires key 'ifname' config['ifname'] = config['source_interface'] verify_source_interface(config) if 'source_address' in config: @@ -108,12 +237,70 @@ def verify(options): ) if 'kernel' in options: - cpu_vendor = get_cpus()[0]['vendor_id'] + _cpu_info = get_cpus()[0] + cpu_vendor = _cpu_info.get('vendor_id', 'unknown') if 'amd_pstate_driver' in options['kernel'] and cpu_vendor != 'AuthenticAMD': raise ConfigError( f'AMD pstate driver cannot be used with "{cpu_vendor}" CPU!' ) + isolate_cpus = dict_search('kernel.cpu.isolate_cpus', options) + if isolate_cpus: + available_cores = sorted({int(cpu['cpu']) for cpu in get_available_cpus()}) + cpus_list = range_str_to_list(isolate_cpus) + reserved_cpus = default_resource_map.get('reserved_cpu_cores') + + cpus_available = len(available_cores) - reserved_cpus + if len(cpus_list) > cpus_available: + raise ConfigError( + f'Cannot isolate {len(cpus_list)} CPUs ({isolate_cpus}): ' + f'only {cpus_available} of {len(available_cores)} physical cores ' + f'are available ({reserved_cpus} reserved for the system)' + ) + + not_available = [cpu for cpu in cpus_list if cpu not in available_cores] + if not_available: + not_available_str = list_to_range_str(not_available) + available_str = list_to_range_str(available_cores) + raise ConfigError( + f'CPU(s) {not_available_str} do not exist on this system. ' + f'Available CPUs: {available_str}' + ) + + _, hp_memory_bytes = _get_total_hugepages_and_memory( + options['kernel'].get('memory', {}) + ) + if hp_memory_bytes: + memory = psutil.virtual_memory() + memory_total_bytes = memory.total + + # Exclude hugepage usage from system "used" memory + hp_memory_used = sum( + p['memory'] for p in mem_check.get_hugepages_info().values() + ) + memory_used_bytes = memory.used - hp_memory_used + + # TODO: need to calculate how much memory is consumed for other services, tmpfs etc. + # for now we should leave at least 4 GB for system usage and other processes + min_system_reserved_gd = 4 + memory_margin_gb = 1 + reserved_bytes = max( + min_system_reserved_gd * 1024**3, + memory_used_bytes + memory_margin_gb * 1024**3, + ) + + available_for_hp_bytes = memory_total_bytes - reserved_bytes + if available_for_hp_bytes < hp_memory_bytes: + # For the error message, convert to GB and round to 1 decimal + hp_memory_gb = round(hp_memory_bytes / 1024**3, 1) + available_for_hp_gb = max(0, round(available_for_hp_bytes / 1024**3, 1)) + reserved_gb = round(reserved_bytes / 1024**3, 1) + raise ConfigError( + f'Configured hugepages require {hp_memory_gb} GB of memory, but only ' + f'{available_for_hp_gb:.1f} GB is available ' + f'({reserved_gb} GB is reserved for system usage and services)' + ) + return None @@ -122,7 +309,14 @@ def generate(options): render(ssh_config, 'system/ssh_config.j2', options) render(usb_autosuspend, 'system/40_usb_autosuspend.j2', options) + # XXX: This code path and if statements must be kept in sync with the Kernel + # option handling in image_installer.py:get_cli_kernel_options(). This + # occurrence is used for having the appropriate options passed to GRUB + # when re-configuring options on the CLI. cmdline_options = [] + kernel_opts = options.get('kernel', {}) + k_cpu_opts = kernel_opts.get('cpu', {}) + k_memory_opts = kernel_opts.get('memory', {}) if 'kernel' in options: if 'disable_mitigations' in options['kernel']: cmdline_options.append('mitigations=off') @@ -133,12 +327,175 @@ def generate(options): cmdline_options.append( f'initcall_blacklist=acpi_cpufreq_init amd_pstate={mode}' ) - grub_util.update_kernel_cmdline_options(' '.join(cmdline_options)) + if 'quiet' in options['kernel']: + cmdline_options.append('quiet') + + # Early reboot on kernel panic via kernel cmdline + # Keep this in sync with image_installer.py:get_cli_kernel_options() + if 'reboot_on_panic' in options: + cmdline_options.append('panic=60') + + if 'disable_hpet' in kernel_opts: + cmdline_options.append('hpet=disable') + + if 'disable_mce' in kernel_opts: + cmdline_options.append('mce=off') + + if 'disable_softlockup' in kernel_opts: + cmdline_options.append('nosoftlockup') + + # CPU options + isol_cpus = k_cpu_opts.get('isolate_cpus') + if isol_cpus: + cmdline_options.append(f'isolcpus={isol_cpus}') + + nohz_full = k_cpu_opts.get('nohz_full') + if nohz_full: + cmdline_options.append(f'nohz_full={nohz_full}') + + rcu_nocbs = k_cpu_opts.get('rcu_no_cbs') + if rcu_nocbs: + cmdline_options.append(f'rcu_nocbs={rcu_nocbs}') + + if 'disable_nmi_watchdog' in k_cpu_opts: + cmdline_options.append('nmi_watchdog=0') + + # Memory options + if 'disable_numa_balancing' in k_memory_opts: + cmdline_options.append('numa_balancing=disable') + + default_hp_size = k_memory_opts.get('default_hugepage_size') + if default_hp_size: + cmdline_options.append(f'default_hugepagesz={default_hp_size}') + + hp_sizes = k_memory_opts.get('hugepage_size') + if hp_sizes: + for size, settings in hp_sizes.items(): + cmdline_options.append(f'hugepagesz={size}') + count = settings.get('hugepage_count') + if count: + cmdline_options.append(f'hugepages={count}') + + cmdline_options_str = ' '.join(cmdline_options) + + grub_util.update_kernel_cmdline_options(cmdline_options_str) + + options['cmdline_options'] = cmdline_options_str return None +def parse_cmdline(cmdline): + """ + Parse command line parameters into a dictionary of managed parameters. + + Args: + cmdline: The command line string (e.g., from /proc/cmdline) + + Returns: + Dictionary with parsed parameters + """ + # Produce a complete template of all managed parameters with + # consistent default values before scanning the actual kernel cmdline. + result = { + k: (False if v['type'] is bool else None) for k, v in MANAGED_PARAMS.items() + } + + # Mapping from regex group names to real parameter keys + group_to_key = { + 'intel_idle_max_cstate': 'intel_idle.max_cstate', + 'processor_max_cstate': 'processor.max_cstate', + } + + # Find all matches and populate result + for match in _parse_cmdline_pattern.finditer(cmdline): + for group_name, value in match.groupdict().items(): + key = group_to_key.get(group_name, group_name) + + # skip empty values and unknown parameters + if value is None or key not in MANAGED_PARAMS: + continue + + entry = MANAGED_PARAMS[key] + + if entry['type'] is bool: + result[key] = True + elif entry['type'] is int: + result[key] = int(value) + else: + result[key] = value + + return result + + +def generate_cmdline_for_kexec(options): + """ + Build an updated kernel cmdline string based on desired options and the + currently running /proc/cmdline. + + Returns: + tuple: (kexec_required, new_cmdline) + - kexec_required (bool): True if kernel options were added, removed or modified. + - new_cmdline (str): The updated kernel command line string. + """ + # Read current cmdline and parse it + current_cmdline = read_file('/proc/cmdline').strip() + current_parsed = parse_cmdline(current_cmdline) + + # Parse desired options from options['cmdline_options'] + desired_options = options.get('cmdline_options', '') + desired_parsed = parse_cmdline(desired_options) + + # Compare dicts to define if kexec is needed + kexec_required = current_parsed != desired_parsed + if not kexec_required: + return kexec_required, current_cmdline + + # Clean managed params and surrounding whitespaces + clean_patterns = [entry['clean'] for entry in MANAGED_PARAMS.values()] + combined_pattern = ( + r'(?:(?<=^)|(?<=\s))(?:' + '|'.join(clean_patterns) + r')(?=\s|$)' + ) + cleaned = re.sub(combined_pattern, ' ', current_cmdline) + cleaned = re.sub(r'\s+', ' ', cleaned).strip() + + # Build new cmdline + parts = [] + for key, entry in MANAGED_PARAMS.items(): + val = desired_parsed[key] + if val is None or val is False: + continue + + if 'build' in entry: + parts.append(entry['build'](val)) + elif entry['type'] is bool: + parts.append(key) + else: + parts.append(f'{key}={val}') + + rebuilt = ' '.join(parts) + + new_cmdline = (cleaned + ' ' + rebuilt).strip() if cleaned else rebuilt + + return kexec_required, new_cmdline + + def apply(options): + kexec_required, cmdline_new = generate_cmdline_for_kexec(options) + if kexec_required: + if not boot_configuration_complete() and os.getenv('VYOS_CONFIGD'): + cmd( + 'kexec -l /boot/vmlinuz --initrd=/boot/initrd.img ' + f'--command-line="{cmdline_new}" --kexec-file-syscall' + ) + os.sync() + cmd('systemctl kexec') + elif boot_configuration_complete(): + Warning( + 'Kernel configuration options have changed. ' + 'To apply these changes, you must save the configuration and reboot the system!' + ) + # System bootup beep beep_service = 'vyos-beep.service' if 'startup_beep' in options: @@ -216,6 +573,34 @@ def apply(options): else: write_file(kernel_dynamic_debug, f'module {module} -p') + if 'resource_limits' in options: + total_pages, total_bytes = _get_total_hugepages_and_memory( + options.get('kernel', {}).get('memory', {}) + ) + + # Minimum recommended system values + max_map_count_min = 65530 # ensures large workload compatibility + shmmax_min = 8589934592 # 8 GiB safe default for large allocations + + max_map_count_conf = options['resource_limits'].get('max_map_count', 'auto') + shmmax_conf = options['resource_limits'].get('shmmax', 'auto') + + parameters = { + 'vm.max_map_count': ( + max(total_pages * 2, max_map_count_min) + if max_map_count_conf == 'auto' + else int(max_map_count_conf) + ), + 'kernel.shmmax': ( + max(total_bytes, shmmax_min) + if shmmax_conf == 'auto' + else int(shmmax_conf) + ), + } + + for parameter, value in parameters.items(): + sysctl_write(parameter.split('.'), value) + if __name__ == '__main__': try: diff --git a/src/conf_mode/system_proxy.py b/src/conf_mode/system_proxy.py index 079c43e7e..3843ad527 100755 --- a/src/conf_mode/system_proxy.py +++ b/src/conf_mode/system_proxy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/system_sflow.py b/src/conf_mode/system_sflow.py index a22dac36f..d54801ecf 100755 --- a/src/conf_mode/system_sflow.py +++ b/src/conf_mode/system_sflow.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -19,12 +19,14 @@ import os from sys import exit from vyos.config import Config +from vyos.configdep import set_dependents, call_dependents from vyos.configverify import verify_vrf from vyos.template import render from vyos.utils.process import call from vyos.utils.network import is_addr_assigned from vyos import ConfigError from vyos import airbag + airbag.enable() hsflowd_conf_path = '/run/sflow/hsflowd.conf' @@ -38,17 +40,37 @@ def get_config(config=None): else: conf = Config() base = ['system', 'sflow'] + + vpp_sflow = conf.exists(['vpp', 'sflow']) + if not conf.exists(base): - return None + return { + 'remove': True, + 'vpp_sflow': vpp_sflow, + } - sflow = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - with_recursive_defaults=True) + sflow = conf.get_config_dict( + base, key_mangling=('-', '_'), get_first_key=True, with_recursive_defaults=True + ) + + sflow.update({'vpp_sflow': vpp_sflow}) + + if vpp_sflow: + set_dependents('vpp_sflow', conf) return sflow + def verify(sflow): - if not sflow: + # Check if "vpp" flag could be deleted from configuration + if sflow.get('vpp_sflow'): + if 'vpp' not in sflow or 'remove' in sflow: + raise ConfigError( + 'sFlow is still configured in VPP. ' + 'Please remove sFlow configuration from VPP before proceeding.' + ) + + if 'remove' in sflow: return None # Check if configured sflow agent-address exist in the system @@ -60,9 +82,9 @@ def verify(sflow): ) # Check if at least one interface is configured - if 'interface' not in sflow: - raise ConfigError( - 'sFlow requires at least one interface to be configured!') + # Skip this check if VPP is enabled + if 'interface' not in sflow and 'vpp' not in sflow: + raise ConfigError('sFlow requires at least one interface to be configured!') # Check if at least one server is configured if 'server' not in sflow: @@ -71,8 +93,9 @@ def verify(sflow): verify_vrf(sflow) return None + def generate(sflow): - if not sflow: + if 'remove' in sflow: return None render(hsflowd_conf_path, 'sflow/hsflowd.conf.j2', sflow) @@ -80,8 +103,9 @@ def generate(sflow): # Reload systemd manager configuration call('systemctl daemon-reload') + def apply(sflow): - if not sflow: + if 'remove' in sflow: # Stop flow-accounting daemon and remove configuration file call(f'systemctl stop {systemd_service}') if os.path.exists(hsflowd_conf_path): @@ -91,6 +115,9 @@ def apply(sflow): # Start/reload flow-accounting daemon call(f'systemctl restart {systemd_service}') + call_dependents() + + if __name__ == '__main__': try: config = get_config() diff --git a/src/conf_mode/system_sysctl.py b/src/conf_mode/system_sysctl.py index f6b02023d..8e018ec0b 100755 --- a/src/conf_mode/system_sysctl.py +++ b/src/conf_mode/system_sysctl.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/system_syslog.py b/src/conf_mode/system_syslog.py index 414bd4b6b..e762efd3b 100755 --- a/src/conf_mode/system_syslog.py +++ b/src/conf_mode/system_syslog.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -15,15 +15,22 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import os +import shutil from sys import exit from vyos.base import Warning from vyos.config import Config from vyos.configverify import verify_vrf +from vyos.configverify import verify_pki_certificate +from vyos.configverify import verify_pki_ca_certificate from vyos.defaults import systemd_services from vyos.utils.network import is_addr_assigned from vyos.utils.process import call +from vyos.utils.dict import dict_search +from vyos.utils.file import write_file +from vyos.pki import wrap_certificate +from vyos.pki import wrap_private_key from vyos.template import render from vyos.template import is_ipv4 from vyos.template import is_ipv6 @@ -31,11 +38,74 @@ from vyos import ConfigError from vyos import airbag airbag.enable() +cert_dir = '/etc/rsyslog.d/certs' rsyslog_conf = '/run/rsyslog/rsyslog.conf' -logrotate_conf = '/etc/logrotate.d/vyos-rsyslog' +logrotate_messages_conf = '/etc/logrotate.d/vyos-rsyslog' systemd_socket = 'syslog.socket' -systemd_service = systemd_services['rsyslog'] +systemd_service = systemd_services['syslog'] + + +def _cleanup_tls_certs(): + if os.path.exists(cert_dir): + shutil.rmtree(cert_dir, ignore_errors=True) + + +def _remote_has_tls(remote_options): + return 'tls' in remote_options + + +def _verify_tls_remote_options(remote, remote_options, syslog): + auth_mode = dict_search('tls.auth_mode', remote_options) + certificate = dict_search('tls.certificate', remote_options) + ca_certificate = dict_search('tls.ca_certificate', remote_options) + + if auth_mode != "anon" and not ca_certificate: + raise ConfigError( + f'Option "ca-certificate" is required for remote "{remote}" when TLS is enabled with auth-mode "{auth_mode}"!' + ) + + if certificate: + verify_pki_certificate(syslog, certificate, no_password_protected=True) + + if ca_certificate: + verify_pki_ca_certificate(syslog, ca_certificate) + + permitted_peers = dict_search('tls.permitted_peer', remote_options) + if not permitted_peers: + if auth_mode == "fingerprint": + raise ConfigError( + f'Auth mode "fingerprint" for remote "{remote}" requires "permitted-peer" to be configured!' + ) + elif auth_mode == "name": + raise ConfigError( + f'Auth mode "name" for remote "{remote}" requires "permitted-peer" to specify allowed subject names!' + ) + + +def _save_tls_certificates_for_remote(syslog, remote_options): + ca_certificate = remote_options['tls'].get('ca_certificate') + ca_cert_file_path = None + if ca_certificate: + ca_cert_file_path = os.path.join(cert_dir, f'{ca_certificate}.pem') + pki_ca = syslog['pki']['ca'][ca_certificate] + + ca_cert = wrap_certificate(pki_ca['certificate']) + write_file(ca_cert_file_path, ca_cert) + remote_options['tls']['ca_certificate_path'] = ca_cert_file_path + + cert_name = remote_options['tls'].get('certificate') + cert_file_path = cert_key_path = None + if cert_name: + cert_file_path = os.path.join(cert_dir, f'{cert_name}.pem') + cert_key_path = os.path.join(cert_dir, f'{cert_name}.key') + pki_cert = syslog['pki']['certificate'][cert_name] + + write_file(cert_file_path, wrap_certificate(pki_cert['certificate'])) + write_file(cert_key_path, wrap_private_key(pki_cert['private']['key'])) + + remote_options['tls']['certificate_path'] = cert_file_path + remote_options['tls']['certificate_key_path'] = cert_key_path def get_config(config=None): if config: @@ -46,10 +116,24 @@ def get_config(config=None): if not conf.exists(base): return None - syslog = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, no_tag_node_value_mangle=True) - - syslog.update({ 'logrotate' : logrotate_conf }) + syslog = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_pki=True, + ) + + syslog.update({ 'logrotate' : logrotate_messages_conf }) + + logs_config = conf.get_config_dict( + ['system', 'logs'], + key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True, + ) + max_size_mb = dict_search('logrotate.messages.max_size', logs_config) + syslog['logrotate_size_limit'] = int(max_size_mb) * 1024 * 1024 syslog = conf.merge_defaults(syslog, recursive=True) if syslog.from_defaults(['local']): @@ -63,6 +147,11 @@ def get_config(config=None): tmp = conf.return_value(['system', 'domain-name']) syslog['preserve_fqdn']['domain_name'] = tmp + # prune 'remote <remote> tls' if it was not set by user + for remote in syslog.get('remote', {}): + if syslog.from_defaults(['remote', remote, 'tls']): + del syslog['remote'][remote]['tls'] + return syslog def verify(syslog): @@ -97,17 +186,30 @@ def verify(syslog): raise ConfigError(f'Source-address "{source_address}" does not match '\ f'address-family of remote "{remote}"!') + if _remote_has_tls(remote_options): + _verify_tls_remote_options(remote, remote_options, syslog) + + if 'protocol' in remote_options and remote_options['protocol'] == 'udp': + raise ConfigError( + f'TLS is enabled for remote "{remote}", but protocol is set to UDP. TLS is only supported with protocol TCP!' + ) + + def generate(syslog): + _cleanup_tls_certs() + if not syslog: if os.path.exists(rsyslog_conf): os.unlink(rsyslog_conf) - if os.path.exists(logrotate_conf): - os.unlink(logrotate_conf) return None + if 'remote' in syslog: + for _, remote_options in syslog['remote'].items(): + if _remote_has_tls(remote_options): + _save_tls_certificates_for_remote(syslog, remote_options) + render(rsyslog_conf, 'rsyslog/rsyslog.conf.j2', syslog) - render(logrotate_conf, 'rsyslog/logrotate.j2', syslog) return None def apply(syslog): diff --git a/src/conf_mode/system_task-scheduler.py b/src/conf_mode/system_task-scheduler.py index 129be5d3c..c0253006a 100755 --- a/src/conf_mode/system_task-scheduler.py +++ b/src/conf_mode/system_task-scheduler.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2017 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/system_timezone.py b/src/conf_mode/system_timezone.py index 39770fdb4..54ffb88ee 100755 --- a/src/conf_mode/system_timezone.py +++ b/src/conf_mode/system_timezone.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/system_update-check.py b/src/conf_mode/system_update-check.py index 71ac13e51..6a07d93a6 100755 --- a/src/conf_mode/system_update-check.py +++ b/src/conf_mode/system_update-check.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -15,10 +15,12 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import json +import requests from pathlib import Path from sys import exit +from vyos.base import Warning from vyos.config import Config from vyos.utils.process import call from vyos import ConfigError @@ -54,6 +56,21 @@ def verify(config): if 'url' not in config: raise ConfigError('URL is required!') + url = config['url'] + + # Make sure that provided URL is available and responses a valid JSON + # otherwise print warning and display type of error (connection, timeout and etc.) + try: + response = requests.get(url, timeout=10) + response.raise_for_status() + response.json() + except requests.exceptions.RequestException as e: + error_type = type(e).__name__ + Warning( + '"system update-check url" has a valid URL but ' + f'unable to retrieve data from the server: {error_type}' + ) + def generate(config): # bail out early - looks like removal from running config diff --git a/src/conf_mode/system_watchdog.py b/src/conf_mode/system_watchdog.py new file mode 100755 index 000000000..8c050f333 --- /dev/null +++ b/src/conf_mode/system_watchdog.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 sys import exit +from pathlib import Path +from typing import Optional + +from vyos.config import Config +from vyos.base import Warning +from vyos.template import render +from vyos.utils.kernel import load_module +from vyos.utils.process import call, cmd +from vyos import ConfigError +from vyos import airbag + +airbag.enable() + +watchdog_config_dir = Path('/run/systemd/system.conf.d') +watchdog_config_file = watchdog_config_dir / 'watchdog.conf' +modules_load_directory = Path('/run/modules-load.d') +modules_load_file = modules_load_directory / 'watchdog.conf' +WATCHDOG_DEV = Path('/dev/watchdog0') +WATCHDOG_SYSFS = Path('/sys/class/watchdog/watchdog0') + + +def _get_watchdog_driver_module_name() -> Optional[str]: + """Return the kernel module name backing watchdog0, if discoverable.""" + + module_link = WATCHDOG_SYSFS / 'device/driver/module' + if not module_link.exists(): + return None + + try: + resolved = module_link.resolve() + except OSError: + return None + + # Expected to resolve to /sys/module/<module_name> + module_name = resolved.name.strip() + return module_name or None + + +def _read_sysfs_int(path: Path) -> Optional[int]: + try: + return int(path.read_text().strip()) + except (OSError, ValueError): + return None + + +def _get_watchdog_timeout_limits() -> tuple[int, int]: + """Return (min_timeout, max_timeout) from sysfs if available. + + If sysfs is unavailable (device not present/loaded yet) or zero, fall back to a + conservative common kernel max of 65535 seconds. + """ + + if not WATCHDOG_SYSFS.exists(): + return 1, 65535 + + min_timeout = _read_sysfs_int(WATCHDOG_SYSFS / 'min_timeout') + max_timeout = _read_sysfs_int(WATCHDOG_SYSFS / 'max_timeout') + + # Some drivers may not expose min/max. Fall back to sane defaults. + min_timeout = min_timeout if min_timeout and min_timeout > 0 else 1 + max_timeout = max_timeout if max_timeout and max_timeout > 0 else 65535 + + return min_timeout, max_timeout + + +def _verify_watchdog_module(module: str) -> None: + # Dry-run modprobe (-n) in quiet mode (-q) verifies availability without loading + if load_module(module, quiet=True, dry_run=True) != 0: + raise ConfigError( + f"Watchdog driver module '{module}' was not found or cannot be loaded" + ) + + # Ensure the module looks like a watchdog driver and not an arbitrary module. + # Use modinfo filename location as the heuristic. + filename = cmd(['modinfo', '-F', 'filename', module], raising=ConfigError) + filename_l = filename.strip().lower() + + # Accept modules located under drivers/watchdog, plus explicit exception for + # ipmi_watchdog which lives in drivers/char/ipmi. + is_watchdog_driver = '/watchdog/' in filename_l or filename_l.endswith( + '/ipmi_watchdog.ko' + ) + + if not is_watchdog_driver: + raise ConfigError( + f"Kernel module '{module}' does not look like a watchdog driver module (modinfo filename: {filename.strip()})" + ) + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['system', 'watchdog'] + + if not conf.exists(base): + return None + + watchdog = conf.get_config_dict( + base, key_mangling=('-', '_'), get_first_key=True, with_recursive_defaults=True + ) + + return watchdog + + +def verify(watchdog): + if watchdog is None: + return None + + module = watchdog.get('module') + device_exists = WATCHDOG_DEV.exists() + + # Require a usable watchdog: either device already present or a module provided + if not module and not device_exists: + raise ConfigError( + "No watchdog device found at /dev/watchdog0 and no module configured. " + "Use 'system watchdog module <name>' to load the required watchdog driver for your system." + ) + + # If a module is provided, ensure it exists and is a watchdog module + if module: + _verify_watchdog_module(module) + + # Validate runtime watchdog timeout against kernel driver limits if available. + # Shutdown/Reboot watchdog settings are systemd-level timers and are not + # constrained by the watchdog device driver's min/max. + if 'timeout' in watchdog: + try: + value = int(watchdog['timeout']) + except (TypeError, ValueError): + raise ConfigError("Invalid value for 'timeout'") + + min_timeout, max_timeout = _get_watchdog_timeout_limits() + if value < min_timeout: + raise ConfigError( + f"'timeout' must be >= {min_timeout} seconds (driver minimum)" + ) + if value > max_timeout: + raise ConfigError( + f"'timeout' must be <= {max_timeout} seconds (driver maximum)" + ) + + return None + + +def generate(watchdog): + # If watchdog node removed entirely, clean up everything + if watchdog is None: + watchdog_config_file.unlink(missing_ok=True) + modules_load_file.unlink(missing_ok=True) + return None + + # Persist kernel module autoload on boot if specified (even if not enabled) + module = watchdog.get('module') + if module: + try: + modules_load_directory.mkdir(parents=True, exist_ok=True) + modules_load_file.write_text(f"{module}\n") + except OSError as e: + Warning(f"Failed writing modules-load configuration: {e}") + else: + # If module option removed, drop persisted autoload file + modules_load_file.unlink(missing_ok=True) + + # Try to load kernel module if specified and /dev/watchdog0 is missing + if not WATCHDOG_DEV.exists(): + if module: + # Try to load the module using vyos call wrapper for logging/airbag integration + try: + rc = load_module(module, quiet=True, dry_run=False) + except OSError as e: + Warning( + f"Could not execute modprobe for watchdog module '{module}': {e}" + ) + else: + if rc != 0: + Warning( + f"Could not load watchdog module '{module}' (modprobe exit code {rc})" + ) + # Re-check for device + if not WATCHDOG_DEV.exists(): + Warning("/dev/watchdog0 not found. Systemd watchdog will not be enabled.") + watchdog_config_file.unlink(missing_ok=True) + return None + + # If a module was configured explicitly, warn if the actual driver module + # bound to watchdog0 differs from what the user configured. + if module and WATCHDOG_SYSFS.exists(): + actual_module = _get_watchdog_driver_module_name() + if actual_module and actual_module != module: + Warning( + f"Configured watchdog driver module '{module}' does not match watchdog0 driver module '{actual_module}'" + ) + + # Ensure the directory exists + watchdog_config_dir.mkdir(parents=True, exist_ok=True) + + # Pass through configured time values directly as seconds + render(str(watchdog_config_file), 'system/watchdog.conf.j2', watchdog) + + return None + + +def apply(watchdog): + # Reload systemd daemon to apply/unload the watchdog configuration + # The watchdog settings take immediate effect after systemd is reloaded + call('systemctl daemon-reload') + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/system_wireless.py b/src/conf_mode/system_wireless.py index e0ca0ab8e..2d377c50a 100644 --- a/src/conf_mode/system_wireless.py +++ b/src/conf_mode/system_wireless.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py index 71a503e61..f268f0861 100755 --- a/src/conf_mode/vpn_ipsec.py +++ b/src/conf_mode/vpn_ipsec.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -54,6 +54,7 @@ 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.base import Warning from vyos import airbag airbag.enable() @@ -64,6 +65,8 @@ swanctl_dir = '/etc/swanctl' charon_conf = '/etc/strongswan.d/charon.conf' charon_dhcp_conf = '/etc/strongswan.d/charon/dhcp.conf' charon_radius_conf = '/etc/strongswan.d/charon/eap-radius.conf' +charon_systemd_conf = '/etc/strongswan.d/charon-systemd.conf' +charon_logging_conf = '/etc/strongswan.d/charon-logging.conf' interface_conf = '/etc/strongswan.d/interfaces_use.conf' swanctl_conf = f'{swanctl_dir}/swanctl.conf' @@ -79,6 +82,57 @@ CRL_PATH = f'{swanctl_dir}/x509crl/' DHCP_HOOK_IFLIST = '/tmp/ipsec_dhcp_interfaces' + +def _cleanup_default_prefixes(ipsec: dict, default_values: dict): + """ + Remove default local/remote prefixes from tunnels + that use 'transport' mode ESP and do not have explicit prefix definitions + """ + site_to_site = dict_search_args(ipsec, 'site_to_site', 'peer') or {} + + for peer, peer_conf in site_to_site.items(): + tunnels = peer_conf.get('tunnel') or {} + default_esp_group = peer_conf.get('default_esp_group') + + for tunnel, tunnel_conf in tunnels.items(): + # Determine ESP group name - prefer specific over default + tunnel_esp_group = tunnel_conf.get('esp_group') + esp_group_name = tunnel_esp_group or default_esp_group + + # Get default values for the tunnel + tunnel_defaults = dict_search_args( + default_values, 'site_to_site', 'peer', peer, 'tunnel', tunnel + ) + + # Skip if no defaults found or ESP group defined + # Yes, this can happen because of user misconfiguration + if tunnel_defaults is None or esp_group_name is None: + continue + + # Fetch ESP group details + esp_group_mode = dict_search_args( + ipsec, 'esp_group', esp_group_name, 'mode' + ) + + # Only act if ESP group is in transport mode + if esp_group_mode == 'transport': + + # Look for local and remote prefixes + local_prefixes = dict_search_args(tunnel_conf, 'local', 'prefix') + remote_prefixes = dict_search_args(tunnel_conf, 'remote', 'prefix') + + # Safely remove missing prefixes from defaults + # if user has not defined them but they are in defaults + if not local_prefixes: + prefix = dict_search_args(tunnel_defaults, 'local', 'prefix') + if prefix is not None: + del tunnel_defaults['local']['prefix'] + + if not remote_prefixes: + prefix = dict_search_args(tunnel_defaults, 'remote', 'prefix') + if prefix is not None: + del tunnel_defaults['remote']['prefix'] + def get_config(config=None): if config: conf = config @@ -113,6 +167,9 @@ def get_config(config=None): if 'dead_peer_detection' not in ike: del default_values['ike_group'][name]['dead_peer_detection'] + # Clean up default prefixes for ESP transport-mode tunnels + _cleanup_default_prefixes(ipsec, default_values) + ipsec = config_dict_merge(default_values, ipsec) ipsec['dhcp_interfaces'] = set() @@ -134,7 +191,7 @@ 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 + # Collect the interface dicts for any referenced VTI interfaces in # case we need to bring the interface up ipsec['vti_interface_dicts'] = {} @@ -206,11 +263,29 @@ def verify(ipsec): if not ipsec or 'deleted' in ipsec: return + # T8136 PPK support; keep a list of PPK IDs + ppk_ids = [] + if 'authentication' in ipsec: if 'psk' in ipsec['authentication']: for psk, psk_config in ipsec['authentication']['psk'].items(): if 'id' not in psk_config or 'secret' not in psk_config: - raise ConfigError(f'Authentication psk "{psk}" missing "id" or "secret"') + raise ConfigError( + f'Authentication psk "{psk}" missing "id" or "secret"' + ) + # T8136 PPK Support; Check that PPK has an ID and secret defined, and ID is unique + if 'ppk' in ipsec['authentication']: + for ppk, ppk_config in ipsec['authentication']['ppk'].items(): + if 'id' not in ppk_config: + raise ConfigError(f'Authentication PPK "{ppk}" missing "id"') + if 'secret' not in ppk_config: + raise ConfigError(f'Authentication PPK "{ppk}" missing "secret"') + for ppk_id in ppk_config['id']: + if ppk_id in ppk_ids: + raise ConfigError( + f'Authentication PPK "{ppk}" has duplicate ID "{ppk_id}" from another PPK. IDs should be unique.' + ) + ppk_ids.append(ppk_id) if 'interface' in ipsec: tmp = re.compile(dynamic_interface_pattern) @@ -390,6 +465,25 @@ def verify(ipsec): elif 'pool' not in ipsec['remote_access'] or pool not in ipsec['remote_access']['pool']: raise ConfigError(f'Requested pool "{pool}" does not exist!') + # T8136 IPSEC PPK Support + # PPKs and Childless only works with IKEv2. Check that ike-group is v2 if either option is enabled. Check that PPK ID was actually defined in authentication. Recommend use of childless when using PPKs if not already configured. + if 'ppk' in ra_conf['authentication']: + ike = ra_conf['ike_group'] + if dict_search(f'ike_group.{ike}.key_exchange', ipsec) != 'ikev2': + raise ConfigError( + f'Incorrect configuration in IKE group "{ike}": post-quantum pre-shared keys require explicit IKEv2 usage.' + ) + if 'childless' not in ra_conf: + Warning( + 'It is recommended to use childless IKE SAs when using PPKs' + ) + if 'childless' in ra_conf: + ike = ra_conf['ike_group'] + if dict_search(f'ike_group.{ike}.key_exchange', ipsec) != 'ikev2': + raise ConfigError( + f'Incorrect configuration in IKE group "{ike}": childless IKE SAs can only be used with IKEv2.' + ) + if 'pool' in ipsec['remote_access']: pool_networks = [] for pool, pool_config in ipsec['remote_access']['pool'].items(): @@ -598,6 +692,41 @@ def verify(ipsec): f'for ESP proposal {proposal} on tunnel {tunnel} for site-to-site peer {peer} with VPP' ) + # T8136 IPSEC PPK Support + # PPKs and Childless only works with IKEv2. Check that ike-group is v2 if either option is enabled. Check that PPK ID was actually defined in authentication. Recommend use of childless when using PPKs if not already configured. + if 'ppk' in peer_conf['authentication']: + ike = peer_conf['ike_group'] + if dict_search(f'ike_group.{ike}.key_exchange', ipsec) != 'ikev2': + raise ConfigError( + f'Post-quantum preshared keys must be used with IKEv2! Please configure IKEv2 key-exchange in ike-group "{ike}".' + ) + if 'childless' not in peer_conf: + Warning( + 'It is recommended to use childless IKE SAs when using PPKs' + ) + if 'childless' in peer_conf: + ike = peer_conf['ike_group'] + if dict_search(f'ike_group.{ike}.key_exchange', ipsec) != 'ikev2': + raise ConfigError( + f'Childless IKE SAs be used with IKEv2! Please configure IKEv2 key-exchange in ike-group "{ike}".' + ) + + # Get the referenced IKE group config + ike_group_name = peer_conf.get('ike_group') + ike_group = ipsec['ike_group'].get(ike_group_name, {}) + + # 'ikev2-reauth' only valid for IKEv2 + peer_reauth = peer_conf.get('ikev2_reauth') + reauth_ike_group_configured = ( + peer_reauth == 'inherit' and 'ikev2_reauth' in ike_group + ) + if peer_reauth == 'yes' or reauth_ike_group_configured: + if ike_group.get('key_exchange') != 'ikev2': + raise ConfigError( + 'ikev2-reauth requires key-exchange ikev2 in IKE group! ' + f'Please configure IKEv2 key-exchange in ike-group "{ike_group_name}".' + ) + def cleanup_pki_files(): for path in [CERT_PATH, CA_PATH, CRL_PATH, KEY_PATH, PUBKEY_PATH]: @@ -655,7 +784,15 @@ def generate(ipsec): cleanup_pki_files() if not ipsec or 'deleted' in ipsec: - for config_file in [charon_dhcp_conf, charon_radius_conf, interface_conf, swanctl_conf]: + delete_files = ( + charon_dhcp_conf, + charon_radius_conf, + charon_systemd_conf, + charon_logging_conf, + interface_conf, + swanctl_conf, + ) + for config_file in delete_files: if os.path.isfile(config_file): os.unlink(config_file) render(charon_conf, 'ipsec/charon.j2', {'install_routes': default_install_routes}) @@ -695,6 +832,8 @@ def generate(ipsec): generate_pki_files_x509(ipsec['pki'], rw_conf['authentication']['x509']) if 'site_to_site' in ipsec and 'peer' in ipsec['site_to_site']: + DEFAULT_TS_PREFIX = 'dynamic' + for peer, peer_conf in ipsec['site_to_site']['peer'].items(): if f'peer_{peer}' in ipsec['dhcp_no_address']: continue @@ -723,10 +862,16 @@ def generate(ipsec): passthrough = None for local_prefix in local_prefixes: + if local_prefix == DEFAULT_TS_PREFIX: + continue + for remote_prefix in remote_prefixes: + if remote_prefix == DEFAULT_TS_PREFIX: + continue + local_net = ipaddress.ip_network(local_prefix) remote_net = ipaddress.ip_network(remote_prefix) - if local_net.overlaps(remote_net): + if local_net.subnet_of(remote_net): if passthrough is None: passthrough = [] passthrough.append(local_prefix) @@ -745,6 +890,8 @@ def generate(ipsec): render(charon_conf, 'ipsec/charon.j2', ipsec) render(charon_dhcp_conf, 'ipsec/charon/dhcp.conf.j2', ipsec) render(charon_radius_conf, 'ipsec/charon/eap-radius.conf.j2', ipsec) + render(charon_systemd_conf, 'ipsec/charon_systemd.conf.j2', ipsec) + render(charon_logging_conf, 'ipsec/charon_logging.conf.j2', ipsec) render(interface_conf, 'ipsec/interfaces_use.conf.j2', ipsec) render(swanctl_conf, 'ipsec/swanctl.conf.j2', ipsec) diff --git a/src/conf_mode/vpn_l2tp.py b/src/conf_mode/vpn_l2tp.py index 04ccbcec3..d6f5e4c28 100755 --- a/src/conf_mode/vpn_l2tp.py +++ b/src/conf_mode/vpn_l2tp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/vpn_openconnect.py b/src/conf_mode/vpn_openconnect.py index 42785134f..61c566bf5 100755 --- a/src/conf_mode/vpn_openconnect.py +++ b/src/conf_mode/vpn_openconnect.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -21,6 +21,7 @@ from vyos.base import Warning from vyos.config import Config from vyos.configverify import verify_pki_certificate from vyos.configverify import verify_pki_ca_certificate +from vyos.defaults import systemd_services from vyos.pki import find_chain from vyos.pki import encode_certificate from vyos.pki import load_certificate @@ -37,19 +38,22 @@ from passlib.hash import sha512_crypt from time import sleep from vyos import airbag + airbag.enable() -cfg_dir = '/run/ocserv' -ocserv_conf = cfg_dir + '/ocserv.conf' -ocserv_passwd = cfg_dir + '/ocpasswd' +cfg_dir = '/run/ocserv' +ocserv_conf = cfg_dir + '/ocserv.conf' +ocserv_passwd = cfg_dir + '/ocpasswd' ocserv_otp_usr = cfg_dir + '/users.oath' -radius_cfg = cfg_dir + '/radiusclient.conf' +radius_cfg = cfg_dir + '/radiusclient.conf' radius_servers = cfg_dir + '/radius_servers' + # Generate hash from user cleartext password def get_hash(password): return sha512_crypt.hash(password) + def get_config(config=None): if config: conf = config @@ -59,78 +63,139 @@ def get_config(config=None): if not conf.exists(base): return None - ocserv = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - with_recursive_defaults=True, - with_pki=True) + ocserv = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + with_recursive_defaults=True, + with_pki=True, + ) return ocserv + def verify(ocserv): if ocserv is None: return None # Check if listen-ports not binded other services # It can be only listen by 'ocserv-main' for proto, port in ocserv.get('listen_ports').items(): - if check_port_availability(ocserv['listen_address'], int(port), proto) is not True and \ - not is_listen_port_bind_service(int(port), 'ocserv-main'): + if check_port_availability( + ocserv['listen_address'], int(port), proto + ) is not True and not is_listen_port_bind_service(int(port), 'ocserv-main'): raise ConfigError(f'"{proto}" port "{port}" is used by another service') # Check accounting - if "accounting" in ocserv: - if "mode" in ocserv["accounting"] and "radius" in ocserv["accounting"]["mode"]: - if not origin["accounting"]['radius']['server']: - raise ConfigError('OpenConnect accounting mode radius requires at least one RADIUS server') - if "authentication" not in ocserv or "mode" not in ocserv["authentication"]: - raise ConfigError('Accounting depends on OpenConnect authentication configuration') - elif "radius" not in ocserv["authentication"]["mode"]: - raise ConfigError('RADIUS accounting must be used with RADIUS authentication') + if 'accounting' in ocserv: + if 'mode' in ocserv['accounting'] and 'radius' in ocserv['accounting']['mode']: + if not ocserv['accounting']['radius']['server']: + raise ConfigError( + 'OpenConnect accounting mode radius requires at least one RADIUS server' + ) + if 'authentication' not in ocserv or 'mode' not in ocserv['authentication']: + raise ConfigError( + 'Accounting depends on OpenConnect authentication configuration' + ) + elif 'radius' not in ocserv['authentication']['mode']: + raise ConfigError( + 'RADIUS accounting must be used with RADIUS authentication' + ) # Check authentication - if "authentication" in ocserv: - if "mode" in ocserv["authentication"]: - if ("local" in ocserv["authentication"]["mode"] and - "radius" in ocserv["authentication"]["mode"]): - raise ConfigError('OpenConnect authentication modes are mutually-exclusive, remove either local or radius from your configuration') - if "radius" in ocserv["authentication"]["mode"]: - if not ocserv["authentication"]['radius']['server']: - raise ConfigError('OpenConnect authentication mode radius requires at least one RADIUS server') - if "local" in ocserv["authentication"]["mode"]: - if not ocserv.get("authentication", {}).get("local_users"): - raise ConfigError('OpenConnect mode local required at least one user') - if not ocserv["authentication"]["local_users"]["username"]: - raise ConfigError('OpenConnect mode local required at least one user') + if 'authentication' in ocserv: + if 'mode' in ocserv['authentication']: + if ( + ('local' in ocserv['authentication']['mode'] + and 'radius' in ocserv['authentication']['mode']) + or + ('local' in ocserv['authentication']['mode'] + and 'certificate' in ocserv['authentication']['mode']) + or + ('radius' in ocserv['authentication']['mode'] + and 'certificate' in ocserv['authentication']['mode']) + ): + raise ConfigError( + 'OpenConnect authentication modes are mutually-exclusive. Use only one of local, radius, or certificate.' + ) + if 'radius' in ocserv['authentication']['mode']: + if 'server' not in ocserv['authentication']['radius']: + raise ConfigError( + 'OpenConnect authentication mode radius requires at least one RADIUS server' + ) + if 'local' in ocserv['authentication']['mode']: + if not ocserv.get('authentication', {}).get('local_users'): + raise ConfigError( + 'OpenConnect mode local required at least one user' + ) + if not ocserv['authentication']['local_users']['username']: + raise ConfigError( + 'OpenConnect mode local required at least one user' + ) else: # For OTP mode: verify that each local user has an OTP key - if "otp" in ocserv["authentication"]["mode"]["local"]: + if 'otp' in ocserv['authentication']['mode']['local']: users_wo_key = [] - for user, user_config in ocserv["authentication"]["local_users"]["username"].items(): + for user, user_config in ocserv['authentication'][ + 'local_users' + ]['username'].items(): # User has no OTP key defined - if dict_search('otp.key', user_config) == None: + if dict_search('otp.key', user_config) is None: users_wo_key.append(user) if users_wo_key: - raise ConfigError(f'OTP enabled, but no OTP key is configured for these users:\n{users_wo_key}') + raise ConfigError( + f'OTP enabled, but no OTP key is configured for these users:\n{users_wo_key}' + ) # For password (and default) mode: verify that each local user has password - if "password" in ocserv["authentication"]["mode"]["local"] or "otp" not in ocserv["authentication"]["mode"]["local"]: + if ( + 'password' in ocserv['authentication']['mode']['local'] + or 'otp' not in ocserv['authentication']['mode']['local'] + ): users_wo_pswd = [] - for user in ocserv["authentication"]["local_users"]["username"]: - if not "password" in ocserv["authentication"]["local_users"]["username"][user]: + for user in ocserv['authentication']['local_users']['username']: + if ( + 'password' + not in ocserv['authentication']['local_users'][ + 'username' + ][user] + ): users_wo_pswd.append(user) if users_wo_pswd: - raise ConfigError(f'password required for users:\n{users_wo_pswd}') + raise ConfigError( + f'password required for users:\n{users_wo_pswd}' + ) # Validate that if identity-based-config is configured all child config nodes are set - if 'identity_based_config' in ocserv["authentication"]: - if 'disabled' not in ocserv["authentication"]["identity_based_config"]: - Warning("Identity based configuration files is a 3rd party addition. Use at your own risk, this might break the ocserv daemon!") - if 'mode' not in ocserv["authentication"]["identity_based_config"]: - raise ConfigError('OpenConnect radius identity-based-config enabled but mode not selected') - elif 'group' in ocserv["authentication"]["identity_based_config"]["mode"] and "radius" not in ocserv["authentication"]["mode"]: - raise ConfigError('OpenConnect config-per-group must be used with radius authentication') - if 'directory' not in ocserv["authentication"]["identity_based_config"]: - raise ConfigError('OpenConnect identity-based-config enabled but directory not set') - if 'default_config' not in ocserv["authentication"]["identity_based_config"]: - raise ConfigError('OpenConnect identity-based-config enabled but default-config not set') + if 'identity_based_config' in ocserv['authentication']: + if 'disabled' not in ocserv['authentication']['identity_based_config']: + Warning( + 'Identity based configuration files is a 3rd party addition. Use at your own risk, this might break the ocserv daemon!' + ) + if 'mode' not in ocserv['authentication']['identity_based_config']: + raise ConfigError( + 'OpenConnect radius identity-based-config enabled but mode not selected' + ) + elif ( + 'group' + in ocserv['authentication']['identity_based_config']['mode'] + and 'radius' not in ocserv['authentication']['mode'] + ): + raise ConfigError( + 'OpenConnect config-per-group must be used with radius authentication' + ) + if ( + 'directory' + not in ocserv['authentication']['identity_based_config'] + ): + raise ConfigError( + 'OpenConnect identity-based-config enabled but directory not set' + ) + if ( + 'default_config' + not in ocserv['authentication']['identity_based_config'] + ): + raise ConfigError( + 'OpenConnect identity-based-config enabled but default-config not set' + ) else: raise ConfigError('OpenConnect authentication mode required') else: @@ -144,99 +209,170 @@ def verify(ocserv): raise ConfigError('SSL certificate missing on OpenConnect config!') verify_pki_certificate(ocserv, ocserv['ssl']['certificate']) + if 'ca_certificate' not in ocserv['ssl'] and 'certificate' in ocserv['authentication']['mode']: + raise ConfigError('CA certificate must be provided in certificate authentication mode!') + if 'ca_certificate' in ocserv['ssl']: for ca_cert in ocserv['ssl']['ca_certificate']: verify_pki_ca_certificate(ocserv, ca_cert) # Check network settings - if "network_settings" in ocserv: - if "push_route" in ocserv["network_settings"]: + if 'network_settings' in ocserv: + if 'push_route' in ocserv['network_settings']: # Replace default route - if "0.0.0.0/0" in ocserv["network_settings"]["push_route"]: - ocserv["network_settings"]["push_route"].remove("0.0.0.0/0") - ocserv["network_settings"]["push_route"].append("default") + if '0.0.0.0/0' in ocserv['network_settings']['push_route']: + ocserv['network_settings']['push_route'].remove('0.0.0.0/0') + ocserv['network_settings']['push_route'].append('default') else: - ocserv["network_settings"]["push_route"] = ["default"] + ocserv['network_settings']['push_route'] = ['default'] else: raise ConfigError('OpenConnect network settings required!') + def generate(ocserv): if not ocserv: return None - if "radius" in ocserv["authentication"]["mode"]: + if 'radius' in ocserv['authentication']['mode']: if dict_search(ocserv, 'accounting.mode.radius'): # Render radius client configuration render(radius_cfg, 'ocserv/radius_conf.j2', ocserv) - merged_servers = ocserv["accounting"]["radius"]["server"] | ocserv["authentication"]["radius"]["server"] + merged_servers = ( + ocserv['accounting']['radius']['server'] + | ocserv['authentication']['radius']['server'] + ) # Render radius servers # Merge the accounting and authentication servers into a single dictionary - render(radius_servers, 'ocserv/radius_servers.j2', {'server': merged_servers}) + render( + radius_servers, 'ocserv/radius_servers.j2', {'server': merged_servers} + ) else: # Render radius client configuration render(radius_cfg, 'ocserv/radius_conf.j2', ocserv) # Render radius servers - render(radius_servers, 'ocserv/radius_servers.j2', ocserv["authentication"]["radius"]) - elif "local" in ocserv["authentication"]["mode"]: + render( + radius_servers, + 'ocserv/radius_servers.j2', + ocserv['authentication']['radius'], + ) + elif 'local' in ocserv['authentication']['mode']: # if mode "OTP", generate OTP users file parameters - if "otp" in ocserv["authentication"]["mode"]["local"]: - if "local_users" in ocserv["authentication"]: - for user in ocserv["authentication"]["local_users"]["username"]: + if 'otp' in ocserv['authentication']['mode']['local']: + if 'local_users' in ocserv['authentication']: + for user in ocserv['authentication']['local_users']['username']: # OTP token type from CLI parameters: - otp_interval = str(ocserv["authentication"]["local_users"]["username"][user]["otp"].get("interval")) - token_type = ocserv["authentication"]["local_users"]["username"][user]["otp"].get("token_type") - otp_length = str(ocserv["authentication"]["local_users"]["username"][user]["otp"].get("otp_length")) - if token_type == "hotp-time": - otp_type = "HOTP/T" + otp_interval - elif token_type == "hotp-event": - otp_type = "HOTP/E" + otp_interval = str( + ocserv['authentication']['local_users']['username'][user][ + 'otp' + ].get('interval') + ) + token_type = ocserv['authentication']['local_users']['username'][ + user + ]['otp'].get('token_type') + otp_length = str( + ocserv['authentication']['local_users']['username'][user][ + 'otp' + ].get('otp_length') + ) + if token_type == 'hotp-time': + otp_type = 'HOTP/T' + otp_interval + elif token_type == 'hotp-event': + otp_type = 'HOTP/E' else: - otp_type = "HOTP/T" + otp_interval - ocserv["authentication"]["local_users"]["username"][user]["otp"]["token_tmpl"] = otp_type + "/" + otp_length + otp_type = 'HOTP/T' + otp_interval + ocserv['authentication']['local_users']['username'][user]['otp'][ + 'token_tmpl' + ] = otp_type + '/' + otp_length # if there is a password, generate hash - if "password" in ocserv["authentication"]["mode"]["local"] or not "otp" in ocserv["authentication"]["mode"]["local"]: - if "local_users" in ocserv["authentication"]: - for user in ocserv["authentication"]["local_users"]["username"]: - ocserv["authentication"]["local_users"]["username"][user]["hash"] = get_hash(ocserv["authentication"]["local_users"]["username"][user]["password"]) - - if "password-otp" in ocserv["authentication"]["mode"]["local"]: + if ( + 'password' in ocserv['authentication']['mode']['local'] + or 'otp' not in ocserv['authentication']['mode']['local'] + ): + if 'local_users' in ocserv['authentication']: + for user in ocserv['authentication']['local_users']['username']: + ocserv['authentication']['local_users']['username'][user][ + 'hash' + ] = get_hash( + ocserv['authentication']['local_users']['username'][user][ + 'password' + ] + ) + + if 'password-otp' in ocserv['authentication']['mode']['local']: # Render local users ocpasswd - render(ocserv_passwd, 'ocserv/ocserv_passwd.j2', ocserv["authentication"]["local_users"]) + render( + ocserv_passwd, + 'ocserv/ocserv_passwd.j2', + ocserv['authentication']['local_users'], + ) # Render local users OTP keys - render(ocserv_otp_usr, 'ocserv/ocserv_otp_usr.j2', ocserv["authentication"]["local_users"]) - elif "password" in ocserv["authentication"]["mode"]["local"]: + render( + ocserv_otp_usr, + 'ocserv/ocserv_otp_usr.j2', + ocserv['authentication']['local_users'], + ) + elif 'password' in ocserv['authentication']['mode']['local']: # Render local users ocpasswd - render(ocserv_passwd, 'ocserv/ocserv_passwd.j2', ocserv["authentication"]["local_users"]) - elif "otp" in ocserv["authentication"]["mode"]["local"]: + render( + ocserv_passwd, + 'ocserv/ocserv_passwd.j2', + ocserv['authentication']['local_users'], + ) + elif 'otp' in ocserv['authentication']['mode']['local']: # Render local users OTP keys - render(ocserv_otp_usr, 'ocserv/ocserv_otp_usr.j2', ocserv["authentication"]["local_users"]) + render( + ocserv_otp_usr, + 'ocserv/ocserv_otp_usr.j2', + ocserv['authentication']['local_users'], + ) else: # Render local users ocpasswd - render(ocserv_passwd, 'ocserv/ocserv_passwd.j2', ocserv["authentication"]["local_users"]) + render( + ocserv_passwd, + 'ocserv/ocserv_passwd.j2', + ocserv['authentication']['local_users'], + ) else: - if "local_users" in ocserv["authentication"]: - for user in ocserv["authentication"]["local_users"]["username"]: - ocserv["authentication"]["local_users"]["username"][user]["hash"] = get_hash(ocserv["authentication"]["local_users"]["username"][user]["password"]) + if 'local_users' in ocserv['authentication']: + for user in ocserv['authentication']['local_users']['username']: + ocserv['authentication']['local_users']['username'][user]['hash'] = ( + get_hash( + ocserv['authentication']['local_users']['username'][user][ + 'password' + ] + ) + ) # Render local users - render(ocserv_passwd, 'ocserv/ocserv_passwd.j2', ocserv["authentication"]["local_users"]) + render( + ocserv_passwd, + 'ocserv/ocserv_passwd.j2', + ocserv['authentication']['local_users'], + ) - if "ssl" in ocserv: + if 'ssl' in ocserv: cert_file_path = os.path.join(cfg_dir, 'cert.pem') cert_key_path = os.path.join(cfg_dir, 'cert.key') - if 'certificate' in ocserv['ssl']: cert_name = ocserv['ssl']['certificate'] pki_cert = ocserv['pki']['certificate'][cert_name] loaded_pki_cert = load_certificate(pki_cert['certificate']) - loaded_ca_certs = {load_certificate(c['certificate']) - for c in ocserv['pki']['ca'].values()} if 'ca' in ocserv['pki'] else {} + loaded_ca_certs = ( + { + load_certificate(c['certificate']) + for c in ocserv['pki']['ca'].values() + } + if 'ca' in ocserv['pki'] + else {} + ) cert_full_chain = find_chain(loaded_pki_cert, loaded_ca_certs) - write_file(cert_file_path, - '\n'.join(encode_certificate(c) for c in cert_full_chain)) + write_file( + cert_file_path, + '\n'.join(encode_certificate(c) for c in cert_full_chain), + ) if 'private' in pki_cert and 'key' in pki_cert['private']: write_file(cert_key_path, wrap_private_key(pki_cert['private']['key'])) @@ -250,7 +386,8 @@ def generate(ocserv): loaded_ca_cert = load_certificate(pki_ca_cert['certificate']) ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs) ca_chains.append( - '\n'.join(encode_certificate(c) for c in ca_full_chain)) + '\n'.join(encode_certificate(c) for c in ca_full_chain) + ) write_file(ca_cert_file_path, '\n'.join(ca_chains)) @@ -259,21 +396,24 @@ def generate(ocserv): def apply(ocserv): + service_name = systemd_services['openconnect'] if not ocserv: - call('systemctl stop ocserv.service') + call(f'systemctl stop {service_name}') for file in [ocserv_conf, ocserv_passwd, ocserv_otp_usr]: if os.path.exists(file): os.unlink(file) else: - call('systemctl reload-or-restart ocserv.service') + call(f'systemctl reload-or-restart {service_name}') counter = 0 while True: # exit early when service runs - if is_systemd_service_running("ocserv.service"): + if is_systemd_service_running(service_name): break sleep(0.250) if counter > 5: - raise ConfigError('OpenConnect failed to start, check the logs for details') + raise ConfigError( + 'OpenConnect failed to start, check the logs for details' + ) break counter += 1 diff --git a/src/conf_mode/vpn_pptp.py b/src/conf_mode/vpn_pptp.py index c0d8330bd..c11619779 100755 --- a/src/conf_mode/vpn_pptp.py +++ b/src/conf_mode/vpn_pptp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/vpn_sstp.py b/src/conf_mode/vpn_sstp.py index 7490fd0e0..5382fc711 100755 --- a/src/conf_mode/vpn_sstp.py +++ b/src/conf_mode/vpn_sstp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/conf_mode/vpp.py b/src/conf_mode/vpp.py new file mode 100755 index 000000000..342d58fca --- /dev/null +++ b/src/conf_mode/vpp.py @@ -0,0 +1,910 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from pathlib import Path + +from pyroute2.iproute import IPRoute + +try: + from vpp_papi import VPPIOError, VPPValueError +except ImportError: # pylint: disable=import-error + VPPIOError = VPPValueError = None + +from vyos import ConfigError +from vyos import airbag +from vyos.base import Warning +from vyos.config import Config, config_dict_merge +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents +from vyos.configdict import node_changed, is_member +from vyos.configverify import verify_interface_exists +from vyos.configverify import verify_virtual_interface_exists +from vyos.ifconfig import Section +from vyos.logger import getLogger +from vyos.template import render +from vyos.utils.boot import boot_configuration_complete +from vyos.utils.convert import range_str_to_list +from vyos.utils.convert import list_to_range_str +from vyos.utils.dict import dict_search +from vyos.utils.file import read_file +from vyos.utils.kernel import check_kmod +from vyos.utils.kernel import unload_kmod +from vyos.utils.kernel import list_loaded_modules +from vyos.utils.process import call +from vyos.utils.process import is_systemd_service_active + +from vyos.vpp import VPPControl +from vyos.vpp import control_host +from vyos.vpp import VppNotRunningError +from vyos.vpp.config_verify import ( + verify_vpp_remove_interface, + verify_vpp_minimum_cpus, + verify_vpp_minimum_memory, + verify_vpp_cpu_cores, + verify_vpp_memory, + verify_vpp_statseg_size, + verify_vpp_interfaces_dpdk_num_queues, + verify_routes_count, + verify_vpp_main_heap_size, + verify_vpp_buffers, +) +from vyos.vpp.config_resource_checks import memory +from vyos.vpp.config_filter import iface_filter_eth +from vyos.vpp.utils import EthtoolGDrvinfo +from vyos.vpp.utils import cli_ethernet_with_vifs_ifaces +from vyos.vpp.configdb import JSONStorage + +airbag.enable() + +service_name = 'vpp' +service_conf = Path(f'/run/vpp/{service_name}.conf') +systemd_override = '/run/systemd/system/vpp.service.d/10-override.conf' + +vpp_log = getLogger( + service_name, format='%(filename)s[%(process)d]: %(message)s', address='/dev/log' +) + +dependency_interface_type_map = { + 'vpp_interfaces_bonding': 'bonding', + 'vpp_interfaces_bridge': 'bridge', + 'vpp_interfaces_gre': 'gre', + 'vpp_interfaces_ipip': 'ipip', + 'vpp_interfaces_loopback': 'loopback', + 'vpp_interfaces_vxlan': 'vxlan', + 'vpp_interfaces_xconnect': 'xconnect', +} + +# dict of drivers that needs to be overridden +override_drivers: dict[str, str] = { + 'hv_netvsc': 'uio_hv_generic', +} + +# drivers that does not use PCIe addresses +not_pci_drv: list[str] = ['hv_netvsc'] + +# drivers that support interrupt RX mode for DPDK and XDP +drivers_support_interrupt: dict[str, list] = { + 'atlantic': ['dpdk', 'xdp'], + 'bnx2x': ['dpdk'], + 'e1000': ['dpdk'], + 'ena': ['dpdk', 'xdp'], + 'i40e': ['dpdk', 'xdp'], + 'ice': ['dpdk', 'xdp'], + 'igb': ['xdp'], + 'igc': ['dpdk', 'xdp'], + 'ixgbe': ['dpdk', 'xdp'], + 'qede': ['dpdk', 'xdp'], + 'vmxnet3': ['xdp'], + 'virtio_net': ['xdp'], +} + +# drivers that require changing channels (half the maximum number of RX/TX queues) +ethtool_channels_change_drv: list[str] = ['ena', 'gve'] + +# List of NICs where VPP activation is supported +SUPPORTED_PCI_IDS = ( + '15b3:1019', # Mellanox Technologies MT28800 Family [ConnectX-5 Ex] + '15b3:101d', # Mellanox Technologies MT2892 Family [ConnectX-6 Dx] + '15b3:101e', # Mellanox Technologies ConnectX Family mlx5Gen Virtual Function + '8086:1592', # Intel Corporation Ethernet Controller E810-C for QSFP + '1ae0:0042', # Google, Inc. Compute Engine Virtual Ethernet [gVNIC] + '1af4:1000', # Red Hat, Inc. Virtio network device (legacy ID) + '1af4:1041', # Red Hat, Inc. Virtio network device (modern ID) + '1d0f:ec20', # Amazon.com, Inc. Elastic Network Adapter (ENA) +) +SUPPORTED_DRIVERS = ( + 'hv_netvsc', # Microsoft Hyper-V network interface card +) + + +def _load_module(module_name: str): + """ + Load a kernel module + + Args: + module_name (str): Name of the module to load. + """ + if module_name in list_loaded_modules(): + vpp_log.info(f"Module '{module_name}' is already loaded") + return + try: + check_kmod(module_name) + vpp_log.info(f"Module '{module_name}' loaded successfully") + except Exception as e: + vpp_log.error(f"Failed to load module '{module_name}': {e}") + raise + + +def _unload_module(module_name: str): + """ + Unload a kernel module + + Args: + module_name (str): Name of the module to unload. + """ + if module_name not in list_loaded_modules(): + vpp_log.info(f"Module '{module_name}' is not loaded") + return + try: + unload_kmod(module_name) + vpp_log.info(f"Module '{module_name}' unloaded successfully") + except Exception as e: + vpp_log.error(f"Failed to unload module '{module_name}': {e}") + raise + + +def _configure_vpp_cpu_settings(config: dict): + """Configure VPP CPU settings: main-core and corelist-workers based on 'cpu-cores'. + + Reads the actually-isolated CPUs from the running kernel + (/sys/devices/system/cpu/isolated) and assigns: + - main_core: the first isolated CPU (index 0) + - corelist_workers: the next (cpu_cores - 1) isolated CPUs + """ + cpu_cores = int(config['settings']['resource_allocation']['cpu_cores']) + # Use the system's actual isolated CPUs, not config values which may + # require a reboot to take effect + isolated = read_file('/sys/devices/system/cpu/isolated') + cpus_isolated = range_str_to_list(isolated) + + if cpu_cores <= len(cpus_isolated): + # First isolated CPU is the VPP main thread; remaining are workers + config['settings']['cpu'] = {'main_core': str(cpus_isolated[0])} + + if cpu_cores > 1: + config['settings']['cpu']['corelist_workers'] = list_to_range_str( + cpus_isolated[1:cpu_cores] + ) + + +def _normalize_buffers(config: dict): + """Replace 'auto' buffers_per_numa with calculated value""" + if ( + config['settings']['resource_allocation']['buffers']['buffers_per_numa'] + == 'auto' + ): + buffers = memory.buffers_required(config['settings']) + config['settings']['resource_allocation']['buffers']['buffers_per_numa'] = str( + buffers + ) + + +def _get_max_xdp_rx_queues(config: dict): + """ + Count max number of RX queues for XDP driver + - If the interface driver is in `ethtool_channels_change_drv` + only half of the available queues are used (to avoid NIC issues) + - For other interface drivers the full number of queues is returned. + - If neither `rx` nor `combined` is set, return 1. + """ + for key in ('rx', 'combined'): + value = config['channels'].get(key) + if value: + if config['original_driver'] in ethtool_channels_change_drv: + return max(1, int(value) // 2) + else: + return int(value) + + return 1 + + +def _is_device_allowed(config: dict, iface: str): + """ + Determines if a network interface device is allowed to be used + with VPP based on its PCI ID or driver. + """ + if 'allow_unsupported_nics' in config['settings']: + return True + + persist_config = dict_search(f'persist_config.{iface}', config, default={}) + + pci_id = persist_config.get('pci_id') + # PCI ID is sufficient by itself, if presented + if pci_id is not None and pci_id in SUPPORTED_PCI_IDS: + return True + + # If the PCI ID did not match or does not exist, fall back to a driver + original_driver = persist_config.get('original_driver') + if original_driver is not None and original_driver in SUPPORTED_DRIVERS: + return True + + return False + + +def get_config(config=None): + # use persistent config to store interfaces data between executions + # this is required because some interfaces after they are connected + # to VPP is really hard or impossible to restore without knowing + # their original parameters (like IDs) + with JSONStorage('vpp_conf') as persist_config: + eth_ifaces_persist: dict[str, dict[str, str]] = persist_config.read( + 'eth_ifaces', {} + ) + + if config: + conf = config + else: + conf = Config() + + base = ['vpp'] + base_settings = ['vpp', 'settings'] + + # find interfaces removed from VPP + effective_config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + effective=True, + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + removed_ifaces = [] + tmp = node_changed(conf, base_settings + ['interface']) + if tmp: + for removed_iface in tmp: + to_append = { + 'iface_name': removed_iface, + 'driver': 'dpdk', + } + removed_ifaces.append(to_append) + # add an interface to a list of interfaces that need + # to be reinitialized after the commit + set_dependents('ethernet', conf, removed_iface) + + # Get interfaces that should be used in PPPoE for control-plane integration + pppoe_ifaces = conf.get_config_dict( + ['service', 'pppoe-server', 'interface'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + changed_pppoe_ifaces = [ + iface for iface in pppoe_ifaces if iface.split('.')[0] in tmp + ] + + interfaces_config = conf.get_config_dict( + ['interfaces', 'vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + with_recursive_defaults=True, + ) + + if not conf.exists(base): + if changed_pppoe_ifaces: + set_dependents('pppoe_server', conf) + return { + 'removed_ifaces': removed_ifaces, + 'persist_config': eth_ifaces_persist, + 'interfaces_vpp': interfaces_config, + 'pppoe_ifaces': pppoe_ifaces, + 'remove': {}, + } + + config = conf.get_config_dict( + base, + get_first_key=True, + key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + ) + + # Get default values which we need to conditionally update into the + # dictionary retrieved. + default_values = conf.get_config_defaults(**config.kwargs, recursive=True) + + # Since XDP is no longer configurable via the CLI (T8202), + # this code is kept commented out to simplify reintroducing XDP in the future. + # + # # delete driver-incompatible defaults + # for iface, iface_config in config.get('settings', {}).get('interface', {}).items(): + # if iface_config.get('driver') == 'dpdk': + # del default_values['settings']['interface'][iface]['xdp_options'] + # elif iface_config.get('driver') == 'xdp': + # del default_values['settings']['interface'][iface]['dpdk_options'] + + config = config_dict_merge(default_values, config) + + # add running config + if effective_config: + default_values_effective = conf.get_config_defaults( + **effective_config.kwargs, recursive=True + ) + effective_config = config_dict_merge(default_values_effective, effective_config) + # Buffer normalization (auto → computed) + _normalize_buffers(effective_config) + for iface_config in effective_config['settings']['interface'].values(): + iface_config['driver'] = 'dpdk' + config['effective'] = effective_config + + # Save important info about all interfaces that cannot be retrieved later + # Add new interfaces (only if they are first time seen in a config) + for iface, iface_config in config.get('settings', {}).get('interface', {}).items(): + if iface not in effective_config.get('settings', {}).get('interface', {}): + eth_ifaces_persist[iface] = { + 'original_driver': EthtoolGDrvinfo(iface).driver, + } + eth_ifaces_persist[iface]['bus_id'] = control_host.get_bus_name(iface) + eth_ifaces_persist[iface]['dev_id'] = control_host.get_dev_id(iface) + eth_ifaces_persist[iface]['pci_id'] = control_host.get_pci_id(iface) + eth_ifaces_persist[iface]['channels'] = control_host.get_eth_channels(iface) + + # Return to config dictionary + config['persist_config'] = eth_ifaces_persist + + # list of all Ethernet interfaces with vifs + ifaces_with_vifs = cli_ethernet_with_vifs_ifaces(conf, include_nested_vifs=True) + + if 'settings' in config: + if 'interface' in config['settings']: + interface_rx_mode = config['settings'].get('interface_rx_mode') + + for iface, iface_config in config['settings']['interface'].items(): + iface_config['driver'] = 'dpdk' + + # old_driver = leaf_node_changed( + # conf, base_settings + ['interface', iface, 'driver'] + # ) + # + # if old_driver: + # config['settings']['interface'][iface]['driver_changed'] = {} + + # Get current kernel module, required for extra verification and + # logic for VMBus interfaces + config['settings']['interface'][iface]['kernel_module'] = ( + EthtoolGDrvinfo(iface).driver + ) + + # filter unsupported config nodes + iface_filter_eth(conf, iface) + set_dependents('ethernet', conf, iface) + # Interfaces with changed driver should be removed/readded + # if old_driver and old_driver[0] == 'dpdk': + # removed_ifaces.append( + # { + # 'iface_name': iface, + # 'driver': 'dpdk', + # } + # ) + + # Collect memberships as sets for uniqueness + bond_member = is_member(conf, iface, 'bonding') + bridge_member = is_member(conf, iface, 'bridge') + + # Look for VLAN interfaces of this parent + vlans = [ + vlan_iface + for vlan_iface in ifaces_with_vifs + if vlan_iface.startswith(f'{iface}.') + ] + for vlan_iface in vlans: + bond_member.update(is_member(conf, vlan_iface, 'bonding')) + bridge_member.update(is_member(conf, vlan_iface, 'bridge')) + + # Store as lists + if bond_member: + iface_config['bond_member'] = list(bond_member) + if bridge_member: + iface_config['bridge_member'] = list(bridge_member) + + # Get PCI address or device ID + if iface_config['driver'] == 'dpdk': + if 'dpdk_options' not in iface_config: + iface_config['dpdk_options'] = {} + # Check in a persistent config first + id_from_persistent_conf = eth_ifaces_persist.get(iface, {}).get( + 'dev_id' + ) + if id_from_persistent_conf: + iface_config['dpdk_options']['dev_id'] = id_from_persistent_conf + else: + try: + iface_to_search = iface + # if old_driver and old_driver[0] == 'xdp': + # iface_to_search = f'defunct_{iface}' + iface_config['dpdk_options']['dev_id'] = ( + control_host.get_dev_id(iface_to_search) + ) + except Exception: + # Return empty address if all attempts failed + # We will catch this in verify() + iface_config['dpdk_options']['dev_id'] = '' + # prepare XDP interface parameters + if iface_config['driver'] == 'xdp': + xdp_api_params = { + 'rxq_size': int(iface_config['xdp_options']['rx_queue_size']), + 'txq_size': int(iface_config['xdp_options']['tx_queue_size']), + } + if iface_config['xdp_options']['num_rx_queues'] == 'all': + # 65535 is used as special value to request all available queues + xdp_api_params['rxq_num'] = 65535 + else: + xdp_api_params['rxq_num'] = int( + iface_config['xdp_options']['num_rx_queues'] + ) + if 'zero-copy' in iface_config['xdp_options']: + xdp_api_params['mode'] = 'zero-copy' + if ( + interface_rx_mode in ('interrupt', 'adaptive') + and int(config['settings']['resource_allocation']['cpu_cores']) + > 1 + ): + xdp_api_params['flags'] = 'no_syscall_lock' + iface_config['xdp_api_params'] = xdp_api_params + + # Buffer normalization (auto → computed) + _normalize_buffers(config) + # Configure VPP main-core and workers 'cpu-cores' settings + _configure_vpp_cpu_settings(config) + + if removed_ifaces: + config['removed_ifaces'] = removed_ifaces + + config['interfaces_vpp'] = interfaces_config + + # Dependencies + for dependency, interface_type in dependency_interface_type_map.items(): + if conf.exists(['interfaces', 'vpp', interface_type]): + for iface, iface_config in interfaces_config.get( + interface_type, {} + ).items(): + set_dependents(dependency, conf, iface) + + config['ipoe_conf'] = conf.get_config_dict( + ['service', 'ipoe-server'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + # NAT dependency + if conf.exists(['vpp', 'nat', 'nat44']): + set_dependents('vpp_nat_nat44', conf) + if conf.exists(['vpp', 'nat', 'cgnat']): + set_dependents('vpp_nat_cgnat', conf) + + # sFlow dependency + if conf.exists(['vpp', 'sflow']): + set_dependents('vpp_sflow', conf) + + # ACL dependency + if conf.exists(['vpp', 'acl']): + set_dependents('vpp_acl', conf) + + # IPFIX dependency + if conf.exists(['vpp', 'ipfix']): + set_dependents('vpp_ipfix', conf) + + # PPPoE dependency + added_pppoe_ifaces = [ + iface + for iface in pppoe_ifaces + if iface.split('.')[0] in config.get('settings', {}).get('interface', {}) + ] + changed_pppoe_ifaces.extend(added_pppoe_ifaces) + if changed_pppoe_ifaces: + set_dependents('pppoe_server', conf) + config['changed_pppoe_ifaces'] = changed_pppoe_ifaces + + return config + + +def verify(config): + if config.get('interfaces_vpp') and 'remove' in config: + raise ConfigError( + 'VPP cannot be removed while VPP interfaces exist. Remove all "interfaces vpp" first!' + ) + + # Find PPPoE ifaces where the base matches any VPP interface (base or VLAN) + pppoe_vpp_ifaces = [ + iface for iface in config.get('pppoe_ifaces', {}) if iface.startswith('vpp') + ] + if 'remove' in config and pppoe_vpp_ifaces: + raise ConfigError( + f'Cannot remove VPP: PPPoE server still uses VPP interface(s): {", ".join(pppoe_vpp_ifaces)}' + ) + + # bail out early - looks like removal from running config + if not config or 'remove' in config: + return None + + # Check removed interfaces (and their VLANs) against all VPP features + for removed_iface in config.get('removed_ifaces', []): + verify_vpp_remove_interface( + removed_iface['iface_name'], config, match_vlans=True + ) + + if 'settings' not in config: + raise ConfigError('"settings interface" is required but not set!') + + if 'interface' not in config['settings']: + raise ConfigError('"settings interface" is required but not set!') + + ipoe_ifaces = list( + { + iface + for iface in config['settings']['interface'] + for ipoe_iface in config.get('ipoe_conf', {}).get('interface', {}) + if iface == ipoe_iface or ipoe_iface.startswith(f'{iface}.') + } + ) + if ipoe_ifaces: + raise ConfigError( + f'Interface(s) {", ".join(ipoe_ifaces)} cannot be added to VPP because ' + 'IPoE is already configured. An interface cannot be used by both VPP and IPoE!' + ) + + # check if the system meets minimal requirements + verify_vpp_minimum_memory() + + # check if Ethernet interfaces exist + ethernet_ifaces = Section.interfaces('ethernet') + for iface in config['settings']['interface'].keys(): + if iface not in ethernet_ifaces: + raise ConfigError(f'Interface {iface} does not exist or is not Ethernet!') + + # Resource usage checks + cpu_cores = int(config['settings']['resource_allocation']['cpu_cores']) + verify_vpp_minimum_cpus() + verify_vpp_cpu_cores(cpu_cores) + + verify_vpp_main_heap_size(config['settings']) + verify_vpp_statseg_size(config['settings']) + + # Check buffers + verify_vpp_buffers(config['settings']) + + # Check if available memory is enough for current VPP config + verify_vpp_memory(config) + + interface_rx_mode = config['settings'].get('interface_rx_mode') + + # ensure DPDK/XDP settings are properly configured + for iface, iface_config in config['settings']['interface'].items(): + if not _is_device_allowed(config, iface): + raise ConfigError( + f'NIC used by "{iface}" is not validated for VPP on VyOS. ' + 'Using it is unsafe and unsupported and will void support for the entire system. ' + 'To proceed at your own risk, enable: "set vpp settings allow-unsupported-nics".' + ) + + err_message = f'Cannot add {iface} to VPP - ' + if 'bond_member' in iface_config: + raise ConfigError( + err_message + + f'interface (or its VLAN) is a member of bond(s): {", ".join(iface_config["bond_member"])}' + ) + if 'bridge_member' in iface_config: + raise ConfigError( + err_message + + f'interface (or its VLAN) is a member of bridge(s): {", ".join(iface_config["bridge_member"])}' + ) + + if iface_config['driver'] == 'xdp' and 'xdp_options' in iface_config: + if iface_config['xdp_options']['num_rx_queues'] != 'all': + rx_queues = iface_config['xdp_api_params']['rxq_num'] + max_rx_queues = _get_max_xdp_rx_queues(config['persist_config'][iface]) + if rx_queues > max_rx_queues: + raise ConfigError( + f'Maximum supported number of RX queues for interface {iface} is {max_rx_queues}. ' + f'Please set "xdp-options num-rx-queues" to {max_rx_queues} or fewer' + ) + + Warning(f'Not all RX queues will be connected to VPP for {iface}!') + + if iface_config['driver'] == 'dpdk': + if 'num_rx_queues' in iface_config: + rx_queues = int(iface_config['num_rx_queues']) + verify_vpp_interfaces_dpdk_num_queues( + qtype='receive', num_queues=rx_queues, workers=cpu_cores + ) + + if 'num_tx_queues' in iface_config: + tx_queues = int(iface_config['num_tx_queues']) + verify_vpp_interfaces_dpdk_num_queues( + qtype='transmit', num_queues=tx_queues, workers=cpu_cores + ) + + # RX-mode verification + rx_mode = interface_rx_mode + if rx_mode and rx_mode != 'polling': + # By default drivers operate in polling mode. Not all NIC drivers support + # RX mode interrupt and adaptive + driver = config.get('persist_config').get(iface).get('original_driver') + if ( + driver not in drivers_support_interrupt + or iface_config['driver'] not in drivers_support_interrupt[driver] + ): + raise ConfigError( + f'RX mode {rx_mode} is not supported for interface {iface}' + ) + + verify_routes_count(config['settings']) + + for pppoe_iface in config.get('changed_pppoe_ifaces', []): + if '.' in pppoe_iface: + verify_virtual_interface_exists(config, pppoe_iface) + else: + verify_interface_exists(config, pppoe_iface) + + +def generate(config): + if not config or 'remove' in config: + # Remove old config and return + service_conf.unlink(missing_ok=True) + return None + + render(service_conf, 'vpp/startup.conf.j2', config['settings']) + render(systemd_override, 'vpp/override.conf.j2', config) + + return None + + +def initialize_interface(iface, driver, iface_config) -> None: + # DPDK - rescan PCI to use a proper driver + if driver == 'dpdk' and iface_config['original_driver'] not in not_pci_drv: + # 'gve' devices require a specific unbind/bind process instead of a standard PCI rescan. + if iface_config['original_driver'] == 'gve': + control_host.rebind_gve_driver( + iface, iface_config['bus_id'], iface_config['dev_id'] + ) + else: + control_host.pci_rescan(iface_config['dev_id']) + # rename to the proper name + iface_new_name: str = control_host.get_eth_name(iface_config['dev_id']) + control_host.rename_iface(iface_new_name, iface) + + # XDP - rename an interface, disable promisc and XDP, set original channels + if driver == 'xdp': + control_host.set_promisc(f'defunct_{iface}', 'off') + control_host.rename_iface(f'defunct_{iface}', iface) + control_host.xdp_remove(iface) + if iface_config['original_driver'] in ethtool_channels_change_drv: + control_host.set_eth_channels(iface, iface_config['channels']) + + # Rename Mellanox NIC to a normal name + try: + if control_host.get_eth_driver(f'defunct_{iface}') == 'mlx5_core': + control_host.rename_iface(f'defunct_{iface}', iface) + except Exception: + pass + + # Replace a driver with original for VMBus interfaces and rename it + if driver == 'dpdk' and iface_config['original_driver'] in override_drivers: + control_host.override_driver(iface_config['bus_id'], iface_config['dev_id']) + iface_new_name: str = control_host.get_eth_name(iface_config['dev_id']) + control_host.rename_iface(iface_new_name, iface) + + +def apply(config): + # modrpobe modules + modules = ('vfio_iommu_type1', 'vfio_pci', 'vfio_pci_core', 'vfio') + # Open persistent config + # It is required for operations with interfaces + if not config or 'remove' in config: + # Cleanup persistent config + with JSONStorage('vpp_conf') as persist_config: + persist_config.delete() + # And stop the service + call(f'systemctl stop {service_name}.service') + # Unlod modules (modprobe -r) + for module in modules: + _unload_module(module) + else: + # Some interfaces required extra preparation before VPP can be started + if 'settings' in config and 'interface' in config.get('settings'): + # modprobe vfio + if any( + iface_config.get('driver') == 'dpdk' + for iface_config in config['settings']['interface'].values() + ): + for module in modules: + _load_module(module) + + for iface, iface_config in config['settings']['interface'].items(): + if iface_config['driver'] == 'dpdk': + # ena interfaces require noiommu mode + if iface_config['kernel_module'] == 'ena': + control_host.unsafe_noiommu_mode(True) + + original_driver = config['persist_config'][iface]['original_driver'] + effective_ifaces = ( + config.get('effective', {}) + .get('settings', {}) + .get('interface', {}) + ) + # Check if the driver needs to be overridden: + # either the kernel module requires it, or the interface is being switched + # from XDP (hv_netvsc) to DPDK (T7797) + override_xdp_to_dpdk = ( + effective_ifaces.get(iface, {}).get('driver') == 'xdp' + and original_driver == 'hv_netvsc' + ) + k_module = ( + original_driver + if override_xdp_to_dpdk + else iface_config['kernel_module'] + ) + if ( + iface_config['kernel_module'] in override_drivers + or override_xdp_to_dpdk + ): + control_host.override_driver( + config['persist_config'][iface]['bus_id'], + config['persist_config'][iface]['dev_id'], + override_drivers[k_module], + ) + + call('systemctl daemon-reload') + call(f'systemctl restart {service_name}.service') + + # Initialize interfaces removed from VPP + for iface in config.get('removed_ifaces', []): + initialize_interface( + iface['iface_name'], + iface['driver'], + config['persist_config'][iface['iface_name']], + ) + + # Remove what is not in the config anymore + if iface['iface_name'] not in config.get('settings', {}).get('interface', {}): + del config['persist_config'][iface['iface_name']] + + if 'settings' in config and 'interface' in config.get('settings'): + interface_rx_mode = config['settings'].get('interface_rx_mode') + + # connect to VPP + try: + # Bail out early if VPP service is not running + if not is_systemd_service_active(f'{service_name}.service'): + raise VppNotRunningError( + 'VPP service is not running or failed to start' + ) + + vpp_control = VPPControl() + + # preconfigure LCP plugin + if 'ignore_kernel_routes' in config['settings']: + vpp_control.cli_cmd('lcp param route-no-paths off') + else: + vpp_control.cli_cmd('lcp param route-no-paths on') + # add interfaces + iproute = IPRoute() + for iface, iface_config in config['settings']['interface'].items(): + # add XDP interfaces + if iface_config['driver'] == 'xdp': + control_host.rename_iface(iface, f'defunct_{iface}') + + # Some cloud NICs fail to load XDP if all RX queues are configured. To avoid this, + # we limit the number of queues to half of the maximum supported by the driver. + if ( + config['persist_config'][iface]['original_driver'] + in ethtool_channels_change_drv + ): + max_rx_queues = _get_max_xdp_rx_queues( + config['persist_config'][iface] + ) + channels_orig = config['persist_config'][iface]['channels'] + channels = {} + if channels_orig.get('rx'): + channels = {'rx': max_rx_queues, 'tx': max_rx_queues} + if channels_orig.get('combined'): + channels['combined'] = max_rx_queues + if channels: + control_host.set_eth_channels(f'defunct_{iface}', channels) + + vpp_control.xdp_iface_create( + host_if=f'defunct_{iface}', + name=iface, + **iface_config['xdp_api_params'], + ) + # replicate MAC address of a real interface + real_mac = control_host.get_eth_mac(f'defunct_{iface}') + vpp_control.set_iface_mac(iface, real_mac) + if 'promisc' in iface_config['xdp_options']: + control_host.set_promisc(f'defunct_{iface}', 'on') + control_host.set_status(f'defunct_{iface}', 'up') + control_host.flush_ip(f'defunct_{iface}') + # Rename Mellanox interfaces to hide them and create LCP properly + if ( + iface in Section.interfaces() + and control_host.get_eth_driver(iface) == 'mlx5_core' + ): + control_host.rename_iface(iface, f'defunct_{iface}') + control_host.set_status(f'defunct_{iface}', 'up') + control_host.flush_ip(f'defunct_{iface}') + # Create lcp + if iface not in Section.interfaces(): + vpp_control.lcp_pair_add(iface, iface) + + # For unknown reasons, if multiple interfaces later try to be + # initialized by configuration scripts, some of them may stuck + # in an endless UP/DOWN loop + # We found two workarounds - pause initialization (requires + # main code modifications). + # And this one + dev_index = iproute.link_lookup(ifname=iface)[0] + iproute.link('set', index=dev_index, state='up') + + # Set rx-mode. Should be configured after interface state set to UP + rx_mode = interface_rx_mode + if rx_mode: + # to hardware side + vpp_control.iface_rxmode(iface, rx_mode) + # to kernel side + lcp_name = vpp_control.lcp_pair_find(vpp_name_hw=iface).get( + 'vpp_name_kernel' + ) + vpp_control.iface_rxmode(lcp_name, rx_mode) + + # Synchronize routes via LCP + vpp_control.lcp_resync() + + except (VPPIOError, VPPValueError, VppNotRunningError) as e: + # if cannot connect to VPP or an error occurred then + # we need to stop vpp service and initialize interfaces + call(f'systemctl stop {service_name}.service') + for iface, iface_config in config['settings']['interface'].items(): + initialize_interface( + iface, iface_config['driver'], config['persist_config'][iface] + ) + + raise ConfigError( + f'An error occurred: {e}. ' + 'VPP service will be restarted with the previous configuration' + ) + + # Save persistent config + if 'persist_config' in config and config['persist_config']: + with JSONStorage('vpp_conf') as persist_config: + persist_config.write('eth_ifaces', config['persist_config']) + + # reinitialize interfaces, but not during the first boot + if boot_configuration_complete(): + call_dependents() + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpp_acl.py b/src/conf_mode/vpp_acl.py new file mode 100644 index 000000000..5b282dfdc --- /dev/null +++ b/src/conf_mode/vpp_acl.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import ipaddress + +from vyos import ConfigError + +from vyos.configdiff import Diff +from vyos.configdict import node_changed +from vyos.config import Config +from vyos.utils.network import get_protocol_by_name + +from vyos.vpp.utils import cli_ethernet_with_vifs_ifaces +from vyos.vpp.utils import cli_ifaces_list +from vyos.vpp.acl import Acl +from vyos.vpp.config_verify import verify_vpp_interface_not_a_member + + +# TCP flag names to bit values +TCP_FLAGS = { + 'FIN': 0x01, + 'SYN': 0x02, + 'RST': 0x04, + 'PSH': 0x08, + 'ACK': 0x10, + 'URG': 0x20, + 'ECN': 0x40, + 'CWR': 0x80, +} + +# ACL action flags +action_map = { + 'deny': 0, + 'permit': 1, + 'permit-reflect': 2, +} + + +def get_tcp_mask_value(set_flags, unset_flags): + mask = 0 + value = 0 + + for flag in set_flags + unset_flags: + bit = TCP_FLAGS.get(flag.upper()) + mask |= bit + if flag in set_flags: + value |= bit + + return mask, value + + +def get_port_first_last(port_range, protocol): + first_port = 0 + last_port = 65535 + if not port_range: + if protocol in ['icmp', 'ipv6-icmp']: + last_port = 255 + elif '-' not in port_range: + first_port = last_port = port_range + else: + first_port, last_port = port_range.split('-') + return int(first_port), int(last_port) + + +def create_ip_rules_list(rules): + rules_list = [] + for rule in rules.values(): + r = { + 'is_permit': action_map[rule.get('action')], + 'src_prefix': rule.get('source', {}).get('prefix', ''), + 'dst_prefix': rule.get('destination', {}).get('prefix', ''), + 'proto': ( + int(get_protocol_by_name(rule.get('protocol'))) + if rule.get('protocol') != 'all' + else 0 + ), + } + + tcp_flags = rule.get('tcp_flags', {}) + set_flags = tcp_flags.get('is_set', []) + unet_flags = tcp_flags.get('is_not_set', []) + tcp_mask, tcp_value = get_tcp_mask_value(set_flags, unet_flags) + r['tcp_flags_mask'] = tcp_mask + r['tcp_flags_value'] = tcp_value + + src_ports = rule.get('source', {}).get('port') + src_first_port, src_last_port = get_port_first_last( + src_ports, rule.get('protocol') + ) + r['srcport_or_icmptype_first'] = src_first_port + r['srcport_or_icmptype_last'] = src_last_port + + dst_ports = rule.get('destination', {}).get('port') + dst_first_port, dst_last_port = get_port_first_last( + dst_ports, rule.get('protocol') + ) + r['dstport_or_icmpcode_first'] = dst_first_port + r['dstport_or_icmpcode_last'] = dst_last_port + + rules_list.append(r) + + return rules_list + + +def create_mac_rules_list(rules): + rules_list = [] + for rule in rules.values(): + r = { + 'is_permit': action_map[rule.get('action')], + 'src_prefix': rule.get('prefix', ''), + 'src_mac': rule.get('mac_address', ''), + 'src_mac_mask': rule.get('mac_mask', ''), + } + rules_list.append(r) + + return rules_list + + +def get_config(config=None) -> dict: + if config: + conf = config + else: + conf = Config() + + base = ['vpp', 'acl'] + + # Get config_dict with default values + config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + with_recursive_defaults=True, + ) + + if not conf.exists(['vpp']): + config['remove_vpp'] = True + return config + + # Get effective config as we need full dictionary for deletion + effective_config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + effective=True, + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if not config: + config['remove'] = True + + changed_ip_ifaces = node_changed( + conf, + base + ['ip', 'interface'], + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + changed_mac_ifaces = node_changed( + conf, + base + ['mac', 'interface'], + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + config.update( + { + 'changed_ip_ifaces': changed_ip_ifaces, + 'changed_mac_ifaces': changed_mac_ifaces, + 'vpp_ifaces': list( + dict.fromkeys( + cli_ifaces_list(conf) + cli_ethernet_with_vifs_ifaces(conf) + ) + ), + } + ) + + if effective_config: + config.update({'effective': effective_config}) + + # VPP interface membership data for member-conflict checks + config['interfaces_vpp'] = conf.get_config_dict( + ['interfaces', 'vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + return config + + +def verify(config): + if 'remove' in config or 'remove_vpp' in config: + return None + + for acl_type in ['ip', 'mac']: + if acl_type in config: + acl = config.get(acl_type) + if 'tag_name' not in acl: + raise ConfigError(f'"tag-name" is required for "acl {acl_type}"') + + for acl_name, acl_config in acl.get('tag_name').items(): + if 'rule' not in acl_config: + raise ConfigError(f'Rules must be configured for ACL {acl_name}') + + for rule, rule_config in acl_config.get('rule').items(): + err_msg = f'Configuration error for {acl_type} ACL {acl_name} in rule {rule}:' + if 'action' not in rule_config: + raise ConfigError(f'{err_msg} action must be defined') + + for iface, iface_config in acl.get('interface', {}).items(): + if iface not in config.get('vpp_ifaces'): + raise ConfigError( + f'{iface} must be a VPP interface for ACL interface' + ) + verify_vpp_interface_not_a_member(iface, config) + + if 'ip' in config: + acl = config.get('ip') + for acl_name, acl_config in acl.get('tag_name').items(): + for rule, rule_config in acl_config.get('rule').items(): + err_msg = ( + f'Configuration error for {acl_type} ACL {acl_name} in rule {rule}:' + ) + + # verify IPv4 and IPv6 address family + src_prefix = rule_config.get('source', {}).get('prefix') + dst_prefix = rule_config.get('destination', {}).get('prefix') + src = ipaddress.ip_network(src_prefix) if src_prefix else None + dst = ipaddress.ip_network(dst_prefix) if dst_prefix else None + + if src and dst: + if src.version != dst.version: + raise ConfigError( + f'{err_msg} source and destination prefixes must be from the same IP family' + ) + elif src or dst: + family = src.version if src else dst.version + if family == 6: + raise ConfigError( + f'{err_msg} both source and destination prefixes must be defined for IPv6' + ) + + # verify protocol + protocol = rule_config.get('protocol') + if protocol != 'all': + proto = get_protocol_by_name(protocol) + if not isinstance(proto, int) and ( + not proto.isdigit() or int(proto) > 147 + ): + raise ConfigError( + f'{err_msg} protocol name {protocol} is not valid' + ) + + # verify TCP flags + if 'tcp_flags' in rule_config: + if rule_config.get('protocol') != 'tcp': + raise ConfigError( + f'{err_msg} protocol must be tcp when specifying tcp flags' + ) + + tcp_flags = rule_config.get('tcp_flags', {}) + flags_set = tcp_flags.get('is_set', []) + flags_not_set = tcp_flags.get('is_not_set', []) + + # same flag cannot be both set and not set + conflict = [flag for flag in flags_set if flag in flags_not_set] + if conflict: + raise ConfigError( + f'{err_msg} cannot match a TCP flag as both set and not set: ' + f'{", ".join(sorted(conflict))}' + ) + + for iface, iface_config in acl.get('interface', {}).items(): + if not any(key in iface_config for key in ('input', 'output')): + raise ConfigError( + f'Please specify direction input/output for interface {iface}' + ) + + for direction in ['input', 'output']: + if direction in iface_config: + iface_acl = iface_config.get(direction) + if 'acl_tag' not in iface_acl: + raise ConfigError( + f'"acl-tag" is required for {direction} interface {iface}' + ) + + used_names = [] + for tag, tag_conf in iface_acl.get('acl_tag').items(): + if 'tag_name' not in tag_conf: + raise ConfigError( + f'"tag-name" is required for {direction} interface {iface} with acl-tag {tag}' + ) + name = tag_conf.get('tag_name') + if name not in acl.get('tag_name').keys(): + raise ConfigError( + f'ACL with tag-name {name} does not exist. ' + f'Cannot use it for {direction} interface {iface}' + ) + if name in used_names: + raise ConfigError( + f'ACL with tag-name {name} is already used for {direction} interface {iface}' + ) + used_names.append(name) + + if 'mac' in config: + acl = config.get('mac') + for iface, iface_config in acl.get('interface', {}).items(): + if 'tag_name' not in iface_config: + raise ConfigError(f'"tag-name" is required for interface {iface}') + name = iface_config.get('tag_name') + if name not in acl.get('tag_name').keys(): + raise ConfigError( + f'ACL with tag-name {name} does not exist. Cannot use it for interface {iface}' + ) + + +def generate(config): + pass + + +def apply(config): + if 'remove_vpp' in config: + return None + + acl = Acl() + + if 'effective' in config: + # Delete ACL ip + if 'ip' in config.get('effective'): + remove_config_ip = config.get('effective').get('ip') + + # Delete ACL interfaces + for interface in config.get('changed_ip_ifaces'): + acl.delete_acl_interface(interface) + + # Delete ACLs + for acl_name in remove_config_ip.get('tag_name'): + if acl_name not in config.get('ip', {}).get('tag_name', {}): + acl.delete_acl(acl_name) + + # Delete ACL mac + if 'mac' in config.get('effective'): + remove_config_mac = config.get('effective').get('mac') + + # Delete ACL interfaces + for interface in config.get('changed_mac_ifaces'): + acl.delete_acl_mac_interface(interface) + + # Delete ACL mac + for acl_name in remove_config_mac.get('tag_name'): + if acl_name not in config.get('mac', {}).get('tag_name', {}): + acl.delete_acl_mac(acl_name) + + if 'remove' in config: + return None + + # Add or replace ACL ip + config_ip = config.get('ip', {}) + for acl_name in config_ip.get('tag_name', {}): + rules = create_ip_rules_list( + config_ip.get('tag_name').get(acl_name).get('rule') + ) + acl.add_replace_acl(acl_name, rules) + + for iface, iface_config in config_ip.get('interface', {}).items(): + input_tags = [ + v['tag_name'] + for v in iface_config.get('input', {}).get('acl_tag', {}).values() + ] + output_tags = [ + v['tag_name'] + for v in iface_config.get('output', {}).get('acl_tag', {}).values() + ] + acl.add_acl_interface(iface, input_tags, output_tags) + + # Add or replace ACL mac + config_mac = config.get('mac', {}) + for acl_name in config_mac.get('tag_name', {}): + rules = create_mac_rules_list( + config_mac.get('tag_name').get(acl_name).get('rule') + ) + acl.add_replace_acl_mac(acl_name, rules) + + for iface, iface_config in config_mac.get('interface', {}).items(): + acl.add_acl_mac_interface(iface, iface_config.get('tag_name')) + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpp_interfaces_bonding.py b/src/conf_mode/vpp_interfaces_bonding.py new file mode 100644 index 000000000..c7fd43bb4 --- /dev/null +++ b/src/conf_mode/vpp_interfaces_bonding.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdep import set_dependents, call_dependents +from vyos.configverify import verify_mtu_ipv6 +from vyos import ConfigError +from vyos.utils.assertion import assert_mac +from vyos.utils.process import is_systemd_service_active + +from vyos.ifconfig import Interface +from vyos.ifconfig.vpp import VPPBondInterface +from vyos.vpp.config_deps import deps_bond_dict +from vyos.vpp.config_deps import deps_bridge_dict +from vyos.vpp.config_deps import deps_xconnect_dict +from vyos.vpp.config_verify import verify_member_conflicts +from vyos.vpp.config_verify import verify_vpp_remove_bridge_interface +from vyos.vpp.config_verify import verify_vpp_remove_xconnect_interface +from vyos.vpp.config_verify import verify_vpp_remove_interface +from vyos.vpp.config_verify import verify_vpp_interface_not_in_feature +from vyos.vpp.utils import cli_ifaces_list + + +def _get_bond_mode(mode_name: str) -> int: + """Convert VyOS CLI name bonding mode to VPP compatible""" + mode_mapping = { + 'round-robin': 1, + 'active-backup': 2, + 'xor-hash': 3, + 'broadcast': 4, + '802.3ad': 5, + } + + return mode_mapping.get(mode_name, 5) + + +def _get_bond_lb(lb_name: str) -> int: + """Convert VyOS CLI name bonding load balance to VPP compatible""" + lb_mapping = { + 'layer2': 0, + 'layer2+3': 2, + 'layer3+4': 1, + } + + return lb_mapping.get(lb_name, 0) + + +def get_config(config=None) -> dict: + """Get Bonding interface configuration + + Args: + config (vyos.config.Config, optional): The VyOS configuration dictionary + Returns: + dict: Bonding interface configuration + """ + if config: + conf = config + else: + conf = Config() + + base = ['interfaces', 'vpp', 'bonding'] + + ifname, config = get_interface_dict(conf, base) + + # Get pppoe-server interfaces + config['pppoe_ifaces'] = conf.list_nodes(['service', 'pppoe-server', 'interface']) + + if not conf.exists(['vpp']) and not conf.exists(base): + config['remove_vpp'] = True + return config + + config['vpp_ifaces'] = cli_ifaces_list(conf, 'candidate') + + # convert values to VPP compatible + if 'mode' in config: + config['mode'] = _get_bond_mode(config['mode']) + if 'hash_policy' in config: + config['hash_policy'] = _get_bond_lb(config['hash_policy']) + + # Get 'vpp settings' config with default values + config['vpp_settings'] = conf.get_config_dict( + ['vpp', 'settings'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + # VPP config for member-in-feature checks + config['vpp'] = conf.get_config_dict( + ['vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + config['bond_members'] = deps_bond_dict(conf) + + # Dependency + config['xconn_members'] = deps_xconnect_dict(conf) + if ifname in config['xconn_members']: + for xconn_iface in config['xconn_members'][ifname]: + set_dependents('vpp_interfaces_xconnect', conf, xconn_iface) + + config['bridge_members'] = deps_bridge_dict(conf) + if ifname in config['bridge_members']: + for bridge_iface in config['bridge_members'][ifname]: + set_dependents('vpp_interfaces_bridge', conf, bridge_iface) + + # PPPoE dependency + if any(i == ifname or i.startswith(f'{ifname}.') for i in config['pppoe_ifaces']): + set_dependents('pppoe_server', conf) + + # NAT dependency + if conf.exists(['vpp', 'nat', 'nat44']): + set_dependents('vpp_nat_nat44', conf) + if conf.exists(['vpp', 'nat', 'cgnat']): + set_dependents('vpp_nat_cgnat', conf) + + # ACL dependency + if conf.exists(['vpp', 'acl']): + set_dependents('vpp_acl', conf) + + # IPFIX dependency + if conf.exists(['vpp', 'ipfix']): + set_dependents('vpp_ipfix', conf) + + return config + + +def verify(config): + ifname = config['ifname'] + if 'deleted' in config and any( + i == ifname or i.startswith(f'{ifname}.') + for i in config.get('pppoe_ifaces', []) + ): + raise ConfigError( + 'Cannot remove interface: it is still in use by the PPPoE server' + ) + + if 'remove_vpp' in config: + return None + + verify_vpp_remove_xconnect_interface(config) + verify_vpp_remove_bridge_interface(config) + + if 'deleted' in config: + verify_vpp_remove_interface(ifname, config.get('vpp'), match_vlans=True) + return None + + if not is_systemd_service_active('vpp.service'): + raise ConfigError( + 'Cannot configure VPP bonding interface: vpp.service is not running' + ) + + # Member must belong to VPP + for iface in config.get('member', {}).get('interface', []): + if iface not in config['vpp_ifaces']: + raise ConfigError(f'{iface} must be a VPP interface for bonding') + + # Each interface can belong only to one bond + bond_members = config['bond_members'][iface] + if len(bond_members) > 1: + raise ConfigError( + f'Interface {iface} cannot be a member of multiple bonding interfaces: {", ".join(bond_members)}' + ) + + verify_member_conflicts(iface, config, 'bond') + verify_vpp_interface_not_in_feature(iface, config.get('vpp')) + + if mtu := config.get('mtu'): + mtu = int(mtu) + max_mtu = Interface(iface).get_max_mtu() + min_mtu = Interface(iface).get_min_mtu() + if mtu > max_mtu: + raise ConfigError( + f'Configured MTU is greater than member interface "{iface}" maximum of {max_mtu}!' + ) + if mtu < min_mtu: + raise ConfigError( + f'Configured MTU is less than member interface "{iface}" minimum of {min_mtu}!' + ) + + if 'mac' in config: + mac = config['mac'] + try: + assert_mac(mac, test_all_zero=False) + except Exception: + raise ConfigError( + f'Cannot use {mac}: it is a multicast MAC address. Please provide a unicast MAC address.' + ) + + for vif_remove in config.get('vif_remove', []): + vif_iface = f'{ifname}.{vif_remove}' + if vif_iface in config.get('pppoe_ifaces', []): + raise ConfigError( + f'Cannot remove interface {vif_iface}: it is still in use by the PPPoE server' + ) + verify_vpp_remove_interface(vif_iface, config.get('vpp')) + + verify_mtu_ipv6(config) + + +def generate(config): + pass + + +def apply(config): + if 'remove_vpp' in config: + return None + + ifname = config.get('ifname') + bond = VPPBondInterface(ifname, config) + bond.remove() + + if 'deleted' in config: + return + + bond.update(config) + + call_dependents() + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpp_interfaces_bridge.py b/src/conf_mode/vpp_interfaces_bridge.py new file mode 100644 index 000000000..4d65690ce --- /dev/null +++ b/src/conf_mode/vpp_interfaces_bridge.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos import ConfigError +from vyos.utils.process import is_systemd_service_active + +from vyos.ifconfig.vpp import VPPBridgeInterface +from vyos.vpp.config_deps import deps_bond_dict +from vyos.vpp.config_deps import deps_bridge_dict +from vyos.vpp.config_deps import deps_xconnect_dict +from vyos.vpp.config_verify import verify_member_conflicts +from vyos.vpp.config_verify import verify_vpp_interface_not_in_feature + + +def get_config(config=None) -> dict: + """Get Bridge interface configuration + + Args: + config (vyos.config.Config, optional): The VyOS configuration dictionary + Returns: + dict: Bridge interface configuration + """ + if config: + conf = config + else: + conf = Config() + + base = ['interfaces', 'vpp', 'bridge'] + + ifname, config = get_interface_dict(conf, base) + + if not conf.exists(['vpp']) and not conf.exists(base): + config['remove_vpp'] = True + return config + + # Get global vpp interfaces for verify + config['vpp_interfaces'] = conf.get_config_dict( + ['vpp', 'settings', 'interface'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + # Get all gre interfaces config + config['gre_interfaces'] = conf.get_config_dict( + ['interfaces', 'vpp', 'gre'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + with_recursive_defaults=True, + ) + + config['bond_members'] = deps_bond_dict(conf) + config['bridge_members'] = deps_bridge_dict(conf) + config['xconn_members'] = deps_xconnect_dict(conf) + + # VPP config for member-in-feature checks + config['vpp'] = conf.get_config_dict( + ['vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + return config + + +def verify(config): + if 'deleted' in config or 'remove_vpp' in config: + return None + + if not is_systemd_service_active('vpp.service'): + raise ConfigError( + 'Cannot configure VPP bridge interface: vpp.service is not running' + ) + + # Check if interface exists in vpp before adding to bridge-domain + allowed_prefixes = ('vppbond', 'vppgre', 'vpplo', 'vppvxlan') + + if 'member' in config: + bvi_exists = False + for member, member_config in ( + config.get('member', {}).get('interface', {}).items() + ): + # Check if the interface exists in VPP settings or starts with allowed prefixes + if not ( + member in config.get('vpp_interfaces', {}) + or member.startswith(allowed_prefixes) + ): + raise ConfigError( + f"Interface '{member}' not found in 'vpp settings interface' or does not start with allowed prefixes {allowed_prefixes}" + ) + + # Each interface can belong only to one bridge + bridge_members = config['bridge_members'][member] + if len(bridge_members) > 1: + raise ConfigError( + f'Interface {member} is added to more than one bridge: {", ".join(bridge_members)}' + ) + + verify_member_conflicts(member, config, 'bridge') + verify_vpp_interface_not_in_feature(member, config.get('vpp')) + + # Check if BVI is already defined, only one BVI per bridge domain is allowed + if 'bvi' in member_config: + if bvi_exists: + raise ConfigError("Only one BVI per bridge domain is allowed") + if not member.startswith('vpplo'): + raise ConfigError("BVI can only be defined on loopback interface") + bvi_exists = True + + # check GRE tunnels as part of the bridge, only tunnel-type "teb" is allowed + # set interfaces vpp bridge vppbr1 member interface vppgre1 + # set interfaces vpp gre vppgre1 tunnel-type teb + if member.startswith('vppgre'): + if member in config.get('gre_interfaces'): + gre_config = config.get('gre_interfaces').get(member) + if gre_config.get('tunnel_type') != 'teb': + raise ConfigError( + f'GRE interface "{member}" in bridge must have tunnel-type "teb". ' + f'Current tunnel-type is "{gre_config.get("tunnel_type")}".' + ) + + +def generate(config): + pass + + +def apply(config): + if 'remove_vpp' in config: + return None + + ifname = config.get('ifname') + bridge = VPPBridgeInterface(ifname) + bridge.remove() + + if 'deleted' in config: + return + + bridge.update(config) + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpp_interfaces_gre.py b/src/conf_mode/vpp_interfaces_gre.py new file mode 100644 index 000000000..3756449a2 --- /dev/null +++ b/src/conf_mode/vpp_interfaces_gre.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos import ConfigError + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdep import set_dependents, call_dependents +from vyos.configverify import verify_mtu_ipv6 +from vyos.utils.process import is_systemd_service_active + +from vyos.ifconfig.vpp import VPPGREInterface +from vyos.vpp.config_deps import deps_bridge_dict +from vyos.vpp.config_deps import deps_xconnect_dict +from vyos.vpp.config_verify import verify_vpp_remove_bridge_interface +from vyos.vpp.config_verify import verify_vpp_remove_xconnect_interface +from vyos.vpp.config_verify import verify_vpp_tunnel_source_address +from vyos.vpp.utils import cli_ethernet_with_vifs_ifaces + + +def get_config(config=None) -> dict: + """Get GRE interface configuration + + Args: + config (vyos.config.Config, optional): The VyOS configuration dictionary + Returns: + dict: GRE interface configuration + """ + if config: + conf = config + else: + conf = Config() + + base = ['interfaces', 'vpp', 'gre'] + + ifname, config = get_interface_dict(conf, base) + + if not conf.exists(['vpp']) and not conf.exists(base): + config['remove_vpp'] = True + return config + + # list of all Ethernet interfaces with vifs + config['vpp_ether_vif_ifaces'] = cli_ethernet_with_vifs_ifaces(conf) + + # Dependency + config['xconn_members'] = deps_xconnect_dict(conf) + if ifname in config['xconn_members']: + for xconn_iface in config['xconn_members'][ifname]: + set_dependents('vpp_interfaces_xconnect', conf, xconn_iface) + + config['bridge_members'] = deps_bridge_dict(conf) + if ifname in config['bridge_members']: + for bridge_iface in config['bridge_members'][ifname]: + set_dependents('vpp_interfaces_bridge', conf, bridge_iface) + + # Get 'vpp settings' config + config['vpp_settings'] = conf.get_config_dict( + ['vpp', 'settings'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + # Get all gre interfaces config + config['gre_interfaces'] = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + with_recursive_defaults=True, + ) + + # NAT dependency + if conf.exists(['vpp', 'nat', 'nat44']): + set_dependents('vpp_nat_nat44', conf) + if conf.exists(['vpp', 'nat', 'cgnat']): + set_dependents('vpp_nat_cgnat', conf) + + # ACL dependency + if conf.exists(['vpp', 'acl']): + set_dependents('vpp_acl', conf) + + return config + + +def verify(config): + # No need to verify anything if vpp is removed + if 'remove_vpp' in config: + return None + + verify_vpp_remove_xconnect_interface(config) + verify_vpp_remove_bridge_interface(config) + + # config removed + if 'deleted' in config: + return None + + if not is_systemd_service_active('vpp.service'): + raise ConfigError( + 'Cannot configure VPP GRE interface: vpp.service is not running' + ) + + # source-address and remote are mandatory options + required_keys = {'source_address', 'remote', 'tunnel_type'} + if not all(key in config for key in required_keys): + missing_keys = required_keys - set(config.keys()) + raise ConfigError( + f"Required options are missing: {', '.join(missing_keys).replace('_', '-')}" + ) + + # verify source address and remote address + verify_vpp_tunnel_source_address(config) + if config.get('source_address') == config.get('remote'): + raise ConfigError('Remote address must not be the same as source address') + + verify_mtu_ipv6(config) + + # Disable checks as point-to-multipoint mode does not work without 'teib' feature that is not implemented yet + # # check multipoint mode + # if config.get('mode') == 'point-to-multipoint': + # # For multipoint mode, remote IP must be 0.0.0.0 + # if config.get('remote') != '0.0.0.0': + # raise ConfigError('For point-to-multipoint mode, remote must be 0.0.0.0') + # + # # Only one multipoint GRE tunnel is allowed from the same source address + # # set interfaces vpp gre vppgre0 mode 'point-to-multipoint' + # # set interfaces vpp gre vppgre0 remote '0.0.0.0' + # # set interfaces vpp gre vppgre0 source-address '192.0.2.1' + # # set interfaces vpp gre vppgre1 mode 'point-to-multipoint' + # # set interfaces vpp gre vppgre1 remote '0.0.0.0' + # # set interfaces vpp gre vppgre1 source-address '192.0.2.1' + # for other_iface, other_iface_config in config['gre_interfaces'].items(): + # if other_iface == config['ifname']: + # continue + # if other_iface_config['mode'] == 'point-to-multipoint': + # if config['source_address'] == other_iface_config.get('source_address'): + # raise ConfigError( + # 'Only one multipoint GRE tunnel is allowed from the same source address' + # ) + + +def generate(config): + pass + + +def apply(config): + if 'remove_vpp' in config: + return None + + ifname = config.get('ifname') + gre = VPPGREInterface(ifname, config) + gre.remove() + + if 'deleted' in config: + return + + gre.update(config) + + call_dependents() + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpp_interfaces_ipip.py b/src/conf_mode/vpp_interfaces_ipip.py new file mode 100644 index 000000000..dd4a19e36 --- /dev/null +++ b/src/conf_mode/vpp_interfaces_ipip.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos import ConfigError + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdep import set_dependents, call_dependents +from vyos.configverify import verify_mtu_ipv6 +from vyos.utils.process import is_systemd_service_active + +from vyos.ifconfig.vpp import VPPIPIPInterface +from vyos.vpp.config_deps import deps_xconnect_dict +from vyos.vpp.config_verify import ( + verify_vpp_remove_xconnect_interface, + verify_vpp_tunnel_source_address, +) +from vyos.vpp.utils import cli_ethernet_with_vifs_ifaces + + +def get_config(config=None) -> dict: + """Get IPIP interface configuration + + Args: + config (vyos.config.Config, optional): The VyOS configuration dictionary + Returns: + dict: IPIP interface configuration + """ + if config: + conf = config + else: + conf = Config() + + base = ['interfaces', 'vpp', 'ipip'] + + ifname, config = get_interface_dict(conf, base) + + if not conf.exists(['vpp']) and not conf.exists(base): + config['remove_vpp'] = True + return config + + # list of all Ethernet interfaces with vifs + config['vpp_ether_vif_ifaces'] = cli_ethernet_with_vifs_ifaces(conf) + + # Dependency + config['xconn_members'] = deps_xconnect_dict(conf) + if ifname in config['xconn_members']: + for xconn_iface in config['xconn_members'][ifname]: + set_dependents('vpp_interfaces_xconnect', conf, xconn_iface) + + # Get 'vpp settings' config with default values + config['vpp_settings'] = conf.get_config_dict( + ['vpp', 'settings'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + return config + + +def verify(config): + # No need to verify anything if vpp is removed + if 'remove_vpp' in config: + return None + + verify_vpp_remove_xconnect_interface(config) + + # config removed + if 'deleted' in config: + return None + + if not is_systemd_service_active('vpp.service'): + raise ConfigError( + 'Cannot configure VPP ipip interface: vpp.service is not running' + ) + + # source-address and remote are mandatory options + required_keys = {'source_address', 'remote'} + if not all(key in config for key in required_keys): + missing_keys = required_keys - set(config.keys()) + raise ConfigError( + f"Required options are missing: {', '.join(missing_keys).replace('_', '-')}" + ) + + # verify source address and remote address + verify_vpp_tunnel_source_address(config) + if config.get('source_address') == config.get('remote'): + raise ConfigError('Remote address must not be the same as source address') + + verify_mtu_ipv6(config) + + +def generate(config): + pass + + +def apply(config): + if 'remove_vpp' in config: + return None + + ifname = config.get('ifname') + # Delete interface + ipip = VPPIPIPInterface(ifname, config) + ipip.remove() + + if 'deleted' in config: + return None + + ipip.update(config) + + call_dependents() + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpp_interfaces_loopback.py b/src/conf_mode/vpp_interfaces_loopback.py new file mode 100644 index 000000000..2f7b59354 --- /dev/null +++ b/src/conf_mode/vpp_interfaces_loopback.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos import ConfigError + +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdep import set_dependents, call_dependents +from vyos.utils.process import is_systemd_service_active + +from vyos.ifconfig.vpp import VPPLoopbackInterface + + +def get_config(config=None) -> dict: + """Get Loopback interface configuration + + Args: + config (vyos.config.Config, optional): The VyOS configuration dictionary + Returns: + dict: Loopback interface configuration + """ + if config: + conf = config + else: + conf = Config() + + base = ['interfaces', 'vpp', 'loopback'] + + ifname, config = get_interface_dict(conf, base) + + if not conf.exists(['vpp']) and not conf.exists(base): + config['remove_vpp'] = True + return config + + # Get 'vpp settings' config + config['vpp_settings'] = conf.get_config_dict( + ['vpp', 'settings'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + # NAT dependency + if conf.exists(['vpp', 'nat', 'nat44']): + set_dependents('vpp_nat_nat44', conf) + if conf.exists(['vpp', 'nat', 'cgnat']): + set_dependents('vpp_nat_cgnat', conf) + + # ACL dependency + if conf.exists(['vpp', 'acl']): + set_dependents('vpp_acl', conf) + + return config + + +def verify(config): + # No need to verify anything if vpp is removed + if 'remove_vpp' in config: + return None + + if not is_systemd_service_active('vpp.service'): + raise ConfigError( + 'Cannot configure VPP loopback interface: vpp.service is not running' + ) + + +def generate(config): + pass + + +def apply(config): + if 'remove_vpp' in config: + return None + + ifname = config.get('ifname') + loopback = VPPLoopbackInterface(ifname, config) + loopback.remove() + + if 'deleted' in config: + return + + loopback.update(config) + + call_dependents() + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpp_interfaces_vxlan.py b/src/conf_mode/vpp_interfaces_vxlan.py new file mode 100644 index 000000000..ac9a9517b --- /dev/null +++ b/src/conf_mode/vpp_interfaces_vxlan.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos import ConfigError + +from vyos.base import Warning +from vyos.config import Config +from vyos.configdict import get_interface_dict +from vyos.configdep import set_dependents, call_dependents +from vyos.configverify import verify_mtu_ipv6 +from vyos.ifconfig import Interface +from vyos.template import is_ipv6 +from vyos.utils.network import get_interfaces_by_ip +from vyos.utils.process import is_systemd_service_active + +from vyos.ifconfig.vpp import VPPVXLANInterface +from vyos.vpp.config_deps import deps_bridge_dict +from vyos.vpp.config_deps import deps_xconnect_dict +from vyos.vpp.config_verify import verify_vpp_remove_bridge_interface +from vyos.vpp.config_verify import verify_vpp_remove_xconnect_interface +from vyos.vpp.config_verify import verify_vpp_tunnel_source_address +from vyos.vpp.utils import cli_ethernet_with_vifs_ifaces + + +def get_config(config=None) -> dict: + """Get VXLAN interface configuration + + Args: + config (vyos.config.Config, optional): The VyOS configuration dictionary + Returns: + dict: VXLAN interface configuration + """ + if config: + conf = config + else: + conf = Config() + + base = ['interfaces', 'vpp', 'vxlan'] + + ifname, config = get_interface_dict(conf, base) + + if not conf.exists(['vpp']) and not conf.exists(base): + config['remove_vpp'] = True + return config + + # list of all Ethernet interfaces with vifs + config['vpp_ether_vif_ifaces'] = cli_ethernet_with_vifs_ifaces(conf) + + # Dependency + config['xconn_members'] = deps_xconnect_dict(conf) + if ifname in config['xconn_members']: + for xconn_iface in config['xconn_members'][ifname]: + set_dependents('vpp_interfaces_xconnect', conf, xconn_iface) + + config['bridge_members'] = deps_bridge_dict(conf) + if ifname in config['bridge_members']: + for bridge_iface in config['bridge_members'][ifname]: + set_dependents('vpp_interfaces_bridge', conf, bridge_iface) + + # Get 'vpp settings' config with default values + config['vpp_settings'] = conf.get_config_dict( + ['vpp', 'settings'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + ) + + # NAT dependency + if conf.exists(['vpp', 'nat', 'nat44']): + set_dependents('vpp_nat_nat44', conf) + if conf.exists(['vpp', 'nat', 'cgnat']): + set_dependents('vpp_nat_cgnat', conf) + + # ACL dependency + if conf.exists(['vpp', 'acl']): + set_dependents('vpp_acl', conf) + + return config + + +def verify(config): + # No need to verify anything if vpp is removed + if 'remove_vpp' in config: + return None + + verify_vpp_remove_xconnect_interface(config) + verify_vpp_remove_bridge_interface(config) + + if 'deleted' in config: + return None + + if not is_systemd_service_active('vpp.service'): + raise ConfigError( + 'Cannot configure VPP vxlan interface: vpp.service is not running' + ) + + required_keys = {'source_address', 'remote', 'vni'} + if not all(key in config for key in required_keys): + missing_keys = required_keys - set(config.keys()) + raise ConfigError( + f"Required options are missing: {', '.join(missing_keys).replace('_', '-')}" + ) + + # verify source address and remote address + verify_vpp_tunnel_source_address(config) + if config.get('source_address') == config.get('remote'): + raise ConfigError('Remote address must not be the same as source address') + + # VXLAN adds at least an overhead of 50 bytes - we need to check the + # underlying device if our VXLAN package is not going to be fragmented! + source_address = config['source_address'] + vxlan_overhead = 50 + if is_ipv6(source_address): + # IPv6 adds an extra 20 bytes overhead because the IPv6 header is 20 + # bytes larger than the IPv4 header - assuming no extra options are + # in use. + vxlan_overhead += 20 + + ifaces_with_ip = get_interfaces_by_ip(source_address) + vpp_ifaces = config['vpp_ether_vif_ifaces'] + matching_iface = next((iface for iface in ifaces_with_ip if iface in vpp_ifaces)) + + lower_mtu = Interface(matching_iface).get_mtu() + if lower_mtu < (int(config['mtu']) + vxlan_overhead): + Warning( + f'Underlying device MTU is too small ({lower_mtu} bytes) ' + f'for VXLAN overhead ({vxlan_overhead} bytes!)' + ) + + verify_mtu_ipv6(config) + + +def generate(config): + pass + + +def apply(config): + if 'remove_vpp' in config: + return None + + ifname = config.get('ifname') + # Delete interface + vxlan = VPPVXLANInterface(ifname, config) + vxlan.remove() + + if 'deleted' in config: + return None + + vxlan.update(config) + + call_dependents() + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpp_interfaces_xconnect.py b/src/conf_mode/vpp_interfaces_xconnect.py new file mode 100644 index 000000000..29f2da520 --- /dev/null +++ b/src/conf_mode/vpp_interfaces_xconnect.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.config import Config +from vyos import ConfigError +from vyos.configdict import get_interface_dict +from vyos.utils.process import is_systemd_service_active + +from vyos.ifconfig.vpp import VPPXconnectInterface +from vyos.vpp.config_deps import deps_bond_dict +from vyos.vpp.config_deps import deps_bridge_dict +from vyos.vpp.config_deps import deps_xconnect_dict +from vyos.vpp.config_verify import verify_member_conflicts +from vyos.vpp.config_verify import verify_vpp_interface_not_in_feature +from vyos.vpp.utils import cli_ifaces_list + + +def get_config(config=None) -> dict: + """Get Xconnect interface configuration + + Args: + config (vyos.config.Config, optional): The VyOS configuration dictionary + Returns: + dict: Bridge interface configuration + """ + if config: + conf = config + else: + conf = Config() + + base = ['interfaces', 'vpp', 'xconnect'] + + ifname, config = get_interface_dict(conf, base) + + if not conf.exists(['vpp']) and not conf.exists(base): + config['remove_vpp'] = True + return config + + # Get effective config as we need full dictionary per interface delete + effective_config = conf.get_config_dict( + base + [ifname], + key_mangling=('-', '_'), + effective=True, + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if effective_config: + config.update({'effective': effective_config}) + + config['bond_members'] = deps_bond_dict(conf) + config['bridge_members'] = deps_bridge_dict(conf) + config['xconn_members'] = deps_xconnect_dict(conf) + config['vpp_ifaces'] = cli_ifaces_list(conf, 'candidate') + + # VPP config for member-in-feature checks + config['vpp'] = conf.get_config_dict( + ['vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + return config + + +def verify(config): + if 'deleted' in config or 'remove_vpp' in config: + return None + + if not is_systemd_service_active('vpp.service'): + raise ConfigError( + 'Cannot configure layer 2 cross-connect: vpp.service is not running' + ) + + # Xconnect requires 2 members + if len(config.get('member', {}).get('interface')) != 2: + raise ConfigError('Cross connect requires 2 members') + + not_allowed_prefixes = ('vppbond', 'vppbr', 'vpplo') + for iface in config.get('member', {}).get('interface', []): + # Ensure the interface is allowed as xconnect member + if iface.startswith(not_allowed_prefixes): + raise ConfigError(f'{iface} cannot be configured as xconnect member') + # Member must belong to VPP + if iface not in config['vpp_ifaces']: + raise ConfigError(f'{iface} must be a VPP interface for xconnect') + + # Each interface can belong only to one xconnect + xconn_members = config['xconn_members'][iface] + if len(xconn_members) > 1: + raise ConfigError( + f'Interface {iface} added to more than one xconnect: {", ".join(xconn_members)}' + ) + + verify_member_conflicts(iface, config, 'xconn') + verify_vpp_interface_not_in_feature(iface, config.get('vpp')) + + +def generate(config): + pass + + +def apply(config): + if 'remove_vpp' in config: + return None + + ifname = config.get('ifname') + xconnect = VPPXconnectInterface(ifname) + + # Delete xconnect + if 'effective' in config: + remove_config = config.get('effective') + members = remove_config['member']['interface'] + xconnect.remove(members) + + if 'deleted' in config: + return None + + # Add xconnect + xconnect.update(config) + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpp_ipfix.py b/src/conf_mode/vpp_ipfix.py new file mode 100644 index 000000000..8a7633389 --- /dev/null +++ b/src/conf_mode/vpp_ipfix.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 import ConfigError +from vyos.config import Config +from vyos.vpp.ipfix import IPFIX +from vyos.vpp.utils import cli_ifaces_list +from vyos.vpp.utils import vpp_iface_name_transform +from vyos.vpp.control_vpp import VPPControl +from vyos.vpp.config_verify import verify_vpp_interface_not_a_member + + +def get_config(config=None) -> dict: + if config: + conf = config + else: + conf = Config() + + base = ['vpp', 'ipfix'] + + # Get config_dict with default values + config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + with_recursive_defaults=True, + ) + + # Get effective config as we need full dictionary for deletion + effective_config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + effective=True, + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if effective_config: + config.update({'effective': effective_config}) + + if not conf.exists(base): + config['remove'] = True + return config + + # Add list of VPP interfaces to the config + config.update({'vpp_ifaces': cli_ifaces_list(conf)}) + + # VPP interface membership data for member-conflict checks + config['interfaces_vpp'] = conf.get_config_dict( + ['interfaces', 'vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + return config + + +def verify(config): + if 'remove' in config: + return None + + # Verify that at least one interface is configured + if 'interface' not in config or not config['interface']: + raise ConfigError( + 'At least one interface must be configured for IPFIX monitoring' + ) + + # Verify that all interfaces specified exist in VPP + vpp = VPPControl() + for interface in config['interface']: + vpp_iface_name = vpp_iface_name_transform(interface) + if vpp.get_sw_if_index(vpp_iface_name) is None: + raise ConfigError( + f'{interface} must be a VPP interface for IPFIX monitoring' + ) + verify_vpp_interface_not_a_member(interface, config) + + # Verify that at least one collector is configured + if 'collector' not in config: + raise ConfigError('At least one IPFIX collector must be configured') + + # Enforce that only one collector is configured (VPP limitation) + if len(config['collector']) > 1: + raise ConfigError('Only one IPFIX collector can be configured') + + # Verify that source_address is specified + for c, c_conf in config.get('collector', {}).items(): + if 'source_address' not in c_conf: + raise ConfigError(f'Source address must be specified for collector {c}') + + # Verify active timeout is not greater than inactive timeout + if 'active_timeout' in config and 'inactive_timeout' in config: + active_timeout = int(config['active_timeout']) + inactive_timeout = int(config['inactive_timeout']) + + if active_timeout > inactive_timeout: + raise ConfigError( + f'Active timeout ({active_timeout}) cannot be greater than inactive timeout ({inactive_timeout})' + ) + + +def generate(config): + # No templates to render for IPFIX + pass + + +def apply(config): + i = IPFIX() + + # Remove collectors + for c, c_conf in config.get('effective', {}).get('collector', {}).items(): + i.ipfix_exporter_delete() + + # Remove interfaces + for iface, iface_conf in config.get('effective', {}).get('interface', {}).items(): + iface = vpp_iface_name_transform(iface) + direction = iface_conf.get('direction') + which = iface_conf.get('flow_variant') + i.flowprobe_interface_delete(iface, direction=direction, which=which) + + if 'remove' in config: + return None + + active_timeout = config.get('active_timeout') + inactive_timeout = config.get('inactive_timeout') + flowprobe_record = config.get('flowprobe_record') + + # Flowprobe params + i.flowprobe_set_params( + active_timer=int(active_timeout), + passive_timer=int(inactive_timeout), + record_flags=list(flowprobe_record), + ) + + # Collectors + for c, c_conf in config.get('collector', {}).items(): + collector_address = c + collector_port = c_conf.get('port') + src_address = c_conf.get('source_address') + template_interval = c_conf.get('template_interval') + path_mtu = c_conf.get('path_mtu') + udp_checksum = 'udp_checksum' in c_conf + + i.collector_address = collector_address + i.src_address = src_address + i.collector_port = int(collector_port) + i.template_interval = int(template_interval) + i.path_mtu = int(path_mtu) + i.udp_checksum = udp_checksum + # VRF support is not currently implemented; exporter is always configured in the default VRF (0). + # Consider adding VRF support in the future if needed. + i.vrf_id = 0 + + i.set_ipfix_exporter() + + # Interfaces + if 'interface' in config: + for iface, iface_config in config.get('interface', {}).items(): + iface = vpp_iface_name_transform(iface) + direction = iface_config.get('direction') + which = iface_config.get('flow_variant') + + i.flowprobe_interface_add(iface, direction=direction, which=which) + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpp_nat_cgnat.py b/src/conf_mode/vpp_nat_cgnat.py new file mode 100644 index 000000000..838d13239 --- /dev/null +++ b/src/conf_mode/vpp_nat_cgnat.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos import ConfigError +from vyos.config import Config, config_dict_merge +from vyos.configdict import node_changed +from vyos.configdiff import Diff +from vyos.vpp.utils import cli_ifaces_list +from vyos.vpp.utils import vpp_iface_name_transform + +from vyos.vpp.nat.det44 import Det44 +from vyos.vpp.control_vpp import VPPControl +from vyos.vpp.config_verify import verify_nat_interfaces +from vyos.vpp.config_verify import verify_vpp_interface_not_a_member + +protocol_map = { + 'all': 0, + 'icmp': 1, + 'tcp': 6, + 'udp': 17, +} + + +def get_config(config=None) -> dict: + if config: + conf = config + else: + conf = Config() + + base = ['vpp', 'nat', 'cgnat'] + + # Get config_dict with default values + config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if not conf.exists(['vpp']): + config['remove_vpp'] = True + return config + + # Get effective config as we need full dictionary to delete + effective_config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + effective=True, + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if not config: + config['remove'] = True + + # Get default values which we need to conditionally update into the + # dictionary retrieved. + default_values = conf.get_config_defaults(**config.kwargs, recursive=True) + config = config_dict_merge(default_values, config) + + config_changed = node_changed( + conf, + base, + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + changed_rules = node_changed( + conf, + base + ['rule'], + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + changed_exclude_rules = node_changed( + conf, + base + ['exclude', 'rule'], + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + if not config_changed: + changed_rules = list(config.get('rule', {}).keys()) + changed_exclude_rules = list(config.get('exclude', {}).get('rule', {}).keys()) + + config.update( + { + 'changed_rules': changed_rules, + 'changed_exclude_rules': changed_exclude_rules, + 'vpp_ifaces': cli_ifaces_list(conf), + } + ) + + config['nat44_config'] = conf.get_config_dict( + ['vpp', 'nat', 'nat44'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if effective_config: + config.update({'effective': effective_config}) + + # VPP interface membership data for member-conflict checks + config['interfaces_vpp'] = conf.get_config_dict( + ['interfaces', 'vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + return config + + +def verify(config): + if 'remove' in config or 'remove_vpp' in config: + return None + + if 'interface' not in config: + raise ConfigError('Interfaces must be configured for CGNAT') + if 'rule' not in config: + raise ConfigError('Rules must be configured for CGNAT') + + required_keys = {'inside', 'outside'} + missing_keys = required_keys - set(config['interface'].keys()) + if missing_keys: + raise ConfigError( + f'Both inside and outside interfaces must be configured. ' + f'Please add: {", ".join(missing_keys)}' + ) + + conflict_ifaces = set(config['interface']['inside']).intersection( + set(config['interface']['outside']) + ) + if conflict_ifaces: + raise ConfigError( + f'Interface cannot be both inside and outside. ' + f'Please choose a side for: {", ".join(conflict_ifaces)} ' + ) + + verify_nat_interfaces(config, 'nat44') + + vpp = VPPControl() + for direction in ['inside', 'outside']: + for interface in config['interface'][direction]: + vpp_iface_name = vpp_iface_name_transform(interface) + if vpp.get_sw_if_index(vpp_iface_name) is None: + raise ConfigError( + f'{interface} must be a VPP interface for {direction} CGNAT interface' + ) + verify_vpp_interface_not_a_member(interface, config) + + required_keys = {'outside_prefix', 'inside_prefix'} + for rule in config['rule']: + missing_keys = required_keys - set(config['rule'][rule].keys()) + if missing_keys: + raise ConfigError( + f'Both inside-prefix and outside-prefix must be configured in rule {rule}. ' + f'Please add: {", ".join(missing_keys).replace("_", "-")}' + ) + + # Verify exclude rules (identity mappings) + if 'exclude' in config: + # Track identity mappings to detect duplicates + seen_mappings = {} + + for rule, rule_config in config['exclude'].get('rule', {}).items(): + error_msg = f'Exclude rule {rule}:' + + if 'local_address' not in rule_config: + raise ConfigError(f'{error_msg} local-address must be specified') + + has_protocol = ( + 'protocol' in rule_config and rule_config.get('protocol') != 'all' + ) + has_port = 'local_port' in rule_config + + # Either both protocol and local-port are set, or neither + if has_protocol != has_port: + raise ConfigError( + f'{error_msg} protocol and local-port must either both be specified or both omitted' + ) + + # Check for duplicate identity mappings + # VPP identifies identity mappings by (address, protocol, port) tuple + local_addr = rule_config['local_address'] + protocol = rule_config.get('protocol', 'all') + port = rule_config.get('local_port', 0) + + mapping_key = (local_addr, protocol, port) + + if mapping_key in seen_mappings: + duplicate_rule = seen_mappings[mapping_key] + raise ConfigError( + f'{error_msg} duplicate identity mapping - ' + f'address {local_addr}, protocol {protocol}, port {port} ' + f'already configured in exclude rule {duplicate_rule}' + ) + + seen_mappings[mapping_key] = rule + + +def generate(config): + pass + + +def apply(config): + if 'remove_vpp' in config: + return None + + cgnat = Det44() + + if 'remove' in config: + cgnat.disable_det44_plugin() + return None + + if 'effective' in config: + remove_config = config.get('effective') + # Delete inside interfaces + for interface in cgnat.get_det44_interfaces_inside(): + cgnat.delete_det44_interface_inside(interface) + # Delete outside interfaces + for interface in cgnat.get_det44_interfaces_outside(): + cgnat.delete_det44_interface_outside(interface) + # Delete CGNAT rules + for rule in config['changed_rules']: + if rule in remove_config.get('rule', {}): + rule_config = remove_config['rule'][rule] + in_addr, in_plen = rule_config['inside_prefix'].split('/') + out_addr, out_plen = rule_config['outside_prefix'].split('/') + cgnat.delete_det44_mapping( + in_addr=in_addr, + in_plen=int(in_plen), + out_addr=out_addr, + out_plen=int(out_plen), + ) + # Delete CGNAT exclude rules + for rule in config['changed_exclude_rules']: + if rule in remove_config.get('exclude', {}).get('rule', {}): + rule_config = remove_config['exclude']['rule'][rule] + cgnat.delete_det44_identity_mapping( + ip_address=rule_config.get('local_address'), + protocol=protocol_map[rule_config.get('protocol', 'all')], + port=int(rule_config.get('local_port', 0)), + tag=rule_config.get('description', ''), + ) + + # Add DET44 + cgnat.enable_det44_plugin() + # Add inside interfaces + for interface in config['interface']['inside']: + vpp_iface_name = vpp_iface_name_transform(interface) + cgnat.add_det44_interface_inside(vpp_iface_name) + # Add outside interfaces + for interface in config['interface']['outside']: + vpp_iface_name = vpp_iface_name_transform(interface) + cgnat.add_det44_interface_outside(vpp_iface_name) + # Add CGNAT rules + for rule in config['changed_rules']: + if rule in config.get('rule', {}): + rule_config = config['rule'][rule] + in_addr, in_plen = rule_config['inside_prefix'].split('/') + out_addr, out_plen = rule_config['outside_prefix'].split('/') + cgnat.add_det44_mapping( + in_addr=in_addr, + in_plen=int(in_plen), + out_addr=out_addr, + out_plen=int(out_plen), + ) + # Add CGNAT exclude rules + for rule in config['changed_exclude_rules']: + if rule in config.get('exclude', {}).get('rule', {}): + rule_config = config['exclude']['rule'][rule] + cgnat.add_det44_identity_mapping( + ip_address=rule_config.get('local_address'), + protocol=protocol_map[rule_config.get('protocol', 'all')], + port=int(rule_config.get('local_port', 0)), + tag=rule_config.get('description', ''), + ) + # Set CGNAT timeouts + cgnat.set_det44_timeouts( + icmp=int(config['timeout']['icmp']), + udp=int(config['timeout']['udp']), + tcp_established=int(config['timeout']['tcp_established']), + tcp_transitory=int(config['timeout']['tcp_transitory']), + ) + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpp_nat_nat44.py b/src/conf_mode/vpp_nat_nat44.py new file mode 100644 index 000000000..8d69ee786 --- /dev/null +++ b/src/conf_mode/vpp_nat_nat44.py @@ -0,0 +1,533 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import ipaddress + +from vyos import ConfigError + +from vyos.configdiff import Diff +from vyos.configdict import node_changed +from vyos.config import Config, config_dict_merge +from vyos.utils.network import get_interface_address + +from vyos.vpp.utils import cli_ifaces_list +from vyos.vpp.utils import vpp_iface_name_transform +from vyos.vpp.nat.nat44 import Nat44 +from vyos.vpp.control_vpp import VPPControl +from vyos.vpp.config_verify import verify_nat_interfaces +from vyos.vpp.config_verify import verify_vpp_interface_not_a_member + + +protocol_map = { + 'all': 0, + 'icmp': 1, + 'tcp': 6, + 'udp': 17, +} + + +def get_config(config=None) -> dict: + if config: + conf = config + else: + conf = Config() + + base = ['vpp', 'nat', 'nat44'] + + # Get config_dict + config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if not conf.exists(['vpp']): + config['remove_vpp'] = True + return config + + # Get effective config as we need full dictionary for deletion + effective_config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + effective=True, + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if not config: + config['remove'] = True + return config + + # Get default values which we need to conditionally update into the + # dictionary retrieved. + default_values = conf.get_config_defaults(**config.kwargs, recursive=True) + config = config_dict_merge(default_values, config) + + config_changed = node_changed( + conf, + base, + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + changed_static_rules = node_changed( + conf, + base + ['static', 'rule'], + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + changed_exclude_rules = node_changed( + conf, + base + ['exclude', 'rule'], + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + if not config_changed: + changed_static_rules = list(config.get('static', {}).get('rule', {}).keys()) + changed_exclude_rules = list(config.get('exclude', {}).get('rule', {}).keys()) + + config.update( + { + 'changed_static_rules': changed_static_rules, + 'changed_exclude_rules': changed_exclude_rules, + 'vpp_ifaces': cli_ifaces_list(conf), + } + ) + + config['cgnat_config'] = conf.get_config_dict( + ['vpp', 'nat', 'cgnat'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if effective_config: + config.update({'effective': effective_config}) + + # VPP interface membership data for member-conflict checks + config['interfaces_vpp'] = conf.get_config_dict( + ['interfaces', 'vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + return config + + +def convert_range_to_list_ips(address_range) -> list: + """Converts IP range to a list of IPs . + + Example: + % ip = IPOperations('192.0.0.1-192.0.2.5') + % ip.convert_prefix_to_list_ips() + ['192.0.2.1', '192.0.2.2', '192.0.2.3', '192.0.2.4', '192.0.2.5'] + """ + if '-' in address_range: + start_ip, end_ip = address_range.split('-') + start_ip = ipaddress.ip_address(start_ip) + end_ip = ipaddress.ip_address(end_ip) + return [ + str(ipaddress.ip_address(ip)) + for ip in range(int(start_ip), int(end_ip) + 1) + ] + else: + return [address_range] + + +def verify(config): + if 'remove' in config or 'remove_vpp' in config: + return None + + if 'interface' not in config: + raise ConfigError('Interfaces must be configured for NAT44') + + required_keys = {'inside', 'outside'} + missing_keys = required_keys - set(config['interface'].keys()) + if missing_keys: + raise ConfigError( + f'Both inside and outside interfaces must be configured. Please add: {", ".join(missing_keys)}' + ) + + verify_nat_interfaces(config, 'cgnat') + + vpp = VPPControl() + for direction in ['inside', 'outside']: + for interface in config['interface'][direction]: + vpp_iface_name = vpp_iface_name_transform(interface) + if vpp.get_sw_if_index(vpp_iface_name) is None: + raise ConfigError( + f'{interface} must be a VPP interface for {direction} NAT interface' + ) + verify_vpp_interface_not_a_member(interface, config) + + if not config.get('address_pool', {}).get('translation') and not config.get( + 'static', {} + ).get('rule'): + raise ConfigError('"address-pool translation" or "static rule" is required') + + addresses_translation = [] + addresses_twice_nat = [] + if 'address_pool' in config: + address_pool = config.get('address_pool') + if 'translation' in address_pool: + if not address_pool['translation'].get('address') and not address_pool[ + 'translation' + ].get('interface'): + raise ConfigError( + '"address-pool translation" requires address or interface' + ) + + for address_range in address_pool['translation'].get('address', []): + addresses = convert_range_to_list_ips(address_range) + for address in addresses: + if address in addresses_translation: + raise ConfigError( + f'Address {address} is already in use in "address-pool translation address"' + ) + addresses_translation.append(address) + + for interface in address_pool['translation'].get('interface', []): + if interface not in config['vpp_ifaces']: + raise ConfigError( + f'{interface} must be a VPP interface for "address-pool translation interface"' + ) + address_info = get_interface_address(interface).get('addr_info') + if not address_info: + raise ConfigError( + f'{interface} should have an address to be used for "address-pool translation interface"' + ) + iface_address = address_info[0].get('local') + addresses_translation.append(iface_address) + + if 'twice_nat' in address_pool: + if not address_pool['twice_nat'].get('address') and not address_pool[ + 'twice_nat' + ].get('interface'): + raise ConfigError( + '"address-pool twice-nat" requires address or interface' + ) + + for address_range in address_pool['twice_nat'].get('address', []): + addresses = convert_range_to_list_ips(address_range) + for address in addresses: + if address in addresses_twice_nat: + raise ConfigError( + f'Address {address} is already in use in "address-pool twice-nat address"' + ) + addresses_twice_nat.append(address) + + for interface in address_pool['twice_nat'].get('interface', []): + if interface not in config['vpp_ifaces']: + raise ConfigError( + f'{interface} must be a VPP interface for "address-pool twice-nat interface"' + ) + address_info = get_interface_address(interface).get('addr_info') + if not address_info: + raise ConfigError( + f'{interface} should have an address to be used for "address-pool twice-nat interface"' + ) + iface_address = address_info[0].get('local') + addresses_twice_nat.append(iface_address) + + if 'static' in config: + addresses_with_ports = set() + addresses_without_ports = set() + local_addresses = set() + + for rule, rule_config in config['static'].get('rule', {}).items(): + error_msg = f'Configuration error in static rule {rule}:' + + if not rule_config.get('local', {}).get('address'): + raise ConfigError(f'{error_msg} local settings require address') + + if not rule_config.get('external', {}).get('address'): + raise ConfigError(f'{error_msg} external settings require address') + + has_local_port = 'port' in rule_config.get('local', {}) + has_external_port = 'port' in rule_config.get('external', {}) + + if not has_external_port == has_local_port: + raise ConfigError( + f'{error_msg} source and destination ports must either ' + 'both be specified, or neither must be specified' + ) + + # Either both protocol and ports are set, or both no protocol and no ports + if (rule_config['protocol'] != 'all') != has_local_port: + raise ConfigError( + f'{error_msg} protocol and ports must either both be specified or both omitted' + ) + + ext_address = rule_config['external']['address'] + port = rule_config['external'].get('port') + local_address = rule_config['local']['address'] + + if port: + pair = (ext_address, port) + if ( + pair in addresses_with_ports + or ext_address in addresses_without_ports + ): + raise ConfigError( + f'{error_msg} external address/port is already in use!' + ) + addresses_with_ports.add(pair) + + else: + if ext_address in addresses_without_ports or any( + addr == ext_address for addr, _ in addresses_with_ports + ): + raise ConfigError( + f'{error_msg} external address is already in use!' + ) + addresses_without_ports.add(ext_address) + + if local_address in local_addresses: + raise ConfigError( + f'{error_msg} local address {local_address} is already in use' + ) + local_addresses.add(local_address) + + options = rule_config.get('options', {}) + + if 'self_twice_nat' in options and ext_address not in addresses_translation: + raise ConfigError( + f'{error_msg} external address {ext_address} must be part of ' + '"address-pool translation" when using self-twice-nat' + ) + + if all(key in options for key in ('twice_nat', 'self_twice_nat')): + raise ConfigError( + f'{error_msg} cannot set both options "twice-nat" and "self-twice-nat"' + ) + if any(key in options for key in ('twice_nat', 'self_twice_nat')): + if not has_local_port or rule_config['protocol'] == 'all': + raise ConfigError( + f'{error_msg} twice-nat/self-twice-nat options require port and protocol to be set' + ) + if not config.get('address_pool', {}).get('twice_nat'): + raise ConfigError( + f'{error_msg} twice-nat/self-twice-nat options require "address-pool twice-nat" to be set' + ) + if 'twice_nat_address' in options: + if not any(key in options for key in ('twice_nat', 'self_twice_nat')): + raise ConfigError( + f'{error_msg} twice-nat/self-twice-nat option required when twice-nat-address is set' + ) + tn_address = options['twice_nat_address'] + if tn_address not in addresses_twice_nat: + raise ConfigError( + f'{error_msg} twice-nat-address {tn_address} is not in "address-pool twice-nat"' + ) + + if 'exclude' in config: + for rule, rule_config in config['exclude'].get('rule', {}).items(): + keys = {'local_address', 'external_interface'} + if not any(key in rule_config for key in keys): + raise ConfigError( + f'Local-address or external-interface must be specified for exclude rule {rule}' + ) + if all(key in rule_config for key in keys): + raise ConfigError( + f'Cannot set both address and interface for exclude rule {rule}' + ) + if ( + 'external_interface' in rule_config + and rule_config.get('external_interface') not in config['vpp_ifaces'] + ): + raise ConfigError( + f'{rule_config["external_interface"]} must be a VPP interface for exclude rule {rule}' + ) + + # Either both protocol and local-port are set, or both no protocol and no port + if (rule_config['protocol'] != 'all') != ('local_port' in rule_config): + raise ConfigError( + f'Protocol and local-port must either both be specified or both omitted for exclude rule {rule}' + ) + + +def generate(config): + pass + + +def apply(config): + if 'remove_vpp' in config: + return None + + n = Nat44() + + if 'remove' in config: + n.disable_nat44_ed() + return None + + if 'effective' in config: + remove_config = config.get('effective') + # Delete inside interfaces + for interface in remove_config['interface']['inside']: + if interface not in config.get('interface', {}).get('inside', []): + vpp_iface_name = vpp_iface_name_transform(interface) + n.delete_nat44_interface_inside(vpp_iface_name) + # Delete outside interfaces + for interface in remove_config['interface']['outside']: + if interface not in config.get('interface', {}).get('outside', []): + vpp_iface_name = vpp_iface_name_transform(interface) + n.delete_nat44_interface_outside(vpp_iface_name) + # Delete address pool + address_pool = config.get('address_pool', {}) + for address in ( + remove_config.get('address_pool', {}) + .get('translation', {}) + .get('address', []) + ): + if address not in address_pool.get('translation', {}).get('address', []): + n.delete_nat44_address_range(address, twice_nat=False) + for interface in ( + remove_config.get('address_pool', {}) + .get('translation', {}) + .get('interface', []) + ): + if interface not in address_pool.get('translation', {}).get( + 'interface', [] + ): + n.delete_nat44_interface_address(interface, twice_nat=False) + for address in ( + remove_config.get('address_pool', {}) + .get('twice_nat', {}) + .get('address', []) + ): + if address not in address_pool.get('twice_nat', {}).get('address', []): + n.delete_nat44_address_range(address, twice_nat=True) + for interface in ( + remove_config.get('address_pool', {}) + .get('twice_nat', {}) + .get('interface', []) + ): + if interface not in address_pool.get('twice_nat', {}).get('interface', []): + n.delete_nat44_interface_address(interface, twice_nat=True) + # Delete NAT static mapping rules + for rule in config['changed_static_rules']: + if rule in remove_config.get('static', {}).get('rule', {}): + rule_config = remove_config['static']['rule'][rule] + n.delete_nat44_static_mapping( + local_ip=rule_config.get('local').get('address'), + external_ip=rule_config.get('external', {}).get('address', ''), + local_port=int(rule_config.get('local', {}).get('port', 0)), + external_port=int(rule_config.get('external', {}).get('port', 0)), + protocol=protocol_map[rule_config.get('protocol', 'all')], + twice_nat='twice_nat' in rule_config.get('options', {}), + self_twice_nat='self_twice_nat' in rule_config.get('options', {}), + out2in='out_to_in_only' in rule_config.get('options', {}), + pool_ip=rule_config.get('options', {}).get('twice_nat_address'), + ) + # Delete NAT exclude rules + for rule in config['changed_exclude_rules']: + if rule in remove_config.get('exclude', {}).get('rule', {}): + rule_config = remove_config['exclude']['rule'][rule] + n.delete_nat44_identity_mapping( + ip_address=rule_config.get('local_address'), + protocol=protocol_map[rule_config.get('protocol', 'all')], + port=int(rule_config.get('local_port', 0)), + interface=rule_config.get('external_interface'), + ) + + # Add NAT44 + n.enable_nat44_ed() + + # Dynamic rules always require `address-pool translation` in CLI - we can use this for an easy validation + # Forwarding must be disabled when dynamic rules are present + # Without dynamic rules, forwarding remains enabled + enable_forwarding = not bool(config.get('address_pool', {}).get('translation')) + n.enable_disable_nat44_forwarding(enable_forwarding) + + # Add inside interfaces + for interface in config['interface']['inside']: + vpp_iface_name = vpp_iface_name_transform(interface) + n.add_nat44_interface_inside(vpp_iface_name) + # Add outside interfaces + for interface in config['interface']['outside']: + vpp_iface_name = vpp_iface_name_transform(interface) + n.add_nat44_interface_outside(vpp_iface_name) + # Add translation pool + for address in ( + config.get('address_pool', {}).get('translation', {}).get('address', []) + ): + n.add_nat44_address_range(address, twice_nat=False) + for interface in ( + config.get('address_pool', {}).get('translation', {}).get('interface', []) + ): + n.add_nat44_interface_address(interface, twice_nat=False) + for address in ( + config.get('address_pool', {}).get('twice_nat', {}).get('address', []) + ): + n.add_nat44_address_range(address, twice_nat=True) + for interface in ( + config.get('address_pool', {}).get('twice_nat', {}).get('interface', []) + ): + n.add_nat44_interface_address(interface, twice_nat=True) + # Add NAT static mapping rules + for rule in config['changed_static_rules']: + if rule in config.get('static', {}).get('rule', {}): + rule_config = config['static']['rule'][rule] + n.add_nat44_static_mapping( + local_ip=rule_config.get('local').get('address'), + external_ip=rule_config.get('external', {}).get('address', ''), + local_port=int(rule_config.get('local', {}).get('port', 0)), + external_port=int(rule_config.get('external', {}).get('port', 0)), + protocol=protocol_map[rule_config.get('protocol', 'all')], + twice_nat='twice_nat' in rule_config.get('options', {}), + self_twice_nat='self_twice_nat' in rule_config.get('options', {}), + out2in='out_to_in_only' in rule_config.get('options', {}), + pool_ip=rule_config.get('options', {}).get('twice_nat_address'), + ) + # Add NAT exclude rules + for rule in config['changed_exclude_rules']: + if rule in config.get('exclude', {}).get('rule', {}): + rule_config = config['exclude']['rule'][rule] + n.add_nat44_identity_mapping( + ip_address=rule_config.get('local_address'), + protocol=protocol_map[rule_config.get('protocol', 'all')], + port=int(rule_config.get('local_port', 0)), + interface=rule_config.get('external_interface'), + ) + if 'timeout' in config: + n.set_nat_timeouts( + icmp=int(config.get('timeout').get('icmp')), + udp=int(config.get('timeout').get('udp')), + tcp_established=int(config.get('timeout').get('tcp_established')), + tcp_transitory=int(config.get('timeout').get('tcp_transitory')), + ) + if 'session_limit' in config: + n.set_nat44_session_limit(int(config['session_limit'])) + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpp_sflow.py b/src/conf_mode/vpp_sflow.py new file mode 100644 index 000000000..de592f514 --- /dev/null +++ b/src/conf_mode/vpp_sflow.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 import ConfigError +from vyos.config import Config +from vyos.vpp.utils import cli_ifaces_list +from vyos.vpp.sflow import SFlow +from vyos.vpp.config_verify import verify_vpp_interface_not_a_member + + +def get_config(config=None) -> dict: + if config: + conf = config + else: + conf = Config() + + base = ['vpp', 'sflow'] + + # Get config_dict with default values + config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + with_recursive_defaults=True, + ) + + # Get effective config as we need full dictionary for deletion + effective_config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + effective=True, + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + # Get system sflow configuration to check for server + system_sflow = conf.get_config_dict( + ['system', 'sflow'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_recursive_defaults=True, + ) + + if system_sflow: + config['system_sflow'] = system_sflow + + if effective_config: + config.update({'effective': effective_config}) + + if not conf.exists(base): + config['remove'] = True + return config + + # Add list of VPP interfaces to the config + config.update({'vpp_ifaces': cli_ifaces_list(conf)}) + + # VPP interface membership data for member-conflict checks + config['interfaces_vpp'] = conf.get_config_dict( + ['interfaces', 'vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + return config + + +def verify(config): + if 'remove' in config: + return None + + # Check if interface section exists + if 'interface' not in config: + raise ConfigError('Interfaces must be configured for sFlow') + + # Verify that all interfaces specified exist in VPP + for interface in config['interface']: + if interface not in config['vpp_ifaces']: + raise ConfigError( + f'{interface} must be a VPP interface for sFlow monitoring' + ) + verify_vpp_interface_not_a_member(interface, config) + + # Verify that system sflow has enable-vpp defined + if 'system_sflow' not in config or 'vpp' not in config.get('system_sflow', {}): + raise ConfigError( + '"sflow vpp" must be defined under system sflow configuration' + ) + + +def generate(config): + # No templates to render for sFlow + pass + + +def apply(config): + s = SFlow() + + # Disable sFlow on deleted interface + for interface in config.get('effective', {}).get('interface', []): + if interface not in config.get('interface', []): + s.disable_sflow(interface) + + if 'remove' in config: + return None + + # Configure sample rate + if 'sampling_rate' in config.get('system_sflow', {}): + s.set_sampling_rate(int(config['system_sflow']['sampling_rate'])) + + # Configure polling interval + if 'polling' in config.get('system_sflow', {}): + s.set_polling_interval(int(config['system_sflow']['polling'])) + + # Configure header bytes + if 'header_bytes' in config: + s.set_header_bytes(int(config['header_bytes'])) + + # Configure interfaces + for interface in config.get('interface', []): + s.enable_sflow(interface) + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vrf.py b/src/conf_mode/vrf.py index 8baf55857..c307ae27e 100755 --- a/src/conf_mode/vrf.py +++ b/src/conf_mode/vrf.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -18,6 +18,8 @@ from sys import exit from jmespath import search from json import loads +import vyos.defaults + from vyos.config import Config from vyos.configdict import node_changed from vyos.configverify import verify_route_map @@ -27,6 +29,8 @@ from vyos.frrender import get_frrender_dict from vyos.ifconfig import Interface from vyos.template import render from vyos.utils.dict import dict_search +from vyos.utils.dict import dict_set_nested +from vyos.utils.dict import dict_search_recursive from vyos.utils.network import get_vrf_tableid from vyos.utils.network import get_vrf_members from vyos.utils.network import interface_exists @@ -116,6 +120,17 @@ def get_config(config=None): vrf = conf.get_config_dict(base, key_mangling=('-', '_'), no_tag_node_value_mangle=True, get_first_key=True) + # Policy based routing supports referencing VRFs in it's rules - we need to + # prevent VRF deletion if VRF is used in a PBR rule + for policy_type in ['local-route', 'local-route6', 'route', 'route6']: + tmp = conf.get_config_dict(['policy', policy_type], + key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True) + if tmp: + policy_type = policy_type.replace('-', '_') + dict_set_nested(f'policy.{policy_type}', tmp, vrf) + # determine which VRF has been removed for name in node_changed(conf, base + ['name']): if 'vrf_remove' not in vrf: @@ -128,6 +143,10 @@ def get_config(config=None): # get VRF bound routing instances routes = vrf_routing(conf, name) if routes: vrf['vrf_remove'][name]['route'] = routes + # get VRF bound policy routes + if 'policy' in vrf: + for key, _ in dict_search_recursive(vrf['policy'], 'vrf'): + if key == name: vrf['vrf_remove'][name]['policy'] = {} if 'name' in vrf: vrf['conntrack'] = conntrack_required(conf) @@ -141,12 +160,13 @@ def verify(vrf): # ensure VRF is not assigned to any interface if 'vrf_remove' in vrf: for name, config in vrf['vrf_remove'].items(): + err = f'Can not remove VRF "{name}",' if 'interface' in config: - raise ConfigError(f'Can not remove VRF "{name}", it still has '\ - f'member interfaces!') + raise ConfigError(f'{err} it still has member interfaces!') if 'route' in config: - raise ConfigError(f'Can not remove VRF "{name}", it still has '\ - f'static routes installed!') + raise ConfigError(f'{err} it still has static routes installed!') + if 'policy' in config: + raise ConfigError(f'{err} it still has policy routes!') if 'name' in vrf: reserved_names = ['add', 'all', 'broadcast', 'default', 'delete', 'dev', @@ -157,12 +177,17 @@ def verify(vrf): for name, vrf_config in vrf['name'].items(): # Reserved VRF names if name in reserved_names: - raise ConfigError(f'VRF name "{name}" is reserved and connot be used!') + raise ConfigError(f'VRF name "{name}" is reserved and cannot be used!') # table id is mandatory if 'table' not in vrf_config: raise ConfigError(f'VRF "{name}" table id is mandatory!') + if int(vrf_config['table']) == vyos.defaults.rt_global_vrf: + raise ConfigError( + f'VRF "{name}" table id {vrf_config["table"]} cannot be used!' + ) + # routing table id can't be changed - OS restriction if interface_exists(name): tmp = get_vrf_tableid(name) @@ -218,13 +243,13 @@ def apply(vrf): bind_all = '0' if 'bind_to_all' in vrf: bind_all = '1' - sysctl_write('net.ipv4.tcp_l3mdev_accept', bind_all) - sysctl_write('net.ipv4.udp_l3mdev_accept', bind_all) + sysctl_write(['net', 'ipv4', 'tcp_l3mdev_accept'], bind_all) + sysctl_write(['net', 'ipv4', 'udp_l3mdev_accept'], bind_all) for tmp in (dict_search('vrf_remove', vrf) or []): if interface_exists(tmp): # T5492: deleting a VRF instance may leafe processes running - # (e.g. dhclient) as there is a depedency ordering issue in the CLI. + # (e.g. dhclient) as there is a dependency ordering issue in the CLI. # We need to ensure that we stop the dhclient processes first so # a proper DHCLP RELEASE message is sent for interface in get_vrf_members(tmp): @@ -233,13 +258,19 @@ def apply(vrf): vrf_iface.set_dhcpv6(False) # Remove nftables conntrack zone map item - nft_del_element = f'delete element inet vrf_zones ct_iface_map {{ "{tmp}" }}' + nft_del_element = f'delete element inet vrf_zones ct_iface_map {{ \'"{tmp}"\' }}' # Check if deleting is possible first to avoid raising errors _, err = popen(f'nft --check {nft_del_element}') if not err: # Remove map element cmd(f'nft {nft_del_element}') + # Remove all ip rules pointing to this VRF table + table_id = get_vrf_tableid(tmp) + for afi in ['-4', '-6']: + while call(f'ip {afi} rule del table {table_id}') == 0: + pass + # Delete the VRF Kernel interface call(f'ip link delete dev {tmp}') @@ -313,11 +344,11 @@ def apply(vrf): state = 'down' if 'disable' in config else 'up' vrf_if.set_admin_state(state) # Add nftables conntrack zone map item - nft_add_element = f'add element inet vrf_zones ct_iface_map {{ "{name}" : {table} }}' + nft_add_element = f'add element inet vrf_zones ct_iface_map {{ \'"{name}"\' : {table} }}' cmd(f'nft {nft_add_element}') # Only call into nftables as long as there is nothing setup to avoid wasting - # CPU time and thus lenghten the commit process + # CPU time and thus lengthen 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 diff --git a/src/etc/bash_completion.d/vyatta-op b/src/etc/bash_completion.d/vyatta-op index 8ac2d9b20..fbd2045f3 100644 --- a/src/etc/bash_completion.d/vyatta-op +++ b/src/etc/bash_completion.d/vyatta-op @@ -243,7 +243,7 @@ _vyatta_op_set_completions () _vyatta_op_noncompletions=( ) completions=( ) - # make runable commands have a non-comp + # make runnable commands have a non-comp ndef=${_vyatta_op_node_path}/node.def [ -f $ndef ] && \ node_run=$( _vyatta_op_get_node_def_field $ndef run ) @@ -503,7 +503,7 @@ commands () { if [ "$_OFR_CONFIGURE" != "" ]; then if $(cli-shell-api sessionChanged); then - echo "You have uncommited changes, please commit them before using the commands pipe" + echo "You have uncommitted changes, please commit them before using the commands pipe" else vyos-config-to-commands fi @@ -516,7 +516,7 @@ json () { if [ "$_OFR_CONFIGURE" != "" ]; then if $(cli-shell-api sessionChanged); then - echo "You have uncommited changes, please commit them before using the JSON pipe" + echo "You have uncommitted changes, please commit them before using the JSON pipe" else vyos-config-to-json fi diff --git a/src/etc/cron.d/vyos-geoip b/src/etc/cron.d/vyos-geoip index 9bb38a850..27d74ee8b 100644 --- a/src/etc/cron.d/vyos-geoip +++ b/src/etc/cron.d/vyos-geoip @@ -1 +1 @@ -30 4 * * 1 root sg vyattacfg "/usr/libexec/vyos/geoip-update.py --force" >/tmp/geoip-update.log 2>&1 +30 4 * * 1 root sg vyattacfg "/usr/libexec/vyos/geoip-update.py" >/tmp/geoip-update.log 2>&1 diff --git a/src/etc/default/vyatta b/src/etc/default/vyatta index e5fa3bb30..0a5129e8b 100644 --- a/src/etc/default/vyatta +++ b/src/etc/default/vyatta @@ -173,6 +173,7 @@ unset _vyatta_extglob declare -x -r vyos_bin_dir=/usr/bin declare -x -r vyos_sbin_dir=/usr/sbin declare -x -r vyos_share_dir=/usr/share + declare -x -r vyconf_bin_dir=/usr/libexec/vyos/vyconf/bin if test -z "$vyos_conf_scripts_dir" ; then declare -x -r vyos_conf_scripts_dir=$vyos_libexec_dir/conf_mode diff --git a/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper b/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper index 2a1c5a7b2..bd111d282 100644 --- a/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper +++ b/src/etc/dhcp/dhclient-enter-hooks.d/03-vyos-ipwrapper @@ -1,10 +1,47 @@ -# redefine ip command to use FRR when it is available +# redefine ip command to use FRR when it is available and preserve static addresses # default route distance IF_METRIC=${IF_METRIC:-210} # Check if interface is inside a VRF -VRF_OPTION=$(/usr/sbin/ip -j -d link show ${interface} | awk '{if(match($0, /.*"master":"(\w+)".*"info_slave_kind":"vrf"/, IFACE_DETAILS)) printf("vrf %s", IFACE_DETAILS[1])}') +VRF_OPTION=$(/usr/sbin/ip --json --detail link show ${interface} | jq -r '.[0] | select(.linkinfo.info_slave_kind == "vrf") | "vrf \(.master)"') + +# Flush only DHCP (dynamic) addresses from interface, preserving static addresses +# +# When dhclient renews/rebinds a lease, it calls: +# ip -4 addr flush dev <interface> +# This removes ALL IPv4 addresses, including static addresses configured +# via VyOS (e.g., "set interfaces ethernet eth0 address 192.168.1.1/24"). +# +# The ip() wrapper below intercepts the "ip -4 addr flush" command and replaces +# it with a selective flush that only removes addresses marked as "dynamic" by +# the kernel. DHCP-assigned addresses have the "dynamic" flag set automatically. +_flush_dhcp_addrs() { + local dev="${1:-$interface}" + + logmsg info "Selectively flushing only dynamic (DHCP) addresses from ${dev}" + + local addrs + addrs=$(/usr/sbin/ip -4 -j addr show dev "${dev}" 2>&1 | jq -r ' + .[] | .addr_info[]? | select(.family == "inet" and .dynamic == true) + | "\(.local)/\(.prefixlen)" + ' 2>&1) + if [ $? -ne 0 ]; then + logmsg warn "Failed to list dynamic IPv4 addresses on ${dev}; skipping selective flush. ip/jq error output: $addrs" + return 0 + fi + + while IFS= read -r addr; do + if [ -n "$addr" ]; then + logmsg info "Removing dynamic address ${addr} from ${dev}" + if ! /usr/sbin/ip -4 addr del "${addr}" dev "${dev}" 2>&1; then + logmsg warn "Failed to remove dynamic address ${addr} from ${dev}" + fi + fi + done <<< "$addrs" + + return 0 +} # get status of FRR function frr_alive () { @@ -90,7 +127,21 @@ function vtysh_conf () { # replace ip command with this wrapper function ip () { - # pass comand to system `ip` if this is not related to routes change + # Intercept: ip -4 addr flush dev <interface> + # to preserve static addresses (only remove dynamic/DHCP addresses) + if [ "$1" = "-4" ] && [ "$2" = "addr" ] && [ "$3" = "flush" ]; then + logmsg info "Intercepting 'ip -4 addr flush' to preserve static addresses" + shift 3 + local dev="" + while [ $# -gt 0 ]; do + [ "$1" = "dev" ] && dev="$2" && break + shift + done + _flush_dhcp_addrs "${dev:-$interface}" + return $? + fi + + # pass command to system `ip` if this is not related to routes change if [ "$2" != "route" ] ; then logmsg info "Passing command to /usr/sbin/ip: \"$@\"" /usr/sbin/ip $@ diff --git a/src/etc/dhcp/dhclient-enter-hooks.d/06-vyos-nodefaultroute b/src/etc/dhcp/dhclient-enter-hooks.d/06-vyos-nodefaultroute new file mode 100644 index 000000000..38f674276 --- /dev/null +++ b/src/etc/dhcp/dhclient-enter-hooks.d/06-vyos-nodefaultroute @@ -0,0 +1,20 @@ +# Don't add default route if no-default-route is configured for interface + +# As configuration is not available to cli-shell-api at the first boot, we must use vyos.config, which contains a workaround for this +function get_no_default_route { +python3 - <<PYEND +from vyos.config import Config +import os + +config = Config() +if config.exists('interfaces'): + iface_types = config.list_nodes('interfaces') + for iface_type in iface_types: + if config.exists("interfaces {} {} dhcp-options no-default-route".format(iface_type, os.environ['interface'])): + print("True") +PYEND +} + +if [[ "$(get_no_default_route)" == 'True' ]]; then + new_routers="" +fi diff --git a/src/etc/dhcp/dhclient-enter-hooks.d/98-vyos-static-routes-dhclient-hook b/src/etc/dhcp/dhclient-enter-hooks.d/98-vyos-static-routes-dhclient-hook new file mode 100755 index 000000000..439a8dd87 --- /dev/null +++ b/src/etc/dhcp/dhclient-enter-hooks.d/98-vyos-static-routes-dhclient-hook @@ -0,0 +1,33 @@ +#!/bin/bash +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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/>. + +DHCP_HOOK_IFLIST="/tmp/static_dhcp_interfaces" + +# Only run if there are static routes with dhcp-interface configured +if ! { [ -f $DHCP_HOOK_IFLIST ] && grep -qw $interface $DHCP_HOOK_IFLIST; }; then + return 0 +fi + +# Handle interface state changes that require static route regeneration +# - PREINIT: interface is about to be configured, cleanup old routes +# - EXPIRE: lease has expired, remove routes +# - FAIL: DHCP failed, remove routes +# - RELEASE: lease released, remove routes +# - STOP: dhclient stopped, remove routes +if [ "$reason" == "PREINIT" ] || [ "$reason" == "EXPIRE" ] || [ "$reason" == "FAIL" ] || [ "$reason" == "RELEASE" ] || [ "$reason" == "STOP" ]; then + # Re-generate static routes config to remove routes that depend on this interface + sudo /usr/libexec/vyos/vyos-request-configd-update.py +fi diff --git a/src/etc/dhcp/dhclient-exit-hooks.d/98-run-user-hooks b/src/etc/dhcp/dhclient-exit-hooks.d/97-run-user-hooks index 910b586f0..910b586f0 100755 --- a/src/etc/dhcp/dhclient-exit-hooks.d/98-run-user-hooks +++ b/src/etc/dhcp/dhclient-exit-hooks.d/97-run-user-hooks diff --git a/src/etc/dhcp/dhclient-exit-hooks.d/98-vyos-static-routes-dhclient-hook b/src/etc/dhcp/dhclient-exit-hooks.d/98-vyos-static-routes-dhclient-hook new file mode 100755 index 000000000..7038bf25e --- /dev/null +++ b/src/etc/dhcp/dhclient-exit-hooks.d/98-vyos-static-routes-dhclient-hook @@ -0,0 +1,39 @@ +#!/bin/bash +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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/>. + +DHCP_HOOK_IFLIST="/tmp/static_dhcp_interfaces" + +# Only run if there are static routes with dhcp-interface configured +if ! { [ -f $DHCP_HOOK_IFLIST ] && grep -qw $interface $DHCP_HOOK_IFLIST; }; then + return 0 +fi + +# Re-generate the config on the following events: +# - BOUND: always re-generate +# - RENEW: re-generate if the IP address changed +# - REBIND: re-generate if the IP address changed +# - EXPIRE: always re-generate (route should be removed) +# - RELEASE: always re-generate (route should be removed) +if [ "$reason" == "RENEW" ] || [ "$reason" == "REBIND" ]; then + if [ "$old_routers" == "$new_routers" ]; then + return 0 + fi +elif [ "$reason" != "BOUND" ] && [ "$reason" != "EXPIRE" ] && [ "$reason" != "RELEASE" ]; then + return 0 +fi + +# Re-generate the static routes config +sudo /usr/libexec/vyos/vyos-request-configd-update.py diff --git a/src/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook b/src/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook index 57f803055..f10ee7f43 100755 --- a/src/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook +++ b/src/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook @@ -1,6 +1,6 @@ #!/bin/bash # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/etc/ipsec.d/vti-up-down b/src/etc/ipsec.d/vti-up-down index e1765ae85..58089bbae 100755 --- a/src/etc/ipsec.d/vti-up-down +++ b/src/etc/ipsec.d/vti-up-down @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -29,7 +29,7 @@ from vyos.configquery import ConfigTreeQuery from vyos.configdict import get_interface_dict from vyos.utils.commit import wait_for_commit_lock from vyos.utils.process import call -from vyos.utils.vti_updown_db import open_vti_updown_db_for_update +from vyos.utils.vti_updown_db import open_vti_updown_db_for_create_or_update def supply_interface_dict(interface): # Lazy-load the running config on first invocation @@ -58,10 +58,10 @@ if __name__ == '__main__': wait_for_commit_lock() if verb in ['up-client', 'up-client-v6', 'up-host', 'up-host-v6']: - with open_vti_updown_db_for_update() as db: + with open_vti_updown_db_for_create_or_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: + with open_vti_updown_db_for_create_or_update() as db: db.remove(interface, connection, protocol) db.commit(supply_interface_dict) diff --git a/src/etc/modprobe.d/openvpn.conf b/src/etc/modprobe.d/openvpn.conf index a9259fea2..db965e61a 100644 --- a/src/etc/modprobe.d/openvpn.conf +++ b/src/etc/modprobe.d/openvpn.conf @@ -1 +1 @@ -blacklist ovpn-dco-v2 +blacklist ovpn diff --git a/src/etc/netplug/linkup.d/vyos-python-helper b/src/etc/netplug/linkup.d/vyos-python-helper deleted file mode 100755 index 9c59c58ad..000000000 --- a/src/etc/netplug/linkup.d/vyos-python-helper +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -PYTHON3=$(which python3) -# Call the real python script and forward commandline arguments -$PYTHON3 /etc/netplug/vyos-netplug-dhcp-client "${@:1}" diff --git a/src/etc/netplug/netplug b/src/etc/netplug/netplug deleted file mode 100755 index 60b65e8c9..000000000 --- a/src/etc/netplug/netplug +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/sh -# -# Copyright 2023 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/>. - -dev="$1" -action="$2" - -case "$action" in -in) - run-parts --arg $dev --arg in /etc/netplug/linkup.d - ;; -out) - run-parts --arg $dev --arg out /etc/netplug/linkdown.d - ;; - -# probe loads and initialises the driver for the interface and brings the -# interface into the "up" state, so that it can generate netlink(7) events. -# This interferes with "admin down" for an interface. Thus, commented out. An -# "admin up" is treated as a "link up" and thus, "link up" action is executed. -# To execute "link down" action on "admin down", run appropriate script in -# /etc/netplug/linkdown.d -#probe) -# ;; - -*) - exit 1 - ;; -esac diff --git a/src/etc/netplug/netplugd.conf b/src/etc/netplug/netplugd.conf deleted file mode 100644 index 7da3c67e8..000000000 --- a/src/etc/netplug/netplugd.conf +++ /dev/null @@ -1,4 +0,0 @@ -eth* -br* -bond* -wlan* diff --git a/src/etc/netplug/vyos-netplug-dhcp-client b/src/etc/netplug/vyos-netplug-dhcp-client deleted file mode 100755 index 4cc824afd..000000000 --- a/src/etc/netplug/vyos-netplug-dhcp-client +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright 2023-2025 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 sys - -from time import sleep - -from vyos.config import Config -from vyos.configdict import get_interface_dict -from vyos.ifconfig import Interface -from vyos.ifconfig import Section -from vyos.utils.boot import boot_configuration_complete -from vyos.utils.commit import commit_in_progress -from vyos import airbag - -airbag.enable() - -if len(sys.argv) < 3: - airbag.noteworthy('Must specify both interface and link status!') - sys.exit(1) - -if not boot_configuration_complete(): - airbag.noteworthy('System bootup not yet finished...') - sys.exit(1) - -interface = sys.argv[1] -# helper scripts should only work on physical interfaces not on individual -# sub-interfaces. Moving e.g. a VLAN interface in/out a VRF will also trigger -# this script which should be prohibited - bail out early -if '.' in interface: - sys.exit(0) - -while commit_in_progress(): - sleep(1) - -in_out = sys.argv[2] -config = Config() - -interface_path = ['interfaces'] + Section.get_config_path(interface).split() -_, interface_config = get_interface_dict( - config, interface_path[:-1], ifname=interface, with_pki=True -) -Interface(interface).update(interface_config) diff --git a/src/etc/opennhrp/opennhrp-script.py b/src/etc/opennhrp/opennhrp-script.py deleted file mode 100755 index f6f6d075c..000000000 --- a/src/etc/opennhrp/opennhrp-script.py +++ /dev/null @@ -1,371 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2021-2023 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 os -import re -import sys -import vyos.ipsec - -from json import loads -from pathlib import Path - -from vyos.logger import getLogger -from vyos.utils.process import cmd -from vyos.utils.process import process_named_running - -NHRP_CONFIG: str = '/run/opennhrp/opennhrp.conf' - - -def vici_get_ipsec_uniqueid(conn: str, src_nbma: str, - dst_nbma: str) -> list[str]: - """ Find and return IKE SAs by src nbma and dst nbma - - Args: - conn (str): a connection name - src_nbma (str): an IP address of NBMA source - dst_nbma (str): an IP address of NBMA destination - - Returns: - list: a list of IKE connections that match a criteria - """ - if not conn or not src_nbma or not dst_nbma: - logger.error( - f'Incomplete input data for resolving IKE unique ids: ' - f'conn: {conn}, src_nbma: {src_nbma}, dst_nbma: {dst_nbma}') - return [] - - try: - logger.info( - f'Resolving IKE unique ids for: conn: {conn}, ' - f'src_nbma: {src_nbma}, dst_nbma: {dst_nbma}') - list_ikeid: list[str] = [] - list_sa: list = vyos.ipsec.get_vici_sas_by_name(conn, None) - for sa in list_sa: - if sa[conn]['local-host'].decode('ascii') == src_nbma \ - and sa[conn]['remote-host'].decode('ascii') == dst_nbma: - list_ikeid.append(sa[conn]['uniqueid'].decode('ascii')) - return list_ikeid - except Exception as err: - logger.error(f'Unable to find unique ids for IKE: {err}') - return [] - - -def vici_ike_terminate(list_ikeid: list[str]) -> bool: - """Terminating IKE SAs by list of IKE IDs - - Args: - list_ikeid (list[str]): a list of IKE ids to terminate - - Returns: - bool: result of termination action - """ - if not list: - logger.warning('An empty list for termination was provided') - return False - - try: - vyos.ipsec.terminate_vici_ikeid_list(list_ikeid) - return True - except Exception as err: - logger.error(f'Failed to terminate SA for IKE ids {list_ikeid}: {err}') - return False - - -def parse_type_ipsec(interface: str) -> tuple[str, str]: - """Get DMVPN Type and NHRP Profile from the configuration - - Args: - interface (str): a name of interface - - Returns: - tuple[str, str]: `peer_type` and `profile_name` - """ - if not interface: - logger.error('Cannot find peer type - no input provided') - return '', '' - - config_file: str = Path(NHRP_CONFIG).read_text() - regex: str = rf'^interface {interface} #(?P<peer_type>hub|spoke) ?(?P<profile_name>[^\n]*)$' - match = re.search(regex, config_file, re.M) - if match: - return match.groupdict()['peer_type'], match.groupdict()[ - 'profile_name'] - return '', '' - - -def add_peer_route(nbma_src: str, nbma_dst: str, mtu: str) -> None: - """Add a route to a NBMA peer - - Args: - nbma_src (str): a local IP address - nbma_dst (str): a remote IP address - mtu (str): a MTU for a route - """ - logger.info(f'Adding route from {nbma_src} to {nbma_dst} with MTU {mtu}') - # Find routes to a peer - route_get_cmd: str = f'sudo ip --json route get {nbma_dst} from {nbma_src}' - try: - route_info_data = loads(cmd(route_get_cmd)) - except Exception as err: - logger.error(f'Unable to find a route to {nbma_dst}: {err}') - return - - # Check if an output has an expected format - if not isinstance(route_info_data, list): - logger.error( - f'Garbage returned from the "{route_get_cmd}" ' - f'command: {route_info_data}') - return - - # Add static routes to a peer - for route_item in route_info_data: - route_dev = route_item.get('dev') - route_dst = route_item.get('dst') - route_gateway = route_item.get('gateway') - # Prepare a command to add a route - route_add_cmd = 'sudo ip route add' - if route_dst: - route_add_cmd = f'{route_add_cmd} {route_dst}' - if route_gateway: - route_add_cmd = f'{route_add_cmd} via {route_gateway}' - if route_dev: - route_add_cmd = f'{route_add_cmd} dev {route_dev}' - route_add_cmd = f'{route_add_cmd} proto 42 mtu {mtu}' - # Add a route - try: - cmd(route_add_cmd) - except Exception as err: - logger.error( - f'Unable to add a route using command "{route_add_cmd}": ' - f'{err}') - - -def vici_initiate(conn: str, child_sa: str, src_addr: str, - dest_addr: str) -> bool: - """Initiate IKE SA connection with specific peer - - Args: - conn (str): an IKE connection name - child_sa (str): a child SA profile name - src_addr (str): NBMA local address - dest_addr (str): NBMA address of a peer - - Returns: - bool: a result of initiation command - """ - logger.info( - f'Trying to initiate connection. Name: {conn}, child sa: {child_sa}, ' - f'src_addr: {src_addr}, dst_addr: {dest_addr}') - try: - vyos.ipsec.vici_initiate(conn, child_sa, src_addr, dest_addr) - return True - except Exception as err: - logger.error(f'Unable to initiate connection {err}') - return False - - -def vici_terminate(conn: str, src_addr: str, dest_addr: str) -> None: - """Find and terminate IKE SAs by local NBMA and remote NBMA addresses - - Args: - conn (str): IKE connection name - src_addr (str): NBMA local address - dest_addr (str): NBMA address of a peer - """ - logger.info( - f'Terminating IKE connection {conn} between {src_addr} ' - f'and {dest_addr}') - - ikeid_list: list[str] = vici_get_ipsec_uniqueid(conn, src_addr, dest_addr) - - if not ikeid_list: - logger.warning( - f'No active sessions found for IKE profile {conn}, ' - f'local NBMA {src_addr}, remote NBMA {dest_addr}') - else: - try: - vyos.ipsec.terminate_vici_ikeid_list(ikeid_list) - except Exception as err: - logger.error( - f'Failed to terminate SA for IKE ids {ikeid_list}: {err}') - -def iface_up(interface: str) -> None: - """Proceed tunnel interface UP event - - Args: - interface (str): an interface name - """ - if not interface: - logger.warning('No interface name provided for UP event') - - logger.info(f'Turning up interface {interface}') - try: - cmd(f'sudo ip route flush proto 42 dev {interface}') - cmd(f'sudo ip neigh flush dev {interface}') - except Exception as err: - logger.error( - f'Unable to flush route on interface "{interface}": {err}') - - -def peer_up(dmvpn_type: str, conn: str) -> None: - """Proceed NHRP peer UP event - - Args: - dmvpn_type (str): a type of peer - conn (str): an IKE profile name - """ - logger.info(f'Peer UP event for {dmvpn_type} using IKE profile {conn}') - src_nbma = os.getenv('NHRP_SRCNBMA') - dest_nbma = os.getenv('NHRP_DESTNBMA') - dest_mtu = os.getenv('NHRP_DESTMTU') - - if not src_nbma or not dest_nbma: - logger.error( - f'Can not get NHRP NBMA addresses: local {src_nbma}, ' - f'remote {dest_nbma}') - return - - logger.info(f'NBMA addresses: local {src_nbma}, remote {dest_nbma}') - if dest_mtu: - add_peer_route(src_nbma, dest_nbma, dest_mtu) - if conn and dmvpn_type == 'spoke' and process_named_running('charon'): - vici_terminate(conn, src_nbma, dest_nbma) - vici_initiate(conn, 'dmvpn', src_nbma, dest_nbma) - - -def peer_down(dmvpn_type: str, conn: str) -> None: - """Proceed NHRP peer DOWN event - - Args: - dmvpn_type (str): a type of peer - conn (str): an IKE profile name - """ - logger.info(f'Peer DOWN event for {dmvpn_type} using IKE profile {conn}') - - src_nbma = os.getenv('NHRP_SRCNBMA') - dest_nbma = os.getenv('NHRP_DESTNBMA') - - if not src_nbma or not dest_nbma: - logger.error( - f'Can not get NHRP NBMA addresses: local {src_nbma}, ' - f'remote {dest_nbma}') - return - - logger.info(f'NBMA addresses: local {src_nbma}, remote {dest_nbma}') - if conn and dmvpn_type == 'spoke' and process_named_running('charon'): - vici_terminate(conn, src_nbma, dest_nbma) - try: - cmd(f'sudo ip route del {dest_nbma} src {src_nbma} proto 42') - except Exception as err: - logger.error( - f'Unable to del route from {src_nbma} to {dest_nbma}: {err}') - - -def route_up(interface: str) -> None: - """Proceed NHRP route UP event - - Args: - interface (str): an interface name - """ - logger.info(f'Route UP event for interface {interface}') - - dest_addr = os.getenv('NHRP_DESTADDR') - dest_prefix = os.getenv('NHRP_DESTPREFIX') - next_hop = os.getenv('NHRP_NEXTHOP') - - if not dest_addr or not dest_prefix or not next_hop: - logger.error( - f'Can not get route details: dest_addr {dest_addr}, ' - f'dest_prefix {dest_prefix}, next_hop {next_hop}') - return - - logger.info( - f'Route details: dest_addr {dest_addr}, dest_prefix {dest_prefix}, ' - f'next_hop {next_hop}') - - try: - cmd(f'sudo ip route replace {dest_addr}/{dest_prefix} proto 42 \ - via {next_hop} dev {interface}') - cmd('sudo ip route flush cache') - except Exception as err: - logger.error( - f'Unable replace or flush route to {dest_addr}/{dest_prefix} ' - f'via {next_hop} dev {interface}: {err}') - - -def route_down(interface: str) -> None: - """Proceed NHRP route DOWN event - - Args: - interface (str): an interface name - """ - logger.info(f'Route DOWN event for interface {interface}') - - dest_addr = os.getenv('NHRP_DESTADDR') - dest_prefix = os.getenv('NHRP_DESTPREFIX') - - if not dest_addr or not dest_prefix: - logger.error( - f'Can not get route details: dest_addr {dest_addr}, ' - f'dest_prefix {dest_prefix}') - return - - logger.info( - f'Route details: dest_addr {dest_addr}, dest_prefix {dest_prefix}') - try: - cmd(f'sudo ip route del {dest_addr}/{dest_prefix} proto 42') - cmd('sudo ip route flush cache') - except Exception as err: - logger.error( - f'Unable delete or flush route to {dest_addr}/{dest_prefix}: ' - f'{err}') - - -if __name__ == '__main__': - logger = getLogger('opennhrp-script', syslog=True) - logger.debug( - f'Running script with arguments: {sys.argv}, ' - f'environment: {os.environ}') - - action = sys.argv[1] - interface = os.getenv('NHRP_INTERFACE') - - if not interface: - logger.error('Can not get NHRP interface name') - sys.exit(1) - - dmvpn_type, profile_name = parse_type_ipsec(interface) - if not dmvpn_type: - logger.info(f'Interface {interface} is not NHRP tunnel') - sys.exit() - - dmvpn_conn: str = '' - if profile_name: - dmvpn_conn: str = f'dmvpn-{profile_name}-{interface}' - if action == 'interface-up': - iface_up(interface) - elif action == 'peer-register': - pass - elif action == 'peer-up': - peer_up(dmvpn_type, dmvpn_conn) - elif action == 'peer-down': - peer_down(dmvpn_type, dmvpn_conn) - elif action == 'route-up': - route_up(interface) - elif action == 'route-down': - route_down(interface) - - sys.exit() diff --git a/src/etc/ppp/ip-up.d/96-vyos-sstpc-callback b/src/etc/ppp/ip-up.d/96-vyos-sstpc-callback index 4e8804f29..e8d396887 100755 --- a/src/etc/ppp/ip-up.d/96-vyos-sstpc-callback +++ b/src/etc/ppp/ip-up.d/96-vyos-sstpc-callback @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/etc/ppp/ip-up.d/99-vyos-pppoe-callback b/src/etc/ppp/ip-up.d/99-vyos-pppoe-callback index fa1917ab1..79c7a27bf 100755 --- a/src/etc/ppp/ip-up.d/99-vyos-pppoe-callback +++ b/src/etc/ppp/ip-up.d/99-vyos-pppoe-callback @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/etc/ppp/ip-up.d/99-vyos-pppoe-wlb b/src/etc/ppp/ip-up.d/99-vyos-pppoe-wlb index fff258afa..9b708b88f 100755 --- a/src/etc/ppp/ip-up.d/99-vyos-pppoe-wlb +++ b/src/etc/ppp/ip-up.d/99-vyos-pppoe-wlb @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/etc/ppp/ipv6-up.d/99-vyos-pppoe-callback b/src/etc/ppp/ipv6-up.d/99-vyos-pppoe-callback new file mode 120000 index 000000000..ce0827d8b --- /dev/null +++ b/src/etc/ppp/ipv6-up.d/99-vyos-pppoe-callback @@ -0,0 +1 @@ +../ip-up.d/99-vyos-pppoe-callback
\ No newline at end of file diff --git a/src/etc/skel/.bashrc b/src/etc/skel/.bashrc index f807f0c72..1c4bce4fb 100644 --- a/src/etc/skel/.bashrc +++ b/src/etc/skel/.bashrc @@ -114,9 +114,4 @@ if ! shopt -oq posix; then . /etc/bash_completion fi fi -OPAMROOT='/opt/opam'; export OPAMROOT; -OPAM_SWITCH_PREFIX='/opt/opam/4.07.0'; export OPAM_SWITCH_PREFIX; -CAML_LD_LIBRARY_PATH='/opt/opam/4.07.0/lib/stublibs:/opt/opam/4.07.0/lib/ocaml/stublibs:/opt/opam/4.07.0/lib/ocaml'; export CAML_LD_LIBRARY_PATH; -OCAML_TOPLEVEL_PATH='/opt/opam/4.07.0/lib/toplevel'; export OCAML_TOPLEVEL_PATH; -MANPATH=':/opt/opam/4.07.0/man'; export MANPATH; -PATH='/opt/opam/4.07.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'; export PATH; +PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'; export PATH; diff --git a/src/etc/sysctl.d/30-vyos-router.conf b/src/etc/sysctl.d/30-vyos-router.conf index 76be41ddc..ef81cebac 100644 --- a/src/etc/sysctl.d/30-vyos-router.conf +++ b/src/etc/sysctl.d/30-vyos-router.conf @@ -83,6 +83,16 @@ net.ipv4.conf.default.ignore_routes_with_linkdown=1 net.ipv6.conf.all.ignore_routes_with_linkdown=1 net.ipv6.conf.default.ignore_routes_with_linkdown=1 +# Disable IPv6 interface autoconfigurationnable packet forwarding for IPv6 +net.ipv6.conf.all.autoconf=0 +net.ipv6.conf.default.autoconf=0 +net.ipv6.conf.*.autoconf=0 + +# Disable IPv6 router advertisements +net.ipv6.conf.all.accept_ra=0 +net.ipv6.conf.default.accept_ra=0 +net.ipv6.conf.*.accept_ra=0 + # Enable packet forwarding for IPv6 net.ipv6.conf.all.forwarding=1 diff --git a/src/etc/systemd/system/certbot.service.d/10-override.conf b/src/etc/systemd/system/certbot.service.d/10-override.conf index 542f77eb2..5c6d98b63 100644 --- a/src/etc/systemd/system/certbot.service.d/10-override.conf +++ b/src/etc/systemd/system/certbot.service.d/10-override.conf @@ -1,7 +1,6 @@ -[Unit] -After= -After=vyos-router.service - [Service] +Group=vyattacfg ExecStart= -ExecStart=/usr/bin/certbot renew --config-dir /config/auth/letsencrypt --no-random-sleep-on-renew --post-hook "/usr/libexec/vyos/vyos-certbot-renew-pki.sh" +ExecStart=/usr/libexec/vyos/op_mode/pki.py renew_certbot +# Required for properly loading VyOS config +PrivateTmp=false diff --git a/src/etc/systemd/system/fastnetmon.service.d/override.conf b/src/etc/systemd/system/fastnetmon.service.d/override.conf deleted file mode 100644 index 841666070..000000000 --- a/src/etc/systemd/system/fastnetmon.service.d/override.conf +++ /dev/null @@ -1,12 +0,0 @@ -[Unit] -RequiresMountsFor=/run -ConditionPathExists=/run/fastnetmon/fastnetmon.conf -After= -After=vyos-router.service - -[Service] -Type=simple -WorkingDirectory=/run/fastnetmon -PIDFile=/run/fastnetmon.pid -ExecStart= -ExecStart=/usr/sbin/fastnetmon --configuration_file /run/fastnetmon/fastnetmon.conf diff --git a/src/etc/systemd/system/frr.service.d/override.conf b/src/etc/systemd/system/frr.service.d/override.conf index 614b4f7ed..7c5e1ad24 100644 --- a/src/etc/systemd/system/frr.service.d/override.conf +++ b/src/etc/systemd/system/frr.service.d/override.conf @@ -3,9 +3,12 @@ After=vyos-router.service [Service] LimitNOFILE=4096 -ExecStartPre=/bin/bash -c 'mkdir -p /run/frr/config; \ - echo "log syslog" > /run/frr/config/frr.conf; \ - echo "log facility local7" >> /run/frr/config/frr.conf; \ +ExecStartPre=/bin/bash -c 'if [ ! -f /run/frr/config/frr.conf ]; then \ + mkdir -p /run/frr/config; \ + echo "log facility daemon" > /run/frr/config/frr.conf; \ + echo "log timestamp precision 3" >> /run/frr/config/frr.conf; \ + echo "log syslog notifications" >> /run/frr/config/frr.conf; \ chown frr:frr /run/frr/config/frr.conf; \ chmod 664 /run/frr/config/frr.conf; \ - mount --bind /run/frr/config/frr.conf /etc/frr/frr.conf' + mount --bind /run/frr/config/frr.conf /etc/frr/frr.conf; \ +fi;' diff --git a/src/etc/systemd/system/isc-kea-dhcp-ddns-server.service.d/override.conf b/src/etc/systemd/system/isc-kea-dhcp-ddns-server.service.d/override.conf new file mode 100644 index 000000000..0afaacd71 --- /dev/null +++ b/src/etc/systemd/system/isc-kea-dhcp-ddns-server.service.d/override.conf @@ -0,0 +1,7 @@ +[Unit] +After= +After=vyos-router.service + +[Service] +ExecStart= +ExecStart=/usr/sbin/kea-dhcp-ddns -c /var/run/kea/kea-dhcp-ddns.conf diff --git a/src/etc/systemd/system/isc-kea-dhcp-ddns-server@.service b/src/etc/systemd/system/isc-kea-dhcp-ddns-server@.service new file mode 100644 index 000000000..4b1504ffc --- /dev/null +++ b/src/etc/systemd/system/isc-kea-dhcp-ddns-server@.service @@ -0,0 +1,15 @@ +[Unit] +Description=Kea DDNS Service +Documentation=man:kea-dhcp-ddns(8) +Wants=network-online.target +After=vyos-router.service + +[Service] +User=root +AmbientCapabilities=CAP_NET_BIND_SERVICE +Environment="KEA_LOCKFILE_DIR=/run/lock/kea" +ExecStart=/usr/libexec/vyos/system/kea-vrf-helper %i /usr/sbin/kea-dhcp-ddns -c /var/run/kea/kea-%i-dhcp-ddns.conf +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/src/etc/systemd/system/kea-dhcp4-server.service.d/override.conf b/src/etc/systemd/system/isc-kea-dhcp4-server.service.d/override.conf index 4a04892c0..f315e197b 100644 --- a/src/etc/systemd/system/kea-dhcp4-server.service.d/override.conf +++ b/src/etc/systemd/system/isc-kea-dhcp4-server.service.d/override.conf @@ -3,7 +3,9 @@ After= After=vyos-router.service [Service] +Environment="KEA_DHCP_DATA_DIR=/config/dhcp" +Environment="KEA_HOOK_SCRIPTS_PATH=/usr/libexec/vyos/system" ExecStart= -ExecStart=/usr/sbin/kea-dhcp4 -c /run/kea/kea-dhcp4.conf +ExecStart=/usr/sbin/kea-dhcp4 -c /var/run/kea/kea-dhcp4.conf ExecStartPost=!/usr/bin/python3 /usr/libexec/vyos/system/sync-dhcp-lease-to-hosts.py --inet Restart=on-failure diff --git a/src/etc/systemd/system/isc-kea-dhcp4-server@.service b/src/etc/systemd/system/isc-kea-dhcp4-server@.service new file mode 100644 index 000000000..28521e755 --- /dev/null +++ b/src/etc/systemd/system/isc-kea-dhcp4-server@.service @@ -0,0 +1,17 @@ +[Unit] +Description=Kea IPv4 DHCP daemon +Documentation=man:kea-dhcp4(8) +Wants=network-online.target +After=vyos-router.service + +[Service] +User=root +AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_NET_RAW +Environment="KEA_DHCP_DATA_DIR=/config/dhcp" +Environment="KEA_HOOK_SCRIPTS_PATH=/usr/libexec/vyos/system" +Environment="KEA_LOCKFILE_DIR=/run/lock/kea" +ExecStart=/usr/libexec/vyos/system/kea-vrf-helper %i /usr/sbin/kea-dhcp4 -c /var/run/kea/kea-%i-dhcp4.conf +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/src/etc/systemd/system/isc-kea-dhcp6-server.service.d/override.conf b/src/etc/systemd/system/isc-kea-dhcp6-server.service.d/override.conf new file mode 100644 index 000000000..cc1885877 --- /dev/null +++ b/src/etc/systemd/system/isc-kea-dhcp6-server.service.d/override.conf @@ -0,0 +1,9 @@ +[Unit] +After= +After=vyos-router.service + +[Service] +Environment="KEA_DHCP_DATA_DIR=/config/dhcp" +Environment="KEA_HOOK_SCRIPTS_PATH=/usr/libexec/vyos/system" +ExecStart= +ExecStart=/usr/sbin/kea-dhcp6 -c /var/run/kea/kea-dhcp6.conf diff --git a/src/etc/systemd/system/isc-kea-dhcp6-server@.service b/src/etc/systemd/system/isc-kea-dhcp6-server@.service new file mode 100644 index 000000000..072a2ef5f --- /dev/null +++ b/src/etc/systemd/system/isc-kea-dhcp6-server@.service @@ -0,0 +1,17 @@ +[Unit] +Description=Kea IPv6 DHCP daemon +Documentation=man:kea-dhcp6(8) +Wants=network-online.target +After=vyos-router.service + +[Service] +User=root +AmbientCapabilities=CAP_NET_BIND_SERVICE +Environment="KEA_DHCP_DATA_DIR=/config/dhcp" +Environment="KEA_HOOK_SCRIPTS_PATH=/usr/libexec/vyos/system" +Environment="KEA_LOCKFILE_DIR=/run/lock/kea" +ExecStart=/usr/libexec/vyos/system/kea-vrf-helper %i /usr/sbin/kea-dhcp6 -c /var/run/kea/kea-%i-dhcp6.conf +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/src/etc/systemd/system/kea-ctrl-agent.service.d/override.conf b/src/etc/systemd/system/kea-ctrl-agent.service.d/override.conf deleted file mode 100644 index c74fafb42..000000000 --- a/src/etc/systemd/system/kea-ctrl-agent.service.d/override.conf +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -After= -After=vyos-router.service -ConditionFileNotEmpty= - -[Service] -ExecStart= -ExecStart=/usr/sbin/kea-ctrl-agent -c /run/kea/kea-ctrl-agent.conf -AmbientCapabilities=CAP_NET_BIND_SERVICE -CapabilityBoundingSet=CAP_NET_BIND_SERVICE diff --git a/src/etc/systemd/system/kea-dhcp6-server.service.d/override.conf b/src/etc/systemd/system/kea-dhcp6-server.service.d/override.conf deleted file mode 100644 index cb33fc057..000000000 --- a/src/etc/systemd/system/kea-dhcp6-server.service.d/override.conf +++ /dev/null @@ -1,7 +0,0 @@ -[Unit] -After= -After=vyos-router.service - -[Service] -ExecStart= -ExecStart=/usr/sbin/kea-dhcp6 -c /run/kea/kea-dhcp6.conf diff --git a/src/etc/telegraf/custom_scripts/vyos_services_input_filter.py b/src/etc/telegraf/custom_scripts/vyos_services_input_filter.py index 00f2f184c..36c1643b3 100755 --- a/src/etc/telegraf/custom_scripts/vyos_services_input_filter.py +++ b/src/etc/telegraf/custom_scripts/vyos_services_input_filter.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -20,7 +20,7 @@ from vyos.configquery import ConfigTreeQuery from vyos.utils.process import is_systemd_service_running from vyos.utils.process import process_named_running -# Availible services and prouceses +# Available services and processes # 1 - service # 2 - process services = { diff --git a/src/etc/udev/rules.d/40-usb_modeswitch.rules b/src/etc/udev/rules.d/40-usb_modeswitch.rules new file mode 100644 index 000000000..cb296dd6c --- /dev/null +++ b/src/etc/udev/rules.d/40-usb_modeswitch.rules @@ -0,0 +1,11 @@ +# The Linux kernel selects configuration 2 by default, but that does not work with ModemManager - configuration 3 (MBIM mode) is a better choice + +# HP LT4132 which is a re-brand of Huawei ME906s-158 +ACTION=="add|change", SUBSYSTEM=="usb", ATTR{idVendor}=="03f0", ATTR{idProduct}=="a31d", ATTR{bConfigurationValue}!="3", ATTR{bConfigurationValue}:="0" +ACTION=="add|change", SUBSYSTEM=="usb", ATTR{idVendor}=="03f0", ATTR{idProduct}=="a31d", ATTR{bConfigurationValue}!="3", RUN+="/bin/sh -c 'sleep 1; echo 3 > %S%p/bConfigurationValue'" +ACTION=="add|change", SUBSYSTEM=="net", ATTRS{idVendor}=="03f0", ATTRS{idProduct}=="a31d", ATTR{cdc_ncm/ndp_to_end}=="N", ATTR{cdc_ncm/ndp_to_end}:="Y" + +# Huawei Technologies Co., Ltd. ME906s LTE M.2 +ACTION=="add|change", SUBSYSTEM=="usb", ATTR{idVendor}=="12d1", ATTR{idProduct}=="15c1", ATTR{bConfigurationValue}!="3", ATTR{bConfigurationValue}:="0" +ACTION=="add|change", SUBSYSTEM=="usb", ATTR{idVendor}=="12d1", ATTR{idProduct}=="15c1", ATTR{bConfigurationValue}!="3", RUN+="/bin/sh -c 'sleep 1; echo 3 > %S%p/bConfigurationValue'" +ACTION=="add|change", SUBSYSTEM=="net", ATTRS{idVendor}=="12d1", ATTRS{idProduct}=="15c1", ATTR{cdc_ncm/ndp_to_end}=="N", ATTR{cdc_ncm/ndp_to_end}:="Y" diff --git a/src/etc/udev/rules.d/90-vyos-serial.rules b/src/etc/udev/rules.d/90-vyos-serial.rules index f86b2258f..3fb34d16a 100644 --- a/src/etc/udev/rules.d/90-vyos-serial.rules +++ b/src/etc/udev/rules.d/90-vyos-serial.rules @@ -14,7 +14,7 @@ SUBSYSTEMS=="usb-serial", ENV{.ID_PORT}="$attr{port_number}" IMPORT{builtin}="path_id", IMPORT{builtin}="usb_id" -# Change the name of the usb id to a "more" human redable format. +# Change the name of the usb id to a "more" human readable format. # # - $env{ID_PATH} usually is a name like: "pci-0000:00:10.0-usb-0:2.3.3.4:1.0-port0" so we strip the "pci-*" # portion and only use the usb part @@ -22,7 +22,9 @@ IMPORT{builtin}="path_id", IMPORT{builtin}="usb_id" # (tr -d -) does the replacement # - Replace the first group after ":" to represent the bus relation (sed -e 0,/:/s//b/) indicated by "b" # - Replace the next group after ":" to represent the port relation (sed -e 0,/:/s//p/) indicated by "p" -ENV{ID_PATH}=="?*", ENV{.ID_PORT}=="", PROGRAM="/bin/sh -c 'echo $env{ID_PATH} | cut -d- -f3- | tr -d - | sed -e 0,/:/s//b/ | sed -e 0,/:/s//p/'", SYMLINK+="serial/by-bus/$result" -ENV{ID_PATH}=="?*", ENV{.ID_PORT}=="?*", PROGRAM="/bin/sh -c 'echo $env{ID_PATH} | cut -d- -f3- | tr -d - | sed -e 0,/:/s//b/ | sed -e 0,/:/s//p/'", SYMLINK+="serial/by-bus/$result" +ENV{ID_PATH}=="?*", PROGRAM="/bin/sh -c 'echo $env{ID_PATH} | cut -d- -f3- | tr -d - | sed -e 0,/:/s//b/ | sed -e 0,/:/s//p/'", ENV{.BY_BUS}="%c" +ENV{.BY_BUS}=="?*", ENV{.ID_PORT}=="", SYMLINK+="serial/by-bus/%E{.BY_BUS}" +ENV{.BY_BUS}=="?*", ENV{.ID_PORT}=="0", SYMLINK+="serial/by-bus/%E{.BY_BUS}" +ENV{.BY_BUS}=="?*", ENV{.ID_PORT}=="?*", ENV{.ID_PORT}!="0", SYMLINK+="serial/by-bus/%E{.BY_BUS}p%E{.ID_PORT}" LABEL="serial_end" diff --git a/src/etc/vmware-tools/scripts/resume-vm-default.d/ether-resume.py b/src/etc/vmware-tools/scripts/resume-vm-default.d/ether-resume.py index 7da57bca8..cfe774edd 100755 --- a/src/etc/vmware-tools/scripts/resume-vm-default.d/ether-resume.py +++ b/src/etc/vmware-tools/scripts/resume-vm-default.d/ether-resume.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/helpers/add-system-version.py b/src/helpers/add-system-version.py index 5270ee7d3..70bbd2202 100755 --- a/src/helpers/add-system-version.py +++ b/src/helpers/add-system-version.py @@ -1,6 +1,6 @@ #!/usr/bin/python3 -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/helpers/config_dependency.py b/src/helpers/config_dependency.py index 817bcc65a..4a7383cc2 100755 --- a/src/helpers/config_dependency.py +++ b/src/helpers/config_dependency.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -59,7 +59,7 @@ def graph_from_dependency_dict(d: dict) -> dict: for k in list(d): g[k] = set() # add the dependencies for every sub-case; should there be cases - # that are mutally exclusive in the future, the graphs will be + # that are mutually exclusive in the future, the graphs will be # distinguished for el in list(d[k]): g[k] |= set(d[k][el]) @@ -94,7 +94,7 @@ def path_exists(s): return s def main(): - parser = ArgumentParser(description='generate and save dict from xml defintions') + parser = ArgumentParser(description='generate and save dict from xml definitions') parser.add_argument('--dependency-dir', type=path_exists, default=dependency_dir, help='location of vyos-1x dependency directory') diff --git a/src/helpers/geoip-update.py b/src/helpers/geoip-update.py index 34accf2cc..1879ab7ef 100755 --- a/src/helpers/geoip-update.py +++ b/src/helpers/geoip-update.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -13,32 +13,77 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +# +# Note: script is used both in opmode and by build hook `40-init-geoip-database.chroot` import argparse import sys from vyos.configquery import ConfigTreeQuery -from vyos.firewall import geoip_update - -def get_config(config=None): - if config: - conf = config - else: - conf = ConfigTreeQuery() - base = ['firewall'] +from vyos.geoip import geoip_download_dbip +from vyos.geoip import geoip_download_maxmind +from vyos.geoip import db_initialise +from vyos.geoip import db_is_initialised +from vyos.geoip import db_import_dbip_ranges +from vyos.geoip import db_import_maxmind_ranges +from vyos.geoip import geoip_update - if not conf.exists(base): - return None - - return conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, - no_tag_node_value_mangle=True) +def get_config(conf): + return ( + conf.get_config_dict(['firewall', 'global-options', 'geoip'], key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True, with_defaults=True), + conf.get_config_dict(['firewall'], key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) if conf.exists(['firewall']) else None, + conf.get_config_dict(['policy'], key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) if conf.exists(['policy']) else None, + ) if __name__ == '__main__': parser = argparse.ArgumentParser() - parser.add_argument("--force", help="Force update", action="store_true") + parser.add_argument("--init", help="Initialise", action="store_true") args = parser.parse_args() - firewall = get_config() + if args.init: + db_initialise() + db_import_dbip_ranges(delete_file=True) + sys.exit(0) + + conf = ConfigTreeQuery() + + if not conf.exists(['system', 'name-server']): + print('There are no system name-servers configured') + sys.exit(1) + + options, firewall, policy = get_config(conf) + + if not db_is_initialised(): + db_initialise() + + if options['provider'] == 'db-ip': + print('Downloading latest DB-IP database...') + if not geoip_download_dbip(): + print('Failed to download, aborting.') + sys.exit(1) + + print('Extracting database...') + if not db_import_dbip_ranges(delete_file=True): + print('Failed to extract, aborting.') + sys.exit(1) + + elif options['provider'] == 'maxmind': + account_id = options['maxmind_account_id'] + license_key = options['maxmind_license_key'] + lite = 'maxmind_lite' in options + + print('Downloading latest MaxMind database...') + if not geoip_download_maxmind(account_id, license_key, lite): + print('Failed to download, aborting.') + sys.exit(1) + + print('Extracting database...') + if not db_import_maxmind_ranges(delete_file=True): + print('Failed to extract, aborting.') + sys.exit(1) - if not geoip_update(firewall, force=args.force): + if not geoip_update(firewall=firewall, policy=policy): sys.exit(1) diff --git a/src/helpers/priority.py b/src/helpers/priority.py index 04186104c..6630889af 100755 --- a/src/helpers/priority.py +++ b/src/helpers/priority.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/helpers/read-saved-value.py b/src/helpers/read-saved-value.py index 1463e9ffe..f4048f373 100755 --- a/src/helpers/read-saved-value.py +++ b/src/helpers/read-saved-value.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/helpers/reset_section.py b/src/helpers/reset_section.py new file mode 100755 index 000000000..2d5695d6c --- /dev/null +++ b/src/helpers/reset_section.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 argparse +import sys +import os +import grp + +from vyos.configsession import ConfigSession +from vyos.config import Config +from vyos.configdiff import get_config_diff +from vyos.xml_ref import is_leaf +from vyos.utils.commit import wait_for_commit_lock + + +CFG_GROUP = 'vyattacfg' +DEBUG = False + + +def type_str_to_list(value): + if isinstance(value, str): + return value.split() + raise argparse.ArgumentTypeError('path must be a whitespace separated string') + + +parser = argparse.ArgumentParser() +parser.add_argument('path', type=type_str_to_list, help='section to reload/rollback') +parser.add_argument('--pid', help='pid of config session') + +group = parser.add_mutually_exclusive_group() +group.add_argument('--reload', action='store_true', help='retry proposed commit') +group.add_argument( + '--rollback', action='store_true', default=True, help='rollback to stable commit' +) + +args = parser.parse_args() + +path = args.path +reload = args.reload +rollback = args.rollback +pid = args.pid + +try: + if is_leaf(path): + sys.exit('path is leaf node: neither allowed nor useful') +except ValueError: + if DEBUG: + sys.exit('nonexistent path: neither allowed nor useful') + else: + sys.exit() + +test = Config() +in_session = test.in_session() + +if in_session: + if reload: + sys.exit('reset_section reload not available inside of a config session') + + diff = get_config_diff(test) + if not diff.is_node_changed(path): + # No discrepancies at path after commit, hence no error to revert. + sys.exit() + + del diff +else: + if not reload: + sys.exit('reset_section rollback not available outside of a config session') + +del test + + +session_id = int(pid) if pid else os.getpid() + +if in_session: + # check hint left by vyshim when ConfigError is from apply stage + hint_name = f'/tmp/apply_{session_id}' + if not os.path.exists(hint_name): + # no apply error; exit + sys.exit() + else: + # cleanup hint and continue with reset + os.unlink(hint_name) + +cfg_group = grp.getgrnam(CFG_GROUP) +os.setgid(cfg_group.gr_gid) +os.umask(0o002) + +wait_for_commit_lock() + +shared = not bool(reload) + +session = ConfigSession(session_id, shared=shared) + +session_env = session.get_session_env() +config = Config(session_env) + +d = config.get_config_dict(path, effective=True, get_first_key=True) + +if in_session: + session.discard() + +session.delete(path) +session.commit() + +if not d: + # nothing more to do in either case of reload/rollback + sys.exit() + +session.set_section(path, d) +out = session.commit() +print(out) diff --git a/src/helpers/run-config-activation.py b/src/helpers/run-config-activation.py index 58293702a..7e7a6571e 100755 --- a/src/helpers/run-config-activation.py +++ b/src/helpers/run-config-activation.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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,20 +14,33 @@ # 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 re + +import sys import logging + from pathlib import Path from argparse import ArgumentParser from vyos.compose_config import ComposeConfig from vyos.compose_config import ComposeConfigError +from vyos.utils.activate import refresh_activation_list +from vyos.utils.activate import get_activation_scripts +from vyos.utils.activate import get_activation +from vyos.utils.activate import set_activation +from vyos.utils.activate import is_active +from vyos.utils.system import load_as_module +from vyos.utils.func import FalseCallable from vyos.defaults import directories +from vyos.defaults import activation_list + parser = ArgumentParser() -parser.add_argument('config_file', type=str, - help="configuration file to modify with system-specific settings") -parser.add_argument('--test-script', type=str, - help="test effect of named script") +parser.add_argument( + 'config_file', + type=str, + help='configuration file to modify with system-specific settings', +) +parser.add_argument('--test-script', type=str, help='test effect of named script') args = parser.parse_args() @@ -40,6 +53,7 @@ formatter = logging.Formatter('%(message)s') fh.setFormatter(formatter) logger.addHandler(fh) + if 'vyos-activate-debug' in Path('/proc/cmdline').read_text(): print(f'\nactivate-debug enabled: file {checkpoint_file}_* on error') debug = checkpoint_file @@ -48,36 +62,67 @@ else: debug = None logger.setLevel(logging.INFO) -def sort_key(s: Path): - s = s.stem - pre, rem = re.match(r'(\d*)(?:-)?(.+)', s).groups() - return int(pre or 0), rem def file_ext(file_name: str) -> str: - """Return an identifier from file name for checkpoint file extension. - """ + """Return an identifier from file name for checkpoint file extension.""" return Path(file_name).stem + +refresh_activation_list() + +if not Path(activation_list).exists(): + logger.error('Missing config activation list!') + sys.exit(1) + script_dir = Path(directories['activate']) if args.test_script: - script_list = [script_dir.joinpath(args.test_script)] + script_list = [script_dir.joinpath(args.test_script).stem] else: - script_list = sorted(script_dir.glob('*.py'), key=sort_key) + script_list = list(get_activation_scripts()) config_file = args.config_file config_str = Path(config_file).read_text() compose = ComposeConfig(config_str, checkpoint_file=debug) +false_call = FalseCallable() + +update_config = False for file in script_list: - file = file.as_posix() + if not is_active(file): + continue + logger.info(f'calling {file}') - try: - compose.apply_file(file, func_name='activate') - except ComposeConfigError as e: - if debug: - compose.write(f'{compose.checkpoint_file}_{file_ext(file)}') - logger.error(f'config-activation error in {file}: {e}') - -compose.write(config_file, with_version=True) + + mod_name = Path(file).stem.replace('-', '_') + mod = load_as_module(mod_name, script_dir.joinpath(f'{file}.py').as_posix()) + + pre_condition = getattr(mod, 'pre_condition', false_call) + post_condition = getattr(mod, 'post_condition', false_call) + activate = getattr(mod, 'activate', false_call) + + if not activate: + logger.error(f'missing activate function in {file}') + continue + + if not pre_condition or pre_condition(): + try: + compose.apply_func(activate) + except ComposeConfigError as e: + if debug: + compose.write(f'{compose.checkpoint_file}_{file_ext(file)}') + logger.error(f'config-activation error in {file}: {e}') + update_config = False + break + + if post_condition: + post_condition() + + if get_activation(file) == 'once': + set_activation(file, 'off') + + update_config = True + +if update_config: + compose.write(config_file, with_version=True) diff --git a/src/helpers/run-config-migration.py b/src/helpers/run-config-migration.py index e6ce97363..6a3533644 100755 --- a/src/helpers/run-config-migration.py +++ b/src/helpers/run-config-migration.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -19,6 +19,7 @@ import sys import time from argparse import ArgumentParser from shutil import copyfile +from vyos.utils.file import read_file from vyos.migrate import ConfigMigrate from vyos.migrate import ConfigMigrateError @@ -76,3 +77,9 @@ except ConfigMigrateError as e: if backup is not None and not config_migrate.config_modified: os.unlink(backup) + +# T1771: add knob on Kernel command-line to simulate failed config migrator run +# used to test if the automatic image reboot works. +kernel_cmdline = read_file('/proc/cmdline') +if 'vyos-fail-migration' in kernel_cmdline.split(): + sys.exit(1) diff --git a/src/helpers/set_vyconf_backend.py b/src/helpers/set_vyconf_backend.py new file mode 100755 index 000000000..dddbe12f6 --- /dev/null +++ b/src/helpers/set_vyconf_backend.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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/>. +# +# + +# N.B. only for use within testing framework; explicit invocation will leave +# system in inconsistent state. + +import os +import sys +from argparse import ArgumentParser + +from vyos.utils.backend import set_vyconf_backend + +if os.getuid() != 0: + sys.exit('Requires root privileges') + +parser = ArgumentParser() +parser.add_argument('--disable', action='store_true', + help='enable/disable vyconf backend') +parser.add_argument('--no-prompt', action='store_true', + help='confirm without prompt') + +args = parser.parse_args() + +match args.disable: + case False: + set_vyconf_backend(True, no_prompt=args.no_prompt) + case True: + set_vyconf_backend(False, no_prompt=args.no_prompt) diff --git a/src/helpers/show_commit_data.py b/src/helpers/show_commit_data.py index d507ed9a4..85ee64cb1 100755 --- a/src/helpers/show_commit_data.py +++ b/src/helpers/show_commit_data.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/helpers/strip-private.py b/src/helpers/strip-private.py index cb29069cf..71b7c079a 100755 --- a/src/helpers/strip-private.py +++ b/src/helpers/strip-private.py @@ -1,6 +1,6 @@ #!/usr/bin/python3 -# Copyright 2021-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/helpers/teardown-config-session.py b/src/helpers/teardown-config-session.py new file mode 100755 index 000000000..49a3f5bc5 --- /dev/null +++ b/src/helpers/teardown-config-session.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 + +from vyos.vyconf_session import VyconfSession + +if len(sys.argv) < 2: + sys.exit('session pid is required') + +pid = sys.argv[1] + +try: + vc = VyconfSession(pid=int(pid), extant=True) +except ValueError: + # as this script is called on any config session exit, ignore non-existent + pass +else: + vc.teardown() diff --git a/src/helpers/test_commit.py b/src/helpers/test_commit.py index 00a413687..cfff85b9d 100755 --- a/src/helpers/test_commit.py +++ b/src/helpers/test_commit.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/helpers/validate-config.py b/src/helpers/validate-config.py new file mode 100755 index 000000000..1c77d2240 --- /dev/null +++ b/src/helpers/validate-config.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 +import argparse + +from vyos.configtree import ConfigTree +from vyos.configtree import validate_tree_filter +from vyos.component_version import add_system_version + + +parser = argparse.ArgumentParser() +parser.add_argument('config_file', help='config file to validate') +parser.add_argument('--filtered-config', help='write valid subset of config file') + +args = parser.parse_args() + +config_file = args.config_file +filtered_config = args.filtered_config + +with open(config_file) as f: + config_str = f.read() + +config_tree = ConfigTree(config_str) + +valid_tree, out = validate_tree_filter(config_tree) + +if filtered_config: + add_system_version(valid_tree.to_string(), filtered_config) + +if out: + print(out) + +sys.exit(int(bool(out))) diff --git a/src/helpers/vyconf_cli.py b/src/helpers/vyconf_cli.py new file mode 100755 index 000000000..9a614675b --- /dev/null +++ b/src/helpers/vyconf_cli.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 os +import sys + +from vyos.vyconf_session import VyconfSession + + +pid = os.getppid() + +vs = VyconfSession(pid=pid) + +script_path = sys.argv[0] +script_name = os.path.basename(script_path) +# drop prefix 'vy_' if present +if script_name.startswith('vy_'): + func_name = script_name[3:] +else: + func_name = script_name + +if hasattr(vs, func_name): + func = getattr(vs, func_name) +else: + sys.exit(f'Call unimplemented: {func_name}') + +res = func() +if isinstance(res, bool): + # for use in shell scripts + sys.exit(int(not res)) + +if isinstance(res, tuple): + out, err = res + print(out) + sys.exit(err) diff --git a/src/helpers/vyos-boot-config-loader.py b/src/helpers/vyos-boot-config-loader.py index 42de696ce..9826a6d3e 100755 --- a/src/helpers/vyos-boot-config-loader.py +++ b/src/helpers/vyos-boot-config-loader.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -13,8 +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 os import sys @@ -23,10 +21,13 @@ import grp import traceback from datetime import datetime -from vyos.defaults import directories, config_status -from vyos.configsession import ConfigSession, ConfigSessionError +from vyos.defaults import directories +from vyos.defaults import config_status +from vyos.configsession import ConfigSession +from vyos.configsession import ConfigSessionError from vyos.configtree import ConfigTree from vyos.utils.process import cmd +from vyos.utils.file import write_file STATUS_FILE = config_status TRACE_FILE = '/tmp/boot-config-trace' @@ -68,16 +69,16 @@ def trace_to_file(trace_file_name): print('{0}'.format(e)) def failsafe(config_file_name): - fail_msg = """ + fail_msg = f""" !!!!! There were errors loading the configuration - Please examine the errors in - {0} - and correct + Please examine the errors in: + {TRACE_FILE} !!!!! - """.format(TRACE_FILE) + """ print(fail_msg, file=sys.stderr) + write_file('/run/motd.d/9999-boot-config-error', fail_msg) users = [x[0] for x in pwd.getpwall()] if 'vyos' in users: diff --git a/src/helpers/vyos-certbot-renew-pki.sh b/src/helpers/vyos-certbot-renew-pki.sh deleted file mode 100755 index 1c273d2fa..000000000 --- a/src/helpers/vyos-certbot-renew-pki.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/vbash -source /opt/vyatta/etc/functions/script-template -/usr/libexec/vyos/conf_mode/pki.py certbot_renew diff --git a/src/helpers/vyos-check-wwan.py b/src/helpers/vyos-check-wwan.py index 334f08dd3..4768ddf3f 100755 --- a/src/helpers/vyos-check-wwan.py +++ b/src/helpers/vyos-check-wwan.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/helpers/vyos-config-encrypt.py b/src/helpers/vyos-config-encrypt.py index 84860bd6a..e035040e1 100755 --- a/src/helpers/vyos-config-encrypt.py +++ b/src/helpers/vyos-config-encrypt.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -20,43 +20,32 @@ import sys from argparse import ArgumentParser from cryptography.fernet import Fernet -from tempfile import NamedTemporaryFile -from tempfile import TemporaryDirectory +from tempfile import NamedTemporaryFile, TemporaryDirectory -from vyos.tpm import clear_tpm_key -from vyos.tpm import read_tpm_key -from vyos.tpm import write_tpm_key +from vyos.system.image import is_live_boot, get_running_image +from vyos.tpm import clear_tpm_key, read_tpm_key, write_tpm_key from vyos.utils.io import ask_input, ask_yes_no -from vyos.utils.process import cmd +from vyos.utils.process import cmd, run +from vyos.defaults import directories persistpath_cmd = '/opt/vyatta/sbin/vyos-persistpath' -mount_paths = ['/config', '/opt/vyatta/etc/config'] +# mount_path is /opt/vyatta/etc/config as of this writing +mount_path = directories['config'] +mount_path_old = f'{mount_path}.old' dm_device = '/dev/mapper/vyos_config' + def is_opened(): return os.path.exists(dm_device) -def get_current_image(): - with open('/proc/cmdline', 'r') as f: - args = f.read().split(" ") - for arg in args: - if 'vyos-union' in arg: - k, v = arg.split("=") - path_split = v.split("/") - return path_split[-1] - return None - def load_config(key): if not key: return persist_path = cmd(persistpath_cmd).strip() - image_name = get_current_image() + image_name = get_running_image() image_path = os.path.join(persist_path, 'luks', image_name) - if not os.path.exists(image_path): - raise Exception("Encrypted config volume doesn't exist") - if is_opened(): print('Encrypted config volume is already mounted') return @@ -67,24 +56,22 @@ def load_config(key): cmd(f'cryptsetup -q open {image_path} vyos_config --key-file={key_file}') - for path in mount_paths: - cmd(f'mount /dev/mapper/vyos_config {path}') - cmd(f'chgrp -R vyattacfg {path}') + run(f'umount -l {mount_path}') + cmd(f'mount /dev/mapper/vyos_config {mount_path}') + cmd(f'chgrp -R vyattacfg {mount_path}') os.unlink(key_file) return True -def encrypt_config(key, recovery_key): - if is_opened(): - raise Exception('An encrypted config volume is already mapped') - +def encrypt_config(key, recovery_key=None, is_tpm=True): # Clear and write key to TPM - try: - clear_tpm_key() - except: - pass - write_tpm_key(key) + if is_tpm: + try: + clear_tpm_key() + except: + pass + write_tpm_key(key) persist_path = cmd(persistpath_cmd).strip() size = ask_input('Enter size of encrypted config partition (MB): ', numeric_only=True, default=512) @@ -94,38 +81,49 @@ def encrypt_config(key, recovery_key): if not os.path.isdir(luks_folder): os.mkdir(luks_folder) - image_name = get_current_image() + image_name = get_running_image() image_path = os.path.join(luks_folder, image_name) - # Create file for encrypted config - cmd(f'fallocate -l {size}M {image_path}') + try: + # Create file for encrypted config + cmd(f'fallocate -l {size}M {image_path}') - # Write TPM key for slot #1 - with NamedTemporaryFile(dir='/dev/shm', delete=False) as f: - f.write(key) - key_file = f.name + # Write TPM key for slot #1 + with NamedTemporaryFile(dir='/dev/shm', delete=False) as f: + f.write(key) + key_file = f.name - # Format and add main key to volume - cmd(f'cryptsetup -q luksFormat {image_path} {key_file}') + # Format and add main key to volume + cmd(f'cryptsetup -q luksFormat {image_path} {key_file}') - if recovery_key: - # Write recovery key for slot 2 - with NamedTemporaryFile(dir='/dev/shm', delete=False) as f: - f.write(recovery_key) - recovery_key_file = f.name + if recovery_key: + # Write recovery key for slot 2 + with NamedTemporaryFile(dir='/dev/shm', delete=False) as f: + f.write(recovery_key) + recovery_key_file = f.name - cmd(f'cryptsetup -q luksAddKey {image_path} {recovery_key_file} --key-file={key_file}') + cmd(f'cryptsetup -q luksAddKey {image_path} {recovery_key_file} --key-file={key_file}') - # Open encrypted volume and format with ext4 - cmd(f'cryptsetup -q open {image_path} vyos_config --key-file={key_file}') - cmd('mkfs.ext4 /dev/mapper/vyos_config') + # Open encrypted volume and format with ext4 + cmd(f'cryptsetup -q open {image_path} vyos_config --key-file={key_file}') + cmd('mkfs.ext4 /dev/mapper/vyos_config') + except Exception as e: + print('An error occurred while creating the encrypted config volume, aborting.') + + if os.path.exists('/dev/mapper/vyos_config'): + run('cryptsetup -q close vyos_config') + + if os.path.exists(image_path): + os.unlink(image_path) + + raise e with TemporaryDirectory() as d: cmd(f'mount /dev/mapper/vyos_config {d}') - # Move /config to encrypted volume - shutil.copytree('/config', d, copy_function=shutil.move, dirs_exist_ok=True) - + # Move mount_path to encrypted volume + shutil.copytree(mount_path, d, copy_function=shutil.move, dirs_exist_ok=True) + cmd(f'chgrp -R vyattacfg {d}') cmd(f'umount {d}') os.unlink(key_file) @@ -133,22 +131,53 @@ def encrypt_config(key, recovery_key): if recovery_key: os.unlink(recovery_key_file) - for path in mount_paths: - cmd(f'mount /dev/mapper/vyos_config {path}') - cmd(f'chgrp vyattacfg {path}') + run(f'umount -l {mount_path}') + cmd(f'mount /dev/mapper/vyos_config {mount_path}') + cmd(f'chgrp vyattacfg {mount_path}') return True -def decrypt_config(key): +def config_backup_folder(base): + # Get next available backup folder + if not os.path.exists(base): + return base + + idx = 1 + while os.path.exists(f'{base}.{idx}'): + idx += 1 + return f'{base}.{idx}' + +def test_decrypt(key): if not key: return persist_path = cmd(persistpath_cmd).strip() - image_name = get_current_image() + image_name = get_running_image() image_path = os.path.join(persist_path, 'luks', image_name) - if not os.path.exists(image_path): - raise Exception("Encrypted config volume doesn't exist") + key_file = None + + if not is_opened(): + with NamedTemporaryFile(dir='/dev/shm', delete=False) as f: + f.write(key) + key_file = f.name + + try: + cmd(f'cryptsetup -q open {image_path} vyos_config --key-file={key_file}') + os.unlink(key_file) + return True + except: + return False + return False + +def decrypt_config(key): + if not key: + return + + persist_path = cmd(persistpath_cmd).strip() + image_name = get_running_image() + image_path = os.path.join(persist_path, 'luks', image_name) + original_config_path = os.path.join(persist_path, 'boot', image_name, 'rw', 'opt', 'vyatta', 'etc', 'config') key_file = None @@ -160,22 +189,26 @@ def decrypt_config(key): cmd(f'cryptsetup -q open {image_path} vyos_config --key-file={key_file}') # unmount encrypted volume mount points - for path in mount_paths: - if os.path.ismount(path): - cmd(f'umount {path}') + run(f'umount -Alq /dev/mapper/vyos_config') + + # If /opt/vyatta/etc/config is populated, move to /opt/vyatta/etc/config.old + if len(os.listdir(mount_path)) > 0: + backup_path = config_backup_folder(mount_path_old) + print(f'Moving existing {mount_path} folder to {backup_path}') + shutil.move(mount_path, backup_path) - # If /config is populated, move to /config.old - if len(os.listdir('/config')) > 0: - print('Moving existing /config folder to /config.old') - shutil.move('/config', '/config.old') + # Mount original persistence config path + if not os.path.exists(mount_path): + os.mkdir(mount_path) + cmd(f'mount --bind {original_config_path} {mount_path}') # Temporarily mount encrypted volume and migrate files to /config on rootfs with TemporaryDirectory() as d: cmd(f'mount /dev/mapper/vyos_config {d}') - # Move encrypted volume to /config - shutil.copytree(d, '/config', copy_function=shutil.move, dirs_exist_ok=True) - cmd(f'chgrp -R vyattacfg /config') + # Move encrypted volume to /opt/vyatta/etc/config + shutil.copytree(d, mount_path, copy_function=shutil.move, dirs_exist_ok=True) + cmd(f'chgrp -R vyattacfg {mount_path}') cmd(f'umount {d}') @@ -188,7 +221,8 @@ def decrypt_config(key): os.unlink(image_path) try: - clear_tpm_key() + if ask_yes_no('Do you want to clear the TPM? This will cause issues if other system images use the key'): + clear_tpm_key() except: pass @@ -199,12 +233,28 @@ if __name__ == '__main__': print("Must specify action.") sys.exit(1) + if is_live_boot(): + print("Config encryption not available on live-ISO environment") + sys.exit(1) + parser = ArgumentParser(description='Config encryption') parser.add_argument('--disable', help='Disable encryption', action="store_true") parser.add_argument('--enable', help='Enable encryption', action="store_true") parser.add_argument('--load', help='Load encrypted config volume', action="store_true") args = parser.parse_args() + if args.disable or args.load: + persist_path = cmd(persistpath_cmd).strip() + image_name = get_running_image() + image_path = os.path.join(persist_path, 'luks', image_name) + + if not os.path.exists(image_path): + print('Encrypted config volume does not exist, aborting.') + sys.exit(0) + elif args.enable and is_opened(): + print('An encrypted config volume is already mapped, aborting.') + sys.exit(0) + tpm_exists = os.path.exists('/sys/class/tpm/tpm0') key = None @@ -213,30 +263,43 @@ if __name__ == '__main__': question_key_str = 'recovery key' if tpm_exists else 'key' - if tpm_exists: - if args.enable: - key = Fernet.generate_key() - elif args.disable or args.load: + if not is_opened(): + if tpm_exists: + existing_key = None + try: - key = read_tpm_key() - need_recovery = False - except: - print('Failed to read key from TPM, recovery key required') - need_recovery = True - else: - need_recovery = True + existing_key = read_tpm_key() + except: pass + + if args.enable: + if existing_key: + print('WARNING: An encryption key already exists in the TPM.') + print('If you choose not to use the existing key, any system image') + print('using the old key will need the recovery key.') + if existing_key and ask_yes_no('Do you want to use the existing TPM key?'): + key = existing_key + else: + key = Fernet.generate_key() + elif args.disable or args.load: + if existing_key and test_decrypt(existing_key): + need_recovery = False + else: + print('TPM key invalid or not found, recovery key required') + need_recovery = True + else: + need_recovery = True if args.enable and not tpm_exists: print('WARNING: VyOS will boot into a default config when encrypted without a TPM') print('You will need to manually login with default credentials and use "encryption load"') - print('to mount the encrypted volume and use "load /config/config.boot"') + print(f'to mount the encrypted volume and use "load {mount_path}/config.boot"') if not ask_yes_no('Are you sure you want to proceed?'): sys.exit(0) if need_recovery or (args.enable and not ask_yes_no(f'Automatically generate a {question_key_str}?', default=True)): while True: - recovery_key = ask_input(f'Enter {question_key_str}:', default=None).encode() + recovery_key = ask_input(f'Enter {question_key_str}:', default=None, no_echo=True).encode() if len(recovery_key) >= 32: break @@ -250,12 +313,12 @@ if __name__ == '__main__': decrypt_config(key or recovery_key) print('Encrypted config volume has been disabled') - print('Contents have been migrated to /config on rootfs') + print(f'Contents have been migrated to {mount_path} on rootfs') elif args.load: load_config(key or recovery_key) print('Encrypted config volume has been mounted') - print('Use "load /config/config.boot" to load configuration') + print(f'Use "load {mount_path}/config.boot" to load configuration') elif args.enable and tpm_exists: encrypt_config(key, recovery_key) @@ -263,7 +326,10 @@ if __name__ == '__main__': print('Backup the recovery key in a safe place!') print('Recovery key: ' + recovery_key.decode()) elif args.enable: - encrypt_config(recovery_key) + if recovery_key != ask_input('Confirm key:', default=None, no_echo=True).encode(): + raise ValueError("Keys did not match!") + + encrypt_config(recovery_key, is_tpm=False) print('Encrypted config volume has been enabled without TPM') print('Backup the key in a safe place!') diff --git a/src/helpers/vyos-failover.py b/src/helpers/vyos-failover.py index 348974364..22f8f1d35 100755 --- a/src/helpers/vyos-failover.py +++ b/src/helpers/vyos-failover.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -15,22 +15,50 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import argparse +import atexit import json -import socket +import signal import time +from collections import namedtuple +from vyos.template import get_dhcp_router from vyos.utils.process import rc_cmd +from vyos.utils.process import run from pathlib import Path from systemd import journal my_name = Path(__file__).stem +# Timeout between configuration reading +# When no checks timeouts worked (e.g. no config files) +config_timeout = 1 -def is_route_exists(route, gateway, interface, metric): +# Useful debug info to console, use debug = True +# sudo systemctl stop vyos-failover.service +# sudo /usr/libexec/vyos/vyos-failover.py --config /run/vyos-failover.conf +debug = False +debug_output_journal = False +debug_output_print = True + + +def print_debug(*args, **kwargs): + if debug: + if debug_output_print: + print(*args, **kwargs) + if debug_output_journal: + journal.send(*args, **kwargs, SYSLOG_IDENTIFIER=my_name) + + +def wrap_vrf(command, vrf): + if not vrf: + return command + return f"sudo ip vrf exec {vrf} {command}" + + +def is_route_exists(ip_args): """Check if route with expected gateway, dev and metric exists""" - rc, data = rc_cmd(f'ip --json route show protocol failover {route} ' - f'via {gateway} dev {interface} metric {metric}') + rc, data = rc_cmd(f'ip --json route show {ip_args}') if rc == 0: data = json.loads(data) if len(data) > 0: @@ -38,41 +66,7 @@ def is_route_exists(route, gateway, interface, metric): return False -def get_best_route_options(route, debug=False): - """ - Return current best route ('gateway, interface, metric) - - % get_best_route_options('203.0.113.1') - ('192.168.0.1', 'eth1', 1) - - % get_best_route_options('203.0.113.254') - (None, None, None) - """ - rc, data = rc_cmd(f'ip --detail --json route show protocol failover {route}') - if rc == 0: - data = json.loads(data) - if len(data) == 0: - print(f'\nRoute {route} for protocol failover was not found') - return None, None, None - # Fake metric 999 by default - # Search route with the lowest metric - best_metric = 999 - for entry in data: - if debug: print('\n', entry) - metric = entry.get('metric') - gateway = entry.get('gateway') - iface = entry.get('dev') - if metric < best_metric: - best_metric = metric - best_gateway = gateway - best_interface = iface - if debug: - print(f'### Best_route exists: {route}, best_gateway: {best_gateway}, ' - f'best_metric: {best_metric}, best_iface: {best_interface}') - return best_gateway, best_interface, best_metric - - -def is_port_open(ip, port): +def is_port_open(ip, port, vrf=''): """ Check connection to remote host and port Return True if host alive @@ -80,33 +74,29 @@ def is_port_open(ip, port): % is_port_open('example.com', 8080) True """ - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP) - s.settimeout(2) - try: - s.connect((ip, int(port))) - s.shutdown(socket.SHUT_RDWR) - return True - except: - return False - finally: - s.close() - - -def is_target_alive(target_list=None, - iface='', - proto='icmp', - port=None, - debug=False, - policy='any-available') -> bool: - """Check the availability of each target in the target_list using + cmd = wrap_vrf(f"nc -w2 -z {ip} {port}", vrf) + rc, data = rc_cmd(cmd) + return rc == 0 + + +def is_target_alive( + targets=None, + iface='', + proto='icmp', + port=None, + nexthop_vrf='', + policy='any-available', +) -> bool: + """Check the availability of each target in the target_dict using the specified protocol ICMP, ARP, TCP Args: - target_list (list): A list of IP addresses or hostnames to check. + targets (tuple of TargetNamedTuple): A dict: keys are IP addresses to check, values - dicts with options. + Possible keys (all optional): 'vrf' and 'interface'. iface (str): The name of the network interface to use for the check. proto (str): The protocol to use for the check. Options are 'icmp', 'arp', or 'tcp'. port (int): The port number to use for the TCP check. Only applicable if proto is 'tcp'. - debug (bool): If True, print debug information during the check. + nexthop_vrf (str): Nexthop VRF name - if specific vrf is not given for target, use this one policy (str): The policy to use for the check. Options are 'any-available' or 'all-available'. Returns: @@ -120,30 +110,41 @@ def is_target_alive(target_list=None, iface = f'-I {iface}' num_reachable_targets = 0 - for target in target_list: + for options in targets: + target = options.target + vrf = options.vrf if options.vrf else nexthop_vrf + # don't use nexthop interface if 'vrf' is given + iface_opt = iface if vrf == nexthop_vrf else '' + # in any case if 'interface' is given, use it + if options.interface: + iface_opt = f'-I {options.interface}' match proto: case 'icmp': - command = f'/usr/bin/ping -q {target} {iface} -n -c 2 -W 1' + command = f'/usr/bin/ping -q {target} {iface_opt} -n -c 2 -W 1' + command = wrap_vrf(command, vrf) rc, response = rc_cmd(command) - if debug: - print(f' [ CHECK-TARGET ]: [{command}] -- return-code [RC: {rc}]') + print_debug( + f' [ CHECK-TARGET ]: [{command}] -- return-code [RC: {rc}]' + ) if rc == 0: num_reachable_targets += 1 if policy == 'any-available': return True case 'arp': - command = f'/usr/bin/arping -b -c 2 -f -w 1 -i 1 {iface} {target}' + command = f'/usr/bin/arping -b -c 2 -f -w 1 -i 1 {iface_opt} {target}' + command = wrap_vrf(command, vrf) rc, response = rc_cmd(command) - if debug: - print(f' [ CHECK-TARGET ]: [{command}] -- return-code [RC: {rc}]') + print_debug( + f' [ CHECK-TARGET ]: [{command}] -- return-code [RC: {rc}]' + ) if rc == 0: num_reachable_targets += 1 if policy == 'any-available': return True case _ if proto == 'tcp' and port is not None: - if is_port_open(target, port): + if is_port_open(target, port, vrf=vrf): num_reachable_targets += 1 if policy == 'any-available': return True @@ -151,85 +152,426 @@ def is_target_alive(target_list=None, case _: return False - if policy == 'all-available' and num_reachable_targets == len(target_list): + if policy == 'all-available' and num_reachable_targets == len(targets): return True return False -if __name__ == '__main__': - # Parse command arguments and get config - parser = argparse.ArgumentParser() - parser.add_argument('-c', - '--config', - action='store', - help='Path to protocols failover configuration', - required=True, - type=Path) +TargetNamedTuple = namedtuple( + 'TargetConfig', + [ + 'target', + 'vrf', + 'interface', + ], +) + +NextHopNamedTuple = namedtuple( + 'NextHopConfig', + [ + 'route', + 'dhcp_interface', + 'next_hop', + 'vrf', + 'vrf_opt', + 'conf_iface', + 'conf_metric', + 'port', + 'port_opt', + 'policy', + 'proto', + 'targets', + 'pretty_targets', + 'timeout', + 'onlink', + ], +) + + +def get_nexthop_config_vars( + destination, vrf, vrf_opt, nexthop_config, next_hop, dhcp_interface +): + port = nexthop_config.get('check').get('port') + + targets = tuple( + TargetNamedTuple( + target=target, + vrf=target_config.get('vrf', None), + interface=target_config.get('interface', None), + ) + for target, target_config in nexthop_config.get('check').get('target').items() + ) + + # for print to journal and debug + pretty_targets = [] + for target in targets: + p = target.target + options = [] + if target.vrf: + options.append(f"vrf: {target.vrf}") + if target.interface: + options.append(f"interface: {target.interface}") + if options: + p += ' (' + ', '.join(options) + ')' + pretty_targets.append(p) + pretty_targets = ', '.join(pretty_targets) + + return NextHopNamedTuple( + route=destination, + dhcp_interface=dhcp_interface, + next_hop=next_hop, + vrf=vrf, + vrf_opt=vrf_opt, + # For next-hop interface is mandatory + # For dhcp-interface it may be not given, then dhcp-interface is used + conf_iface=nexthop_config.get('interface', dhcp_interface), + conf_metric=int(nexthop_config.get('metric')), + port=port, + port_opt=f'port {port}' if port else '', + policy=nexthop_config.get('check').get('policy'), + proto=nexthop_config.get('check').get('type'), + targets=targets, + pretty_targets=pretty_targets, + timeout=nexthop_config.get('check').get('timeout'), + onlink='onlink' if 'onlink' in nexthop_config else '', + ) + + +RouteNamedTuple = namedtuple( + 'RouteConfig', + [ + 'destination', + 'vrf', + 'vrf_opt', + 'config_path', + 'nexthops', + ], +) + + +def get_route_config(route, route_config, config_path, vrf): + vrf_opt = f'vrf {vrf}' if vrf else '' + nexthops = [] + if route_config.get('next_hop'): + nexthops.extend( + get_nexthop_config_vars(route, vrf, vrf_opt, nexthop_config, next_hop, None) + for next_hop, nexthop_config in route_config.get('next_hop').items() + ) + if route_config.get('dhcp_interface'): + nexthops.extend( + get_nexthop_config_vars( + route, vrf, vrf_opt, dhcp_nexthop_config, None, interface + ) + for interface, dhcp_nexthop_config in route_config.get( + 'dhcp_interface' + ).items() + ) + nexthops = tuple(nexthops) + return RouteNamedTuple( + destination=route, + vrf=vrf, + vrf_opt=vrf_opt, + config_path=config_path, + nexthops=nexthops, + ) + + +def parse_config(config, path): + parsed = [] + vrf = config.get('vrf_context', '') + for route, route_config in config.get('route').items(): + parsed.append(get_route_config(route, route_config, path, vrf)) + return parsed + + +def flush_all_routes(): + print_debug("flush_all_routes called") + flush_cmd = 'ip route flush protocol failover table all' + run(flush_cmd) + journal.send( + flush_cmd, + SYSLOG_IDENTIFIER=my_name, + ) + + +kill_called = False + + +def kill_handler(*args): + global kill_called + if kill_called: + return + kill_called = True + print_debug(f"kill_handler called for signal {args[0]}") + + +def get_ip_command_args(nhc): + return ( + f'{nhc.route} via {nhc.next_hop} dev {nhc.conf_iface} ' + f'{nhc.onlink} metric {nhc.conf_metric} {nhc.vrf_opt} proto failover' + ) + + +def delete_route(ip_args): + print_debug(f' [ DEL ] -- ip route del {ip_args} [DELETE]') + rc_cmd(f'ip route del {ip_args}') + journal.send( + f'ip route del {ip_args}', + SYSLOG_IDENTIFIER=my_name, + ) + + +def update_configuration(last_modification_times, all_routes, config_dir): + """ + Updates configuration: + rechecks config_dir for new/updated files, + deletes routes that were deleted from configuration + + Args: + last_modification_times(dict): keys: relative path to file, value: last modification time. + Is updated. + all_routes(list): list of routes that were configured in previous call. + Is updated. + config_dir(Path): path to configuration directory + """ - args = parser.parse_args() try: - config_path = Path(args.config) - config = json.loads(config_path.read_text()) - except Exception as err: - print( - f'Configuration file "{config_path}" does not exist or malformed: {err}' - ) + # First check if there are any changes at all + have_changes = False + for child in config_dir.iterdir(): + file_key = str(child) + if file_key not in last_modification_times: + have_changes = True + print_debug(f"New file '{child}', have changes, rereading all") + break + modtime = child.stat().st_mtime_ns + if modtime != last_modification_times[file_key]: + have_changes = True + print_debug(f"File '{child} modified, have changes, rereading all...") + break + + if not have_changes: + print_debug("No changes in configuration detected.") + return + + last_modification_times.clear() + new_routes = [] + + # It is important that in configuration directory there MUST be + # only files generated by conf_mode/protocols_failover.py - otherwise + # the script won't be able to detect when all VRFs are disabled and + # won't be able to stop the service gracefully + for child in config_dir.iterdir(): + if not child.is_file(): + print( + f"Path {child} under configuration dir is not a file! Please clean configuration directory {config_dir}." + ) + exit(1) + + modtime = child.stat().st_mtime_ns + file_key = str(child) + last_modification_times[file_key] = modtime + + try: + config = json.loads(child.read_text()) + print_debug(f"Config from '{child}': {config}") + except OSError as err: + print(f'Configuration file "{child}" could not be read: {err}') + exit(1) + except json.JSONDecodeError as err: + print( + f'Configuration file "{child}" could not be parsed as JSON: {err}' + ) + exit(1) + except UnicodeDecodeError as err: + print(f'Configuration file "{child}" has Unicode errors: {err}') + exit(1) + + parsed_config = parse_config(config, file_key) + new_routes.extend(parsed_config) + except OSError as err: + print(f'Configuration dir "{config_dir}" does not exist or not readable: {err}') exit(1) - # Useful debug info to console, use debug = True - # sudo systemctl stop vyos-failover.service - # sudo /usr/libexec/vyos/vyos-failover.py --config /run/vyos-failover.conf - debug = False + old_routes_set = set(all_routes) + new_routes_set = set(new_routes) + + delete_routes = old_routes_set - new_routes_set + add_routes = new_routes_set - old_routes_set + + # Delete not needed routes + for route_config in delete_routes: + print_debug( + f"Deleting route {route_config}, not present in updated configuration" + ) + for nhc in route_config.nexthops: + ip_args = get_ip_command_args(nhc) + if is_route_exists(ip_args): + delete_route(ip_args) + all_routes.remove(route_config) + + # Add new routes + print_debug(f"Adding routes {add_routes}, new in updated configuration") + all_routes.extend(add_routes) - while(True): + print_debug(f"All routes: {all_routes}") - for route, route_config in config.get('route').items(): - exists_gateway, exists_iface, exists_metric = get_best_route_options(route, debug=debug) +def process_dhcp_interface(nhc, nexthop_by_dhcp_nexthop): + """ + Processes NextHopNamedTuple with dhcp_interface != None + Return NextHopNamedTuple with next_hop equal to DHCP gateway of nhc. + If there is no gateway for nhc, return None + + Args: + nhc(NextHopNamedTuple): configuration with dhcp_interface + nexthop_by_dhcp_nexthop(dict): dict with previous returned values + """ + cur_dhcpgw = get_dhcp_router(nhc.dhcp_interface) + if not cur_dhcpgw: + cur_dhcpgw = False + + if nhc in nexthop_by_dhcp_nexthop: + prev_dhcpgw = nexthop_by_dhcp_nexthop[nhc].next_hop + else: + prev_dhcpgw = False + + # Equal - do nothing, just return previous value + if prev_dhcpgw == cur_dhcpgw: + if not cur_dhcpgw: + return None + return nexthop_by_dhcp_nexthop[nhc] + + print_debug( + f"DHCP Gateway changed for interface {nhc.dhcp_interface} from '{prev_dhcpgw}' to '{cur_dhcpgw}'" + ) + + # dhcpgw differ and there was previous dhcpgw + if prev_dhcpgw: + prevnhc = nexthop_by_dhcp_nexthop.pop(nhc) + print_debug( + f"Deleting previous nexthop {prevnhc} because of DHCP interface change" + ) + ip_args = get_ip_command_args(prevnhc) + if is_route_exists(ip_args): + delete_route(ip_args) + + newnhc = None + # dhcpgw differ and there is new dhcpgw + if cur_dhcpgw: + newnhc = nhc._replace(next_hop=cur_dhcpgw, dhcp_interface=None) + print_debug(f"Saving new nexthop {newnhc} because of DHCP interface change") + nexthop_by_dhcp_nexthop[nhc] = newnhc + + return newnhc - for next_hop, nexthop_config in route_config.get('next_hop').items(): - conf_iface = nexthop_config.get('interface') - conf_metric = int(nexthop_config.get('metric')) - port = nexthop_config.get('check').get('port') - port_opt = f'port {port}' if port else '' - policy = nexthop_config.get('check').get('policy') - proto = nexthop_config.get('check').get('type') - target = nexthop_config.get('check').get('target') - timeout = nexthop_config.get('check').get('timeout') - onlink = 'onlink' if 'onlink' in nexthop_config else '' + +if __name__ == '__main__': + print_debug(f"{my_name} started") + + # Parse command arguments and get config + parser = argparse.ArgumentParser() + parser.add_argument( + '-c', + '--config-dir', + action='store', + help='Path to protocols failover configuration dir', + required=True, + type=Path, + ) + + args = parser.parse_args() + config_dir = Path(args.config_dir) + + last_modification_times = {} + all_routes = [] + + + # Clean all `failover` routes now and at exit + flush_all_routes() + atexit.register(flush_all_routes) + signal.signal(signal.SIGINT, kill_handler) + signal.signal(signal.SIGTERM, kill_handler) + + # keys: NextHopNamedTuple with dhcp_interface != None + # values: NextHopNamedTuple with next_hop != None + # Translates nexthop with dhcp_interface to usual nexthop + nexthop_by_dhcp_nexthop = {} + + had_sleeps = True + while not kill_called: + # Check in case daemon was launched without routes + if not had_sleeps: + time.sleep(int(config_timeout)) + + update_configuration(last_modification_times, all_routes, config_dir) + had_sleeps = False + + for route_config in all_routes: + if kill_called: + break + route = route_config.destination + vrf = route_config.vrf + vrf_opt = route_config.vrf_opt + + for nhc in route_config.nexthops: + if nhc.dhcp_interface: + nhc = process_dhcp_interface(nhc, nexthop_by_dhcp_nexthop) + if not nhc: + continue + + next_hop = nhc.next_hop + ip_args = get_ip_command_args(nhc) + + is_alive = is_target_alive( + nhc.targets, + nhc.conf_iface, + nhc.proto, + nhc.port, + nexthop_vrf=vrf, + policy=nhc.policy, + ) # Route not found in the current routing table - if not is_route_exists(route, next_hop, conf_iface, conf_metric): - if debug: print(f" [NEW_ROUTE_DETECTED] route: [{route}]") + if not is_route_exists(ip_args): + print_debug(f" [NEW_ROUTE_DETECTED] route: [{route} {vrf_opt}]") # Add route if check-target alive - if is_target_alive(target, conf_iface, proto, port, debug=debug, policy=policy): - if debug: print(f' [ ADD ] -- ip route add {route} via {next_hop} dev {conf_iface} ' - f'metric {conf_metric} proto failover\n###') - rc, command = rc_cmd(f'ip route add {route} via {next_hop} dev {conf_iface} ' - f'{onlink} metric {conf_metric} proto failover') + if is_alive: + print_debug(f' [ ADD ] -- ip route add {ip_args}\n###') + rc, command = rc_cmd(f'ip route add {ip_args}') # If something is wrong and gateway not added # Example: Error: Next-hop has invalid gateway. - if rc !=0: - if debug: print(f'{command} -- return-code [RC: {rc}] {next_hop} dev {conf_iface}') + if rc != 0: + print_debug( + f'{command} -- return-code [RC: {rc}] {next_hop} dev {nhc.conf_iface}' + ) else: - journal.send(f'ip route add {route} via {next_hop} dev {conf_iface} ' - f'{onlink} metric {conf_metric} proto failover', SYSLOG_IDENTIFIER=my_name) + journal.send( + f'ip route add {ip_args}', + SYSLOG_IDENTIFIER=my_name, + ) else: - if debug: print(f' [ TARGET_FAIL ] target checks fails for [{target}], do nothing') - journal.send(f'Check fail for route {route} target {target} proto {proto} ' - f'{port_opt}', SYSLOG_IDENTIFIER=my_name) - - # Route was added, check if the target is alive - # We should delete route if check fails only if route exists in the routing table - if not is_target_alive(target, conf_iface, proto, port, debug=debug, policy=policy) and \ - is_route_exists(route, next_hop, conf_iface, conf_metric): - if debug: - print(f'Nexh_hop {next_hop} fail, target not response') - print(f' [ DEL ] -- ip route del {route} via {next_hop} dev {conf_iface} ' - f'metric {conf_metric} proto failover [DELETE]') - rc_cmd(f'ip route del {route} via {next_hop} dev {conf_iface} metric {conf_metric} proto failover') - journal.send(f'ip route del {route} via {next_hop} dev {conf_iface} ' - f'metric {conf_metric} proto failover', SYSLOG_IDENTIFIER=my_name) - - time.sleep(int(timeout)) + print_debug( + f' [ TARGET_FAIL ] target checks fails for [{nhc.pretty_targets}], do nothing' + ) + journal.send( + f'Check fail for route {route} interface "{nhc.conf_iface}" target {nhc.pretty_targets} proto {nhc.proto} ' + f'{nhc.port_opt}', + SYSLOG_IDENTIFIER=my_name, + ) + else: + # Route was added, check if the target is alive + # We should delete route if check fails only if route exists in the routing table + if not is_alive: + print_debug( + f"Next_hop {next_hop} fail, target check didn't pass" + ) + delete_route(ip_args) + + had_sleeps = True + time.sleep(int(nhc.timeout)) + if kill_called: + break + + print_debug(f"Out of main loop, {kill_called=}") diff --git a/src/helpers/vyos-interface-rescan.py b/src/helpers/vyos-interface-rescan.py index 012357259..fea9bca1c 100755 --- a/src/helpers/vyos-interface-rescan.py +++ b/src/helpers/vyos-interface-rescan.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/helpers/vyos-load-balancer.py b/src/helpers/vyos-load-balancer.py index 30329fd5c..127ce05e5 100755 --- a/src/helpers/vyos-load-balancer.py +++ b/src/helpers/vyos-load-balancer.py @@ -1,6 +1,6 @@ #!/usr/bin/python3 -# Copyright 2024-2025 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -24,6 +24,7 @@ import time from vyos.config import Config from vyos.template import render from vyos.utils.commit import commit_in_progress +from vyos.utils.dict import dict_search_args from vyos.utils.network import get_interface_address from vyos.utils.process import rc_cmd from vyos.utils.process import run @@ -102,15 +103,36 @@ def get_ipv4_address(ifname): return addr_json['addr_info'][0]['local'] return None +def get_dynamic_nexthop(ifname: str) -> str | None | bool: + ''' + Resolve the dynamic next-hop for a WAN interface. + + Determines the current default gateway learned dynamically on the interface: + - PPPoE interfaces (`pppoe*`): uses `parse_ppp_nexthop`. + - Other interfaces (e.g. DHCP): uses `parse_dhcp_nexthop`. + + Return values: + - str: IPv4 next-hop address + - None: when DHCP lease has no router value + - False: when PPPoE nexthop state file is missing + + Args: + ifname: Interface name (e.g. 'pppoe0', 'eth0'). + + Returns: + See above for possible values. + ''' + if ifname.startswith('pppoe'): + return parse_ppp_nexthop(ifname) + else: + return parse_dhcp_nexthop(ifname) + def dynamic_nexthop_update(lb, ifname): # Update on DHCP/PPP address/nexthop changes # Return True if nftables needs to be updated - IP change if 'dhcp_nexthop' in lb['health_state'][ifname]: - if ifname[:5] == 'pppoe': - dhcp_nexthop_addr = parse_ppp_nexthop(ifname) - else: - dhcp_nexthop_addr = parse_dhcp_nexthop(ifname) + dhcp_nexthop_addr = get_dynamic_nexthop(ifname) table_num = lb['health_state'][ifname]['table_number'] @@ -125,6 +147,40 @@ def dynamic_nexthop_update(lb, ifname): return False +def restore_default_route(lb: dict, ifname: str) -> None: + """ + Restores a missing default route for a WAN interface in its policy routing table. + + When a link flap or DHCP/PPP renegotiation removes the per-interface default route, + this function checks the interface’s assigned table for an existing default entry. + If none is found, it determines the proper next-hop (from DHCP, PPP, or static config) + and reinstalls the route using: + ip route replace table <table_num> default dev <ifname> via <nexthop> + + @param lb Load-balancer state/config dictionary. + @param ifname Interface name whose default route should be verified and restored. + @returns None — exits quietly if the table number or next-hop cannot be found. + """ + table_num = dict_search_args(lb, 'health_state', ifname, 'table_number') + if not table_num: + return + + rc, out = rc_cmd(f'ip -j route show default table {table_num}') + if rc == 0: + rt_table = json.loads(out) + if len(rt_table) > 0: + return + else: + if 'dhcp_nexthop' in lb['health_state'][ifname]: + nexthop_addr = get_dynamic_nexthop(ifname) + else: + nexthop_addr = dict_search_args(lb, 'interface_health', ifname, 'nexthop') + + if nexthop_addr: + run(f'ip route replace table {table_num} default dev {ifname} via {nexthop_addr}') + else: + return + def nftables_update(lb): # Atomically reload nftables table from template if not os.path.exists(nftables_wlb_conf): @@ -148,7 +204,17 @@ def cleanup(lb): index = 1 for ifname, health_conf in lb['interface_health'].items(): table_num = lb['mark_offset'] + index + suppress_prio = lb['mark_offset'] + index + table_prio = suppress_prio + 100 run(f'ip route del table {table_num} default') + run( + f'ip rule del fwmark {hex(table_num)} table main ' + f'suppress_prefixlength 0 priority {suppress_prio}' + ) + run( + f'ip rule del fwmark {hex(table_num)} table {table_num} ' + f'priority {table_prio}' + ) run(f'ip rule del fwmark {hex(table_num)} table {table_num}') index += 1 @@ -160,6 +226,14 @@ def get_config(): lb = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, with_recursive_defaults=True) + lb['firewall_group'] = conf.get_config_dict(['firewall', 'group'], key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) + + # prune limit key if not set by user + for rule in lb.get('rule', []): + if lb.from_defaults(['rule', rule, 'limit']): + del lb['rule'][rule]['limit'] + lb['test_defaults'] = get_defaults(base + ['interface-health', 'A', 'test', 'B'], get_first_key=True) return lb @@ -199,7 +273,19 @@ if __name__ == '__main__': else: run(f'ip route replace table {table_num} default dev {ifname} via {health_conf["nexthop"]}') - run(f'ip rule add fwmark {hex(table_num)} table {table_num}') + suppress_prio = lb['mark_offset'] + index + table_prio = suppress_prio + 100 + if 'only_default_route' in lb: + run( + f'ip rule add fwmark {hex(table_num)} table main ' + f'suppress_prefixlength 0 priority {suppress_prio}' + ) + run( + f'ip rule add fwmark {hex(table_num)} table {table_num} ' + f'priority {table_prio}' + ) + else: + run(f'ip rule add fwmark {hex(table_num)} table {table_num}') index += 1 @@ -208,8 +294,8 @@ if __name__ == '__main__': run('ip route flush cache') if 'flush_connections' in lb: - run('conntrack --delete') - run('conntrack -F expect') + for _state in lb['health_state'].values(): + run(f'conntrack --delete --mark {_state["table_number"]}') with open(wlb_status_file, 'w') as f: f.write(json.dumps(lb['health_state'])) @@ -246,6 +332,7 @@ if __name__ == '__main__': # Main loop + init = True; try: while True: ip_change = False @@ -274,6 +361,11 @@ if __name__ == '__main__': state['state'] = False state['state_changed'] = True + #Force state changed to trigger the first write + if init == True: + state['state_changed'] = True + init = False + if state['state_changed']: state['if_addr'] = get_ipv4_address(ifname) on_state_change(lb, ifname, state['state']) @@ -281,6 +373,8 @@ if __name__ == '__main__': if dynamic_nexthop_update(lb, ifname): ip_change = True + restore_default_route(lb, ifname) + if any(state['state_changed'] for ifname, state in lb['health_state'].items()): if not nftables_update(lb): break @@ -288,8 +382,8 @@ if __name__ == '__main__': run('ip route flush cache') if 'flush_connections' in lb: - run('conntrack --delete') - run('conntrack -F expect') + for _state in lb['health_state'].values(): + run(f'conntrack --delete --mark {_state["table_number"]}') with open(wlb_status_file, 'w') as f: f.write(json.dumps(lb['health_state'])) diff --git a/src/helpers/vyos-load-config.py b/src/helpers/vyos-load-config.py index 16083fd41..7c49e9c3a 100755 --- a/src/helpers/vyos-load-config.py +++ b/src/helpers/vyos-load-config.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -16,84 +16,60 @@ # # -"""Load config file from within config session. -Config file specified by URI or path (without scheme prefix). -Example: load https://somewhere.net/some.config - or - load /tmp/some.config -""" - import os import sys -import gzip +import argparse import tempfile -import vyos.defaults -import vyos.remote -from vyos.configsource import ConfigSourceSession, VyOSError + +from vyos.remote import get_config_file +from vyos.config import Config from vyos.migrate import ConfigMigrate from vyos.migrate import ConfigMigrateError +from vyos.load_config import load as load_config +from vyos.defaults import directories -class LoadConfig(ConfigSourceSession): - """A subclass for calling 'loadFile'. - This does not belong in configsource.py, and only has a single caller. - """ - def load_config(self, path): - return self._run(['/bin/cli-shell-api','loadFile',path]) -file_name = sys.argv[1] if len(sys.argv) > 1 else 'config.boot' -configdir = vyos.defaults.directories['config'] -protocols = ['scp', 'sftp', 'http', 'https', 'ftp', 'tftp'] +default_config_file = os.path.join(directories['config'], 'config.boot') -def get_local_config(filename): - if os.path.isfile(filename): - fname = filename - elif os.path.isfile(os.path.join(configdir, filename)): - fname = os.path.join(configdir, filename) - else: - sys.exit(f"No such file '{filename}'") +parser = argparse.ArgumentParser() +parser.add_argument('config_file', nargs='?', help='config file to load') +parser.add_argument( + '--migrate', action='store_true', help='migrate config file before merge' +) - if fname.endswith('.gz'): - with gzip.open(fname, 'rb') as f: - try: - config_str = f.read().decode() - except OSError as e: - sys.exit(e) - else: - with open(fname, 'r') as f: - try: - config_str = f.read() - except OSError as e: - sys.exit(e) +args = parser.parse_args() - return config_str - -if any(file_name.startswith(f'{x}://') for x in protocols): - config_string = vyos.remote.get_remote_config(file_name) - if not config_string: - sys.exit(f"No such config file at '{file_name}'") -else: - config_string = get_local_config(file_name) +file_name = args.config_file if args.config_file else default_config_file -config = LoadConfig() +# pylint: disable=consider-using-with +file_path = tempfile.NamedTemporaryFile(delete=False).name +err = get_config_file(file_name, file_path) +if err: + os.remove(file_path) + sys.exit(err) -print(f"Loading configuration from '{file_name}'") +if args.migrate: + migrate = ConfigMigrate(file_path) + try: + migrate.run() + except ConfigMigrateError as e: + os.remove(file_path) + sys.exit(e) -with tempfile.NamedTemporaryFile() as fp: - with open(fp.name, 'w') as fd: - fd.write(config_string) +config = Config() - config_migrate = ConfigMigrate(fp.name) - try: - config_migrate.run() - except ConfigMigrateError as err: - sys.exit(err) +if config.vyconf_session is not None: + out, err = config.vyconf_session.load_config(file_path) + if err: + os.remove(file_path) + sys.exit(out) + print(out) +else: + load_config(file_path) - try: - config.load_config(fp.name) - except VyOSError as err: - sys.exit(err) +os.remove(file_path) if config.session_changed(): print("Load complete. Use 'commit' to make changes effective.") else: - print("No configuration changes to commit.") + print('No configuration changes to commit.') diff --git a/src/helpers/vyos-merge-config.py b/src/helpers/vyos-merge-config.py index 5ef845ac2..e8a696eb5 100755 --- a/src/helpers/vyos-merge-config.py +++ b/src/helpers/vyos-merge-config.py @@ -1,108 +1,101 @@ #!/usr/bin/python3 -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 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 library is distributed in the hope that it will be useful, +# 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 -# Lesser General Public License for more details. +# 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/>. +# # -# 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 import sys +import shlex +import argparse import tempfile -import vyos.defaults -import vyos.remote +from vyos.remote import get_config_file from vyos.config import Config from vyos.configtree import ConfigTree +from vyos.configtree import mask_inclusive +from vyos.configtree import merge from vyos.migrate import ConfigMigrate from vyos.migrate import ConfigMigrateError -from vyos.utils.process import cmd -from vyos.utils.process import DEVNULL +from vyos.load_config import load_explicit -if (len(sys.argv) < 2): - print("Need config file name to merge.") - print("Usage: merge <config file> [config path]") - sys.exit(0) -file_name = sys.argv[1] +parser = argparse.ArgumentParser() +parser.add_argument('config_file', help='config file to merge from') +parser.add_argument( + '--destructive', action='store_true', help='replace values with those of merge file' +) +parser.add_argument('--paths', nargs='+', help='only merge from listed paths') +parser.add_argument( + '--migrate', action='store_true', help='migrate config file before merge' +) -configdir = vyos.defaults.directories['config'] +args = parser.parse_args() -protocols = ['scp', 'sftp', 'http', 'https', 'ftp', 'tftp'] +file_name = args.config_file +paths = [shlex.split(s) for s in args.paths] if args.paths else [] -if any(x in file_name for x in protocols): - config_file = vyos.remote.get_remote_config(file_name) - if not config_file: - sys.exit("No config file by that name.") -else: - canonical_path = "{0}/{1}".format(configdir, file_name) - first_err = None - try: - with open(canonical_path, 'r') as f: - config_file = f.read() - except Exception as err: - first_err = err - try: - with open(file_name, 'r') as f: - config_file = f.read() - except Exception as err: - print(first_err) - print(err) - sys.exit(1) - -with tempfile.NamedTemporaryFile() as file_to_migrate: - with open(file_to_migrate.name, 'w') as fd: - fd.write(config_file) - - config_migrate = ConfigMigrate(file_to_migrate.name) +# pylint: disable=consider-using-with +file_path = tempfile.NamedTemporaryFile(delete=False).name +err = get_config_file(file_name, file_path) +if err: + os.remove(file_path) + sys.exit(err) + +if args.migrate: + migrate = ConfigMigrate(file_path) try: - config_migrate.run() + migrate.run() except ConfigMigrateError as e: + os.remove(file_path) sys.exit(e) -merge_config_tree = ConfigTree(config_file) +with open(file_path) as f: + merge_str = f.read() + +merge_ct = ConfigTree(merge_str) -effective_config = Config() -effective_config_tree = effective_config._running_config +if paths: + mask = ConfigTree('') + for p in paths: + mask.set(p) -effective_cmds = effective_config_tree.to_commands() -merge_cmds = merge_config_tree.to_commands() + merge_ct = mask_inclusive(merge_ct, mask) -effective_cmd_list = effective_cmds.splitlines() -merge_cmd_list = merge_cmds.splitlines() +with open(file_path, 'w') as f: + f.write(merge_ct.to_string()) -effective_cmd_set = set(effective_cmd_list) -add_cmds = [ cmd for cmd in merge_cmd_list if cmd not in effective_cmd_set ] +config = Config() -path = None -if (len(sys.argv) > 2): - path = sys.argv[2:] - if (not effective_config_tree.exists(path) and not - merge_config_tree.exists(path)): - print("path {} does not exist in either effective or merge" - " config; will use root.".format(path)) - path = None - else: - path = " ".join(path) +if config.vyconf_session is not None: + out, err = config.vyconf_session.merge_config( + file_path, destructive=args.destructive + ) + if err: + os.remove(file_path) + sys.exit(out) + print(out) +else: + session_ct = config.get_config_tree() + merge_res = merge(session_ct, merge_ct, destructive=args.destructive) -if path: - add_cmds = [ cmd for cmd in add_cmds if path in cmd ] + load_explicit(merge_res) -for add in add_cmds: - try: - cmd(f'/opt/vyatta/sbin/my_{add}', shell=True, stderr=DEVNULL) - except OSError as err: - print(err) +os.remove(file_path) -if effective_config.session_changed(): +if config.session_changed(): print("Merge complete. Use 'commit' to make changes effective.") else: - print("No configuration changes to commit.") + print('No configuration changes to commit.') diff --git a/src/helpers/vyos-request-configd-update.py b/src/helpers/vyos-request-configd-update.py new file mode 100755 index 000000000..55ed0eef9 --- /dev/null +++ b/src/helpers/vyos-request-configd-update.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 + +import json +import zmq + +from vyos.utils.commit import wait_for_commit_lock +from vyos.defaults import vyos_configd_socket_path + +context = zmq.Context() + +request = { + 'type': 'node', + 'last': True, + 'data': '/usr/libexec/vyos/conf_mode/protocols_static.py', +} +request = json.dumps(request) + +print("Waiting for commit lock...") +wait_for_commit_lock() + +print("Connecting to vyos-configd server...") +socket = context.socket(zmq.REQ) +socket.connect(vyos_configd_socket_path) + +print(f"Sending request {request}...") +socket.send_string(request) + +message = socket.recv() +print(f"Received reply {request} [ {message} ]") + +print("All done") diff --git a/src/helpers/vyos-save-config.py b/src/helpers/vyos-save-config.py index fa2ea0ce4..208fb8ae8 100755 --- a/src/helpers/vyos-save-config.py +++ b/src/helpers/vyos-save-config.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -23,15 +23,22 @@ from argparse import ArgumentParser from vyos.config import Config from vyos.remote import urlc -from vyos.component_version import add_system_version +from vyos.component_version import add_system_version_string from vyos.defaults import directories +from vyos.utils.file import write_file +from vyos.utils.file import write_file_sync +from vyos.utils.file import write_file_atomic +from vyos.utils.file import file_is_persistent DEFAULT_CONFIG_PATH = os.path.join(directories['config'], 'config.boot') remote_save = None parser = ArgumentParser(description='Save configuration') parser.add_argument('file', type=str, nargs='?', help='Save configuration to file') -parser.add_argument('--write-json-file', type=str, help='Save JSON of configuration to file') +parser.add_argument( + '--write-json-file', type=str, help='Save JSON of configuration to file' +) + args = parser.parse_args() file = args.file json_file = args.write_json_file @@ -47,16 +54,43 @@ if re.match(r'\w+:/', save_file): except ValueError as e: sys.exit(e) + config = Config() ct = config.get_config_tree(effective=True) +# The effective config is None before boot configuration is complete. +# Nothing to write, nor do we want to invite saving an empty string: +# exit gracefully. +if ct is None: + sys.exit() + +config_str = ct.to_string() +versioned_config_str = add_system_version_string(config_str) + # pylint: disable=consider-using-with -write_file = save_file if remote_save is None else NamedTemporaryFile(delete=False).name +file_to_write = ( + save_file if remote_save is None else NamedTemporaryFile(delete=False).name +) -# config_tree is None before boot configuration is complete; -# automated saves should check boot_configuration_complete -config_str = None if ct is None else ct.to_string() -add_system_version(config_str, write_file) +if file_is_persistent(file_to_write): + if os.geteuid() == 0: + try: + write_file_atomic(file_to_write, versioned_config_str) + except OSError as e: + print(f'failed to write config file, write_file_atomic: {e}') + sys.exit(1) + else: + try: + write_file_sync(file_to_write, versioned_config_str) + except OSError as e: + print(f'failed to write config file, write_file_sync: {e}') + sys.exit(1) +else: + try: + write_file(file_to_write, versioned_config_str) + except Exception as e: # pylint: disable=broad-exception-caught + print(f'failed to write config file, write_file: {e}') + sys.exit(1) if json_file is not None and ct is not None: try: @@ -67,6 +101,6 @@ if json_file is not None and ct is not None: if remote_save is not None: try: - remote_save.upload(write_file) + remote_save.upload(file_to_write) finally: - os.remove(write_file) + os.remove(file_to_write) diff --git a/src/helpers/vyos_config_sync.py b/src/helpers/vyos_config_sync.py index 9d9aec376..9e425d7c4 100755 --- a/src/helpers/vyos_config_sync.py +++ b/src/helpers/vyos_config_sync.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -17,6 +17,7 @@ # import os +import sys import json import requests import urllib3 @@ -26,6 +27,9 @@ from typing import Optional, List, Tuple, Dict, Any from vyos.config import Config from vyos.configtree import ConfigTree from vyos.configtree import mask_inclusive +from vyos.configtree import mask_exclusive +from vyos.defaults import config_sync_exclusion_list +from vyos.derivedtree import subtree_from_list_of_partial_paths from vyos.template import bracketize_ipv6 @@ -40,9 +44,12 @@ logger.name = os.path.basename(__file__) API_HEADERS = {'Content-Type': 'application/json'} -def post_request(url: str, - data: str, - headers: Dict[str, str]) -> requests.Response: +def post_request( + url: str, + data: str, + params: Dict[str, Any], + headers: Dict[str, str], +) -> requests.Response: """Sends a POST request to the specified URL Args: @@ -54,16 +61,20 @@ def post_request(url: str, requests.Response: The response object representing the server's response to the request """ - response = requests.post(url, - data=data, - headers=headers, - verify=False, - timeout=timeout) + response = requests.post( + url, + data=data, + params=params, + headers=headers, + verify=False, + timeout=timeout, + ) return response - -def retrieve_config(sections: List[list[str]]) -> Tuple[Dict[str, Any], Dict[str, Any]]: +def retrieve_config( + sections: List[list[str]], exclusions: List[list[str]] +) -> Tuple[Dict[str, Any], Dict[str, Any]]: """Retrieves the configuration from the local server. Args: @@ -76,18 +87,32 @@ def retrieve_config(sections: List[list[str]]) -> Tuple[Dict[str, Any], Dict[str - config: The subtree of masked config data, as a dictionary. """ - mask = ConfigTree('') - for section in sections: - mask.set(section) - mask_dict = json.loads(mask.to_json()) - config = Config() config_tree = config.get_config_tree() - masked = mask_inclusive(config_tree, mask) + + # set inclusion mask + mask_in = ConfigTree('') + for section in sections: + mask_in.set(section) + mask_in_str = mask_in.write_internal_string() + + ## set exclusion mask + # pass global settings, read at startup: + exclude_list = exclusions + # read local settings from Config + # ... exclude_list += ... + mask_ex = subtree_from_list_of_partial_paths(config_tree, exclude_list) + mask_ex_str = json.dumps(exclude_list) + + masked = mask_inclusive(config_tree, mask_in) + masked = mask_exclusive(masked, mask_ex) + + mask_dict = {'inclusive': mask_in_str, 'exclusive': mask_ex_str} config_dict = json.loads(masked.to_json()) return mask_dict, config_dict + def set_remote_config( address: str, key: str, @@ -116,6 +141,10 @@ def set_remote_config( urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) url = f'https://{address}:{port}/configure-section' + params = { + # Ask the remote API to perform the configure and commit workflow asynchronously + 'in_background': True, + } data = json.dumps({ 'op': op, 'mask': mask, @@ -124,7 +153,7 @@ def set_remote_config( }) try: - config = post_request(url, data, headers) + config = post_request(url, data, params, headers) return config.json() except requests.exceptions.RequestException as e: print(f"An error occurred: {e}") @@ -137,14 +166,19 @@ def is_section_revised(section: List[str]) -> bool: return is_node_revised(section) -def config_sync(secondary_address: str, - secondary_key: str, - sections: List[list[str]], - mode: str, - secondary_port: int): +def config_sync( + secondary_address: str, + secondary_key: str, + sections: List[list[str]], + mode: str, + secondary_port: int, + exclusions: List[list[str]], +): """Retrieve a config section from primary router in JSON format and send it to secondary router """ + # pylint: disable=too-many-arguments + if not any(map(is_section_revised, sections)): return @@ -153,7 +187,7 @@ def config_sync(secondary_address: str, ) # Sync sections ("nat", "firewall", etc) - mask_dict, config_dict = retrieve_config(sections) + mask_dict, config_dict = retrieve_config(sections, exclusions) logger.debug( f"Retrieved config for sections '{sections}': {config_dict}") @@ -171,7 +205,19 @@ if __name__ == '__main__': # Read configuration from file if not os.path.exists(CONFIG_FILE): logger.error(f"Post-commit: No config file '{CONFIG_FILE}' exists") - exit(0) + sys.exit() + + try: + with open(config_sync_exclusion_list) as f: + exclude_list = json.load(f) + except FileNotFoundError: + logger.error(f"Exclusion list '{config_sync_exclusion_list}' not found") + sys.exit() + except (json.JSONDecodeError, OSError) as e: + logger.error( + f"Failed to load config-sync exclusion list '{config_sync_exclusion_list}': {e}" + ) + sys.exit() with open(CONFIG_FILE, 'r') as f: config_data = f.read() @@ -188,7 +234,7 @@ if __name__ == '__main__': if not all([mode, secondary_address, secondary_key, sections]): logger.error("Missing required configuration data for config synchronization.") - exit(0) + sys.exit() # Generate list_sections of sections/subsections # [ @@ -202,4 +248,11 @@ if __name__ == '__main__': else: list_sections.append([section]) - config_sync(secondary_address, secondary_key, list_sections, mode, secondary_port) + config_sync( + secondary_address, + secondary_key, + list_sections, + mode, + secondary_port, + exclude_list, + ) diff --git a/src/helpers/write-config-file-value.py b/src/helpers/write-config-file-value.py new file mode 100644 index 000000000..f3993223c --- /dev/null +++ b/src/helpers/write-config-file-value.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# 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 argparse import ArgumentParser +from shlex import split as shlex_split + +from vyos.utils.config import write_saved_value + +def _split_quoted(s: str) -> list[str]: + parts = shlex_split(s) + if not parts: + raise ValueError('empty string') + return parts + +if __name__ == '__main__': + parser = ArgumentParser() + parser.add_argument( + '--path', + required=True, + help='Quoted CLI path, e.g. "system console device ttyS1 speed"', + ) + parser.add_argument( + '--value', + required=False, + help='Value for the node, e.g. "9600". If omitted, creates a valueless node.', + ) + parser.add_argument( + '--config-file', + required=True, + help=f'Path to saved config.boot', + ) + args = parser.parse_args() + + path = _split_quoted(args.path) + write_saved_value(path, value=args.value, config_path=args.config_file) diff --git a/src/init/vyconfd.sh b/src/init/vyconfd.sh new file mode 100755 index 000000000..670651f57 --- /dev/null +++ b/src/init/vyconfd.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# +# Check if state file exists to determine if restart +if [ -f /var/run/vyconfd.state ]; then + echo "Restarting vyconfd from active config" + /usr/libexec/vyos/vyconf/vyconfd --log-file /var/run/log/vyconfd.log --reload-active-config --legacy-config-path +else + echo "Starting vyconfd from saved config" + touch /var/run/vyconfd.state + /usr/libexec/vyos/vyconf/vyconfd --log-file /var/run/log/vyconfd.log --legacy-config-path +fi diff --git a/src/init/vyos-router b/src/init/vyos-router index ab3cc42cb..982126637 100755 --- a/src/init/vyos-router +++ b/src/init/vyos-router @@ -1,5 +1,5 @@ #!/bin/bash -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -67,45 +67,65 @@ disabled () { grep -q -w no-vyos-$1 /proc/cmdline } +motd_helper() { + MOTD_DIR="/run/motd.d" + MOTD_FILE="${MOTD_DIR}/99-vyos-update-failed" + + if [[ ! -d ${MOTD_DIR} ]]; then + mkdir -p ${MOTD_DIR} + fi + + echo "" > ${MOTD_FILE} + echo "WARNING: Image update to \"$1\" failed." >> ${MOTD_FILE} + echo "Please check the logs:" >> ${MOTD_FILE} + echo "/usr/lib/live/mount/persistence/boot/$1/rw/var/log" >> ${MOTD_FILE} + echo "Message is cleared on next reboot!" >> ${MOTD_FILE} + echo "" >> ${MOTD_FILE} +} + # Load encrypted config volume mount_encrypted_config() { persist_path=$(/opt/vyatta/sbin/vyos-persistpath) if [ $? == 0 ]; then if [ -e $persist_path/boot ]; then image_name=$(cat /proc/cmdline | sed -e s+^.*vyos-union=/boot/++ | sed -e 's/ .*$//') - if [ -z "$image_name" ]; then - return + return 0 fi if [ ! -f $persist_path/luks/$image_name ]; then - return + return 0 fi - vyos_tpm_key=$(python3 -c 'from vyos.tpm import read_tpm_key; print(read_tpm_key().decode())' 2>/dev/null) + if [ ! -e /sys/class/tpm/tpm0 ]; then + echo "WARN: TPM device not found, encrypted config volume will not be automatically mounted" + echo "Use 'encryption load' to load volume manually with a key" + echo "or 'encryption disable' to decrypt volume with a key" + return 1 + fi + vyos_tpm_key=$(python3 -c 'from vyos.tpm import read_tpm_key; print(read_tpm_key().decode())' 2>/dev/null) if [ $? -ne 0 ]; then echo "ERROR: Failed to fetch encryption key from TPM. Encrypted config volume has not been mounted" echo "Use 'encryption load' to load volume with recovery key" echo "or 'encryption disable' to decrypt volume with recovery key" - return + return 1 fi echo $vyos_tpm_key | tr -d '\r\n' | cryptsetup open $persist_path/luks/$image_name vyos_config --key-file=- - if [ $? -ne 0 ]; then echo "ERROR: Failed to decrypt config volume. Encrypted config volume has not been mounted" echo "Use 'encryption load' to load volume with recovery key" echo "or 'encryption disable' to decrypt volume with recovery key" - return + return 1 fi - mount /dev/mapper/vyos_config /config mount /dev/mapper/vyos_config $vyatta_sysconfdir/config echo "Mounted encrypted config volume" fi fi + return 0 } unmount_encrypted_config() { @@ -122,7 +142,6 @@ unmount_encrypted_config() { return fi - umount /config umount $vyatta_sysconfdir/config cryptsetup close vyos_config @@ -145,6 +164,8 @@ init_bootfile () { else $vyos_libexec_dir/add-system-version.py > $BOOTFILE fi + fi + if [ -f $BOOTFILE ] ; then chgrp ${GROUP} $BOOTFILE chmod 660 $BOOTFILE fi @@ -160,20 +181,30 @@ migrate_bootfile () if [ -x $vyos_libexec_dir/run-config-migration.py ]; then log_progress_msg migrate sg ${GROUP} -c "$vyos_libexec_dir/run-config-migration.py $BOOTFILE" + STATUS=$? + if [[ "$STATUS" != "0" ]]; then + return 1 + fi # update vyconf copy after migration if [ -d $VYCONF_CONFIG_DIR ] ; then cp -f $BOOTFILE $VYCONF_CONFIG_DIR/config.boot fi fi + return 0 } # configure system-specific settings -system_config () +system_activate () { if [ -x $vyos_libexec_dir/run-config-activation.py ]; then - log_progress_msg system + log_progress_msg activate sg ${GROUP} -c "$vyos_libexec_dir/run-config-activation.py $BOOTFILE" + STATUS=$? + if [[ "$STATUS" != "0" ]]; then + return 1 + fi fi + return 0 } # load the initial config @@ -187,8 +218,13 @@ load_bootfile () fi if [ -x $vyos_libexec_dir/vyos-boot-config-loader.py ]; then sg ${GROUP} -c "$vyos_libexec_dir/vyos-boot-config-loader.py $BOOTFILE" + STATUS=$? + if [[ "$STATUS" != "0" ]]; then + return 1 + fi fi ) + return 0 } # restore if missing pre-config script @@ -283,16 +319,44 @@ bind_mount_boot () fi } +# this is called before migration and bind_mount_slash_config to check for +# upgrade data installed by legacy image update tools +copy_legacy_config_path () +{ + if [ -d /config ] ; then + if [ -f /config/.upgraded ] ; then + rm -f /config/.upgraded + cp -a /config /opt/vyatta/etc + chgrp -R vyattacfg /opt/vyatta/etc/config + chmod -R 775 /opt/vyatta/etc/config + chmod 660 /opt/vyatta/etc/config/config.boot + touch /opt/vyatta/etc/config/.vyatta_config + rm -rf /config + fi + fi +} + +bind_mount_slash_config () +{ + if [ -d /opt/vyatta/etc/config ] + then + if [ ! -d /config ] ; then + mkdir /config + fi + mount --bind /opt/vyatta/etc/config /config + fi +} + clear_or_override_config_files () { for conf in snmp/snmpd.conf snmp/snmptrapd.conf snmp/snmp.conf \ keepalived/keepalived.conf cron.d/vyos-crontab \ ipvsadm.rules default/ipvsadm resolv.conf do - if [ -s /etc/$conf ] ; then - empty /etc/$conf - chmod 0644 /etc/$conf - fi + if [ -s /etc/$conf ] ; then + empty /etc/$conf + chmod 0644 /etc/$conf + fi done } @@ -396,7 +460,10 @@ gen_duid () UUID=$(cat ${UUID_FILE} | tr -d -) fi if [ -z ${UUID} ]; then - UUID=$(uuidgen --sha1 --namespace @dns --name $(cat ${UUID_FILE_ALT}) | tr -d -) + file_alt="$(cat ${UUID_FILE_ALT})" + if [ -n "${file_alt}" ]; then + UUID=$(uuidgen --sha1 --namespace @dns --name ${file_alt} | tr -d -) + fi fi # Add DUID type4 (UUID) information DUID_TYPE="0004" @@ -417,6 +484,8 @@ gen_duid () start () { + log_success_msg "Starting VyOS router" + # reset and clean config files security_reset || log_failure_msg "security reset failed" @@ -426,7 +495,7 @@ start () chmod 775 /var/run/vyatta /var/log/vyatta log_daemon_msg "Waiting for NICs to settle down" - # On boot time udev migth take a long time to reorder nic's, this will ensure that + # On boot time udev might take a long time to reorder nic's, this will ensure that # all udev activity is completed and all nics presented at boot-time will have their # final name before continuing with vyos-router initialization. SECONDS=0 @@ -459,6 +528,26 @@ start () nfct helper add tns inet6 tcp nft --file /usr/share/vyos/vyos-firewall-init.conf || log_failure_msg "could not initiate firewall rules" + # Create needed kea directories + mkdir -p /var/run/kea /run/lock/kea + chmod 750 /var/run/kea /run/lock/kea + chown _kea:_kea /var/run/kea /run/lock/kea + if [ -d /opt/vyatta/etc/config ]; then + if [ ! -d /opt/vyatta/etc/config/dhcp ]; then + mkdir /opt/vyatta/etc/config/dhcp + chmod 750 /opt/vyatta/etc/config/dhcp + chown _kea:vyattacfg /opt/vyatta/etc/config/dhcp + fi + fi + + # Ensure rsyslog is the default syslog daemon + SYSTEMD_SYSLOG="/etc/systemd/system/syslog.service" + SYSTEMD_RSYSLOG="/lib/systemd/system/rsyslog.service" + if [ ! -L ${SYSTEMD_SYSLOG} ] || [ "$(readlink -f ${SYSTEMD_SYSLOG})" != "${SYSTEMD_RSYSLOG}" ]; then + ln -sf ${SYSTEMD_RSYSLOG} ${SYSTEMD_SYSLOG} + systemctl daemon-reload + fi + # As VyOS does not execute commands that are not present in the CLI we call # the script by hand to have a single source for the login banner and MOTD ${vyos_conf_scripts_dir}/system_syslog.py || log_failure_msg "could not reset syslog" @@ -474,7 +563,7 @@ start () # enable some debugging before loading the configuration if grep -q vyos-debug /proc/cmdline; then - log_action_begin_msg "Enable runtime debugging options" + log_success_msg "Enable runtime debugging options" FRR_DEBUG=$(python3 -c "from vyos.defaults import frr_debug_enable; print(frr_debug_enable)") touch $FRR_DEBUG touch /tmp/vyos.container.debug @@ -501,7 +590,7 @@ start () && chgrp ${GROUP} ${vyatta_configdir} log_action_end_msg $? - mount_encrypted_config + mount_encrypted_config || overall_status=1 # T5239: early read of system hostname as this value is read-only once during # FRR initialisation @@ -513,12 +602,13 @@ start () # This is a safety net! systemctl start frr.service + disabled copy_legacy_config_path || copy_legacy_config_path + disabled bootfile || init_bootfile cleanup_post_commit_hooks - log_daemon_msg "Starting VyOS router" - disabled migrate || migrate_bootfile + disabled migrate || migrate_bootfile || overall_status=1 restore_if_missing_preconfig_script @@ -526,27 +616,66 @@ start () run_postupgrade_script - update_interface_config - - disabled system_config || system_config + update_interface_config || overall_status=1 - systemctl start vyconfd.service + disabled system_activate || system_activate || overall_status=1 for s in ${subinit[@]} ; do - if ! disabled $s; then - log_progress_msg $s - if ! ${vyatta_sbindir}/${s}.init start - then log_failure_msg - exit 1 + if ! disabled $s; then + log_progress_msg $s + if ! ${vyatta_sbindir}/${s}.init start + then log_failure_msg + exit 1 + fi fi - fi done bind_mount_boot - disabled configure || load_bootfile + disabled bind_mount_slash_config || bind_mount_slash_config + + disabled configure || load_bootfile || overall_status=1 log_end_msg $? + FIRST_BOOT_FILE="/config/first_boot" + UPDATE_FAILED_BOOT_FILE="/config/update_failed" + AUTOMATIC_REBOOT_TMO=$(${vyos_libexec_dir}/read-saved-value.py --path "system option reboot-on-upgrade-failure") + # Image upgrade failed - get previous image name, re-set it as default image + # and perform an automatic reboot. Automatic reboot timeout can be set via CLI + if [[ -n $AUTOMATIC_REBOOT_TMO ]] && [[ -f ${FIRST_BOOT_FILE} ]] && [[ ${overall_status} -ne 0 ]]; then + previous_image=$(jq -r '.previous_image' ${FIRST_BOOT_FILE}) + + # If the image update failed, we need to inform the image we will revert + # to about this + running_image=$(${vyos_op_scripts_dir}/image_info.py show_images_current --raw | jq -r '.image_running') + echo "{\"failed_image_update\": \"${running_image}\"}" \ + > /usr/lib/live/mount/persistence/boot/${previous_image}/rw/${UPDATE_FAILED_BOOT_FILE} + + ${vyos_op_scripts_dir}/image_manager.py --action set --image-name "${previous_image}" >/dev/null 2>&1 + motd_helper "${running_image}" + + log_daemon_msg "Booting failed, reverting to previous image" + log_progress_msg ${previous_image} + log_end_msg 0 + log_daemon_msg "Automatic reboot in ${AUTOMATIC_REBOOT_TMO} minutes" + sync ; shutdown --reboot --no-wall ${AUTOMATIC_REBOOT_TMO} >/dev/null 2>&1 + log_progress_msg "Use \"reboot cancel\" to cancel" + log_end_msg 0 + fi + # After image upgrade failure and once booted into the previous working + # image, inform the user via MOTD about the failure + if [[ -n $AUTOMATIC_REBOOT_TMO ]] && [[ -f ${UPDATE_FAILED_BOOT_FILE} ]] ; then + failed_image_update=$(jq -r '.failed_image_update' ${UPDATE_FAILED_BOOT_FILE}) + motd_helper "${failed_image_update}" + fi + # Clear marker files used by automatic reboot on image upgrade mechanism + if [[ -f ${FIRST_BOOT_FILE} ]]; then + rm -f ${FIRST_BOOT_FILE} + fi + if [[ -f ${UPDATE_FAILED_BOOT_FILE} ]] ; then + rm -f ${UPDATE_FAILED_BOOT_FILE} + fi + telinit q chmod g-w,o-w / @@ -557,12 +686,20 @@ start () if [[ ! -z "$tmp" ]]; then vtysh -c "rpki start" fi + + # Start netplug daemon + systemctl start vyos-netlinkd.service } stop() { local -i status=0 log_daemon_msg "Stopping VyOS router" + + # As vyos-netlinkd consumes a configuration, we need to stop it prior + # unmounting /config + systemctl stop vyos-netlinkd.service + for ((i=${#sub_inits[@]} - 1; i >= 0; i--)) ; do s=${subinit[$i]} log_progress_msg $s @@ -575,7 +712,6 @@ stop() log_action_end_msg $? systemctl stop vyconfd.service - systemctl stop frr.service unmount_encrypted_config diff --git a/src/migration-scripts/bgp/0-to-1 b/src/migration-scripts/bgp/0-to-1 index a2f3343d8..a072286ea 100644 --- a/src/migration-scripts/bgp/0-to-1 +++ b/src/migration-scripts/bgp/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/bgp/1-to-2 b/src/migration-scripts/bgp/1-to-2 index c0fc3b05a..64732ac72 100644 --- a/src/migration-scripts/bgp/1-to-2 +++ b/src/migration-scripts/bgp/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/bgp/2-to-3 b/src/migration-scripts/bgp/2-to-3 index d8bc34db6..9d3ab3ecf 100644 --- a/src/migration-scripts/bgp/2-to-3 +++ b/src/migration-scripts/bgp/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/bgp/3-to-4 b/src/migration-scripts/bgp/3-to-4 index 842aef0ce..9e45bd4d6 100644 --- a/src/migration-scripts/bgp/3-to-4 +++ b/src/migration-scripts/bgp/3-to-4 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/bgp/4-to-5 b/src/migration-scripts/bgp/4-to-5 index d779eb11e..42b2fd0b6 100644 --- a/src/migration-scripts/bgp/4-to-5 +++ b/src/migration-scripts/bgp/4-to-5 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/bgp/5-to-6 b/src/migration-scripts/bgp/5-to-6 index e6fea6574..3443e8000 100644 --- a/src/migration-scripts/bgp/5-to-6 +++ b/src/migration-scripts/bgp/5-to-6 @@ -1,4 +1,4 @@ -# Copyright 2025 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -25,7 +25,7 @@ def migrate(config: ConfigTree) -> None: return for address_family in ['ipv4-unicast', 'ipv6-unicast']: - # there is no non-main routing table beeing redistributed under this addres family + # there is no non-main routing table being redistributed under this address family # bail out early and continue with next AFI table_path = bgp_base + ['address-family', address_family, 'redistribute', 'table'] if not config.exists(table_path): diff --git a/src/migration-scripts/cluster/1-to-2 b/src/migration-scripts/cluster/1-to-2 index 5ca4531ea..5e5136a64 100644 --- a/src/migration-scripts/cluster/1-to-2 +++ b/src/migration-scripts/cluster/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/config-management/0-to-1 b/src/migration-scripts/config-management/0-to-1 index 44c685630..9c1b96a4b 100644 --- a/src/migration-scripts/config-management/0-to-1 +++ b/src/migration-scripts/config-management/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/conntrack-sync/1-to-2 b/src/migration-scripts/conntrack-sync/1-to-2 index 3e10e98c3..793bf01d8 100644 --- a/src/migration-scripts/conntrack-sync/1-to-2 +++ b/src/migration-scripts/conntrack-sync/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/conntrack/1-to-2 b/src/migration-scripts/conntrack/1-to-2 index 0a4fb3de9..5b126c8ab 100644 --- a/src/migration-scripts/conntrack/1-to-2 +++ b/src/migration-scripts/conntrack/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/conntrack/2-to-3 b/src/migration-scripts/conntrack/2-to-3 index 5ad4e6350..e74fe3105 100644 --- a/src/migration-scripts/conntrack/2-to-3 +++ b/src/migration-scripts/conntrack/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/conntrack/3-to-4 b/src/migration-scripts/conntrack/3-to-4 index 679a260d5..709f97310 100644 --- a/src/migration-scripts/conntrack/3-to-4 +++ b/src/migration-scripts/conntrack/3-to-4 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/conntrack/4-to-5 b/src/migration-scripts/conntrack/4-to-5 index 775fe7480..b5d2a79bc 100644 --- a/src/migration-scripts/conntrack/4-to-5 +++ b/src/migration-scripts/conntrack/4-to-5 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/conntrack/5-to-6 b/src/migration-scripts/conntrack/5-to-6 new file mode 100644 index 000000000..330849243 --- /dev/null +++ b/src/migration-scripts/conntrack/5-to-6 @@ -0,0 +1,30 @@ +# Copyright 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/>. + +# T7202: fix lower limit of supported conntrack hash-size to match Kernel +# lower limit. + +from vyos.configtree import ConfigTree + +base = ['system', 'conntrack'] +def migrate(config: ConfigTree) -> None: + if not config.exists(base): + # Nothing to do + return + + if config.exists(base + ['hash-size']): + tmp = config.return_value(base + ['hash-size']) + if int(tmp) < 1024: + config.set(base + ['hash-size'], value=1024) diff --git a/src/migration-scripts/container/0-to-1 b/src/migration-scripts/container/0-to-1 index 99102a5e6..6f11bdbac 100644 --- a/src/migration-scripts/container/0-to-1 +++ b/src/migration-scripts/container/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -13,7 +13,7 @@ # 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/>. -# T4870: change underlaying container filesystem from vfs to overlay +# T4870: change underlying container filesystem from vfs to overlay import os import shutil @@ -35,7 +35,7 @@ def migrate(config: ConfigTree) -> None: image_name = config.return_value(base + [container, 'image']) call(f'sudo podman image save --quiet --output /root/{container}.tar --format oci-archive {image_name}') - # No need to adjust the strage driver online (this is only used for testing and + # No need to adjust the storage driver online (this is only used for testing and # debugging on a live system) - it is already overlay2 when the migration script # is run during system update. But the specified driver in the image is actually # overwritten by the still present VFS filesystem on disk. Thus podman still diff --git a/src/migration-scripts/container/1-to-2 b/src/migration-scripts/container/1-to-2 index c12dd8ebb..8664cfac9 100644 --- a/src/migration-scripts/container/1-to-2 +++ b/src/migration-scripts/container/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/container/2-to-3 b/src/migration-scripts/container/2-to-3 new file mode 100644 index 000000000..0b43ecdb2 --- /dev/null +++ b/src/migration-scripts/container/2-to-3 @@ -0,0 +1,31 @@ +# Copyright 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/>. + +# T7473: container: allow log-driver to be set per container + +from vyos.configtree import ConfigTree + +def migrate(config: ConfigTree) -> None: + log_base = ['container', 'log-driver'] + container_base = ['container', 'name'] + + if not config.exists(log_base): + return + else: + log_driver = config.return_value(log_base) + for container in config.list_nodes(container_base): + # Set the log-driver for each container + config.set(container_base + [container, 'log-driver'], value=log_driver) + config.delete(log_base) diff --git a/src/migration-scripts/dhcp-relay/1-to-2 b/src/migration-scripts/dhcp-relay/1-to-2 index 54cd8d6c0..50ac29df1 100644 --- a/src/migration-scripts/dhcp-relay/1-to-2 +++ b/src/migration-scripts/dhcp-relay/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/dhcp-server/10-to-11 b/src/migration-scripts/dhcp-server/10-to-11 index f54a4c7b7..82f303868 100644 --- a/src/migration-scripts/dhcp-server/10-to-11 +++ b/src/migration-scripts/dhcp-server/10-to-11 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/dhcp-server/4-to-5 b/src/migration-scripts/dhcp-server/4-to-5 index a655515dc..9b121d23f 100644 --- a/src/migration-scripts/dhcp-server/4-to-5 +++ b/src/migration-scripts/dhcp-server/4-to-5 @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/dhcp-server/5-to-6 b/src/migration-scripts/dhcp-server/5-to-6 index 9404cd038..fd2ed9e34 100644 --- a/src/migration-scripts/dhcp-server/5-to-6 +++ b/src/migration-scripts/dhcp-server/5-to-6 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/dhcp-server/6-to-7 b/src/migration-scripts/dhcp-server/6-to-7 index 4e6583a31..fbb8c06f9 100644 --- a/src/migration-scripts/dhcp-server/6-to-7 +++ b/src/migration-scripts/dhcp-server/6-to-7 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/dhcp-server/7-to-8 b/src/migration-scripts/dhcp-server/7-to-8 index 7fcb62e86..fb939e020 100644 --- a/src/migration-scripts/dhcp-server/7-to-8 +++ b/src/migration-scripts/dhcp-server/7-to-8 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -41,9 +41,6 @@ def migrate(config: ConfigTree) -> None: for network in config.list_nodes(base + ['shared-network-name']): base_network = base + ['shared-network-name', network] - if config.exists(base_network + ['ping-check']): - config.delete(base_network + ['ping-check']) - if config.exists(base_network + ['shared-network-parameters']): config.delete(base_network +['shared-network-parameters']) @@ -57,9 +54,6 @@ def migrate(config: ConfigTree) -> None: if config.exists(base_subnet + ['enable-failover']): config.delete(base_subnet + ['enable-failover']) - if config.exists(base_subnet + ['ping-check']): - config.delete(base_subnet + ['ping-check']) - if config.exists(base_subnet + ['subnet-parameters']): config.delete(base_subnet + ['subnet-parameters']) diff --git a/src/migration-scripts/dhcp-server/8-to-9 b/src/migration-scripts/dhcp-server/8-to-9 index 5843e9fda..34c80cb22 100644 --- a/src/migration-scripts/dhcp-server/8-to-9 +++ b/src/migration-scripts/dhcp-server/8-to-9 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/dhcp-server/9-to-10 b/src/migration-scripts/dhcp-server/9-to-10 index eda97550d..0d3d53d5e 100644 --- a/src/migration-scripts/dhcp-server/9-to-10 +++ b/src/migration-scripts/dhcp-server/9-to-10 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/dhcpv6-server/0-to-1 b/src/migration-scripts/dhcpv6-server/0-to-1 index fd9b2d739..e46063a26 100644 --- a/src/migration-scripts/dhcpv6-server/0-to-1 +++ b/src/migration-scripts/dhcpv6-server/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 202-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/dhcpv6-server/1-to-2 b/src/migration-scripts/dhcpv6-server/1-to-2 index ad307495c..cd7b06673 100644 --- a/src/migration-scripts/dhcpv6-server/1-to-2 +++ b/src/migration-scripts/dhcpv6-server/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/dhcpv6-server/2-to-3 b/src/migration-scripts/dhcpv6-server/2-to-3 index b44798d18..728720ecb 100644 --- a/src/migration-scripts/dhcpv6-server/2-to-3 +++ b/src/migration-scripts/dhcpv6-server/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/dhcpv6-server/3-to-4 b/src/migration-scripts/dhcpv6-server/3-to-4 index e38e36505..db10fcb6c 100644 --- a/src/migration-scripts/dhcpv6-server/3-to-4 +++ b/src/migration-scripts/dhcpv6-server/3-to-4 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/dhcpv6-server/4-to-5 b/src/migration-scripts/dhcpv6-server/4-to-5 index ad18e1a84..fd8c63644 100644 --- a/src/migration-scripts/dhcpv6-server/4-to-5 +++ b/src/migration-scripts/dhcpv6-server/4-to-5 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/dhcpv6-server/5-to-6 b/src/migration-scripts/dhcpv6-server/5-to-6 index cad0a3538..e12020597 100644 --- a/src/migration-scripts/dhcpv6-server/5-to-6 +++ b/src/migration-scripts/dhcpv6-server/5-to-6 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/dns-dynamic/0-to-1 b/src/migration-scripts/dns-dynamic/0-to-1 index 6a91b36af..a75fedcad 100644 --- a/src/migration-scripts/dns-dynamic/0-to-1 +++ b/src/migration-scripts/dns-dynamic/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/dns-dynamic/1-to-2 b/src/migration-scripts/dns-dynamic/1-to-2 index 7f4938147..424d4fa85 100644 --- a/src/migration-scripts/dns-dynamic/1-to-2 +++ b/src/migration-scripts/dns-dynamic/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -51,8 +51,10 @@ def migrate(config: ConfigTree) -> None: # Migrate "service dns dynamic address <interface> service <service> protocol dnsexit" # to "service dns dynamic address <interface> service <service> protocol dnsexit2" for address in config.list_nodes(address_path): - for svc_cfg in config.list_nodes(address_path + [address, 'service']): - if config.exists(address_path + [address, 'service', svc_cfg, 'protocol']): - protocol = config.return_value(address_path + [address, 'service', svc_cfg, 'protocol']) - if protocol == 'dnsexit': - config.set(address_path + [address, 'service', svc_cfg, 'protocol'], 'dnsexit2') + service_path = address_path + [address, 'service'] + if config.exists(service_path): + for svc_cfg in config.list_nodes(service_path): + if config.exists(service_path + [svc_cfg, 'protocol']): + protocol = config.return_value(service_path + [svc_cfg, 'protocol']) + if protocol == 'dnsexit': + config.set(service_path + [svc_cfg, 'protocol'], 'dnsexit2') diff --git a/src/migration-scripts/dns-dynamic/2-to-3 b/src/migration-scripts/dns-dynamic/2-to-3 index 9aafc41a4..9e79078fa 100644 --- a/src/migration-scripts/dns-dynamic/2-to-3 +++ b/src/migration-scripts/dns-dynamic/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -33,7 +33,7 @@ def normalize_name(name): the old name format. """ # Normalize unicode characters to ASCII (NFKD) - # Replace all separators with hypens, strip leading and trailing hyphens + # Replace all separators with hyphens, strip leading and trailing hyphens name = normalize('NFKD', name).encode('ascii', 'ignore').decode() name = re.sub(r'(\s|_|\W)+', '-', name).strip('-') diff --git a/src/migration-scripts/dns-dynamic/3-to-4 b/src/migration-scripts/dns-dynamic/3-to-4 index c8e1ffeee..ff640f7ef 100644 --- a/src/migration-scripts/dns-dynamic/3-to-4 +++ b/src/migration-scripts/dns-dynamic/3-to-4 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/dns-forwarding/0-to-1 b/src/migration-scripts/dns-forwarding/0-to-1 index 264ffb40d..fe1669e84 100644 --- a/src/migration-scripts/dns-forwarding/0-to-1 +++ b/src/migration-scripts/dns-forwarding/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/dns-forwarding/1-to-2 b/src/migration-scripts/dns-forwarding/1-to-2 index 15ed1e136..2d90078ec 100644 --- a/src/migration-scripts/dns-forwarding/1-to-2 +++ b/src/migration-scripts/dns-forwarding/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/dns-forwarding/2-to-3 b/src/migration-scripts/dns-forwarding/2-to-3 index 729c1f00a..0fdfd8447 100644 --- a/src/migration-scripts/dns-forwarding/2-to-3 +++ b/src/migration-scripts/dns-forwarding/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/dns-forwarding/3-to-4 b/src/migration-scripts/dns-forwarding/3-to-4 index b02c0b7ca..f22cfea81 100644 --- a/src/migration-scripts/dns-forwarding/3-to-4 +++ b/src/migration-scripts/dns-forwarding/3-to-4 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/firewall/10-to-11 b/src/migration-scripts/firewall/10-to-11 index 70a170940..f608c200d 100644 --- a/src/migration-scripts/firewall/10-to-11 +++ b/src/migration-scripts/firewall/10-to-11 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -36,10 +36,57 @@ # set firewall [ipv4 | ipv6] input filter rule <5,10,15,...> action jump # set firewall [ipv4 | ipv6] input filter rule <5,10,15,...> jump-target <name> +# T8281: Normalize firewall rule network prefixes for source and destination address +# +# Fixes invalid/non-canonical prefixes like: +# 10.10.10.1/30 -> 10.10.10.0/30 +# 2001:db8::1/64 -> 2001:db8::/64 +# +# Only "source address" and "destination address" are rewritten. + +from ipaddress import ip_network from vyos.configtree import ConfigTree base = ['firewall'] + +def normalize_address(config: ConfigTree, path: list[str]): + if not config.exists(path): + return + + def normalize(value: str) -> str | None: + if '/' not in value: + return None + + is_except = value.startswith('!') + if is_except: + value = value.lstrip('!') + + try: + new_value = str(ip_network(value, strict=False)) + except ValueError: + return None + + return f'!{new_value}' if is_except else new_value + + value = config.return_value(path) + if value: + normalized = normalize(value) + if normalized and normalized != value: + config.set(path, value=normalized) + + +def migrate_ruleset(config: ConfigTree, ruleset_base: list[str]): + if not config.exists(ruleset_base + ['rule']): + return + + for rule_id in config.list_nodes(ruleset_base + ['rule']): + rule_base = ruleset_base + ['rule', rule_id] + + for direction in ['source', 'destination']: + normalize_address(config, rule_base + [direction, 'address']) + + def migrate(config: ConfigTree) -> None: if not config.exists(base): # Nothing to do @@ -185,3 +232,25 @@ def migrate(config: ConfigTree) -> None: inp_ipv6_rule = inp_ipv6_rule + 5 config.delete(base + ['interface']) + + ### Normalize/fix firewall rule network prefixes for source and destination address (T8281) + for family in ['ipv4', 'ipv6', 'bridge']: + # 1) Named rulesets: + # set firewall ipv4|ipv6|bridge name <ruleset> rule <id> ... + ruleset_base = base + [family, 'name'] + if config.exists(ruleset_base): + for ruleset in config.list_nodes(ruleset_base): + migrate_ruleset(config, ruleset_base + [ruleset]) + + # 2) Hook-based rulesets: + # set firewall ipv4|ipv6|bridge input|output|forward filter|raw rule <id> ... + # set firewall ipv4|ipv6 prerouting raw rule <id> ... + hook_rulesets = { + 'input': ['filter', 'raw'], + 'output': ['filter', 'raw'], + 'forward': ['filter', 'raw'], + 'prerouting': ['raw'], + } + for hook, rulesets in hook_rulesets.items(): + for ruleset in rulesets: + migrate_ruleset(config, base + [family, hook, ruleset]) diff --git a/src/migration-scripts/firewall/11-to-12 b/src/migration-scripts/firewall/11-to-12 index 80a74cca9..5e9e9de32 100644 --- a/src/migration-scripts/firewall/11-to-12 +++ b/src/migration-scripts/firewall/11-to-12 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -13,7 +13,7 @@ # 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/>. -# T5681: Firewall re-writing. Simplify cli when mathcing interface +# T5681: Firewall re-writing. Simplify cli when matching interface # From # set firewall ... rule <rule> [inbound-interface | outboubd-interface] interface-name <iface> # set firewall ... rule <rule> [inbound-interface | outboubd-interface] interface-group <iface_group> diff --git a/src/migration-scripts/firewall/12-to-13 b/src/migration-scripts/firewall/12-to-13 index d7b801cd3..58c4e864a 100644 --- a/src/migration-scripts/firewall/12-to-13 +++ b/src/migration-scripts/firewall/12-to-13 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/firewall/13-to-14 b/src/migration-scripts/firewall/13-to-14 index 723b0aea2..dbd585584 100644 --- a/src/migration-scripts/firewall/13-to-14 +++ b/src/migration-scripts/firewall/13-to-14 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/firewall/14-to-15 b/src/migration-scripts/firewall/14-to-15 index e4a2aaee4..692b4d949 100644 --- a/src/migration-scripts/firewall/14-to-15 +++ b/src/migration-scripts/firewall/14-to-15 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -13,7 +13,7 @@ # 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/>. -# T5535: Migrate <set system ip disable-directed-broadcast> to <set firewall global-options directed-broadcas [enable|disable] +# T5535: Migrate <set system ip disable-directed-broadcast> to <set firewall global-options directed-broadcast [enable|disable] from vyos.configtree import ConfigTree diff --git a/src/migration-scripts/firewall/15-to-16 b/src/migration-scripts/firewall/15-to-16 index 8e28bba6f..1eac943ba 100644 --- a/src/migration-scripts/firewall/15-to-16 +++ b/src/migration-scripts/firewall/15-to-16 @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/migration-scripts/firewall/16-to-17 b/src/migration-scripts/firewall/16-to-17 index ad0706f04..e0582aeab 100644 --- a/src/migration-scripts/firewall/16-to-17 +++ b/src/migration-scripts/firewall/16-to-17 @@ -1,4 +1,4 @@ -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/migration-scripts/firewall/17-to-18 b/src/migration-scripts/firewall/17-to-18 index 34ce6aa07..6cde20870 100755 --- a/src/migration-scripts/firewall/17-to-18 +++ b/src/migration-scripts/firewall/17-to-18 @@ -1,4 +1,4 @@ -# Copyright (C) 2024-2025 VyOS maintainers and contributors +# Copyright 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 diff --git a/src/migration-scripts/firewall/18-to-19 b/src/migration-scripts/firewall/18-to-19 new file mode 100644 index 000000000..bcc4a482b --- /dev/null +++ b/src/migration-scripts/firewall/18-to-19 @@ -0,0 +1,35 @@ +# Copyright 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/>. + +# From +# set firewall global-options apply-to-bridged-traffic invalid-connections +# To +# set firewall global-options apply-to-bridged-traffic accept-invalid ethernet-type <ethertype> + +from vyos.configtree import ConfigTree + +base = ['firewall', 'global-options', 'apply-to-bridged-traffic'] + +def migrate(config: ConfigTree) -> None: + if not config.exists(base + ['invalid-connections']): + # Nothing to do + return + + ether_types = ['dhcp', 'arp', 'pppoe-discovery', 'pppoe', '802.1q', '802.1ad', 'wol'] + + for ether_type in ether_types: + config.set(base + ['accept-invalid', 'ethernet-type'], value=ether_type, replace=False) + + config.delete(base + ['invalid-connections']) diff --git a/src/migration-scripts/firewall/19-to-20 b/src/migration-scripts/firewall/19-to-20 new file mode 100644 index 000000000..c0ef5e127 --- /dev/null +++ b/src/migration-scripts/firewall/19-to-20 @@ -0,0 +1,100 @@ +# Copyright 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/>. + +# T7366: Firewall rules allow empty nodes +# When configuring the firewall, many nodes are accepted with empty values, +# which are not parsed into rules. + +# Very few of these have subsequent error handling. Some should be obvious +# to the user that a value is required, like 'inbound-interface' (though +# an error should still be thrown if they're configured without children). + +# But some could be misunderstood and lead to an outage or wide open firewall. +# For instance, let's say someone wanted to block all icmp. They may incorrectly +# configure: + +# set firewall ipv4 input filter rule 10 action drop +# set firewall ipv4 input filter rule 10 icmp + +# And this would create this rule in nftables, dropping all traffic in subsequent rules: + +# counter packets 0 bytes 0 drop comment "ipv4-INP-filter-10" + +# They could also unintentionally allow all traffic by attempting to only allow icmp in a rule. + +import json + +from vyos.configtree import ConfigTree +from vyos.utils.dict import dict_search_args + +firewall_base = ['firewall'] + +def migrate(config: ConfigTree) -> None: + if not config.exists(firewall_base): + # Nothing to do + return + + firewall_dict = json.loads(config.to_json()).get('firewall') + + is_empty_list = [] + is_empty_list.append([ + ['add-address-to-group'], + ['connection-status'], + ['destination', 'group'], + ['destination', 'geoip'], + ['destination'], + ['fragment'], + ['gre', 'flags'], + ['gre'], + ['hop-limit'], + ['icmp'], + ['icmpv6'], + ['inbound-interface'], + ['ipsec'], + ['limit'], + ['log-options'], + ['outbound-interface'], + ['set'], + ['source', 'group'], + ['source', 'geoip'], + ['source'], + ['tcp', 'flags'], + ['tcp'], + ['time'], + ['ttl'], + ['vlan'] + ]) + + for family in ['ipv4', 'ipv6', 'bridge']: + if family in firewall_dict: + for chain in ['name','forward','input','output', 'prerouting']: + if chain in firewall_dict[family]: + for priority, priority_conf in firewall_dict[family][chain].items(): + if 'rule' in priority_conf: + for rule_id, rule_conf in priority_conf['rule'].items(): + node_deleted_list = [] + for node in is_empty_list[0]: + if dict_search_args(rule_conf, *node) == {}: + if len(node) == 1: + if node not in node_deleted_list: + config.delete(firewall_base + [family, chain, priority, 'rule', rule_id, node[0]]) + else: + del firewall_dict[family][chain][priority]['rule'][rule_id][node[0]][node[1]] + + if dict_search_args(rule_conf, node[0]) == {}: + config.delete(firewall_base + [family, chain, priority, 'rule', rule_id, node[0]]) + node_deleted_list.append([node[0]]) + else: + config.delete(firewall_base + [family, chain, priority, 'rule', rule_id, node[0], node[1]]) diff --git a/src/migration-scripts/firewall/5-to-6 b/src/migration-scripts/firewall/5-to-6 index d01684787..49348a620 100644 --- a/src/migration-scripts/firewall/5-to-6 +++ b/src/migration-scripts/firewall/5-to-6 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/firewall/6-to-7 b/src/migration-scripts/firewall/6-to-7 index 1afbc780b..db83497cd 100644 --- a/src/migration-scripts/firewall/6-to-7 +++ b/src/migration-scripts/firewall/6-to-7 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -92,6 +92,24 @@ icmpv6_translations = { v4_groups = ["address-group", "network-group", "port-group"] v6_groups = ["ipv6-address-group", "ipv6-network-group", "port-group"] +valid_group_name_pattern = re.compile(r'^[A-Za-z0-9_.-]+$') +invalid_group_name_pattern = re.compile(r'[^A-Za-z0-9_.-]') + +def is_valid_group_name(name: str) -> bool: + return bool(valid_group_name_pattern.match(name)) + +def sanitize_group_name(name: str) -> str: + """Function to sanitize group name by replacing invalid characters""" + return invalid_group_name_pattern.sub('_', name) + +def get_unique_group_name(config: ConfigTree, path: list[str], name: str) -> str: + """Function to generate a unique group name if the proposed name already exists""" + + new_name = sanitize_group_name(name) + while config.exists(path + [new_name]): + new_name = f'{new_name}_' + return new_name + def migrate(config: ConfigTree) -> None: if not config.exists(base): # Nothing to do @@ -108,16 +126,13 @@ def migrate(config: ConfigTree) -> None: if config.exists(name_description): tmp = config.return_value(name_description) config.set(name_description, value=tmp[:max_len_description]) - if '+' in group_name: - replacement_string = "_" + + if not is_valid_group_name(group_name): if group_type in v4_groups and not v4_found: v4_found = True if group_type in v6_groups and not v6_found: v6_found = True - new_group_name = group_name.replace('+', replacement_string) - while config.exists(base + ['group', group_type, new_group_name]): - replacement_string = replacement_string + "_" - new_group_name = group_name.replace('+', replacement_string) + new_group_name = get_unique_group_name(config, base + ['group', group_type], group_name) translated_dict[group_name] = new_group_name config.copy(base + ['group', group_type, group_name], base + ['group', group_type, new_group_name]) config.delete(base + ['group', group_type, group_name]) @@ -185,17 +200,25 @@ def migrate(config: ConfigTree) -> None: if config.exists(base + ['name', name, 'rule', rule, direction, 'group']) and v4_found: for group_type in config.list_nodes(base + ['name', name, 'rule', rule, direction, 'group']): group_name = config.return_value(base + ['name', name, 'rule', rule, direction, 'group', group_type]) - if '+' in group_name: - if group_name[0] == "!": - new_group_name = "!" + translated_dict[group_name[1:]] + + translated_group_name = group_name[1:] if group_name.startswith('!') else group_name + if translated_group_name in translated_dict: + if group_name.startswith('!'): + new_group_name = '!' + translated_dict[translated_group_name] else: - new_group_name = translated_dict[group_name] + new_group_name = translated_dict[translated_group_name] config.set(base + ['name', name, 'rule', rule, direction, 'group', group_type], value=new_group_name) pg_base = base + ['name', name, 'rule', rule, direction, 'group', 'port-group'] proto_base = base + ['name', name, 'rule', rule, 'protocol'] - if config.exists(pg_base) and not config.exists(proto_base): - config.set(proto_base, value='tcp_udp') + if config.exists(pg_base): + if config.exists(proto_base): + proto_name = config.return_value(proto_base) + set_as_tcp_udp = proto_name == 'all' + else: + set_as_tcp_udp = False + if set_as_tcp_udp: + config.set(proto_base, value='tcp_udp') if '+' in name: replacement_string = "_" @@ -282,17 +305,25 @@ def migrate(config: ConfigTree) -> None: if config.exists(base + ['ipv6-name', name, 'rule', rule, direction, 'group']) and v6_found: for group_type in config.list_nodes(base + ['ipv6-name', name, 'rule', rule, direction, 'group']): group_name = config.return_value(base + ['ipv6-name', name, 'rule', rule, direction, 'group', group_type]) - if '+' in group_name: - if group_name[0] == "!": - new_group_name = "!" + translated_dict[group_name[1:]] + + translated_group_name = group_name[1:] if group_name.startswith('!') else group_name + if translated_group_name in translated_dict: + if group_name.startswith('!'): + new_group_name = '!' + translated_dict[translated_group_name] else: - new_group_name = translated_dict[group_name] + new_group_name = translated_dict[translated_group_name] config.set(base + ['ipv6-name', name, 'rule', rule, direction, 'group', group_type], value=new_group_name) pg_base = base + ['ipv6-name', name, 'rule', rule, direction, 'group', 'port-group'] proto_base = base + ['ipv6-name', name, 'rule', rule, 'protocol'] - if config.exists(pg_base) and not config.exists(proto_base): - config.set(proto_base, value='tcp_udp') + if config.exists(pg_base): + if config.exists(proto_base): + proto_name = config.return_value(proto_base) + set_as_tcp_udp = proto_name == 'all' + else: + set_as_tcp_udp = False + if set_as_tcp_udp: + config.set(proto_base, value='tcp_udp') if '+' in name: replacement_string = "_" diff --git a/src/migration-scripts/firewall/7-to-8 b/src/migration-scripts/firewall/7-to-8 index b8bcc52cc..80303849e 100644 --- a/src/migration-scripts/firewall/7-to-8 +++ b/src/migration-scripts/firewall/7-to-8 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/firewall/8-to-9 b/src/migration-scripts/firewall/8-to-9 index 3c9e84662..916000387 100644 --- a/src/migration-scripts/firewall/8-to-9 +++ b/src/migration-scripts/firewall/8-to-9 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/firewall/9-to-10 b/src/migration-scripts/firewall/9-to-10 index 306a53a86..57970e8ab 100644 --- a/src/migration-scripts/firewall/9-to-10 +++ b/src/migration-scripts/firewall/9-to-10 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/flow-accounting/0-to-1 b/src/migration-scripts/flow-accounting/0-to-1 index 77670e3ef..af4c6cbe6 100644 --- a/src/migration-scripts/flow-accounting/0-to-1 +++ b/src/migration-scripts/flow-accounting/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/flow-accounting/1-to-2 b/src/migration-scripts/flow-accounting/1-to-2 index 5ffb1eec8..c1a48274e 100644 --- a/src/migration-scripts/flow-accounting/1-to-2 +++ b/src/migration-scripts/flow-accounting/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/flow-accounting/2-to-3 b/src/migration-scripts/flow-accounting/2-to-3 new file mode 100644 index 000000000..48292fc1d --- /dev/null +++ b/src/migration-scripts/flow-accounting/2-to-3 @@ -0,0 +1,65 @@ +# Copyright 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/>. + +# migrate from pmacct to ipt-NETFLOW: +# Remove 'timeout' subtree, 'buffer-size', 'disable-imt', 'packet-length' and +# 'syslog-facility' from 'system flow-accounting' +# +# Move "system flow-accounting interface" to "system flow-accounting netflow interface" +# +# Remove "system flow-accounting source" and add it to each server +# under "system flow-accounting netflow server SERVER source-address" + + +from vyos.configtree import ConfigTree + +base = ['system', 'flow-accounting'] +remove_keys = [ + ['buffer-size'], + ['disable-imt'], + ['netflow', 'timeout'], + ['packet-length'], + ['syslog-facility'], +] + +def migrate(config: ConfigTree) -> None: + if not config.exists(base): + # Nothing to do + return + + # Remove not needed pmacct fields + for k in remove_keys: + p = base + k + if config.exists(p): + config.delete(p) + + # Move "system flow-accounting interface" -> "system flow-accounting netflow interface" + if config.exists(base + ['interface']): + config.copy(base + ['interface'], base + ['netflow', 'interface']) + config.delete(base + ['interface']) + + # Remove old "source-address", add it to each server as "source-address" + source_prev_path = base + ['netflow', 'source-address'] + if config.exists(source_prev_path): + source_value = config.return_value(source_prev_path) + config.delete(source_prev_path) + + # So we loose 'source-address' value if there are no servers + # configured, but it is not valid configuration if there + # is no server, so it shouldn't be a problem + if config.exists(base + ['netflow', 'server']): + path = base + ['netflow', 'server'] + for server in config.list_nodes(path): + config.set(path + [server, 'source-address'], source_value) diff --git a/src/migration-scripts/https/0-to-1 b/src/migration-scripts/https/0-to-1 index 52fe3f2ad..c39f5d15b 100644 --- a/src/migration-scripts/https/0-to-1 +++ b/src/migration-scripts/https/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 202-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/https/1-to-2 b/src/migration-scripts/https/1-to-2 index dad7ac1f0..708b7c919 100644 --- a/src/migration-scripts/https/1-to-2 +++ b/src/migration-scripts/https/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/https/2-to-3 b/src/migration-scripts/https/2-to-3 index 1125caebf..9efaf840a 100644 --- a/src/migration-scripts/https/2-to-3 +++ b/src/migration-scripts/https/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/https/3-to-4 b/src/migration-scripts/https/3-to-4 index c01236cc6..be9bb17ff 100644 --- a/src/migration-scripts/https/3-to-4 +++ b/src/migration-scripts/https/3-to-4 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/https/4-to-5 b/src/migration-scripts/https/4-to-5 index 0f1c7901f..a99e8d8a5 100644 --- a/src/migration-scripts/https/4-to-5 +++ b/src/migration-scripts/https/4-to-5 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/https/5-to-6 b/src/migration-scripts/https/5-to-6 index 6ef6976b6..f51b35972 100644 --- a/src/migration-scripts/https/5-to-6 +++ b/src/migration-scripts/https/5-to-6 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/https/6-to-7 b/src/migration-scripts/https/6-to-7 index 571f3b6ae..36ac3d1d9 100644 --- a/src/migration-scripts/https/6-to-7 +++ b/src/migration-scripts/https/6-to-7 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/ids/0-to-1 b/src/migration-scripts/ids/0-to-1 index 1b963e839..108f68399 100644 --- a/src/migration-scripts/ids/0-to-1 +++ b/src/migration-scripts/ids/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/ids/1-to-2 b/src/migration-scripts/ids/1-to-2 new file mode 100644 index 000000000..6f93f8b45 --- /dev/null +++ b/src/migration-scripts/ids/1-to-2 @@ -0,0 +1,30 @@ +# Copyright 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/>. + +# T: Migrate threshold and add new threshold types + +from vyos.configtree import ConfigTree + +# The old 'service ids' path was only used for FastNetMon +# Suricata is in 'service suricata', +# so this isn't an overreach +base = ['service', 'ids'] + +def migrate(config: ConfigTree) -> None: + if not config.exists(base): + # Nothing to do + return + else: + config.delete(base) diff --git a/src/migration-scripts/interfaces/0-to-1 b/src/migration-scripts/interfaces/0-to-1 index 7c135e76e..c4417c4ca 100644 --- a/src/migration-scripts/interfaces/0-to-1 +++ b/src/migration-scripts/interfaces/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -20,6 +20,8 @@ from vyos.configtree import ConfigTree +base = ['interfaces', 'bridge'] + def migrate_bridge(config, tree, intf): # check if bridge-group exists tree_bridge = tree + ['bridge-group'] @@ -49,8 +51,6 @@ def migrate_bridge(config, tree, intf): def migrate(config: ConfigTree) -> None: - base = ['interfaces', 'bridge'] - if not config.exists(base): # Nothing to do return diff --git a/src/migration-scripts/interfaces/1-to-2 b/src/migration-scripts/interfaces/1-to-2 index ebf02b028..d0abb4466 100644 --- a/src/migration-scripts/interfaces/1-to-2 +++ b/src/migration-scripts/interfaces/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -34,7 +34,7 @@ def migrate(config: ConfigTree) -> None: if config.exists(['interfaces', 'ethernet', intf, 'bond-group']): # get configured bond interface bond = config.return_value(['interfaces', 'ethernet', intf, 'bond-group']) - # delete old interface asigned (nested) bond group + # delete old interface assigned (nested) bond group config.delete(['interfaces', 'ethernet', intf, 'bond-group']) # create new bond member interface config.set(base + [bond, 'member', 'interface'], value=intf, replace=False) @@ -42,7 +42,7 @@ def migrate(config: ConfigTree) -> None: # # some combinations were allowed in the past from a CLI perspective # but the kernel overwrote them - remove from CLI to not confuse the users. - # In addition new consitency checks are in place so users can't repeat the + # In addition new consistency checks are in place so users can't repeat the # mistake. One of those nice issues is https://vyos.dev/T532 for bond in config.list_nodes(base): if config.exists(base + [bond, 'arp-monitor', 'interval']) and config.exists(base + [bond, 'mode']): diff --git a/src/migration-scripts/interfaces/10-to-11 b/src/migration-scripts/interfaces/10-to-11 index 8a562f2d0..2e8d2d099 100644 --- a/src/migration-scripts/interfaces/10-to-11 +++ b/src/migration-scripts/interfaces/10-to-11 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/11-to-12 b/src/migration-scripts/interfaces/11-to-12 index 132cecbb7..e1bd496c9 100644 --- a/src/migration-scripts/interfaces/11-to-12 +++ b/src/migration-scripts/interfaces/11-to-12 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/12-to-13 b/src/migration-scripts/interfaces/12-to-13 index 585deb898..abd01ceb8 100644 --- a/src/migration-scripts/interfaces/12-to-13 +++ b/src/migration-scripts/interfaces/12-to-13 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/13-to-14 b/src/migration-scripts/interfaces/13-to-14 index 45d8e3b5f..c8b686ee9 100644 --- a/src/migration-scripts/interfaces/13-to-14 +++ b/src/migration-scripts/interfaces/13-to-14 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/14-to-15 b/src/migration-scripts/interfaces/14-to-15 index d45d59bba..e48371539 100644 --- a/src/migration-scripts/interfaces/14-to-15 +++ b/src/migration-scripts/interfaces/14-to-15 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/15-to-16 b/src/migration-scripts/interfaces/15-to-16 index c9abdb5f8..44768356c 100644 --- a/src/migration-scripts/interfaces/15-to-16 +++ b/src/migration-scripts/interfaces/15-to-16 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/16-to-17 b/src/migration-scripts/interfaces/16-to-17 index 7d241ac68..e3161f178 100644 --- a/src/migration-scripts/interfaces/16-to-17 +++ b/src/migration-scripts/interfaces/16-to-17 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/17-to-18 b/src/migration-scripts/interfaces/17-to-18 index f45695a88..a9bf8174c 100644 --- a/src/migration-scripts/interfaces/17-to-18 +++ b/src/migration-scripts/interfaces/17-to-18 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/18-to-19 b/src/migration-scripts/interfaces/18-to-19 index ae1a07adb..2b54d5cdc 100644 --- a/src/migration-scripts/interfaces/18-to-19 +++ b/src/migration-scripts/interfaces/18-to-19 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/19-to-20 b/src/migration-scripts/interfaces/19-to-20 index 7ee6302e2..7262bdad8 100644 --- a/src/migration-scripts/interfaces/19-to-20 +++ b/src/migration-scripts/interfaces/19-to-20 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/2-to-3 b/src/migration-scripts/interfaces/2-to-3 index 695dcbf7a..bb59eb3ce 100644 --- a/src/migration-scripts/interfaces/2-to-3 +++ b/src/migration-scripts/interfaces/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/20-to-21 b/src/migration-scripts/interfaces/20-to-21 index 0b6895177..73c98957c 100644 --- a/src/migration-scripts/interfaces/20-to-21 +++ b/src/migration-scripts/interfaces/20-to-21 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/21-to-22 b/src/migration-scripts/interfaces/21-to-22 index 046eb10c6..a5feffce3 100644 --- a/src/migration-scripts/interfaces/21-to-22 +++ b/src/migration-scripts/interfaces/21-to-22 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/22-to-23 b/src/migration-scripts/interfaces/22-to-23 index 31f7fa2ff..401d5db0e 100644 --- a/src/migration-scripts/interfaces/22-to-23 +++ b/src/migration-scripts/interfaces/22-to-23 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/23-to-24 b/src/migration-scripts/interfaces/23-to-24 index b72ceee49..c534628ce 100644 --- a/src/migration-scripts/interfaces/23-to-24 +++ b/src/migration-scripts/interfaces/23-to-24 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/24-to-25 b/src/migration-scripts/interfaces/24-to-25 index 9f8cc80ec..7386507f9 100644 --- a/src/migration-scripts/interfaces/24-to-25 +++ b/src/migration-scripts/interfaces/24-to-25 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/25-to-26 b/src/migration-scripts/interfaces/25-to-26 index 7a4032d10..1f10e3dca 100644 --- a/src/migration-scripts/interfaces/25-to-26 +++ b/src/migration-scripts/interfaces/25-to-26 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -29,6 +29,7 @@ from vyos.pki import encode_dh_parameters from vyos.pki import encode_private_key from vyos.pki import verify_crl from vyos.utils.process import run +from vyos.utils.file import read_file def wrapped_pem_to_config_value(pem): out = [] @@ -38,20 +39,28 @@ def wrapped_pem_to_config_value(pem): out.append(line) return "".join(out) -def read_file_for_pki(config_auth_path): - full_path = os.path.join(AUTH_DIR, config_auth_path) - output = None +def read_auth_file(config_auth_path): + full_path = os.path.normpath(os.path.join(AUTH_DIR, config_auth_path)) + + # If the file is not found under `/config/auth`, it may be because the `/config` + # partition has not been bind-mounted yet during early boot migration execution. + # Fall back to the equivalent path under `/opt/vyatta/etc/config/auth` which + # is accessible at all boot stages. + if not os.path.isfile(full_path) and full_path.startswith(f'{AUTH_DIR}/'): + full_path = AUTH_DIR_FALLBACK + full_path[len(AUTH_DIR): ] if os.path.isfile(full_path): if not os.access(full_path, os.R_OK): - run(f'sudo chmod 644 {full_path}') + run(['sudo', 'chmod', '644', full_path]) + + return read_file(full_path) - with open(full_path, 'r') as f: - output = f.read() + return None - return output AUTH_DIR = '/config/auth' +AUTH_DIR_FALLBACK = '/opt/vyatta/etc/config/auth' + pki_base = ['pki'] def migrate(config: ConfigTree) -> None: @@ -69,13 +78,30 @@ def migrate(config: ConfigTree) -> None: config.set_tag(pki_base + ['openvpn', 'shared-secret']) key_file = config.return_value(base + [interface, 'shared-secret-key-file']) - key = read_file_for_pki(key_file) + key = read_auth_file(key_file) key_pki_name = f'{pki_name}_shared' if key: - config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'key'], value=wrapped_pem_to_config_value(key)) - config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'version'], value='1') - config.set(base + [interface, 'shared-secret-key'], value=key_pki_name) + # Check if OpenVPN shared-secret already exists - no need to check node + # existence as it is always created above when entering this context + secret_exists = None + for secret_name in config.list_nodes(pki_base + ['openvpn', 'shared-secret']): + secret_path = pki_base + ['openvpn', 'shared-secret', secret_name, 'key'] + if not config.exists(secret_path): + continue + + secret = config.return_value(secret_path) + # Check for duplicate cert/key - and re-use if possible + if secret == wrapped_pem_to_config_value(key): + secret_exists = secret_name + break + + if secret_exists: + config.set(base + [interface, 'shared-secret-key'], value=secret_exists) + else: + config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'key'], value=wrapped_pem_to_config_value(key)) + config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'version'], value='1') + config.set(base + [interface, 'shared-secret-key'], value=key_pki_name) else: print(f'Failed to migrate shared-secret-key on openvpn interface {interface}') @@ -90,13 +116,30 @@ def migrate(config: ConfigTree) -> None: config.set_tag(pki_base + ['openvpn', 'shared-secret']) key_file = config.return_value(base + [interface, 'tls', 'auth-file']) - key = read_file_for_pki(key_file) + key = read_auth_file(key_file) key_pki_name = f'{pki_name}_auth' if key: - config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'key'], value=wrapped_pem_to_config_value(key)) - config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'version'], value='1') - config.set(base + [interface, 'tls', 'auth-key'], value=key_pki_name) + # Check if OpenVPN auth key already exists - no need to check node + # existence as it is always created above when entering this context + secret_exists = None + for secret_name in config.list_nodes(pki_base + ['openvpn', 'shared-secret']): + secret_path = pki_base + ['openvpn', 'shared-secret', secret_name, 'key'] + if not config.exists(secret_path): + continue + + secret = config.return_value(secret_path) + # Check for duplicate cert/key - and re-use if possible + if secret == wrapped_pem_to_config_value(key): + secret_exists = secret_name + break + + if secret_exists: + config.set(base + [interface, 'tls', 'auth-key'], value=secret_exists) + else: + config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'key'], value=wrapped_pem_to_config_value(key)) + config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'version'], value='1') + config.set(base + [interface, 'tls', 'auth-key'], value=key_pki_name) else: print(f'Failed to migrate auth-key on openvpn interface {interface}') @@ -108,13 +151,30 @@ def migrate(config: ConfigTree) -> None: config.set_tag(pki_base + ['openvpn', 'shared-secret']) key_file = config.return_value(base + [interface, 'tls', 'crypt-file']) - key = read_file_for_pki(key_file) + key = read_auth_file(key_file) key_pki_name = f'{pki_name}_crypt' if key: - config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'key'], value=wrapped_pem_to_config_value(key)) - config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'version'], value='1') - config.set(base + [interface, 'tls', 'crypt-key'], value=key_pki_name) + # Check if OpenVPN auth key already exists - no need to check node + # existence as it is always created above when entering this context + secret_exists = None + for secret_name in config.list_nodes(pki_base + ['openvpn', 'shared-secret']): + secret_path = pki_base + ['openvpn', 'shared-secret', secret_name, 'key'] + if not config.exists(secret_path): + continue + + secret = config.return_value(secret_path) + # Check for duplicate cert/key - and re-use if possible + if secret == wrapped_pem_to_config_value(key): + secret_exists = secret_name + break + + if secret_exists: + config.set(base + [interface, 'tls', 'crypt-key'], value=secret_exists) + else: + config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'key'], value=wrapped_pem_to_config_value(key)) + config.set(pki_base + ['openvpn', 'shared-secret', key_pki_name, 'version'], value='1') + config.set(base + [interface, 'tls', 'crypt-key'], value=key_pki_name) else: print(f'Failed to migrate crypt-key on openvpn interface {interface}') @@ -128,28 +188,41 @@ def migrate(config: ConfigTree) -> None: config.set_tag(pki_base + ['ca']) cert_file = config.return_value(x509_base + ['ca-cert-file']) - cert_path = os.path.join(AUTH_DIR, cert_file) - - if os.path.isfile(cert_path): - if not os.access(cert_path, os.R_OK): - run(f'sudo chmod 644 {cert_path}') - - with open(cert_path, 'r') as f: - certs_str = f.read() - certs_data = certs_str.split(CERT_BEGIN) - index = 1 - for cert_data in certs_data[1:]: - cert = load_certificate(CERT_BEGIN + cert_data, wrap_tags=False) - - if cert: - ca_certs[f'{pki_name}_{index}'] = cert - cert_pem = encode_certificate(cert) + certs_str = read_auth_file(cert_file) + + if certs_str: + certs_data = certs_str.split(CERT_BEGIN) + index = 1 + for cert_data in certs_data[1:]: + cert = load_certificate(CERT_BEGIN + cert_data, wrap_tags=False) + + if cert: + ca_certs[f'{pki_name}_{index}'] = cert + cert_pem = encode_certificate(cert) + + # Check if CA already exists - no need to check node existence as + # it is always created above when entering this context + ca_exists = None + for ca_name in config.list_nodes(pki_base + ['ca']): + ca_cert_path = pki_base + ['ca', ca_name, 'certificate'] + if not config.exists(ca_cert_path): + continue + + ca_base64 = config.return_value(ca_cert_path) + # Check for duplicate cert/key - and re-use if possible + if ca_base64 == wrapped_pem_to_config_value(cert_pem): + ca_exists = ca_name + break + + if ca_exists: + config.set(x509_base + ['ca-certificate'], value=ca_exists, replace=False) + else: config.set(pki_base + ['ca', f'{pki_name}_{index}', 'certificate'], value=wrapped_pem_to_config_value(cert_pem)) config.set(x509_base + ['ca-certificate'], value=f'{pki_name}_{index}', replace=False) - else: - print(f'Failed to migrate CA certificate on openvpn interface {interface}') + else: + print(f'Failed to migrate CA certificate on openvpn interface {interface}') - index += 1 + index += 1 else: print(f'Failed to migrate CA certificate on openvpn interface {interface}') @@ -161,26 +234,36 @@ def migrate(config: ConfigTree) -> None: config.set_tag(pki_base + ['ca']) crl_file = config.return_value(x509_base + ['crl-file']) - crl_path = os.path.join(AUTH_DIR, crl_file) - crl = None - crl_ca_name = None - - if os.path.isfile(crl_path): - if not os.access(crl_path, os.R_OK): - run(f'sudo chmod 644 {crl_path}') + crl_data = read_auth_file(crl_file) - with open(crl_path, 'r') as f: - crl_data = f.read() - crl = load_crl(crl_data, wrap_tags=False) + crl = load_crl(crl_data, wrap_tags=False) if crl_data else None + crl_ca_name = None - for ca_name, ca_cert in ca_certs.items(): - if verify_crl(crl, ca_cert): - crl_ca_name = ca_name - break + if crl: + for ca_name, ca_cert in ca_certs.items(): + if verify_crl(crl, ca_cert): + crl_ca_name = ca_name + break - if crl and crl_ca_name: + if crl_ca_name: crl_pem = encode_certificate(crl) - config.set(pki_base + ['ca', crl_ca_name, 'crl'], value=wrapped_pem_to_config_value(crl_pem)) + + # Check if CRL already exists - no need to check node + # existence as it is always created above when entering this context + crl_exists = None + for ca_name in config.list_nodes(pki_base + ['ca']): + crl_path = pki_base + ['ca', ca_name, 'crl'] + if not config.exists(crl_path): + continue + + crl_base64 = config.return_value(crl_path) + # Check if CRL is a duplicate and we have already imported it + if crl_base64 == wrapped_pem_to_config_value(crl_pem): + crl_exists = ca_name + break + + if not crl_exists: + config.set(pki_base + ['ca', crl_ca_name, 'crl'], value=wrapped_pem_to_config_value(crl_pem)) else: print(f'Failed to migrate CRL on openvpn interface {interface}') @@ -192,21 +275,31 @@ def migrate(config: ConfigTree) -> None: config.set_tag(pki_base + ['certificate']) cert_file = config.return_value(x509_base + ['cert-file']) - cert_path = os.path.join(AUTH_DIR, cert_file) - cert = None - - if os.path.isfile(cert_path): - if not os.access(cert_path, os.R_OK): - run(f'sudo chmod 644 {cert_path}') + cert_data = read_auth_file(cert_file) - with open(cert_path, 'r') as f: - cert_data = f.read() - cert = load_certificate(cert_data, wrap_tags=False) + cert = load_certificate(cert_data, wrap_tags=False) if cert_data else None if cert: cert_pem = encode_certificate(cert) - config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem)) - config.set(x509_base + ['certificate'], value=pki_name) + # Check if certificate public key already exists - no need to check node + # existence as it is always created above when entering this context + cert_exists = None + for cert_name in config.list_nodes(pki_base + ['certificate']): + cert_path = pki_base + ['certificate', cert_name, 'certificate'] + if not config.exists(cert_path): + continue + + cert_base64 = config.return_value(cert_path) + # Check for duplicate cert/key - and re-use if possible + if cert_base64 == wrapped_pem_to_config_value(cert_pem): + cert_exists = cert_name + break + + if cert_exists: + config.set(x509_base + ['certificate'], value=cert_exists) + else: + config.set(pki_base + ['certificate', pki_name, 'certificate'], value=wrapped_pem_to_config_value(cert_pem)) + config.set(x509_base + ['certificate'], value=pki_name) else: print(f'Failed to migrate certificate on openvpn interface {interface}') @@ -214,20 +307,28 @@ def migrate(config: ConfigTree) -> None: if config.exists(x509_base + ['key-file']): key_file = config.return_value(x509_base + ['key-file']) - key_path = os.path.join(AUTH_DIR, key_file) - key = None - - if os.path.isfile(key_path): - if not os.access(key_path, os.R_OK): - run(f'sudo chmod 644 {key_path}') + key_data = read_auth_file(key_file) - with open(key_path, 'r') as f: - key_data = f.read() - key = load_private_key(key_data, passphrase=None, wrap_tags=False) + key = load_private_key(key_data, passphrase=None, wrap_tags=False) if key_data else None if key: key_pem = encode_private_key(key, passphrase=None) - config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem)) + # Check if certificate public key already exists - no need to check node + # existence as it is always created above when entering this context + key_exists = None + for key_name in config.list_nodes(pki_base + ['certificate']): + key_path = pki_base + ['certificate', key_name, 'private', 'key'] + if not config.exists(key_path): + continue + + key_base64 = config.return_value(key_path) + # Check for duplicate cert/key - and re-use if possible + if key_base64 == wrapped_pem_to_config_value(key_pem): + key_exists = key_name + break + + if not key_exists: + config.set(pki_base + ['certificate', pki_name, 'private', 'key'], value=wrapped_pem_to_config_value(key_pem)) else: print(f'Failed to migrate private key on openvpn interface {interface}') @@ -239,21 +340,32 @@ def migrate(config: ConfigTree) -> None: config.set_tag(pki_base + ['dh']) dh_file = config.return_value(x509_base + ['dh-file']) - dh_path = os.path.join(AUTH_DIR, dh_file) - dh = None - - if os.path.isfile(dh_path): - if not os.access(dh_path, os.R_OK): - run(f'sudo chmod 644 {dh_path}') + dh_data = read_auth_file(dh_file) - with open(dh_path, 'r') as f: - dh_data = f.read() - dh = load_dh_parameters(dh_data, wrap_tags=False) + dh = load_dh_parameters(dh_data, wrap_tags=False) if dh_data else None if dh: dh_pem = encode_dh_parameters(dh) - config.set(pki_base + ['dh', pki_name, 'parameters'], value=wrapped_pem_to_config_value(dh_pem)) - config.set(x509_base + ['dh-params'], value=pki_name) + + # Check if DH parameters already exists - no need to check node existence + # as it is always created above when entering this context + dh_exists = None + for dh_name in config.list_nodes(pki_base + ['dh']): + dh_param_path = pki_base + ['dh', dh_name, 'parameters'] + if not config.exists(dh_param_path): + continue + + dh_base64 = config.return_value(dh_param_path) + # Check for duplicate cert/key - and re-use if possible + if dh_base64 == wrapped_pem_to_config_value(dh_pem): + dh_exists = dh_name + break + + if dh_exists: + config.set(x509_base + ['dh-params'], value=dh_exists) + else: + config.set(pki_base + ['dh', pki_name, 'parameters'], value=wrapped_pem_to_config_value(dh_pem)) + config.set(x509_base + ['dh-params'], value=pki_name) else: print(f'Failed to migrate DH parameters on openvpn interface {interface}') @@ -270,15 +382,15 @@ def migrate(config: ConfigTree) -> None: if config.exists(private_key_path): key_file = config.return_value(private_key_path) - full_key_path = f'/config/auth/wireguard/{key_file}/private.key' + full_key_path = f'{AUTH_DIR}/wireguard/{key_file}/private.key' + key_data = read_auth_file(full_key_path) - if not os.path.exists(full_key_path): + if not key_data: print(f'Could not find wireguard private key for migration on interface "{interface}"') continue - with open(full_key_path, 'r') as f: - key_data = f.read().strip() - config.set(private_key_path, value=key_data) + key_data = key_data.strip() + config.set(private_key_path, value=key_data) for peer in config.list_nodes(base + [interface, 'peer']): config.rename(base + [interface, 'peer', peer, 'pubkey'], 'public-key') @@ -300,16 +412,9 @@ def migrate(config: ConfigTree) -> None: config.set_tag(pki_base + ['ca']) cert_file = config.return_value(x509_base + ['ca-cert-file']) - cert_path = os.path.join(AUTH_DIR, cert_file) - cert = None - - if os.path.isfile(cert_path): - if not os.access(cert_path, os.R_OK): - run(f'sudo chmod 644 {cert_path}') + cert_data = read_auth_file(cert_file) - with open(cert_path, 'r') as f: - cert_data = f.read() - cert = load_certificate(cert_data, wrap_tags=False) + cert = load_certificate(cert_data, wrap_tags=False) if cert_data else None if cert: cert_pem = encode_certificate(cert) @@ -326,16 +431,9 @@ def migrate(config: ConfigTree) -> None: config.set_tag(pki_base + ['certificate']) cert_file = config.return_value(x509_base + ['cert-file']) - cert_path = os.path.join(AUTH_DIR, cert_file) - cert = None + cert_data = read_auth_file(cert_file) - if os.path.isfile(cert_path): - if not os.access(cert_path, os.R_OK): - run(f'sudo chmod 644 {cert_path}') - - with open(cert_path, 'r') as f: - cert_data = f.read() - cert = load_certificate(cert_data, wrap_tags=False) + cert = load_certificate(cert_data, wrap_tags=False) if cert_data else None if cert: cert_pem = encode_certificate(cert) @@ -348,16 +446,9 @@ def migrate(config: ConfigTree) -> None: if config.exists(x509_base + ['key-file']): key_file = config.return_value(x509_base + ['key-file']) - key_path = os.path.join(AUTH_DIR, key_file) - key = None - - if os.path.isfile(key_path): - if not os.access(key_path, os.R_OK): - run(f'sudo chmod 644 {key_path}') + key_data = read_auth_file(key_file) - with open(key_path, 'r') as f: - key_data = f.read() - key = load_private_key(key_data, passphrase=None, wrap_tags=False) + key = load_private_key(key_data, passphrase=None, wrap_tags=False) if key_data else None if key: key_pem = encode_private_key(key, passphrase=None) diff --git a/src/migration-scripts/interfaces/26-to-27 b/src/migration-scripts/interfaces/26-to-27 index 3f58de02c..c04655da0 100644 --- a/src/migration-scripts/interfaces/26-to-27 +++ b/src/migration-scripts/interfaces/26-to-27 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/27-to-28 b/src/migration-scripts/interfaces/27-to-28 index eb9363e39..fb156a899 100644 --- a/src/migration-scripts/interfaces/27-to-28 +++ b/src/migration-scripts/interfaces/27-to-28 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/28-to-29 b/src/migration-scripts/interfaces/28-to-29 index 886d49e2c..9a2ba5757 100644 --- a/src/migration-scripts/interfaces/28-to-29 +++ b/src/migration-scripts/interfaces/28-to-29 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/29-to-30 b/src/migration-scripts/interfaces/29-to-30 index 7b32d871e..c9664e83a 100644 --- a/src/migration-scripts/interfaces/29-to-30 +++ b/src/migration-scripts/interfaces/29-to-30 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/3-to-4 b/src/migration-scripts/interfaces/3-to-4 index 4e56200e1..ed27a917b 100644 --- a/src/migration-scripts/interfaces/3-to-4 +++ b/src/migration-scripts/interfaces/3-to-4 @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/30-to-31 b/src/migration-scripts/interfaces/30-to-31 index 7e509dd86..48ca063e2 100644 --- a/src/migration-scripts/interfaces/30-to-31 +++ b/src/migration-scripts/interfaces/30-to-31 @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/31-to-32 b/src/migration-scripts/interfaces/31-to-32 index 24077ed24..1c5147e6a 100644 --- a/src/migration-scripts/interfaces/31-to-32 +++ b/src/migration-scripts/interfaces/31-to-32 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/32-to-33 b/src/migration-scripts/interfaces/32-to-33 index c7b1c5b36..11b7aa58b 100644 --- a/src/migration-scripts/interfaces/32-to-33 +++ b/src/migration-scripts/interfaces/32-to-33 @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -13,28 +13,46 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -# -# T6318: WiFi country-code should be set system-wide instead of per-device -from vyos.configtree import ConfigTree +# T7646: restore behavior of IPv6 default route if only dhcpv6 was defined but +# not "ipv6 address autoconf" -base = ['interfaces', 'wireless'] +from vyos.configtree import ConfigTree def migrate(config: ConfigTree) -> None: - if not config.exists(base): - # Nothing to do - return + for type in config.list_nodes(['interfaces']): + for interface in config.list_nodes(['interfaces', type]): + iface_base_path = ['interfaces', type, interface] + dhcpv6_addr_path = iface_base_path + ['address'] + + if config.exists(dhcpv6_addr_path) and 'dhcpv6' in config.return_values(dhcpv6_addr_path): + autoconf_path = iface_base_path + ['ipv6', 'address', 'autoconf'] + if not config.exists(autoconf_path): + config.set(autoconf_path) + + vif_path = iface_base_path + ['vif'] + if config.exists(vif_path): + for vif in config.list_nodes(vif_path): + vif_dhcpv6_addr_path = vif_path + [vif, 'address'] + if config.exists(vif_dhcpv6_addr_path) and 'dhcpv6' in config.return_values(vif_dhcpv6_addr_path): + vif_autoconf_path = vif_path + [vif, 'ipv6', 'address', 'autoconf'] + if not config.exists(vif_autoconf_path): + config.set(vif_autoconf_path) - installed = False - for interface in config.list_nodes(base): - cc_path = base + [interface, 'country-code'] - if config.exists(cc_path): - tmp = config.return_value(cc_path) - config.delete(cc_path) + vif_s_path = iface_base_path + ['vif-s'] + if config.exists(vif_s_path): + for vif_s in config.list_nodes(vif_s_path): + vif_s_dhcpv6_addr_path = vif_s_path + [vif_s, 'address'] + if config.exists(vif_s_dhcpv6_addr_path) and 'dhcpv6' in config.return_values(vif_s_dhcpv6_addr_path): + vif_s_autoconf_path = vif_s_path + [vif_s, 'ipv6', 'address', 'autoconf'] + if not config.exists(vif_s_autoconf_path): + config.set(vif_s_autoconf_path) - # There can be only ONE wireless country-code per device, everything - # else makes no sense as a WIFI router can not operate in two - # different countries - if not installed: - config.set(['system', 'wireless', 'country-code'], value=tmp) - installed = True + vif_c_path = iface_base_path + ['vif-s', vif_s, 'vif-c'] + if config.exists(vif_c_path): + for vif_c in config.list_nodes(vif_c_path): + vif_c_dhcpv6_addr_path = vif_c_path + [vif_c, 'address'] + if config.exists(vif_c_dhcpv6_addr_path) and 'dhcpv6' in config.return_values(vif_c_dhcpv6_addr_path): + vif_c_autoconf_path = vif_c_path + [vif_c, 'ipv6', 'address', 'autoconf'] + if not config.exists(vif_c_autoconf_path): + config.set(vif_c_autoconf_path) diff --git a/src/migration-scripts/interfaces/33-to-34 b/src/migration-scripts/interfaces/33-to-34 new file mode 100644 index 000000000..ccda04d0c --- /dev/null +++ b/src/migration-scripts/interfaces/33-to-34 @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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/>. +# +# T6318: WiFi country-code should be set system-wide instead of per-device + +from vyos.configtree import ConfigTree + +base = ['interfaces', 'wireless'] + +def migrate(config: ConfigTree) -> None: + if not config.exists(base): + # Nothing to do + return + + installed = False + for interface in config.list_nodes(base): + cc_path = base + [interface, 'country-code'] + if config.exists(cc_path): + tmp = config.return_value(cc_path) + config.delete(cc_path) + + # There can be only ONE wireless country-code per device, everything + # else makes no sense as a WIFI router can not operate in two + # different countries + if not installed: + config.set(['system', 'wireless', 'country-code'], value=tmp) + installed = True diff --git a/src/migration-scripts/interfaces/4-to-5 b/src/migration-scripts/interfaces/4-to-5 index 93fa7c393..839e23d77 100644 --- a/src/migration-scripts/interfaces/4-to-5 +++ b/src/migration-scripts/interfaces/4-to-5 @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/5-to-6 b/src/migration-scripts/interfaces/5-to-6 index 44c32ba63..e172d9c80 100644 --- a/src/migration-scripts/interfaces/5-to-6 +++ b/src/migration-scripts/interfaces/5-to-6 @@ -1,4 +1,4 @@ -# Copyright 202-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -13,7 +13,7 @@ # 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/>. -# Migrate IPv6 router advertisments from a nested interface configuration to +# Migrate IPv6 router advertisements from a nested interface configuration to # a denested "service router-advert" from vyos.configtree import ConfigTree @@ -56,7 +56,7 @@ def copy_rtradv(c, old_base, interface): # cleanup boolean nodes in individual route route_base = new_base + ['route'] if c.exists(route_base): - for route in config.list_nodes(route_base): + for route in c.list_nodes(route_base): if c.exists(route_base + [route, 'remove-route']): tmp = c.return_value(route_base + [route, 'remove-route']) c.delete(route_base + [route, 'remove-route']) @@ -66,7 +66,7 @@ def copy_rtradv(c, old_base, interface): # cleanup boolean nodes in individual prefix prefix_base = new_base + ['prefix'] if c.exists(prefix_base): - for prefix in config.list_nodes(prefix_base): + for prefix in c.list_nodes(prefix_base): if c.exists(prefix_base + [prefix, 'autonomous-flag']): tmp = c.return_value(prefix_base + [prefix, 'autonomous-flag']) c.delete(prefix_base + [prefix, 'autonomous-flag']) diff --git a/src/migration-scripts/interfaces/6-to-7 b/src/migration-scripts/interfaces/6-to-7 index e60121eec..0131b419c 100644 --- a/src/migration-scripts/interfaces/6-to-7 +++ b/src/migration-scripts/interfaces/6-to-7 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/interfaces/7-to-8 b/src/migration-scripts/interfaces/7-to-8 index 43ae320ab..e670deb37 100644 --- a/src/migration-scripts/interfaces/7-to-8 +++ b/src/migration-scripts/interfaces/7-to-8 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -43,7 +43,7 @@ def migrate(config: ConfigTree) -> None: # Nothing to do return - # list all individual wireguard interface isntance + # list all individual wireguard interface instance for i in config.list_nodes(base): iface = base + [i] for peer in config.list_nodes(iface + ['peer']): diff --git a/src/migration-scripts/interfaces/8-to-9 b/src/migration-scripts/interfaces/8-to-9 index bae1b34fa..baabce8bc 100644 --- a/src/migration-scripts/interfaces/8-to-9 +++ b/src/migration-scripts/interfaces/8-to-9 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -26,7 +26,7 @@ def migrate(config: ConfigTree) -> None: # Nothing to do continue - # list all individual interface isntance + # list all individual interface instance for i in config.list_nodes(base): iface = base + [i] if config.exists(iface + ['link']): diff --git a/src/migration-scripts/interfaces/9-to-10 b/src/migration-scripts/interfaces/9-to-10 index cdfd7d432..354416975 100644 --- a/src/migration-scripts/interfaces/9-to-10 +++ b/src/migration-scripts/interfaces/9-to-10 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -13,7 +13,7 @@ # 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/>. -# - rename CLI node 'dhcpv6-options delgate' to 'dhcpv6-options prefix-delegation +# - rename CLI node 'dhcpv6-options delegate' to 'dhcpv6-options prefix-delegation # interface' # - rename CLI node 'interface-id' for prefix-delegation to 'address' as it # represents the local interface IPv6 address assigned by DHCPv6-PD @@ -41,5 +41,5 @@ def migrate(config: ConfigTree) -> None: for interface in config.list_nodes(new_path + ['interface']): config.rename(new_path + ['interface', interface, 'interface-id'], 'address') - # delete old noe + # delete old node config.delete(base_path) diff --git a/src/migration-scripts/ipoe-server/1-to-2 b/src/migration-scripts/ipoe-server/1-to-2 index 034eacb10..6db4a7167 100644 --- a/src/migration-scripts/ipoe-server/1-to-2 +++ b/src/migration-scripts/ipoe-server/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -21,7 +21,7 @@ # - changed cli of all named pools # - moved gateway-address from pool to global configuration with / netmask # gateway can exist without pool if radius is used -# and Framed-ip-address is transmited +# and Framed-ip-address is transmitted # - There are several gateway-addresses in ipoe # - default-pool by migration. # 1. The first pool that contains next-poll. diff --git a/src/migration-scripts/ipoe-server/2-to-3 b/src/migration-scripts/ipoe-server/2-to-3 index dcd15e595..fb4262e1d 100644 --- a/src/migration-scripts/ipoe-server/2-to-3 +++ b/src/migration-scripts/ipoe-server/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/ipoe-server/3-to-4 b/src/migration-scripts/ipoe-server/3-to-4 index 3bad9756d..cce1927f3 100644 --- a/src/migration-scripts/ipoe-server/3-to-4 +++ b/src/migration-scripts/ipoe-server/3-to-4 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/ipsec/10-to-11 b/src/migration-scripts/ipsec/10-to-11 index 6c4ccb553..f88250ac1 100644 --- a/src/migration-scripts/ipsec/10-to-11 +++ b/src/migration-scripts/ipsec/10-to-11 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/ipsec/11-to-12 b/src/migration-scripts/ipsec/11-to-12 index fc65f1825..f6f34bd59 100644 --- a/src/migration-scripts/ipsec/11-to-12 +++ b/src/migration-scripts/ipsec/11-to-12 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/ipsec/12-to-13 b/src/migration-scripts/ipsec/12-to-13 index ffe766eb2..184ba0274 100644 --- a/src/migration-scripts/ipsec/12-to-13 +++ b/src/migration-scripts/ipsec/12-to-13 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/ipsec/13-to-14 b/src/migration-scripts/ipsec/13-to-14 new file mode 100644 index 000000000..f676a09be --- /dev/null +++ b/src/migration-scripts/ipsec/13-to-14 @@ -0,0 +1,33 @@ +# Copyright 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/>. + +# Rename connection-type 'respond' to 'trap' (T7594): +# vpn ipsec site-to-site peer <name> connection-type respond -> trap + +from vyos.configtree import ConfigTree + +base = ['vpn', 'ipsec', 'site-to-site'] + +def migrate(config: ConfigTree) -> None: + # If IPsec config does not exist, nothing to do + if not config.exists(base): + return + + # Iterate through defined peers + for peer in config.list_nodes(base + ['peer']): + path = base + ['peer', peer, 'connection-type'] + if config.value_exists(path, 'respond'): + # Replace old behavior with explicit passive type + config.set(path, 'trap', replace=True) diff --git a/src/migration-scripts/ipsec/4-to-5 b/src/migration-scripts/ipsec/4-to-5 index a88a543d3..62653f32d 100644 --- a/src/migration-scripts/ipsec/4-to-5 +++ b/src/migration-scripts/ipsec/4-to-5 @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/ipsec/5-to-6 b/src/migration-scripts/ipsec/5-to-6 index 373428d61..6c1d9c0d4 100644 --- a/src/migration-scripts/ipsec/5-to-6 +++ b/src/migration-scripts/ipsec/5-to-6 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/ipsec/6-to-7 b/src/migration-scripts/ipsec/6-to-7 index 5679477c0..35d2ad6c6 100644 --- a/src/migration-scripts/ipsec/6-to-7 +++ b/src/migration-scripts/ipsec/6-to-7 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/ipsec/7-to-8 b/src/migration-scripts/ipsec/7-to-8 index 481f00d29..b10230431 100644 --- a/src/migration-scripts/ipsec/7-to-8 +++ b/src/migration-scripts/ipsec/7-to-8 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/ipsec/8-to-9 b/src/migration-scripts/ipsec/8-to-9 index 7f325139f..3a97c01c0 100644 --- a/src/migration-scripts/ipsec/8-to-9 +++ b/src/migration-scripts/ipsec/8-to-9 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/ipsec/9-to-10 b/src/migration-scripts/ipsec/9-to-10 index 321a75973..ccb1a5e02 100644 --- a/src/migration-scripts/ipsec/9-to-10 +++ b/src/migration-scripts/ipsec/9-to-10 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/isis/0-to-1 b/src/migration-scripts/isis/0-to-1 index e24288558..3a9735d29 100644 --- a/src/migration-scripts/isis/0-to-1 +++ b/src/migration-scripts/isis/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -24,13 +24,21 @@ def migrate(config: ConfigTree) -> None: # Nothing to do return - # We need a temporary copy of the config - tmp_base = ['protocols', 'isis2'] - config.copy(base, tmp_base) + if not config.is_tag(base): + # Nothing to do + exit(0) + + # Get IS-IS domain ID + domain_id = config.list_nodes(base) + if domain_id: + # We need a temporary copy of the config + tmp_base = ['protocols', 'isis2'] + config.copy(base + domain_id, tmp_base) # Now it's save to delete the old configuration config.delete(base) - # Rename temporary copy to new final config (IS-IS domain key is static and no - # longer required to be set via CLI) - config.rename(tmp_base, 'isis') + # Rename temporary node to new final config (IS-IS domain key is static and + # no longer required to be set via CLI) + if config.exists(tmp_base): + config.rename(tmp_base, 'isis') diff --git a/src/migration-scripts/isis/1-to-2 b/src/migration-scripts/isis/1-to-2 index 0fc92a6de..e87d91516 100644 --- a/src/migration-scripts/isis/1-to-2 +++ b/src/migration-scripts/isis/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/isis/2-to-3 b/src/migration-scripts/isis/2-to-3 index afb9f2340..9cdc84ac3 100644 --- a/src/migration-scripts/isis/2-to-3 +++ b/src/migration-scripts/isis/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/l2tp/0-to-1 b/src/migration-scripts/l2tp/0-to-1 index f0cb6af96..03fb7e236 100644 --- a/src/migration-scripts/l2tp/0-to-1 +++ b/src/migration-scripts/l2tp/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/l2tp/1-to-2 b/src/migration-scripts/l2tp/1-to-2 index 468d564ac..dd38edfbc 100644 --- a/src/migration-scripts/l2tp/1-to-2 +++ b/src/migration-scripts/l2tp/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/l2tp/2-to-3 b/src/migration-scripts/l2tp/2-to-3 index 00fabb6b6..748d9544e 100644 --- a/src/migration-scripts/l2tp/2-to-3 +++ b/src/migration-scripts/l2tp/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/l2tp/3-to-4 b/src/migration-scripts/l2tp/3-to-4 index 01c3fa844..434b3a72e 100644 --- a/src/migration-scripts/l2tp/3-to-4 +++ b/src/migration-scripts/l2tp/3-to-4 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/l2tp/4-to-5 b/src/migration-scripts/l2tp/4-to-5 index 56d451b8d..c3cc8451e 100644 --- a/src/migration-scripts/l2tp/4-to-5 +++ b/src/migration-scripts/l2tp/4-to-5 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/l2tp/5-to-6 b/src/migration-scripts/l2tp/5-to-6 index cc9f948a6..d57819d2a 100644 --- a/src/migration-scripts/l2tp/5-to-6 +++ b/src/migration-scripts/l2tp/5-to-6 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -28,7 +28,7 @@ def migrate(config: ConfigTree) -> None: value=config.return_value(idle_path)) config.delete(idle_path) - #migrate mppe from authentication to ppp-otion + #migrate mppe from authentication to ppp-options mppe_path = base + ['authentication', 'mppe'] if config.exists(mppe_path): config.set(base + ['ppp-options', 'mppe'], diff --git a/src/migration-scripts/l2tp/6-to-7 b/src/migration-scripts/l2tp/6-to-7 index 4dba5974e..f4e561382 100644 --- a/src/migration-scripts/l2tp/6-to-7 +++ b/src/migration-scripts/l2tp/6-to-7 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/l2tp/7-to-8 b/src/migration-scripts/l2tp/7-to-8 index 527906fc8..b11a28af0 100644 --- a/src/migration-scripts/l2tp/7-to-8 +++ b/src/migration-scripts/l2tp/7-to-8 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/l2tp/8-to-9 b/src/migration-scripts/l2tp/8-to-9 index e6b689e80..e5577da3e 100644 --- a/src/migration-scripts/l2tp/8-to-9 +++ b/src/migration-scripts/l2tp/8-to-9 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/lldp/0-to-1 b/src/migration-scripts/lldp/0-to-1 index c16e7e84b..052fe65c2 100644 --- a/src/migration-scripts/lldp/0-to-1 +++ b/src/migration-scripts/lldp/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/lldp/1-to-2 b/src/migration-scripts/lldp/1-to-2 index 7f233a725..e2d936a9e 100644 --- a/src/migration-scripts/lldp/1-to-2 +++ b/src/migration-scripts/lldp/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/lldp/2-to-3 b/src/migration-scripts/lldp/2-to-3 index 93090756c..653029550 100644 --- a/src/migration-scripts/lldp/2-to-3 +++ b/src/migration-scripts/lldp/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2025 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/monitoring/0-to-1 b/src/migration-scripts/monitoring/0-to-1 index 92f824325..7f7d9df60 100644 --- a/src/migration-scripts/monitoring/0-to-1 +++ b/src/migration-scripts/monitoring/0-to-1 @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/monitoring/1-to-2 b/src/migration-scripts/monitoring/1-to-2 index 8bdaebae9..013e8ff3a 100644 --- a/src/migration-scripts/monitoring/1-to-2 +++ b/src/migration-scripts/monitoring/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/nat/4-to-5 b/src/migration-scripts/nat/4-to-5 index e1919da50..f4a48bbf3 100644 --- a/src/migration-scripts/nat/4-to-5 +++ b/src/migration-scripts/nat/4-to-5 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/nat/5-to-6 b/src/migration-scripts/nat/5-to-6 index a583d4eb6..e40ccebb0 100644 --- a/src/migration-scripts/nat/5-to-6 +++ b/src/migration-scripts/nat/5-to-6 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/nat/6-to-7 b/src/migration-scripts/nat/6-to-7 index e9b90fc98..2e8929b1c 100644 --- a/src/migration-scripts/nat/6-to-7 +++ b/src/migration-scripts/nat/6-to-7 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -13,7 +13,7 @@ # 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/>. -# T5681: Firewall re-writing. Simplify cli when mathcing interface +# T5681: Firewall re-writing. Simplify cli when matching interface # From # 'set nat [source|destination] rule X [inbound-interface|outbound interface] interface-name <iface>' # 'set nat [source|destination] rule X [inbound-interface|outbound interface] interface-group <iface_group>' diff --git a/src/migration-scripts/nat/7-to-8 b/src/migration-scripts/nat/7-to-8 index 9ae389ef1..4af8c935c 100644 --- a/src/migration-scripts/nat/7-to-8 +++ b/src/migration-scripts/nat/7-to-8 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/nat66/0-to-1 b/src/migration-scripts/nat66/0-to-1 index b3c6bf4cc..08348379f 100644 --- a/src/migration-scripts/nat66/0-to-1 +++ b/src/migration-scripts/nat66/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/nat66/1-to-2 b/src/migration-scripts/nat66/1-to-2 index f49940ae0..eef55e878 100644 --- a/src/migration-scripts/nat66/1-to-2 +++ b/src/migration-scripts/nat66/1-to-2 @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -29,7 +29,7 @@ # 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/>. -# T5681: Firewall re-writing. Simplify cli when mathcing interface +# T5681: Firewall re-writing. Simplify cli when matching interface # From # 'set nat66 [source|destination] rule X [inbound-interface|outbound interface] <iface>' # to diff --git a/src/migration-scripts/nat66/2-to-3 b/src/migration-scripts/nat66/2-to-3 index 55d5f4b2b..1e76819d4 100644 --- a/src/migration-scripts/nat66/2-to-3 +++ b/src/migration-scripts/nat66/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/nhrp/0-to-1 b/src/migration-scripts/nhrp/0-to-1 index badd88e04..1923f9283 100644 --- a/src/migration-scripts/nhrp/0-to-1 +++ b/src/migration-scripts/nhrp/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2025 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -112,7 +112,7 @@ def migrate(config: ConfigTree) -> None: config.set(base + [tunnel_name, 'multicast'], value=nbma, replace=False) - ## Delete non-cahching + ## Delete non-caching if config.exists(base + [tunnel_name, 'non-caching']): config.delete(base + [tunnel_name, 'non-caching']) ## Delete shortcut-destination @@ -126,4 +126,4 @@ def migrate(config: ConfigTree) -> None: config.set(base + [tunnel_name, 'shortcut']) config.delete(base + [tunnel_name, 'shortcut-target']) ## Set registration-no-unique - config.set(base + [tunnel_name, 'registration-no-unique'])
\ No newline at end of file + config.set(base + [tunnel_name, 'registration-no-unique']) diff --git a/src/migration-scripts/ntp/0-to-1 b/src/migration-scripts/ntp/0-to-1 index 01f5a460a..895308b75 100644 --- a/src/migration-scripts/ntp/0-to-1 +++ b/src/migration-scripts/ntp/0-to-1 @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/ntp/1-to-2 b/src/migration-scripts/ntp/1-to-2 index d5f800922..d3039041c 100644 --- a/src/migration-scripts/ntp/1-to-2 +++ b/src/migration-scripts/ntp/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2023-2025 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/ntp/2-to-3 b/src/migration-scripts/ntp/2-to-3 index bbda90351..701cffdfb 100644 --- a/src/migration-scripts/ntp/2-to-3 +++ b/src/migration-scripts/ntp/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/openconnect/0-to-1 b/src/migration-scripts/openconnect/0-to-1 index aa5a97eee..90f7ee64a 100644 --- a/src/migration-scripts/openconnect/0-to-1 +++ b/src/migration-scripts/openconnect/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/openconnect/1-to-2 b/src/migration-scripts/openconnect/1-to-2 index 4f74b44df..b145ccb0f 100644 --- a/src/migration-scripts/openconnect/1-to-2 +++ b/src/migration-scripts/openconnect/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -13,7 +13,7 @@ # 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/>. -# Delete depricated outside-nexthop address +# Delete deprecated outside-nexthop address from vyos.configtree import ConfigTree diff --git a/src/migration-scripts/openconnect/2-to-3 b/src/migration-scripts/openconnect/2-to-3 index 00e13ecb0..74cb6f2d8 100644 --- a/src/migration-scripts/openconnect/2-to-3 +++ b/src/migration-scripts/openconnect/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/openvpn/0-to-1 b/src/migration-scripts/openvpn/0-to-1 index e5db731ed..26f41d2ce 100644 --- a/src/migration-scripts/openvpn/0-to-1 +++ b/src/migration-scripts/openvpn/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -17,6 +17,8 @@ from vyos.configtree import ConfigTree +updated_cipher='3des' + def migrate(config: ConfigTree) -> None: if not config.exists(['interfaces', 'openvpn']): # Nothing to do @@ -25,6 +27,7 @@ def migrate(config: ConfigTree) -> None: ovpn_intfs = config.list_nodes(['interfaces', 'openvpn']) for i in ovpn_intfs: # Remove DES and Blowfish from 'encryption cipher' + # Support for these insecure ciphers will be removed in OpenVPN 2.6. cipher_path = ['interfaces', 'openvpn', i, 'encryption', 'cipher'] if config.exists(cipher_path): cipher = config.return_value(cipher_path) @@ -41,3 +44,11 @@ def migrate(config: ConfigTree) -> None: if config.exists(['interfaces', 'openvpn', i, 'encryption']) and \ (config.list_nodes(['interfaces', 'openvpn', i, 'encryption']) == []): config.delete(['interfaces', 'openvpn', i, 'encryption']) + + # We need to take care about site-to-site tunnels which had an implicit + # OpenVPN default cipher (BF-CBC) with block size less than 128 bit (64 bit). + mode_path = ['interfaces', 'openvpn', i, 'mode'] + if config.exists(mode_path) and config.return_value(mode_path) == 'site-to-site': + # if no explicit cipher is defined, we will "upgrade" to 3DES at least + if not config.exists(cipher_path): + config.set(cipher_path, value=updated_cipher) diff --git a/src/migration-scripts/openvpn/1-to-2 b/src/migration-scripts/openvpn/1-to-2 index 2baa7302c..75360446c 100644 --- a/src/migration-scripts/openvpn/1-to-2 +++ b/src/migration-scripts/openvpn/1-to-2 @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -25,11 +25,17 @@ def migrate(config: ConfigTree) -> None: # 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 + mode_path = ['interfaces', 'openvpn', i, 'mode'] cipher_path = ['interfaces', 'openvpn', i, 'encryption', 'cipher'] ncp_cipher_path = ['interfaces', 'openvpn', i, 'encryption', 'ncp-ciphers'] + if config.exists(cipher_path): if config.exists(['interfaces', 'openvpn', i, 'shared-secret-key']): continue + # Only migrate if mode is not 'site-to-site' + if config.value_exists(mode_path, 'site-to-site'): + continue + cipher = config.return_value(cipher_path) config.delete(cipher_path) if cipher == 'none': diff --git a/src/migration-scripts/openvpn/2-to-3 b/src/migration-scripts/openvpn/2-to-3 index 4e6b3c8b7..f2a7d1d69 100644 --- a/src/migration-scripts/openvpn/2-to-3 +++ b/src/migration-scripts/openvpn/2-to-3 @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/migration-scripts/openvpn/3-to-4 b/src/migration-scripts/openvpn/3-to-4 index 0529491c1..86b287102 100644 --- a/src/migration-scripts/openvpn/3-to-4 +++ b/src/migration-scripts/openvpn/3-to-4 @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/openvpn/4-to-5 b/src/migration-scripts/openvpn/4-to-5 new file mode 100644 index 000000000..5c7ee833c --- /dev/null +++ b/src/migration-scripts/openvpn/4-to-5 @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +# Copyright 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/>. +# +# T7633: This migration converts legacy 'encryption cipher' directives into +# 'encryption data-ciphers-fallback' for site-to-site mode tunnels. +# +# In modern OpenVPN (v2.6+), 'cipher' and 'data-ciphers' are not valid +# in site-to-site mode. +# The appropriate directive is now '--data-ciphers-fallback alg'. + +from vyos.configtree import ConfigTree + +def migrate(config: ConfigTree) -> None: + ovpn_intfs = config.list_nodes(['interfaces', 'openvpn'], path_must_exist=False) + for i in ovpn_intfs: + base_path = ['interfaces', 'openvpn', i] + mode_path = base_path + ['mode'] + cipher_path = base_path + ['encryption', 'cipher'] + + # Only migrate if mode is explicitly 'site-to-site' + if config.value_exists(mode_path, 'site-to-site'): + + # Rename 'encryption cipher' with 'encryption data-ciphers-fallback' + if config.exists(cipher_path): + config.rename(cipher_path, 'data-ciphers-fallback') diff --git a/src/migration-scripts/ospf/0-to-1 b/src/migration-scripts/ospf/0-to-1 index a1f810960..2f20184b4 100644 --- a/src/migration-scripts/ospf/0-to-1 +++ b/src/migration-scripts/ospf/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/ospf/1-to-2 b/src/migration-scripts/ospf/1-to-2 index 5368d8dd7..5b7862ee7 100644 --- a/src/migration-scripts/ospf/1-to-2 +++ b/src/migration-scripts/ospf/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/pim/0-to-1 b/src/migration-scripts/pim/0-to-1 index ce24b23ba..c0b48ae67 100644 --- a/src/migration-scripts/pim/0-to-1 +++ b/src/migration-scripts/pim/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/policy/0-to-1 b/src/migration-scripts/policy/0-to-1 index 837946c37..aea43560b 100644 --- a/src/migration-scripts/policy/0-to-1 +++ b/src/migration-scripts/policy/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/policy/1-to-2 b/src/migration-scripts/policy/1-to-2 index ba3e48db0..5990c4a8e 100644 --- a/src/migration-scripts/policy/1-to-2 +++ b/src/migration-scripts/policy/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/policy/2-to-3 b/src/migration-scripts/policy/2-to-3 index 399a55387..74c15885e 100644 --- a/src/migration-scripts/policy/2-to-3 +++ b/src/migration-scripts/policy/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/policy/3-to-4 b/src/migration-scripts/policy/3-to-4 index 5d4959def..b34411c39 100644 --- a/src/migration-scripts/policy/3-to-4 +++ b/src/migration-scripts/policy/3-to-4 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -105,7 +105,7 @@ def migrate(config: ConfigTree) -> None: for rule in config.list_nodes(base + [route_map, 'rule']): base_rule: list[str] = base + [route_map, 'rule', rule, 'set'] - # IF additive presents in coummunity then comm-list is redundant + # IF additive presents in community then comm-list is redundant isAdditive: bool = True #### Change Set community ######## if config.exists(base_rule + ['community']): diff --git a/src/migration-scripts/policy/4-to-5 b/src/migration-scripts/policy/4-to-5 index 0ecfdfd5e..01f019d39 100644 --- a/src/migration-scripts/policy/4-to-5 +++ b/src/migration-scripts/policy/4-to-5 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/policy/5-to-6 b/src/migration-scripts/policy/5-to-6 index acba0b4be..d1c818827 100644 --- a/src/migration-scripts/policy/5-to-6 +++ b/src/migration-scripts/policy/5-to-6 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/policy/6-to-7 b/src/migration-scripts/policy/6-to-7 index 69aa703c5..b4e7ebcea 100644 --- a/src/migration-scripts/policy/6-to-7 +++ b/src/migration-scripts/policy/6-to-7 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/policy/7-to-8 b/src/migration-scripts/policy/7-to-8 index a887f37fe..4fa45b879 100644 --- a/src/migration-scripts/policy/7-to-8 +++ b/src/migration-scripts/policy/7-to-8 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/policy/8-to-9 b/src/migration-scripts/policy/8-to-9 index 355e48e00..b8cc1f471 100644 --- a/src/migration-scripts/policy/8-to-9 +++ b/src/migration-scripts/policy/8-to-9 @@ -1,4 +1,4 @@ -# Copyright (C) 2025 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/pppoe-server/0-to-1 b/src/migration-scripts/pppoe-server/0-to-1 index 8c9a24fbe..c2b25c45f 100644 --- a/src/migration-scripts/pppoe-server/0-to-1 +++ b/src/migration-scripts/pppoe-server/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/pppoe-server/1-to-2 b/src/migration-scripts/pppoe-server/1-to-2 index c9c968bff..31132565e 100644 --- a/src/migration-scripts/pppoe-server/1-to-2 +++ b/src/migration-scripts/pppoe-server/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/pppoe-server/10-to-11 b/src/migration-scripts/pppoe-server/10-to-11 index 6bc138b5c..9d0477172 100644 --- a/src/migration-scripts/pppoe-server/10-to-11 +++ b/src/migration-scripts/pppoe-server/10-to-11 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/pppoe-server/11-to-12 b/src/migration-scripts/pppoe-server/11-to-12 new file mode 100644 index 000000000..d38ba0367 --- /dev/null +++ b/src/migration-scripts/pppoe-server/11-to-12 @@ -0,0 +1,31 @@ +# Copyright 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/>. + +# Delete 'vpp-cp' option from interface settings +# because it will be set automatically (T8143) + +from vyos.configtree import ConfigTree + +base = ['service', 'pppoe-server'] + +def migrate(config: ConfigTree) -> None: + if not config.exists(base): + return + + for interface in config.list_nodes(base + ['interface']): + base_path = base + ['interface', interface] + # Delete vpp-cp option from PPPoE interface settings + if config.exists(base_path + ['vpp-cp']): + config.delete(base_path + ['vpp-cp']) diff --git a/src/migration-scripts/pppoe-server/2-to-3 b/src/migration-scripts/pppoe-server/2-to-3 index 160cffdf8..56dc032aa 100644 --- a/src/migration-scripts/pppoe-server/2-to-3 +++ b/src/migration-scripts/pppoe-server/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/pppoe-server/3-to-4 b/src/migration-scripts/pppoe-server/3-to-4 index 29dd62201..b0eb6f2b9 100644 --- a/src/migration-scripts/pppoe-server/3-to-4 +++ b/src/migration-scripts/pppoe-server/3-to-4 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/pppoe-server/4-to-5 b/src/migration-scripts/pppoe-server/4-to-5 index 03fbfb247..bb75b7e1f 100644 --- a/src/migration-scripts/pppoe-server/4-to-5 +++ b/src/migration-scripts/pppoe-server/4-to-5 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/pppoe-server/5-to-6 b/src/migration-scripts/pppoe-server/5-to-6 index 13de8f8d2..4616f6021 100644 --- a/src/migration-scripts/pppoe-server/5-to-6 +++ b/src/migration-scripts/pppoe-server/5-to-6 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/pppoe-server/6-to-7 b/src/migration-scripts/pppoe-server/6-to-7 index 79745a0c6..e764284da 100644 --- a/src/migration-scripts/pppoe-server/6-to-7 +++ b/src/migration-scripts/pppoe-server/6-to-7 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/pppoe-server/7-to-8 b/src/migration-scripts/pppoe-server/7-to-8 index 90e4fa053..9a26da9e6 100644 --- a/src/migration-scripts/pppoe-server/7-to-8 +++ b/src/migration-scripts/pppoe-server/7-to-8 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/pppoe-server/8-to-9 b/src/migration-scripts/pppoe-server/8-to-9 index e7e0aaa2c..0f10dafec 100644 --- a/src/migration-scripts/pppoe-server/8-to-9 +++ b/src/migration-scripts/pppoe-server/8-to-9 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/pppoe-server/9-to-10 b/src/migration-scripts/pppoe-server/9-to-10 index d3475e8ff..0a1931d81 100644 --- a/src/migration-scripts/pppoe-server/9-to-10 +++ b/src/migration-scripts/pppoe-server/9-to-10 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/pptp/0-to-1 b/src/migration-scripts/pptp/0-to-1 index dd0b6f57e..b0e4c3b65 100644 --- a/src/migration-scripts/pptp/0-to-1 +++ b/src/migration-scripts/pptp/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/pptp/1-to-2 b/src/migration-scripts/pptp/1-to-2 index 1e7601193..afeb6e3bb 100644 --- a/src/migration-scripts/pptp/1-to-2 +++ b/src/migration-scripts/pptp/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/pptp/2-to-3 b/src/migration-scripts/pptp/2-to-3 index 8b0d6d865..9f06aa1a7 100644 --- a/src/migration-scripts/pptp/2-to-3 +++ b/src/migration-scripts/pptp/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/pptp/3-to-4 b/src/migration-scripts/pptp/3-to-4 index 2dabd8475..3fe579aa9 100644 --- a/src/migration-scripts/pptp/3-to-4 +++ b/src/migration-scripts/pptp/3-to-4 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/pptp/4-to-5 b/src/migration-scripts/pptp/4-to-5 index c906f58c4..d8390cc66 100644 --- a/src/migration-scripts/pptp/4-to-5 +++ b/src/migration-scripts/pptp/4-to-5 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/qos/1-to-2 b/src/migration-scripts/qos/1-to-2 index c43d8fa47..dea1a77eb 100644 --- a/src/migration-scripts/qos/1-to-2 +++ b/src/migration-scripts/qos/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/qos/2-to-3 b/src/migration-scripts/qos/2-to-3 index 284fe828e..40fc7637f 100644 --- a/src/migration-scripts/qos/2-to-3 +++ b/src/migration-scripts/qos/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/quagga/10-to-11 b/src/migration-scripts/quagga/10-to-11 index 15dbbb193..08ea1a00e 100644 --- a/src/migration-scripts/quagga/10-to-11 +++ b/src/migration-scripts/quagga/10-to-11 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/quagga/11-to-12 b/src/migration-scripts/quagga/11-to-12 index 8ae2023a1..ae8cabc63 100644 --- a/src/migration-scripts/quagga/11-to-12 +++ b/src/migration-scripts/quagga/11-to-12 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -14,7 +14,7 @@ # along with this library. If not, see <http://www.gnu.org/licenses/>. # T6747: -# - Migrate static BFD configuration to match FRR possibillities +# - Migrate static BFD configuration to match FRR possibilities # - Consolidate static multicast routing configuration under a new node from vyos.configtree import ConfigTree @@ -23,7 +23,7 @@ static_base = ['protocols', 'static'] def migrate(config: ConfigTree) -> None: # Check for static route/route6 configuration - # Migrate static BFD configuration to match FRR possibillities + # Migrate static BFD configuration to match FRR possibilities for route_route6 in ['route', 'route6']: route_route6_base = static_base + [route_route6] if not config.exists(route_route6_base): diff --git a/src/migration-scripts/quagga/2-to-3 b/src/migration-scripts/quagga/2-to-3 index d62c387ba..2494abdae 100644 --- a/src/migration-scripts/quagga/2-to-3 +++ b/src/migration-scripts/quagga/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -15,6 +15,8 @@ from vyos.configtree import ConfigTree +# Just to avoid writing it so many times +af_path = ['address-family', 'ipv4-unicast'] def migrate_neighbor(config, neighbor_path, neighbor): if config.exists(neighbor_path): @@ -106,9 +108,6 @@ def migrate(config: ConfigTree) -> None: # Nothing to do return - # Just to avoid writing it so many times - af_path = ['address-family', 'ipv4-unicast'] - # Check if BGP is actually configured and obtain the ASN asn_list = config.list_nodes(['protocols', 'bgp']) if asn_list: diff --git a/src/migration-scripts/quagga/3-to-4 b/src/migration-scripts/quagga/3-to-4 index 81cf139f6..f801dcff1 100644 --- a/src/migration-scripts/quagga/3-to-4 +++ b/src/migration-scripts/quagga/3-to-4 @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/quagga/4-to-5 b/src/migration-scripts/quagga/4-to-5 index 27b995431..a7bb23d14 100644 --- a/src/migration-scripts/quagga/4-to-5 +++ b/src/migration-scripts/quagga/4-to-5 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/quagga/5-to-6 b/src/migration-scripts/quagga/5-to-6 index 08fd070de..5dcfa3cfb 100644 --- a/src/migration-scripts/quagga/5-to-6 +++ b/src/migration-scripts/quagga/5-to-6 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/quagga/6-to-7 b/src/migration-scripts/quagga/6-to-7 index 095baac03..4a28a47e7 100644 --- a/src/migration-scripts/quagga/6-to-7 +++ b/src/migration-scripts/quagga/6-to-7 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/quagga/7-to-8 b/src/migration-scripts/quagga/7-to-8 index d9de26d15..60afb87cf 100644 --- a/src/migration-scripts/quagga/7-to-8 +++ b/src/migration-scripts/quagga/7-to-8 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/quagga/8-to-9 b/src/migration-scripts/quagga/8-to-9 index eece6c15d..7eed424eb 100644 --- a/src/migration-scripts/quagga/8-to-9 +++ b/src/migration-scripts/quagga/8-to-9 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -16,13 +16,14 @@ # - T2450: drop interface-route and interface-route6 from "protocols static" from vyos.configtree import ConfigTree +from vyos.template import is_ip def migrate_interface_route(config, base, path, route_route6): """ Generic migration function which can be called on every instance of - interface-route, beeing it ipv4, ipv6 or nested under the "static table" nodes. + interface-route, being it ipv4, ipv6 or nested under the "static table" nodes. What we do? - - Drop 'interface-route' or 'interface-route6' and migrate the route unter the + - Drop 'interface-route' or 'interface-route6' and migrate the route under the 'route' or 'route6' tag node. """ if config.exists(base + path): @@ -31,17 +32,24 @@ def migrate_interface_route(config, base, path, route_route6): tmp = base + path + [route, 'next-hop-interface'] for interface in config.list_nodes(tmp): - new_base = base + [route_route6, route, 'interface'] - config.set(new_base) - config.set_tag(base + [route_route6]) - config.set_tag(new_base) - config.copy(tmp + [interface], new_base + [interface]) + if is_ip(interface): # not prohibited in 1.3.x, hence allowed + new_base = base + [route_route6, route, 'next-hop'] + config.set(new_base) + config.set_tag(base + [route_route6]) + config.set_tag(new_base) + config.copy(tmp + [interface], new_base + [interface]) + else: + new_base = base + [route_route6, route, 'interface'] + config.set(new_base) + config.set_tag(base + [route_route6]) + config.set_tag(new_base) + config.copy(tmp + [interface], new_base + [interface]) config.delete(base + path) def migrate_route(config, base, path, route_route6): """ Generic migration function which can be called on every instance of - route, beeing it ipv4, ipv6 or even nested under the static table nodes. + route, being it ipv4, ipv6 or even nested under the static table nodes. What we do? - for consistency reasons rename next-hop-interface to interface @@ -53,7 +61,7 @@ def migrate_route(config, base, path, route_route6): if config.exists(next_hop): for gateway in config.list_nodes(next_hop): # IPv4 routes calls it next-hop-interface, rename this to - # interface instead so it's consitent with IPv6 + # interface instead so it's consistent with IPv6 interface_path = next_hop + [gateway, 'next-hop-interface'] if config.exists(interface_path): config.rename(interface_path, 'interface') @@ -68,7 +76,7 @@ def migrate_route(config, base, path, route_route6): if config.exists(next_hop): for interface in config.list_nodes(next_hop): # IPv4 routes calls it next-hop-interface, rename this to - # interface instead so it's consitent with IPv6 + # interface instead so it's consistent with IPv6 interface_path = next_hop + [interface, 'next-hop-interface'] if config.exists(interface_path): config.rename(interface_path, 'interface') diff --git a/src/migration-scripts/quagga/9-to-10 b/src/migration-scripts/quagga/9-to-10 index 4ac1f0b7d..a2002bf88 100644 --- a/src/migration-scripts/quagga/9-to-10 +++ b/src/migration-scripts/quagga/9-to-10 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/reverse-proxy/0-to-1 b/src/migration-scripts/reverse-proxy/0-to-1 index b495474a6..b8c98ae99 100644 --- a/src/migration-scripts/reverse-proxy/0-to-1 +++ b/src/migration-scripts/reverse-proxy/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/reverse-proxy/1-to-2 b/src/migration-scripts/reverse-proxy/1-to-2 index 61612bc36..4b72107b1 100755 --- a/src/migration-scripts/reverse-proxy/1-to-2 +++ b/src/migration-scripts/reverse-proxy/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/reverse-proxy/2-to-3 b/src/migration-scripts/reverse-proxy/2-to-3 new file mode 100755 index 000000000..bf3403d11 --- /dev/null +++ b/src/migration-scripts/reverse-proxy/2-to-3 @@ -0,0 +1,66 @@ +# Copyright 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/>. + +# T7429: logging facility "all" unavailable in code + +from vyos.configtree import ConfigTree + +base = ['load-balancing', 'haproxy'] +unsupported_facilities = ['all', 'authpriv', 'mark'] + +def config_migrator(config, config_path: list) -> None: + if not config.exists(config_path): + return + # Remove unsupported backend HAProxy syslog facilities form CLI + # Works for both backend and service CLI nodes + for service_backend in config.list_nodes(config_path): + log_path = config_path + [service_backend, 'logging', 'facility'] + if not config.exists(log_path): + continue + # Remove unsupported syslog facilities form CLI + for facility in config.list_nodes(log_path): + if facility in unsupported_facilities: + config.delete(log_path + [facility]) + continue + # Remove unsupported facility log level form CLI. VyOS will fallback + # to default log level if not set + if config.exists(log_path + [facility, 'level']): + tmp = config.return_value(log_path + [facility, 'level']) + if tmp == 'all': + config.delete(log_path + [facility, 'level']) + +def migrate(config: ConfigTree) -> None: + if not config.exists(base): + # Nothing to do + return + + # Remove unsupported syslog facilities form CLI + global_path = base + ['global-parameters', 'logging', 'facility'] + if config.exists(global_path): + for facility in config.list_nodes(global_path): + if facility in unsupported_facilities: + config.delete(global_path + [facility]) + continue + # Remove unsupported facility log level form CLI. VyOS will fallback + # to default log level if not set + if config.exists(global_path + [facility, 'level']): + tmp = config.return_value(global_path + [facility, 'level']) + if tmp == 'all': + config.delete(global_path + [facility, 'level']) + + # Remove unsupported backend HAProxy syslog facilities from CLI + config_migrator(config, base + ['backend']) + # Remove unsupported service HAProxy syslog facilities from CLI + config_migrator(config, base + ['service']) diff --git a/src/migration-scripts/rip/0-to-1 b/src/migration-scripts/rip/0-to-1 index 6d41bcf58..4fbd88cfe 100644 --- a/src/migration-scripts/rip/0-to-1 +++ b/src/migration-scripts/rip/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/rpki/0-to-1 b/src/migration-scripts/rpki/0-to-1 index b6e781fa9..2263489a0 100644 --- a/src/migration-scripts/rpki/0-to-1 +++ b/src/migration-scripts/rpki/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/rpki/1-to-2 b/src/migration-scripts/rpki/1-to-2 index 855236d6c..42447de66 100644 --- a/src/migration-scripts/rpki/1-to-2 +++ b/src/migration-scripts/rpki/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/salt/0-to-1 b/src/migration-scripts/salt/0-to-1 index 3990a88dc..05f6476ce 100644 --- a/src/migration-scripts/salt/0-to-1 +++ b/src/migration-scripts/salt/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/snmp/0-to-1 b/src/migration-scripts/snmp/0-to-1 index 03b190cb7..ee334cdc7 100644 --- a/src/migration-scripts/snmp/0-to-1 +++ b/src/migration-scripts/snmp/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/snmp/1-to-2 b/src/migration-scripts/snmp/1-to-2 index 0120f8acb..0d1b0aa21 100644 --- a/src/migration-scripts/snmp/1-to-2 +++ b/src/migration-scripts/snmp/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -16,7 +16,7 @@ from vyos.configtree import ConfigTree # We no longer support hashed values prefixed with '0x' to unclutter -# CLI and also calculate the hases in advance instead of retrieving +# CLI and also calculate the hashes in advance instead of retrieving # them after service startup - which was always a bad idea prefix = '0x' diff --git a/src/migration-scripts/snmp/2-to-3 b/src/migration-scripts/snmp/2-to-3 index 6d828b619..2cacea007 100644 --- a/src/migration-scripts/snmp/2-to-3 +++ b/src/migration-scripts/snmp/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -13,7 +13,7 @@ # 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/>. -# T4857: Implement FRR SNMP recomendations +# T4857: Implement FRR SNMP recommendations # cli changes from: # set service snmp oid-enable route-table # To diff --git a/src/migration-scripts/ssh/0-to-1 b/src/migration-scripts/ssh/0-to-1 index 65b68f509..4acdd4ec3 100644 --- a/src/migration-scripts/ssh/0-to-1 +++ b/src/migration-scripts/ssh/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/ssh/1-to-2 b/src/migration-scripts/ssh/1-to-2 index b601db3b4..7dec65e58 100644 --- a/src/migration-scripts/ssh/1-to-2 +++ b/src/migration-scripts/ssh/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/ssh/2-to-3 b/src/migration-scripts/ssh/2-to-3 new file mode 100644 index 000000000..a3665af1e --- /dev/null +++ b/src/migration-scripts/ssh/2-to-3 @@ -0,0 +1,43 @@ +# Copyright 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/>. + +# T8098: rijndael-cbc@lysator.liu.se was removed in OpenSSH 6.7 which is used +# starting with VyOS 1.4 - It is an alias for aes256-cbc which was +# standardized in RFC4253, adjust CLI accordingly. +# https://github.com/openssh/openssh-portable/commit/03e93c753d7c223063a +# Also rename "ciphers" -> "cipher" to follow our CLI guidelines to use +# singular when possible + +from vyos.configtree import ConfigTree + +base = ['service', 'ssh'] + +old_path = base + ['ciphers'] +new_path = base + ['cipher'] + +def migrate(config: ConfigTree) -> None: + if not config.exists(base): + # Nothing to do + return + + if config.exists(old_path): + config.rename(old_path, new_path[-1]) + + if config.exists(new_path): + deprecated_cipher = 'rijndael-cbc@lysator.liu.se' + for cipher in config.return_values(new_path): + if cipher == deprecated_cipher: + config.delete_value(new_path, value=deprecated_cipher) + config.set(new_path, value='aes256-cbc', replace=False) diff --git a/src/migration-scripts/sstp/0-to-1 b/src/migration-scripts/sstp/0-to-1 index 1bd7d6c6b..f5d5c4715 100644 --- a/src/migration-scripts/sstp/0-to-1 +++ b/src/migration-scripts/sstp/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -15,7 +15,7 @@ # - migrate from "service sstp-server" to "vpn sstp" # - remove primary/secondary identifier from nameserver -# - migrate RADIUS configuration to a more uniform syntax accross the system +# - migrate RADIUS configuration to a more uniform syntax across the system # - authentication radius-server x.x.x.x to authentication radius server x.x.x.x # - authentication radius-settings to authentication radius # - do not migrate radius server req-limit, use default of unlimited diff --git a/src/migration-scripts/sstp/1-to-2 b/src/migration-scripts/sstp/1-to-2 index 2349e3c9f..7c127cc93 100644 --- a/src/migration-scripts/sstp/1-to-2 +++ b/src/migration-scripts/sstp/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/sstp/2-to-3 b/src/migration-scripts/sstp/2-to-3 index 4255a896e..db752ad32 100644 --- a/src/migration-scripts/sstp/2-to-3 +++ b/src/migration-scripts/sstp/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/sstp/3-to-4 b/src/migration-scripts/sstp/3-to-4 index fd10985de..dede5e87a 100644 --- a/src/migration-scripts/sstp/3-to-4 +++ b/src/migration-scripts/sstp/3-to-4 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/sstp/4-to-5 b/src/migration-scripts/sstp/4-to-5 index 254e828af..74410006b 100644 --- a/src/migration-scripts/sstp/4-to-5 +++ b/src/migration-scripts/sstp/4-to-5 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/sstp/5-to-6 b/src/migration-scripts/sstp/5-to-6 index fc3cc29b2..9e3ad609d 100644 --- a/src/migration-scripts/sstp/5-to-6 +++ b/src/migration-scripts/sstp/5-to-6 @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/system/10-to-11 b/src/migration-scripts/system/10-to-11 index 76d7f23cb..68ff6869c 100644 --- a/src/migration-scripts/system/10-to-11 +++ b/src/migration-scripts/system/10-to-11 @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/system/11-to-12 b/src/migration-scripts/system/11-to-12 index 71c359b7e..c71d6eb7e 100644 --- a/src/migration-scripts/system/11-to-12 +++ b/src/migration-scripts/system/11-to-12 @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/system/12-to-13 b/src/migration-scripts/system/12-to-13 index 014edba91..b0c1fd8ea 100644 --- a/src/migration-scripts/system/12-to-13 +++ b/src/migration-scripts/system/12-to-13 @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/system/13-to-14 b/src/migration-scripts/system/13-to-14 index fbbecbcd3..0c2480bdb 100644 --- a/src/migration-scripts/system/13-to-14 +++ b/src/migration-scripts/system/13-to-14 @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/system/14-to-15 b/src/migration-scripts/system/14-to-15 index 281809460..ea1370365 100644 --- a/src/migration-scripts/system/14-to-15 +++ b/src/migration-scripts/system/14-to-15 @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/system/15-to-16 b/src/migration-scripts/system/15-to-16 index 7db042930..55642d5d1 100644 --- a/src/migration-scripts/system/15-to-16 +++ b/src/migration-scripts/system/15-to-16 @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/system/16-to-17 b/src/migration-scripts/system/16-to-17 index 9fb86af88..5b0c195a1 100644 --- a/src/migration-scripts/system/16-to-17 +++ b/src/migration-scripts/system/16-to-17 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/system/17-to-18 b/src/migration-scripts/system/17-to-18 index 323ef4e65..e0dae4b3b 100644 --- a/src/migration-scripts/system/17-to-18 +++ b/src/migration-scripts/system/17-to-18 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/system/18-to-19 b/src/migration-scripts/system/18-to-19 index 5d9788d70..121706fe5 100644 --- a/src/migration-scripts/system/18-to-19 +++ b/src/migration-scripts/system/18-to-19 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/system/19-to-20 b/src/migration-scripts/system/19-to-20 index cb84e11fc..dd0d2cad1 100644 --- a/src/migration-scripts/system/19-to-20 +++ b/src/migration-scripts/system/19-to-20 @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/system/20-to-21 b/src/migration-scripts/system/20-to-21 index 71c283da6..96863290a 100644 --- a/src/migration-scripts/system/20-to-21 +++ b/src/migration-scripts/system/20-to-21 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/system/21-to-22 b/src/migration-scripts/system/21-to-22 index 0e68a6856..fc414c70b 100644 --- a/src/migration-scripts/system/21-to-22 +++ b/src/migration-scripts/system/21-to-22 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/system/22-to-23 b/src/migration-scripts/system/22-to-23 index e49094e4a..177a206e4 100644 --- a/src/migration-scripts/system/22-to-23 +++ b/src/migration-scripts/system/22-to-23 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -22,7 +22,7 @@ def migrate(config: ConfigTree) -> None: # Nothing to do return - # T4346: drop support to disbale IPv6 address family within the OS Kernel + # T4346: drop support to disable IPv6 address family within the OS Kernel if config.exists(base + ['disable']): config.delete(base + ['disable']) # IPv6 address family disable was the only CLI option set - we can cleanup diff --git a/src/migration-scripts/system/23-to-24 b/src/migration-scripts/system/23-to-24 index feb62bc32..0efd54de7 100644 --- a/src/migration-scripts/system/23-to-24 +++ b/src/migration-scripts/system/23-to-24 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -43,7 +43,7 @@ def migrate(config: ConfigTree) -> None: return # We need a temporary copy of the config tree as the original one needs to be - # deleted first due to a change iun thge tagNode structure. + # deleted first due to a change in the tagNode structure. config.copy(base, tmp_base) config.delete(base) diff --git a/src/migration-scripts/system/24-to-25 b/src/migration-scripts/system/24-to-25 index bdb89902e..d0386f0a7 100644 --- a/src/migration-scripts/system/24-to-25 +++ b/src/migration-scripts/system/24-to-25 @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/system/25-to-26 b/src/migration-scripts/system/25-to-26 index 8832f48e5..ae70b174c 100644 --- a/src/migration-scripts/system/25-to-26 +++ b/src/migration-scripts/system/25-to-26 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/system/26-to-27 b/src/migration-scripts/system/26-to-27 index 499e16e08..dc2bcd55e 100644 --- a/src/migration-scripts/system/26-to-27 +++ b/src/migration-scripts/system/26-to-27 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/system/27-to-28 b/src/migration-scripts/system/27-to-28 index 0a5be48ab..896165af4 100644 --- a/src/migration-scripts/system/27-to-28 +++ b/src/migration-scripts/system/27-to-28 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/system/28-to-29 b/src/migration-scripts/system/28-to-29 index ccf7056c4..7d912b612 100644 --- a/src/migration-scripts/system/28-to-29 +++ b/src/migration-scripts/system/28-to-29 @@ -1,4 +1,4 @@ -# Copyright 2025 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -18,6 +18,16 @@ # - remove syslog user console logging # - move "global preserve-fqdn" one CLI level up # - rename "host" to "remote" +# +# T8059: +# - if a syslog remote contains a port (like 192.0.2.1:9000), +# migrate the port part to the new dedicated "port" option +# +# XXX: this script leads to data loss when a config +# has multiple remotes with the same host address but different ports. +# That issue needs to be addressed separately (T8058) + +import re from vyos.configtree import ConfigTree @@ -64,8 +74,35 @@ def migrate(config: ConfigTree) -> None: config.set(base + ['remote']) config.set_tag(base + ['remote']) for remote in config.list_nodes(base + ['host']): - config.copy(base + ['host', remote], base + ['remote', remote]) - config.set_tag(base + ['remote']) - if vrf: - config.set(base + ['remote', remote, 'vrf'], value=vrf) + # Check if the host address has a port + # to migrate the port part to the new dedicated "port" option + res = re.match(r'(?P<host>[^:]+):(?P<port>.*)', remote) + if res: + remote_host = res.group('host') + remote_port = res.group('port') + else: + remote_host = remote + remote_port = None + + # XXX: the fact that it was possible to use node names like "192.0.2.1:9000" + # made it possible to create configurations that would send different messages + # to different ports on the same server. + # At the moment, such configurations are unsupported + # so the script only keeps one of such remotes. + if config.exists(base + ['remote', remote_host]): + # Skip the remote if it already exists + # XXX: This tacitly implies that if multiple remotes + # with the same address but different ports are used, + # only the first one of those makes it into the migrated config. + continue + else: + config.copy(base + ['host', remote], base + ['remote', remote_host]) + + if remote_port: + config.set(base + ['remote', remote_host, 'port'], value=remote_port) + + config.set_tag(base + ['remote']) + if vrf: + config.set(base + ['remote', remote_host, 'vrf'], value=vrf) + config.delete(base + ['host']) diff --git a/src/migration-scripts/system/29-to-30 b/src/migration-scripts/system/29-to-30 new file mode 100644 index 000000000..7babde3f3 --- /dev/null +++ b/src/migration-scripts/system/29-to-30 @@ -0,0 +1,52 @@ +# Copyright 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/>. + +# T4251: +# - drop "tls enable" node (make "tls" a standalone key) +# - split "tls permitted-peers" list by commas into multiple "tls permitted-peer" entries + +from vyos.configtree import ConfigTree + +base = ['system', 'syslog', 'remote'] + + +def migrate(config: ConfigTree) -> None: + if not config.exists(base): + return + + # Iterate over all remote syslog server entries (like 172.18.0.5) + for remote_addr in config.list_nodes(base): + remote_base = base + [remote_addr] + tls_base = remote_base + ['tls'] + + # (1) Remove "tls enable" -> migrate to simple "tls" + enable_path = tls_base + ['enable'] + if config.exists(enable_path): + # Remove obsolete "enable" node + config.delete(enable_path) + + # (2) Split "tls permitted-peers" (comma-separated string) + permitted_peers_path = tls_base + ['permitted-peers'] + if config.exists(permitted_peers_path): + peers_str = config.return_value(permitted_peers_path) + # Split CSV values and normalize whitespace + peers = [p.strip() for p in peers_str.split(',') if p.strip()] + + # Create a new "permitted-peer" entry per item + for peer in peers: + config.set(tls_base + ['permitted-peer'], value=peer, replace=False) + + # Remove the old combined node + config.delete(permitted_peers_path) diff --git a/src/migration-scripts/system/30-to-31 b/src/migration-scripts/system/30-to-31 new file mode 100644 index 000000000..3e57b3db6 --- /dev/null +++ b/src/migration-scripts/system/30-to-31 @@ -0,0 +1,42 @@ +# Copyright 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/>. + +# T7644: +# FRR 10.5 doesn't have ip protocols: +# - connected +# - kernel +# - table +# +# Remove them from +# - system ip protocol +# - system ipv6 protocol + +from vyos.configtree import ConfigTree + +bases = [ + ['system', 'ip', 'protocol'], + ['system', 'ipv6', 'protocol'], +] +remove_ip_protocols = ['connected', 'kernel', 'table'] + +def migrate(config: ConfigTree) -> None: + for base in bases: + if not config.exists(base): + continue + + # Iterate over all ip protocols to remove + for protocol in remove_ip_protocols: + if config.exists(base + [protocol]): + config.delete(base + [protocol]) diff --git a/src/migration-scripts/system/31-to-32 b/src/migration-scripts/system/31-to-32 new file mode 100644 index 000000000..1688b44ed --- /dev/null +++ b/src/migration-scripts/system/31-to-32 @@ -0,0 +1,38 @@ +# Copyright (C) VyOS Inc. +# +# 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/>. + +# Serial + +from vyos.configtree import ConfigTree +from vyos.system import disk +from vyos.system.grub import CFG_VYOS_VARS +from vyos.system.grub import vars_read + +serial_console = 'ttyS0' +base = ['system', 'console'] + +def migrate(config: ConfigTree) -> None: + if not base: + return + + root_dir = disk.find_persistence() + vars_file: str = f'{root_dir}/{CFG_VYOS_VARS}' + vars_current: dict[str, str] = vars_read(vars_file) + # Check if VyOS installation uses serial boot console + if vars_current['console_type'] == 'ttyS': + # In the past we only supported ttyS0 as boot console, that's why we + # can hardcode it ... + if config.exists(base + ['device', serial_console]): + config.set(base + ['device', serial_console, 'kernel']) diff --git a/src/migration-scripts/system/6-to-7 b/src/migration-scripts/system/6-to-7 index e91ccc4e9..01d214986 100644 --- a/src/migration-scripts/system/6-to-7 +++ b/src/migration-scripts/system/6-to-7 @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/system/7-to-8 b/src/migration-scripts/system/7-to-8 index 64dd4dc93..4e7f8c0e6 100644 --- a/src/migration-scripts/system/7-to-8 +++ b/src/migration-scripts/system/7-to-8 @@ -1,4 +1,4 @@ -# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/system/8-to-9 b/src/migration-scripts/system/8-to-9 index ea5f7af81..f1be35126 100644 --- a/src/migration-scripts/system/8-to-9 +++ b/src/migration-scripts/system/8-to-9 @@ -1,4 +1,4 @@ -# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/vpp/1-to-2 b/src/migration-scripts/vpp/1-to-2 new file mode 100644 index 000000000..822799cc6 --- /dev/null +++ b/src/migration-scripts/vpp/1-to-2 @@ -0,0 +1,34 @@ +# Copyright 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/>. + +# Delete 'vpp settings interface ethX xdp-options no-syscall-lock' +# since it is set automatically + +from vyos.configtree import ConfigTree + +base = ['vpp', 'settings', 'interface'] + +def migrate(config: ConfigTree) -> None: + if not config.exists(base): + # Nothing to do + return + + for iface_name in config.list_nodes(base): + xdp_options_base = base + [iface_name, 'xdp-options'] + if config.exists(xdp_options_base + ['no-syscall-lock']): + # Delete no-syscall-lock option from configuration + config.delete(xdp_options_base + ['no-syscall-lock']) + if config.exists(xdp_options_base) and len(config.list_nodes(xdp_options_base)) == 0: + config.delete(xdp_options_base) diff --git a/src/migration-scripts/vpp/2-to-3 b/src/migration-scripts/vpp/2-to-3 new file mode 100644 index 000000000..655862546 --- /dev/null +++ b/src/migration-scripts/vpp/2-to-3 @@ -0,0 +1,30 @@ +# Copyright 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/>. + +# Remove "vpp sflow sample-rate" since it should be automatically inherited +# from "system sflow" to prevent conflicts + +from vyos.configtree import ConfigTree + +base = ['vpp', 'sflow'] + +def migrate(config: ConfigTree) -> None: + if not config.exists(base): + # Nothing to do + return + + if config.exists(base + ['sample-rate']): + # Delete sample-rate option from sFlow configuration + config.delete(base + ['sample-rate']) diff --git a/src/migration-scripts/vpp/3-to-4 b/src/migration-scripts/vpp/3-to-4 new file mode 100644 index 000000000..8c79299f5 --- /dev/null +++ b/src/migration-scripts/vpp/3-to-4 @@ -0,0 +1,30 @@ +# Copyright 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/>. + +# Delete 'vpp settings nat44 no-forwarding' +# because it will be set automatically (T7972) + + +from vyos.configtree import ConfigTree + +base = ['vpp', 'settings', 'nat44'] + +def migrate(config: ConfigTree) -> None: + + if config.exists(base + ['no-forwarding']): + # Delete no-forwarding option from NAT44 settings + config.delete(base + ['no-forwarding']) + if config.exists(base) and len(config.list_nodes(base)) == 0: + config.delete(base) diff --git a/src/migration-scripts/vpp/4-to-5 b/src/migration-scripts/vpp/4-to-5 new file mode 100644 index 000000000..4eaedc9a1 --- /dev/null +++ b/src/migration-scripts/vpp/4-to-5 @@ -0,0 +1,35 @@ +# Copyright 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/>. + +# Delete node 'driver' and 'xdp-options' from CLI (T8202) + + +from vyos.configtree import ConfigTree + +base = ['vpp', 'settings', 'interface'] + +def migrate(config: ConfigTree) -> None: + if not config.exists(base): + # Nothing to do + return + + for iface_name in config.list_nodes(base): + base_driver = base + [iface_name, 'driver'] + base_xdp_options = base + [iface_name, 'xdp-options'] + if config.exists(base_driver): + # Delete 'driver' node + config.delete(base_driver) + if config.exists(base_xdp_options): + config.delete(base_xdp_options) diff --git a/src/migration-scripts/vpp/5-to-6 b/src/migration-scripts/vpp/5-to-6 new file mode 100644 index 000000000..cf6812446 --- /dev/null +++ b/src/migration-scripts/vpp/5-to-6 @@ -0,0 +1,593 @@ +# Copyright 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/>. +# +# Migrate 'tcp-flags' to 'tcp-flags is-set' and 'tcp-flags is-not-set' multi-value nodes (T8250) +# +# Rename acl 'macip' node to 'mac' (T8252) +# +# Rename `vpp settings logging default-log-level` to +# `vpp settings logging default-level` (T8255) +# +# Move 'vpp nat44' and 'vpp settings nat44' to 'vpp nat nat44'. +# Drop settings for nat workers (T8254) +# +# Delete 'ipsec' node and all settings and replace it with single 'ipsec-acceleration' flag (T8262) +# +# Unify CPU settings into a single 'cpu-cores' node under 'resource-allocation' (T8268) +# +# Convert `vpp settings interface <name> rx-mode` and `vpp kernel-interfaces <name> rx-mode` +# to `vpp settings interfaces-rx-mode` by choose the worst value from all nodes (T8266) +# +# Get rid of 'dpdk-options' section for 'num-*' parameters: +# - `vpp settings interface <ethN> dpdk-options num-*` +# - `vpp settings interface <ethN> num-*` +# and delete `vpp settings interface <ethN> dpdk-options promisc` (T8274) +# +# Migrate all resource settings into 'resource-allocation' section (T8261) +# +# Move bonding interface from vpp section to 'interfaces vpp bonding' (T8283) +# +# Move vxlan interface from vpp section to 'interfaces vpp vxlan' (T8296) +# +# Move ipip interface from vpp section to 'interfaces vpp ipip' (T8314) +# +# Migrate loopback interface from vpp section to 'interfaces vpp loopback' (T8324) +# +# Migrate gre interface to 'interfaces vpp gre', remove interfaces with mode "point-to-multipoint" (T8325) +# +# Migrate bridge interface from vpp section to 'interfaces vpp bridge' (T8327) +# +# Migrate xconnect interface from vpp section to 'interfaces vpp xconnect' (T8328) +# +# Remove vif option from vxlan interface (T8340) + +from vyos.configtree import ConfigTree + +def _migrate_vpp_acl_tcp_flags(config: ConfigTree) -> None: + base = ['vpp', 'acl', 'ip', 'tag-name'] + + if not config.exists(base): + # Nothing to do + return + + for tag_name in config.list_nodes(base): + base_tag = base + [tag_name, 'rule'] + for rule in config.list_nodes(base_tag): + base_tcp_flags = base_tag + [rule, 'tcp-flags'] + + if not config.exists(base_tcp_flags): + return + + flags = config.list_nodes(base_tcp_flags) + set_flags = [flag for flag in flags if flag != 'not'] + not_set_flags = config.list_nodes(base_tcp_flags + ['not']) + + config.delete(base_tcp_flags) + + if set_flags: + for flag in set_flags: + config.set(base_tcp_flags + ['is-set'], value=flag, replace=False) + + if not_set_flags: + for flag in not_set_flags: + config.set(base_tcp_flags + ['is-not-set'], value=flag, replace=False) + + +def _migrate_vpp_macip(config: ConfigTree) -> None: + base = ['vpp', 'acl'] + if config.exists(base + ['macip']): + config.rename(base + ['macip'], 'mac') + + +def _migrate_vpp_unix_settings(config: ConfigTree) -> None: + base_path = ['vpp', 'settings'] + old_base_path = base_path + ['unix'] + old_path = old_base_path + ['poll-sleep-usec'] + new_path = base_path + ['poll-sleep-usec'] + + if config.exists(old_path): + poll_sleep_usec = config.return_value(old_path) + config.set(new_path, value=poll_sleep_usec) + config.delete(old_base_path) + + +def _migrate_vpp_log(config: ConfigTree) -> None: + base = ['vpp', 'settings', 'logging'] + if config.exists(base + ['default-log-level']): + config.rename(base + ['default-log-level'], 'default-level') + + +def _migrate_vpp_nat44(config: ConfigTree) -> None: + base_path = ['vpp', 'nat44'] + new_base_path = ['vpp', 'nat', 'nat44'] + settings_path = ['vpp', 'settings', 'nat44'] + + if not config.exists(base_path): + # Nothing to do + return + + if not config.exists(['vpp', 'nat']): + config.set(['vpp', 'nat']) + + # copy "vpp nat44" to "vpp nat nat44" + config.copy(base_path, new_base_path) + config.delete(base_path) + + # move 'vpp settings nat44' to 'vpp nat nat44' + if config.exists(settings_path): + if config.exists(settings_path + ['session-limit']): + session_limit = config.return_value(settings_path + ['session-limit']) + config.set(new_base_path + ['session-limit'], value=session_limit) + if config.exists(settings_path + ['timeout']): + config.copy(settings_path + ['timeout'], new_base_path + ['timeout']) + config.delete(settings_path) + + +def _migrate_vpp_ipsec(config: ConfigTree) -> None: + base = ['vpp', 'settings'] + + if config.exists(base + ['ipsec']): + config.set(base + ['ipsec-acceleration']) + config.delete(base + ['ipsec']) + + +def _migrate_vpp_cpu(config: ConfigTree) -> None: + settings_path = ['vpp', 'settings'] + cpu_path = settings_path + ['cpu'] + + if not config.exists(cpu_path): + # Nothing to do + return + + # get number of configured workers + workers = 0 + + if config.exists(cpu_path + ['workers']): + workers += int(config.return_value(cpu_path + ['workers'])) + # add main-core to total workers + workers += 1 + + if config.exists(cpu_path + ['corelist-workers']): + def _count_range(item: str) -> int: + if '-' in item: + start, end = map(int, item.split('-')) + return end - start + 1 + return 1 + + tmp = config.return_values(cpu_path + ['corelist-workers']) + workers = sum(_count_range(item) for item in tmp) + 1 # + main core + + # set 'resource-allocation cpu-cores' + if workers: + config.set(settings_path + ['resource-allocation', 'cpu-cores'], value=str(workers)) + + config.delete(cpu_path) + + +def _migrate_vpp_interface_rx_mode(config: ConfigTree) -> None: + # Per-interface rx-mode commands + external_interface_path = ['vpp', 'settings', 'interface'] + kernel_interface_path = ['vpp', 'kernel-interfaces'] + + # New global interface rx-mode command + new_rx_mode_path = ['vpp', 'settings', 'interface-rx-mode'] + + def _iter_by_rx_modes(base_path: list): + if config.exists(base_path): + for iface_name in config.list_nodes(base_path): + rx_mode_path = base_path + [iface_name, 'rx-mode'] + if config.exists(rx_mode_path): + rx_mode = config.return_value(rx_mode_path) + yield rx_mode, rx_mode_path + + rx_modes = set() + + def _collect_rx_modes(base_path: list): + for rx_mode, _ in _iter_by_rx_modes(base_path): + rx_modes.add(rx_mode) + + _collect_rx_modes(external_interface_path) + _collect_rx_modes(kernel_interface_path) + + if not rx_modes: + return + + if 'interrupt' in rx_modes: + rx_mode = 'interrupt' + elif 'adaptive' in rx_modes: + rx_mode = 'adaptive' + else: + rx_mode = 'polling' + + config.set(new_rx_mode_path, value=rx_mode) + + def _delete_rx_modes(base_path: list): + for _, rx_mode_path in _iter_by_rx_modes(base_path): + config.delete(rx_mode_path) + + _delete_rx_modes(external_interface_path) + _delete_rx_modes(kernel_interface_path) + + +def _migrate_vpp_dpdk_options(config: ConfigTree) -> None: + base_path = ['vpp', 'settings', 'interface'] + params = ['num-rx-desc', 'num-rx-queues', 'num-tx-desc', 'num-tx-queues'] + + if not config.exists(base_path): + return + + for iface_name in config.list_nodes(base_path): + iface_path = base_path + [iface_name] + dpdk_path = iface_path + ['dpdk-options'] + + if config.exists(dpdk_path): + for param in params: + param_path = dpdk_path + [param] + new_param_path = iface_path + [param] + + if config.exists(param_path): + # Move `vpp settings interface <iface_name> dpdk-options num-*` + # to `vpp settings interface <iface_name> num-*` + config.copy(param_path, new_param_path) + config.delete(param_path) + + # Delete `vpp settings interface <iface_name> dpdk-options promisc` + promisc_path = dpdk_path + ['promisc'] + if config.exists(promisc_path): + config.delete(promisc_path) + + +def _migrate_vpp_resources(config: ConfigTree) -> None: + base = ['vpp', 'settings'] + new_base = ['vpp', 'settings', 'resource-allocation'] + + if not config.exists(new_base): + config.set(new_base) + + if config.exists(base + ['buffers']): + # copy "settings buffers" to "settings resource-allocation buffers" + config.copy(base + ['buffers'], new_base + ['buffers']) + config.delete(base + ['buffers']) + + if config.exists(base + ['memory']): + # copy "settings memory" to "settings resource-allocation memory" + config.copy(base + ['memory'], new_base + ['memory']) + config.delete(base + ['memory']) + + if config.exists(base + ['statseg']): + # copy "settings statseg" to "settings resource-allocation memory stats" + if not config.exists(new_base + ['memory']): + config.set(new_base + ['memory']) + config.copy(base + ['statseg'], new_base + ['memory', 'stats']) + config.delete(base + ['statseg']) + + if config.exists(base + ['ipv6']): + # copy "settings ipv6" to "settings resource-allocation ipv6" + config.copy(base + ['ipv6'], new_base + ['ipv6']) + config.delete(base + ['ipv6']) + + if config.exists(base + ['lcp']): + # move "settings lcp ignore-kernel-routes" to "settings resource-allocation ignore-kernel-routes" + # and remove "settings lcp netlink" node + if config.exists(base + ['lcp', 'ignore-kernel-routes']): + config.set(new_base + ['ignore-kernel-routes']) + config.delete(base + ['lcp']) + + if config.exists(base + ['l2learn']): + # move "settings l2learn limit" to "settings resource-allocation mac-limit" + if config.exists(base + ['l2learn', 'limit']): + tmp = config.return_value(base + ['l2learn', 'limit']) + config.set(new_base + ['mac-limit'], value=tmp) + config.delete(base + ['l2learn']) + + if config.exists(base + ['physmem']): + # move "settings physmem max-size" to "settings resource-allocation memory physmem-max-size" + if config.exists(base + ['physmem', 'max-size']): + tmp = config.return_value(base + ['physmem', 'max-size']) + config.set(new_base + ['memory', 'physmem-max-size'], value=tmp) + config.delete(base + ['physmem']) + + if len(config.list_nodes(new_base)) == 0: + config.delete(new_base) + + +def _migrate_interface(config, type, ifname): + base = ['vpp', 'interfaces', type] + new_base = ['interfaces', 'vpp', type] + kernel_interface_base = ['vpp', 'kernel-interfaces'] + + kernel_interface_path = base + [ifname, 'kernel-interface'] + kernel_iface = None + if config.exists(kernel_interface_path): + kernel_iface = config.return_value(kernel_interface_path) + config.delete(kernel_interface_path) + new_ifname = f'vpp{ifname}' + iface_base = new_base + [new_ifname] + config.copy(base + [ifname], iface_base) + config.set_tag(new_base) + + if kernel_iface and config.exists(kernel_interface_base + [kernel_iface]): + if config.exists(kernel_interface_base + [kernel_iface, 'vif']): + config.copy(kernel_interface_base + [kernel_iface, 'vif'], iface_base + ['vif']) + if config.exists(kernel_interface_base + [kernel_iface, 'mtu']): + config.copy(kernel_interface_base + [kernel_iface, 'mtu'], iface_base + ['mtu']) + if config.exists(kernel_interface_base + [kernel_iface, 'address']): + config.copy(kernel_interface_base + [kernel_iface, 'address'], iface_base + ['address']) + config.delete(kernel_interface_base + [kernel_iface]) + + +def _migrate_members_bridge(config, ifname): + bridge_path = ['vpp', 'interfaces', 'bridge'] + if config.exists(bridge_path): + for iface in config.list_nodes(bridge_path): + tmp = bridge_path + [iface, 'member', 'interface'] + if config.exists(tmp): + for name in config.list_nodes(tmp): + if name == ifname: + new_name = f'vpp{name}' + config.rename(tmp + [name], new_name) + + +def _migrate_members_xconnect(config, ifname): + xconnect_path = ['vpp', 'interfaces', 'xconnect'] + if config.exists(xconnect_path): + for iface in config.list_nodes(xconnect_path): + tmp = xconnect_path + [iface, 'member', 'interface'] + if config.exists(tmp): + for name in config.return_values(tmp): + if name == ifname: + new_name = f'vpp{name}' + config.delete_value(tmp, name) + config.set(tmp, value=new_name, replace=False) + +def _migrate_vpp_bonding_interface(config: ConfigTree) -> None: + base = ['vpp', 'interfaces', 'bonding'] + new_base = ['interfaces', 'vpp', 'bonding'] + kernel_interface_base = ['vpp', 'kernel-interfaces'] + + if not config.exists(base): + return + + config.set(new_base) + + for ifname in config.list_nodes(base): + _migrate_interface(config, 'bonding', ifname) + + for feature in ['nat44', 'cgnat']: + for direction in ['inside', 'outside']: + tmp_path = ['vpp', 'nat', feature, 'interface', direction] + + if not config.exists(tmp_path): + continue + + names = config.return_values(tmp_path) + for name in names: + if name.split('.')[0] == ifname: + new_name = f'vpp{name}' + config.delete_value(tmp_path, name) + config.set(tmp_path, value=new_name, replace=False) + + ipfix_path = ['vpp', 'ipfix', 'interface'] + if config.exists(ipfix_path): + for name in config.list_nodes(ipfix_path): + if name.split('.')[0] == ifname: + new_name = f'vpp{name}' + config.rename(ipfix_path + [name], new_name) + + _migrate_members_bridge(config, ifname) + + config.delete(base) + + if config.exists(kernel_interface_base) and len(config.list_nodes(kernel_interface_base)) == 0: + config.delete(['vpp', 'kernel-interfaces']) + + if len(config.list_nodes(['vpp', 'interfaces'])) == 0: + config.delete(['vpp', 'interfaces']) + + +def _migrate_vpp_vxlan_interface(config: ConfigTree) -> None: + base = ['vpp', 'interfaces', 'vxlan'] + new_base = ['interfaces', 'vpp', 'vxlan'] + kernel_interface_base = ['vpp', 'kernel-interfaces'] + + if not config.exists(base): + return + + config.set(new_base) + + for ifname in config.list_nodes(base): + _migrate_interface(config, 'vxlan', ifname) + + _migrate_members_bridge(config, ifname) + _migrate_members_xconnect(config, ifname) + + config.delete(base) + + if config.exists(kernel_interface_base) and len(config.list_nodes(kernel_interface_base)) == 0: + config.delete(['vpp', 'kernel-interfaces']) + + if len(config.list_nodes(['vpp', 'interfaces'])) == 0: + config.delete(['vpp', 'interfaces']) + + +def _migrate_vpp_ipip_interface(config: ConfigTree) -> None: + base = ['vpp', 'interfaces', 'ipip'] + new_base = ['interfaces', 'vpp', 'ipip'] + kernel_interface_base = ['vpp', 'kernel-interfaces'] + + if not config.exists(base): + return + + config.set(new_base) + + for ifname in config.list_nodes(base): + _migrate_interface(config, 'ipip', ifname) + + _migrate_members_xconnect(config, ifname) + + config.delete(base) + + if config.exists(kernel_interface_base) and len(config.list_nodes(kernel_interface_base)) == 0: + config.delete(['vpp', 'kernel-interfaces']) + + if len(config.list_nodes(['vpp', 'interfaces'])) == 0: + config.delete(['vpp', 'interfaces']) + + +def _migrate_vpp_loopback_interface(config: ConfigTree) -> None: + base = ['vpp', 'interfaces', 'loopback'] + new_base = ['interfaces', 'vpp', 'loopback'] + kernel_interface_base = ['vpp', 'kernel-interfaces'] + + if not config.exists(base): + return + + config.set(new_base) + + for ifname in config.list_nodes(base): + _migrate_interface(config, 'loopback', ifname) + + _migrate_members_bridge(config, ifname) + + config.delete(base) + + if config.exists(kernel_interface_base) and len(config.list_nodes(kernel_interface_base)) == 0: + config.delete(['vpp', 'kernel-interfaces']) + + if len(config.list_nodes(['vpp', 'interfaces'])) == 0: + config.delete(['vpp', 'interfaces']) + + +def _migrate_vpp_gre_interface(config: ConfigTree) -> None: + base = ['vpp', 'interfaces', 'gre'] + new_base = ['interfaces', 'vpp', 'gre'] + kernel_interface_base = ['vpp', 'kernel-interfaces'] + + if not config.exists(base): + return + + config.set(new_base) + + for ifname in config.list_nodes(base): + if config.exists(base + [ifname, 'mode']): + if config.return_value(base + [ifname, 'mode']) == 'point-to-multipoint': + config.delete(base + [ifname]) + continue + config.delete(base + [ifname, 'mode']) + + _migrate_interface(config, 'gre', ifname) + + _migrate_members_bridge(config, ifname) + _migrate_members_xconnect(config, ifname) + + config.delete(base) + + if config.exists(kernel_interface_base) and len(config.list_nodes(kernel_interface_base)) == 0: + config.delete(['vpp', 'kernel-interfaces']) + + if len(config.list_nodes(['vpp', 'interfaces'])) == 0: + config.delete(['vpp', 'interfaces']) + + +def _migrate_vpp_bridge_interface(config: ConfigTree) -> None: + base = ['vpp', 'interfaces', 'bridge'] + new_base = ['interfaces', 'vpp', 'bridge'] + + if not config.exists(base): + return + + config.set(new_base) + + for ifname in config.list_nodes(base): + _migrate_interface(config, 'bridge', ifname) + + config.delete(base) + + if len(config.list_nodes(['vpp', 'interfaces'])) == 0: + config.delete(['vpp', 'interfaces']) + + +def _migrate_vpp_xconnect_interface(config: ConfigTree) -> None: + base = ['vpp', 'interfaces', 'xconnect'] + new_base = ['interfaces', 'vpp', 'xconnect'] + + if not config.exists(base): + return + + config.set(new_base) + + for ifname in config.list_nodes(base): + tmp = base + [ifname, 'member', 'interface'] + bond_found = any(name.startswith('bond') for name in config.return_values(tmp)) + if bond_found: + config.delete(base + [ifname]) + continue + + _migrate_interface(config, 'xconnect', ifname) + + config.delete(base) + + if len(config.list_nodes(['vpp', 'interfaces'])) == 0: + config.delete(['vpp', 'interfaces']) + + +def _migrate_vpp_vxlan_remove_vif(config: ConfigTree) -> None: + base = ['interfaces', 'vpp', 'vxlan'] + + if not config.exists(base): + return + + for ifname in config.list_nodes(base): + if config.exists(base + [ifname, 'vif']): + config.delete(base + [ifname, 'vif']) + + +def _migrate_vpp_ignore_kernel_routes(config: ConfigTree) -> None: + base = ['vpp', 'settings'] + old_base = base + ['resource-allocation', 'ignore-kernel-routes'] + + if config.exists(old_base): + config.delete(old_base) + + config.set(base + ['ignore-kernel-routes']) + + if len(config.list_nodes(base + ['resource-allocation'])) == 0: + config.delete(base + ['resource-allocation']) + + +def migrate(config: ConfigTree) -> None: + if not config.exists(['vpp']): + # Nothing to do + return + + _migrate_vpp_acl_tcp_flags(config) + _migrate_vpp_macip(config) + _migrate_vpp_unix_settings(config) + _migrate_vpp_log(config) + _migrate_vpp_nat44(config) + _migrate_vpp_ipsec(config) + _migrate_vpp_cpu(config) + _migrate_vpp_interface_rx_mode(config) + _migrate_vpp_dpdk_options(config) + _migrate_vpp_resources(config) + _migrate_vpp_bonding_interface(config) + _migrate_vpp_vxlan_interface(config) + _migrate_vpp_ipip_interface(config) + _migrate_vpp_loopback_interface(config) + _migrate_vpp_gre_interface(config) + _migrate_vpp_bridge_interface(config) + _migrate_vpp_xconnect_interface(config) + _migrate_vpp_vxlan_remove_vif(config) + _migrate_vpp_ignore_kernel_routes(config) diff --git a/src/migration-scripts/vrf/0-to-1 b/src/migration-scripts/vrf/0-to-1 index 70abae2a8..3a9567ab9 100644 --- a/src/migration-scripts/vrf/0-to-1 +++ b/src/migration-scripts/vrf/0-to-1 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/vrf/1-to-2 b/src/migration-scripts/vrf/1-to-2 index 557a9ec58..2b7dd556c 100644 --- a/src/migration-scripts/vrf/1-to-2 +++ b/src/migration-scripts/vrf/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -37,7 +37,10 @@ def migrate(config: ConfigTree) -> None: new_static_base = vrf_base + [vrf, 'protocols'] config.set(new_static_base) config.copy(static_base, new_static_base + ['static']) - config.set_tag(new_static_base + ['static', 'route']) + if config.exists(new_static_base + ['static', 'route']): + config.set_tag(new_static_base + ['static', 'route']) + if config.exists(new_static_base + ['static', 'route6']): + config.set_tag(new_static_base + ['static', 'route6']) # Now delete the old configuration config.delete(base) diff --git a/src/migration-scripts/vrf/2-to-3 b/src/migration-scripts/vrf/2-to-3 index acacffb41..b43067031 100644 --- a/src/migration-scripts/vrf/2-to-3 +++ b/src/migration-scripts/vrf/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -76,7 +76,8 @@ def migrate(config: ConfigTree) -> None: # Get a list of all currently used VRFs and tables vrfs_current = {} for vrf in config.list_nodes(base): - vrfs_current[vrf] = int(config.return_value(base + [vrf, 'table'])) + if config.exists(base + [vrf, 'table']): + vrfs_current[vrf] = int(config.return_value(base + [vrf, 'table'])) # Check VRF names and table numbers name_regex = re.compile(r'^\d.*$') diff --git a/src/migration-scripts/vrf/3-to-4 b/src/migration-scripts/vrf/3-to-4 new file mode 100644 index 000000000..959b736e5 --- /dev/null +++ b/src/migration-scripts/vrf/3-to-4 @@ -0,0 +1,50 @@ +# Copyright 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/>. + +# T7664: +# FRR 10.5 doesn't have ip protocols: +# - connected +# - kernel +# - table +# +# Remove them from +# - vrf name VRF ip protocol +# - vrf name VRF ipv6 protocol + +from vyos.configtree import ConfigTree + +subbases = [ + ['ip', 'protocol'], + ['ipv6', 'protocol'], +] +remove_ip_protocols = ['connected', 'kernel', 'table'] + +base = ['vrf', 'name'] + +def migrate(config: ConfigTree) -> None: + if not config.exists(base): + # Nothing to do + return + + for vrf in config.list_nodes(base): + for subbase in subbases: + protocol_base = base + [vrf] + subbase + + if not config.exists(protocol_base): + continue + + for protocol in remove_ip_protocols: + if config.exists(protocol_base + [protocol]): + config.delete(protocol_base + [protocol]) diff --git a/src/migration-scripts/vrrp/1-to-2 b/src/migration-scripts/vrrp/1-to-2 index 8639a7553..706537813 100644 --- a/src/migration-scripts/vrrp/1-to-2 +++ b/src/migration-scripts/vrrp/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -25,7 +25,7 @@ from vyos.configtree import ConfigTree # It was supported only under ethernet and bonding and their # respective vif, vif-s, and vif-c subinterfaces -def get_vrrp_group(path): +def get_vrrp_group(config, path): group = {"preempt": True, "rfc_compatibility": False, "disable": False} if config.exists(path + ["advertise-interval"]): @@ -115,7 +115,7 @@ def migrate(config: ConfigTree) -> None: if config.exists(parent_path + vg_path): pgroups = config.list_nodes(parent_path + vg_path) for pg in pgroups: - g = get_vrrp_group(parent_path + vg_path + [pg]) + g = get_vrrp_group(config, parent_path + vg_path + [pg]) g["interface"] = pi g["vrid"] = pg groups.append(g) @@ -132,7 +132,7 @@ def migrate(config: ConfigTree) -> None: if config.exists(parent_path + vif_vg_path): vifgroups = config.list_nodes(parent_path + vif_vg_path) for vif_group in vifgroups: - g = get_vrrp_group(parent_path + vif_vg_path + [vif_group]) + g = get_vrrp_group(config, parent_path + vif_vg_path + [vif_group]) g["interface"] = "{0}.{1}".format(pi, vif) g["vrid"] = vif_group groups.append(g) @@ -147,7 +147,7 @@ def migrate(config: ConfigTree) -> None: if config.exists(parent_path + vifs_vg_path): vifsgroups = config.list_nodes(parent_path + vifs_vg_path) for vifs_group in vifsgroups: - g = get_vrrp_group(parent_path + vifs_vg_path + [vifs_group]) + g = get_vrrp_group(config, parent_path + vifs_vg_path + [vifs_group]) g["interface"] = "{0}.{1}".format(pi, vif_s) g["vrid"] = vifs_group groups.append(g) @@ -161,7 +161,7 @@ def migrate(config: ConfigTree) -> None: vifc_vg_path = [pi, "vif-s", vif_s, "vif-c", vif_c, "vrrp", "vrrp-group"] vifcgroups = config.list_nodes(parent_path + vifc_vg_path) for vifc_group in vifcgroups: - g = get_vrrp_group(parent_path + vifc_vg_path + [vifc_group]) + g = get_vrrp_group(config, parent_path + vifc_vg_path + [vifc_group]) g["interface"] = "{0}.{1}.{2}".format(pi, vif_s, vif_c) g["vrid"] = vifc_group groups.append(g) @@ -173,7 +173,7 @@ def migrate(config: ConfigTree) -> None: return # Otherwise, there is VRRP to convert - + # Now convert the collected groups to the new syntax base_group_path = ["high-availability", "vrrp", "group"] sync_path = ["high-availability", "vrrp", "sync-group"] diff --git a/src/migration-scripts/vrrp/2-to-3 b/src/migration-scripts/vrrp/2-to-3 index 468918f91..f9158de1d 100644 --- a/src/migration-scripts/vrrp/2-to-3 +++ b/src/migration-scripts/vrrp/2-to-3 @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/vrrp/3-to-4 b/src/migration-scripts/vrrp/3-to-4 index 9f05cf7a1..bb20979c9 100644 --- a/src/migration-scripts/vrrp/3-to-4 +++ b/src/migration-scripts/vrrp/3-to-4 @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/wanloadbalance/3-to-4 b/src/migration-scripts/wanloadbalance/3-to-4 index e49f46a5b..fb3975385 100644 --- a/src/migration-scripts/wanloadbalance/3-to-4 +++ b/src/migration-scripts/wanloadbalance/3-to-4 @@ -1,4 +1,4 @@ -# Copyright 2025 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/migration-scripts/webproxy/1-to-2 b/src/migration-scripts/webproxy/1-to-2 index 5a4847474..70b54b1cd 100644 --- a/src/migration-scripts/webproxy/1-to-2 +++ b/src/migration-scripts/webproxy/1-to-2 @@ -1,4 +1,4 @@ -# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/ocaml/.gitignore b/src/ocaml/.gitignore new file mode 100644 index 000000000..69fa449dd --- /dev/null +++ b/src/ocaml/.gitignore @@ -0,0 +1 @@ +_build/ diff --git a/src/ocaml/Makefile b/src/ocaml/Makefile new file mode 100644 index 000000000..a1e42c914 --- /dev/null +++ b/src/ocaml/Makefile @@ -0,0 +1,7 @@ +all: + eval $$(opam env --root=/opt/opam --set-root); dune build + +clean: + eval $$(opam env --root=/opt/opam --set-root); dune clean + +.PHONY: all clean diff --git a/src/ocaml/completion/list_interfaces/func.ml b/src/ocaml/completion/list_interfaces/func.ml new file mode 100644 index 000000000..13e1860d9 --- /dev/null +++ b/src/ocaml/completion/list_interfaces/func.ml @@ -0,0 +1 @@ +external list_interfaces: unit -> string list = "interface_list" diff --git a/src/ocaml/completion/list_interfaces/func.mli b/src/ocaml/completion/list_interfaces/func.mli new file mode 100644 index 000000000..b16d37354 --- /dev/null +++ b/src/ocaml/completion/list_interfaces/func.mli @@ -0,0 +1 @@ +external list_interfaces : unit -> string list = "interface_list" diff --git a/src/ocaml/completion/list_interfaces/iface.c b/src/ocaml/completion/list_interfaces/iface.c new file mode 100644 index 000000000..bf2f0250e --- /dev/null +++ b/src/ocaml/completion/list_interfaces/iface.c @@ -0,0 +1,38 @@ +/* + * Simple wrapper of getifaddrs for OCaml list of interfaces + */ +#include <ifaddrs.h> +#include <caml/mlvalues.h> +#include <caml/memory.h> +#include <caml/alloc.h> + +CAMLprim value interface_list(value unit) { + struct ifaddrs *ifaddr; + struct ifaddrs *ifa; + + CAMLparam1( unit ); + CAMLlocal2( cli, cons ); + + cli = Val_emptylist; + + if (getifaddrs(&ifaddr) == -1) { + CAMLreturn(cli); + } + for (ifa = ifaddr; ifa != NULL; ifa = ifa->ifa_next) { + if (ifa->ifa_name == NULL) + continue; + + CAMLlocal1( ml_s ); + cons = caml_alloc(2, 0); + + ml_s = caml_copy_string(ifa->ifa_name); + Store_field( cons, 0, ml_s ); + Store_field( cons, 1, cli ); + + cli = cons; + } + + freeifaddrs(ifaddr); + + CAMLreturn(cli); +} diff --git a/src/ocaml/completion/list_interfaces/list_interfaces.ml b/src/ocaml/completion/list_interfaces/list_interfaces.ml new file mode 100644 index 000000000..b76afd4c1 --- /dev/null +++ b/src/ocaml/completion/list_interfaces/list_interfaces.ml @@ -0,0 +1,143 @@ +(* + *) +let intf_types = ref [] +let broadcast = ref false +let bridgeable = ref false +let bondable = ref false +let no_vlan = ref false + +let args = [ + ("--type", Arg.String (fun s -> intf_types := (s :: !intf_types)), "List interfaces of specified type"); + ("--broadcast", Arg.Unit (fun () -> broadcast := true), "List broadcast interfaces"); + ("--bridgeable", Arg.Unit (fun () -> bridgeable := true), "List bridgeable interfaces"); + ("--bondable", Arg.Unit (fun () -> bondable := true), "List bondable interfaces"); + ("--no-vlan-subinterfaces", Arg.Unit (fun () -> no_vlan := true), "List only parent interfaces"); +] +let usage = Printf.sprintf "Usage: %s [OPTIONS] <number>" Sys.argv.(0) + +let () = Arg.parse args (fun _ -> ()) usage + +let type_to_prefix it = + match it with + | "" -> "" + | "bonding" -> "bond" + | "bridge" -> "br" + | "dummy" -> "dum" + | "ethernet" -> "eth" + | "geneve" -> "gnv" + | "input" -> "ifb" + | "l2tpeth" -> "l2tpeth" + | "loopback" -> "lo" + | "macsec" -> "macsec" + | "openvpn" -> "vtun" + | "pppoe" -> "pppoe" + | "pseudo-ethernet" -> "peth" + | "sstpc" -> "sstpc" + | "tunnel" -> "tun" + | "virtual-ethernet" -> "veth" + | "vti" -> "vti" + | "vxlan" -> "vxlan" + | "wireguard" -> "wg" + | "wireless" -> "wlan" + | "wwan" -> "wwan" + | _ -> "" + +(* filter_section to match the constraint of python.vyos.ifconfig.section + *) +let rx = Pcre2.regexp {|\d(\d|v|\.)*$|} + +let filter_section s = + let r = Pcre2.qreplace_first ~rex:rx ~templ:"" s in + match r with + |"bond"|"br"|"dum"|"eth"|"gnv"|"ifb"|"l2tpeth"|"lo"|"macsec" -> true + |"peth"|"pppoe"|"sstpc"|"tun"|"veth"|"vti"|"vtun"|"vxlan"|"wg"|"wlan"|"wwan" -> true + | _ -> false + +let filter_from_prefix p s = + let pattern = Printf.sprintf "^%s(.*)$" p + in + try + let _ = Pcre2.exec ~pat:pattern s in + true + with Not_found -> false + +let filter_from_type it = + let pre = type_to_prefix it in + match pre with + | "" -> None + | _ -> Some (filter_from_prefix pre) + +let filter_broadcast s = + let pattern = {|^(bond|br|tun|vtun|eth|gnv|peth|macsec|veth|vxlan|wwan|wlan)(.*)$|} + in + try + let _ = Pcre2.exec ~pat:pattern s in + true + with Not_found -> false + +let filter_bridgeable s = + let pattern = {|^(bond|eth|gnv|l2tpeth|lo|tun|veth|vtun|vxlan|wlan)(.*)$|} + in + try + let _ = Pcre2.exec ~pat:pattern s in + true + with Not_found -> false + +let filter_bondable s = + let pattern = {|^(eth|lan|eno|ens)[A-Za-z0-9_-]*$|} + in + try + let _ = Pcre2.exec ~pat:pattern s in + true + with Not_found -> false + +let filter_no_vlan s = + let pattern = {|^([^.]+)(\.\d+)+$|} + in + try + let _ = Pcre2.exec ~pat:pattern s in + false + with Not_found -> true + +let get_interfaces_by_type out intf_type = + let fltr = + if String.length(intf_type) > 0 then + filter_from_type intf_type + else None + in + let l = Func.list_interfaces () in + let res = List.sort_uniq compare l in + let res = + if !broadcast then List.filter filter_broadcast res + else res + in + let res = + if !bridgeable then List.filter filter_bridgeable res + else res + in + let res = + if !bondable then List.filter filter_bondable res + else res + in + let res = + if !no_vlan then List.filter filter_no_vlan res + else res + in + let add_out = + let res = List.filter filter_section res in + match fltr with + | Some f -> List.filter f res + | None -> res + in List.append out add_out + +let get_interfaces = + let types = List.rev !intf_types in + if types <> [] then + List.fold_left get_interfaces_by_type [] types + else + get_interfaces_by_type [] "" + +let () = + let res = get_interfaces in + List.iter (Printf.printf "%s ") res; + Printf.printf "\n" diff --git a/src/ocaml/dune b/src/ocaml/dune new file mode 100644 index 000000000..6dc9c8e7e --- /dev/null +++ b/src/ocaml/dune @@ -0,0 +1,47 @@ +(include_subdirs unqualified) + +(executable + (name numeric) + (public_name numeric) + (modules numeric) + (libraries pcre2)) + +(executable + (name url) + (public_name url) + (modules url) + (libraries pcre2)) + +(executable + (name file_path) + (public_name file-path) + (modules file_path) + (libraries fileutils)) + +(executable + (name validate_value) + (public_name validate-value) + (modules validate_value) + (libraries pcre2 unix containers)) + +(executable + (name list_interfaces) + (public_name list_interfaces) + (modules func list_interfaces) + (libraries pcre2) + (foreign_stubs + (language c) + (names iface))) + +(executable + (name vyos_op_run) + (public_name vyos-op-run) + (modules vyos_op_run) + (libraries + logs + logs.fmt + fmt.tty + yojson + mustache + pcre2 + unix)) diff --git a/src/ocaml/dune-project b/src/ocaml/dune-project new file mode 100644 index 000000000..621644ea3 --- /dev/null +++ b/src/ocaml/dune-project @@ -0,0 +1,2 @@ +(lang dune 2.0) +(name vyos-1x) diff --git a/src/ocaml/validate_value.ml b/src/ocaml/validate_value.ml new file mode 100644 index 000000000..ce4755f2c --- /dev/null +++ b/src/ocaml/validate_value.ml @@ -0,0 +1,112 @@ +type argopt = RegexOpt of string | ExecOpt of string | GroupSeparator +type check = Regex of string | Exec of string | Group of check list + +let options = ref [] +let checks = ref [] +let value = ref "" +let silent = ref false + +let buf = Buffer.create 4096 + +let rec validate_value buf value_constraint value = + match value_constraint with + | Group l -> + List.for_all (fun c -> validate_value buf c value) l + | Regex s -> + (try let _ = Pcre2.exec ~pat:(Printf.sprintf "^%s$" s) value in true + with Not_found -> false) + | Exec c -> + (* XXX: Unix.open_process_in is "shelling out", which is a bad idea on multiple levels, + especially when the input comes directly from the user... + We should do something about it. + *) + let cmd = Printf.sprintf "%s \'%s\' 2>&1" c value in + let chan = Unix.open_process_in cmd in + let out = try CCIO.read_all chan with _ -> "" in + let result = Unix.close_process_in chan in + match result with + | Unix.WEXITED 0 -> true + | Unix.WEXITED 127 -> + let () = Printf.printf "Could not execute validator %s" c in + false + | _ -> + let () = Buffer.add_string buf out; Buffer.add_string buf "\n" in + false + +let args = [ + ("--regex", Arg.String (fun s -> options := (RegexOpt s) :: !options), "Check the value against a regex"); + ("--exec", Arg.String (fun s -> options := (ExecOpt s) :: !options), "Check the value against an external command"); + ("--grp", Arg.Unit (fun () -> options := (GroupSeparator) :: !options), "Group following arguments, combining results with logical and"); + ("--silent", Arg.Unit (fun () -> silent := true), "Suppress individual validator output"); + ("--value", Arg.String (fun s -> value := s), "Value to check"); +] +let usage = Printf.sprintf "Usage: %s [OPTIONS] <number>" Sys.argv.(0) + +let () = Arg.parse args (fun _ -> ()) usage + +let find_next_group n l = + let rec aux i = function + | [] -> List.length l + | h::t -> + if i > n && h = GroupSeparator then i + else aux (i+1) t + in aux 0 l + +let get_next_range = + let n = ref (-1) in + let f () = + let i = !n and j = (n := find_next_group !n !options; !n) in + (i, j) in + f + +let option_to_check opt = + match opt with + | RegexOpt s -> Regex s + | ExecOpt s -> Exec s + | GroupSeparator -> raise (Invalid_argument "GroupSeparator in isolation has no corresponding check") + +let read_initial_options j = + if j > 0 then + let initial_options = List.filteri (fun i _ -> i < j) !options in + ignore (List.map (fun c -> checks := (option_to_check c) :: !checks) initial_options); () + else () + +let read_group_options i j = + if i < j then + let group_options = List.filteri (fun k _ -> i < k && k < j) !options in + let l = List.map (fun c -> option_to_check c) group_options in + checks := (Group l) :: !checks; () + else () + +let read_options () = + options := List.rev(!options); + + let (_, j) = get_next_range () in + read_initial_options j; + + let quit_loop = ref false in + while not !quit_loop do + let i, j = get_next_range () in + if i < j then + read_group_options i j + else + quit_loop := true + done + +let validate = + read_options (); + let value = !value in + let checks = !checks in + match checks with + | [] -> false + | _ -> + List.exists (fun c -> validate_value buf c value) checks + +let _ = + if validate then exit 0 else + (* If we got this far, value validation failed. + Show the user output from the validators. + *) + if not !silent then Buffer.contents buf |> print_endline + else (); + exit 1 diff --git a/src/ocaml/validators/file_path.ml b/src/ocaml/validators/file_path.ml new file mode 100644 index 000000000..ea3068c1f --- /dev/null +++ b/src/ocaml/validators/file_path.ml @@ -0,0 +1,55 @@ +type opts = { + must_be_file : bool; + parent : string option; + lookup_path : string option; + strict : bool; +} + +let default_opts = { + must_be_file = true; + parent = None; + lookup_path = None; + strict = false +} + +let opts = ref default_opts + +let path_arg = ref "" + +let args = [ + ("--file", Arg.Unit (fun () -> opts := {!opts with must_be_file=true}), "Path must point to a file and not a directory (default)"); + ("--directory", Arg.Unit (fun () -> opts := {!opts with must_be_file=false}), "Path must point to a directory"); + ("--parent-dir", Arg.String (fun s -> opts := {!opts with parent=(Some s)}), "Path must be inside specific parent directory"); + ("--lookup-path", Arg.String (fun s -> opts := {!opts with lookup_path=(Some s)}), "Prefix path argument with lookup path"); + ("--strict", Arg.Unit (fun () -> opts := {!opts with strict=true}), "Treat warnings as errors"); +] +let usage = Printf.sprintf "Usage: %s [OPTIONS] <path>" Sys.argv.(0) + +let () = if Array.length Sys.argv = 1 then (Arg.usage args usage; exit 1) +let () = Arg.parse args (fun s -> path_arg := s) usage + +let fail msg = + let () = print_endline msg in + exit 1 + +let () = + let opts = !opts in + let path = + match opts.lookup_path with + | None -> !path_arg + | Some lookup_path -> FilePath.concat lookup_path !path_arg + in + (* First, check if the file/dir path exists at all. *) + let exists = FileUtil.test FileUtil.Exists path in + if not exists then Printf.ksprintf fail {|Incorrect path %s: no such file or directory|} path else + (* If yes, check if it's of the correct type: file or directory. *) + let is_file = FileUtil.test FileUtil.Is_file path in + if ((not is_file) && opts.must_be_file) then Printf.ksprintf fail {|%s is a directory, not a file|} path else + if (is_file && (not opts.must_be_file)) then Printf.ksprintf fail {|%s is a file, not a directory|} path else + match opts.parent with + | None -> + exit 0 + | Some parent -> + if not (FilePath.is_subdir (FilePath.reduce path) (FilePath.reduce parent)) then + let msg = Printf.sprintf {|Path %s is not under %s directory|} path parent in + if opts.strict then fail msg else Printf.printf "Warning: %s\n" msg diff --git a/src/ocaml/validators/numeric.ml b/src/ocaml/validators/numeric.ml new file mode 100644 index 000000000..8e4becb56 --- /dev/null +++ b/src/ocaml/validators/numeric.ml @@ -0,0 +1,246 @@ +type numeric_str = Number_string of string | Range_string of string +type numeric_val = Number_float of float | Range_float of float * float + +type options = { + positive: bool; + nonnegative: bool; + allow_float: bool; + ranges: string list; + not_ranges: string list; + not_values: string list; + relative: bool; + allow_range: bool; + require_range: bool; + parse_hex: bool; + parse_oct: bool; + parse_bin: bool; + parse_dec: bool; +} + +let default_opts = { + positive = false; + nonnegative = false; + allow_float = false; + ranges = []; + not_ranges = []; + not_values = []; + relative = false; + allow_range = false; + require_range = false; + parse_hex = false; + parse_oct = false; + parse_bin = false; + parse_dec = false; +} + +let opts = ref default_opts + +let number_arg = ref "" + +let args = [ + ("--non-negative", Arg.Unit (fun () -> opts := {!opts with nonnegative=true}), "Check if the number is non-negative (>= 0)"); + ("--positive", Arg.Unit (fun () -> opts := {!opts with positive=true}), "Check if the number is positive (> 0)"); + ("--range", Arg.String (fun s -> let optsv = !opts in opts := {optsv with ranges=(s :: optsv.ranges)}), "Check if the number or range is within a range (inclusive)"); + ("--not-range", Arg.String (fun s -> let optsv = !opts in opts := {optsv with not_ranges=(s :: optsv.not_ranges)}), "Check if the number or range is not within a range (inclusive)"); + ("--not-value", Arg.String (fun s -> let optsv = !opts in opts := {optsv with not_values=(s :: optsv.not_values)}), "Check if the number does not equal a specific value"); + ("--float", Arg.Unit (fun () -> opts := {!opts with allow_float=true}), "Allow floating-point numbers"); + ("--relative", Arg.Unit (fun () -> opts := {!opts with relative=true}), "Allow relative increment/decrement (+/-N)"); + ("--allow-range", Arg.Unit (fun () -> opts := {!opts with allow_range=true}), "Allow the argument to be a range rather than a single number"); + ("--require-range", Arg.Unit (fun () -> opts := {!opts with require_range=true; allow_range=true}), "Require the argument to be a range rather than a single number"); + ("--hex", Arg.Unit (fun () -> opts := {!opts with parse_hex=true}), "Parse hexadecimal integers as valid numbers, complete with 0x-prefix"); + ("--octal", Arg.Unit (fun () -> opts := {!opts with parse_oct=true}), "Parse octal integers as valid numbers, complete with 0o-prefix"); + ("--binary", Arg.Unit (fun () -> opts := {!opts with parse_bin=true}), "Parse binary integers as valid numbers, complete with 0b-prefix"); + ("--decimal", Arg.Unit (fun () -> opts := {!opts with parse_dec=true}), "Continue to parse decimal numbers even when other radixes are requested, no prefix required"); + ("--", Arg.Rest (fun s -> number_arg := s), "Interpret next item as an argument"); +] +let usage = Printf.sprintf "Usage: %s [OPTIONS] <number>|<range>" Sys.argv.(0) + +let () = if Array.length Sys.argv = 1 then (Arg.usage args usage; exit 1) +let () = Arg.parse args (fun s -> number_arg := s) usage + +let check_nonnegative opts m = + if opts.nonnegative then + match m with + | Number_float n -> + if (n < 0.0) then + failwith "Number should be non-negative." + | Range_float _ -> + failwith "option '--non-negative' does not apply to a range value" + +let check_positive opts m = + if opts.positive then + match m with + | Number_float n -> + if (n <= 0.0) then + failwith "Number should be positive" + | Range_float _ -> + failwith "option '--positive does' not apply to a range value" + +let looks_like_decimal value = + try let _ = Pcre2.exec ~pat:"^(\\-?)[0-9]+(\\.[0-9]+)?$" value in true + with Not_found -> false + +let looks_like_hex value = + try let _ = Pcre2.exec ~pat:"^(\\-?)0[xX][0-9a-fA-F]+$" value in true + with Not_found -> false + +let looks_like_octal value = + try let _ = Pcre2.exec ~pat:"^(\\-?)0[oO][0-7]+$" value in true + with Not_found -> false + +let looks_like_binary value = + try let _ = Pcre2.exec ~pat:"^(\\-?)0[bB][0-1]+$" value in true + with Not_found -> false + +let is_relative value = + try let _ = Pcre2.exec ~pat:"^[+-](0[xboXBO])?[0-9a-fA-F]+$" value in true + with Not_found -> false + +let number_string_drop_modifier value = + String.sub value 1 (String.length value - 1) + +let get_relative opts t = + if opts.relative then + match t with + | Number_string s -> + if not (is_relative s) then + failwith "Value is not a relative increment/decrement" + else Number_string (number_string_drop_modifier s) + | Range_string _ -> + failwith "increment/decrement does not apply to a range value" + else t + +let number_of_string opts s = + if (opts.allow_float && not opts.parse_dec) then + failwith "Only decimal numbers may be floating point" + else if (opts.parse_hex && (looks_like_hex s)) || + (opts.parse_oct && (looks_like_octal s)) || + (opts.parse_bin && (looks_like_binary s)) then + (* float_of_string won't deal with octal or binary and hex-floats are just weird. + Easier to separate non-decimal parsing this way. + *) + let n = int_of_string_opt s in + match n with + | Some n -> + float_of_int n + | None -> + Printf.ksprintf failwith "'%s' is not a valid non-decimal number" s + else if (opts.parse_dec && (looks_like_decimal s)) then + let n = float_of_string_opt s in + match n with + | Some n -> + (* If floats are explicitly allowed, just return the number. *) + if opts.allow_float then n + (* If floats are not explicitly allowed, check if the argument has a decimal separator in it. + If the argument string contains a dot but float_of_string didn't dislike it, + it's a valid number but not an integer. + *) + else if not (String.contains s '.') then n + (* If float_of_string returned None, the argument string is just garbage rather than a number. *) + else Printf.ksprintf failwith "'%s' is not a valid integer number" s + | None -> + Printf.ksprintf failwith "'%s' is not a valid number" s + else Printf.ksprintf failwith "'%s' is not a valid number" s + +let range_of_string opts s = + let param_opts = { opts with parse_dec = true } in + let rs = String.split_on_char '-' s |> List.map String.trim |> List.map (number_of_string param_opts) in + match rs with + | [l; r] -> (l, r) + | exception (Failure msg) -> + (* Some of the numbers in the range are bad. *) + Printf.ksprintf failwith "'%s' is not a valid number range: %s" s msg + | _ -> + (* The range itself if malformed, like 1-10-20. *) + Printf.ksprintf failwith "'%s' is not a valid number range" s + +let value_in_ranges ranges n = + let in_range (l, r) n = (n >= l) && (n <= r) in + List.fold_left (fun acc r -> acc || (in_range r n)) false ranges + +let value_not_in_ranges ranges n = + let in_range (l, r) n = (n >= l) && (n <= r) in + List.fold_left (fun acc r -> acc && (not (in_range r n))) true ranges + +let check_ranges opts m = + if opts.ranges <> [] then + let ranges = List.map (range_of_string opts) opts.ranges in + match m with + | Number_float n -> + if not (value_in_ranges ranges n) then + Printf.ksprintf failwith "Number is not in any of allowed ranges" + | Range_float (i, j) -> + if (not (value_in_ranges ranges i) || + not (value_in_ranges ranges j)) then + Printf.ksprintf failwith "Range is not in any of allowed ranges" + + +let check_not_ranges opts m = + if opts.not_ranges <> [] then + let ranges = List.map (range_of_string opts) opts.not_ranges in + match m with + | Number_float n -> + if not (value_not_in_ranges ranges n) then + Printf.ksprintf failwith "Number is in one of excluded ranges" + | Range_float (i, j) -> + if (not (value_not_in_ranges ranges i) || + not (value_not_in_ranges ranges j)) then + Printf.ksprintf failwith "Range is in one of excluded ranges" + +let check_not_values opts m = + let param_opts = { opts with parse_dec = true } in + let excluded_values = List.map (number_of_string param_opts) opts.not_values in + if excluded_values = [] then () else + match m with + | Range_float _ -> Printf.ksprintf failwith "--not-value cannot be used with ranges" + | Number_float num -> + begin + let res = List.find_opt ((=) num) excluded_values in + match res with + | None -> () + | Some _ -> Printf.ksprintf failwith "Value is excluded by --not-value" + end + +let check_argument_type opts m = + match m with + | Number_float _ -> + if opts.require_range then Printf.ksprintf failwith "Value must be a range, not a number" + else () + | Range_float _ -> + if opts.allow_range then () + else Printf.ksprintf failwith "Value must be a number, not a range" + +let is_range_val s = + try let _ = Pcre2.exec ~pat:"^(0[xboXBO])?[0-9a-fA-F]+-(0[xboXBO])?[0-9a-fA-F]+$" s in true + with Not_found -> false + +let var_numeric_str s = + match is_range_val s with + | true -> Range_string s + | false -> Number_string s + +let check_default_radix opts = + if (not opts.parse_hex && not opts.parse_oct && not opts.parse_bin) then + {opts with parse_dec=true} + else opts + +let () = try + let s = var_numeric_str !number_arg in + let opts = check_default_radix !opts in + let s = get_relative opts s in + let n = + match s with + | Number_string r -> Number_float (number_of_string opts r) + | Range_string r -> let i, j = range_of_string opts r in + Range_float (i, j) + in + check_argument_type opts n; + check_nonnegative opts n; + check_positive opts n; + check_not_values opts n; + check_ranges opts n; + check_not_ranges opts n +with (Failure err) -> + print_endline err; + exit 1 + diff --git a/src/ocaml/validators/url.ml b/src/ocaml/validators/url.ml new file mode 100644 index 000000000..7ba5dcf6c --- /dev/null +++ b/src/ocaml/validators/url.ml @@ -0,0 +1,149 @@ +(* Extract and validate the scheme part. + As per the RFC: + + Scheme names consist of a sequence of characters. The lower case + letters "a"--"z", digits, and the characters plus ("+"), period + ("."), and hyphen ("-") are allowed. For resiliency, programs + interpreting URLs should treat upper case letters as equivalent to + lower case in scheme names (e.g., allow "HTTP" as well as "http"). + *) +let split_scheme url = + let aux url = + let res = Pcre2.exec ~pat:{|^([a-zA-Z0-9\.\-]+):(.*)$|} url in + let scheme = Pcre2.get_substring res 1 in + let uri = Pcre2.get_substring res 2 in + (String.lowercase_ascii scheme, uri) + in + try Ok (aux url) + with Not_found -> Error (Printf.sprintf {|"%s" is not a valid URL|} url) + +let is_scheme_allowed allowed_schemes scheme = + match List.find_opt ((=) scheme) allowed_schemes with + | Some _ -> Ok () + | None -> Error (Printf.sprintf {|URL scheme "%s:" is not allowed|} scheme) + +let regex_matches regex s = + try + let _ = Pcre2.exec ~rex:regex s in + true + with Not_found -> false + +let host_path_format = + Pcre2.regexp + {|^//(?:[^/?#]+(?::[^/?#]*)?@)?([a-zA-Z0-9\-\._~]+|\[[a-zA-Z0-9:\.]+\])(?::([0-9]+))?(/.*)?$|} + +let host_name_format = Pcre2.regexp {|^[a-zA-Z0-9]+([\-\._~]{1}[a-zA-Z0-9]+)*$|} +let ipv4_addr_format = Pcre2.regexp {|^(([1-9]\d{0,2}|0)\.){3}([1-9]\d{0,2}|0)$|} +let ipv6_addr_format = Pcre2.regexp {|^\[([a-z0-9:\.]+|[A-Z0-9:\.]+)\]$|} + +let is_port s = + try + let n = int_of_string s in + if n > 0 && n < 65536 then true + else false + with Failure _ -> false + +let is_ipv4_octet s = + try + let n = int_of_string s in + if n >= 0 && n < 256 then true + else false + with Failure _ -> false + +let is_ipv6_segment s = + try + let n = int_of_string ("0x" ^ s) in + if n >= 0 && n < 65536 then true + else false + with Failure _ -> false + +let is_ipv4_addr s = + let res = Pcre2.exec ~rex:ipv4_addr_format s in + let ipv4_addr_str = Pcre2.get_substring res 0 in + let ipv4_addr_l = String.split_on_char '.' ipv4_addr_str in + List.for_all is_ipv4_octet ipv4_addr_l + +let is_ipv6_pure_addr s = + let ipv6_addr_l = String.split_on_char ':' s in + if List.length ipv6_addr_l > 8 || List.length ipv6_addr_l < 3 then false + else + let seg_str_l = List.filter (fun s -> String.length s > 0) ipv6_addr_l in + List.for_all is_ipv6_segment seg_str_l + +let is_ipv6_dual_addr s = + let ipv6_addr_l = List.rev (String.split_on_char ':' s) in + match ipv6_addr_l with + | [] -> false + | h::t -> + if not (is_ipv4_addr h) then false + else + if List.length t > 6 || List.length t < 2 then false + else + let seg_str_l = List.filter (fun s -> String.length s > 0) t in + List.for_all is_ipv6_segment seg_str_l + +let is_ipv6_addr s = + let res = Pcre2.exec ~rex:ipv6_addr_format s in + let ipv6_addr_str = Pcre2.get_substring res 1 in + try + let typo = Pcre2.exec ~pat:{|:::|} ipv6_addr_str in + match typo with + | _ -> false + with Not_found -> + is_ipv6_pure_addr ipv6_addr_str || is_ipv6_dual_addr ipv6_addr_str + +let host_path_matches s = + try + let res = Pcre2.exec ~rex:host_path_format s in + let substr = Pcre2.get_substrings ~full_match:false res in + let port_str = Array.get substr 1 in + if String.length port_str > 0 && not (is_port port_str) then false + else + let host = Array.get substr 0 in + match host with + | host when regex_matches ipv6_addr_format host -> is_ipv6_addr host + | host when regex_matches ipv4_addr_format host -> is_ipv4_addr host + | host when regex_matches host_name_format host -> true + | _ -> false + with Not_found -> false + +let validate_uri scheme uri = + if host_path_matches uri then Ok () + else Error (Printf.sprintf {|"%s" is not a valid URI for the %s URL scheme|} uri scheme) + +let validate_url allowed_schemes url = + let (let*) = Result.bind in + let* scheme, uri = split_scheme url in + let* () = is_scheme_allowed allowed_schemes scheme in + let* () = validate_uri scheme uri in + Ok () + +let file_transport_schemes = ["http"; "https"; "ftp"; "sftp"; "scp"; "tftp"] + +let message_schemes = ["mailto"; "tel"; "sms"] + +let allowed_schemes = ref [] +let url = ref "" + +let args = [ + ("--scheme", + Arg.String (fun s -> allowed_schemes := s :: !allowed_schemes), + "Allow only specified schemes"); + ("--file-transport", + Arg.Unit (fun () -> allowed_schemes := (List.append !allowed_schemes file_transport_schemes)), + "Allow only file transport protocols (HTTP/S, FTP, SCP/SFTP, TFTP)"); + ("--", Arg.Rest (fun s -> url := s), "Interpret next item as an argument"); +] + +let usage = Printf.sprintf "Usage: %s [OPTIONS] <URL>" Sys.argv.(0) + +let () = + let () = Arg.parse args (fun s -> url := s) usage in + (* Force all allowed scheme named to lowercase for ease of comparison. *) + let allowed_schemes = List.map String.lowercase_ascii !allowed_schemes in + let res = validate_url allowed_schemes !url in + match res with + | Ok () -> () + | Error msg -> + let () = Printf.fprintf stdout "%s" msg in + exit 1 diff --git a/src/ocaml/vyos-1x.opam b/src/ocaml/vyos-1x.opam new file mode 100644 index 000000000..9da287652 --- /dev/null +++ b/src/ocaml/vyos-1x.opam @@ -0,0 +1,21 @@ +opam-version: "2.0" +name: "vyos-1x" +version: "0.0.4" +synopsis: "VyOS utils" +description: """ +A collection of validators and completion helpers. +""" +maintainer: "Daniil Baturin <daniil@baturin.org>" +authors: "VyOS maintainers and contributors <maintainers@vyos.net>" +license: "MIT" +homepage: "https://github.com/vyos/vyos-1x" +bug-reports: "https://vyos.dev" +dev-repo: "git+https://github.com/vyos/vyos-1x/" +build: [ + ["dune" "subst"] {pinned} + ["dune" "build" "-p" name] +] +depends: [ + "ocamlfind" {build} + "dune" {build & >= "2.0"} +] diff --git a/src/ocaml/vyos_op_run.ml b/src/ocaml/vyos_op_run.ml new file mode 100644 index 000000000..aa88d42fd --- /dev/null +++ b/src/ocaml/vyos_op_run.ml @@ -0,0 +1,532 @@ +(* + * vyos-op-run: the wrapper for executing operational mode commands. + * + * Copyright VyOS maintainers and contributors <maintainers@vyos.io> + * + * 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/>. + *) + +(* Global constants *) +let op_def_file = "/usr/share/vyos/op_cache.json" +let permissions_file = "/etc/vyos/operators.json" +let vyos_admin_group_name = "vyattacfg" + +(* List of commands that operators are unconditionally denied to execute. *) +let admin_only_commands = [ + (* Configuration mode operations *) + ["configure"]; + ["commit"]; + ["commit-confirm"]; + ["confirm"]; + (* XXX: executing a shell through a wrapper that does setuid 0 + provides a ready shell escape and defeats the purpose. + We cannot allow operator-level users to execute shells + in VRFs and network namespaces + at least until we find a way to drop privileges + after attaching to the VRF/netns but before executing the commands. + *) + ["execute"; "shell"]; +] + +(* Execution options *) +type options = { + (* The option not to actually run the command, just print it *) + dry_run: bool; + + (* Enable debug output *) + debug: bool; + + (* The original VyOS command, + like "show interfaces ethernet", + for debugging and for substitutions of $@/$* + *) + vyos_command: string; +} + +let default_options = { + dry_run = false; + debug = false; + vyos_command = "<VyOS command is undefined>"; +} + +(* Exceptions and helpers *) +exception Invalid_command of string +let invalid_command msg = raise (Invalid_command msg) + +exception Internal_error of string +let internal_error msg = raise (Internal_error msg) + +exception Command_error of string +let command_error msg = raise (Command_error msg) + +exception Permission_error +let permission_error () = raise Permission_error + +exception Incomplete_command + +(* Logging setup routines *) +let get_color_style () = + let no_color = Sys.getenv_opt "NO_COLOR" |> Option.is_some in + (* Logs always go to stderr, so we don't check if stdout is a TTY. *) + let interactive = Unix.isatty (Unix.descr_of_out_channel stderr) in + if interactive && (not no_color) then `Ansi_tty else `None + +let setup_logging debug = + let level = + if debug then Logs.Debug + else Logs.Warning + in + let style = get_color_style () in + Logs.set_level (Some level); + Fmt_tty.setup_std_outputs ~style_renderer:style (); + Logs.set_reporter @@ Logs.format_reporter (); + (* Enable exception tracing if debug=true, + by default it's disabled in the OCaml runtime *) + if debug then Printexc.record_backtrace true + +(* JSON data helpers *) +let get_string_field name obj = + let open Yojson.Safe.Util in + member name obj |> to_string + +let read_command_definitions () = + let () = Logs.debug @@ fun m -> m "Reading command definitions from %s" op_def_file in + let ic = open_in op_def_file in + let data = Yojson.Safe.from_channel ic in + let () = close_in ic in + data + +let read_permissions () = + let () = Logs.debug @@ fun m -> m "Reading user permissions from %s" permissions_file in + let ic = open_in permissions_file in + let data = Yojson.Safe.from_channel ic in + let () = close_in ic in + data + +let find_child_node op_node word = + let open Yojson.Safe.Util in + let res = member word op_node in + match res with + | (`Assoc _) as d -> Some d + | `Null -> None + | _ -> + Printf.ksprintf internal_error {|Child node "%s" is not an object!|} word + +let get_node_data op_node = + let open Yojson.Safe.Util in + let res = member "__node_data" op_node in + match res with + | (`Assoc _) as d -> d + | `Null -> + Printf.ksprintf internal_error "Op node has no data!\n" + | _ -> + Printf.ksprintf internal_error "Op node data is not an object!" + +let get_path node_data = + let open Yojson.Safe.Util in + member "path" node_data |> convert_each to_string + +let get_node_type node_data = + let open Yojson.Safe.Util in + let res = member "node_type" node_data in + match res with + | `String _type -> _type + | `Null -> + Printf.ksprintf internal_error "Op node has no type!" + | _ -> + Printf.ksprintf internal_error "Op node data is not a string!" + +let get_command_opt ?(field_name="command") node_data = + let open Yojson.Safe.Util in + let res = member field_name node_data in + match res with + | `String cmd -> Some cmd + | `Null -> None + | _ -> Printf.ksprintf internal_error "command must be a string" + +let get_command ?(field_name="command") node_data = + let res = get_command_opt ~field_name:field_name node_data in + match res with + | Some cmd -> cmd + | None -> Printf.ksprintf internal_error "node is expected to have a command" + +let get_virtual_tag_node node = + let open Yojson.Safe.Util in + let res = member "__virtual_tag" node in + match res with + | `Null -> None + | _ -> Some res + +(* Command permission checks *) + +let rec permission_matches perm cmd = + match perm, cmd with + | [], _ -> + (* If all terms of the permission matched + all words of the command, the command is allowed -- + we follow the implicit approach + "every permission includes all sub-commands" + *) + true + | _, [] -> + (* If the command is shorter than the permission spec, + it means the permission is more specific. + E.g., 'show interfaces ethernet' permission + should reject attempts to run 'show interfaces', + since its intent is to allow access only to Ethernet. *) + false + | (p :: ps), (c :: cs) -> + (* Permission term can be either a command word + or a special token '*' that matches any command. *) + if (p = c) || (p = "*") then permission_matches ps cs + else false + +let group_perms_match perms group cmd = + let get_group_perms perms g = + let perms = Yojson.Safe.Util.path + ["groups"; g; "command_policy"; "allow"] perms + in + match perms with + | Some v -> + (try + v |> + Yojson.Safe.Util.to_list |> + List.map (fun j -> Yojson.Safe.Util.to_list j |> List.map Yojson.Safe.Util.to_string) + with _ -> + Printf.ksprintf internal_error + "Command policy for group %s is not a list of string lists" g) + | None -> Printf.ksprintf internal_error + "Configuration does not define command policy for group %s" g + in + let rec perm_list_matches ps cmd = + match ps with + | [] -> false + | p :: ps -> + if permission_matches p cmd then true + else perm_list_matches ps cmd + in + let group_perms = get_group_perms perms group in + perm_list_matches group_perms cmd + +let is_admin () = + (* If executed by root, skip all permission checks *) + if Unix.getuid () = 0 then + let () = Logs.debug @@ fun m -> m "The user is root, permission checks will be skipped" in + true + else begin + (* Otherwise, check if the user is a VyOS admin *) + let admin_group = Unix.getgrnam vyos_admin_group_name in + let user_groups = Unix.getgroups () in + match (Array.find_opt ((=) admin_group.gr_gid) user_groups) with + | Some _ -> + let () = Logs.debug @@ fun m -> m "The user is a VyOS admin, permission checks will be skipped" in + true + | None -> + let () = Logs.debug @@ fun m -> m "The user does not have VyOS admin permissions" in + false + end + +let has_unsafe_characters cmd = + (* XXX: this function is highly restrictive now, + until we are completely certain that shell escape + cannot happen down the line inside VyOS op mode scripts. + Alphanumeric characters, hyphens, dots, and whitespace + should allow operator users to use most commands + that take interface names, FQDNs, and config entities + like IPsec peer names. + Notable exceptions are: + - 'show bgp regexp': regexes naturally require '$' and other + patently shell-unsafe characters. + - 'monitor traffic interface eth0 filter': + PCAP filters use '!', '&&' and '||', + although people can use 'and', 'or', 'not' + to get around the restriction. + - 'add system image': requires non-alphanumeric characters + for URLs. + *) + let () = Logs.debug @@ fun m -> m "Checking the command for unsafe characters" in + try + let _ = Pcre2.exec ~pat:{|[^a-zA-Z0-9_\-\.\s]|} cmd in + let () = + Printf.fprintf stderr "Command [%s] contains special characters \ + that operator-level users are not allowed to use\n" cmd + in + true + with Not_found -> false + +let is_admin_only_command cmd = + let rec prefix_matches prefix target = + match prefix, target with + | [], _ -> + (* The target matched every word of the prefix, + so it's a match. + *) + true + | _, [] -> + (* The target is shorter than the prefix, + so it's not a match. + *) + false + | (p :: ps), (t :: ts) -> + if p = t then prefix_matches ps ts + else false + in + let () = Logs.debug @@ fun m -> m "Checking if the command is admin-only" in + let res = List.find_opt (fun p -> prefix_matches p cmd) admin_only_commands in + match res with + | None -> false + | Some _ -> + let () = Logs.debug @@ fun m -> m "Commandis reserved for admins" in + true + +let check_command_permissions perms cmd = + let rec aux perms groups cmd = + match groups with + | [] -> permission_error () + | g :: gs -> + if group_perms_match perms g cmd then () + else aux perms gs cmd + in + let () = Logs.debug @@ fun m -> m "Checking if the user is allowed to execute the command" in + (* VyOS admins can execute any commands without restrictions *) + if is_admin () then () else + (* Operators are not allowed to execute commands + with potentially unsafe characters in them *) + if has_unsafe_characters (String.concat " " cmd) then permission_error () else + (* Some commands are unconditionally denied to operators *) + if is_admin_only_command cmd then permission_error () else + (* Operator level users must always be in groups + with defined command policies + *) + let username = Unix.getlogin () in + let groups = Yojson.Safe.Util.path ["users"; username] perms in + match groups with + | None | Some (`List []) -> + Printf.ksprintf internal_error "User %s is not assigned to any operator group" username + | Some gs -> + let group_list = + (try + gs |> + Yojson.Safe.Util.to_list |> + List.map Yojson.Safe.Util.to_string + with _ -> + Printf.ksprintf internal_error "The groups field for user %s is not a list of strings" + username) + in + aux perms group_list cmd + +(* Command rendering and execution *) +let render_command opts env command_tmpl = + let () = Logs.debug @@ fun m -> m "Command template: %s" command_tmpl in + let command_tmpl = (Mustache.of_string command_tmpl) in + let command = Mustache.render command_tmpl (`O env) in + let vyos_command = opts.vyos_command in + Pcre2.replace ~pat:{|\$[@*]|} ~templ:vyos_command command + +let run_external_command opts env command_tmpl = + let cmd = render_command opts env command_tmpl in + if opts.dry_run then Printf.printf "%s\n%!" cmd else + (* Get the user database entry to populate the basic environment from: + we cannot trust an unprivileged user to supply $SHELL + or allow them to impersonate someone else by setting custom $LOGNAME, etc. + *) + let user_pw_entry = Unix.getpwuid @@ Unix.getuid () in + let make_var name value = Printf.sprintf "%s=%s" name value in + let env = [| + (* A knowingly safe executable lookup path. + Since we do not use /usr/local, we do not need to include that. + Executables in VyOS-specific directories are referred to by absolute paths + in the operational command JSON cache, + so we don't need to include those, either. + *) + make_var "PATH" "/usr/sbin:/usr/bin:/sbin:/bin"; + (* Standard UNIX variables *) + make_var "HOME" user_pw_entry.pw_dir; + make_var "USER" user_pw_entry.pw_name; + make_var "LOGNAME" user_pw_entry.pw_name; + make_var "SHELL" user_pw_entry.pw_shell; + (* VyOS-specific variables *) + make_var "vyos_data_dir" "/usr/share/vyos"; + make_var "vyos_validators_dir" "/usr/libexec/vyos/validators"; + make_var "vyos_completion_dir" "/usr/libexec/vyos/completion"; + make_var "vyos_libexec_dir" "/usr/libexec/vyos"; + make_var "vyos_op_scripts_dir" "/usr/libexec/vyos/op_mode"; + |] + in + let shell = "/bin/sh" in + (* We use execve with an absolute path to Bourne shell rather than execvpe + so that a user trying to do PATH=/bad/place vyos-op-run + cannot achieve anything with that trick. + *) + let () = Logs.debug @@ fun m -> m "Executing Unix command: %s" cmd in + let res = Unix.execve shell [|shell; "-c"; cmd|] env in + match res with + | Unix.WEXITED 0 -> () + | _ -> + (* Many op mode commands return non-zero exit codes on benign errors + such as an unconfigured subsystem, + so we shouldn't show this to the user by default. + *) + Logs.debug @@ fun m -> m "Execution of command '%s' failed" cmd + +(* Command lookup *) +let rec run_vyos_command opts ?(env=[]) ?(parent="") node cmd_words = + match cmd_words with + | w :: ws -> + let () = Logs.debug @@ fun m -> m "Looking up node '%s'" w in + let res = find_child_node node w in + begin match res with + | Some child_node -> + (* It's a normal, fixed command word *) + run_vyos_command opts ~env:env ~parent:w child_node ws + | None -> + (* It's either an argument of a tag node + or an incorrect command word *) + let node_data = get_node_data node in + let node_type = get_string_field "node_type" node_data in + let virtual_tag_node = get_virtual_tag_node node in + match node_type, virtual_tag_node with + | "tagNode", None -> + (* It's a simple tag node *) + let env = (Printf.sprintf "%s-tag_value" parent, `String w) :: env in + begin match ws with + | [] -> + let command = get_command node_data in + run_external_command opts env command + | _ as ws -> + run_vyos_command opts ~env:env ~parent:w node ws + end + | "node", Some vtn -> + (* It's a command that can be used either by itself or with an argument. *) + let env = (Printf.sprintf "%s-tag_value" parent, `String w) :: env in + begin match ws with + | [] -> + let vtn_data = get_node_data vtn in + let command = get_command vtn_data in + run_external_command opts env command + | _ -> + (* In the case of a virtual tag node, we take the parent (for variable substitution purposes) + from the upper level. + *) + run_vyos_command opts ~env:env ~parent:parent vtn ws + end + | "node", None | "leafNode", None -> + let path = get_path node_data in + Printf.ksprintf invalid_command {|"%s" is not a valid argument for command [%s]|} + w (String.concat " " path) + | _, _ -> + Printf.ksprintf internal_error + {|Node with type "%s" must not have a <virtualTagNode> child|} + node_type + end + | _ -> + let node_data = get_node_data node in + let node_type = get_node_type node_data in + let command = + begin match node_type with + | "node" | "leafNode" -> + get_command_opt node_data + | "tagNode" -> + (* If it's a tag node but there's no argument, + we need to check if that tag node has standalone behavior attached to it. + *) + get_command_opt ~field_name:"standalone_command" node_data + | "virtualTagNode" -> + None + | _ -> Printf.ksprintf internal_error {|Invalid node type "%s"|} node_type + end + in + begin match command with + | Some command -> + run_external_command opts env command + | None -> + raise Incomplete_command + end + +(* Command line argument parsing *) +let usage_msg = Printf.sprintf {|Usage: %s [OPTIONS] <command> + +%s is the VyOS operational command wrapper. +It is used by the CLI and can be used +for running operational commands from scripts. + +Options: +|} Sys.argv.(0) Sys.argv.(0) + +let get_args () = + let opts = ref default_options in + let args = ref [] in + let add_positional_arg arg = + args := arg :: !args + in + let arg_spec = Arg.align [ + ("--dry-run", + Arg.Unit (fun () -> opts := {!opts with dry_run=true}), + "Show the command instead of executing it"); + ("--debug", + Arg.Unit (fun () -> opts := {!opts with debug=true}), + "Enable debug output"); + ] + in + let () = Arg.parse arg_spec add_positional_arg usage_msg in + let args = List.rev !args in + ({!opts with vyos_command=(String.concat " " args)}, args) + +let () = + let debug = + (* For simplicity, we check for the existence + of the VYOS_DEBUG environment variable, + rather than for specific values. + *) + match Unix.getenv "VYOS_DEBUG" with + | _ -> true + | exception Not_found -> false + in + let options, args = get_args () in + (* If debug is not enabled by the environment variable, + take it from command line options -- + it may be enabled there. + *) + let () = if debug then print_endline "Debug is enabled by the env var" in + let debug = if debug then true else options.debug in + let () = setup_logging debug in + let op_defs = read_command_definitions () in + let permissions = read_permissions () in + let () = Logs.debug @@ fun m -> m "Executing VyOS command [%s]" options.vyos_command in + try + check_command_permissions permissions args; + Unix.setuid 0; + run_vyos_command options ~env:[] ~parent:"" op_defs args + with + | Permission_error -> + Printf.fprintf stderr "You do not have a permission to execute VyOS command [%s]\n" + options.vyos_command; + exit 1 + | Invalid_command msg -> + Printf.fprintf stderr "Invalid command [%s]: %s\n" options.vyos_command msg; + exit 1 + | Command_error msg -> + Printf.fprintf stderr "%s\n" msg; + | Incomplete_command -> + Printf.fprintf stderr "Incomplete command: %s\n" options.vyos_command; + exit 2 + | Sys_error msg -> + Printf.fprintf stderr "System error: %s" msg; + exit 255 + | Unix.Unix_error (err, func, _) -> + Printf.fprintf stderr "Failed to execute Unix call %s: %s" func (Unix.error_message err); + exit 255 + | Internal_error msg -> + Printf.fprintf stderr "Internal error: %s\n" msg; + exit 255 + diff --git a/src/op_mode/accelppp.py b/src/op_mode/accelppp.py index 67ce786d0..6f6fd4858 100755 --- a/src/op_mode/accelppp.py +++ b/src/op_mode/accelppp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/activation.py b/src/op_mode/activation.py new file mode 100644 index 000000000..16192f3eb --- /dev/null +++ b/src/op_mode/activation.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# 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 +import re +import typing +import tabulate + +import vyos.opmode +from vyos.utils.activate import get_activation_scripts +from vyos.utils.activate import set_activation as util_activate +from vyos.utils.activate import get_activation +from vyos.utils.activate import ActiveOpt +from vyos.utils.io import ask_yes_no +from vyos.base import Warning as Warn + + +def _get_raw_data() -> dict: + return get_activation_scripts() + + +def _split_name(name: str) -> tuple[str, str]: + # script names are guaranteed to have this format by construction: + # cf. scripts/generate-activation-scripts-json.py + match = re.match(r'(\d+)\-(.+)', name) + if match is None: + return '0', '_' + prio, base_name = match.groups() + return prio, base_name + + +def _find_full_name(name: str) -> typing.Optional[str]: + script_names = list(_get_raw_data()) + result = list(filter(lambda s: s.endswith(name), script_names)) + + return result[0] if result else None + + +def show_list(raw: bool) -> typing.Optional[list]: + scripts = _get_raw_data() + data = [] + for key in scripts.keys(): + _, name = _split_name(key) + data.append(name) + + if raw: + return data + + print(*data) + return None + + +def show_opts(raw: bool) -> typing.Optional[list]: + opts = list(typing.get_args(ActiveOpt)) + + if raw: + return opts + + print(*opts) + return None + + +def _format_scripts(scripts: dict): + headers = ['name', 'activate on reboot', 'priority'] + data = [] + for key in scripts.keys(): + prio, name = _split_name(key) + value = scripts[key] + data.append([name, value, prio]) + + print('Activation units:') + print(tabulate.tabulate(data, headers)) + + +def show(raw: bool): + activation_dict = _get_raw_data() + if raw: + return activation_dict + return _format_scripts(activation_dict) + + +def set_active(name: str, value: ActiveOpt, no_prompt: bool = False): + PROMPT_ENABLED = f'This will set {name} active on subsequent reboots. Proceed ?' + PROMPT_ONCE = f'This will set {name} active only for the next reboot. Proceed ?' + PROMPT_OFF = f'This will set {name} inactive. Proceed ?' + UNCHANGED = f'{name} is already set to {value}' + UNKNOWN = 'None such' + + full_name = _find_full_name(name) + if not full_name: + Warn(f'No activation unit {name}') + return + + state = get_activation(full_name) + + if state == 'never': + Warn(f'{name} has been set to \'never\' and should not be reset') + return + + if value == state: + print(UNCHANGED) + return + + if value not in list(typing.get_args(ActiveOpt)): + Warn(f'No such value {value}') + return + + match value: + case 'enabled': + message = PROMPT_ENABLED + case 'once': + message = PROMPT_ONCE + case 'off': + message = PROMPT_OFF + case _: + # not reached + message = UNKNOWN + + if no_prompt or ask_yes_no(message, default=True): + util_activate(full_name, value) + + +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/op_mode/bgp.py b/src/op_mode/bgp.py index 096113cb4..7f0815433 100755 --- a/src/op_mode/bgp.py +++ b/src/op_mode/bgp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -80,6 +80,29 @@ show bgp ArgFamily = typing.Literal['inet', 'inet6', 'l2vpn'] ArgFamilyModifier = typing.Literal['unicast', 'labeled_unicast', 'multicast', 'vpn', 'flowspec'] +def reset(command: str): + from vyos.utils.process import cmd + + tokens = command.split() + + # reset -> clear (only if it's the first token) + if tokens and tokens[0] == "reset": + tokens[0] = "clear" + + # peer-group and vrf may have 'all' in their names; don't replace 'all' with '*' + skip_indexes = [] + for index, word in enumerate(tokens[:-1]): + if word in ("peer-group", "vrf"): + skip_indexes.append(index + 1) + + # replace standalone "all" with "*" unless it's in the skip list + for index, word in enumerate(tokens): + if word == "all" and index not in skip_indexes: + tokens[index] = "*" + + command = " ".join(tokens) + cmd(f'vtysh -c "{command}"') + def show_summary(raw: bool): from vyos.utils.process import cmd diff --git a/src/op_mode/bonding.py b/src/op_mode/bonding.py index 07bccbd4b..0ceb65cff 100755 --- a/src/op_mode/bonding.py +++ b/src/op_mode/bonding.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2016-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/bridge.py b/src/op_mode/bridge.py index c4293a77c..9056e16d4 100755 --- a/src/op_mode/bridge.py +++ b/src/op_mode/bridge.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -83,7 +83,7 @@ def _get_raw_data_fdb(bridge): def _get_raw_data_mdb(bridge): - """Get MAC-address multicast gorup for the bridge brX + """Get MAC-address multicast group for the bridge brX :return list """ json_data = cmd(f'bridge --json mdb show br {bridge}') diff --git a/src/op_mode/cgnat.py b/src/op_mode/cgnat.py index 9ad8f92f9..d53f6158b 100755 --- a/src/op_mode/cgnat.py +++ b/src/op_mode/cgnat.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/clear_conntrack.py b/src/op_mode/clear_conntrack.py index fec7cf144..2a4f19607 100755 --- a/src/op_mode/clear_conntrack.py +++ b/src/op_mode/clear_conntrack.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/config_mgmt.py b/src/op_mode/config_mgmt.py index 66de26d1f..fa2abec0e 100755 --- a/src/op_mode/config_mgmt.py +++ b/src/op_mode/config_mgmt.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/config_sync.py b/src/op_mode/config_sync.py new file mode 100644 index 000000000..d6eff7cd2 --- /dev/null +++ b/src/op_mode/config_sync.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# 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 +import typing +from pathlib import Path + +from vyos import opmode +from vyos import http_api_client as http +from vyos.utils.file import read_json +from vyos.utils.dict import dict_to_paths +from vyos.utils.list import list_contains_sublist +from vyos.configtree import ConfigTree +from vyos.configtree import ConfigTreeError +from vyos.config_mgmt import ConfigMgmt +from vyos.config_mgmt import ConfigMgmtError + +CONFIG_FILE = Path('/run/config_sync_conf.conf') + + +def _normalize_section(section: typing.Optional[str]) -> list: + """Convert optional CLI section argument to config tree path list""" + + if not section: + return [] + + # Section can be passed as a single string token ('interfaces ethernet') + return list(section.split()) + + +def _read_json_config() -> dict: + """Read config-sync service runtime JSON file""" + + if not CONFIG_FILE.exists(): + raise opmode.UnconfiguredObject('Config-sync service is not configured') + + return read_json(CONFIG_FILE, defaultonfailure={}) + + +def _load_config_sync_sections() -> list: + """Load sections from config-sync service runtime JSON file""" + + cfg = _read_json_config() + sections = cfg.get('section', {}) + + return list(dict_to_paths(sections)) if sections else [] + + +def _load_config_sync_settings() -> dict: + """Load remote API settings from config-sync service runtime JSON file""" + + cfg = _read_json_config() + secondary = cfg.get('secondary', {}) + address = secondary.get('address') + key = secondary.get('key') + port = int(secondary.get('port', 443)) + timeout = int(secondary.get('timeout')) if secondary.get('timeout') else None + + if not address or not key: + raise opmode.UnconfiguredObject( + 'Config-sync is not fully configured: missing secondary address/key' + ) + + return dict(host=address, key=key, port=port, timeout=timeout) + + +class ConfigSyncDiffManager: + def __init__(self): + api_settings = _load_config_sync_settings() + self._client = http.ApiClient(http.ApiClientConfig(**api_settings)) + + self._config_mgmt = ConfigMgmt() + + def _get_remote_config_tree(self, section_path: list = None) -> ConfigTree: + """ + Retrieve remote config (or subtree) as ConfigTree via HTTPS API. + + Note: Endpoint name is expected to be available on remote VyOS instance. + """ + + payload = { + 'configFormat': 'raw', + 'op': 'showConfig', + 'path': section_path or [], + } + + try: + resp_data = self._client.post('retrieve', payload, raise_on_error=False) + except http.ApiError as e: + raise opmode.InternalError(f'Remote API failed: {e}') from e + + error = (resp_data.get('error') or resp_data.get('detail') or '').strip() + if error: + ignored_errors = ('configuration under specified path is empty',) + if error.lower() not in ignored_errors: + raise opmode.InternalError( + f'Remote API responded with an error: {error}' + ) + + config_raw = resp_data.get('data') or '' + try: + return ConfigTree(config_raw) + except ConfigTreeError as e: + raise opmode.InternalError(f'Unable to build remote ConfigTree: {e}') from e + + def get_sync_diff( + self, + source: str, + sections: list, + commands: typing.Optional[bool] = False, + ) -> str: + """Returns differences between local config and remote config for a given sections""" + + results = [] + remote_tree = self._get_remote_config_tree() + for section_path in sections: + try: + result = self._config_mgmt.remote_compare( + source, + remote_tree, + path=section_path, + commands=commands, + ) + except ConfigMgmtError as e: + raise opmode.InternalError(str(e)) from e + + result = result.strip() + if result: + results.append(result) + + return '\n'.join(results) + + +def show_sync_diff( + raw: bool, + source: typing.Optional[str], + section: typing.Optional[str], + commands: typing.Optional[bool], +) -> str: + """Show differences between local config and remote config for a given section. + + Args: + raw: unused (op-mode convention); output is always text. + source: local source config: running/candidate/saved. + section: optional top-level section to diff (e.g. "nat", "system time-zone"). + commands: flag which indicates format of output. + + Returns: + Diff output (string). Empty diff is rendered as "no changes". + """ + _ = raw # op-mode framework passes it; keep signature consistent + + source = source or 'running' + selected_section = _normalize_section(section) + + configured_sections = _load_config_sync_sections() + if selected_section: + if not list_contains_sublist(configured_sections, selected_section): + raise opmode.UnconfiguredObject( + f"Config-sync is not configured for '{section}' section. " + f"Use 'set service config-sync section {section}' for this." + ) + sections = [selected_section] + else: + sections = configured_sections + + manager = ConfigSyncDiffManager() + output = manager.get_sync_diff(source, sections, commands=commands) + + return output if output else 'No changes between local and remote configuration' + + +if __name__ == '__main__': + try: + res = opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, opmode.Error) as e: + print(e) + sys.exit(1) diff --git a/src/op_mode/connect_disconnect.py b/src/op_mode/connect_disconnect.py index 8903f916a..d5db85d25 100755 --- a/src/op_mode/connect_disconnect.py +++ b/src/op_mode/connect_disconnect.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -18,6 +18,7 @@ import os import argparse from psutil import process_iter +from time import sleep from vyos.configquery import ConfigTreeQuery from vyos.utils.process import call @@ -68,7 +69,7 @@ def connect(interface): if ( count % 60 == 0 ): print(f'Commit still in progress after {count}s - waiting') count += 1 - time.sleep(1) + sleep(1) call('/usr/libexec/vyos/conf_mode/qos.py') def disconnect(interface): @@ -97,19 +98,23 @@ def main(): group = parser.add_mutually_exclusive_group() group.add_argument("--connect", help="Bring up a connection-oriented network interface", action="store_true") group.add_argument("--disconnect", help="Take down connection-oriented network interface", action="store_true") + group.add_argument("--reconnect", help="Reconnect connection-oriented network interface", action="store_true") parser.add_argument("--interface", help="Interface name", action="store", required=True) args = parser.parse_args() - if args.connect or args.disconnect: - if args.disconnect: - disconnect(args.interface) - - if args.connect: - if commit_in_progress(): - print('Cannot connect while a commit is in progress') - exit(1) - connect(args.interface) - + # Disallow connecting interfaces while their configuration might be changing + if args.connect or args.reconnect: + if commit_in_progress(): + print('Cannot connect while a commit is in progress') + exit(1) + + if args.connect: + connect(args.interface) + elif args.disconnect: + disconnect(args.interface) + elif args.reconnect: + disconnect(args.interface) + connect(args.interface) else: parser.print_help() diff --git a/src/op_mode/conntrack.py b/src/op_mode/conntrack.py index c379c3e60..f39012b2b 100755 --- a/src/op_mode/conntrack.py +++ b/src/op_mode/conntrack.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -127,7 +127,6 @@ def get_formatted_output(dict_data): reply_dport = meta['layer4']['dport'] proto = meta['layer4']['protoname'] if direction == 'independent': - conn_id = meta['id'] # T6138 flowtable offload conntrack entries without 'timeout' timeout = meta.get('timeout', 'n/a') orig_src = f'{orig_src}:{orig_sport}' if orig_sport else orig_src @@ -137,10 +136,29 @@ def get_formatted_output(dict_data): state = meta['state'] if 'state' in meta else '' mark = meta['mark'] if 'mark' in meta else '' zone = meta['zone'] if 'zone' in meta else '' - data_entries.append( - [conn_id, orig_src, orig_dst, reply_src, reply_dst, proto, state, timeout, mark, zone]) - headers = ["Id", "Original src", "Original dst", "Reply src", "Reply dst", "Protocol", "State", "Timeout", "Mark", - "Zone"] + data_entry = [ + orig_src, + orig_dst, + reply_src, + reply_dst, + proto, + state, + timeout, + mark, + zone, + ] + data_entries.append(data_entry) + headers = [ + "Original src", + "Original dst", + "Reply src", + "Reply dst", + "Protocol", + "State", + "Timeout", + "Mark", + "Zone", + ] output = tabulate(data_entries, headers, numalign="left") return output diff --git a/src/op_mode/conntrack_sync.py b/src/op_mode/conntrack_sync.py index f3b09b452..0da5b3b0b 100755 --- a/src/op_mode/conntrack_sync.py +++ b/src/op_mode/conntrack_sync.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/container.py b/src/op_mode/container.py index 05f65df1f..e0753f1be 100755 --- a/src/op_mode/container.py +++ b/src/op_mode/container.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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,13 +14,53 @@ # 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 typing import json +import shutil import sys +import subprocess +from pathlib import Path +from vyos.defaults import directories from vyos.utils.process import cmd from vyos.utils.process import rc_cmd +from vyos.utils.process import run import vyos.opmode +def clean_layer(name: str) -> int: + def layer_id_from_containers(name: str) -> str | None: + if containers.is_file(): + try: + index = json.loads(containers.read_text()) + except Exception: + return None + for item in index: + if name in item.get("names", []): + return item.get("layer") + return None + + def purge_layer_by_id(layer_id: str): + layer_dir = overlay_root / layer_id + + # Remove the overlay ID directory + shutil.rmtree(layer_dir, ignore_errors=True) + storage_dir = Path(directories['podman_storage']) + overlay_root = storage_dir / "overlay" + containers = storage_dir / "overlay-containers/containers.json" + unit = f"vyos-container-{name}.service" + layer_id = layer_id_from_containers(name) + if not layer_id: + # No mapping found; nothing to do + return 2 + + purge_layer_by_id(layer_id) + + # Reinitiate the container's overlay layer + cmd(f"rm -f /run/{unit}.cid /run/{unit}.pid") + cmd(f"systemctl reset-failed {unit}") + result = run(f"systemctl start {unit}") + return result + def _get_json_data(command: str) -> list: """ Get container command format JSON @@ -34,7 +74,7 @@ def _get_raw_data(command: str) -> list: def add_image(name: str): """ Pull image from container registry. If registry authentication - is defined within VyOS CLI, credentials are used to login befroe pull """ + is defined within VyOS CLI, credentials are used to login before pull """ from vyos.configquery import ConfigTreeQuery conf = ConfigTreeQuery() @@ -54,14 +94,14 @@ def add_image(name: str): rc, out = rc_cmd(cmd) if rc != 0: raise vyos.opmode.InternalError(out) - rc, output = rc_cmd(f'podman image pull {name}') + rc, output = rc_cmd(f'podman image pull {name}', buffered=False) if rc != 0: raise vyos.opmode.InternalError(output) if do_logout: rc_cmd('podman logout --all') -def delete_image(name: str): +def delete_image(name: str, force: typing.Optional[bool] = False): from vyos.utils.process import rc_cmd if name == 'all': @@ -71,9 +111,33 @@ def delete_image(name: str): if not name: return # replace newline with whitespace name = name.replace('\n', ' ') - rc, output = rc_cmd(f'podman image rm {name}') - if rc != 0: - raise vyos.opmode.InternalError(output) + # convert to list + name = name.split() + else: + # convert str -> list for further processing down the line + name = [name] + + for image in name: + # convert the truncated image ID to a full image ID + rc, ancestor = rc_cmd(f'podman inspect {image} --format "{{{{.Id}}}}"', stderr=None) + if rc != 0: + raise vyos.opmode.InternalError(ancestor) + # check if the image ID is an ancestor of any running container + rc, in_use = rc_cmd(f'podman ps --filter ancestor={ancestor} -q', stderr=None) + if rc != 0: + raise vyos.opmode.InternalError(in_use) + + if bool(in_use): + error = f'Cannot delete image "{image}" because it is currently '\ + f'being used by container "{in_use}"!' + raise vyos.opmode.InternalError(error) + + tmp = f'podman image rm {image}' + if force: tmp += ' --force' + + rc, output = rc_cmd(tmp) + if rc != 0: + raise vyos.opmode.InternalError(output) def show_container(raw: bool): command = 'podman ps --all' @@ -101,14 +165,66 @@ def show_network(raw: bool): def restart(name: str): from vyos.utils.process import rc_cmd + from vyos.config import Config + from vyos.container import restart_network rc, output = rc_cmd(f'systemctl restart vyos-container-{name}.service') if rc != 0: - print(output) - return None + rc2 = clean_layer(name) + if rc2 != 0: + print(output) + return None + if rc == 0: + conf = Config() + container = conf.get_config_dict(['container'], key_mangling=('-', '_'), + no_tag_node_value_mangle=True, + get_first_key=True, + with_recursive_defaults=True) + restart_network(container) print(f'Container "{name}" restarted!') return output +def show_log(name: str, follow: bool = False, raw: bool = False): + """ + Show or monitor logs for a specific container. + Use --follow to continuously stream logs. + """ + from vyos.configquery import ConfigTreeQuery + conf = ConfigTreeQuery() + container = conf.get_config_dict(['container', 'name', name], get_first_key=True, with_recursive_defaults=True) + log_type = container.get('log-driver') + if log_type == 'k8s-file': + if follow: + log_command_list = ['sudo', 'podman', 'logs', '--follow', '--names', name] + else: + log_command_list = ['sudo', 'podman', 'logs', '--names', name] + elif log_type == 'journald': + if follow: + log_command_list = ['journalctl', '--follow', '--unit', f'vyos-container-{name}.service'] + else: + log_command_list = ['journalctl', '-e', '--no-pager', '--unit', f'vyos-container-{name}.service'] + elif log_type == 'none': + print(f'Container "{name}" has disabled logs.') + return None + else: + raise vyos.opmode.InternalError(f'Unknown log type "{log_type}" for container "{name}".') + + process = None + try: + process = subprocess.Popen(log_command_list, + stdout=sys.stdout, + stderr=sys.stderr) + process.wait() + except KeyboardInterrupt: + if process: + process.terminate() + process.wait() + return None + except Exception as e: + raise vyos.opmode.InternalError(f"Error starting logging command: {e} ") + return None + + if __name__ == '__main__': try: res = vyos.opmode.run(sys.modules[__name__]) diff --git a/src/op_mode/cpu.py b/src/op_mode/cpu.py index 1a0f7392f..07cb90187 100755 --- a/src/op_mode/cpu.py +++ b/src/op_mode/cpu.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2016-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -47,7 +47,7 @@ def _format_cpus(cpu_data): def _get_summary_data(): count = get_core_count() cpu_data = get_cpus() - models = [c['model name'] for c in cpu_data] + models = [c.get('model name', 'unknown') for c in cpu_data] env = {'count': count, "models": models} return env diff --git a/src/op_mode/dhcp.py b/src/op_mode/dhcp.py index 725bfc75b..9cb5a84ae 100755 --- a/src/op_mode/dhcp.py +++ b/src/op_mode/dhcp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -83,12 +83,12 @@ ArgOrigin = typing.Literal['local', 'remote'] def _get_raw_server_leases( - config, family='inet', pool=None, sorted=None, state=[], origin=None + config, family='inet', vrf='', pool=None, sorted=None, state=[], origin=None ) -> list: inet_suffix = '6' if family == 'inet6' else '4' pools = [pool] if pool else kea_get_dhcp_pools(config, inet_suffix) - mappings = kea_get_server_leases(config, inet_suffix, pools, state, origin) + mappings = kea_get_server_leases(config, inet_suffix, vrf, pools, state, origin) if sorted: if sorted == 'ip': @@ -134,6 +134,7 @@ def _get_formatted_server_leases(raw_data, family='inet'): if family == 'inet6': for lease in raw_data: ipaddr = lease.get('ip') + hw_addr = lease.get('mac') state = lease.get('state') start = datetime.fromtimestamp( lease.get('last_communication'), timezone.utc @@ -146,19 +147,22 @@ def _get_formatted_server_leases(raw_data, family='inet'): remain = lease.get('remaining') lease_type = lease.get('type') pool = lease.get('pool') + hostname = lease.get('hostname') host_identifier = lease.get('duid') data_entries.append( - [ipaddr, state, start, end, remain, lease_type, pool, host_identifier] + [ipaddr, hw_addr, state, start, end, remain, pool, hostname, lease_type, host_identifier] ) headers = [ 'IPv6 address', + 'MAC address', 'State', 'Last communication', 'Lease expiration', 'Remaining', - 'Type', 'Pool', + 'Hostname', + 'Type', 'DUID', ] @@ -166,9 +170,13 @@ def _get_formatted_server_leases(raw_data, family='inet'): return output -def _get_pool_size(pool, family='inet'): +def _get_pool_size(pool, family='inet', vrf=''): v = 'v6' if family == 'inet6' else '' - base = f'service dhcp{v}-server shared-network-name {pool}' + # if vrf is set get the correct base for config + if vrf: + base = f'vrf name {vrf} service dhcp{v}-server shared-network-name {pool}' + else: + base = f'service dhcp{v}-server shared-network-name {pool}' size = 0 subnets = config.list_nodes(f'{base} subnet') for subnet in subnets: @@ -185,14 +193,14 @@ def _get_pool_size(pool, family='inet'): return size -def _get_raw_server_pool_statistics(config, family='inet', pool=None): +def _get_raw_server_pool_statistics(config, family='inet', vrf='', pool=None): inet_suffix = '6' if family == 'inet6' else '4' pools = [pool] if pool else kea_get_dhcp_pools(config, inet_suffix) stats = [] for p in pools: - size = _get_pool_size(family=family, pool=p) - leases = len(_get_raw_server_leases(config, family=family, pool=p)) + size = _get_pool_size(family=family, vrf=vrf, pool=p) + leases = len(_get_raw_server_leases(config, family=family, vrf=vrf, pool=p)) use_percentage = round(leases / size * 100) if size != 0 else 0 pool_stats = { 'pool': p, @@ -269,11 +277,20 @@ def _verify_server(func): def _wrapper(*args, **kwargs): config = ConfigTreeQuery() family = kwargs.get('family') + vrf = kwargs.get('vrf') v = 'v6' if family == 'inet6' else '' - unconf_message = f'DHCP{v} server is not configured' + # Check if config does not exist - if not config.exists(f'service dhcp{v}-server'): - raise vyos.opmode.UnconfiguredSubsystem(unconf_message) + if vrf: + unconf_message = f'DHCP{v} server is not configured for VRF {vrf}' + if not config.exists(f'vrf name {vrf} service dhcp{v}-server'): + raise vyos.opmode.UnconfiguredSubsystem(unconf_message) + else: + unconf_message = f'DHCP{v} server is not configured' + if not config.exists(f'service dhcp{v}-server'): + raise vyos.opmode.UnconfiguredSubsystem(unconf_message) + + # return return func(*args, **kwargs) return _wrapper @@ -291,11 +308,31 @@ def _verify_client(func): v = 'v6' if family == 'inet6' else '' interface = kwargs.get('interface') interface_path = Section.get_config_path(interface) + path_elems = interface_path.split() + base_path = ['interfaces'] + path_elems + unconf_message = f'DHCP{v} client not configured on interface {interface}!' - # Check if config does not exist - if not config.exists(f'interfaces {interface_path} address dhcp{v}'): + iface_conf = config.get_config_dict( + base_path, key_mangling=('-', '_'), get_first_key=True + ) + + if family == 'inet6': + addrs = iface_conf.get('address', []) + has_dhcpv6_addr = 'dhcpv6' in addrs + + dhcpv6_opts = iface_conf.get('dhcpv6_options', {}) + has_parameters_only = 'parameters_only' in dhcpv6_opts + has_pd = 'pd' in dhcpv6_opts + + config_exists = has_dhcpv6_addr or has_parameters_only or has_pd + else: + addrs = iface_conf.get('address', []) + config_exists = 'dhcp' in addrs + + if not config_exists: raise vyos.opmode.UnconfiguredObject(unconf_message) + return func(*args, **kwargs) return _wrapper @@ -303,25 +340,42 @@ def _verify_client(func): @_verify_server def show_server_pool_statistics( - raw: bool, family: ArgFamily, pool: typing.Optional[str] + raw: bool, family: ArgFamily, vrf: typing.Optional[str], pool: typing.Optional[str] ): v = 'v6' if family == 'inet6' else '' inet_suffix = '6' if family == 'inet6' else '4' - if not is_systemd_service_running(f'kea-dhcp{inet_suffix}-server.service'): + if vrf: + service = f'isc-kea-dhcp{inet_suffix}-server@{vrf}.service' + else: + service = f'isc-kea-dhcp{inet_suffix}-server.service' + + if not is_systemd_service_running(service): Warning(stale_warn_msg) try: - active_config = kea_get_active_config(inet_suffix) + active_config = kea_get_active_config(inet_suffix, vrf) except Exception: - raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') + if vrf: + raise vyos.opmode.DataUnavailable( + f'Cannot fetch DHCP server configuration for VRF {vrf}' + ) + else: + raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') active_pools = kea_get_dhcp_pools(active_config, inet_suffix) if pool and active_pools and pool not in active_pools: - raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') + if vrf: + raise vyos.opmode.IncorrectValue( + f'DHCP{v} pool "{pool}" does not exist for VRF {vrf}!' + ) + else: + raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') - pool_data = _get_raw_server_pool_statistics(active_config, family=family, pool=pool) + pool_data = _get_raw_server_pool_statistics( + active_config, family=family, vrf=vrf, pool=pool + ) if raw: return pool_data else: @@ -332,6 +386,7 @@ def show_server_pool_statistics( def show_server_leases( raw: bool, family: ArgFamily, + vrf: typing.Optional[str], pool: typing.Optional[str], sorted: typing.Optional[str], state: typing.Optional[ArgState], @@ -340,18 +395,33 @@ def show_server_leases( v = 'v6' if family == 'inet6' else '' inet_suffix = '6' if family == 'inet6' else '4' - if not is_systemd_service_running(f'kea-dhcp{inet_suffix}-server.service'): + if vrf: + service = f'isc-kea-dhcp{inet_suffix}-server@{vrf}.service' + else: + service = f'isc-kea-dhcp{inet_suffix}-server.service' + + if not is_systemd_service_running(service): Warning(stale_warn_msg) try: - active_config = kea_get_active_config(inet_suffix) + active_config = kea_get_active_config(inet_suffix, vrf) except Exception: - raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') + if vrf: + raise vyos.opmode.DataUnavailable( + f'Cannot fetch DHCP server configuration for VRF {vrf}' + ) + else: + raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') active_pools = kea_get_dhcp_pools(active_config, inet_suffix) if pool and active_pools and pool not in active_pools: - raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') + if vrf: + raise vyos.opmode.IncorrectValue( + f'DHCP{v} pool "{pool}" does not exist for VRF {vrf}!' + ) + else: + raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') sort_valid = sort_valid_inet6 if family == 'inet6' else sort_valid_inet if sorted and sorted not in sort_valid: @@ -363,6 +433,7 @@ def show_server_leases( lease_data = _get_raw_server_leases( config=active_config, family=family, + vrf=vrf, pool=pool, sorted=sorted, state=state, @@ -378,24 +449,40 @@ def show_server_leases( def show_server_static_mappings( raw: bool, family: ArgFamily, + vrf: typing.Optional[str], pool: typing.Optional[str], sorted: typing.Optional[str], ): v = 'v6' if family == 'inet6' else '' inet_suffix = '6' if family == 'inet6' else '4' - if not is_systemd_service_running(f'kea-dhcp{inet_suffix}-server.service'): + if vrf: + service = f'isc-kea-dhcp{inet_suffix}-server@{vrf}.service' + else: + service = f'isc-kea-dhcp{inet_suffix}-server.service' + + if not is_systemd_service_running(service): Warning(stale_warn_msg) try: - active_config = kea_get_active_config(inet_suffix) + active_config = kea_get_active_config(inet_suffix, vrf) except Exception: - raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') + if vrf: + raise vyos.opmode.DataUnavailable( + f'Cannot fetch DHCP server configuration for VRF {vrf}' + ) + else: + raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') active_pools = kea_get_dhcp_pools(active_config, inet_suffix) if pool and active_pools and pool not in active_pools: - raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') + if vrf: + raise vyos.opmode.IncorrectValue( + f'DHCP{v} pool "{pool}" does not exist for VRF {vrf}!' + ) + else: + raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') if sorted and sorted not in mapping_sort_valid: raise vyos.opmode.IncorrectValue(f'DHCP{v} sort "{sorted}" is invalid!') @@ -409,21 +496,21 @@ def show_server_static_mappings( return _get_formatted_server_static_mappings(static_mappings) -def _lease_valid(inet, address): - leases = kea_get_leases(inet) +def _lease_valid(inet, vrf, address): + leases = kea_get_leases(inet, vrf) return any(lease['ip-address'] == address for lease in leases) @_verify_server -def clear_dhcp_server_lease(family: ArgFamily, address: str): +def clear_dhcp_server_lease(family: ArgFamily, address: str, vrf: typing.Optional[str]): v = 'v6' if family == 'inet6' else '' inet = '6' if family == 'inet6' else '4' - if not _lease_valid(inet, address): + if not _lease_valid(inet, vrf, address): print(f'Lease not found on DHCP{v} server') return None - if not kea_delete_lease(inet, address): + if not kea_delete_lease(inet, vrf, address): print(f'Failed to clear lease for "{address}"') return None @@ -509,7 +596,7 @@ def _get_formatted_client_leases(lease_data): if 'new_dhcp_server_identifier' in lease: data_entries.append(['DHCP Server', lease['new_dhcp_server_identifier']]) if 'new_dhcp_lease_time' in lease: - data_entries.append(['DHCP Server', lease['new_dhcp_lease_time']]) + data_entries.append(['Lease Time', lease['new_dhcp_lease_time']]) if 'vrf' in lease: data_entries.append(['VRF', lease['vrf']]) if 'last_update' in lease: diff --git a/src/op_mode/dns.py b/src/op_mode/dns.py index 16c462f23..7c9f769f1 100755 --- a/src/op_mode/dns.py +++ b/src/op_mode/dns.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/evpn.py b/src/op_mode/evpn.py index cae4ab9f5..a6dee0b34 100644 --- a/src/op_mode/evpn.py +++ b/src/op_mode/evpn.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2016-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/execute_bandwidth_test.sh b/src/op_mode/execute_bandwidth_test.sh index a6ad0b42c..a7c7484d2 100755 --- a/src/op_mode/execute_bandwidth_test.sh +++ b/src/op_mode/execute_bandwidth_test.sh @@ -1,6 +1,6 @@ #!/bin/sh # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/execute_port-scan.py b/src/op_mode/execute_port-scan.py index bf17d0379..47cd2f7c4 100644 --- a/src/op_mode/execute_port-scan.py +++ b/src/op_mode/execute_port-scan.py @@ -1,6 +1,6 @@ #! /usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/file.py b/src/op_mode/file.py index bf13bed6f..8420c5355 100755 --- a/src/op_mode/file.py +++ b/src/op_mode/file.py @@ -1,6 +1,6 @@ #!/usr/bin/python3 -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -133,9 +133,6 @@ def print_file_data(path: str) -> None: with open(path, 'r') as f: for line in f: print(line, end='') - # tcpdump files go to TShark. - elif 'pcap' in file_type or os.path.splitext(path)[1] == '.pcap': - print(cmd(['sudo', 'tshark', '-r', path])) # All other binaries get hexdumped. else: print(cmd(['hexdump', '-C', path])) diff --git a/src/op_mode/firewall.py b/src/op_mode/firewall.py index 7a3ab921d..d5cd088e6 100755 --- a/src/op_mode/firewall.py +++ b/src/op_mode/firewall.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -18,6 +18,7 @@ import argparse import ipaddress import json import re +from signal import signal, SIGPIPE, SIG_DFL import tabulate import textwrap @@ -25,6 +26,9 @@ from vyos.config import Config from vyos.utils.process import cmd from vyos.utils.dict import dict_search_args +signal(SIGPIPE, SIG_DFL) + + def get_config_node(conf, node=None, family=None, hook=None, priority=None): if node == 'nat': if family == 'ipv6': @@ -148,6 +152,38 @@ def get_nftables_group_members(family, table, name): return out +def get_nftables_remote_group_members(family, table, name): + prefix = 'ip6' if family == 'ipv6' else 'ip' + out = [] + + try: + results_str = cmd(f'nft -j list set {prefix} {table} {name}') + results = json.loads(results_str) + except: + return out + + if 'nftables' not in results: + return out + + for obj in results['nftables']: + if 'set' not in obj: + continue + + set_obj = obj['set'] + if 'elem' in set_obj: + for elem in set_obj['elem']: + # search for single IP elements + if isinstance(elem, str): + out.append(elem) + # search for prefix elements + elif isinstance(elem, dict) and 'prefix' in elem: + out.append(f"{elem['prefix']['addr']}/{elem['prefix']['len']}") + # search for IP range elements + elif isinstance(elem, dict) and 'range' in elem: + out.append(f"{elem['range'][0]}-{elem['range'][1]}") + + return out + def output_firewall_vertical(rules, headers, adjust=True): for rule in rules: adjusted_rule = rule + [""] * (len(headers) - len(rule)) if adjust else rule # account for different header length, like default-action @@ -178,7 +214,7 @@ def output_firewall_name(family, hook, priority, firewall_conf, single_rule_id=N row.append(rule_details['conditions']) rows.append(row) - if hook in ['input', 'forward', 'output']: + if hook in ['input', 'forward', 'output', 'prerouting']: def_action = firewall_conf['default_action'] if 'default_action' in firewall_conf else 'accept' else: def_action = firewall_conf['default_action'] if 'default_action' in firewall_conf else 'drop' @@ -316,7 +352,7 @@ def output_firewall_name_statistics(family, hook, prior, prior_conf, single_rule rows.append(row) - if hook in ['input', 'forward', 'output']: + if hook in ['input', 'forward', 'output', 'prerouting']: row = ['default', ''] rule_details = details['default-action'] row.append(rule_details.get('packets', 0)) @@ -556,32 +592,8 @@ def show_firewall_group(name=None): header_tail = [] for group_type, group_type_conf in firewall['group'].items(): - ## - if group_type != 'dynamic_group': - - for group_name, group_conf in group_type_conf.items(): - if name and name != group_name: - continue - - references = find_references(group_type, group_name) - row = [group_name, textwrap.fill(group_conf.get('description') or '', 50), group_type, '\n'.join(references) or 'N/D'] - if 'address' in group_conf: - row.append("\n".join(sorted(group_conf['address']))) - elif 'network' in group_conf: - row.append("\n".join(sorted(group_conf['network'], key=ipaddress.ip_network))) - elif 'mac_address' in group_conf: - row.append("\n".join(sorted(group_conf['mac_address']))) - elif 'port' in group_conf: - row.append("\n".join(sorted(group_conf['port']))) - elif 'interface' in group_conf: - row.append("\n".join(sorted(group_conf['interface']))) - elif 'url' in group_conf: - row.append(group_conf['url']) - else: - row.append('N/D') - rows.append(row) - - else: + # iterate over dynamic-groups + if group_type == 'dynamic_group': if not args.detail: header_tail = ['Timeout', 'Expires'] @@ -590,6 +602,9 @@ def show_firewall_group(name=None): prefix = 'DA_' if dynamic_type == 'address_group' else 'DA6_' if dynamic_type in firewall['group']['dynamic_group']: for dynamic_name, dynamic_conf in firewall['group']['dynamic_group'][dynamic_type].items(): + if name and name != dynamic_name: + continue + references = find_references(dynamic_type, dynamic_name) row = [dynamic_name, textwrap.fill(dynamic_conf.get('description') or '', 50), dynamic_type + '(dynamic)', '\n'.join(references) or 'N/D'] @@ -628,6 +643,68 @@ def show_firewall_group(name=None): header_tail += [""] * (len(members) - 1) rows.append(row) + # iterate over remote-groups + elif group_type == 'remote_group': + for remote_name, remote_conf in group_type_conf.items(): + if name and name != remote_name: + continue + + references = find_references(group_type, remote_name) + row = [remote_name, textwrap.fill(remote_conf.get('description') or '', 50), group_type, '\n'.join(references) or 'N/D'] + members = get_nftables_remote_group_members("ipv4", 'vyos_filter', f'R_{remote_name}') + members6 = get_nftables_remote_group_members("ipv6", 'vyos_filter', f'R6_{remote_name}') + + if 'url' in remote_conf: + # display only the url if no members are found for both views + if not members and not members6: + if args.detail: + header_tail = ['IPv6 Members', 'Remote URL'] + row.append('N/D') + row.append('N/D') + row.append(remote_conf['url']) + else: + row.append(remote_conf['url']) + rows.append(row) + else: + # display all table elements in detail view + if args.detail: + header_tail = ['IPv6 Members', 'Remote URL'] + if members: + row.append(' '.join(members)) + else: + row.append('N/D') + if members6: + row.append(' '.join(members6)) + else: + row.append('N/D') + row.append(remote_conf['url']) + rows.append(row) + else: + row.append(remote_conf['url']) + rows.append(row) + + # catch the rest of the group types + else: + for group_name, group_conf in group_type_conf.items(): + if name and name != group_name: + continue + + references = find_references(group_type, group_name) + row = [group_name, textwrap.fill(group_conf.get('description') or '', 50), group_type, '\n'.join(references) or 'N/D'] + if 'address' in group_conf: + row.append("\n".join(sorted(group_conf['address']))) + elif 'network' in group_conf: + row.append("\n".join(sorted(group_conf['network'], key=ipaddress.ip_network))) + elif 'mac_address' in group_conf: + row.append("\n".join(sorted(group_conf['mac_address']))) + elif 'port' in group_conf: + row.append("\n".join(sorted(group_conf['port']))) + elif 'interface' in group_conf: + row.append("\n".join(sorted(group_conf['interface']))) + else: + row.append('N/D') + rows.append(row) + if rows: print('Firewall Groups\n') if args.detail: diff --git a/src/op_mode/flow_accounting_op.py b/src/op_mode/flow_accounting_op.py index 497ccafdf..078634610 100755 --- a/src/op_mode/flow_accounting_op.py +++ b/src/op_mode/flow_accounting_op.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -18,18 +18,16 @@ import sys import argparse import re import ipaddress -import os.path from tabulate import tabulate -from json import loads -from vyos.utils.commit import commit_in_progress +from vyos.utils.kernel import is_module_loaded from vyos.utils.process import cmd -from vyos.utils.process import run from vyos.logger import syslog +from vyos.configquery import ConfigTreeQuery +from vyos import ipt_netflow # some default values -uacctd_pidfile = '/var/run/uacctd.pid' -uacctd_pipefile = '/tmp/uacctd.pipe' +flows_dump_path = '/proc/net/stat/ipt_netflow_flows' def parse_port(port): try: @@ -45,7 +43,7 @@ def parse_ports(arg): if re.match(r'^\d+$', arg): # Single port port = parse_port(arg) - return {"type": "single", "value": port} + return {"type": "single", "values": (port,)} elif re.match(r'^\d+\-\d+$', arg): # Port range ports = arg.split("-") @@ -53,12 +51,12 @@ def parse_ports(arg): if ports[0] > ports[1]: raise ValueError("Malformed port range \'{0}\': lower end is greater than the higher".format(arg)) else: - return {"type": "range", "value": (ports[0], ports[1])} + return {"type": "range", "values": range(ports[0], ports[1] + 1)} elif re.match(r'^\d+,.*\d$', arg): # Port list - ports = re.split(r',+', arg) # This allows duplicate commad like '1,,2,3,4' + ports = re.split(r',+', arg) # This allows duplicate commas like '1,,2,3,4' ports = list(map(parse_port, ports)) - return {"type": "list", "value": ports} + return {"type": "list", "values": ports} else: raise ValueError("Malformed port spec \'{0}\'".format(arg)) @@ -69,9 +67,8 @@ def check_host(host): raise ValueError("Invalid host \'{}\', must be a valid IP or IPv6 address".format(host)) # check if flow-accounting running -def _uacctd_running(): - command = 'systemctl status uacctd.service > /dev/null' - return run(command) == 0 +def _netflow_running(): + return is_module_loaded(ipt_netflow.module_name) # get list of interfaces @@ -89,26 +86,62 @@ def _get_ifaces_dict(): if regex_filter.search(iface_line): ifaces_dict[int(regex_filter.search(iface_line).group('iface_index'))] = regex_filter.search(iface_line).group('iface_name') - # return dictioanry + # return dictionary return ifaces_dict # get list of flows def _get_flows_list(): - # run command to get flows list - out = cmd(f'/usr/bin/pmacct -s -O json -T flows -p {uacctd_pipefile}', - message='Failed to get flows list') + # File format: + # When MAC disabled: + # # hash a dev:i,o proto src:ip,port dst:ip,port nexthop tos,tcpflags,options,tcpoptions packets bytes ts:first,last + # 1 c06c 0 4,-1 1 10.2.0.7,0 10.1.0.5,0 0.0.0.0 0,0,0,0 186 15624 92261,131 + # 2 1e3ca 0 3,-1 1 10.1.0.5,0 10.2.0.7,2048 0.0.0.0 0,0,0,0 186 15624 92261,132 + + # When MAC enabled + VLAN fix: + # hash a dev:i,o mac:src,dst vlan type proto src:ip,port dst:ip,port nexthop tos,tcpflags,options,tcpoptions packets bytes ts:first,last + # 1 11a41 0 4,-1 0c:27:1f:55:00:00,0c:e8:b1:71:00:02 - 0800 1 10.2.0.7,0 10.1.0.5,0 0.0.0.0 0,0,0,0 1182 99288 591502,529 + # 2 13bc5 0 4,-1 0c:27:1f:55:00:00,0c:e8:b1:71:00:02 - 0800 1 10.2.0.7,0 10.2.0.1,2048 0.0.0.0 0,0,0,0 577 48468 590831,1006 + # 3 166dd 0 3,-1 0c:f1:0a:d5:00:00,0c:e8:b1:71:00:01 - 0800 1 10.1.0.5,0 10.2.0.7,2048 0.0.0.0 0,0,0,0 1182 99288 591502,529 - # read output - flows_out = out.splitlines() - # make a list with flows flows_list = [] - for flow_line in flows_out: - try: - flows_list.append(loads(flow_line)) - except Exception as err: - syslog.error('Unable to read flow info: {}'.format(err)) + with open(flows_dump_path) as f: + headers = f.readline() + headers = headers.split() + for i, h in enumerate(headers): + + if ',' in h and ':' not in h: + h = 'extra:' + h + + if ':' in h: + key, subkeys = h.split(':', 1) + headers[i] = {'key': key, 'subkeys': subkeys.split(',')} + + linenum = 1 + for flow_line in f: + linenum += 1 + flow_dict = {} + flow_line = flow_line.split() + if len(flow_line) != len(headers): + syslog.error( + f'Unexpected number of elements in {flows_dump_path}, line {linenum}' + ) + continue + for i, val in enumerate(flow_line): + if isinstance(headers[i], str): + flow_dict[headers[i]] = val + elif isinstance(headers[i], dict): + val = val.split(',') + if len(val) != len(headers[i]['subkeys']): + syslog.error( + f"Unexpected number of elements in {flows_dump_path} in column {headers[i]['key']} in line {linenum}" + ) + continue + flow_dict[headers[i]['key']] = dict(zip(headers[i]['subkeys'], val)) + else: + assert False, "Unexpected type of header" + flows_list.append(flow_dict) # return list of flows return flows_list @@ -119,12 +152,15 @@ def _flows_filter(flows, ifaces): # predefine filtered flows list flows_filtered = [] + def _iface_to_str(iface): + if int(iface) in ifaces: + return ifaces[int(iface)] + return 'unknown' + # add interface names to flows for flow in flows: - if flow['iface_in'] in ifaces: - flow['iface_in_name'] = ifaces[flow['iface_in']] - else: - flow['iface_in_name'] = 'unknown' + flow['iface_in_name'] = _iface_to_str(flow['dev']['i']) + flow['iface_out_name'] = _iface_to_str(flow['dev']['o']) # iterate through flows list for flow in flows: @@ -134,16 +170,19 @@ def _flows_filter(flows, ifaces): continue # filter by host if cmd_args.host: - if flow['ip_src'] != cmd_args.host and flow['ip_dst'] != cmd_args.host: + if ( + flow['src']['ip'] != cmd_args.host + and flow['dst']['ip'] != cmd_args.host + ): continue # filter by ports if cmd_args.ports: - if cmd_args.ports['type'] == 'single': - if flow['port_src'] != cmd_args.ports['value'] and flow['port_dst'] != cmd_args.ports['value']: - continue - else: - if flow['port_src'] not in cmd_args.ports['value'] and flow['port_dst'] not in cmd_args.ports['value']: - continue + # for 'single' it is a tuple with one value, for 'list' - list of ports, for range - range of ports + if ( + int(flow['src']['port']) not in cmd_args.ports['values'] + and int(flow['dst']['port']) not in cmd_args.ports['values'] + ): + continue # add filtered flows to new list flows_filtered.append(flow) @@ -159,23 +198,36 @@ def _flows_filter(flows, ifaces): # print flow table def _flows_table_print(flows): # define headers and body - table_headers = ['IN_IFACE', 'SRC_MAC', 'DST_MAC', 'SRC_IP', 'DST_IP', 'SRC_PORT', 'DST_PORT', 'PROTOCOL', 'TOS', 'PACKETS', 'FLOWS', 'BYTES'] + table_headers = [ + 'IN_IFACE', + 'SRC_MAC', + 'DST_MAC', + 'SRC_IP', + 'DST_IP', + 'SRC_PORT', + 'DST_PORT', + 'PROTOCOL', + 'TOS', + 'PACKETS', + # 'FLOWS', # What was here in pmacct? + 'BYTES', + ] table_body = [] # convert flows to list for flow in flows: table_line = [ flow.get('iface_in_name'), - flow.get('mac_src'), - flow.get('mac_dst'), - flow.get('ip_src'), - flow.get('ip_dst'), - flow.get('port_src'), - flow.get('port_dst'), - flow.get('ip_proto'), - flow.get('tos'), + flow.get('mac', {}).get('src'), + flow.get('mac', {}).get('dst'), + flow.get('src', {}).get('ip'), + flow.get('dst', {}).get('ip'), + flow.get('src', {}).get('port'), + flow.get('dst', {}).get('port'), + flow.get('proto'), + flow.get('extra', {}).get('tos'), flow.get('packets'), - flow.get('flows'), - flow.get('bytes') + # flow.get('flows'), + flow.get('bytes'), ] table_body.append(table_line) # configure and fill table @@ -190,21 +242,37 @@ def _flows_table_print(flows): sys.exit(0) -# check if in-memory table is active -def _check_imt(): - if not os.path.exists(uacctd_pipefile): - print("In-memory table is not available") - sys.exit(1) - - # define program arguments cmd_args_parser = argparse.ArgumentParser(description='show flow-accounting') -cmd_args_parser.add_argument('--action', choices=['show', 'clear', 'restart'], required=True, help='command to flow-accounting daemon') -cmd_args_parser.add_argument('--filter', choices=['interface', 'host', 'ports', 'top'], required=False, nargs='*', help='filter flows to display') -cmd_args_parser.add_argument('--interface', required=False, help='interface name for output filtration') -cmd_args_parser.add_argument('--host', type=str, required=False, help='host address for output filtering') -cmd_args_parser.add_argument('--ports', type=str, required=False, help='port number, range or list for output filtering') -cmd_args_parser.add_argument('--top', type=int, required=False, help='top records for output filtering') +# 'clear' and 'restart' are not implemented +cmd_args_parser.add_argument( + '--action', + choices=['show', 'restart'], + default='show', + help='show stat or restart module', +) +cmd_args_parser.add_argument( + '--filter', + choices=['interface', 'host', 'ports', 'top'], + required=False, + nargs='*', + help='filter flows to display', +) +cmd_args_parser.add_argument( + '--interface', required=False, help='interface name for output filtration' +) +cmd_args_parser.add_argument( + '--host', type=str, required=False, help='host address for output filtering' +) +cmd_args_parser.add_argument( + '--ports', + type=str, + required=False, + help='port number, range or list for output filtering', +) +cmd_args_parser.add_argument( + '--top', type=int, required=False, help='top records for output filtering' +) # parse arguments cmd_args = cmd_args_parser.parse_args() @@ -219,30 +287,13 @@ except ValueError as e: sys.exit(1) # main logic -# do nothing if uacctd daemon is not running -if not _uacctd_running(): +# do nothing if ipt_NETFLOW is not active +if not _netflow_running(): print("flow-accounting is not active") sys.exit(1) -# restart pmacct daemon -if cmd_args.action == 'restart': - if commit_in_progress(): - print('Cannot restart flow-accounting while a commit is in progress') - exit(1) - # run command to restart flow-accounting - cmd('systemctl restart uacctd.service', - message='Failed to restart flow-accounting') - -# clear in-memory collected flows -if cmd_args.action == 'clear': - _check_imt() - # run command to clear flows - cmd(f'/usr/bin/pmacct -e -p {uacctd_pipefile}', - message='Failed to clear flows') - # show table with flows if cmd_args.action == 'show': - _check_imt() # get interfaces index and names ifaces_dict = _get_ifaces_dict() # get flows @@ -254,4 +305,22 @@ if cmd_args.action == 'show': # print flows _flows_table_print(tabledata) +if cmd_args.action == 'restart': + ipt_netflow.stop() + + # get needed interfaces + conf = ConfigTreeQuery() + config_path = ['system', 'flow-accounting'] + if not conf.exists(config_path + ['netflow', 'interface']): + print("Flow accounting not configured, exiting") + sys.exit(1) + + ingress_interfaces = conf.values(config_path + ['netflow', 'interface']) + if conf.exists(config_path + ['enable-egress']): + egress_interfaces = ingress_interfaces + else: + egress_interfaces = [] + + ipt_netflow.start(ingress_interfaces, egress_interfaces) + sys.exit(0) diff --git a/src/op_mode/force_mtu_host.sh b/src/op_mode/force_mtu_host.sh index c72fc243f..e3e24b57b 100755 --- a/src/op_mode/force_mtu_host.sh +++ b/src/op_mode/force_mtu_host.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as diff --git a/src/op_mode/force_root-partition-auto-resize.sh b/src/op_mode/force_root-partition-auto-resize.sh index b39e87560..e17f63a88 100755 --- a/src/op_mode/force_root-partition-auto-resize.sh +++ b/src/op_mode/force_root-partition-auto-resize.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as diff --git a/src/op_mode/format_disk.py b/src/op_mode/format_disk.py index dc3c96322..56cb51a02 100755 --- a/src/op_mode/format_disk.py +++ b/src/op_mode/format_disk.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -123,10 +123,10 @@ if __name__ == '__main__': f'\ndata on {target_disk}.\n') if not ask_yes_no('Do you wish to proceed?'): - print(f'Disk drive {target_disk} will not be re-formated') + print(f'Disk drive {target_disk} will not be re-formatted') exit(0) - print(f'Re-formating disk drive {target_disk}...') + print(f'Re-formatting disk drive {target_disk}...') print('Making backup copy of partitions...') backup_partitions(target_disk) diff --git a/src/op_mode/generate_interfaces_debug_archive.py b/src/op_mode/generate_interfaces_debug_archive.py index 3059aad23..cf1f23c42 100755 --- a/src/op_mode/generate_interfaces_debug_archive.py +++ b/src/op_mode/generate_interfaces_debug_archive.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -54,7 +54,7 @@ CMD_INTERFACES_LIST: list[str] = [ "ethtool --phy-statistics " ] -# get intefaces info +# get interfaces info interfaces_list = os.popen('ls /sys/class/net/').read().split() # modify CMD_INTERFACES_LIST for all interfaces diff --git a/src/op_mode/generate_ipsec_debug_archive.py b/src/op_mode/generate_ipsec_debug_archive.py index ca2eeb511..de0f25bfd 100755 --- a/src/op_mode/generate_ipsec_debug_archive.py +++ b/src/op_mode/generate_ipsec_debug_archive.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/generate_openconnect_otp_key.py b/src/op_mode/generate_openconnect_otp_key.py index 99b67d261..5cb1c6edf 100755 --- a/src/op_mode/generate_openconnect_otp_key.py +++ b/src/op_mode/generate_openconnect_otp_key.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/generate_ovpn_client_file.py b/src/op_mode/generate_ovpn_client_file.py index 1d2f1067a..a55f2e3c5 100755 --- a/src/op_mode/generate_ovpn_client_file.py +++ b/src/op_mode/generate_ovpn_client_file.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -108,13 +108,13 @@ if __name__ == '__main__': required=True, ) parser.add_argument( - "-a", "--ca", type=str, help='OpenVPN CA cerificate', required=True + "-a", "--ca", type=str, help='OpenVPN CA certificate', required=True ) parser.add_argument( - "-c", "--cert", type=str, help='OpenVPN client cerificate', required=True + "-c", "--cert", type=str, help='OpenVPN client certificate', required=True ) parser.add_argument( - "-k", "--key", type=str, help='OpenVPN client cerificate key', action="store" + "-k", "--key", type=str, help='OpenVPN client certificate key', action="store" ) args = parser.parse_args() diff --git a/src/op_mode/generate_psk.py b/src/op_mode/generate_psk.py index d51293712..816150dbf 100644 --- a/src/op_mode/generate_psk.py +++ b/src/op_mode/generate_psk.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/generate_public_key_command.py b/src/op_mode/generate_public_key_command.py index 8ba55c901..1c1246742 100755 --- a/src/op_mode/generate_public_key_command.py +++ b/src/op_mode/generate_public_key_command.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/generate_service_rule-resequence.py b/src/op_mode/generate_service_rule-resequence.py index 9333d6353..fd6354110 100755 --- a/src/op_mode/generate_service_rule-resequence.py +++ b/src/op_mode/generate_service_rule-resequence.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/generate_ssh_server_key.py b/src/op_mode/generate_ssh_server_key.py index d6063c43c..459157afd 100755 --- a/src/op_mode/generate_ssh_server_key.py +++ b/src/op_mode/generate_ssh_server_key.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -15,6 +15,8 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from sys import exit + +from vyos.defaults import directories from vyos.utils.io import ask_yes_no from vyos.utils.process import cmd from vyos.utils.commit import commit_in_progress @@ -26,6 +28,8 @@ if commit_in_progress(): print('Cannot restart SSH while a commit is in progress') exit(1) +conf_mode_dir = directories['conf_mode'] + cmd('rm -v /etc/ssh/ssh_host_*') cmd('dpkg-reconfigure openssh-server') -cmd('systemctl restart ssh.service') +cmd(f'{conf_mode_dir}/service_ssh.py') diff --git a/src/op_mode/generate_system_login_user.py b/src/op_mode/generate_system_login_user.py index 1b328eae0..c0cb69708 100755 --- a/src/op_mode/generate_system_login_user.py +++ b/src/op_mode/generate_system_login_user.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/generate_tech-support_archive.py b/src/op_mode/generate_tech-support_archive.py index 41b53cd15..d005d78ee 100755 --- a/src/op_mode/generate_tech-support_archive.py +++ b/src/op_mode/generate_tech-support_archive.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -13,34 +13,32 @@ # # 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 import argparse import glob from datetime import datetime from pathlib import Path from shutil import rmtree - from socket import gethostname from sys import exit from tarfile import open as tar_open -from vyos.utils.process import rc_cmd + +from vyos.defaults import directories +from vyos.utils.process import call +from vyos.utils.process import cmd +from vyos.utils.file import get_name_from_path from vyos.remote import upload -def op(cmd: str) -> str: - """Returns a command with the VyOS operational mode wrapper.""" - return f'/opt/vyatta/bin/vyatta-op-cmd-wrapper {cmd}' - -def save_stdout(command: str, file: Path) -> None: - rc, stdout = rc_cmd(command) - body: str = f'''### {command} ### -Command: {command} -Exit code: {rc} -Stdout: -{stdout} - -''' - with file.open(mode='a') as f: - f.write(body) + +# Example: bdbdd9a4807f_tech-support-archive_2026-02-02T13-53-18 +ARCHIVE_PATTERN = '_tech-support-archive_' +# Example: drops-debug_2026-02-02T13-53-18 +ARCHIVE_TMP_DIR_PATTERN = 'drops-debug_' +DEFAULT_TMP_DIR = '/tmp' +EXCLUDED_ARCHIVE_EXT = ('.iso', '.gz', '.tar', '.zip') + + def __rotate_logs(path: str, log_pattern:str): files_list = glob.glob(f'{path}/{log_pattern}') if len(files_list) > 5: @@ -48,12 +46,59 @@ def __rotate_logs(path: str, log_pattern:str): os.remove(oldest_file) +def __save_show_report_files(reports_dir: Path): + """ + Save result of execution `show tech-support report` command + :param reports_dir: path to the result directory + :type reports_dir: pathlib.Path + """ + + vyos_op_scripts_dir = directories['op_mode'] + script_path = f'{vyos_op_scripts_dir}/show_techsupport_report.py' + arguments = [ + '--launched-from-generate-archive', + '--outdir', + str(reports_dir), + ] + output = cmd([script_path] + arguments) + + if output.strip(): + print(output) + + def __generate_archived_files(location_path: str) -> None: """ - Generate arhives of main directories + Generate archives of main directories :param location_path: path to temporary directory :type location_path: str """ + + # sync/flush journald before archiving /var/log/journal + cmd(['journalctl', '--sync']) + cmd(['journalctl', '--flush']) + + def __tar_filter(tarinfo): + # path inside tar, because we set arcname=... below + name = tarinfo.name + basename = os.path.basename(name) + + # /var/log: exclude /var/log/messages and /var/log/messages.* + if name.startswith('var/log/messages'): + if basename == 'messages' or basename.startswith('messages.'): + return None + + # /tmp, /home: exclude previous tech-support archives and temporary archive directories + if name.startswith(('tmp/', 'home/')): + if ARCHIVE_PATTERN in name or basename.startswith(ARCHIVE_TMP_DIR_PATTERN): + return None + + # /home, /opt/vyatta/etc/config, /tmp: exclude general archives + if name.startswith(('home/', 'opt/vyatta/etc/config/', 'tmp/')): + if basename.lower().endswith(EXCLUDED_ARCHIVE_EXT): + return None + + return tarinfo + # Dictionary arhive_name:directory_to_arhive archive_dict = { 'etc': '/etc', @@ -62,87 +107,149 @@ def __generate_archived_files(location_path: str) -> None: 'root': '/root', 'tmp': '/tmp', 'core-dump': '/var/core', - 'config': '/opt/vyatta/etc/config' - } - # Dictionary arhive_name:excluding pattern - archive_excludes = { - # Old location of archives - 'config': 'tech-support-archive', - # New locations of arhives - 'tmp': 'tech-support-archive' + 'config': '/opt/vyatta/etc/config', + 'run': '/run', } + for archive_name, path in archive_dict.items(): - archive_file: str = f'{location_path}/{archive_name}.tar.gz' + if not os.path.exists(path): + continue + + arcname = str(path).lstrip('/') # e.g. /etc -> 'etc' + + archive_file = f'{location_path}/{archive_name}.tar.gz' with tar_open(name=archive_file, mode='x:gz') as tar_file: - if archive_name in archive_excludes: - tar_file.add(path, filter=lambda x: None if str(archive_excludes[archive_name]) in str(x.name) else x) - else: - tar_file.add(path) + try: + tar_file.add(path, arcname=arcname, filter=__tar_filter) + except (PermissionError, OSError) as e: + print(f'Unable to read `{path}` to archive files:', e) + continue # skip paths we can't read def __generate_main_archive_file(archive_file: str, tmp_dir_path: str) -> None: """ - Generate main arhive file - :param archive_file: name of arhive file + Generate main archive file + :param archive_file: name of archive file :type archive_file: str - :param tmp_dir_path: path to arhive memeber + :param tmp_dir_path: path to archive member :type tmp_dir_path: str """ + + arcname = get_name_from_path(archive_file) with tar_open(name=archive_file, mode='x:gz') as tar_file: - tar_file.add(tmp_dir_path, arcname=os.path.basename(tmp_dir_path)) + tar_file.add(tmp_dir_path, arcname=arcname) + +def __generate_topology_snapshots(output_dir: Path) -> None: + """ + Generates physical and logical topology PNG files using `lstopo` + + :param output_dir: directory where topology PNGs will be stored + """ + + physical_topo = output_dir / 'topology.png' + logical_topo = output_dir / 'topology-logical.png' + + # Capture physical topology + call(['lstopo', '--output-format', 'png', str(physical_topo)]) + + # Capture logical topology + call(['lstopo', '--logical', '--output-format', 'png', str(logical_topo)]) + + +def __resolve_main_archive_path(input_path: str, default_archive_name: str) -> Path: + """ + Normalize path for saving a .tar.gz file based on rules: + + Rules: + - file -> file.tar.gz + - file.tar -> file.tar.gz + - file.tgz -> file.tgz + - dir/ -> dir/{default_archive_name} + - ../dir/file.tar.gz -> (unchanged) + - file.zip -> file.tar.gz + + :param input_path: user's provided path to the archive + :param default_archive_name: name of archive if user didn't provide it + """ + + path = Path(input_path) + + # Case 1: default temporary directory -> extend by default name of file + if input_path == DEFAULT_TMP_DIR: + return path / default_archive_name + + # Case 2: already .tar.gz -> return unchanged + if path.name.endswith(('.tar.gz', '.tgz')): + return path + + # Case 3: already .tar -> .tar.gz + if path.name.endswith('.tar'): + return path.with_suffix('.tar.gz') + + # Case 4: directory (explicit trailing slash OR existing directory) + if input_path.endswith(('/', '\\')) or path.is_dir(): + dir_path = path + return dir_path / default_archive_name + + # Default behavior for any other extension + return path.with_suffix('.tar.gz') if __name__ == '__main__': - defualt_tmp_dir = '/tmp' parser = argparse.ArgumentParser() - parser.add_argument("path", nargs='?', default=defualt_tmp_dir) + parser.add_argument('path', nargs='?', default=DEFAULT_TMP_DIR) args = parser.parse_args() - location_path = args.path[:-1] if args.path[-1] == '/' else args.path hostname: str = gethostname() - time_now: str = datetime.now().isoformat(timespec='seconds').replace(":", "-") - - remote = False - tmp_path = '' - tmp_dir_path = '' - if 'ftp://' in args.path or 'scp://' in args.path: - remote = True - tmp_path = defualt_tmp_dir + time_now: str = datetime.now().isoformat(timespec='seconds').replace(':', '-') + default_archive_inner_dir = f'{hostname}{ARCHIVE_PATTERN}{time_now}' + default_archive_name = f'{default_archive_inner_dir}.tar.gz' + + is_remote = args.path.startswith(('ftp://', 'scp://')) + if is_remote: + base_tmp_path = DEFAULT_TMP_DIR + archive_dest_path = Path(f'{base_tmp_path}/{default_archive_name}') else: - tmp_path = location_path - archive_pattern = f'_tech-support-archive_' - archive_file_name = f'{hostname}{archive_pattern}{time_now}.tar.gz' + # Define destination path to the main archive file based on a rules + archive_dest_path = __resolve_main_archive_path(args.path, default_archive_name) + base_tmp_path = str(archive_dest_path.parent) + default_archive_name = archive_dest_path.name + default_archive_inner_dir = get_name_from_path(archive_dest_path.name) # Log rotation in tmp directory - if tmp_path == defualt_tmp_dir: - __rotate_logs(tmp_path, f'*{archive_pattern}*') + if base_tmp_path == DEFAULT_TMP_DIR: + __rotate_logs(base_tmp_path, f'*{ARCHIVE_PATTERN}*') # Temporary directory creation - tmp_dir_path = f'{tmp_path}/drops-debug_{time_now}' - tmp_dir: Path = Path(tmp_dir_path) + tmp_dir: Path = Path(f'{base_tmp_path}/{ARCHIVE_TMP_DIR_PATTERN}{time_now}') tmp_dir.mkdir(parents=True) - report_file: Path = Path(f'{tmp_dir_path}/show_tech-support_report.txt') - report_file.touch() + # Directory which contains list of 'tech-support' reports + reports_dir: Path = Path(f'{tmp_dir}/show_tech-support_report') + + # Call the topology snapshot function here + __generate_topology_snapshots(tmp_dir) + try: + # Generate files using `show tech-support report` command + __save_show_report_files(reports_dir) - save_stdout(op('show tech-support report'), report_file) # Generate included archives - __generate_archived_files(tmp_dir_path) + __generate_archived_files(tmp_dir) # Generate main archive - __generate_main_archive_file(f'{tmp_path}/{archive_file_name}', tmp_dir_path) - # Delete temporary directory - rmtree(tmp_dir) + __generate_main_archive_file(archive_dest_path, tmp_dir) + # Upload to remote site if it is scpecified - if remote: - upload(f'{tmp_path}/{archive_file_name}', args.path) - print(f'Debug file is generated and located in {location_path}/{archive_file_name}') + if is_remote: + upload_uri = args.path + upload(str(archive_dest_path), upload_uri) except Exception as err: print(f'Error during generating a debug file: {err}') - # cleanup - if tmp_dir.exists(): - rmtree(tmp_dir) + else: + print(f'Debug file is generated and located in {archive_dest_path}') finally: - # cleanup + # Delete temporary directory + if tmp_dir.exists(): + rmtree(tmp_dir, ignore_errors=True) exit() diff --git a/src/op_mode/igmp-proxy.py b/src/op_mode/igmp-proxy.py index 709e25915..d197e11f6 100755 --- a/src/op_mode/igmp-proxy.py +++ b/src/op_mode/igmp-proxy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/ikev2_profile_generator.py b/src/op_mode/ikev2_profile_generator.py index cf2bc6d5c..0db9ef545 100755 --- a/src/op_mode/ikev2_profile_generator.py +++ b/src/op_mode/ikev2_profile_generator.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/image_info.py b/src/op_mode/image_info.py index 56aefcd6e..119960a6f 100755 --- a/src/op_mode/image_info.py +++ b/src/op_mode/image_info.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This file is part of VyOS. # @@ -72,6 +72,14 @@ def _format_show_images_details( return tabulated +def show_images_current(raw: bool) -> Union[image.BootDetails, str]: + + images_summary = show_images_summary(raw=True) + if raw: + return {'image_running' : images_summary['image_running']} + else: + return images_summary['image_running'] + def show_images_summary(raw: bool) -> Union[image.BootDetails, str]: images_available: list[str] = grub.version_list() diff --git a/src/op_mode/image_installer.py b/src/op_mode/image_installer.py index 3fe9737da..07f69ccd5 100755 --- a/src/op_mode/image_installer.py +++ b/src/op_mode/image_installer.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2023-2025 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This file is part of VyOS. # @@ -17,14 +17,23 @@ # You should have received a copy of the GNU General Public License along with # VyOS. If not, see <https://www.gnu.org/licenses/>. -from argparse import ArgumentParser, Namespace +from argparse import ArgumentParser +from argparse import Namespace from pathlib import Path -from shutil import copy, chown, rmtree, copytree +from shutil import copy +from shutil import chown +from shutil import rmtree +from shutil import copytree +from shutil import disk_usage from glob import glob from sys import exit from os import environ from os import readlink -from os import getpid, getppid +from os import getpid +from os import getppid +from os import sync +from json import loads +from json import dumps from typing import Union from urllib.parse import urlparse from passlib.hosts import linux_context @@ -34,22 +43,42 @@ from psutil import disk_partitions from vyos.base import Warning from vyos.configtree import ConfigTree +from vyos.config_mgmt import unsaved_commits +from vyos.defaults import base_dir +from vyos.defaults import directories +from vyos.defaults import activation_hint +from vyos.flavor import get_image_serial_console from vyos.remote import download -from vyos.system import disk, grub, image, compat, raid, SYSTEM_CFG_VER +from vyos.system import disk +from vyos.system import grub +from vyos.system import image +from vyos.system import compat +from vyos.system import raid +from vyos.system import SYSTEM_CFG_VER +from vyos.system import grub_util from vyos.template import render -from vyos.utils.auth import ( - DEFAULT_PASSWORD, - EPasswdStrength, - evaluate_strength -) -from vyos.utils.io import ask_input, ask_yes_no, select_entry +from vyos.utils.auth import DEFAULT_PASSWORD +from vyos.utils.auth import EPasswdStrength +from vyos.utils.auth import evaluate_strength +from vyos.utils.auth import get_local_users +from vyos.utils.auth import get_user_home_dir +from vyos.utils.dict import dict_search +from vyos.utils.io import ask_input +from vyos.utils.io import ask_yes_no +from vyos.utils.io import select_entry from vyos.utils.file import chmod_2775 -from vyos.utils.process import cmd, run, rc_cmd +from vyos.utils.file import read_file +from vyos.utils.file import write_file +from vyos.utils.process import cmd +from vyos.utils.process import run +from vyos.utils.process import rc_cmd from vyos.version import get_version_data # define text messages MSG_ERR_NOT_LIVE: str = 'The system is already installed. Please use "add system image" instead.' MSG_ERR_LIVE: str = 'The system is in live-boot mode. Please use "install image" instead.' +MSG_ERR_NOT_ENOUGH_SPACE: str = 'Image upgrade requires at least 2GB of free drive space.' +MSG_ERR_UNSAVED_COMMITS: str = 'There are unsaved changes to the configuration. Either save or revert before upgrade.' MSG_ERR_NO_DISK: str = 'No suitable disk was found. There must be at least one disk of 2GB or greater size.' MSG_ERR_IMPROPER_IMAGE: str = 'Missing sha256sum.txt.\nEither this image is corrupted, or of era 1.2.x (md5sum) and would downgrade image tools;\ndisallowed in either case.' MSG_ERR_INCOMPATIBLE_IMAGE: str = 'Image compatibility check failed, aborting installation.' @@ -58,6 +87,7 @@ MSG_ERR_FLAVOR_MISMATCH: str = 'The current image flavor is "{0}", the new image MSG_ERR_MISSING_ARCHITECTURE: str = 'The new image version data does not specify architecture, cannot check compatibility (is it a legacy release image?)' MSG_ERR_MISSING_FLAVOR: str = 'The new image version data does not specify flavor, cannot check compatibility (is it a legacy release image?)' MSG_ERR_CORRUPT_CURRENT_IMAGE: str = 'Version data in the current image is malformed: missing flavor and/or architecture fields. Upgrade compatibility cannot be checked.' +MSG_ERR_UNSUPPORTED_SIGNATURE_TYPE: str = 'Unsupported signature type, signature cannot be verified.' MSG_INFO_INSTALL_WELCOME: str = 'Welcome to VyOS installation!\nThis command will install VyOS to your permanent storage.' MSG_INFO_INSTALL_EXIT: str = 'Exiting from VyOS installation' MSG_INFO_INSTALL_SUCCESS: str = 'The image installed successfully; please reboot now.' @@ -72,7 +102,9 @@ MSG_INFO_INSTALL_PARTITONING: str = 'Creating partition table...' MSG_INPUT_CONFIG_FOUND: str = 'An active configuration was found. Would you like to copy it to the new image?' MSG_INPUT_CONFIG_CHOICE: str = 'The following config files are available for boot:' MSG_INPUT_CONFIG_CHOOSE: str = 'Which file would you like as boot config?' +MSG_INPUT_UNSAVED_COMMITS: str = 'There are unsaved changes to the configuration. They will not be copied to the new image. Continue without saving?' MSG_INPUT_IMAGE_NAME: str = 'What would you like to name this image?' +MSG_INPUT_IMAGE_NAME_TAKEN: str = 'There is already an installed image by that name; please choose again' MSG_INPUT_IMAGE_DEFAULT: str = 'Would you like to set the new image as the default one for boot?' MSG_INPUT_PASSWORD: str = 'Please enter a password for the "vyos" user:' MSG_INPUT_PASSWORD_CONFIRM: str = 'Please confirm password for the "vyos" user:' @@ -101,6 +133,8 @@ CONST_MIN_ROOT_SIZE: int = 1610612736 # 1.5 GB CONST_RESERVED_SPACE: int = (2 + 1 + 256) * 1024**2 # define directories and paths +DIR_CONFIG: str = directories['config'] +DIR_DATA: str = directories['data'] DIR_INSTALLATION: str = '/mnt/installation' DIR_ROOTFS_SRC: str = f'{DIR_INSTALLATION}/root_src' DIR_ROOTFS_DST: str = f'{DIR_INSTALLATION}/root_dst' @@ -110,19 +144,20 @@ DIR_KERNEL_SRC: str = '/boot/' FILE_ROOTFS_SRC: str = '/usr/lib/live/mount/medium/live/filesystem.squashfs' ISO_DOWNLOAD_PATH: str = '' -external_download_script = '/usr/libexec/vyos/simple-download.py' -external_latest_image_url_script = '/usr/libexec/vyos/latest-image-url.py' +external_download_script: str = f'{base_dir}/simple-download.py' +external_latest_image_url_script: str = f'{base_dir}/latest-image-url.py' + +(flavor_sercon_type, flavor_sercon_num, flavor_sercon_speed) = get_image_serial_console() # default boot variables DEFAULT_BOOT_VARS: dict[str, str] = { 'timeout': '5', 'console_type': 'tty', - 'console_num': '0', - 'console_speed': '115200', + 'console_num': flavor_sercon_num, + 'console_speed': flavor_sercon_speed, 'bootmode': 'normal' } - def bytes_to_gb(size: int) -> float: """Convert Bytes to GBytes, rounded to 1 decimal number @@ -248,12 +283,18 @@ def search_previous_installation(disks: list[str]) -> None: print('Searching for data from previous installations') image_data = [] encrypted_configs = [] + legacy_bind_mount = False for disk_name in disks: for partition in disk.partition_list(disk_name): if disk.partition_mount(partition, mnt_tmp): if Path(mnt_tmp + '/boot').exists(): for path in Path(mnt_tmp + '/boot').iterdir(): if path.joinpath('rw/config/.vyatta_config').exists(): + legacy_bind_mount = True + image_data.append((path.name, partition)) + elif path.joinpath( + 'rw/opt/vyatta/etc/config/.vyatta_config' + ).exists(): image_data.append((path.name, partition)) if Path(mnt_tmp + '/luks').exists(): for path in Path(mnt_tmp + '/luks').iterdir(): @@ -306,7 +347,12 @@ def search_previous_installation(disks: list[str]) -> None: disk.partition_mount(image_drive, mnt_tmp) if not encrypted: - copytree(f'{mnt_tmp}/boot/{image_name}/rw/config', mnt_config) + if legacy_bind_mount: + copytree(f'{mnt_tmp}/boot/{image_name}/rw/config', mnt_config) + else: + copytree( + f'{mnt_tmp}/boot/{image_name}/rw/opt/vyatta/etc/config', mnt_config + ) else: copy(f'{mnt_tmp}/luks/{image_name}', mnt_encrypted_config) @@ -329,7 +375,7 @@ def copy_preserve_owner(src: str, dst: str, *, follow_symlinks=True): def copy_previous_installation_data(target_dir: str) -> None: if Path('/mnt/config').exists(): - copytree('/mnt/config', f'{target_dir}/opt/vyatta/etc/config', + copytree('/mnt/config', f'{target_dir}{DIR_CONFIG}', dirs_exist_ok=True) if Path('/mnt/ssh').exists(): copytree('/mnt/ssh', f'{target_dir}/etc/ssh', @@ -475,6 +521,77 @@ def setup_grub(root_dir: str) -> None: render(grub_cfg_menu, grub.TMPL_GRUB_MENU, {}) render(grub_cfg_options, grub.TMPL_GRUB_OPTS, {}) +def get_cli_kernel_options(config_file: str) -> list: + config = ConfigTree(read_file(config_file)) + config_dict = loads(config.to_json()) + cmdline_options = [] + kernel_options = dict_search('system.option.kernel', config_dict) + if kernel_options is None: + return cmdline_options + + k_cpu_opts = kernel_options.get('cpu', {}) + k_memory_opts = kernel_options.get('memory', {}) + + # XXX: This code path and if statements must be kept in sync with the Kernel + # option handling in system_options.py:generate(). This occurrence is used + # for having the appropriate options passed to GRUB after an image upgrade! + if 'disable-mitigations' in kernel_options: + cmdline_options.append('mitigations=off') + if 'disable-power-saving' in kernel_options: + cmdline_options.append('intel_idle.max_cstate=0 processor.max_cstate=1') + if 'amd-pstate-driver' in kernel_options: + mode = kernel_options['amd-pstate-driver'] + cmdline_options.append( + f'initcall_blacklist=acpi_cpufreq_init amd_pstate={mode}') + if 'quiet' in kernel_options: + cmdline_options.append('quiet') + + # Early reboot on kernel panic via kernel cmdline (must match system_option.py) + if dict_search('system.option.reboot-on-panic', config_dict) is not None: + cmdline_options.append('panic=60') + + if 'disable-hpet' in kernel_options: + cmdline_options.append('hpet=disable') + + if 'disable-mce' in kernel_options: + cmdline_options.append('mce=off') + + if 'disable-softlockup' in kernel_options: + cmdline_options.append('nosoftlockup') + + # CPU options + isol_cpus = k_cpu_opts.get('isolate-cpus') + if isol_cpus: + cmdline_options.append(f'isolcpus={isol_cpus}') + + nohz_full = k_cpu_opts.get('nohz-full') + if nohz_full: + cmdline_options.append(f'nohz_full={nohz_full}') + + rcu_nocbs = k_cpu_opts.get('rcu-no-cbs') + if rcu_nocbs: + cmdline_options.append(f'rcu_nocbs={rcu_nocbs}') + + if 'disable-nmi-watchdog' in k_cpu_opts: + cmdline_options.append('nmi_watchdog=0') + + # Memory options + if 'disable-numa-balancing' in k_memory_opts: + cmdline_options.append('numa_balancing=disable') + + default_hp_size = k_memory_opts.get('default-hugepage-size') + if default_hp_size: + cmdline_options.append(f'default_hugepagesz={default_hp_size}') + + hp_sizes = k_memory_opts.get('hugepage-size') + if hp_sizes: + for size, settings in hp_sizes.items(): + cmdline_options.append(f'hugepagesz={size}') + count = settings.get('hugepage-count') + if count: + cmdline_options.append(f'hugepages={count}') + + return cmdline_options def configure_authentication(config_file: str, password: str) -> None: """Write encrypted password to config file @@ -489,10 +606,7 @@ def configure_authentication(config_file: str, password: str) -> None: plaintext exposed """ encrypted_password = linux_context.hash(password) - - with open(config_file) as f: - config_string = f.read() - + config_string = read_file(config_file) config = ConfigTree(config_string) config.set([ 'system', 'login', 'user', 'vyos', 'authentication', @@ -505,6 +619,49 @@ def configure_authentication(config_file: str, password: str) -> None: with open(config_file, 'w') as f: f.write(config.to_string()) +def configure_serial_console(config_file: str, console_type: str) -> None: + """Apply serial console settings to config.boot from kernel cmdline. + + This overlaps with 05-serial_console.py activation logic, but that script + only runs during live boot. During installation, the user may pick a + different source config, so serial console settings must be written to + the final target config explicitly. + + Behavior: + - Reads the kernel serial console device/speed from the current boot cmdline. + - If the detected device is a valid tty, writes: + system console device <TTY> speed <rate> + - If "console_type == 'S'", also writes: + system console device <TTY> kernel + + Args: + config_file (str): path of target config file + console_type (str): 'K' (KVM/tty) or 'S' (serial) + """ + from vyos.utils.serial import is_tty + from vyos.utils.kernel import get_kernel_serial_console + + # Parse current kernel cmdline and continue only for valid serial console + # data. Prevent writing incomplete/invalid console settings to config.boot. + k_console_type, k_console_num, k_console_speed = get_kernel_serial_console() + device = f'{k_console_type}{k_console_num}' + if not is_tty(device) or not k_console_speed: + return + + base = ['system', 'console', 'device'] + config_string = read_file(config_file) + config = ConfigTree(config_string) + config.set(base + [device, 'speed'], value=k_console_speed) + config.set_tag(base) + + # Only mark this device as kernel boot console when console_type 'S' for + # serial was defined by user. + if console_type == 'S': + config.set(base + [device, 'kernel']) + + with open(config_file, 'w') as f: + f.write(config.to_string()) + def validate_signature(file_path: str, sign_type: str) -> None: """Validate a file by signature and delete a signature file @@ -514,7 +671,6 @@ def validate_signature(file_path: str, sign_type: str) -> None: """ print('Validating signature') signature_valid: bool = False - # validate with minisig if sign_type == 'minisig': pub_key_list = glob('/usr/share/vyos/keys/*.minisign.pub') for pubkey in pub_key_list: @@ -523,11 +679,8 @@ def validate_signature(file_path: str, sign_type: str) -> None: signature_valid = True break Path(f'{file_path}.minisig').unlink() - # validate with GPG - if sign_type == 'asc': - if run(f'gpg --verify ${file_path}.asc ${file_path}') == 0: - signature_valid = True - Path(f'{file_path}.asc').unlink() + else: + exit(MSG_ERR_UNSUPPORTED_SIGNATURE_TYPE) # warn or pass if not signature_valid: @@ -537,21 +690,18 @@ def validate_signature(file_path: str, sign_type: str) -> None: print('Signature is valid') def download_file(local_file: str, remote_path: str, vrf: str, - username: str, password: str, progressbar: bool = False, check_space: bool = False): - environ['REMOTE_USERNAME'] = username - environ['REMOTE_PASSWORD'] = password + # Server credentials are implicitly passed in environment variables + # that are set by add_image if vrf is None: download(local_file, remote_path, progressbar=progressbar, check_space=check_space, raise_error=True) else: - remote_auth = f'REMOTE_USERNAME={username} REMOTE_PASSWORD={password}' vrf_cmd = f'ip vrf exec {vrf} {external_download_script} \ --local-file {local_file} --remote-path {remote_path}' - cmd(vrf_cmd, auth=remote_auth) + cmd(vrf_cmd, env=environ) def image_fetch(image_path: str, vrf: str = None, - username: str = '', password: str = '', no_prompt: bool = False) -> Path: """Fetch an ISO image @@ -570,9 +720,8 @@ def image_fetch(image_path: str, vrf: str = None, if image_path == 'latest': command = external_latest_image_url_script if vrf: - command = f'REMOTE_USERNAME={username} REMOTE_PASSWORD={password} \ - ip vrf exec {vrf} ' + command - code, output = rc_cmd(command) + command = f'ip vrf exec {vrf} {command}' + code, output = rc_cmd(command, env=environ) if code: print(output) exit(MSG_INFO_INSTALL_EXIT) @@ -581,24 +730,25 @@ def image_fetch(image_path: str, vrf: str = None, try: # check a type of path if urlparse(image_path).scheme: - # download an image + # Download the image file ISO_DOWNLOAD_PATH = os.path.join(os.path.expanduser("~"), '{0}.iso'.format(uuid4())) download_file(ISO_DOWNLOAD_PATH, image_path, vrf, - username, password, progressbar=True, check_space=True) - # download a signature + # Download the image signature + # VyOS only supports minisign signatures at the moment, + # but we keep the logic for multiple signatures + # in case we add something new in the future sign_file = (False, '') - for sign_type in ['minisig', 'asc']: + for sign_type in ['minisig']: try: download_file(f'{ISO_DOWNLOAD_PATH}.{sign_type}', - f'{image_path}.{sign_type}', vrf, - username, password) + f'{image_path}.{sign_type}', vrf) sign_file = (True, sign_type) break except Exception: - print(f'{sign_type} signature is not available') - # validate a signature if it is available + print(f'Could not download {sign_type} signature') + # Validate the signature if it is available if sign_file[0]: validate_signature(ISO_DOWNLOAD_PATH, sign_file[1]) else: @@ -625,7 +775,7 @@ def migrate_config() -> bool: Returns: bool: user's decision """ - active_config_path: Path = Path('/opt/vyatta/etc/config/config.boot') + active_config_path: Path = Path(f'{DIR_CONFIG}/config.boot') if active_config_path.exists(): if ask_yes_no(MSG_INPUT_CONFIG_FOUND, default=True): return True @@ -643,6 +793,20 @@ def copy_ssh_host_keys() -> bool: return False +def copy_ssh_known_hosts() -> bool: + """Ask user to copy SSH `known_hosts` files + + Returns: + bool: user's decision + """ + known_hosts_files = get_known_hosts_files() + msg = ( + 'Would you like to save the SSH known hosts (fingerprints) ' + 'from your current configuration?' + ) + return known_hosts_files and ask_yes_no(msg, default=True) + + def console_hint() -> str: pid = getppid() if 'SUDO_USER' in environ else getpid() try: @@ -651,12 +815,11 @@ def console_hint() -> str: path = '/dev/tty' name = Path(path).name - if name == 'ttyS0': + if name.startswith(('ttyS', 'ttyAMA')): return 'S' else: return 'K' - def cleanup(mounts: list[str] = [], remove_items: list[str] = []) -> None: """Clean up after installation @@ -805,12 +968,12 @@ def install_image() -> None: print(MSG_WARN_PASSWORD_CONFIRM) # ask for default console + console_dict: dict[str, str] = {'K': 'tty', 'S': flavor_sercon_type} console_type: str = ask_input(MSG_INPUT_CONSOLE_TYPE, default=console_hint(), - valid_responses=['K', 'S']) - console_dict: dict[str, str] = {'K': 'tty', 'S': 'ttyS'} + valid_responses=console_dict.keys()) - config_boot_list = ['/opt/vyatta/etc/config/config.boot', + config_boot_list = [f'{DIR_CONFIG}/config.boot', '/opt/vyatta/etc/config.boot.default'] default_config = config_boot_list[0] @@ -843,10 +1006,10 @@ def install_image() -> None: Path(f'{DIR_DST_ROOT}/boot/efi').mkdir(parents=True) disk.partition_mount(install_target.partition['efi'], f'{DIR_DST_ROOT}/boot/efi') - # a config dir. It is the deepest one, so the comand will + # a config dir. It is the deepest one, so the command will # create all the rest in a single step print('Creating a configuration file') - target_config_dir: str = f'{DIR_DST_ROOT}/boot/{image_name}/rw/opt/vyatta/etc/config/' + target_config_dir: str = f'{DIR_DST_ROOT}/boot/{image_name}/rw{DIR_CONFIG}/' Path(target_config_dir).mkdir(parents=True) chown(target_config_dir, group='vyattacfg') chmod_2775(target_config_dir) @@ -854,6 +1017,8 @@ def install_image() -> None: copy(default_config, f'{target_config_dir}/config.boot') configure_authentication(f'{target_config_dir}/config.boot', user_password) + configure_serial_console(f'{target_config_dir}/config.boot', + console_type) Path(f'{target_config_dir}/.vyatta_config').touch() # create a persistence.conf @@ -878,6 +1043,14 @@ def install_image() -> None: write_dir: str = f'{DIR_DST_ROOT}/boot/{image_name}/rw' raid.update_default(write_dir) + # set activation hint + target_data_dir: str = f'{DIR_DST_ROOT}/boot/{image_name}/rw{DIR_DATA}/' + data_path = Path(target_data_dir) + data_path.mkdir(parents=True) + data_path.chmod(0o755) + init_hint = data_path.joinpath(Path(activation_hint).name) + init_hint.touch() + setup_grub(DIR_DST_ROOT) # add information about version grub.create_structure() @@ -897,8 +1070,7 @@ def install_image() -> None: for disk_target in l: disk.partition_mount(disk_target.partition['efi'], f'{DIR_DST_ROOT}/boot/efi') grub.install(disk_target.name, f'{DIR_DST_ROOT}/boot/', - f'{DIR_DST_ROOT}/boot/efi', - id=f'VyOS (RAID disk {l.index(disk_target) + 1})') + f'{DIR_DST_ROOT}/boot/efi') disk.partition_umount(disk_target.partition['efi']) else: print('Installing GRUB to the drive') @@ -924,7 +1096,7 @@ def install_image() -> None: except Exception as err: print(f'Unable to install VyOS: {err}') - # unmount filesystems and clenup + # unmount filesystems and cleanup try: if install_target is not None: if is_raid_install(install_target): @@ -939,6 +1111,55 @@ def install_image() -> None: exit(1) +def get_known_hosts_files(for_root=True, for_users=True) -> list: + """Collect all existing `known_hosts` files for root and/or users under /home""" + + files = [] + + if for_root: + base_files = ('/root/.ssh/known_hosts', '/etc/ssh/ssh_known_hosts') + for file_path in base_files: + root_known_hosts = Path(file_path) + if root_known_hosts.exists(): + files.append(root_known_hosts) + + if for_users: # for each non-system user + for user in get_local_users(): + home_dir = Path(get_user_home_dir(user)) + if home_dir.exists(): + known_hosts = home_dir / '.ssh' / 'known_hosts' + if known_hosts.exists(): + files.append(known_hosts) + + return files + + +def migrate_known_hosts(target_dir: str): + """Copy `known_hosts` for root and all users to the new image directory""" + + def _mkdir_and_copy_file(known_hosts_file, target_known_hosts): + target_known_hosts.parent.mkdir(parents=True, exist_ok=True) + copy(known_hosts_file, target_known_hosts) + + # Copy root only files using default path + known_hosts_files = get_known_hosts_files(for_root=True, for_users=False) + for known_hosts_file in known_hosts_files: + target_known_hosts = Path(f'{target_dir}{known_hosts_file}') + _mkdir_and_copy_file(known_hosts_file, target_known_hosts) + + # During image installation, backup critical user-specific files (e.g., known_hosts) + # from each user's home directory into /var/.users_backups/{user}. This ensures that their + # SSH configuration and trust relationships are preserved across system re-installations + # or provisioning. + # More details: https://github.com/vyos/vyos-1x/pull/4678#pullrequestreview-3169648265 + known_hosts_files = get_known_hosts_files(for_root=False, for_users=True) + for known_hosts_file in known_hosts_files: + username = known_hosts_file.parent.parent.name + base_dir = Path(f'{target_dir}/var/.users_backups/{username}') + target_known_hosts = base_dir / '.ssh' / 'known_hosts' + _mkdir_and_copy_file(known_hosts_file, target_known_hosts) + + @compat.grub_cfg_update def add_image(image_path: str, vrf: str = None, username: str = '', password: str = '', no_prompt: bool = False, force: bool = False) -> None: @@ -950,8 +1171,26 @@ def add_image(image_path: str, vrf: str = None, username: str = '', if image.is_live_boot(): exit(MSG_ERR_LIVE) + # Trying to upgrade with insufficient space can break the system. + # It's better to be on the safe side: + # our images are a bit below 1G, + # so one gigabyte to download the image plus one more to install it + # sounds like a sensible estimate. + if disk_usage('/').free < (2 * 1024**3): + exit(MSG_ERR_NOT_ENOUGH_SPACE) + + if unsaved_commits(): + if not no_prompt: + if not ask_yes_no(MSG_INPUT_UNSAVED_COMMITS, default=False): + exit() + else: + exit(MSG_ERR_UNSAVED_COMMITS) + + environ['REMOTE_USERNAME'] = username + environ['REMOTE_PASSWORD'] = password + # fetch an image - iso_path: Path = image_fetch(image_path, vrf, username, password, no_prompt) + iso_path: Path = image_fetch(image_path, vrf, no_prompt) try: # mount an ISO Path(DIR_ISO_MOUNT).mkdir(mode=0o755, parents=True) @@ -984,8 +1223,12 @@ def add_image(image_path: str, vrf: str = None, username: str = '', f'Adding image would downgrade image tools to v.{cfg_ver}; disallowed') if not no_prompt: + versions = grub.version_list() while True: image_name: str = ask_input(MSG_INPUT_IMAGE_NAME, version_name) + if image_name in versions: + print(MSG_INPUT_IMAGE_NAME_TAKEN) + continue if image.validate_name(image_name): break print(MSG_WARN_IMAGE_NAME_WRONG) @@ -997,18 +1240,45 @@ def add_image(image_path: str, vrf: str = None, username: str = '', # find target directory root_dir: str = disk.find_persistence() - # a config dir. It is the deepest one, so the comand will + cmdline_options = [] + + # a config dir. It is the deepest one, so the command will # create all the rest in a single step - target_config_dir: str = f'{root_dir}/boot/{image_name}/rw/opt/vyatta/etc/config/' + target_config_dir: str = f'{root_dir}/boot/{image_name}/rw{DIR_CONFIG}/' # copy config if no_prompt or migrate_config(): - print('Copying configuration directory') - # copytree preserves perms but not ownership: - Path(target_config_dir).mkdir(parents=True) - chown(target_config_dir, group='vyattacfg') - chmod_2775(target_config_dir) - copytree('/opt/vyatta/etc/config/', target_config_dir, symlinks=True, - copy_function=copy_preserve_owner, dirs_exist_ok=True) + if Path('/dev/mapper/vyos_config').exists(): + print('Copying encrypted configuration volume') + + # Record information from which image we upgraded to the new one. + # This can be used for a future automatic rollback into the old image. + # + # For encrypted config, we need to copy, sync filesystems and remove from current image + tmp = {'previous_image' : image.get_running_image()} + write_file('/opt/vyatta/etc/config/first_boot', dumps(tmp)) + sync() + + # Copy encrypted volumes + current_name = image.get_running_image() + current_config_path = f'{root_dir}/luks/{current_name}' + target_config_path = f'{root_dir}/luks/{image_name}' + copy(current_config_path, target_config_path) + + # Now remove from current image + Path('/opt/vyatta/etc/config/first_boot').unlink() + else: + print('Copying configuration directory') + # copytree preserves perms but not ownership: + Path(target_config_dir).mkdir(parents=True) + chown(target_config_dir, group='vyattacfg') + chmod_2775(target_config_dir) + copytree(f'{DIR_CONFIG}/', target_config_dir, symlinks=True, + copy_function=copy_preserve_owner, dirs_exist_ok=True) + + # Record information from which image we upgraded to the new one. + # This can be used for a future automatic rollback into the old image. + tmp = {'previous_image' : image.get_running_image()} + write_file(f'{target_config_dir}/first_boot', dumps(tmp)) else: Path(target_config_dir).mkdir(parents=True) chown(target_config_dir, group='vyattacfg') @@ -1023,6 +1293,11 @@ def add_image(image_path: str, vrf: str = None, username: str = '', for host_key in host_keys: copy(host_key, target_ssh_dir) + target_ssh_known_hosts_dir: str = f'{root_dir}/boot/{image_name}/rw' + if no_prompt or copy_ssh_known_hosts(): + print('Copying SSH known_hosts files') + migrate_known_hosts(target_ssh_known_hosts_dir) + # copy system image and kernel files print('Copying system image files') for file in Path(f'{DIR_ISO_MOUNT}/live').iterdir(): @@ -1040,6 +1315,13 @@ def add_image(image_path: str, vrf: str = None, username: str = '', if set_as_default: grub.set_default(image_name, root_dir) + if Path(f'{target_config_dir}/config.boot').exists(): + cmdline_options = get_cli_kernel_options( + f'{target_config_dir}/config.boot') + grub_util.update_kernel_cmdline_options(' '.join(cmdline_options), + root_dir=root_dir, + version=image_name) + except OSError as e: # if no space error, remove image dir and cleanup if e.errno == ENOSPC: diff --git a/src/op_mode/image_manager.py b/src/op_mode/image_manager.py index fb4286dbc..985db96dd 100755 --- a/src/op_mode/image_manager.py +++ b/src/op_mode/image_manager.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This file is part of VyOS. # @@ -33,7 +33,7 @@ DELETE_IMAGE_PROMPT_MSG: str = 'Select an image to delete:' MSG_DELETE_IMAGE_RUNNING: str = 'Currently running image cannot be deleted; reboot into another image first' MSG_DELETE_IMAGE_DEFAULT: str = 'Default image cannot be deleted; set another image as default first' -ConsoleType: TypeAlias = Literal['tty', 'ttyS'] +ConsoleType: TypeAlias = Literal['tty', 'ttyS', 'ttyAMA'] def annotate_list(images_list: list[str]) -> list[str]: """Annotate list of images with additional info diff --git a/src/op_mode/install_mok.sh b/src/op_mode/install_mok.sh new file mode 100755 index 000000000..29f78cd1f --- /dev/null +++ b/src/op_mode/install_mok.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +if test -f /var/lib/shim-signed/mok/vyos-dev-2025-shim.der; then + mokutil --ignore-keyring --import /var/lib/shim-signed/mok/vyos-dev-2025-shim.der; +else + echo "Secure Boot Machine Owner Key not found"; +fi diff --git a/src/op_mode/interfaces.py b/src/op_mode/interfaces.py index e7afc4caa..ecc4e73d2 100755 --- a/src/op_mode/interfaces.py +++ b/src/op_mode/interfaces.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -13,14 +13,13 @@ # # 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 import re import sys import glob import json import typing +import textwrap from datetime import datetime from tabulate import tabulate @@ -28,18 +27,14 @@ import vyos.opmode from vyos.ifconfig import Section from vyos.ifconfig import Interface from vyos.ifconfig import VRRP +from vyos.utils.dict import dict_set_nested +from vyos.utils.io import catch_broken_pipe +from vyos.utils.network import get_interface_vrf +from vyos.utils.network import interface_exists from vyos.utils.process import cmd from vyos.utils.process import rc_cmd from vyos.utils.process import call - -def catch_broken_pipe(func): - def wrapped(*args, **kwargs): - try: - func(*args, **kwargs) - except (BrokenPipeError, KeyboardInterrupt): - # Flush output to /dev/null and bail out. - os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno()) - return wrapped +from vyos.configquery import op_mode_config_dict # The original implementation of filtered_interfaces has signature: # (ifnames: list, iftypes: typing.Union[str, list], vif: bool, vrrp: bool) -> intf: Interface: @@ -84,6 +79,18 @@ def filtered_interfaces(ifnames: typing.Union[str, list], yield interface +def is_interface_has_mac(interface_name): + interface_no_mac = ('tun', 'wg') + return not any(interface_name.startswith(prefix) for prefix in interface_no_mac) + +def detailed_output(dataset, headers): + for data in dataset: + adjusted_rule = data + [""] * (len(headers) - len(data)) # account for different header length, like default-action + transformed_rule = [[header, adjusted_rule[i]] for i, header in enumerate(headers) if i < len(adjusted_rule)] # create key-pair list from headers and rules lists; wrap at 100 char + + print(tabulate(transformed_rule, tablefmt="presto")) + print() + def _split_text(text, used=0): """ take a string and attempt to split it to fit with the width of the screen @@ -109,6 +116,7 @@ def _split_text(text, used=0): continue if line: yield line[1:] + line = f' {word}' else: line = f'{line} {word}' @@ -236,10 +244,6 @@ def _get_summary_data(ifname: typing.Optional[str], iftype = '' ret = [] - def is_interface_has_mac(interface_name): - interface_no_mac = ('tun', 'wg') - return not any(interface_name.startswith(prefix) for prefix in interface_no_mac) - for interface in filtered_interfaces(ifname, iftype, vif, vrrp): res_intf = {} @@ -296,6 +300,140 @@ def _get_counter_data(ifname: typing.Optional[str], return ret +def _get_kernel_data(raw, ifname = None, detail = False, + statistics = False): + if ifname: + # Check if the interface exists + if not interface_exists(ifname): + raise vyos.opmode.IncorrectValue(f"{ifname} does not exist!") + int_name = f'dev {ifname}' + else: + int_name = '' + + kernel_interface = json.loads(cmd(f'ip -j -d -s address show {int_name}')) + + # Return early if raw + if raw: + return kernel_interface, None + + # Format the kernel data + kernel_interface_out = _format_kernel_data(kernel_interface, detail, statistics) + + return kernel_interface, kernel_interface_out + +def _format_kernel_data(data, detail, statistics): + output_list = [] + podman_vrf = {} + tmpInfo = {} + + # Sort interfaces by name + for interface in sorted(data, key=lambda x: x.get('ifname', '')): + interface_name = interface.get('ifname', '') + + # Skip VRF interfaces + if interface.get('linkinfo', {}).get('info_kind') == 'vrf': + continue + # Skip spawned interfaces + elif interface_name.startswith(('tunl', 'gre', 'erspan', 'pim6reg')): + continue + + master = interface.get('master', 'default') + vrf = get_interface_vrf(interface) + + # Get the device model; ex. Intel Corporation Ethernet Controller I225-V + dev_model = interface.get('parentdev', '') + if 'parentdev' in interface: + parentdev = interface['parentdev'] + if re.match(r'^[0-9a-fA-F]{4}:', parentdev): + dev_model = cmd(f'lspci -nn -s {parentdev}').split(']:')[1].strip() + + # Get the IP addresses on interface + ip_list = [] + has_global = False + + for ip in interface['addr_info']: + if ip.get('scope') in ('global', 'host'): + has_global = True + local = ip.get('local', '-') + prefixlen = ip.get('prefixlen', '') + ip_list.append(f"{local}/{prefixlen}") + + # If no global IP address, add '-'; indicates no IP address on interface + if not has_global: + ip_list.append('-') + + # Generate a mapping of podman interfaces to their VRF + if interface_name.startswith('pod-'): + dict_set_nested(f'{interface_name}.vrf', master, podman_vrf) + + # If the veth interface's master is a podman interface, the VRF is the VRF of the podman interface + if master.startswith('pod-'): + vrf = podman_vrf.get(master).get('vrf', 'default') + + rx_stats = interface.get('stats64', {}).get('rx') + tx_stats = interface.get('stats64', {}).get('tx') + + sl_status = ('A' if not 'UP' in interface['flags'] else 'u') + '/' + ('D' if interface['operstate'] == 'DOWN' else 'u') + + # Generate temporary dict to hold data + tmpInfo['ifname'] = interface_name + tmpInfo['ip'] = ip_list + tmpInfo['mac'] = interface.get('address', 'n/a') if is_interface_has_mac(interface_name) else 'n/a' + tmpInfo['mtu'] = interface.get('mtu', '') + tmpInfo['vrf'] = vrf + tmpInfo['status'] = sl_status + tmpInfo['description'] = "\n".join(textwrap.wrap(interface.get('ifalias', ''), width=50)) + tmpInfo['device'] = dev_model + tmpInfo['alternate_names'] = interface.get('altnames', '') + tmpInfo['minimum_mtu'] = interface.get('min_mtu', '') + tmpInfo['maximum_mtu'] = interface.get('max_mtu', '') + tmpInfo['rx_packets'] = rx_stats.get('packets', "") + tmpInfo['rx_bytes'] = rx_stats.get('bytes', "") + tmpInfo['rx_errors'] = rx_stats.get('errors', "") + tmpInfo['rx_dropped'] = rx_stats.get('dropped', "") + tmpInfo['rx_over_errors'] = rx_stats.get('over_errors', '') + tmpInfo['multicast'] = rx_stats.get('multicast', "") + tmpInfo['tx_packets'] = tx_stats.get('packets', "") + tmpInfo['tx_bytes'] = tx_stats.get('bytes', "") + tmpInfo['tx_errors'] = tx_stats.get('errors', "") + tmpInfo['tx_dropped'] = tx_stats.get('dropped', "") + tmpInfo['tx_carrier_errors'] = tx_stats.get('carrier_errors', "") + tmpInfo['tx_collisions'] = tx_stats.get('collisions', "") + + # Order the stats based on 'detail' or 'statistics' + if detail: + stat_keys = [ + "rx_packets", "rx_bytes", "rx_errors", "rx_dropped", + "rx_over_errors", "multicast", + "tx_packets", "tx_bytes", "tx_errors", "tx_dropped", + "tx_carrier_errors", "tx_collisions", + ] + elif statistics: + stat_keys = [ + "rx_packets", "rx_bytes", "tx_packets", "tx_bytes", + "rx_dropped", "tx_dropped", "rx_errors", "tx_errors", + ] + else: + stat_keys = [] + + stat_list = [tmpInfo.get(k, "") for k in stat_keys] + + # Generate output list; detail adds more fields + output_list.append([tmpInfo['ifname'], + *(['\n'.join(tmpInfo['ip'])] if not statistics else []), + *([tmpInfo['mac']] if not statistics else []), + *([tmpInfo['vrf']] if not statistics else []), + *([tmpInfo['mtu']] if not statistics else []), + *([tmpInfo['status']] if not statistics else []), + *([tmpInfo['description']] if not statistics else []), + *([tmpInfo['device']] if detail else []), + *(['\n'.join(tmpInfo['alternate_names'])] if detail else []), + *([tmpInfo['minimum_mtu']] if detail else []), + *([tmpInfo['maximum_mtu']] if detail else []), + *(stat_list if any([detail, statistics]) else [])]) + + return output_list + @catch_broken_pipe def _format_show_data(data: list): unhandled = [] @@ -445,6 +583,34 @@ def _format_show_counters(data: list): print (output) return output +def show_kernel(raw: bool, intf_name: typing.Optional[str], + detail: bool, statistics: bool): + raw_data, data = _get_kernel_data(raw, intf_name, detail, statistics) + + # Return early if raw + if raw: + return raw_data + + if detail: + # Detail headers; ex. show interfaces kernel detail; show interfaces kernel eth0 detail + detail_header = ['Interface', 'IP Address', 'MAC', 'VRF', 'MTU', 'S/L', 'Description', + 'Device', 'Alternate Names','Minimum MTU', 'Maximum MTU', 'RX_Packets', + 'RX_Bytes', 'RX_Errors', 'RX_Dropped', 'Receive Overrun Errors', 'Received Multicast', + 'TX_Packets', 'TX_Bytes', 'TX_Errors', 'TX_Dropped', 'Transmit Carrier Errors', + 'Transmit Collisions'] + elif statistics: + # Statistics headers; ex. show interfaces kernel statistics; show interfaces kernel eth0 statistics + headers = ['Interface', 'Rx Packets', 'Rx Bytes', 'Tx Packets', 'Tx Bytes', 'Rx Dropped', 'Tx Dropped', 'Rx Errors', 'Tx Errors'] + else: + # Normal headers; ex. show interfaces kernel; show interfaces kernel eth0 + print('Codes: S - State, L - Link, u - Up, D - Down, A - Admin Down') + headers = ['Interface', 'IP Address', 'MAC', 'VRF', 'MTU', 'S/L', 'Description'] + + + if detail: + detailed_output(data, detail_header) + else: + print(tabulate(data, headers)) def _show_raw(data: list, intf_name: str): if intf_name is not None and len(data) <= 1: @@ -489,6 +655,86 @@ def show_counters(raw: bool, intf_name: typing.Optional[str], return _show_raw(data, intf_name) return _format_show_counters(data) +def show_vlan_to_vni(raw: bool, intf_name: typing.Optional[str], + vid: typing.Optional[str], detail: bool, + statistics: bool): + if not interface_exists(intf_name): + raise vyos.opmode.UnconfiguredObject(f"Interface {intf_name} does not exist\n") + + if not vid: + vid = "all" + + tunnel_data = json.loads(cmd(f"bridge -j vlan tunnelshow dev {intf_name} vid {vid}")) + + if not tunnel_data: + if vid == "all": + raise vyos.opmode.UnconfiguredObject(f"No VLAN-to-VNI mapping found for interface {intf_name}\n") + else: + raise vyos.opmode.UnconfiguredObject(f"No VLAN-to-VNI mapping found for VLAN {vid}\n") + + statistics_data = json.loads(cmd(f"bridge -j -s vlan tunnelshow dev {intf_name} vid {vid}"))[0] + + mapping_config = op_mode_config_dict(['interfaces', 'vxlan', intf_name, 'vlan-to-vni'], + get_first_key=True) + + raw_data = {intf_name: {}} + output_list = [] + + for tunnel in tunnel_data[0].get("tunnels", []): + tunnel_id = tunnel.get("tunid") + tunnel_dict = raw_data[intf_name][tunnel_id] = {} + + for vlan in statistics_data.get("vlans", []): + if vlan.get("vid") == tunnel.get("vlan"): + vlan_id = str(vlan.get("vid")) + description = mapping_config.get(vlan_id, {}).get("description", "") + + # detail allows for longer descriptions; each output wraps to 80 characters + if detail: + description = "\n".join(textwrap.wrap(description, width=65)) + elif raw: + pass + else: + description = "\n".join(textwrap.wrap(description, width=48)) + + if raw: + tunnel_dict["vlan"] = vlan_id + tunnel_dict["rx_bytes"] = vlan.get("rx_bytes") + tunnel_dict["tx_bytes"] = vlan.get("tx_bytes") + tunnel_dict["rx_packets"] = vlan.get("rx_packets") + tunnel_dict["tx_packets"] = vlan.get("tx_packets") + tunnel_dict["description"] = description + else: + #Generate output list; detail adds more fields + output_list.append([ + *([intf_name] if not detail else []), + vlan_id, + tunnel_id, + *([description] if not statistics else []), + *([vlan.get("rx_packets")] if any([detail, statistics]) else []), + *([vlan.get("rx_bytes")] if any([detail, statistics]) else []), + *([vlan.get("tx_packets")] if any([detail, statistics]) else []), + *([vlan.get("tx_bytes")] if any([detail, statistics]) else []) + ]) + + if raw: + return raw_data + + if detail: + # Detail headers; ex. show interfaces vxlan vxlan1 vlan-to-vni detail + detail_header = ['VLAN', 'VNI', 'Description', 'Rx Packets', 'Rx Bytes', 'Tx Packets', 'Tx Bytes'] + print('-' * 35) + print(f"Interface: {intf_name}\n") + detailed_output(output_list, detail_header) + elif statistics: + # Statistics headers; ex. show interfaces vxlan vxlan1 vlan-to-vni statistics + headers = ['Interface', 'VLAN', 'VNI', 'Rx Packets', 'Rx Bytes', 'Tx Packets', 'Tx Bytes'] + print(tabulate(output_list, headers)) + else: + # Normal headers; ex. show interfaces vxlan vxlan1 vlan-to-vni + headers = ['Interface', 'VLAN', 'VNI', 'Description'] + print(tabulate(output_list, headers)) + def clear_counters(intf_name: typing.Optional[str], intf_type: typing.Optional[str], vif: bool, vrrp: bool): diff --git a/src/op_mode/interfaces_wireguard.py b/src/op_mode/interfaces_wireguard.py index 627af0579..b600bd3a4 100644 --- a/src/op_mode/interfaces_wireguard.py +++ b/src/op_mode/interfaces_wireguard.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -18,31 +18,13 @@ import sys import vyos.opmode from vyos.ifconfig import WireGuardIf -from vyos.configquery import ConfigTreeQuery - -def _verify(func): - """Decorator checks if WireGuard interface config exists""" - from functools import wraps - - @wraps(func) - def _wrapper(*args, **kwargs): - config = ConfigTreeQuery() - interface = kwargs.get('intf_name') - if not config.exists(['interfaces', 'wireguard', interface]): - unconf_message = f'WireGuard interface {interface} is not configured' - raise vyos.opmode.UnconfiguredSubsystem(unconf_message) - return func(*args, **kwargs) - - return _wrapper - - -@_verify +@vyos.opmode.verify_cli_exists(['interfaces', 'wireguard'], + 'WireGuard interface {interface} is not configured!') def show_summary(raw: bool, intf_name: str): intf = WireGuardIf(intf_name, create=False, debug=False) return intf.operational.show_interface() - if __name__ == '__main__': try: res = vyos.opmode.run(sys.modules[__name__]) diff --git a/src/op_mode/interfaces_wireless.py b/src/op_mode/interfaces_wireless.py index bf6e462f3..d1070e95f 100755 --- a/src/op_mode/interfaces_wireless.py +++ b/src/op_mode/interfaces_wireless.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -23,18 +23,9 @@ from tabulate import tabulate from vyos.utils.process import popen from vyos.configquery import ConfigTreeQuery -def _verify(func): - """Decorator checks if Wireless LAN config exists""" - from functools import wraps - - @wraps(func) - def _wrapper(*args, **kwargs): - config = ConfigTreeQuery() - if not config.exists(['interfaces', 'wireless']): - unconf_message = 'No Wireless interfaces configured' - raise vyos.opmode.UnconfiguredSubsystem(unconf_message) - return func(*args, **kwargs) - return _wrapper +verify_path = ['interfaces', 'wireless'] +verify_error = 'Wireless/WiFi subsystem unconfigured!' +verify_interface_error = 'Wireless interface {interface} is not configured!' def _get_raw_info_data(): output_data = [] @@ -93,7 +84,7 @@ def _get_raw_scan_data(intf_name): ssid['ssid'] = line.lstrip().split(':')[-1].lstrip() elif line.lstrip().startswith('signal: '): - # Siganl can be " signal: -67.00 dBm", thus strip all leading whitespaces + # Signal can be " signal: -67.00 dBm", thus strip all leading whitespaces ssid['signal'] = line.lstrip().split(':')[-1].split()[0] elif line.lstrip().startswith('DS Parameter set: channel'): @@ -156,7 +147,7 @@ def _format_station_data(raw_data): headers = ["Station", "Signal", "RX bytes", "RX packets", "TX bytes", "TX packets"] return tabulate(output, headers, numalign="left") -@_verify +@vyos.opmode.verify_cli_exists(verify_path, verify_error) def show_info(raw: bool): info_data = _get_raw_info_data() if raw: @@ -169,7 +160,7 @@ def show_scan(raw: bool, intf_name: str): return data return _format_scan_data(data) -@_verify +@vyos.opmode.verify_cli_exists(verify_path, verify_interface_error) def show_stations(raw: bool, intf_name: str): data = _get_raw_station_data(intf_name) if raw: diff --git a/src/op_mode/ipoe-control.py b/src/op_mode/ipoe-control.py index b7d6a0c43..632cc93cc 100755 --- a/src/op_mode/ipoe-control.py +++ b/src/op_mode/ipoe-control.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -27,7 +27,7 @@ cmd_dict = { 'actions' : { 'show_sessions' : 'show sessions', 'show_stat' : 'show stat', - 'terminate' : 'teminate' + 'terminate' : 'terminate' } } diff --git a/src/op_mode/ipsec.py b/src/op_mode/ipsec.py index 1ab50b105..3061089ee 100755 --- a/src/op_mode/ipsec.py +++ b/src/op_mode/ipsec.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -229,18 +229,48 @@ def _get_parent_sa_state(connection_name: str, data: list) -> str: ike_state = 'up' return ike_state +def _get_parent_ppk_state(connection_name: str, data: list) -> str: + """Get paren PPK state by connection name -def _get_child_sa_state(connection_name: str, tunnel_name: str, data: list) -> str: + Args: + connection_name (str): Connection name + data (list): List of current SAs from vici + + Returns: + Parent PPK state + """ + ppk_state = 'no' + if not data: + return ppk_state + for sa in data: + # check if parent PPK exists + for connection, connection_conf in sa.items(): + if connection_name != connection: + continue + if 'ppk' in connection_conf and connection_conf['ppk'].lower() == 'yes': + ppk_state = 'yes' + return ppk_state + + +def _get_child_sa_state( + connection_name: str, tunnel_name: str, data: list, mode: str +) -> str: """Get child SA state by connection and tunnel name Args: connection_name (str): Connection name tunnel_name (str): Tunnel name data (list): List of current SAs from vici + mode (str): Mode of child from vici list_connections Returns: - str: `up` if child SA state is 'installed' otherwise `down` + str: `up` if child SA state is 'installed' or child is passthrough + otherwise `down` """ + # passthrough child (trap mode) has 'PASS' mode and is always up, + # but has no sa, so is not present in list_sas (data) + if mode == 'PASS': + return 'up' child_sa = 'down' if not data: return child_sa @@ -327,10 +357,22 @@ def _get_raw_data_connections(list_connections: list, list_sas: list) -> list: base_list['local_id'] = conn_conf.get('local-1', '').get('id') base_list['remote_id'] = conn_conf.get('remote-1', '').get('id') base_list['version'] = conn_conf.get('version', 'IKE') + if conn_conf.get('ppk_id'): + if conn_conf.get('ppk_required') == 'yes': + base_list['ppk'] = 'req/' + _get_parent_ppk_state( + connection, list_sas + ) + else: + base_list['ppk'] = 'opt/' + _get_parent_ppk_state( + connection, list_sas + ) + else: + base_list['ppk'] = 'none/' + _get_parent_ppk_state(connection, list_sas) base_list['children'] = [] children = conn_conf['children'] for tunnel, tun_options in children.items(): - state = _get_child_sa_state(connection, tunnel, list_sas) + mode = tun_options.get('mode') + state = _get_child_sa_state(connection, tunnel, list_sas, mode) local_ts = tun_options.get('local-ts') remote_ts = tun_options.get('remote-ts') dpd_action = tun_options.get('dpd_action') @@ -391,6 +433,8 @@ def _get_formatted_output_conections(data): f'{entry["ike_proposal"]["hash"]}/' f'{entry["ike_proposal"]["dh"]}' ) + ppk = entry['ppk'] + connections.append( [ ike_name, @@ -402,6 +446,7 @@ def _get_formatted_output_conections(data): local_id, remote_id, proposal, + ppk, ] ) for tun in entry['children']: @@ -419,6 +464,7 @@ def _get_formatted_output_conections(data): f'{tun["esp_proposal"]["hash"]}/' f'{tun["esp_proposal"]["dh"]}' ) + ppk = '-' connections.append( [ tun_name, @@ -430,6 +476,7 @@ def _get_formatted_output_conections(data): local_id, remote_id, proposal, + ppk, ] ) connection_headers = [ @@ -442,8 +489,12 @@ def _get_formatted_output_conections(data): 'Local id', 'Remote id', 'Proposal', + 'PPK', ] - output = tabulate(connections, connection_headers, numalign='left') + output = ( + 'PPK Codes: none - Not Configured, opt - PPK is Optional, req - PPK is required, no - PPK not negotiated, yes - PPK negotiated\n' + + tabulate(connections, connection_headers, numalign='left') + ) return output @@ -453,7 +504,7 @@ def _get_formatted_output_conections(data): def _get_childsa_id_list(ike_sas: list) -> list: """ Generate list of CHILD SA ids based on list of OrderingDict - wich is returned by vici + which is returned by vici :param ike_sas: list of IKE SAs generated by vici :type ike_sas: list :return: list of IKE SAs ids @@ -472,7 +523,7 @@ def _get_con_childsa_name_list( ) -> list: """ Generate list of CHILD SA ids based on list of OrderingDict - wich is returned by vici + which is returned by vici :param ike_sas: list of IKE SAs connections generated by vici :type ike_sas: list :param filter_dict: dict of filter options @@ -739,7 +790,7 @@ def show_sa(raw: bool): def _get_output_sas_detail(ra_output_list: list) -> str: """ - Formate all IKE SAs detail output + Format all IKE SAs detail output :param ra_output_list: IKE SAs list :type ra_output_list: list :return: formatted RA IKE SAs detail output @@ -870,7 +921,7 @@ def _get_formatted_ipsec_proposal(sa: dict) -> str: def _get_output_ra_sas_detail(ra_output_list: list) -> str: """ - Formate RA IKE SAs detail output + Format RA IKE SAs detail output :param ra_output_list: IKE SAs list :type ra_output_list: list :return: formatted RA IKE SAs detail output diff --git a/src/op_mode/kernel_modules.py b/src/op_mode/kernel_modules.py index e381a1df7..5475158aa 100755 --- a/src/op_mode/kernel_modules.py +++ b/src/op_mode/kernel_modules.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/lldp.py b/src/op_mode/lldp.py index fac622b81..6d77db5bc 100755 --- a/src/op_mode/lldp.py +++ b/src/op_mode/lldp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/load-balancing_haproxy.py b/src/op_mode/load-balancing_haproxy.py index ae6734e16..3ea016677 100755 --- a/src/op_mode/load-balancing_haproxy.py +++ b/src/op_mode/load-balancing_haproxy.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/load-balancing_wan.py b/src/op_mode/load-balancing_wan.py index 9fa473802..6f1d00dcd 100755 --- a/src/op_mode/load-balancing_wan.py +++ b/src/op_mode/load-balancing_wan.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -56,13 +56,15 @@ def _get_raw_data(): return data def _get_formatted_output(raw_data): + from time import time + for ifname, if_data in raw_data.items(): latest_change = if_data['last_success'] if if_data['last_success'] > if_data['last_failure'] else if_data['last_failure'] change_dt = datetime.fromtimestamp(latest_change) if latest_change > 0 else None success_dt = datetime.fromtimestamp(if_data['last_success']) if if_data['last_success'] > 0 else None failure_dt = datetime.fromtimestamp(if_data['last_failure']) if if_data['last_failure'] > 0 else None - now = datetime.utcnow() + now = datetime.fromtimestamp(time()) fmt_data = { 'ifname': ifname, diff --git a/src/op_mode/log.py b/src/op_mode/log.py index 797ba5a88..0bf44cf6a 100755 --- a/src/op_mode/log.py +++ b/src/op_mode/log.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/maya_date.py b/src/op_mode/maya_date.py index 847b543e0..2d5a13ab9 100755 --- a/src/op_mode/maya_date.py +++ b/src/op_mode/maya_date.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (c) 2013, 2018 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -168,7 +168,7 @@ class MayaDate(object): """ The start date is not the beginning of both cycles, it's 4 Ajaw. So we need to add 4 to the 13 days cycle day, - and substract 1 from the 20 day cycle to get correct result. + and subtract 1 from the 20 day cycle to get correct result. """ tzolkin_13 = (days + 4) % 13 tzolkin_20 = (days - 1) % 20 @@ -181,7 +181,7 @@ class MayaDate(object): """ Returns haab date string. The time start on 8 Kumk'u rather than 0 Pop, which is - 17 days before the new haab, so we need to substract 17 + 17 days before the new haab, so we need to subtract 17 from the current date to get correct result. """ days = self.days diff --git a/src/op_mode/memory.py b/src/op_mode/memory.py index eb530035b..20d937243 100755 --- a/src/op_mode/memory.py +++ b/src/op_mode/memory.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/mtr.py b/src/op_mode/mtr.py index 522cbe008..646d95e7b 100644 --- a/src/op_mode/mtr.py +++ b/src/op_mode/mtr.py @@ -1,6 +1,6 @@ #! /usr/bin/env python3 -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/mtr_execute.py b/src/op_mode/mtr_execute.py index 2585a7ee4..b97e46a1f 100644 --- a/src/op_mode/mtr_execute.py +++ b/src/op_mode/mtr_execute.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/multicast.py b/src/op_mode/multicast.py index 0666f8af3..096d01665 100755 --- a/src/op_mode/multicast.py +++ b/src/op_mode/multicast.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/nat.py b/src/op_mode/nat.py index c6cf4770a..f97d7dc0f 100755 --- a/src/op_mode/nat.py +++ b/src/op_mode/nat.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -144,9 +144,11 @@ def _get_formatted_output_rules(data, direction, family): if 'expr' in rule['rule']: interface = rule.get('rule').get('expr')[0].get('match').get('right') \ if jmespath.search('rule.expr[*].match.left.meta', rule) else 'any' + if interface[0] == '@': + interface = interface[3:] for index, match in enumerate(jmespath.search('rule.expr[*].match', rule)): if 'payload' in match['left']: - # Handle NAT rule containing comma-seperated list of ports + # Handle NAT rule containing comma-separated list of ports if (isinstance(match['right'], dict) and ('prefix' in match['right'] or 'set' in match['right'] or 'range' in match['right'])): @@ -154,7 +156,10 @@ def _get_formatted_output_rules(data, direction, family): my_dict = {**match['left']['payload'], **match['right']} my_dict['op'] = match['op'] op = '!' if my_dict.get('op') == '!=' else '' - proto = my_dict.get('protocol').upper() + if my_dict['field'] in ['sport', 'dport']: + proto = my_dict.get('protocol').upper() + if proto == 'TH': + proto = 'TCP, UDP' if my_dict['field'] == 'saddr': saddr = f'{op}{my_dict["prefix"]["addr"]}/{my_dict["prefix"]["len"]}' elif my_dict['field'] == 'daddr': @@ -166,6 +171,10 @@ def _get_formatted_output_rules(data, direction, family): # Handle NAT rule containing a single port else: field = jmespath.search('left.payload.field', match) + if field in ['sport', 'dport']: + proto = jmespath.search('left.payload.protocol', match).upper() + if proto == 'TH': + proto = 'TCP, UDP' if field == 'saddr': saddr = match.get('right') elif field == 'daddr': @@ -186,8 +195,12 @@ sport {sport}''' destination = f'''{daddr} dport {dport}''' - if jmespath.search('left.payload.field', match) == 'protocol': - field_proto = match.get('right').upper() + if jmespath.search('left.meta.key', match) == 'l4proto': + right = match.get('right') + if isinstance(right, dict) and 'set' in right: + proto = ', '.join(right['set']) + elif isinstance(right, str): + proto = right.upper() for expr in rule.get('rule').get('expr'): if 'snat' in expr: diff --git a/src/op_mode/neighbor.py b/src/op_mode/neighbor.py index 8b3c45c7c..9bbad94e1 100755 --- a/src/op_mode/neighbor.py +++ b/src/op_mode/neighbor.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -109,7 +109,7 @@ def reset(family: ArgFamily, interface: typing.Optional[str], address: typing.Op run(f"""ip --family {family} neighbor flush dev {interface}""") else: # Flush an entire neighbor table - run(f"""ip --family {family} neighbor flush""") + run(f"""ip --family {family} neighbor flush all""") if __name__ == '__main__': try: @@ -119,4 +119,3 @@ if __name__ == '__main__': except (ValueError, vyos.opmode.Error) as e: print(e) sys.exit(1) - diff --git a/src/op_mode/ntp.py b/src/op_mode/ntp.py index 6ec0fedcb..42d0c1b00 100644 --- a/src/op_mode/ntp.py +++ b/src/op_mode/ntp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/openconnect-control.py b/src/op_mode/openconnect-control.py index b70d4fa16..dec5b9482 100755 --- a/src/op_mode/openconnect-control.py +++ b/src/op_mode/openconnect-control.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/openconnect.py b/src/op_mode/openconnect.py index 62c683ebb..35df25856 100755 --- a/src/op_mode/openconnect.py +++ b/src/op_mode/openconnect.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/openvpn.py b/src/op_mode/openvpn.py index 092873909..7347ac757 100755 --- a/src/op_mode/openvpn.py +++ b/src/op_mode/openvpn.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/otp.py b/src/op_mode/otp.py index a4ab9b22b..aceb75660 100755 --- a/src/op_mode/otp.py +++ b/src/op_mode/otp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 - -# Copyright 2017, 2022 VyOS maintainers and contributors <maintainers@vyos.io> +# +# Copyright 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 diff --git a/src/op_mode/ping.py b/src/op_mode/ping.py index 583d8792c..f52dfa7de 100755 --- a/src/op_mode/ping.py +++ b/src/op_mode/ping.py @@ -1,6 +1,6 @@ #! /usr/bin/env python3 -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -97,7 +97,7 @@ options = { 'no-loopback': { 'ping': '{command} -L', 'type': 'noarg', - 'help': 'Supress loopback of multicast pings' + 'help': 'Suppress loopback of multicast pings' }, 'pattern': { 'ping': '{command} -p {value}', diff --git a/src/op_mode/pki.py b/src/op_mode/pki.py index 49a461e9e..bf8bba657 100755 --- a/src/op_mode/pki.py +++ b/src/op_mode/pki.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -26,6 +26,7 @@ from cryptography.x509.oid import ExtendedKeyUsageOID import vyos.opmode +from vyos.base import Warning from vyos.config import Config from vyos.config import config_dict_mangle_acme from vyos.pki import encode_certificate @@ -417,7 +418,7 @@ def parse_san_string(san_string): output.append(ipaddress.IPv6Address(value)) elif tag == 'dns' or tag == 'rfc822': output.append(value) - return + return output def generate_certificate_request( @@ -1251,6 +1252,7 @@ def show_certificate_authority( def show_certificate( raw: bool, name: typing.Optional[str] = None, + private: typing.Optional[bool] = False, pem: typing.Optional[bool] = False, fingerprint: typing.Optional[ArgsFingerprint] = None, ): @@ -1281,12 +1283,31 @@ def show_certificate( if not cert: continue - if name and pem: + if name and pem and not (private or fingerprint): print(encode_certificate(cert)) return - elif name and fingerprint: + elif name and fingerprint and not private: print(get_certificate_fingerprint(cert, fingerprint)) return + elif name and private: + if 'private' in cert_dict and 'key' in cert_dict['private']: + protected = 'password_protected' in cert_dict['private'] + private_key = load_private_key( + cert_dict['private']['key'], + passphrase=None, + wrap_tags=True, + ) + if private_key: + print(encode_private_key(private_key, passphrase=None)) + else: + if protected: + print(f'Private key for certificate "{cert_name}" is ' + 'password-protected and cannot be displayed') + else: + print(f'Failed to load private key for certificate "{cert_name}"') + else: + print(f'No private key found for certificate "{cert_name}"') + return ca_name = get_certificate_ca(cert, ca_certs) cert_subject_cn = cert.subject.rfc4514_string().split(',')[0] @@ -1373,6 +1394,27 @@ def show_all(raw: bool): print('\n') show_crl(raw) +def renew_certbot(raw: bool, force: typing.Optional[bool] = False): + from vyos.defaults import directories + + certbot_config = directories['certbot'] + vyos_conf_scripts_dir = directories['conf_mode'] + + if force and not os.path.isdir(f'{certbot_config}'): + # Assume someone deleted the certbot_config folder, renew alone will not + # work as there are no configuration files left to know what to renew. + # Re-run CLI PKI helper to initially request certificates via ACME + # again. This should never be the case - but sometimes the universe has + # a bad time + Warning(f'Directory "{certbot_config}" missing. Reinitializing PKI ' \ + 'subsystem...\n\n') + out = cmd(f'sudo sg vyattacfg -c "{vyos_conf_scripts_dir}/pki.py"') + elif force: + out = cmd(f'sudo sg vyattacfg -c "{vyos_conf_scripts_dir}/pki.py certbot_renew_force"') + else: + out = cmd(f'sudo sg vyattacfg -c "{vyos_conf_scripts_dir}/pki.py certbot_renew"') + + print(out) if __name__ == '__main__': try: diff --git a/src/op_mode/policy_route.py b/src/op_mode/policy_route.py index d12465008..966dc80f9 100755 --- a/src/op_mode/policy_route.py +++ b/src/op_mode/policy_route.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/powerctrl.py b/src/op_mode/powerctrl.py index c32a2be7d..a60c33dc6 100755 --- a/src/op_mode/powerctrl.py +++ b/src/op_mode/powerctrl.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/ppp-server-ctrl.py b/src/op_mode/ppp-server-ctrl.py index 2bae5b32a..96b497549 100755 --- a/src/op_mode/ppp-server-ctrl.py +++ b/src/op_mode/ppp-server-ctrl.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/qos.py b/src/op_mode/qos.py index 464b552ee..47a7a3d59 100755 --- a/src/op_mode/qos.py +++ b/src/op_mode/qos.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/raid.py b/src/op_mode/raid.py index fed8ae2c3..985ef730f 100755 --- a/src/op_mode/raid.py +++ b/src/op_mode/raid.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/reset_openvpn.py b/src/op_mode/reset_openvpn.py index cef5299da..e3345435a 100755 --- a/src/op_mode/reset_openvpn.py +++ b/src/op_mode/reset_openvpn.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/reset_vpn.py b/src/op_mode/reset_vpn.py index 61d7c8c81..4ff1740d2 100755 --- a/src/op_mode/reset_vpn.py +++ b/src/op_mode/reset_vpn.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/reset_wireguard.py b/src/op_mode/reset_wireguard.py index 1fcfb31b5..ad8ea0346 100755 --- a/src/op_mode/reset_wireguard.py +++ b/src/op_mode/reset_wireguard.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -16,35 +16,16 @@ import sys import typing - import vyos.opmode from vyos.ifconfig import WireGuardIf -from vyos.configquery import ConfigTreeQuery - - -def _verify(func): - """Decorator checks if WireGuard interface config exists""" - from functools import wraps - - @wraps(func) - def _wrapper(*args, **kwargs): - config = ConfigTreeQuery() - interface = kwargs.get('interface') - if not config.exists(['interfaces', 'wireguard', interface]): - unconf_message = f'WireGuard interface {interface} is not configured' - raise vyos.opmode.UnconfiguredSubsystem(unconf_message) - return func(*args, **kwargs) - return _wrapper - - -@_verify +@vyos.opmode.verify_cli_exists(['interfaces', 'wireguard'], + 'WireGuard interface {interface} is not configured!') def reset_peer(interface: str, peer: typing.Optional[str] = None): intf = WireGuardIf(interface, create=False, debug=False) return intf.operational.reset_peer(peer) - if __name__ == '__main__': try: res = vyos.opmode.run(sys.modules[__name__]) diff --git a/src/op_mode/restart.py b/src/op_mode/restart.py index efa835485..4f05d6eb8 100755 --- a/src/op_mode/restart.py +++ b/src/op_mode/restart.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as @@ -26,11 +26,11 @@ config = ConfigTreeQuery() service_map = { 'dhcp': { - 'systemd_service': 'kea-dhcp4-server', + 'systemd_service': 'isc-kea-dhcp4-server', 'path': ['service', 'dhcp-server'], }, 'dhcpv6': { - 'systemd_service': 'kea-dhcp6-server', + 'systemd_service': 'isc-kea-dhcp6-server', 'path': ['service', 'dhcpv6-server'], }, 'dns_dynamic': { diff --git a/src/op_mode/restart_dhcp_relay.py b/src/op_mode/restart_dhcp_relay.py index 42626cac4..99c2c20b6 100755 --- a/src/op_mode/restart_dhcp_relay.py +++ b/src/op_mode/restart_dhcp_relay.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/restart_frr.py b/src/op_mode/restart_frr.py index 83146f5ec..188d99037 100755 --- a/src/op_mode/restart_frr.py +++ b/src/op_mode/restart_frr.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/route.py b/src/op_mode/route.py index 4aa57dbf4..b11b0ccc2 100755 --- a/src/op_mode/route.py +++ b/src/op_mode/route.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/secure_boot.py b/src/op_mode/secure_boot.py index 5f6390a15..a2d4c9e72 100755 --- a/src/op_mode/secure_boot.py +++ b/src/op_mode/secure_boot.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/serial.py b/src/op_mode/serial.py index a5864872b..e9f9fc121 100644 --- a/src/op_mode/serial.py +++ b/src/op_mode/serial.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/sflow.py b/src/op_mode/sflow.py index 0f3feb35a..668d0b7b3 100755 --- a/src/op_mode/sflow.py +++ b/src/op_mode/sflow.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/show-bond.py b/src/op_mode/show-bond.py index f676e0841..19abc440a 100755 --- a/src/op_mode/show-bond.py +++ b/src/op_mode/show-bond.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -60,7 +60,7 @@ elif args.slaves: cfg_dict['mode'] = tmp.get_mode() cfg_dict['admin_state'] = tmp.get_admin_state() cfg_dict['oper_state'] = tmp.operational.get_state() - cfg_dict['members'] = tmp.get_slaves() + cfg_dict['members'] = tmp.get_members() data.append(cfg_dict) elif args.interface: @@ -74,7 +74,7 @@ elif args.interface: # each bond member interface has its own statistics data['members'] = [] - for member in BondIf(args.interface).get_slaves(): + for member in BondIf(args.interface).get_members(): tmp = {} tmp['ifname'] = member tmp['rx_bytes'] = read_file(f'/sys/class/net/{member}/statistics/rx_bytes') diff --git a/src/op_mode/show_acceleration.py b/src/op_mode/show_acceleration.py index 1c4831f1d..05c591356 100755 --- a/src/op_mode/show_acceleration.py +++ b/src/op_mode/show_acceleration.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -26,7 +26,7 @@ from vyos.utils.process import popen def detect_qat_dev(): output, err = popen('lspci -nn', decode='utf-8') if not err: - data = re.findall('(8086:19e2)|(8086:37c8)|(8086:0435)|(8086:6f54)', output) + data = re.findall('(8086:19e2)|(8086:37c[8-9])|(8086:0435)|(8086:6f54)', output) # QAT devices found if data: return @@ -71,7 +71,7 @@ def get_qat_proc_path(qat_dev): q_bsf = q_list[1] return "/sys/kernel/debug/qat_"+q_type+"_"+q_bsf+"/" -# Check if QAT service confgured +# Check if QAT service configured def check_qat_if_conf(): if not Config().exists_effective('system acceleration qat'): print("\t system acceleration qat is not configured") @@ -92,8 +92,8 @@ args = parser.parse_args() if args.hw: detect_qat_dev() - # Show availible Intel QAT devices - call('lspci -nn | egrep -e \'8086:37c8|8086:19e2|8086:0435|8086:6f54\'') + # Show available Intel QAT devices + call('lspci -nn | egrep -e \'8086:37c[8-9]|8086:19e2|8086:0435|8086:6f54\'') elif args.flow and args.dev: check_qat_if_conf() call('cat '+get_qat_proc_path(args.dev)+"fw_counters") diff --git a/src/op_mode/show_bonding_detail.sh b/src/op_mode/show_bonding_detail.sh new file mode 100755 index 000000000..62265daa2 --- /dev/null +++ b/src/op_mode/show_bonding_detail.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +if [ -f "/proc/net/bonding/$1" ]; then + cat "/proc/net/bonding/$1"; +else + echo "Interface $1 does not exist!"; +fi diff --git a/src/op_mode/show_configuration_json.py b/src/op_mode/show_configuration_json.py index fdece533b..4e4b4d386 100755 --- a/src/op_mode/show_configuration_json.py +++ b/src/op_mode/show_configuration_json.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/show_openconnect_otp.py b/src/op_mode/show_openconnect_otp.py index 3771fb385..61e52d01e 100755 --- a/src/op_mode/show_openconnect_otp.py +++ b/src/op_mode/show_openconnect_otp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# Copyright 2017-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -97,7 +97,7 @@ def display_otp_ocserv(username, params, info): if __name__ == '__main__': parser = argparse.ArgumentParser(add_help=False, description='Show OTP authentication information for selected user') parser.add_argument('--user', action="store", type=str, default='', help='Username') - parser.add_argument('--info', action="store", type=str, default='full', help='Wich information to display') + parser.add_argument('--info', action="store", type=str, default='full', help='Which information to display') args = parser.parse_args() if check_uname_otp(args.user): diff --git a/src/op_mode/show_openvpn.py b/src/op_mode/show_openvpn.py index 6abafc8b6..9f2708b69 100755 --- a/src/op_mode/show_openvpn.py +++ b/src/op_mode/show_openvpn.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/show_openvpn_mfa.py b/src/op_mode/show_openvpn_mfa.py index 100c42154..a08fd33ae 100755 --- a/src/op_mode/show_openvpn_mfa.py +++ b/src/op_mode/show_openvpn_mfa.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2017-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/op_mode/show_ppp_stats.sh b/src/op_mode/show_ppp_stats.sh new file mode 100755 index 000000000..d9c17f966 --- /dev/null +++ b/src/op_mode/show_ppp_stats.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +if [ -d "/sys/class/net/$1" ]; then + /usr/sbin/pppstats "$1"; +fi diff --git a/src/op_mode/show_sensors.py b/src/op_mode/show_sensors.py index 5e3084fe9..b7ff05178 100755 --- a/src/op_mode/show_sensors.py +++ b/src/op_mode/show_sensors.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2017-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -17,25 +17,26 @@ import re import sys + from vyos.utils.process import popen from vyos.utils.process import DEVNULL -output,retcode = popen("sensors --no-adapter", stderr=DEVNULL) +output, retcode = popen("sensors --no-adapter", stderr=DEVNULL) if retcode == 0: print (output) sys.exit(0) else: - output,retcode = popen("sensors-detect --auto",stderr=DEVNULL) - match = re.search(r'#----cut here----(.*)#----cut here----',output, re.DOTALL) + output, retcode = popen("sensors-detect --auto", stderr=DEVNULL) + match = re.search(r'#----cut here----(.*)#----cut here----', output, + re.DOTALL) if match: for module in match.group(0).split('\n'): if not module.startswith("#"): popen("modprobe {}".format(module.strip())) - output,retcode = popen("sensors --no-adapter", stderr=DEVNULL) + output, retcode = popen("sensors --no-adapter", stderr=DEVNULL) if retcode == 0: - print (output) + print(output) sys.exit(0) - -print ("No sensors found") +print("No sensors found") sys.exit(1) diff --git a/src/op_mode/show_techsupport_report.py b/src/op_mode/show_techsupport_report.py index 32cf67778..2434da29a 100644 --- a/src/op_mode/show_techsupport_report.py +++ b/src/op_mode/show_techsupport_report.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -16,235 +16,321 @@ import os import sys -from typing import List +import argparse +from pathlib import Path +from dataclasses import dataclass +from dataclasses import field +from typing import Callable +from typing import Optional +from typing import Sequence + from vyos.ifconfig import Section from vyos.ifconfig import Interface from vyos.utils.process import rc_cmd +from vyos.utils.process import wrap_op as op -def print_header(command: str) -> None: - """Prints a command with headers '-'. - - Example: +@dataclass(frozen=True) +class BaseSpec: + # Available only for 'show tech-support report' (not 'generate tech-support archive') + report_only: Optional[bool] = field(kw_only=True, default=False) - % print_header('Example command') - --------------- - Example command - --------------- - """ - header_length = len(command) * '-' - print(f"\n{header_length}\n{command}\n{header_length}") +@dataclass(frozen=True) +class CommandSpec(BaseSpec): + header: str # Display header for a command section + command: str # Shell command to execute -def execute_command(command: str, header_text: str) -> None: - """Executes a command and prints the output with a header. +@dataclass(frozen=True) +class FuncSpec(BaseSpec): + name: str # Display name for a function section + fn: Callable[ + ['Runner'], None + ] # Callable that receives a Runner and writes output via it - Example: - % execute_command('uptime', "Uptime of the system") - -------------------- - Uptime of the system - -------------------- - 20:21:57 up 9:04, 5 users, load average: 0.00, 0.00, 0.0 +class OutputSink: + """Writes output to stdout and/or a file, depending on parameters""" - """ - print_header(header_text) - try: - rc, output = rc_cmd(command) - # Enable unbuffered print param to improve responsiveness of printed - # output to end user - print(output, flush=True) - # Exit gracefully when user interrupts program output - # Flush standard streams; redirect remaining output to devnull - # Resolves T5633: Bug #1 and 3 - except (BrokenPipeError, KeyboardInterrupt): - os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno()) - sys.exit(1) - except Exception as e: - print(f"Error executing command: {command}") - print(f"Error message: {e}") + def __init__(self, *, file_path: Optional[Path]): + # Target file path; None means stdout + self._file_path = file_path + self._fh = None + def __enter__(self) -> 'OutputSink': + # Open output file if not writing to stdout + if not self.is_stdout: + self._file_path.parent.mkdir(parents=True, exist_ok=True) + # Use text mode, overwrite per run + self._fh = self._file_path.open('w', encoding='utf-8', errors='replace') + return self -def op(cmd: str) -> str: - """Returns a command with the VyOS operational mode wrapper.""" - return f'/opt/vyatta/bin/vyatta-op-cmd-wrapper {cmd}' + def __exit__(self, exc_type, exc, tb): + if self._fh is not None: + self._fh.close() + self._fh = None + @property + def is_stdout(self): + # True when output is directed to stdout + return self._file_path is None -def get_ethernet_interfaces() -> List[Interface]: + def write(self, text: str): + # Enable unbuffered print param to improve responsiveness of printed + # output to end user + if self.is_stdout: + print(text, end='', flush=True) + else: + if self._fh is not None: + self._fh.write(text) + self._fh.flush() + + +class Runner: + """Executes commands and writes output to the provided sink""" + + def __init__(self, sink: OutputSink): + self.sink = sink + + def exec(self, command: str, header: str = None): + texts = [] + + if header: + texts.append(header) + texts.append(f'Command: {command}') + + try: + # Start a new section for this command + self.section('\n'.join(texts)) + + rc, output = rc_cmd(command) + # Ensure output ends with newline when present + if output and not output.endswith('\n'): + output += '\n' + + self.sink.write(output) + if rc not in (0, None): + self.sink.write( + f'Command `{command}` returned non-zero ({rc}) exit status\n' + ) + except (BrokenPipeError, KeyboardInterrupt): + raise + except Exception as e: + self.sink.write(f'Error executing command: {command}\n') + self.sink.write(f'Error message: {e}\n') + + def section(self, title: str): + """Just print a section header without running a command""" + self.sink.write(header_block(title)) + + +def header_block(title: str, delimiter='-') -> str: + """Create an underline/overline header block for multiline text.""" + lines = title.splitlines() + max_len = max(len(line) for line in lines) + line = delimiter * max_len + title_block = '\n'.join(lines) + return f'\n{line}\n{title_block}\n{line}\n' + + +def select_reports(args: argparse.Namespace) -> dict[str, tuple[BaseSpec]]: + reports = REPORTS.copy() + requested = args.reports + + if requested: + missing = [r for r in requested if r not in reports] + if missing: + known = ', '.join(sorted(reports.keys())) + raise SystemExit(f'Unknown report(s): {", ".join(missing)}. Known: {known}') + + reports = {name: reports[name] for name in requested} + + if args.launched_from_generate_archive: + for key in reports.keys(): + items = reports[key] + reports[key] = {item for item in items if not item.report_only} + + return reports + + +def execute_item(item: BaseSpec, runner: Runner) -> None: + if isinstance(item, CommandSpec): + runner.exec(item.command, item.header) + elif isinstance(item, FuncSpec): + runner.section(item.name) + item.fn(runner) + else: + assert False, f'Unsupported report type: {type(item)}' + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: + p = argparse.ArgumentParser(description='VyOS tech-support command collector') + p.add_argument( + '--outdir', + type=Path, + default=None, + help=( + 'Directory to write report files into (one file per report group). ' + 'If this option is omitted, files are written to stdout.' + ), + ) + p.add_argument( + '--reports', + nargs='*', + default=[], + help=( + 'Which report groups to run (default: all). ' + 'Example: --reports vyos-main-info' + ), + ) + p.add_argument( + '--launched-from-generate-archive', + action='store_true', + default=False, + help=( + 'A boolean flag indicates that command executed for generating ' + 'tech-support archive (`generate tech-support archive`). ' + 'In this case some sections will be ignored because already available in archive.' + ), + ) + return p.parse_args(argv) + + +def main(argv: Sequence[str]): + args = parse_args(argv) + + # Select which reports to execute + chosen = select_reports(args) + + # Execute each report and write to its own sink + for report_name, commands in chosen.items(): + # Use a per-report output file when `outdir` is provided + file_path = (args.outdir / report_name) if args.outdir else None + + with OutputSink(file_path=file_path) as sink: + runner = Runner(sink) + try: + # Write top-level report header + sink.write(header_block(report_name, delimiter='=')) + + # Execute each item in the report + for item in commands: + execute_item(item, runner) + except (BrokenPipeError, KeyboardInterrupt): + if sink.is_stdout: + # Exit gracefully when user interrupts program output + # Flush standard streams; redirect remaining output to devnull + # Resolves T5633: Bug #1 and 3 + os.dup2( + os.open(os.devnull, os.O_WRONLY), + sys.stdout.fileno(), # pylint: disable = no-member + ) + sys.exit(1) + + +def get_ethernet_interfaces() -> list[Interface]: """Returns a list of Ethernet interfaces.""" return Section.interfaces('ethernet') -def show_version() -> None: - """Prints the VyOS version and package changes.""" - execute_command(op('show version'), 'VyOS Version and Package Changes') - - -def show_config_file() -> None: - """Prints the contents of a configuration file with a header.""" - execute_command('cat /opt/vyatta/etc/config/config.boot', 'Configuration file') - - -def show_running_config() -> None: - """Prints the running configuration.""" - execute_command(op('show configuration'), 'Running configuration') - - -def show_package_repository_config() -> None: - """Prints the package repository configuration file.""" - execute_command('cat /etc/apt/sources.list', 'Package Repository Configuration File') - execute_command('ls -l /etc/apt/sources.list.d/', 'Repositories') - - -def show_user_startup_scripts() -> None: - """Prints the user startup scripts.""" - execute_command('cat /config/scripts/vyos-preconfig-bootup.script', 'User Startup Scripts (Preconfig)') - execute_command('cat /config/scripts/vyos-postconfig-bootup.script', 'User Startup Scripts (Postconfig)') - - -def show_frr_config() -> None: - """Prints the FRR configuration.""" - execute_command('vtysh -c "show run"', 'FRR configuration') - - -def show_interfaces() -> None: - """Prints the interfaces.""" - execute_command(op('show interfaces'), 'Interfaces') - - -def show_interface_statistics() -> None: - """Prints the interface statistics.""" - execute_command('ip -s link show', 'Interface statistics') - - -def show_physical_interface_statistics() -> None: +def show_physical_interface_statistics(r: Runner): """Prints the physical interface statistics.""" - execute_command('/usr/bin/true', 'Physical Interface statistics') + for iface in get_ethernet_interfaces(): - # Exclude vlans - if '.' in iface: + if '.' in iface: # exclude VLANs continue - execute_command(f'ethtool --driver {iface}', f'ethtool --driver {iface}') - execute_command(f'ethtool --statistics {iface}', f'ethtool --statistics {iface}') - execute_command(f'ethtool --show-ring {iface}', f'ethtool --show-ring {iface}') - execute_command(f'ethtool --show-coalesce {iface}', f'ethtool --show-coalesce {iface}') - execute_command(f'ethtool --pause {iface}', f'ethtool --pause {iface}') - execute_command(f'ethtool --show-features {iface}', f'ethtool --show-features {iface}') - execute_command(f'ethtool --phy-statistics {iface}', f'ethtool --phy-statistics {iface}') - execute_command('netstat --interfaces', 'netstat --interfaces') - execute_command('netstat --listening', 'netstat --listening') - execute_command('cat /proc/net/dev', 'cat /proc/net/dev') + r.exec(f'ethtool --driver {iface}') + r.exec(f'ethtool --statistics {iface}') + r.exec(f'ethtool --show-ring {iface}') + r.exec(f'ethtool --show-coalesce {iface}') + r.exec(f'ethtool --pause {iface}') + r.exec(f'ethtool --show-features {iface}') + r.exec(f'ethtool --phy-statistics {iface}') + r.exec(f'ethtool --module-info {iface}') -def show_bridge() -> None: - """Show bridge interfaces.""" - execute_command(op('show bridge'), 'Show bridge') + for path in Path('/run/udev/vyos').glob('*'): + if path.is_file(): + r.exec(f'cat {path.resolve()}') -def show_arp() -> None: - """Prints ARP entries.""" - execute_command(op('show arp'), 'ARP Table (Total entries)') - execute_command(op('show ipv6 neighbors'), 'show ipv6 neighbors') +def _exec_list_op(r: Runner, commands: list): + for command in commands: + r.exec(op(command)) -def show_route() -> None: +def show_route(r: Runner): """Prints routing information.""" - cmd_list_route = [ - "show ip route bgp | head -108", - "show ip route cache", - "show ip route connected", - "show ip route forward", - "show ip route isis | head -108", - "show ip route kernel", - "show ip route ospf | head -108", - "show ip route rip", - "show ip route static", - "show ip route summary", - "show ip route supernets-only", - "show ip route table all", - "show ip route vrf all", - "show ipv6 route bgp | head -108", - "show ipv6 route cache", - "show ipv6 route connected", - "show ipv6 route forward", - "show ipv6 route isis", - "show ipv6 route kernel", - "show ipv6 route ospfv3", - "show ipv6 route rip", - "show ipv6 route static", - "show ipv6 route summary", - "show ipv6 route table all", - "show ipv6 route vrf all", + commands = [ + 'show ip route bgp | head -108', + 'show ip route connected', + 'show ip route forward | head -108', + 'show ip route isis | head -108', + 'show ip route kernel', + 'show ip route ospf | head -108', + 'show ip route rip | head -108', + 'show ip route static', + 'show ip route summary', + 'show ip route supernets-only | head -108', + 'show ip route table all | head -108', + 'show ip route vrf all | head -108', + 'show ipv6 route bgp | head -108', + 'show ipv6 route connected', + 'show ipv6 route forward | head -108', + 'show ipv6 route isis | head -108', + 'show ipv6 route kernel', + 'show ipv6 route ospfv3 | head -108', + 'show ipv6 route rip | head -108', + 'show ipv6 route static', + 'show ipv6 route summary', + 'show ipv6 route table all | head -108', + 'show ipv6 route vrf all | head -108', ] - for command in cmd_list_route: - execute_command(op(command), command) - - -def show_firewall() -> None: - """Prints firweall information.""" - execute_command('sudo nft list ruleset', 'nft list ruleset') - + _exec_list_op(r, commands) -def show_system() -> None: - """Prints system parameters.""" - execute_command(op('show version'), 'Show System Version') - execute_command(op('show system storage'), 'Show System Storage') - execute_command(op('show system image details'), 'Show System Image Details') +def show_evpn(r: Runner): + """Prints EVPN information.""" -def show_date() -> None: - """Print the current date.""" - execute_command('date', 'Current Time') - - -def show_installed_packages() -> None: - """Prints installed packages.""" - execute_command('dpkg --list', 'Installed Packages') - - -def show_loaded_modules() -> None: - """Prints loaded modules /proc/modules""" - execute_command('cat /proc/modules', 'Loaded Modules') - - -def show_cpu_statistics() -> None: - """Prints CPU statistics.""" - execute_command('/usr/bin/true', 'CPU') - execute_command('lscpu', 'Installed CPU\'s') - execute_command('top --iterations 1 --batch-mode --accum-time-toggle', 'Cumulative CPU Time Used by Running Processes') - execute_command('cat /proc/loadavg', 'Load Average') - - -def show_system_interrupts() -> None: - """Prints system interrupts.""" - execute_command('cat /proc/interrupts', 'Hardware Interrupt Counters') - - -def show_soft_irqs() -> None: - """Prints soft IRQ's.""" - execute_command('cat /proc/softirqs', 'Soft IRQ\'s') + commands = [ + 'show evpn mac vni all', + 'show evpn next-hops vni all', + 'show evpn rmac vni all', + 'show evpn access-vlan', + 'show evpn arp-cache vni all', + 'show evpn es', + 'show evpn es-evi', + ] + _exec_list_op(r, commands) -def show_softnet_statistics() -> None: - """Prints softnet statistics.""" - execute_command('cat /proc/net/softnet_stat', 'cat /proc/net/softnet_stat') +def show_mpls(r: Runner): + """Prints MPLS information.""" + commands = [ + 'show mpls pseudowire', + 'show mpls table', + 'show mpls ldp binding', + 'show mpls ldp discovery', + 'show mpls ldp interface', + 'show mpls ldp neighbor', + ] + _exec_list_op(r, commands) -def show_running_processes() -> None: - """Prints current running processes""" - execute_command('ps -ef', 'Running Processes') +def show_rpki(r: Runner): + """Prints RPKI information.""" -def show_memory_usage() -> None: - """Prints memory usage""" - execute_command('/usr/bin/true', 'Memory') - execute_command('cat /proc/meminfo', 'Installed Memory') - execute_command('free', 'Memory Usage') + commands = [ + 'show rpki cache-server', + 'show rpki cache-connection', + ] + _exec_list_op(r, commands) -def list_disks(): +def _list_disks(): disks = set() with open('/proc/partitions') as partitions_file: for line in partitions_file: @@ -254,60 +340,211 @@ def list_disks(): return disks -def show_storage() -> None: +def show_storage(r: Runner): """Prints storage information.""" - execute_command('cat /proc/devices', 'Devices') - execute_command('cat /proc/partitions', 'Partitions') - - for disk in list_disks(): - execute_command(f'fdisk --list /dev/{disk}', f'Partitioning for disk {disk}') - - -def main(): - # Configuration data - show_version() - show_config_file() - show_running_config() - show_package_repository_config() - show_user_startup_scripts() - show_frr_config() - - # Interfaces - show_interfaces() - show_interface_statistics() - show_physical_interface_statistics() - show_bridge() - show_arp() - - # Routing - show_route() - - # Firewall - show_firewall() - - # System - show_system() - show_date() - show_installed_packages() - show_loaded_modules() - - # CPU - show_cpu_statistics() - show_system_interrupts() - show_soft_irqs() - show_softnet_statistics() - - # Memory - show_memory_usage() - - # Storage - show_storage() - - # Processes - show_running_processes() - - # TODO: Get information from clouds - -if __name__ == "__main__": - main() + r.exec('cat /proc/mounts', 'Mount table') + r.exec('cat /proc/partitions', 'Partitions table') + + for disk in _list_disks(): + r.exec(f'fdisk --list /dev/{disk}', f'Partitioning for disk {disk}') + + r.exec('df -ah', 'Filesystem usage') + r.exec('df -ahi', 'Filesystem inode usage') + + +def show_kernel_interface_counters(r: Runner): + """Prints kernel network interface counters by fixed format.""" + + headers = ( + 'bytes', + 'packets', + 'errs', + 'drop', + 'fifo', + 'frame', + 'compressed', + 'multicast', + ) + new_headers = ( + ['Interface'] + [f'RX-{h}' for h in headers] + [f'TX-{h}' for h in headers] + ) + echo_template = ' '.join(new_headers) + awk_template = ','.join([f'${i}' for i in range(1, len(new_headers) + 1)]) + + cmd = ( + """(echo "{0}" && awk 'NR>2 {{print {1}}}' /proc/net/dev) | column -t""".format( + echo_template, awk_template + ) + ) + r.exec(cmd, 'cat /proc/net/dev | column -t') + + +REPORTS: dict[str, tuple[BaseSpec]] = { + 'vyos-main-info': ( + CommandSpec('VyOS version and package info', op('show version')), + CommandSpec( + 'Running configuration (commands)', op('show configuration commands') + ), + CommandSpec('Running configuration (structured)', op('show configuration')), + CommandSpec( + 'Configuration file (config.boot)', + 'cat /opt/vyatta/etc/config/config.boot', + report_only=True, # Ignored because already exists in 'tech-support archive' + ), + CommandSpec('Interfaces summary', op('show interfaces')), + CommandSpec('Bridge status', op('show bridge')), + CommandSpec('ARP table', op('show arp')), + CommandSpec('IPv6 neighbor table', op('show ipv6 neighbors')), + CommandSpec('System storage overview', op('show system storage')), + CommandSpec('Installed images details', op('show system image details')), + CommandSpec( + 'User startup script (preconfig)', + 'cat /config/scripts/vyos-preconfig-bootup.script', + report_only=True, + ), + CommandSpec( + 'User startup script (postconfig)', + 'cat /config/scripts/vyos-postconfig-bootup.script', + report_only=True, + ), + ), + 'routing-info': ( + FuncSpec('Routing table (IPv4/IPv6)', show_route), + CommandSpec('BFD peers', op('show bfd peers')), + FuncSpec('EVPN status', show_evpn), + FuncSpec('MPLS status', show_mpls), + FuncSpec('RPKI status', show_rpki), + ), + 'frr-info': ( + CommandSpec('FRR running configuration', 'vtysh -c "show running-config"'), + CommandSpec('FRR memory usage', 'vtysh -c "show memory"'), + CommandSpec('FRR work queues', 'vtysh -c "show work-queues"'), + CommandSpec('FRR IPv4 nexthop tracking (NHT)', 'vtysh -c "show ip nht"'), + CommandSpec('FRR IPv6 nexthop tracking (NHT)', 'vtysh -c "show ipv6 nht"'), + CommandSpec('FRR DMVPN status', 'vtysh -c "show dmvpn"'), + CommandSpec('FRR event CPU stats', 'vtysh -c "show event cpu"'), + CommandSpec('FRR event poll stats', 'vtysh -c "show event poll"'), + CommandSpec('FRR event timers', 'vtysh -c "show event timers"'), + CommandSpec( + 'FRR SRv6 locator', + 'vtysh -c "show segment-routing srv6 locator"', + ), + CommandSpec( + 'FRR SRv6 manager', + 'vtysh -c "show segment-routing srv6 manager"', + ), + ), + 'proc-and-sysctl-info': ( + CommandSpec('System load average', 'cat /proc/loadavg'), + FuncSpec('Kernel network interface counters', show_kernel_interface_counters), + CommandSpec('Loaded kernel modules', 'cat /proc/modules'), + CommandSpec('Hardware interrupt counters', 'cat /proc/interrupts'), + CommandSpec('SoftIRQ counters', 'cat /proc/softirqs'), + CommandSpec('Softnet statistics', 'cat /proc/net/softnet_stat'), + CommandSpec('Memory info', 'cat /proc/meminfo'), + CommandSpec( + 'NUMA node memory info', + 'cat /sys/devices/system/node/node*/meminfo', + ), + CommandSpec('VM statistics', 'cat /proc/vmstat'), + CommandSpec('Registered character/block devices', 'cat /proc/devices'), + CommandSpec('Kernel command line', 'cat /proc/cmdline'), + CommandSpec('All sysctl values', 'sysctl -a'), + ), + 'net-and-processes-info': ( + CommandSpec('Network interfaces', 'netstat --interfaces'), + CommandSpec('Listening sockets', 'netstat --listening'), + CommandSpec('Socket summary', 'ss -s'), + CommandSpec('All sockets with details', 'ss -a -e -m -p'), + CommandSpec('Full process listing', 'ps -eF'), + ), + 'ethtool-info': ( + CommandSpec( + 'Link details and interface counters', + 'ip -s -d link show', + ), + FuncSpec('Physical interface statistics', show_physical_interface_statistics), + ), + 'lspci-and-numa-info': ( + CommandSpec('PCI devices', 'lspci -knnv'), + CommandSpec('', 'numactl --hardware'), + CommandSpec('', 'numastat -cm'), + ), + 'nftables-info': ( + CommandSpec('nftables ruleset', 'nft list ruleset'), + CommandSpec('VyOS firewall configuration', op('show firewall')), + CommandSpec('VyOS firewall zone policy', op('show firewall zone-policy')), + ), + 'dpkg-and-modules-info': ( + CommandSpec('Installed packages', 'dpkg --list'), + CommandSpec( + 'Diff dpkg status (image vs running system)', + 'diff /usr/lib/live/mount/rootfs/*.squashfs/var/lib/dpkg/status /var/lib/dpkg/status', + ), + CommandSpec('APT sources list', 'cat /etc/apt/sources.list'), + CommandSpec( + 'APT sources.d directory listing', 'ls -l /etc/apt/sources.list.d/' + ), + CommandSpec('Loaded kernel modules', 'lsmod'), + ), + 'system-resources-info': ( + CommandSpec('Current time (date)', 'date'), + CommandSpec('CPU information', 'lscpu'), + CommandSpec( + 'Per-process cumulative CPU usage snapshot (top batch, accumulated)', + 'top --iterations 1 --batch-mode --accum-time-toggle', + ), + CommandSpec('atop snapshot (CPU view)', 'atop -a -y -1 -c -g -C 1 1 | tee'), + CommandSpec('atop snapshot (memory view)', 'atop -a -y -1 -c -m -M 1 1 | tee'), + CommandSpec('atop snapshot (disk view)', 'atop -a -y -1 -c -d -D 1 1 | tee'), + CommandSpec('atop snapshot (network view)', 'atop -a -y -1 -c -n -N 1 1 | tee'), + CommandSpec('Memory usage', 'free -lhv'), + FuncSpec('Storage overview', show_storage), + ), + 'ipsec-debug-info': ( + CommandSpec('strongSwan connections', 'swanctl -L'), + CommandSpec('strongSwan loaded connections', 'swanctl -l'), + CommandSpec('strongSwan policies', 'swanctl -P'), + CommandSpec('XFRM security associations', 'ip x sa show'), + CommandSpec('XFRM policies', 'ip x policy show'), + CommandSpec('XFRM state', 'ip xfrm state'), + CommandSpec('Tunnels', 'ip tunnel show'), + CommandSpec('Addresses', 'ip address'), + CommandSpec('Policy routing rules', 'ip rule show'), + CommandSpec('Routes', 'ip route | head -100'), + CommandSpec('Routes from table 220', 'ip route show table 220'), + ), + 'vpp-info': ( + CommandSpec('', 'cat /run/vpp/vpp.conf'), + CommandSpec('', 'vppctl show version verbose cmdline'), + CommandSpec('', 'vppctl show hardware-interfaces'), + CommandSpec('', 'vppctl show interface address'), + CommandSpec('', 'vppctl show interface'), + CommandSpec('', 'vppctl show errors'), + CommandSpec('', 'vppctl show runtime'), + CommandSpec( + '', + 'vppctl show memory api-segment stats-segment numa-heaps main-heap map verbose', + ), + CommandSpec('', 'vppctl show buffers'), + CommandSpec('', 'vppctl show physmem detail'), + CommandSpec('', 'vppctl show physmem map'), + CommandSpec('', 'vppctl show cpu'), + CommandSpec('', 'vppctl show threads'), + CommandSpec('', 'vppctl show node counters'), + CommandSpec('', 'vppctl show l2fib'), + CommandSpec('', 'vppctl show bridge-domain'), + CommandSpec('', 'vppctl show ip fib | head -100'), + CommandSpec('', 'vppctl show ip neighbors'), + CommandSpec('', 'vppctl show ip6 fib | head -100'), + CommandSpec('', 'vppctl show ip6 neighbors'), + CommandSpec('', 'vppctl show mpls fib'), + CommandSpec('', 'vppctl show mpls tunnel'), + CommandSpec('', 'vppctl show trace'), + ), +} + + +if __name__ == '__main__': + main(sys.argv[1:]) diff --git a/src/op_mode/show_usb_serial.py b/src/op_mode/show_usb_serial.py index 973bf19c8..7c1d2a07f 100755 --- a/src/op_mode/show_usb_serial.py +++ b/src/op_mode/show_usb_serial.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/show_users.py b/src/op_mode/show_users.py index 82bd585c9..086c8b1e2 100755 --- a/src/op_mode/show_users.py +++ b/src/op_mode/show_users.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -13,15 +13,15 @@ # # 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 argparse -import pwd import struct import sys from time import ctime from tabulate import tabulate from vyos.config import Config - +from vyos.utils.auth import get_local_passwd_entries class UserInfo: def __init__(self, uid, name, user_type, is_locked, login_time, tty, host): @@ -79,7 +79,9 @@ def list_users(): vyos_users = cfg.list_effective_nodes('system login user') users = [] with open('/var/log/lastlog', 'rb') as lastlog_file: - for (name, _, uid, _, _, _, _) in pwd.getpwall(): + for entry in get_local_passwd_entries(): + name = entry.pw_name + uid = entry.pw_uid lastlog_info = decode_lastlog(lastlog_file, uid) if lastlog_info is None: continue diff --git a/src/op_mode/show_virtual_server.py b/src/op_mode/show_virtual_server.py index 7880edc97..5c6ba4f4e 100755 --- a/src/op_mode/show_virtual_server.py +++ b/src/op_mode/show_virtual_server.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/show_vpp_interfaces.py b/src/op_mode/show_vpp_interfaces.py new file mode 100755 index 000000000..64f1eb086 --- /dev/null +++ b/src/op_mode/show_vpp_interfaces.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import argparse +import json +from tabulate import tabulate + +from vyos.configquery import ConfigTreeQuery +from vyos.utils.process import rc_cmd + +from vyos.vpp import VPPControl +from vyos.vpp.utils import ( + vpp_ifaces_list, + vpp_ip_addresses_by_index, + vpp_ifaces_stats, +) + + +def get_iproute_address_list(interface: str) -> list: + """Get data from the Linux command 'ip --json address list dev {interface}' and return a list info + for the given interface. + + Args: + interface (str): Interface name. + + Returns: + list: A dictionary containing the JSON data from the 'ip --json address list' command for the specified interface. + """ + rc, out = rc_cmd(f'ip --json address list dev {interface}') + if rc: + return [] + return json.loads(out) + + +def get_iproute_link_list(interface): + """Get data from the Linux command 'ip --json link show dev {interface}' and return a list info + for the given interface. + + Args: + interface (str): Interface name. + + Returns: + list: A dictionary containing the JSON data from the 'ip --json link show' command for the specified interface. + """ + rc, out = rc_cmd(f'ip --json link list dev {interface}') + if rc: + return [] + return json.loads(out) + + +def merge_dicts(*dicts) -> dict: + """Merge dictionaries into a new dictionary. + + Args: + *dicts: Any number of dictionaries. + + Returns: + dict: A new dictionary containing all the key-value pairs from the given dictionaries. + """ + merged = {} + for dictionary in dicts: + merged.update(dictionary) + return merged + + +def show_interfaces(interfaces_list: list) -> str: + """Get JSON info from linux and represent it in a table format + Use tabulate to generate table + + Interface IP Address Mtu S/L Description + --------- ---------- --- --- ----------- + dum0 203.0.113.1/32 1500 u/u + 100.64.1.1/24 + eth0 192.168.122.14/24 1500 u/u WAN + + :return: + """ + table = [] + for interface in interfaces_list: + # Get the data for the interface + ip_address_data = get_iproute_address_list(interface) + link_data = get_iproute_link_list(interface) + + # Skip this interface if data is not available + if not link_data: + continue + interface_data = merge_dicts(ip_address_data[0], link_data[0]) + + # Get the interface name + interface_name = interface_data['ifname'] + + # Get the IP addresses and their corresponding prefixes + ip_info = [ + (address['local'], address.get('prefixlen', '')) + for address in interface_data['addr_info'] + ] + + # Format the IP addresses with prefixes and line breaks + ip_addresses = '\n'.join(f'{ip}/{prefix}' for ip, prefix in ip_info) + + # Get the MAC address + mac = interface_data.get('address', 'n/a') + + # Get the MTU + mtu = interface_data.get('mtu') + + # Get the state of the interface + state = interface_data['operstate'].lower() + + # Get the description of the interface + description = interface_data.get('ifalias', '') + + # Create the list of values for the table + values = [interface_name, ip_addresses, mac, mtu, state, description] + + # Append the list of values to the table + table.append(values) + + # Print the table with IP addresses listed on separate lines + headers = ['Interface', 'IP Address', 'MAC', 'MTU', 'State', 'Description'] + return tabulate(table, headers=headers, tablefmt='simple') + + +def show_interfaces_dataplane(interfaces_list: list, filter_type: str = 'all') -> str: + table = [] + interface_dp_filter = ('tun', 'tap') + lcp_pair_list = vpp.lcp_pairs_list() + vpp_name_kernel_to_kernel_name = { + entry['vpp_name_kernel']: entry['kernel_name'] for entry in lcp_pair_list + } + for interface in interfaces_list: + interface_name = interface.get('interface_name') + if filter_type == 'no_tun_tap' and interface_name.startswith( + interface_dp_filter + ): + continue + if filter_type == 'only_tun_tap' and not interface_name.startswith( + interface_dp_filter + ): + continue + kernel_name = vpp_name_kernel_to_kernel_name.get(interface_name, '') + + dp_ip_addresses = vpp_ip_addresses_by_index( + vpp.api, interface.get('sw_if_index') + ) + dp_ipv6_addresses = vpp_ip_addresses_by_index( + vpp.api, interface.get('sw_if_index'), is_ipv6=True + ) + ip_addresses = '\n'.join(dp_ip_addresses + dp_ipv6_addresses) + + mac = str(interface.get('l2_address', 'n/a')) + mtu = interface.get('mtu', [])[0] + # state + flags = interface.get('flags') + state = 'up' if flags == 3 else 'down' + + iftype = interface.get('interface_dev_type').split()[0] + + values = [kernel_name, interface_name, iftype, ip_addresses, mac, mtu, state] + table.append(values) + headers = [ + 'Kernel', + 'Dataplane', + 'Type', + 'IP Address', + 'MAC', + 'MTU', + 'State', + ] + table = sorted(table) + return tabulate(table, headers=headers, tablefmt='simple') + + +def show_interfaces_hardware(intf_name) -> str: + if not intf_name: + intf_name = '' + + statistics = vpp_ifaces_stats(intf_name) + for intf, stats in sorted(statistics.items()): + print(f'\n---------------------------------\nInterface {intf}:\n') + table = [] + for k, v in stats.items(): + if isinstance(v, dict): + for i, j in v.items(): + table.append([f"{k} {i}", j]) + else: + table.append([k, v]) + print(tabulate(table, tablefmt="presto")) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Show VPP interfaces') + parser.add_argument( + '--summary', + action='store_true', + help='Show summary of VPP interfaces (ethernet and kernel tun)', + ) + parser.add_argument( + '--dataplane', action='store_true', help='Show VPP ethernet interfaces' + ) + parser.add_argument( + '--kernel', action='store_true', help='Show VPP kernel interfaces' + ) + parser.add_argument( + '--iproute', action='store_true', help='Show interfaces (iproute2)' + ) + parser.add_argument( + '--hardware', + action='store_true', + help='Show more detailed statistics for VPP interfaces', + ) + parser.add_argument('--intf-name', action='store', help='Kernel interface name') + + args = parser.parse_args() + + config = ConfigTreeQuery() + + if not config.exists('vpp settings interface'): + print('VPP interfaces not configured') + exit(0) + + vpp = VPPControl() + dp_ifaces_list = vpp_ifaces_list(vpp.api) + + if args.summary: + print(show_interfaces_dataplane(dp_ifaces_list, filter_type='all')) + + if args.dataplane: + print(show_interfaces_dataplane(dp_ifaces_list, filter_type='no_tun_tap')) + exit(0) + + if args.kernel: + print(show_interfaces_dataplane(dp_ifaces_list, filter_type='only_tun_tap')) + + if args.iproute: + vpp_interfaces = [] + vpp_ethernet = config.list_nodes('vpp settings interface') + vpp_interfaces.extend(vpp_ethernet) + if config.exists('interfaces vpp'): + for iface_type in config.list_nodes('interfaces vpp'): + vpp_kernel_interfaces = config.list_nodes( + f'interfaces vpp {iface_type}' + ) + vpp_interfaces.extend(vpp_kernel_interfaces) + print(show_interfaces(interfaces_list=vpp_interfaces)) + + if args.hardware: + show_interfaces_hardware(intf_name=args.intf_name) diff --git a/src/op_mode/show_wwan.py b/src/op_mode/show_wwan.py index bd97bb0e5..05e7d0e75 100755 --- a/src/op_mode/show_wwan.py +++ b/src/op_mode/show_wwan.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/snmp.py b/src/op_mode/snmp.py index 3d6cd220a..c7dfb51ef 100755 --- a/src/op_mode/snmp.py +++ b/src/op_mode/snmp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -22,7 +22,7 @@ from vyos.utils.process import call config_file_daemon = r'/etc/snmp/snmpd.conf' -parser = argparse.ArgumentParser(description='Retrieve infomration from running SNMP daemon') +parser = argparse.ArgumentParser(description='Retrieve information from running SNMP daemon') parser.add_argument('--allowed', action="store_true", help='Show available SNMP communities') parser.add_argument('--community', action="store", help='Show status of given SNMP community', type=str) parser.add_argument('--host', action="store", help='SNMP host to connect to', type=str, default='localhost') diff --git a/src/op_mode/snmp_ifmib.py b/src/op_mode/snmp_ifmib.py index c71febac9..d733540f5 100755 --- a/src/op_mode/snmp_ifmib.py +++ b/src/op_mode/snmp_ifmib.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -28,7 +28,7 @@ from vyos.utils.process import popen parser = argparse.ArgumentParser(description='Retrieve SNMP interfaces information') parser.add_argument('--ifindex', action='store', nargs='?', const='all', help='Show interface index') -parser.add_argument('--ifalias', action='store', nargs='?', const='all', help='Show interface aliase') +parser.add_argument('--ifalias', action='store', nargs='?', const='all', help='Show interface alias') parser.add_argument('--ifdescr', action='store', nargs='?', const='all', help='Show interface description') def show_ifindex(intf): diff --git a/src/op_mode/snmp_v3.py b/src/op_mode/snmp_v3.py index abeb524dd..94c6691b0 100755 --- a/src/op_mode/snmp_v3.py +++ b/src/op_mode/snmp_v3.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/ssh.py b/src/op_mode/ssh.py index 0c51576b0..a4442c301 100755 --- a/src/op_mode/ssh.py +++ b/src/op_mode/ssh.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2017-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/op_mode/storage.py b/src/op_mode/storage.py index 8fd2ffea1..0f3fabe32 100755 --- a/src/op_mode/storage.py +++ b/src/op_mode/storage.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -13,10 +13,8 @@ # # 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 - import vyos.opmode from jinja2 import Template @@ -28,9 +26,6 @@ Used: {{used}} ({{use_percentage}}%) Available: {{avail}} ({{avail_percentage}}%) """ -def _get_formatted_output(): - return _get_system_storage() - def show(raw: bool): from vyos.utils.disk import get_persistent_storage_stats @@ -49,7 +44,7 @@ def show(raw: bool): tmpl = Template(output_tmpl) return tmpl.render(data).strip() - return output + return None if __name__ == '__main__': try: @@ -59,4 +54,3 @@ if __name__ == '__main__': except (ValueError, vyos.opmode.Error) as e: print(e) sys.exit(1) - diff --git a/src/op_mode/stp.py b/src/op_mode/stp.py new file mode 100755 index 000000000..c2a897183 --- /dev/null +++ b/src/op_mode/stp.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 +import typing +import json +from tabulate import tabulate + +import vyos.opmode +from vyos.utils.process import cmd +from vyos.utils.network import interface_exists + +def detailed_output(dataset, headers): + for data in dataset: + adjusted_rule = data + [""] * (len(headers) - len(data)) # account for different header length, like default-action + transformed_rule = [[header, adjusted_rule[i]] for i, header in enumerate(headers) if i < len(adjusted_rule)] # create key-pair list from headers and rules lists; wrap at 100 char + + print(tabulate(transformed_rule, tablefmt="presto")) + print() + +def _get_bridge_vlan_data(iface): + allowed_vlans = [] + native_vlan = None + vlanData = json.loads(cmd(f"bridge -j -d vlan show")) + for vlans in vlanData: + if vlans['ifname'] == iface: + for allowed in vlans['vlans']: + if "flags" in allowed and "PVID" in allowed["flags"]: + native_vlan = allowed['vlan'] + elif allowed.get('vlanEnd', None): + allowed_vlans.append(f"{allowed['vlan']}-{allowed['vlanEnd']}") + else: + allowed_vlans.append(str(allowed['vlan'])) + + if not allowed_vlans: + allowed_vlans = ["none"] + if not native_vlan: + native_vlan = "none" + + return ",".join(allowed_vlans), native_vlan + +def _get_stp_data(ifname, brInfo, brStatus): + tmpInfo = {} + + tmpInfo['bridge_name'] = brInfo.get('ifname') + tmpInfo['up_state'] = brInfo.get('operstate') + tmpInfo['priority'] = brInfo.get('linkinfo').get('info_data').get('priority') + tmpInfo['vlan_filtering'] = "Enabled" if brInfo.get('linkinfo').get('info_data').get('vlan_filtering') == 1 else "Disabled" + tmpInfo['vlan_protocol'] = brInfo.get('linkinfo').get('info_data').get('vlan_protocol') + + # The version of VyOS I tested had am issue with the "ip -d link show type bridge" + # output. The root_id was always the local bridge, even though the underlying system + # understood when it wasn't. Could be an upstream Bug. I pull from the "/sys/class/net" + # structure instead. This can be changed later if the "ip link" behavior is corrected. + + #tmpInfo['bridge_id'] = brInfo.get('linkinfo').get('info_data').get('bridge_id') + #tmpInfo['root_id'] = brInfo.get('linkinfo').get('info_data').get('root_id') + + tmpInfo['bridge_id'] = cmd(f"cat /sys/class/net/{brInfo.get('ifname')}/bridge/bridge_id").split('.') + tmpInfo['root_id'] = cmd(f"cat /sys/class/net/{brInfo.get('ifname')}/bridge/root_id").split('.') + + # The "/sys/class/net" structure stores the IDs without separators like ':' or '.' + # This adds a ':' after every 2 characters to make it resemble a MAC Address + tmpInfo['bridge_id'][1] = ':'.join(tmpInfo['bridge_id'][1][i:i+2] for i in range(0, len(tmpInfo['bridge_id'][1]), 2)) + tmpInfo['root_id'][1] = ':'.join(tmpInfo['root_id'][1][i:i+2] for i in range(0, len(tmpInfo['root_id'][1]), 2)) + + tmpInfo['stp_state'] = "Enabled" if brInfo.get('linkinfo', {}).get('info_data', {}).get('stp_state') == 1 else "Disabled" + + # I don't call any of these values, but I created them to be called within raw output if desired + + tmpInfo['mcast_snooping'] = "Enabled" if brInfo.get('linkinfo').get('info_data').get('mcast_snooping') == 1 else "Disabled" + tmpInfo['rxbytes'] = brInfo.get('stats64').get('rx').get('bytes') + tmpInfo['rxpackets'] = brInfo.get('stats64').get('rx').get('packets') + tmpInfo['rxerrors'] = brInfo.get('stats64').get('rx').get('errors') + tmpInfo['rxdropped'] = brInfo.get('stats64').get('rx').get('dropped') + tmpInfo['rxover_errors'] = brInfo.get('stats64').get('rx').get('over_errors') + tmpInfo['rxmulticast'] = brInfo.get('stats64').get('rx').get('multicast') + tmpInfo['txbytes'] = brInfo.get('stats64').get('tx').get('bytes') + tmpInfo['txpackets'] = brInfo.get('stats64').get('tx').get('packets') + tmpInfo['txerrors'] = brInfo.get('stats64').get('tx').get('errors') + tmpInfo['txdropped'] = brInfo.get('stats64').get('tx').get('dropped') + tmpInfo['txcarrier_errors'] = brInfo.get('stats64').get('tx').get('carrier_errors') + tmpInfo['txcollosions'] = brInfo.get('stats64').get('tx').get('collisions') + + tmpStatus = [] + for members in brStatus: + if members.get('master') == brInfo.get('ifname'): + allowed_vlans, native_vlan = _get_bridge_vlan_data(members['ifname']) + tmpStatus.append({'interface': members.get('ifname'), + 'state': members.get('state').capitalize(), + 'mtu': members.get('mtu'), + 'pathcost': members.get('cost'), + 'bpduguard': "Enabled" if members.get('guard') == True else "Disabled", + 'rootguard': "Enabled" if members.get('root_block') == True else "Disabled", + 'mac_learning': "Enabled" if members.get('learning') == True else "Disabled", + 'neigh_suppress': "Enabled" if members.get('neigh_suppress') == True else "Disabled", + 'vlan_tunnel': "Enabled" if members.get('vlan_tunnel') == True else "Disabled", + 'isolated': "Enabled" if members.get('isolated') == True else "Disabled", + **({'allowed_vlans': allowed_vlans} if allowed_vlans else {}), + **({'native_vlan': native_vlan} if native_vlan else {})}) + + tmpInfo['members'] = tmpStatus + return tmpInfo + +def show_stp(raw: bool, ifname: typing.Optional[str], detail: bool): + rawList = [] + rawDict = {'stp': []} + + if ifname: + if not interface_exists(ifname): + raise vyos.opmode.Error(f"{ifname} does not exist!") + else: + ifname = "" + + bridgeInfo = json.loads(cmd(f"ip -j -d -s link show type bridge {ifname}")) + + if not bridgeInfo: + raise vyos.opmode.Error(f"No Bridges configured!") + + bridgeStatus = json.loads(cmd(f"bridge -j -s -d link show")) + + for bridges in bridgeInfo: + output_list = [] + amRoot = "" + bridgeDict = _get_stp_data(ifname, bridges, bridgeStatus) + + if bridgeDict['bridge_id'][1] == bridgeDict['root_id'][1]: + amRoot = " (This bridge is the root)" + + print('-' * 80) + print(f"Bridge interface {bridgeDict['bridge_name']} ({bridgeDict['up_state']}):\n") + print(f"Spanning Tree is {bridgeDict['stp_state']}") + print(f"Bridge ID {bridgeDict['bridge_id'][1]}, Priority {int(bridgeDict['bridge_id'][0], 16)}") + print(f"Root ID {bridgeDict['root_id'][1]}, Priority {int(bridgeDict['root_id'][0], 16)}{amRoot}") + print(f"VLANs {bridgeDict['vlan_filtering'].capitalize()}, Protocol {bridgeDict['vlan_protocol']}") + print() + + for members in bridgeDict['members']: + output_list.append([members['interface'], + members['state'], + *([members['pathcost']] if detail else []), + members['bpduguard'], + members['rootguard'], + members['mac_learning'], + *([members['neigh_suppress']] if detail else []), + *([members['vlan_tunnel']] if detail else []), + *([members['isolated']] if detail else []), + *([members['allowed_vlans']] if detail else []), + *([members['native_vlan']] if detail else [])]) + + if raw: + rawList.append(bridgeDict) + elif detail: + headers = ['Interface', 'State', 'Pathcost', 'BPDU_Guard', 'Root_Guard', 'Learning', 'Neighbor_Suppression', 'Q-in-Q', 'Port_Isolation', 'Allowed VLANs', 'Native VLAN'] + detailed_output(output_list, headers) + else: + headers = ['Interface', 'State', 'BPDU_Guard', 'Root_Guard', 'Learning'] + print(tabulate(output_list, headers)) + print() + + if raw: + rawDict['stp'] = rawList + return rawDict + +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/op_mode/system.py b/src/op_mode/system.py index 854b4b699..6d0815a4a 100755 --- a/src/op_mode/system.py +++ b/src/op_mode/system.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/tcpdump.py b/src/op_mode/tcpdump.py index 607b59603..351b3d520 100644 --- a/src/op_mode/tcpdump.py +++ b/src/op_mode/tcpdump.py @@ -1,6 +1,6 @@ #! /usr/bin/env python3 -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -16,6 +16,7 @@ import sys +from vyos.utils.io import catch_broken_pipe from vyos.utils.process import call options = { @@ -51,8 +52,6 @@ options = { }, } -tcpdump = 'sudo /usr/bin/tcpdump' - class List(list): def first(self): return self.pop(0) if self else '' @@ -92,7 +91,8 @@ def complete(prefix): return [o for o in options if o.startswith(prefix)] -def convert(command, args): +def convert(args): + command = 'sudo /usr/bin/tcpdump' while args: shortname = args.first() longnames = complete(shortname) @@ -109,6 +109,9 @@ def convert(command, args): command=command, value=args.first()) return command +@catch_broken_pipe +def run_tcpdump(command: str, ifname: str) -> None: + call(f'{command} -i {ifname}') if __name__ == '__main__': args = List(sys.argv[1:]) @@ -161,5 +164,4 @@ if __name__ == '__main__': sys.stdout.write(helplines) sys.exit(0) - command = convert(tcpdump, args) - call(f'{command} -i {ifname}') + run_tcpdump(convert(args), ifname) diff --git a/src/op_mode/tech_support.py b/src/op_mode/tech_support.py index 24ac0af1b..6055cbf15 100644 --- a/src/op_mode/tech_support.py +++ b/src/op_mode/tech_support.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -20,6 +20,7 @@ import json import vyos.opmode from vyos.utils.process import cmd +from vyos.base import Warning def _get_version_data(): from vyos.version import get_version_data @@ -51,7 +52,12 @@ def _get_storage(): def _get_devices(): devices = {} devices["pci"] = cmd("lspci") - devices["usb"] = cmd("lsusb") + + try: + devices["usb"] = cmd("lsusb") + except OSError: + Warning("Could not retrieve information about USB devices") + devices["usb"] = {} return devices diff --git a/src/op_mode/toggle_help_binding.sh b/src/op_mode/toggle_help_binding.sh index a8708f3da..7c8bc05ce 100755 --- a/src/op_mode/toggle_help_binding.sh +++ b/src/op_mode/toggle_help_binding.sh @@ -1,6 +1,6 @@ #!/bin/bash # -# Copyright (C) 2019 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/traceroute.py b/src/op_mode/traceroute.py index d2bac3f7c..ef67b7416 100755 --- a/src/op_mode/traceroute.py +++ b/src/op_mode/traceroute.py @@ -1,6 +1,6 @@ #! /usr/bin/env python3 -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -85,7 +85,7 @@ options = { 'help': 'Use TCP SYN for tracerouting (default port is 80)' }, 'tos': { - 'traceroute': '{commad} -t {value}', + 'traceroute': '{command} -t {value}', 'type': '<tos>', 'help': 'Mark packets with specified TOS' }, @@ -222,11 +222,23 @@ if __name__ == '__main__': args.append(name) args.append(option['dflt']) + af = socket.AF_UNSPEC + for i in range(len(args)): + matched = complete(args[i]) + if len(matched) == 1 and matched[0] == 'source-address' and i + 1 < len(args): + try: + src_version = ipaddress.ip_address(args[i + 1]).version + af = socket.AF_INET6 if src_version == 6 else socket.AF_INET + except ValueError: + pass + break + try: - ip = socket.gethostbyname(host) + info = socket.getaddrinfo(host, None, af, socket.SOCK_STREAM) + ip = info[0][4][0] except UnicodeError: - sys.exit(f'tracroute: Unknown host: {host}') - except socket.gaierror: + sys.exit(f'traceroute: Unknown host: {host}') + except OSError: ip = host try: diff --git a/src/op_mode/update_suricata.sh b/src/op_mode/update_suricata.sh new file mode 100755 index 000000000..6e4e605f4 --- /dev/null +++ b/src/op_mode/update_suricata.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +if test -f /run/suricata/suricata.yaml; then + suricata-update --suricata-conf /run/suricata/suricata.yaml; + systemctl restart suricata; +else + echo "Service Suricata not configured"; +fi diff --git a/src/op_mode/uptime.py b/src/op_mode/uptime.py index 1c1a149ec..90475cb50 100755 --- a/src/op_mode/uptime.py +++ b/src/op_mode/uptime.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as diff --git a/src/op_mode/version.py b/src/op_mode/version.py index 71a40dd50..b93e3081b 100755 --- a/src/op_mode/version.py +++ b/src/op_mode/version.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2016-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/vpn_ike_sa.py b/src/op_mode/vpn_ike_sa.py index 9385bcd0c..0cd192174 100755 --- a/src/op_mode/vpn_ike_sa.py +++ b/src/op_mode/vpn_ike_sa.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/vpn_ipsec.py b/src/op_mode/vpn_ipsec.py index ef89e605f..3d7049a14 100755 --- a/src/op_mode/vpn_ipsec.py +++ b/src/op_mode/vpn_ipsec.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -23,13 +23,13 @@ SWANCTL_CONF = '/etc/swanctl/swanctl.conf' def get_peer_connections(peer, tunnel, return_all = False): - search = rf'^[\s]*(peer_{peer}_(tunnel_[\d]+|vti)).*' + search = rf'^[\s]*({peer}-(tunnel-[\d]+|vti))[\s]*{{' matches = [] with open(SWANCTL_CONF, 'r') as f: for line in f.readlines(): result = re.match(search, line) if result: - suffix = f'tunnel_{tunnel}' if tunnel.isnumeric() else tunnel + suffix = f'tunnel-{tunnel}' if tunnel.isnumeric() else tunnel if return_all or (result[2] == suffix): matches.append(result[1]) return matches @@ -66,7 +66,8 @@ def debug_peer(peer, tunnel): return for conn in conns: - call(f'/usr/sbin/ipsec statusall | grep {conn}') + command = f'/usr/sbin/ipsec statusall | grep {conn}' + call(command) if __name__ == '__main__': diff --git a/src/op_mode/vpp.py b/src/op_mode/vpp.py new file mode 100755 index 000000000..80697f503 --- /dev/null +++ b/src/op_mode/vpp.py @@ -0,0 +1,525 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 +import json +import typing + +from tabulate import tabulate +from vyos.vpp import VPPControl +from vyos.vpp.utils import vpp_iface_name_transform +from vyos.configquery import ConfigTreeQuery +import vyos.opmode + +NO_INDEX = 0xFFFFFFFF + +class VPPShow: + RX_STATES = { + 0: 'INITIALIZE', + 1: 'PORT_DISABLED', + 2: 'EXPIRED', + 3: 'LACP_DISABLED', + 4: 'DEFAULTED', + 5: 'CURRENT', + } + TX_STATES = {0: 'TRANSMIT'} + MUX_STATES = { + 0: 'DETACHED', + 1: 'WAITING', + 2: 'ATTACHED', + 3: 'COLLECTING_DISTRIBUTING', + } + PTX_STATES = {0: 'NO_PERIODIC', 1: 'FAST', 2: 'SLOW', 3: 'PERIODIC_TX'} + BOND_MODE = { + 1: 'round-robin', + 2: 'active-backup', + 3: 'xor', + 4: 'broadcast', + 5: 'lacp', + } + BOND_LB = { + 0: 'layer2', + 1: 'layer3+4', + 2: 'layer2+3', + 3: 'round-robin', + 4: 'broadcast', + 5: 'active-backup', + } + + def __init__(self): + self.config = ConfigTreeQuery() + self.vpp = VPPControl() + + # ----------------------------- + # IPFIX Interfaces + # ----------------------------- + def _get_ipfix_interfaces_raw(self) -> typing.List[dict]: + interfaces = self.vpp.api.flowprobe_interface_dump() + index_map = { + i.sw_if_index: i.interface_name for i in self.vpp.api.sw_interface_dump() + } + + return [ + { + 'interface': index_map.get(e.sw_if_index, f'if{e.sw_if_index}'), + 'sw_if_index': e.sw_if_index, + 'which': e.which.name.replace('FLOWPROBE_WHICH_', '').lower(), + 'direction': e.direction.name.replace( + 'FLOWPROBE_DIRECTION_', '' + ).lower(), + } + for e in interfaces + ] + + def _show_ipfix_interfaces_formatted(self, data: typing.List[dict]) -> str: + if not data: + return 'No flowprobe interfaces configured.' + table_data = [ + { + 'Interface': d['interface'], + 'VppIfIndex': d['sw_if_index'], + 'Flow-variant': d['which'], + 'Direction': d['direction'], + } + for d in data + ] + return tabulate(table_data, headers='keys', tablefmt='simple') + + def ipfix_interfaces(self, raw: bool): + base = ['vpp', 'ipfix', 'interface'] + if not self.config.exists(base): + raise vyos.opmode.UnconfiguredSubsystem( + 'vpp ipfix interface is not configured' + ) + + data = self._get_ipfix_interfaces_raw() + return data if raw else self._show_ipfix_interfaces_formatted(data) + + # ----------------------------- + # IPFIX Collectors + # ----------------------------- + def _get_ipfix_collectors_raw(self) -> typing.List[dict]: + _, collectors = self.vpp.api.ipfix_all_exporter_get() + return [ + { + 'collector_address': str(c.collector_address), + 'collector_port': c.collector_port, + 'src_address': str(c.src_address), + 'vrf_id': c.vrf_id, + 'path_mtu': c.path_mtu, + 'template_interval': c.template_interval, + 'udp_checksum': bool(c.udp_checksum), + } + for c in collectors + ] + + def _show_ipfix_collectors_formatted(self, data: typing.List[dict]) -> str: + if not data: + return 'No IPFIX collectors configured.' + table_data = [ + { + 'Collector': f"{d['collector_address']}:{d['collector_port']}", + 'Source': d['src_address'], + 'VRF': d['vrf_id'], + 'MTU': d['path_mtu'], + 'Template Intvl': d['template_interval'], + 'UDP Cksum': 'on' if d['udp_checksum'] else 'off', + } + for d in data + ] + return tabulate(table_data, headers='keys', tablefmt='simple') + + def ipfix_collectors(self, raw: bool): + base = ['vpp', 'ipfix', 'collector'] + if not self.config.exists(base): + raise vyos.opmode.UnconfiguredSubsystem( + 'vpp ipfix collector is not configured' + ) + + data = self._get_ipfix_collectors_raw() + return data if raw else self._show_ipfix_collectors_formatted(data) + + # ----------------------------- + # IPFIX table + # ----------------------------- + def _get_ipfix_table_raw(self): + # VPP does not have API call to get this data + data = self.vpp.cli_cmd('show flowprobe table') + return [data.reply] + + def _show_ipfix_table_formatted(self) -> str: + data = self.vpp.cli_cmd('show flowprobe table') + return data.reply + + def ipfix_table(self, raw: bool): + base = ['vpp', 'ipfix', 'collector'] + if not self.config.exists(base): + raise vyos.opmode.UnconfiguredSubsystem( + 'vpp ipfix collector is not configured' + ) + + data = self._get_ipfix_table_raw() + return data if raw else self._show_ipfix_table_formatted() + + # ----------------------------- + # Bonding information + # ----------------------------- + def _get_raw_output(self, data_dump: typing.List[dict]) -> list[dict]: + data = [json.loads(json.dumps(d._asdict(), default=str)) for d in data_dump] + return data + + def _get_lacp_raw(self, ifname: typing.Optional[str]) -> list[dict]: + lacp_dump = self.vpp.api.sw_interface_lacp_dump() + data = self._get_raw_output(lacp_dump) + + if ifname: + res = next((d for d in data if d['interface_name'] == ifname), None) + if not res: + raise vyos.opmode.IncorrectValue( + f'Interface {ifname} is not a member of any LACP bond' + ) + data = [res] + + return data + + def _get_lacp_info_formatted(self, data): + + def bit(x, n): + return (x >> n) & 1 + + def bits_to_str(x): + return ' '.join(f'{bit(x, n):3d}' for n in range(7, -1, -1)) + + # Headers (exactly like VPP) + print(f'{"":55} {"actor state":32} {"partner state":32}') + print( + 'interface name'.ljust(26) + + 'sw_if_index'.ljust(13) + + 'bond interface'.ljust(17) + + 'exp/def/dis/col/syn/agg/tim/act'.ljust(33) + + 'exp/def/dis/col/syn/agg/tim/act'.ljust(32) + ) + + for d in data: + iface = d['interface_name'] + sw_if = str(d['sw_if_index']) + bond_if = d['bond_interface_name'] + actor_bits = bits_to_str(d['actor_state']) + partner_bits = bits_to_str(d['partner_state']) + + print( + f'{iface:25} {sw_if:12} {bond_if:16} {actor_bits:32} {partner_bits:32}' + ) + + # LAG ID formatting + lag_line = ( + f' LAG ID: ' + f'[({d["actor_system_priority"]:04x},{d["actor_system"].replace(":", "-")},' + f'{d["actor_key"]:04x},{d["actor_port_priority"]:04x},{d["actor_port_number"]:04x}), ' + f'({d["partner_system_priority"]:04x},{d["partner_system"].replace(":", "-")},' + f'{d["partner_key"]:04x},{d["partner_port_priority"]:04x},{d["partner_port_number"]:04x})]' + ) + print(lag_line) + + # State machine line + print( + f' RX-state: {self.RX_STATES[d["rx_state"]]}, ' + f'TX-state: {self.TX_STATES[d["tx_state"]]}, ' + f'MUX-state: {self.MUX_STATES[d["mux_state"]]}, ' + f'PTX-state: {self.PTX_STATES[d["ptx_state"]]}' + ) + + def _get_bond_raw(self, index: typing.Optional[str]) -> list[dict]: + bond_dump = self.vpp.api.sw_bond_interface_dump(sw_if_index=index) + + result = [] + for bond in bond_dump: + bond_info = { + 'interface_name': bond.interface_name, + 'sw_if_index': bond.sw_if_index, + 'mode': self.BOND_MODE[bond.mode], + 'hash_policy': self.BOND_LB[bond.lb], + 'active_members': bond.active_members, + 'members': {}, + } + members = self.vpp.api.sw_member_interface_dump( + sw_if_index=bond.sw_if_index + ) + for member in members: + bond_info['members'][member.interface_name] = { + 'sw_if_index': member.sw_if_index, + 'is_passive': member.is_passive, + 'is_long_timeout': member.is_long_timeout, + 'is_local_numa': member.is_local_numa, + 'weight': member.weight, + } + result.append(bond_info) + + return result + + def _show_bond_info_formatted(self, data: typing.List[dict]) -> str: + table_data = [ + { + 'Interface': d['interface_name'], + 'Mode': d['mode'], + 'Hash': d['hash_policy'], + 'Members': '\n'.join(sorted(d['members'].keys())), + 'Active members': d['active_members'], + } + for d in data + ] + return tabulate(table_data, headers='keys', tablefmt='simple', numalign='left') + + def lacp_info(self, raw: bool, ifname: typing.Optional[str]): + data = self._get_lacp_raw(ifname) + + if not data: + raise vyos.opmode.DataUnavailable( + 'No VPP interface is configured with LACP (802.3ad) mode' + ) + + if raw: + return data + + return self._get_lacp_info_formatted(data) + + def lacp_details(self, raw: bool, ifname: typing.Optional[str]) -> str: + # Check if interface is a part of any LACP bond + self._get_lacp_raw(ifname) + + # VPP does not have API call to get this data + cmd_command = f'show lacp{f" {ifname}" if ifname else ""} details' + data = self.vpp.cli_cmd(cmd_command) + + if raw: + return [data.reply] + + return data.reply + + def bond_info(self, raw: bool, ifname: typing.Optional[str]) -> str: + index = NO_INDEX + if ifname: + if not ifname.startswith('vppbond') or not ifname[7:].isdigit(): + raise vyos.opmode.IncorrectValue( + f'"{ifname}" is not a valid bonding interface name (expected vppbondN)' + ) + + ifname_vpp = vpp_iface_name_transform(ifname) + index = self.vpp.get_sw_if_index(ifname_vpp) + if index is None: + raise vyos.opmode.IncorrectValue( + f'Bonding interface {ifname} does not exist in VPP' + ) + + data = self._get_bond_raw(index) + + return data if raw else self._show_bond_info_formatted(data) + + def bond_details(self, raw: bool) -> str: + # VPP API call is not so informative -> use CLI command + cmd_command = 'show bond details' + data = self.vpp.cli_cmd(cmd_command) + return [data.reply] if raw else data.reply + + # ----------------------------- + # Bridge-domain information + # ----------------------------- + def _parse_bridge_id(self, ifname: typing.Optional[str]) -> typing.Optional[int]: + if ifname is None: + return None + + if not ifname.startswith('vppbr') and not ifname[5:].isdigit(): + raise vyos.opmode.IncorrectValue( + f'"{ifname}" is not a valid bridge interface name (expected vppbrN)' + ) + + if not self.config.exists(['interfaces', 'vpp', 'bridge', ifname]): + raise vyos.opmode.IncorrectValue( + f'Bridge interface {ifname} does not exist' + ) + + return int(ifname[5:]) + + def _get_bridge_domain_raw( + self, bd_id: typing.Optional[int] = None + ) -> typing.List[dict]: + # Dump bridge domains + domains = self.vpp.api.bridge_domain_dump( + bd_id=bd_id if bd_id is not None else NO_INDEX + ) + + result = [] + for d in domains: + domain_info = { + 'bd_id': d.bd_id, + 'learning': bool(d.learn), + 'forward': bool(d.forward), + 'uu_flood': bool(d.uu_flood), + 'flood': bool(d.flood), + 'arp_term': bool(d.arp_term), + 'arp_ufwd': bool(d.arp_ufwd), + 'mac_age': d.mac_age, + 'bvi_interface': d.bvi_sw_if_index, + 'n_sw_ifs': d.n_sw_ifs, + 'members': [ + { + 'ifname': self.vpp.get_interface_name(m.sw_if_index), + 'sw_if_index': m.sw_if_index, + 'shg': m.shg, + } + for m in d.sw_if_details + ], + } + result.append(domain_info) + + result.sort(key=lambda x: x['bd_id']) + + return result + + def _show_bridge_domain_formatted(self, data: typing.List[dict]) -> str: + if not data: + return 'No bridge domains configured.' + + table_data = [ + { + 'BD-ID': d['bd_id'], + 'Age(min)': 'off' if d['mac_age'] == 0 else d['mac_age'], + 'Learning': 'on' if d['learning'] else 'off', + 'U-Forwrd': 'on' if d['forward'] else 'off', + 'UU-Flood': 'flood' if d['uu_flood'] else 'drop', + 'Flooding': 'on' if d['flood'] else 'off', + 'ARP-Term': 'on' if d['arp_term'] else 'off', + 'arp-ufwd': 'on' if d['arp_ufwd'] else 'off', + 'BVI-Intf': ( + self.vpp.get_interface_name(d['bvi_interface']) + if d['bvi_interface'] != NO_INDEX + else 'N/A' + ), + } + for d in data + ] + return tabulate(table_data, headers='keys', tablefmt='simple', numalign='left') + + def bridge_domain(self, raw: bool, ifname: typing.Optional[str] = None): + bd_id = self._parse_bridge_id(ifname) + data = self._get_bridge_domain_raw(bd_id) + return data if raw else self._show_bridge_domain_formatted(data) + + def bridge_domain_details(self, raw: bool, ifname: typing.List): + bd_id = self._parse_bridge_id(ifname) + + # VPP API call is not so informative -> use CLI command + cmd_command = f'show bridge-domain {bd_id} detail' + data = self.vpp.cli_cmd(cmd_command) + + if raw: + return [data.reply] + + return data.reply + + # ----------------------------- + # Runtime table + # ----------------------------- + def _get_runtime_raw(self): + # VPP does not have API call to get this data + data = self.vpp.cli_cmd('show runtime') + return [data.reply] + + def _show_runtime_formatted(self) -> str: + data = self.vpp.cli_cmd('show runtime') + return data.reply + + def runtime(self, raw: bool): + data = self._get_runtime_raw() + return data if raw else self._show_runtime_formatted() + + # ----------------------------- + # Interfaces mode + # ----------------------------- + def mode(self, raw: bool): + # VPP does not have API call to get this data + data = self.vpp.cli_cmd('show mode') + return [data.reply] if raw else data.reply + + +# ----------------------------- +# VyOS IPFIX op-mode entries +# ----------------------------- +@vyos.opmode.verify_cli_exists(['vpp', 'ipfix', 'interface']) +def show_ipfix_interfaces(raw: bool): + return VPPShow().ipfix_interfaces(raw) + +@vyos.opmode.verify_cli_exists(['vpp', 'ipfix', 'collector']) +def show_ipfix_collectors(raw: bool): + return VPPShow().ipfix_collectors(raw) + +@vyos.opmode.verify_cli_exists(['vpp', 'ipfix']) +def show_ipfix_table(raw: bool): + return VPPShow().ipfix_table(raw) + +# ----------------------------- +# VPP Bonding information +# ----------------------------- +@vyos.opmode.verify_cli_exists(['interfaces', 'vpp', 'bonding']) +def show_lacp(raw: bool, ifname: typing.Optional[str]): + return VPPShow().lacp_info(raw, ifname) + +@vyos.opmode.verify_cli_exists(['interfaces', 'vpp', 'bonding']) +def show_lacp_details(raw: bool, ifname: typing.Optional[str]): + return VPPShow().lacp_details(raw, ifname) + +@vyos.opmode.verify_cli_exists(['interfaces', 'vpp', 'bonding']) +def show_bond(raw: bool, ifname: typing.Optional[str]): + return VPPShow().bond_info(raw, ifname) + +@vyos.opmode.verify_cli_exists(['interfaces', 'vpp', 'bonding']) +def show_bond_details(raw: bool): + return VPPShow().bond_details(raw) + +# ----------------------------- +# Bridge op-mode entry +# ----------------------------- +@vyos.opmode.verify_cli_exists(['interfaces', 'vpp', 'bridge']) +def show_bridge(raw: bool, ifname: typing.Optional[str] = None): + return VPPShow().bridge_domain(raw, ifname) + +@vyos.opmode.verify_cli_exists(['interfaces', 'vpp', 'bridge']) +def show_bridge_details(raw: bool, ifname: typing.Optional[str] = None): + return VPPShow().bridge_domain_details(raw, ifname) + +# ----------------------------- +# show runtime +# ----------------------------- +@vyos.opmode.verify_cli_exists(['vpp']) +def show_runtime(raw: bool): + return VPPShow().runtime(raw) + +# ----------------------------- +# show mode +# ----------------------------- +@vyos.opmode.verify_cli_exists(['vpp']) +def show_mode(raw: bool): + return VPPShow().mode(raw) + + +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/op_mode/vpp_acl.py b/src/op_mode/vpp_acl.py new file mode 100644 index 000000000..6d5daba06 --- /dev/null +++ b/src/op_mode/vpp_acl.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import json +import sys +import typing +from tabulate import tabulate + +import vyos.opmode +from vyos.config import Config +from vyos.configquery import ConfigTreeQuery + +from vyos.vpp import VPPControl + + +NO_ACL_INDEX = 0xFFFFFFFF + +# ACL action flags +action_map = { + 0: 'deny', + 1: 'permit', + 2: 'permit-reflect', +} + +# TCP flag names to bit values +TCP_FLAGS = { + 'FIN': 0x01, + 'SYN': 0x02, + 'RST': 0x04, + 'PSH': 0x08, + 'ACK': 0x10, + 'URG': 0x20, + 'ECN': 0x40, + 'CWR': 0x80, +} + + +def _verify(target): + """Decorator checks if config for VPP NAT CGNAT exists""" + from functools import wraps + + if target not in ['ip', 'mac', 'no_target']: + raise ValueError('Invalid target') + + def _verify_target(func): + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + path = 'vpp acl' + if target == 'ip': + path += ' ip' + elif target == 'mac': + path += ' mac' + if not config.exists(path): + raise vyos.opmode.UnconfiguredSubsystem(f'"{path}" is not configured') + return func(*args, **kwargs) + + return _wrapper + + return _verify_target + + +def _get_acl_tag_by_index(vpp, acl_index): + acl = vpp.api.acl_dump(acl_index=acl_index) + if acl: + return acl[0].tag + + return None + + +def _get_mac_acl_tag_by_index(vpp, acl_index): + acl = vpp.api.macip_acl_dump(acl_index=acl_index) + if acl: + return acl[0].tag + + return None + + +def _get_tcp_flag_states(value, mask): + set_flags = [] + unset_flags = [] + for flag, bit in TCP_FLAGS.items(): + if mask & bit: # This flag is being checked + if value & bit: + set_flags.append(flag) + else: + unset_flags.append(flag) + return sorted(set_flags), sorted(unset_flags) + + +def _get_raw_output_acls(data_dump): + out = [] + for data in data_dump: + rules = [json.loads(json.dumps(d._asdict(), default=str)) for d in data.r] + out.append( + { + 'acl_index': data.acl_index, + 'tag': data.tag, + 'count': data.count, + 'r': rules, + } + ) + return out + + +def _get_raw_output_interfaces(data_dump): + ifaces_list = [] + for iface in data_dump: + if iface.count != 0: + ifaces_list.append(json.loads(json.dumps(iface._asdict(), default=str))) + return ifaces_list + + +def _get_formatted_output_interfaces(vpp, interfaces): + data_entries = [] + for interface in interfaces: + name = vpp.get_interface_name(interface.get('sw_if_index')) + input_acls = [] + for acl_index in interface.get('acls')[: interface.get('n_input')]: + input_acls.append(_get_acl_tag_by_index(vpp, int(acl_index))) + output_acls = [] + for acl_index in interface.get('acls')[interface.get('n_input') :]: + output_acls.append(_get_acl_tag_by_index(vpp, int(acl_index))) + values = [ + name, + '\n'.join(input_acls), + '\n'.join(output_acls), + ] + data_entries.append(values) + + headers = ['Interface', 'Input ACLs', 'Output ACLs'] + return tabulate(data_entries, headers=headers, tablefmt='simple') + + +def _get_formatted_output_mac_interfaces(vpp, interfaces): + data_entries = [] + for interface in interfaces: + name = vpp.get_interface_name(interface.get('sw_if_index')) + acl = _get_mac_acl_tag_by_index(vpp, int(interface.get('acls')[0])) + data_entries.append([name, acl]) + + headers = ['Interface', 'ACL'] + return tabulate(data_entries, headers=headers, tablefmt='simple') + + +def _get_formatted_output_acls(acls_list): + conf = Config() + + for acl in acls_list: + acl_index = acl.get('acl_index') + tag = acl.get('tag') + rules = acl.get('r') + print( + '\n---------------------------------\n' + f'IP ACL "tag-name {tag}" acl_index {acl_index}\n' + ) + + path = ['vpp', 'acl', 'ip', 'tag-name', tag, 'rule'] + conf_rules = conf.list_nodes(path) + data_entries = [] + for rule_index, rule in enumerate(rules): + srcport_first = str(rule.get('srcport_or_icmptype_first')) + srcport_last = str(rule.get('srcport_or_icmptype_last')) + dstport_first = str(rule.get('dstport_or_icmpcode_first')) + dstport_last = str(rule.get('dstport_or_icmpcode_last')) + set_flags, unset_flags = _get_tcp_flag_states( + rule.get('tcp_flags_value'), rule.get('tcp_flags_mask') + ) + + values = [ + conf_rules[rule_index], + action_map.get(rule.get('is_permit')), + rule.get('src_prefix'), + ( + f'{srcport_first}-{srcport_last}' + if srcport_first != srcport_last + else srcport_first + ), + rule.get('dst_prefix'), + ( + f'{dstport_first}-{dstport_last}' + if dstport_first != dstport_last + else dstport_first + ), + rule.get('proto'), + '\n'.join(set_flags), + '\n'.join(unset_flags), + ] + data_entries.append(values) + + headers = [ + 'Rule', + 'Action', + 'Src prefix', + 'Src port', + 'Dst prefix', + 'Dst port', + 'Proto', + 'TCP flags set', + 'TCP flags not set', + ] + print(tabulate(data_entries, headers=headers, tablefmt='simple')) + print('\n') + + +def _get_formatted_output_mac_acls(acls_list): + conf = Config() + + for acl in acls_list: + acl_index = acl.get('acl_index') + tag = acl.get('tag') + rules = acl.get('r') + print( + '\n---------------------------------\n' + f'MACIP ACL "tag-name {tag}" acl_index {acl_index}\n' + ) + + path = ['vpp', 'acl', 'mac', 'tag-name', tag, 'rule'] + conf_rules = conf.list_nodes(path) + data_entries = [] + for rule_index, rule in enumerate(rules): + values = [ + conf_rules[rule_index], + action_map.get(rule.get('is_permit')), + rule.get('src_prefix'), + rule.get('src_mac'), + rule.get('src_mac_mask'), + ] + data_entries.append(values) + + headers = [ + 'Rule', + 'Action', + 'IP prefix', + 'MAC address', + 'MAC mask', + ] + print(tabulate(data_entries, headers=headers, tablefmt='simple')) + print('\n') + + +def _find_acl_by_tag(acls, tag_name): + return [acl for acl in acls if acl['tag'] == tag_name] + + +@_verify('ip') +def show_ip_acls(raw: bool, tag_name: typing.Optional[str]): + vpp = VPPControl() + acls_dump = vpp.api.acl_dump(acl_index=NO_ACL_INDEX) + acls: list[dict] = _get_raw_output_acls(acls_dump) + + if tag_name: + acls = _find_acl_by_tag(acls, tag_name) + + if raw: + return acls + + else: + return _get_formatted_output_acls(acls) + + +@_verify('mac') +def show_mac_acls(raw: bool, tag_name: typing.Optional[str]): + vpp = VPPControl() + acls_dump = vpp.api.macip_acl_dump(acl_index=NO_ACL_INDEX) + acls: list[dict] = _get_raw_output_acls(acls_dump) + + if tag_name: + acls = _find_acl_by_tag(acls, tag_name) + + if raw: + return acls + + else: + return _get_formatted_output_mac_acls(acls) + + +@_verify('ip') +def show_interfaces(raw: bool): + vpp = VPPControl() + interfaces_dump = vpp.api.acl_interface_list_dump() + interfaces: list[dict] = _get_raw_output_interfaces(interfaces_dump) + + if raw: + return interfaces + + else: + return _get_formatted_output_interfaces(vpp, interfaces) + + +@_verify('mac') +def show_mac_interfaces(raw: bool): + vpp = VPPControl() + interfaces_dump = vpp.api.macip_acl_interface_list_dump() + interfaces: list[dict] = _get_raw_output_interfaces(interfaces_dump) + + if raw: + return interfaces + + else: + return _get_formatted_output_mac_interfaces(vpp, interfaces) + + +@_verify('no_target') +def show_all_acls(raw: bool): + conf = Config() + acls_all = {} + path = ['vpp', 'acl'] + if conf.exists(path + ['ip']): + ip_acls = show_ip_acls(raw, tag_name=None) + acls_all['ip'] = ip_acls + if conf.exists(path + ['mac']): + mac_acls = show_mac_acls(raw, tag_name=None) + acls_all['mac'] = mac_acls + + if raw: + return acls_all + + +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/op_mode/vpp_nat_cgnat.py b/src/op_mode/vpp_nat_cgnat.py new file mode 100644 index 000000000..6699d9c55 --- /dev/null +++ b/src/op_mode/vpp_nat_cgnat.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import json +import sys +from tabulate import tabulate + +import vyos.opmode +from vyos.configquery import ConfigTreeQuery + +from vyos.vpp import VPPControl + + +def _verify(func): + """Decorator checks if config for VPP NAT CGNAT exists""" + from functools import wraps + + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + base = 'vpp nat cgnat' + if not config.exists(base): + raise vyos.opmode.UnconfiguredSubsystem(f'{base} is not configured') + + return func(*args, **kwargs) + + return _wrapper + + +def _get_raw_output(data_dump): + data = [json.loads(json.dumps(d._asdict(), default=str)) for d in data_dump] + return data + + +def _get_formatted_output_interfaces(vpp, interfaces): + print('CGNAT interfaces:') + for interface in interfaces: + name = vpp.get_interface_name(interface['sw_if_index']) + iface_type = 'in' if interface['is_inside'] else 'out' + print(f' {name} {iface_type}') + + +def _get_formatted_output_mappings(rules_list): + data_entries = [] + for rule in rules_list: + in_addr = rule.get('in_addr') + in_plen = str(rule.get('in_plen')) + out_addr = rule.get('out_addr') + out_plen = str(rule.get('out_plen')) + sharing_ratio = rule.get('sharing_ratio') + ports_per_host = rule.get('ports_per_host') + ses_num = rule.get('ses_num') + + values = [ + f'{in_addr}/{in_plen}', + f'{out_addr}/{out_plen}', + sharing_ratio, + ports_per_host, + ses_num, + ] + data_entries.append(values) + headers = [ + 'Inside', + 'Outside', + 'Sharing ratio', + 'Ports per host', + 'Sessions', + ] + out = sorted(data_entries, key=lambda x: x[0]) + return tabulate(out, headers=headers, tablefmt='simple') + + +@_verify +def show_sessions(raw: bool): + vpp = VPPControl() + out = vpp.cli_cmd('show det44 sessions').reply + out = out.replace('NAT44 deterministic', 'CGNAT') + return out + + +@_verify +def show_mappings(raw: bool): + vpp = VPPControl() + nat_static_dump = vpp.api.det44_map_dump() + rules_list: list[dict] = _get_raw_output(nat_static_dump) + + if raw: + return rules_list + + else: + return _get_formatted_output_mappings(rules_list) + + +@_verify +def show_interfaces(raw: bool): + vpp = VPPControl() + interfaces_dump = vpp.api.det44_interface_dump() + interfaces: list[dict] = _get_raw_output(interfaces_dump) + + if raw: + return interfaces + + else: + return _get_formatted_output_interfaces(vpp, interfaces) + + +@_verify +def show_exclude_rules(raw: bool): + """Show CGNAT exclude rules (identity mappings)""" + vpp = VPPControl() + identity_mappings_dump = vpp.api.det44_identity_mapping_dump() + mappings: list[dict] = _get_raw_output(identity_mappings_dump) + + if raw: + return mappings + + if not mappings: + return "No CGNAT exclude rules configured" + + data_entries = [] + for m in mappings: + proto_map = {0: 'all', 1: 'icmp', 6: 'tcp', 17: 'udp', 255: 'all'} + proto_name = proto_map.get(m.get('protocol'), str(m.get('protocol'))) + port_str = str(m.get('port')) if m.get('port') else 'any' + + # Check if address-only (flag & 1) + if m.get('flags', 0) & 1: + proto_name = 'all' + port_str = 'any' + + tag_raw = m.get('tag') + if isinstance(tag_raw, bytes): + tag = tag_raw.decode('utf-8', errors='replace').rstrip('\x00') + else: + tag = str(tag_raw) if tag_raw else '' + + values = [m.get('addr'), proto_name, port_str, m.get('vrf_id', 0), tag] + data_entries.append(values) + + headers = ['Address', 'Protocol', 'Port', 'VRF', 'Description'] + out = sorted(data_entries, key=lambda x: x[0]) + return tabulate(out, headers=headers, tablefmt='simple') + + +@_verify +def clear_session(address: str, port: str, ext_address: str, ext_port: str): + vpp = VPPControl() + vpp.api.det44_close_session_in( + in_addr=address, + in_port=int(port), + ext_addr=ext_address, + ext_port=int(ext_port), + ) + + +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/op_mode/vpp_nat_nat44.py b/src/op_mode/vpp_nat_nat44.py new file mode 100644 index 000000000..97fca8dbc --- /dev/null +++ b/src/op_mode/vpp_nat_nat44.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# 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, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import json +import sys +from tabulate import tabulate + +import vyos.opmode +from vyos.configquery import ConfigTreeQuery + +from vyos.vpp import VPPControl + + +protocol_map = { + 0: 'all', + 1: 'icmp', + 6: 'tcp', + 17: 'udp', +} + +# NAT flags +flags_map = { + 'twice-nat': 0x01, + 'self-twice-nat': 0x02, + 'out2in-only': 0x04, + 'out': 0x10, + 'in': 0x20, +} + + +def _verify(func): + """Decorator checks if config for VPP NAT44 exists""" + from functools import wraps + + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + base = 'vpp nat nat44' + if not config.exists(base): + raise vyos.opmode.UnconfiguredSubsystem(f'{base} is not configured') + + return func(*args, **kwargs) + + return _wrapper + + +def decode_bitmask(bitmask: int) -> list: + """Decode a bitmask into a list of flag names""" + return [name for name, value in flags_map.items() if bitmask & value] + + +def _get_raw_output(data_dump): + data = [json.loads(json.dumps(d._asdict(), default=str)) for d in data_dump] + return data + + +def _get_raw_output_sessions(vpp_api): + users: list[dict] = vpp_api.nat44_user_dump() + sessions_list: list[dict] = [] + for user in users: + ip_address = str(user._asdict().get('ip_address')) + user_sessions_dump = vpp_api.nat44_user_session_v3_dump(ip_address=ip_address) + user_sessions = [ + json.loads(json.dumps(session._asdict(), default=str)) + for session in user_sessions_dump + ] + sessions_list.extend(user_sessions) + return sorted(sessions_list, key=lambda x: x["inside_ip_address"]) + + +def _get_formatted_output_sessions(sessions_list): + print('NAT44 ED sessions:') + print(f'--------------- {len(sessions_list)} sessions ---------------') + for session in sessions_list: + in_ip_addr = session.get('inside_ip_address') + in_port = session.get('inside_port') + out_ip_addr = session.get('outside_ip_address') + out_port = session.get('outside_port') + protocol = protocol_map[session.get('protocol')].upper() + last_heard = session.get('last_heard') + time_since_last_heard = session.get('time_since_last_heard') + total_bytes = session.get('total_bytes') + total_pkts = session.get('total_pkts') + ext_host_address = session.get('ext_host_address') + ext_host_port = session.get('ext_host_port') + is_timed_out = session.get('is_timed_out') + + print(f' i2o {in_ip_addr} proto {protocol} port {in_port}') + print(f' o2i {out_ip_addr} proto {protocol} port {out_port}') + print(f' external host {ext_host_address}:{ext_host_port}') + print( + f' i2o flow: match: saddr {in_ip_addr} sport {in_port} daddr {ext_host_address} dport {ext_host_port} proto {protocol} rewrite: saddr {out_ip_addr}' + + ( + f' sport {out_port}' + if protocol != 'ICMP' + else f' daddr {ext_host_address} icmp-id {ext_host_port}' + ) + ) + print( + f' o2i flow: match: saddr {ext_host_address} sport {ext_host_port} daddr {out_ip_addr} dport {out_port} proto {protocol} rewrite: ' + + ( + f'daddr {in_ip_addr} dport {in_port}' + if protocol != 'ICMP' + else f' saddr {ext_host_address} daddr {in_ip_addr} icmp-id {ext_host_port}' + ) + ) + print(f' last heard {last_heard}') + print(f' time since last heard {time_since_last_heard}') + print(f' total packets {total_pkts}, total bytes {total_bytes}') + if is_timed_out: + print(' session timed out') + print('\n') + + +def _get_formatted_output_addresses(addresses): + twice_nat_address = [] + translation_address = [] + for address_info in addresses: + address = address_info.get('ip_address') + if address_info.get('flags') & flags_map['twice-nat']: + twice_nat_address.append(address) + else: + translation_address.append(address) + + print('NAT44 pool addresses:') + for addr in translation_address: + print(f' {addr}') + print('NAT44 twice-nat pool addresses:') + for addr in twice_nat_address: + print(f' {addr}') + + +def _get_formatted_output_interfaces(vpp, interfaces): + print('NAT44 interfaces:') + for interface in interfaces: + name = vpp.get_interface_name(interface['sw_if_index']) + iface_type = decode_bitmask(interface['flags']) + print(f' {name} {" ".join(iface_type)}') + + +def _get_formatted_output_rules(rules_list): + data_entries = [] + for rule in rules_list: + external_address = rule.get('external_ip_address') + external_port = rule.get('external_port') or '' + local_address = rule.get('local_ip_address') + local_port = rule.get('local_port') or '' + protocol = protocol_map[rule.get('protocol', 0)] + options = ' '.join(decode_bitmask(rule.get('flags'))) + + values = [ + external_address, + external_port, + local_address, + local_port, + protocol, + options, + ] + data_entries.append(values) + headers = [ + 'External address', + 'External port', + 'Local address', + 'Local port', + 'Protocol', + 'Options', + ] + out = sorted(data_entries, key=lambda x: x[2]) + return tabulate(out, headers=headers, tablefmt='simple') + + +@_verify +def show_sessions(raw: bool): + vpp = VPPControl() + sessions_list: list[dict] = _get_raw_output_sessions(vpp.api) + + if raw: + return sessions_list + + else: + return _get_formatted_output_sessions(sessions_list) + + +@_verify +def show_summary(raw: bool): + vpp = VPPControl() + return vpp.cli_cmd('show nat44 summary').reply + + +@_verify +def show_static(raw: bool): + vpp = VPPControl() + nat_static_dump = vpp.api.nat44_static_mapping_dump() + rules_list: list[dict] = _get_raw_output(nat_static_dump) + + if raw: + return rules_list + + else: + return _get_formatted_output_rules(rules_list) + + +@_verify +def show_addresses(raw: bool): + vpp = VPPControl() + addresses_dump = vpp.api.nat44_address_dump() + addresses: list[dict] = _get_raw_output(addresses_dump) + + if raw: + return addresses + + else: + return _get_formatted_output_addresses(addresses) + + +@_verify +def show_interfaces(raw: bool): + vpp = VPPControl() + interfaces_dump = vpp.api.nat44_interface_dump() + interfaces: list[dict] = _get_raw_output(interfaces_dump) + + if raw: + return interfaces + + else: + return _get_formatted_output_interfaces(vpp, interfaces) + + +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/op_mode/vrf.py b/src/op_mode/vrf.py index 51032a4b5..a13b48866 100755 --- a/src/op_mode/vrf.py +++ b/src/op_mode/vrf.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/vrrp.py b/src/op_mode/vrrp.py index ef1338e23..92eb12d81 100755 --- a/src/op_mode/vrrp.py +++ b/src/op_mode/vrrp.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -191,7 +191,7 @@ def _get_formatted_statistics_output(data: list) -> str: Prepare formatted statistics output from the given data. Args: - data (list): A list of dictionaries containing vrrp grop information + data (list): A list of dictionaries containing vrrp group information and statistics. Returns: @@ -228,7 +228,7 @@ def _get_formatted_detail_output(data: list) -> str: Prepare formatted detail information output from the given data. Args: - data (list): A list of dictionaries containing vrrp grop information + data (list): A list of dictionaries containing vrrp group information and statistics. Returns: diff --git a/src/op_mode/webproxy_update_blacklist.sh b/src/op_mode/webproxy_update_blacklist.sh index 05ea86f9e..90594daf6 100755 --- a/src/op_mode/webproxy_update_blacklist.sh +++ b/src/op_mode/webproxy_update_blacklist.sh @@ -1,6 +1,6 @@ #!/bin/sh # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/op_mode/wireguard_client.py b/src/op_mode/wireguard_client.py index 04d8ce28c..04d91cc47 100755 --- a/src/op_mode/wireguard_client.py +++ b/src/op_mode/wireguard_client.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -33,7 +33,7 @@ server_config = """WireGuard client configuration for interface: {{ interface }} To enable this configuration on a VyOS router you can use the following commands: -=== VyOS (server) configurtation === +=== VyOS (server) configuration === {% for addr in address if address is defined %} set interfaces wireguard {{ interface }} peer {{ name }} allowed-ips '{{ addr }}' diff --git a/src/op_mode/zone.py b/src/op_mode/zone.py index df39549d2..8bdf373a8 100644 --- a/src/op_mode/zone.py +++ b/src/op_mode/zone.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/opt/vyatta/etc/shell/level/users/allowed-op b/src/opt/vyatta/etc/shell/level/users/allowed-op index 381fd26e5..8d4749628 100644 --- a/src/opt/vyatta/etc/shell/level/users/allowed-op +++ b/src/opt/vyatta/etc/shell/level/users/allowed-op @@ -11,7 +11,10 @@ exit force monitor ping +poweroff +reboot reset +restart release renew set diff --git a/src/opt/vyatta/etc/shell/level/users/allowed-op.in b/src/opt/vyatta/etc/shell/level/users/allowed-op.in index 9752f99a2..e01559b1c 100644 --- a/src/opt/vyatta/etc/shell/level/users/allowed-op.in +++ b/src/opt/vyatta/etc/shell/level/users/allowed-op.in @@ -7,7 +7,10 @@ exit force monitor ping +poweroff +reboot reset +restart release renew set diff --git a/src/opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-op-run b/src/opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-op-run index f0479ae88..3cccb9154 100644 --- a/src/opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-op-run +++ b/src/opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-op-run @@ -20,7 +20,7 @@ _vyatta_op_init () { - # empty and default line compeletion + # empty and default line completion complete -E -F _vyatta_op_expand complete -D -F _vyatta_op_default_expand @@ -100,7 +100,7 @@ _vyatta_op_conv_node_path () _vyatta_op_conv_run_cmd () { - # Substitue bash positional variables + # Substitute bash positional variables # for the same value in the expanded array local restore_shopts=$( shopt -p extglob nullglob | tr \\n \; ) shopt -s extglob @@ -169,9 +169,18 @@ _vyatta_op_run () _vyatta_op_last_comp=${_vyatta_op_last_comp_init} false; estat=$? - stty echo 2> /dev/null # turn echo on, this is a workaround for bug 7570 - # not a fix we need to look at why the readline library - # is getting confused on paged help text. + + # Only touch terminal settings when `stdout` is a real TTY. + # When output is piped (e.g. via `sudo ... | cat`), running `stty` can interfere + # with non-interactive execution and produce corrupted/indented output. + if [ -t 1 ]; then + + # Turn echo on, this is a workaround for bug 7570 + # not a fix we need to look at why the readline library + # is getting confused on paged help text. + stty echo 2> /dev/null + + fi i=1 declare -a args # array of expanded arguments @@ -217,15 +226,40 @@ _vyatta_op_run () local run_cmd=$(_vyatta_op_get_node_def_field $tpath/node.def run) run_cmd=$(_vyatta_op_conv_run_cmd "$run_cmd") # convert the positional parameters local ret=0 + + if [[ $EUID -eq 0 ]] || groups "$(whoami)" | grep -q -E ' vyattacfg(\s|$)'; then + # If the user is an admin, + # use sudo directly for now + op_runner="sudo" + else + # If the user is an operator, + # use the new operational command runner + # (operator users have no sudo permissions) + # and give it the original VyOS command + op_runner="vyos-op-run" + run_cmd="$@" + fi + # Exception for the `show file` command local file_cmd='\$\{vyos_op_scripts_dir\}\/file\.py' local cmd_regex="^(LESSOPEN=|less|pager|tail|(sudo )?$file_cmd).*" if [ -n "$run_cmd" ]; then eval $restore_shopts - if [[ -t 1 && "${args[1]}" == "show" && ! $run_cmd =~ $cmd_regex ]] ; then - eval "($run_cmd) | ${VYATTA_PAGER:-cat}" - else + if [[ "${args[1]}" == "configure" ]]; then + # The "configure" command modifies the shell environment + # and must run in the current shell. eval "$run_cmd" + elif [[ "${args[1]} ${args[2]}" =~ ^set[[:space:]]+(builtin|terminal) ]]; then + # Some commands like "set terminal width" + # only affect the user shell + # (so they don't need special privileges) + # and must be executed directly in the current shell + # to be able to do their job. + eval "$run_cmd" + elif [[ -t 1 && "${args[1]}" == "show" && ! $run_cmd =~ $cmd_regex ]] ; then + eval "($op_runner $run_cmd) | ${VYATTA_PAGER:-cat}" + else + eval "$op_runner $run_cmd" fi else echo -ne "\n Incomplete command: ${args[@]}\n\n" >&2 diff --git a/src/opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-unpriv b/src/opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-unpriv index 1507f4f0d..cf28641f3 100644 --- a/src/opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-unpriv +++ b/src/opt/vyatta/share/vyatta-op/functions/interpreter/vyatta-unpriv @@ -35,7 +35,7 @@ vyatta_unpriv_ambiguous () vyatta_unpriv_init () { - # empty and default line compeletion + # empty and default line completion complete -E -F _vyatta_op_expand complete -D -F _vyatta_op_default_expand diff --git a/src/services/api/background.py b/src/services/api/background.py new file mode 100644 index 000000000..2b25af307 --- /dev/null +++ b/src/services/api/background.py @@ -0,0 +1,179 @@ +# Copyright 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 time +import functools +from collections import deque +from enum import Enum +from threading import Lock +from typing import Any +from typing import Callable +from typing import Optional +from uuid import uuid4 + +from fastapi import BackgroundTasks +from pydantic import BaseModel +from pydantic import StrictStr +from pydantic import StrictInt + + +def _ts(): + """Return current Unix timestamp (seconds since epoch)""" + return int(time.time()) + + +class BackgroundOpStatus(str, Enum): + queued = 'queued' + running = 'running' + succeeded = 'succeeded' + failed = 'failed' + + @property + def is_completed(self): + """True if the operation is in a terminal state (succeeded/failed)""" + return self in (BackgroundOpStatus.succeeded, BackgroundOpStatus.failed) + + +class BackgroundOpRecord(BaseModel): + """Metadata and outcome for a single background operation""" + + op_id: StrictStr + created_at: StrictInt + started_at: Optional[StrictInt] = None + finished_at: Optional[StrictInt] = None + status: BackgroundOpStatus = BackgroundOpStatus.queued + result: Optional[Any] = None + error: Optional[StrictStr] = None + + +class BackgroundOpError(Exception): + """Raised when a background operation cannot be enqueued/executed""" + + pass + + +class BackgroundOpManager: + """ + In-memory FIFO operation queue. + + Uses BackgroundTasks to schedule a `drain()` call after the response, + so `enqueue()` is fast and non-blocking for the client. + """ + + DEFAULT_MAX_QUEUE_SIZE = 128 + + def __init__(self, max_queue_size: int = DEFAULT_MAX_QUEUE_SIZE): + # max number of queued (pending) operations allowed at a time + self._max_queue_size = max_queue_size + + # FIFO queue of operation IDs waiting to be executed + self._queue = deque() + self._jobs = {} + self._workers = {} + + # protects _queue/_jobs/_workers/_drain_scheduled from concurrent access + self._mx = Lock() + + # whether a drain task has already been scheduled via BackgroundTasks + self._drain_scheduled = False + + def enqueue( + self, + background_tasks: BackgroundTasks, + func: Callable, + *args, + **kwargs, + ) -> BackgroundOpRecord: + """Enqueue a function for background execution and return its record""" + + assert isinstance(background_tasks, BackgroundTasks) + assert callable(func), '`func` argument should be function or lambda' + + with self._mx: + if len(self._queue) >= self._max_queue_size: + raise BackgroundOpError( + f'Background operation queue is full ({self._max_queue_size})' + ) + + op_id = str(uuid4()) + record = BackgroundOpRecord(op_id=op_id, created_at=_ts()) + + self._jobs[op_id] = record + # store the callable for later execution (outside the lock) + self._workers[op_id] = functools.partial(func, *args, **kwargs) + self._queue.append(op_id) + + if not self._drain_scheduled: + # schedule a single drain() call after the current response + background_tasks.add_task(self.drain) + self._drain_scheduled = True + + # Best-effort pruning: keep history bounded by dropping oldest completed records + if len(self._jobs) > self._max_queue_size: + oldest = min(self._jobs.values(), key=lambda record: record.created_at) + if oldest.status.is_completed: + del self._jobs[oldest.op_id] + + return record + + def drain(self): + """Run queued operations sequentially until the queue is empty""" + + while True: + with self._mx: + if not self._queue: + # allow future enqueue() calls to schedule the next drain() + self._drain_scheduled = False + return + + op_id = self._queue.popleft() + record = self._jobs[op_id] + func = self._workers.pop(op_id) + + record.status = BackgroundOpStatus.running + record.started_at = _ts() + + # execute outside the lock to avoid blocking enqueues/status reads + result = error = status = None + try: + result = func() + except Exception as e: # noqa: BLE001 + status = BackgroundOpStatus.failed + error = str(e) + else: + status = BackgroundOpStatus.succeeded + + with self._mx: + record.result = result + record.error = error + record.status = status + record.finished_at = _ts() + + def get_record(self, op_id: str) -> BackgroundOpRecord | None: + """Return a deep copy of a single record""" + + with self._mx: + record = self._jobs.get(op_id) + return record.copy(deep=True) if record else None + + def get_records(self) -> list: + """Return deep copies of all records, sorted oldest-first by created_at""" + + with self._mx: + records = [record.copy(deep=True) for record in self._jobs.values()] + + # stable-ish ordering (oldest first) + records.sort(key=lambda record: record.created_at) + return records diff --git a/src/services/api/graphql/README.graphql b/src/services/api/graphql/README.graphql index 1133d79ed..0f43ac356 100644 --- a/src/services/api/graphql/README.graphql +++ b/src/services/api/graphql/README.graphql @@ -64,7 +64,7 @@ save to /config/config.boot; to save to an alternative path, specify fileName. Similarly, using an analogous 'endpoint' (meaning the form of the request -and resolver; the actual enpoint for all GraphQL requests is +and resolver; the actual endpoint for all GraphQL requests is https://hostname/graphql), one can load an arbitrary config file from a path. diff --git a/src/services/api/graphql/bindings.py b/src/services/api/graphql/bindings.py index ebf745f32..7380dbb5f 100644 --- a/src/services/api/graphql/bindings.py +++ b/src/services/api/graphql/bindings.py @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/services/api/graphql/generate/generate_schema.py b/src/services/api/graphql/generate/generate_schema.py index dd5e7ea56..bb36a4c04 100755 --- a/src/services/api/graphql/generate/generate_schema.py +++ b/src/services/api/graphql/generate/generate_schema.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/services/api/graphql/generate/schema_from_composite.py b/src/services/api/graphql/generate/schema_from_composite.py index 06e74032d..9a07f88fe 100755 --- a/src/services/api/graphql/generate/schema_from_composite.py +++ b/src/services/api/graphql/generate/schema_from_composite.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -15,7 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # # -# A utility to generate GraphQL schema defintions from typing information of +# A utility to generate GraphQL schema definitions from typing information of # composite functions comprising several requests. import os diff --git a/src/services/api/graphql/generate/schema_from_config_session.py b/src/services/api/graphql/generate/schema_from_config_session.py index 1d5ff1e53..bfa4bc006 100755 --- a/src/services/api/graphql/generate/schema_from_config_session.py +++ b/src/services/api/graphql/generate/schema_from_config_session.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -15,7 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # # -# A utility to generate GraphQL schema defintions from typing information of +# A utility to generate GraphQL schema definitions from typing information of # (wrappers of) native configsession functions. import os diff --git a/src/services/api/graphql/generate/schema_from_op_mode.py b/src/services/api/graphql/generate/schema_from_op_mode.py index ab7cb691f..618ea2e61 100755 --- a/src/services/api/graphql/generate/schema_from_op_mode.py +++ b/src/services/api/graphql/generate/schema_from_op_mode.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -15,7 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # # -# A utility to generate GraphQL schema defintions from standardized op-mode +# A utility to generate GraphQL schema definitions from standardized op-mode # scripts. import os diff --git a/src/services/api/graphql/graphql/auth_token_mutation.py b/src/services/api/graphql/graphql/auth_token_mutation.py index c74364603..a8020d149 100644 --- a/src/services/api/graphql/graphql/auth_token_mutation.py +++ b/src/services/api/graphql/graphql/auth_token_mutation.py @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/services/api/graphql/graphql/directives.py b/src/services/api/graphql/graphql/directives.py index 3927aee58..037f09204 100644 --- a/src/services/api/graphql/graphql/directives.py +++ b/src/services/api/graphql/graphql/directives.py @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/services/api/graphql/graphql/mutations.py b/src/services/api/graphql/graphql/mutations.py index 0b391c070..c979d06e8 100644 --- a/src/services/api/graphql/graphql/mutations.py +++ b/src/services/api/graphql/graphql/mutations.py @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/services/api/graphql/graphql/queries.py b/src/services/api/graphql/graphql/queries.py index 9303fe909..3a8d12344 100644 --- a/src/services/api/graphql/graphql/queries.py +++ b/src/services/api/graphql/graphql/queries.py @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/services/api/graphql/libs/key_auth.py b/src/services/api/graphql/libs/key_auth.py index ffd7f32b2..dc3322fea 100644 --- a/src/services/api/graphql/libs/key_auth.py +++ b/src/services/api/graphql/libs/key_auth.py @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/services/api/graphql/libs/op_mode.py b/src/services/api/graphql/libs/op_mode.py index 86e38eae6..fa726264c 100644 --- a/src/services/api/graphql/libs/op_mode.py +++ b/src/services/api/graphql/libs/op_mode.py @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/services/api/graphql/libs/token_auth.py b/src/services/api/graphql/libs/token_auth.py index 4f743a096..73c52bdf0 100644 --- a/src/services/api/graphql/libs/token_auth.py +++ b/src/services/api/graphql/libs/token_auth.py @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/services/api/graphql/routers.py b/src/services/api/graphql/routers.py index ed3ee1e8c..c6886ba1c 100644 --- a/src/services/api/graphql/routers.py +++ b/src/services/api/graphql/routers.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -32,7 +32,7 @@ def graphql_init(app: 'FastAPI'): state = SessionState() - # import after initializaion of state + # import after initialization of state from .bindings import generate_schema schema = generate_schema() diff --git a/src/services/api/graphql/session/composite/system_status.py b/src/services/api/graphql/session/composite/system_status.py index 516a4eff6..1674b2c2b 100755 --- a/src/services/api/graphql/session/composite/system_status.py +++ b/src/services/api/graphql/session/composite/system_status.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/services/api/graphql/session/override/remove_firewall_address_group_members.py b/src/services/api/graphql/session/override/remove_firewall_address_group_members.py index b91932e14..9f39465a1 100644 --- a/src/services/api/graphql/session/override/remove_firewall_address_group_members.py +++ b/src/services/api/graphql/session/override/remove_firewall_address_group_members.py @@ -1,4 +1,4 @@ -# Copyright 2021 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/services/api/graphql/session/session.py b/src/services/api/graphql/session/session.py index 619534f43..e4725e752 100644 --- a/src/services/api/graphql/session/session.py +++ b/src/services/api/graphql/session/session.py @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/services/api/rest/models.py b/src/services/api/rest/models.py index dda50010f..bfea17344 100644 --- a/src/services/api/rest/models.py +++ b/src/services/api/rest/models.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -26,6 +26,7 @@ from typing import Self from pydantic import BaseModel from pydantic import StrictStr +from pydantic import StrictInt from pydantic import field_validator from pydantic import model_validator from fastapi.responses import HTMLResponse @@ -47,7 +48,7 @@ def success(data): # Pydantic models for validation # Pydantic will cast when possible, so use StrictStr validators added as # needed for additional constraints -# json_schema_extra adds anotations to OpenAPI to add examples +# json_schema_extra adds annotations to OpenAPI to add examples class ApiModel(BaseModel): @@ -71,6 +72,8 @@ class BaseConfigureModel(BasePathModel): class ConfigureModel(ApiModel, BaseConfigureModel): + confirm_time: StrictInt = 0 + class Config: json_schema_extra = { 'example': { @@ -81,8 +84,12 @@ class ConfigureModel(ApiModel, BaseConfigureModel): } +class ConfirmModel(ApiModel): + op: StrictStr + class ConfigureListModel(ApiModel): commands: List[BaseConfigureModel] + confirm_time: StrictInt = 0 class Config: json_schema_extra = { @@ -134,13 +141,17 @@ class RetrieveModel(ApiModel): class ConfigFileModel(ApiModel): op: StrictStr file: StrictStr = None + string: StrictStr = None + confirm_time: StrictInt = 0 + destructive: bool = False class Config: json_schema_extra = { 'example': { 'key': 'id_key', - 'op': 'save | load', + 'op': 'save | load | merge | confirm', 'file': 'filename', + 'string': 'config_string' } } @@ -251,6 +262,20 @@ class RebootModel(ApiModel): } +class RenewModel(ApiModel): + op: StrictStr + path: List[StrictStr] + + class Config: + json_schema_extra = { + 'example': { + 'key': 'id_key', + 'op': 'renew', + 'path': ['op', 'mode', 'path'], + } + } + + class ResetModel(ApiModel): op: StrictStr path: List[StrictStr] diff --git a/src/services/api/rest/routers.py b/src/services/api/rest/routers.py index e52c77fda..fe67d4612 100644 --- a/src/services/api/rest/routers.py +++ b/src/services/api/rest/routers.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -18,6 +18,7 @@ # pylint: disable=wildcard-import,unused-wildcard-import # pylint: disable=broad-exception-caught +import asyncio import json import copy import logging @@ -28,12 +29,14 @@ from typing import Callable from typing import TYPE_CHECKING from fastapi import Depends +from fastapi import Query from fastapi import Request from fastapi import Response from fastapi import HTTPException from fastapi import APIRouter from fastapi import BackgroundTasks from fastapi.routing import APIRoute +from fastapi.concurrency import run_in_threadpool from starlette.datastructures import FormData from starlette.formparsers import FormParser from starlette.formparsers import MultiPartParser @@ -45,12 +48,15 @@ from vyos.configtree import ConfigTree from vyos.configdiff import get_config_diff from vyos.configsession import ConfigSessionError +from ..background import BackgroundOpManager +from ..background import BackgroundOpError from ..session import SessionState from .models import success from .models import error from .models import responses from .models import ApiModel from .models import ConfigureModel +from .models import ConfirmModel from .models import ConfigureListModel from .models import ConfigSectionModel from .models import ConfigSectionListModel @@ -66,6 +72,7 @@ from .models import GenerateModel from .models import ShowModel from .models import RebootModel from .models import ResetModel +from .models import RenewModel from .models import ImportPkiModel from .models import PoweroffModel from .models import TracerouteModel @@ -79,6 +86,7 @@ LOG = logging.getLogger('http_api.routers') lock = Lock() +asynclock = asyncio.Lock() def check_auth(key_list, key): key_id = None @@ -99,7 +107,7 @@ def auth_required(data: ApiModel): # override Request and APIRoute classes in order to convert form request to json; -# do all explicit validation here, for backwards compatability of error messages; +# do all explicit validation here, for backwards compatibility of error messages; # the explicit validation may be dropped, if desired, in favor of native # validation by FastAPI/Pydantic, as is used for application/json requests class MultipartRequest(Request): @@ -227,7 +235,7 @@ class MultipartRequest(Request): 400, f"Malformed command '{0}': 'path' field must be a list of strings", ) - if endpoint in ('/configure'): + if endpoint in ('/configure',): if not c['path']: self.form_err = ( 400, @@ -238,7 +246,7 @@ class MultipartRequest(Request): 400, f"Malformed command '{c}': 'value' field must be a string", ) - if endpoint in ('/configure-section'): + if endpoint in ('/configure-section',): if 'section' not in c and 'config' not in c: self.form_err = ( 400, @@ -290,6 +298,10 @@ router = APIRouter( self_ref_msg = 'Requested HTTP API server configuration change; commit will be called in the background' +# Global background-op manager used by the REST API to run long config commits after the response +background_op_manager = BackgroundOpManager() + + def call_commit(s: SessionState): try: s.session.commit() @@ -301,24 +313,71 @@ def call_commit(s: SessionState): LOG.warning(f'ConfigSessionError: {e}') -def _configure_op( +def call_commit_confirm(s: SessionState): + env = s.session.get_session_env() + env['IN_COMMIT_CONFIRM'] = 't' + try: + s.session.commit() + s.session.commit_confirm(minutes=s.confirm_time) + except ConfigSessionError as e: + s.session.discard() + if s.debug: + LOG.warning(f'ConfigSessionError:\n {traceback.format_exc()}') + else: + LOG.warning(f'ConfigSessionError: {e}') + finally: + del env['IN_COMMIT_CONFIRM'] + + +def run_commit(s: SessionState): + try: + out = s.session.commit() + return out, None + except Exception as e: + return None, e + + +def run_commit_confirm(s: SessionState): + env = s.session.get_session_env() + env['IN_COMMIT_CONFIRM'] = 't' + try: + out_c = s.session.commit() + out_cc = s.session.commit_confirm(minutes=s.confirm_time) + out = out_c + '\n' + out_cc + return out, None + except Exception as e: + return None, e + finally: + del env['IN_COMMIT_CONFIRM'] + + +def _execute_configure_op( data: Union[ + ConfirmModel, ConfigureModel, ConfigureListModel, ConfigSectionModel, ConfigSectionListModel, ConfigSectionTreeModel, ], - _request: Request, - background_tasks: BackgroundTasks, + background_tasks: BackgroundTasks | None = None, ): # pylint: disable=too-many-branches,too-many-locals,too-many-nested-blocks,too-many-statements # pylint: disable=consider-using-with + # True when invoked by the background operation + # runner (no FastAPI BackgroundTasks context passed in) + is_background_job = background_tasks is None + state = SessionState() session = state.session env = session.get_session_env() + # A non-zero confirm_time will start commit-confirm timer on commit + confirm_time = 0 + if isinstance(data, (ConfigureModel, ConfigureListModel, ConfigFileModel)): + confirm_time = data.confirm_time + # Allow users to pass just one command if not isinstance(data, (ConfigureListModel, ConfigSectionListModel)): data = [data] @@ -338,10 +397,18 @@ def _configure_op( try: for c in data: op = c.op - if not isinstance(c, BaseConfigSectionTreeModel): + op_error = ConfigSessionError(f"'{op}' is not a valid operation") + + if not isinstance(c, (ConfirmModel, BaseConfigSectionTreeModel)): path = c.path - if isinstance(c, BaseConfigureModel): + if isinstance(c, ConfirmModel): + if op == 'confirm': + msg = session.confirm() + else: + raise op_error + + elif isinstance(c, BaseConfigureModel): if c.value: value = c.value else: @@ -354,8 +421,8 @@ def _configure_op( section = c.section elif isinstance(c, BaseConfigSectionTreeModel): - mask = c.mask - config = c.config + mask_dict = c.mask + config_dict = c.config if isinstance(c, BaseConfigureModel): if op == 'set': @@ -369,7 +436,7 @@ def _configure_op( elif op == 'comment': session.comment(path, value=value) else: - raise ConfigSessionError(f"'{op}' is not a valid operation") + raise op_error elif isinstance(c, BaseConfigSectionModel): if op == 'set': @@ -377,26 +444,50 @@ def _configure_op( elif op == 'load': session.load_section(path, section) else: - raise ConfigSessionError(f"'{op}' is not a valid operation") + raise op_error elif isinstance(c, BaseConfigSectionTreeModel): if op == 'set': - session.set_section_tree(config) + session.set_section_tree(config_dict) elif op == 'load': - session.load_section_tree(mask, config) + config_tree = config.get_config_tree() + session.load_section_tree(config_tree, mask_dict, config_dict) else: - raise ConfigSessionError(f"'{op}' is not a valid operation") + raise op_error # end for + config = Config(session_env=env) d = get_config_diff(config) - if d.is_node_changed(['service', 'https']): - background_tasks.add_task(call_commit, state) - msg = self_ref_msg + state.confirm_time = confirm_time if confirm_time else 0 + + if not d.is_node_changed(['service', 'https']): + if confirm_time: + out, err = run_commit_confirm(state) + if err: + raise err + msg = msg + out if msg else out + else: + out, err = run_commit(state) + if err: + raise err + msg = msg + out if msg else out else: - # capture non-fatal warnings - out = session.commit() - msg = out if out else msg + if is_background_job: + # If already running as a background job, commit synchronously here + if confirm_time: + call_commit_confirm(state) + else: + call_commit(state) + else: + # Otherwise schedule the commit to run after the HTTP response + if confirm_time: + background_tasks.add_task(call_commit_confirm, state) + else: + background_tasks.add_task(call_commit, state) + + out = self_ref_msg + msg = msg + out if msg else out LOG.info(f"Configuration modified via HTTP API using key '{state.id}'") except ConfigSessionError as e: @@ -411,16 +502,68 @@ def _configure_op( status = 500 # Don't give the details away to the outer world - error_msg = 'An internal error occured. Check the logs for details.' + error_msg = 'An internal error occurred. Check the logs for details.' finally: + if 'IN_COMMIT_CONFIRM' in env: + del env['IN_COMMIT_CONFIRM'] lock.release() + # Background jobs return raw success text or raise on failure; + # the API wrapper formats HTTP responses and returns it + if is_background_job: + if status == 200: + return msg + else: + raise RuntimeError(error_msg) + if status != 200: return error(status, error_msg) return success(msg) +async def _configure_op( + data: Union[ + ConfirmModel, + ConfigureModel, + ConfigureListModel, + ConfigSectionModel, + ConfigSectionListModel, + ConfigSectionTreeModel, + ], + background_tasks: BackgroundTasks, + in_background: bool = False, +): + """ + API wrapper for configure operations. + + If `in_background=True`: enqueue the whole configure + workflow and return an operation record immediately. + Otherwise: run the configure workflow in a threadpool + and return the normal API response. + """ + + if in_background: + try: + # Enqueue and return an operation handle that + # can be polled via `/retrieve/background-operations` + record = background_op_manager.enqueue( + background_tasks, + _execute_configure_op, + data, + ) + except BackgroundOpError as e: + return error(500, str(e)) + + return success({'operation': record.model_dump()}) + + return await run_in_threadpool( + _execute_configure_op, + data, + background_tasks=background_tasks, + ) + + def create_path_import_pki_no_prompt(path): correct_paths = ['ca', 'certificate', 'key-pair'] if path[1] not in correct_paths: @@ -431,21 +574,23 @@ def create_path_import_pki_no_prompt(path): @router.post('/configure') -def configure_op( - data: Union[ConfigureModel, ConfigureListModel], +async def configure_op( + data: Union[ConfigureModel, ConfigureListModel, ConfirmModel], request: Request, background_tasks: BackgroundTasks, + in_background: bool = Query(False), ): - return _configure_op(data, request, background_tasks) + return await _configure_op(data, background_tasks, in_background) @router.post('/configure-section') -def configure_section_op( +async def configure_section_op( data: Union[ConfigSectionModel, ConfigSectionListModel, ConfigSectionTreeModel], request: Request, background_tasks: BackgroundTasks, + in_background: bool = Query(False), ): - return _configure_op(data, request, background_tasks) + return await _configure_op(data, background_tasks, in_background) @router.post('/retrieve') @@ -487,49 +632,98 @@ async def retrieve_op(data: RetrieveModel): return error(400, str(e)) except Exception: LOG.critical(traceback.format_exc()) - return error(500, 'An internal error occured. Check the logs for details.') + return error(500, 'An internal error occurred. Check the logs for details.') return success(res) +@router.post('/retrieve/background-operations') +async def retrieve_background_operations( + op_id: str = Query(None), +): + if op_id: + # Return only that record + record = background_op_manager.get_record(op_id) + records = [record] if record else [] + else: + # Return the full in-memory operation history (oldest first) + records = background_op_manager.get_records() + + result = { + 'operations': [record.model_dump() for record in records], + } + + return success(result) + + @router.post('/config-file') -def config_file_op(data: ConfigFileModel, background_tasks: BackgroundTasks): +async def config_file_op(data: ConfigFileModel, background_tasks: BackgroundTasks): state = SessionState() session = state.session env = session.get_session_env() op = data.op msg = None - try: - if op == 'save': - if data.file: - path = data.file - else: - path = '/config/config.boot' - msg = session.save_config(path) - elif op == 'load': - if data.file: - path = data.file - else: - return error(400, 'Missing required field "file"') + # A non-zero confirm_time will start commit-confirm timer on commit + confirm_time = data.confirm_time + + # Serialize config operations without blocking the event loop + async with asynclock: + try: + if op == 'save': + path = data.file or '/config/config.boot' + msg = session.save_config(path) + + elif op in ('load', 'merge'): + if data.file: + path = data.file + elif data.string: + path = '/tmp/config.file' + with open(path, 'w') as f: + f.write(data.string) + else: + return error(400, 'Missing required field "file | string"') + + match op: + case 'load': + session.migrate_and_load_config(path) + case 'merge': + session.merge_config(path, destructive=data.destructive) - session.migrate_and_load_config(path) + config = Config(session_env=env) + d = get_config_diff(config) - config = Config(session_env=env) - d = get_config_diff(config) + state.confirm_time = confirm_time if confirm_time else 0 - if d.is_node_changed(['service', 'https']): - background_tasks.add_task(call_commit, state) - msg = self_ref_msg + if not d.is_node_changed(['service', 'https']): + if confirm_time: + out, err = await run_in_threadpool(run_commit_confirm, state) + else: + out, err = await run_in_threadpool(run_commit, state) + + if err: + raise err + msg = (msg or '') + (out or '') + else: + if confirm_time: + background_tasks.add_task(call_commit_confirm, state) + else: + background_tasks.add_task(call_commit, state) + out = self_ref_msg + msg = (msg or '') + (out or '') + elif op == 'confirm': + msg = session.confirm() else: - session.commit() - else: - return error(400, f"'{op}' is not a valid operation") - except ConfigSessionError as e: - return error(400, str(e)) - except Exception: - LOG.critical(traceback.format_exc()) - return error(500, 'An internal error occured. Check the logs for details.') + return error(400, f"'{op}' is not a valid operation") + + except ConfigSessionError as e: + return error(400, str(e)) + except Exception: + LOG.critical(traceback.format_exc()) + return error(500, 'An internal error occurred. Check the logs for details.') + finally: + if 'IN_COMMIT_CONFIRM' in env: + del env['IN_COMMIT_CONFIRM'] return success(msg) @@ -554,7 +748,7 @@ def image_op(data: ImageModel): return error(400, str(e)) except Exception: LOG.critical(traceback.format_exc()) - return error(500, 'An internal error occured. Check the logs for details.') + return error(500, 'An internal error occurred. Check the logs for details.') return success(res) @@ -587,7 +781,7 @@ def container_image_op(data: ContainerImageModel): return error(400, str(e)) except Exception: LOG.critical(traceback.format_exc()) - return error(500, 'An internal error occured. Check the logs for details.') + return error(500, 'An internal error occurred. Check the logs for details.') return success(res) @@ -603,13 +797,14 @@ def generate_op(data: GenerateModel): try: if op == 'generate': res = session.generate(path) + session.commit() else: return error(400, f"'{op}' is not a valid operation") except ConfigSessionError as e: return error(400, str(e)) except Exception: LOG.critical(traceback.format_exc()) - return error(500, 'An internal error occured. Check the logs for details.') + return error(500, 'An internal error occurred. Check the logs for details.') return success(res) @@ -631,7 +826,7 @@ def show_op(data: ShowModel): return error(400, str(e)) except Exception: LOG.critical(traceback.format_exc()) - return error(500, 'An internal error occured. Check the logs for details.') + return error(500, 'An internal error occurred. Check the logs for details.') return success(res) @@ -653,10 +848,30 @@ def reboot_op(data: RebootModel): return error(400, str(e)) except Exception: LOG.critical(traceback.format_exc()) - return error(500, 'An internal error occured. Check the logs for details.') + return error(500, 'An internal error occurred. Check the logs for details.') return success(res) +@router.post('/renew') +def renew_op(data: RenewModel): + state = SessionState() + session = state.session + + op = data.op + path = data.path + + try: + if op == 'renew': + res = session.renew(path) + else: + return error(400, f"'{op}' is not a valid operation") + except ConfigSessionError as e: + return error(400, str(e)) + except Exception: + LOG.critical(traceback.format_exc()) + return error(500, 'An internal error occurred. Check the logs for details.') + + return success(res) @router.post('/reset') def reset_op(data: ResetModel): @@ -675,7 +890,7 @@ def reset_op(data: ResetModel): return error(400, str(e)) except Exception: LOG.critical(traceback.format_exc()) - return error(500, 'An internal error occured. Check the logs for details.') + return error(500, 'An internal error occurred. Check the logs for details.') return success(res) @@ -715,7 +930,7 @@ def import_pki(data: ImportPkiModel): return error(400, str(e)) except Exception: LOG.critical(traceback.format_exc()) - return error(500, 'An internal error occured. Check the logs for details.') + return error(500, 'An internal error occurred. Check the logs for details.') finally: lock.release() @@ -739,7 +954,7 @@ def poweroff_op(data: PoweroffModel): return error(400, str(e)) except Exception: LOG.critical(traceback.format_exc()) - return error(500, 'An internal error occured. Check the logs for details.') + return error(500, 'An internal error occurred. Check the logs for details.') return success(res) diff --git a/src/services/api/session.py b/src/services/api/session.py index ad3ef660c..c25a444e9 100644 --- a/src/services/api/session.py +++ b/src/services/api/session.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/services/vyos-commitd b/src/services/vyos-commitd index 8dbd39058..b8e430b93 100755 --- a/src/services/vyos-commitd +++ b/src/services/vyos-commitd @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -42,6 +42,7 @@ from vyos.defaults import directories from vyos.utils.boot import boot_configuration_complete from vyos.configsource import ConfigSourceCache from vyos.configsource import ConfigSourceError +from vyos.configdiff import get_commit_scripts from vyos.config import Config from vyos.frrender import FRRender from vyos.frrender import get_frrender_dict @@ -72,8 +73,9 @@ class Session: # pylint: disable=too-many-instance-attributes session_id: str = '' - named_active: str = None - named_proposed: str = None + session_pid: int = None + sudo_user: str = None + user: str = None dry_run: bool = False atomic: bool = False background: bool = False @@ -229,14 +231,30 @@ def initialization(session: Session) -> Session: config = Config(config_source=configsource) + # required by protobuf schema; non-existence will raise early error + if session.session_pid: + os.environ['SESSION_PID'] = str(session.session_pid) + + # required by protobuf schema; may be empty string + if session.sudo_user: + os.environ['SUDO_USER'] = session.sudo_user + + # required by protobuf schema; may be empty string + if session.user: + os.environ['USER'] = session.user + dependent_func: dict[str, list[typing.Callable]] = {} setattr(config, 'dependent_func', dependent_func) + commit_scripts = get_commit_scripts(config) + logger.debug(f'commit_scripts: {commit_scripts}') + scripts_called = [] setattr(config, 'scripts_called', scripts_called) - dry_run = False - setattr(config, 'dry_run', dry_run) + dry_run = session.dry_run + config.set_bool_attr('dry_run', dry_run) + logger.debug(f'commit dry_run is {dry_run}') session.config = config @@ -249,11 +267,16 @@ def run_script(script_name: str, config: Config, args: list) -> tuple[bool, str] script = conf_mode_scripts[script_name] script.argv = args config.set_level([]) + dry_run = config.get_bool_attr('dry_run') try: c = script.get_config(config) script.verify(c) - script.generate(c) - script.apply(c) + if not dry_run: + script.generate(c) + script.apply(c) + else: + if hasattr(script, 'call_dependents'): + script.call_dependents() except ConfigError as e: logger.error(e) return False, str(e) @@ -265,6 +288,38 @@ def run_script(script_name: str, config: Config, args: list) -> tuple[bool, str] return True, '' +def call_frr_render(frr, config): + # pylint: disable=redefined-outer-name + def _call_frr_render(frr, config): + # pylint: disable=broad-exception-caught + try: + tmp = get_frrender_dict(config) + if frr.generate(tmp): + # only apply a new FRR configuration if anything changed + # in comparison to the previous applied configuration + frr.apply() + + except ConfigError as e: + logger.error(e) + return False, str(e) + except Exception: + tb = traceback.format_exc() + logger.error(tb) + return False, tb + + return True, '' + + with redirect_stdout(io.StringIO()) as o: + result, err_out = _call_frr_render(frr, config) + amb_out = o.getvalue() + o.close() + + out = amb_out + err_out + logger.info(out) + + return result, out + + def process_call_data(call: Call, config: Config, last: bool = False) -> None: # pylint: disable=too-many-locals @@ -296,8 +351,6 @@ def process_call_data(call: Call, config: Config, last: bool = False) -> None: out = amb_out + err_out - call.set_reply(success, out) - logger.info(f'[{script_name}] {out}') if last: @@ -305,11 +358,11 @@ def process_call_data(call: Call, config: Config, last: bool = False) -> None: logger.debug(f'scripts_called: {scripts_called}') if last and success: - tmp = get_frrender_dict(config) - if frr.generate(tmp): - # only apply a new FRR configuration if anything changed - # in comparison to the previous applied configuration - frr.apply() + s, o = call_frr_render(frr, config) + success = s + out = out + o + + call.set_reply(success, out) def process_session_data(session: Session) -> Session: diff --git a/src/services/vyos-configd b/src/services/vyos-configd index 28acccd2c..2f060dc82 100755 --- a/src/services/vyos-configd +++ b/src/services/vyos-configd @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -33,6 +33,7 @@ from enum import Enum import zmq from vyos.defaults import directories +from vyos.defaults import vyos_configd_socket_path from vyos.utils.boot import boot_configuration_complete from vyos.configsource import ConfigSourceString from vyos.configsource import ConfigSourceError @@ -57,7 +58,7 @@ if debug: else: logger.setLevel(logging.INFO) -SOCKET_PATH = 'ipc:///run/vyos-configd.sock' +SOCKET_PATH = vyos_configd_socket_path MAX_MSG_SIZE = 65535 PAD_MSG_SIZE = 6 @@ -68,6 +69,7 @@ class Response(Enum): ERROR_COMMIT = 2 ERROR_DAEMON = 4 PASS = 8 + ERROR_COMMIT_APPLY = 16 vyos_conf_scripts_dir = directories['conf_mode'] @@ -142,8 +144,6 @@ def run_script(script_name, config, args) -> tuple[Response, str]: try: c = script.get_config(config) script.verify(c) - script.generate(c) - script.apply(c) except ConfigError as e: logger.error(e) return Response.ERROR_COMMIT, str(e) @@ -152,6 +152,17 @@ def run_script(script_name, config, args) -> tuple[Response, str]: logger.error(tb) return Response.ERROR_COMMIT, tb + try: + script.generate(c) + script.apply(c) + except ConfigError as e: + logger.error(e) + return Response.ERROR_COMMIT_APPLY, str(e) + except Exception: + tb = traceback.format_exc() + logger.error(tb) + return Response.ERROR_COMMIT_APPLY, tb + return Response.SUCCESS, '' @@ -263,6 +274,39 @@ def process_node_data(config, data, _last: bool = False) -> tuple[Response, str] out = amb_out + err_out + logger.info(f'[{script_name}] {out}') + + return result, out + + +def call_frr_render(frr, config): + def _call_frr_render(frr, config): + # pylint: disable=broad-exception-caught + try: + tmp = get_frrender_dict(config) + if frr.generate(tmp): + # only apply a new FRR configuration if anything changed + # in comparison to the previous applied configuration + frr.apply() + + except ConfigError as e: + logger.error(e) + return Response.ERROR_COMMIT_APPLY, str(e) + except Exception: + tb = traceback.format_exc() + logger.error(tb) + return Response.ERROR_COMMIT_APPLY, tb + + return Response.SUCCESS, '' + + with redirect_stdout(io.StringIO()) as o: + result, err_out = _call_frr_render(frr, config) + amb_out = o.getvalue() + o.close() + + out = amb_out + err_out + logger.info(out) + return result, out @@ -335,17 +379,16 @@ if __name__ == '__main__': config = initialization(socket) elif message['type'] == 'node': res, out = process_node_data(config, message['data'], message['last']) - send_result(socket, res, out) if message['last'] and config: scripts_called = getattr(config, 'scripts_called', []) logger.debug(f'scripts_called: {scripts_called}') if res == Response.SUCCESS: - tmp = get_frrender_dict(config) - if frr.generate(tmp): - # only apply a new FRR configuration if anything changed - # in comparison to the previous applied configuration - frr.apply() + r, o = call_frr_render(frr, config) + res = r + out = out + o + + send_result(socket, res, out) else: logger.critical(f'Unexpected message: {message}') diff --git a/src/services/vyos-conntrack-logger b/src/services/vyos-conntrack-logger index 9c31b465f..6e0733291 100755 --- a/src/services/vyos-conntrack-logger +++ b/src/services/vyos-conntrack-logger @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -15,10 +15,8 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import argparse -import grp import logging import multiprocessing -import os import queue import signal import socket diff --git a/src/services/vyos-domain-resolver b/src/services/vyos-domain-resolver index aba5ba9db..e1a52c93a 100755 --- a/src/services/vyos-domain-resolver +++ b/src/services/vyos-domain-resolver @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -28,7 +28,7 @@ from vyos.utils.commit import commit_in_progress from vyos.utils.dict import dict_search_args from vyos.utils.kernel import WIREGUARD_REKEY_AFTER_TIME from vyos.utils.file import makedir, chmod_775, write_file, read_file -from vyos.utils.network import is_valid_ipv4_address_or_range +from vyos.utils.network import is_valid_ipv4_address_or_range, is_valid_ipv6_address_or_range from vyos.utils.process import cmd from vyos.utils.process import run from vyos.xml_ref import get_defaults @@ -48,6 +48,7 @@ ipv4_tables = { 'ip vyos_mangle', 'ip vyos_filter', 'ip vyos_nat', + 'ip vyos_wanloadbalance', 'ip raw' } @@ -92,12 +93,14 @@ def resolve(domains, ipv6=False): for domain in domains: resolved = fqdn_resolve(domain, ipv6=ipv6) + cache_key = f'{domain}_ipv6' if ipv6 else domain + if resolved and cache: - domain_state[domain] = resolved + domain_state[cache_key] = resolved elif not resolved: - if domain not in domain_state: + if cache_key not in domain_state: continue - resolved = domain_state[domain] + resolved = domain_state[cache_key] ip_list = ip_list | resolved return ip_list @@ -141,10 +144,11 @@ def update_remote_group(config): for set_name, remote_config in remote_groups.items(): if 'url' not in remote_config: continue - nft_set_name = f'R_{set_name}' + nft_ip_set_name = f'R_{set_name}' + nft_ip6_set_name = f'R6_{set_name}' # Create list file if necessary - list_file = os.path.join(firewall_config_dir, f"{nft_set_name}.txt") + list_file = os.path.join(firewall_config_dir, f"{nft_ip_set_name}.txt") if not os.path.exists(list_file): write_file(list_file, '', user="root", group="vyattacfg", mode=0o644) @@ -157,16 +161,32 @@ def update_remote_group(config): # Read list file ip_list = [] + ip6_list = [] + invalid_list = [] for line in read_file(list_file).splitlines(): line_first_word = line.strip().partition(' ')[0] if is_valid_ipv4_address_or_range(line_first_word): ip_list.append(line_first_word) + elif is_valid_ipv6_address_or_range(line_first_word): + ip6_list.append(line_first_word) + else: + if line_first_word[0].isalnum(): + invalid_list.append(line_first_word) - # Load tables + # Load ip tables for table in ipv4_tables: - if (table, nft_set_name) in valid_sets: - conf_lines += nft_output(table, nft_set_name, ip_list) + if (table, nft_ip_set_name) in valid_sets: + conf_lines += nft_output(table, nft_ip_set_name, ip_list) + + # Load ip6 tables + for table in ipv6_tables: + if (table, nft_ip6_set_name) in valid_sets: + conf_lines += nft_output(table, nft_ip6_set_name, ip6_list) + + invalid_str = ", ".join(invalid_list) + if invalid_str: + logger.info(f'Invalid address for set {set_name}: {invalid_str}') count += 1 diff --git a/src/services/vyos-hostsd b/src/services/vyos-hostsd index 1ba90471e..89742b431 100755 --- a/src/services/vyos-hostsd +++ b/src/services/vyos-hostsd @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -233,10 +233,7 @@ # } import os -import sys -import time import json -import signal import traceback import re import logging @@ -245,7 +242,6 @@ import zmq from voluptuous import Schema, MultipleInvalid, Required, Any from collections import OrderedDict from vyos.utils.file import makedir -from vyos.utils.permission import chown from vyos.utils.permission import chmod_755 from vyos.utils.process import popen from vyos.utils.process import process_named_running diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index be3dd5051..94697ed4d 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -1,6 +1,6 @@ #!/usr/share/vyos-http-api-tools/bin/python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -99,7 +99,7 @@ def info(q: Annotated[InfoQueryParams, Query()]): res.update(banner=banner) except Exception: LOG.critical(traceback.format_exc()) - return error(500, 'An internal error occured. Check the logs for details.') + return error(500, 'An internal error occurred. Check the logs for details.') return success(res) diff --git a/src/services/vyos-netlinkd b/src/services/vyos-netlinkd new file mode 100755 index 000000000..2b158e05d --- /dev/null +++ b/src/services/vyos-netlinkd @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 re +import sys +import syslog +import signal +import select + +from pyroute2 import IPRoute # pylint: disable = no-name-in-module +from pyroute2 import NetlinkError # pylint: disable = no-name-in-module +from pyroute2.netlink.rtnl import RTMGRP_LINK +from time import sleep +from typing import Optional + +from vyos.configquery import op_mode_config_dict +from vyos.ifconfig import Section +from vyos.utils.boot import boot_configuration_complete +from vyos.utils.commit import commit_in_progress2 +from vyos.utils.dict import dict_search +from vyos.utils.process import cmd +from vyos.utils.process import is_systemd_service_active +from vyos.utils.process import stop_systemd_unit + +running = True + +# compile regex once during startup for fast match +IFACE_RE = re.compile(r"^(?:eth|br|bond|wlan)") + +def match_iface(ifname: str) -> bool: + """ Helper function returning true if interface name is a match for further + processing (e.g. restart of DHCP(v6) client) + """ + return IFACE_RE.match(ifname) is not None + +def sigterm_handler(signo, frame): + global running + running = False + sig = signal.Signals(signo) + syslog.syslog(syslog.LOG_INFO, f'Received signal {sig.name} - shutting down...') + +def _handle_dhcp_events(operstate: Optional[str], ifname: str) -> None: + systemdV4_service = f'dhclient@{ifname}.service' + systemdV6_service = f'dhcp6c@{ifname}.service' + + # Only handle explicit UP/DOWN state transitions; ignore other kernel states. + if operstate not in ['UP', 'DOWN']: + return None + + if operstate == 'DOWN': + # Interface moved state to down + if is_systemd_service_active(systemdV4_service): + syslog.syslog(syslog.LOG_DEBUG, f'Stopping {systemdV4_service}...') + stop_systemd_unit(systemdV4_service, raise_on_failure=False) + if is_systemd_service_active(systemdV6_service): + syslog.syslog(syslog.LOG_DEBUG, f'Stopping {systemdV6_service}...') + stop_systemd_unit(systemdV6_service, raise_on_failure=False) + + elif operstate == 'UP': + v6_restart = False + interface_path = Section.get_config_path(ifname, delimiter='.') + + config_dict = op_mode_config_dict( + ['interfaces'], key_mangling=('-', '_'), get_first_key=True + ) + + if tmp := dict_search(f'{interface_path}.address', config_dict): + # Always (re-)start the DHCP(v6) client service. If the DHCP(v6) client + # is already running - which could happen if the interface is re- + # configured in operational down state, it will have an exponential backoff + # time increasing while not receiving a DHCP(v6) reply. + # + # To make the interface instantly available, and as for a DHCP(v6) lease + # we will re-start the service and thus cancel the backoff time. + if 'dhcp' in tmp: + syslog.syslog(syslog.LOG_DEBUG, f'Restarting {systemdV4_service}...') + cmd(f'systemctl restart {systemdV4_service}') + if 'dhcpv6' in tmp: + v6_restart = True + + if dict_search(f'{interface_path}.dhcpv6_options.pd', config_dict): + v6_restart = True + + if v6_restart: + syslog.syslog(syslog.LOG_DEBUG, f'Restarting {systemdV6_service}...') + cmd(f'systemctl restart {systemdV6_service}') + + return None + +def main(): + syslog.openlog(ident="vyos-netlinkd", + logoption=syslog.LOG_PID, + facility=syslog.LOG_DAEMON) + syslog.syslog(syslog.LOG_INFO, "VyOS Netlink listener daemon started.") + + # Subscribe to link notifications only (not routes/rules/neigh/addr/...). + ipr = IPRoute() + try: + # newer pyroute2 versions support bind group in IPRoute() constructor + ipr.bind(groups=RTMGRP_LINK) + syslog.syslog(syslog.LOG_INFO, + 'IPRoute.bind() using groups=RTMGRP_LINK RTNL subscription') + except TypeError: + syslog.syslog(syslog.LOG_WARNING, + 'IPRoute.bind() has no groups= support; using default RTNL subscriptions', + ) + ipr.bind() + fd = ipr.fileno() + + global running + while running: + if not boot_configuration_complete(): + syslog.syslog(syslog.LOG_INFO, 'System bootup not yet finished...') + sleep(5) + continue + + try: + # Wait for up to 1 second for a netlink message + rlist, _, _ = select.select([fd], [], [], 1.0) + if not rlist: + # timeout - retry + continue + + # Check if a config commit is in progress before processing any + # messages. This avoids blocking per-message and reduces unnecessary + # calls to commit_in_progress2() + if commit_in_progress2(): + syslog.syslog(syslog.LOG_DEBUG, + 'Config commit in progress, skipping netlink events') + sleep(1) + continue + + # Receive and process any messages + for message in ipr.get(): + # Parse NETLINK message + match message['event']: + # Message received during interface creation or modification + # e.g. link up/down. + case 'RTM_NEWLINK': + attrs = dict(message.get('attrs', [])) + ifname = attrs.get('IFLA_IFNAME', None) + mac = attrs.get('IFLA_ADDRESS', '<unknown>') + operstate = attrs.get('IFLA_OPERSTATE', None) + syslog.syslog(syslog.LOG_DEBUG, + f'RTM_NEWLINK -> {ifname}, state={operstate}, mac={mac}') + + # Bail out early - no interface name in the message + if not ifname: + continue + # Bail out early - not interested in interface type + if not match_iface(ifname): + continue + + _handle_dhcp_events(operstate, ifname) + + # Deletion of a network link which has been previously added to the kernel + case 'RTM_DELLINK': + pass + case _: + pass + + except NetlinkError as e: + syslog.syslog(syslog.LOG_ERR, f'Netlink error: {e}') + except Exception as e: + syslog.syslog(syslog.LOG_ERR, f'Unhandled exception: {e}') + except KeyboardInterrupt: + break + + ipr.close() + syslog.syslog(syslog.LOG_INFO, 'Netlink listener daemon stopped.') + sys.exit(0) + +if __name__ == "__main__": + signal.signal(signal.SIGTERM, sigterm_handler) + signal.signal(signal.SIGINT, sigterm_handler) + main() diff --git a/src/services/vyos-network-event-logger b/src/services/vyos-network-event-logger index 840ff3cda..699c57b1a 100644 --- a/src/services/vyos-network-event-logger +++ b/src/services/vyos-network-event-logger @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -732,7 +732,7 @@ class RouteFormatter(BaseMSGFormatter): message += self._format_rta_pref(msg.get_attr("RTA_PREF")) if msg.get_attr('RTA_TTL_PROPAGATE') is not None: - message += f' ttl-propogate {"enabled" if msg.get_attr("RTA_TTL_PROPAGATE") else "disabled"}' + message += f' ttl-propagate {"enabled" if msg.get_attr("RTA_TTL_PROPAGATE") else "disabled"}' if msg.get_attr('RTA_MULTIPATH') is not None: _tmp = self._format_rta_multipath( diff --git a/src/shim/mkjson/mkjson.c b/src/shim/mkjson/mkjson.c index 1172664fb..58ab455c5 100644 --- a/src/shim/mkjson/mkjson.c +++ b/src/shim/mkjson/mkjson.c @@ -279,7 +279,7 @@ char *mkjson( enum mkjson_container_type otype, int count, ... ) for ( i = 0; i < count; i++ ) { // Add separators: - // - not on the begining + // - not on the beginning // - always after valid chunk // - between two valid chunks // - between valid and ignored chunk if the latter isn't the last one @@ -304,4 +304,3 @@ char *mkjson( enum mkjson_container_type otype, int count, ... ) return json; } - diff --git a/src/shim/vyshim.c b/src/shim/vyshim.c index 1eb653cbf..7b516cd46 100644 --- a/src/shim/vyshim.c +++ b/src/shim/vyshim.c @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020-2024 VyOS maintainers and contributors + * Copyright VyOS maintainers and contributors <maintainers@vyos.io> * * 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 @@ -18,8 +18,10 @@ #include <stdlib.h> #include <stdio.h> #include <string.h> +#include <fcntl.h> #include <unistd.h> #include <string.h> +#include <sys/stat.h> #include <sys/time.h> #include <time.h> #include <stdint.h> @@ -55,15 +57,17 @@ enum { SUCCESS = 1 << 0, ERROR_COMMIT = 1 << 1, ERROR_DAEMON = 1 << 2, - PASS = 1 << 3 + PASS = 1 << 3, + ERROR_COMMIT_APPLY = 1 << 4 }; volatile int init_alarm = 0; volatile int timeout = 0; -int initialization(void *); +int initialization(void *, char *); int pass_through(char **, int); void timer_handler(int); +void leave_hint(char *); double get_posix_clock_time(void); @@ -94,8 +98,17 @@ int main(int argc, char* argv[]) char *test = strstr(string_node_data, "VYOS_TAGNODE_VALUE"); ex_index = test ? 2 : 1; + char *env_tmp = getenv("VYATTA_CONFIG_TMP"); + if (env_tmp == NULL) { + fprintf(stderr, "Error: Environment variable VYATTA_CONFIG_TMP is not set.\n"); + exit(EXIT_FAILURE); + } + char *pid_str = strdup(env_tmp); + strsep(&pid_str, "_"); + debug_print("config session pid: %s\n", pid_str); + if (access(COMMIT_MARKER, F_OK) != -1) { - init_timeout = initialization(requester); + init_timeout = initialization(requester, pid_str); if (!init_timeout) remove(COMMIT_MARKER); } @@ -151,13 +164,19 @@ int main(int argc, char* argv[]) ret = -1; } + if (err & ERROR_COMMIT_APPLY) { + debug_print("Received ERROR_COMMIT_APPLY\n"); + leave_hint(pid_str); + ret = -1; + } + zmq_close(requester); zmq_ctx_destroy(context); return ret; } -int initialization(void* Requester) +int initialization(void* Requester, char* pid_val) { char *active_str = NULL; size_t active_len = 0; @@ -185,10 +204,6 @@ int initialization(void* Requester) double prev_time_value, time_value; double time_diff; - char *pid_val = getenv("VYATTA_CONFIG_TMP"); - strsep(&pid_val, "_"); - debug_print("config session pid: %s\n", pid_val); - char *sudo_user = getenv("SUDO_USER"); if (!sudo_user) { char nobody[] = "nobody"; @@ -338,6 +353,16 @@ void timer_handler(int signum) return; } +void leave_hint(char *pid_val) +{ + char tmp_str[16]; + mode_t omask = umask(0); + snprintf(tmp_str, sizeof(tmp_str), "/tmp/apply_%s", pid_val); + open(tmp_str, O_CREAT|O_RDWR|O_TRUNC, 0666); + chown(tmp_str, 1002, 102); + umask(omask); +} + #ifdef _POSIX_MONOTONIC_CLOCK double get_posix_clock_time(void) { diff --git a/src/system/grub_update.py b/src/system/grub_update.py index 5a0534195..0477cfa99 100644 --- a/src/system/grub_update.py +++ b/src/system/grub_update.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This file is part of VyOS. # @@ -58,7 +58,7 @@ if __name__ == '__main__': vyos_menuentries = compat.parse_menuentries(grub_cfg_main) vyos_versions = compat.find_versions(vyos_menuentries) unparsed_items = compat.filter_unparsed(grub_cfg_main) - # compatibilty for raid installs + # compatibility for raid installs search_root = compat.get_search_root(unparsed_items) common_dict = {} common_dict['search_root'] = search_root diff --git a/src/system/kea-vrf-helper b/src/system/kea-vrf-helper new file mode 100755 index 000000000..b4dd6a349 --- /dev/null +++ b/src/system/kea-vrf-helper @@ -0,0 +1,14 @@ +#!/bin/bash + +VRF=$1 +shift + +export KEA_DHCP_DATA_DIR=/config/dhcp +export KEA_HOOK_SCRIPTS_PATH=/usr/libexec/vyos/system +export KEA_LOCKFILE_DIR=/run/lock/kea + +ip vrf exec $VRF \ + setpriv --reuid=_kea --regid=_kea --init-groups \ + --inh-caps +net_bind_service,+net_raw \ + --ambient-caps +net_bind_service,+net_raw \ + $@ diff --git a/src/system/keepalived-fifo.py b/src/system/keepalived-fifo.py index 24733803a..c6c3fa9d0 100755 --- a/src/system/keepalived-fifo.py +++ b/src/system/keepalived-fifo.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -55,7 +55,7 @@ class KeepalivedFifo: self._config_load() self.pipe_path = cmd_args.PIPE - # create queue for messages and events for syncronization + # create queue for messages and events for synchronization self.message_queue = Queue(maxsize=100) self.stopme = threading.Event() self.message_event = threading.Event() @@ -111,7 +111,7 @@ class KeepalivedFifo: # wait for a new message event from pipe_wait self.message_event.wait() try: - # clear mesage event flag + # clear message event flag self.message_event.clear() # get all messages from queue and try to process them while self.message_queue.empty() is not True: diff --git a/src/system/normalize-ip b/src/system/normalize-ip index 08f922a8e..9ef57e28a 100755 --- a/src/system/normalize-ip +++ b/src/system/normalize-ip @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/system/on-dhcp-event.sh b/src/system/on-dhcp-event.sh index 47c276270..492727b3e 100755 --- a/src/system/on-dhcp-event.sh +++ b/src/system/on-dhcp-event.sh @@ -1,6 +1,6 @@ #!/bin/bash # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -30,7 +30,7 @@ get_subnet_domain_name () { from vyos.kea import kea_get_active_config from vyos.utils.dict import dict_search_args -config = kea_get_active_config('4') +config = kea_get_active_config('4', '') shared_networks = dict_search_args(config, 'arguments', f'Dhcp4', 'shared-networks') found = False diff --git a/src/system/on-dhcpv6-event.sh b/src/system/on-dhcpv6-event.sh index cbb370999..93fd3ce81 100755 --- a/src/system/on-dhcpv6-event.sh +++ b/src/system/on-dhcpv6-event.sh @@ -1,6 +1,6 @@ #!/bin/bash # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/system/standalone_root_pw_reset b/src/system/standalone_root_pw_reset index c82cea321..9a7da8e4c 100755 --- a/src/system/standalone_root_pw_reset +++ b/src/system/standalone_root_pw_reset @@ -54,7 +54,7 @@ change_password() { # set the password for the user then store it in the config # so the user is recreated on the next full system boot. - local epwd=$(mkpasswd --method=sha-512 "$pwd1") + local epwd=$(mkpasswd --method=yescrypt "$pwd1") # escape any slashes in resulting password local eepwd=$(sed 's:/:\\/:g' <<< $epwd) set_encrypted_password $user $eepwd $CF @@ -64,7 +64,7 @@ change_password() { dead() { echo $* echo - echo "This tool can only recover missing admininistrator password." + echo "This tool can only recover missing administrator password." echo "It is not a full system restore" echo echo -n "Hit return to reboot system: " @@ -90,7 +90,7 @@ fi echo -n "Do you wish to reset the admin password? (y or n) " read -t $TIME_TO_WAIT response if [ "$?" != "0" ]; then - echo + echo echo "Response not received in time." echo "The admin password will not be reset." echo "Rebooting in 5 seconds..." @@ -127,7 +127,7 @@ fi # Leftover from V3.0 if grep -q /opt/vyatta/etc/config /etc/fstab -then +then echo "Mounting the config filesystem..." mount /opt/vyatta/etc/config/ fi diff --git a/src/system/sync-dhcp-lease-to-hosts.py b/src/system/sync-dhcp-lease-to-hosts.py index 5c8b18faf..ea50b98a3 100755 --- a/src/system/sync-dhcp-lease-to-hosts.py +++ b/src/system/sync-dhcp-lease-to-hosts.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -33,17 +33,17 @@ logs_handler = logging.StreamHandler() logger.addHandler(logs_handler) -def _get_all_server_leases(inet_suffix='4') -> list: +def _get_all_server_leases(inet_suffix='4', vrf='') -> list: mappings = [] try: - active_config = kea_get_active_config(inet_suffix) + active_config = kea_get_active_config(inet_suffix, vrf) except Exception: raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') try: pools = kea_get_dhcp_pools(active_config, inet_suffix) mappings = kea_get_server_leases( - active_config, inet_suffix, pools, state=[], origin=None + active_config, inet_suffix, vrf, pools, state=[], origin=None ) except Exception: raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server leases') diff --git a/src/system/sync-snmp-engine-boots.py b/src/system/sync-snmp-engine-boots.py new file mode 100644 index 000000000..4213325c4 --- /dev/null +++ b/src/system/sync-snmp-engine-boots.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# 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/>. +# +# Called via systemd ExecStartPost= after snmpd starts. +# Reads the live engineBoots value from /var/lib/snmp/snmpd.conf and +# writes it to /config/snmp/engineboots.count only if the value differs. +# Fixes T8538: ensures the persistent counter is always in sync with +# what snmpd actually used, so the next restart increments correctly. + +import os +import logging +import contextlib + +import vyos.opmode + +from vyos.utils.file import read_file +from vyos.utils.file import write_file + +SNMPD_CONF = '/var/lib/snmp/snmpd.conf' +PERSIST_FILE = '/config/snmp/engineboots.count' + +# Configure logging +logger = logging.getLogger(__name__) +logger.addHandler(logging.StreamHandler()) +logger.setLevel(logging.DEBUG) + + +def _read_snmpd_engine_boots() -> int | None: + """Return the engineBoots value from snmpd's persistent conf, or None.""" + + content = read_file(SNMPD_CONF, defaultonfailure='', sudo=True) + for line in content.splitlines(): + if line.startswith('engineBoots'): + parts = line.split() + if len(parts) < 2: + continue + _, value, *_ = parts + with contextlib.suppress(ValueError): + return int(value) + + return None + + +def _read_persist_engine_boots() -> int | None: + """Return the currently saved engineBoots counter, or None.""" + + raw = read_file(PERSIST_FILE, defaultonfailure='') + with contextlib.suppress(ValueError): + return int(raw.strip()) + + return None + + +if __name__ == '__main__': + snmpd_boots = _read_snmpd_engine_boots() + if snmpd_boots is None: + raise vyos.opmode.DataUnavailable( + f'Could not read engineBoots from {SNMPD_CONF}' + ) + + logger.debug(f'engineBoots from snmpd: {snmpd_boots}') + + persist_boots = _read_persist_engine_boots() + logger.debug(f'engineBoots from persist file: {persist_boots}') + + if persist_boots == snmpd_boots: + logger.debug('engineBoots already in sync, nothing to do') + else: + os.makedirs(os.path.dirname(PERSIST_FILE), exist_ok=True) + write_file(PERSIST_FILE, str(snmpd_boots)) + logger.debug(f'engineBoots updated: {persist_boots} -> {snmpd_boots}') diff --git a/src/system/uacctd_stop.py b/src/system/uacctd_stop.py deleted file mode 100755 index a1b57335b..000000000 --- a/src/system/uacctd_stop.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2023 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/>. - -# Control pmacct daemons in a tricky way. -# Pmacct has signal processing in a main loop, together with packet -# processing. Because of this, while it is waiting for packets, it cannot -# handle the control signal. We need to start the systemctl command and then -# send some packets to pmacct to wake it up - -from argparse import ArgumentParser -from socket import socket, AF_INET, SOCK_DGRAM -from sys import exit -from time import sleep - -from psutil import Process - - -def stop_process(pid: int, timeout: int) -> None: - """Send a signal to uacctd - and then send packets to special address predefined in a firewall - to unlock main loop in uacctd and finish the process properly - - Args: - pid (int): uacctd PID - timeout (int): seconds to wait for a process end - """ - # find a process - uacctd = Process(pid) - uacctd.terminate() - - # create a socket - trigger = socket(AF_INET, SOCK_DGRAM) - - first_cycle: bool = True - while uacctd.is_running() and timeout: - print('sending a packet to uacctd...') - trigger.sendto(b'WAKEUP', ('127.0.254.0', 1)) - # do not sleep during first attempt - if not first_cycle: - sleep(1) - timeout -= 1 - first_cycle = False - - -if __name__ == '__main__': - parser = ArgumentParser() - parser.add_argument('process_id', - type=int, - help='PID file of uacctd core process') - parser.add_argument('timeout', - type=int, - help='time to wait for process end') - args = parser.parse_args() - stop_process(args.process_id, args.timeout) - exit() diff --git a/src/system/vyos-config-cloud-init.py b/src/system/vyos-config-cloud-init.py index 0a6c1f9bc..1fdfb00db 100755 --- a/src/system/vyos-config-cloud-init.py +++ b/src/system/vyos-config-cloud-init.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/system/vyos-event-handler.py b/src/system/vyos-event-handler.py index dd2793046..bb1a42ee9 100755 --- a/src/system/vyos-event-handler.py +++ b/src/system/vyos-event-handler.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -134,7 +134,7 @@ if __name__ == '__main__': ) exit(1) - # Prepare for proper exitting + # Prepare for proper exiting signal(SIGTERM, handle_signal) signal(SIGINT, handle_signal) diff --git a/src/system/vyos-system-update-check.py b/src/system/vyos-system-update-check.py index c874f1e2c..b7d1fc7c5 100755 --- a/src/system/vyos-system-update-check.py +++ b/src/system/vyos-system-update-check.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -60,7 +60,7 @@ if __name__ == '__main__': remote_version = jmespath.search('[0].version', remote_data) if local_version != remote_version and remote_version: call(f'wall -n "Update available: {remote_version} \nUpdate URL: {url}"') - # MOTD used in /run/motd.d/10-update + # MOTD used in /run/motd.d/10-vyos-update motd_file.parent.mkdir(exist_ok=True) motd_file.write_text(f'---\n' f'Current version: {local_version}\n' diff --git a/src/systemd/igmpproxy.service b/src/systemd/igmpproxy.service new file mode 100644 index 000000000..55ab14beb --- /dev/null +++ b/src/systemd/igmpproxy.service @@ -0,0 +1,10 @@ +[Unit] +Description=IGMP multicast proxy +After=vyos-router.service +ConditionPathExists=/run/igmpproxy/igmpproxy.conf + +[Service] +Type=simple +Restart=on-failure +RestartSec=10 +ExecStart=/usr/sbin/igmpproxy -n /run/igmpproxy/igmpproxy.conf diff --git a/src/systemd/opennhrp.service b/src/systemd/opennhrp.service deleted file mode 100644 index c9a44de29..000000000 --- a/src/systemd/opennhrp.service +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -Description=OpenNHRP -After=vyos-router.service -ConditionPathExists=/run/opennhrp/opennhrp.conf -StartLimitIntervalSec=0 - -[Service] -Type=forking -ExecStart=/usr/sbin/opennhrp -d -v -a /run/opennhrp.socket -c /run/opennhrp/opennhrp.conf -s /etc/opennhrp/opennhrp-script.py -p /run/opennhrp/opennhrp.pid -ExecReload=/usr/bin/kill -HUP $MAINPID -PIDFile=/run/opennhrp/opennhrp.pid -Restart=on-failure -RestartSec=20 diff --git a/src/systemd/vpp-failure-handler.service b/src/systemd/vpp-failure-handler.service new file mode 100644 index 000000000..3a75f9519 --- /dev/null +++ b/src/systemd/vpp-failure-handler.service @@ -0,0 +1,9 @@ +[Unit] +Description=Restart VPP on failure + +[Service] +Type=oneshot +User=root +Group=vyattacfg +UMask=0002 +ExecStart=/usr/bin/python3 /usr/libexec/vyos/reset_section.py vpp --reload diff --git a/src/systemd/vyconfd.service b/src/systemd/vyconfd.service index ab2280263..6e6292f60 100644 --- a/src/systemd/vyconfd.service +++ b/src/systemd/vyconfd.service @@ -8,7 +8,7 @@ DefaultDependencies=no After=systemd-remount-fs.service [Service] -ExecStart=/usr/libexec/vyos/vyconf/vyconfd --log-file /var/run/log/vyconfd.log +ExecStart=/usr/libexec/vyos/init/vyconfd.sh Type=exec SyslogIdentifier=vyconfd SyslogFacility=daemon diff --git a/src/systemd/vyos-netlinkd.service b/src/systemd/vyos-netlinkd.service new file mode 100644 index 000000000..6c8a80878 --- /dev/null +++ b/src/systemd/vyos-netlinkd.service @@ -0,0 +1,25 @@ +[Unit] +Description=VyOS netlink daemon + +# Without this option, lots of default dependencies are added, +# among them network.target, which creates a dependency cycle +DefaultDependencies=no + +# Seemingly sensible way to say "as early as the system is ready" +# All vyos-netlinkd needs is read/write mounted root +After=systemd-remount-fs.service vyos-router.service + +[Service] +ExecStart=/usr/bin/python3 -u /usr/libexec/vyos/services/vyos-netlinkd +Type=simple + +SyslogIdentifier=vyos-netlinkd +SyslogFacility=daemon + +Restart=on-failure + +User=root +Group=vyattacfg + +[Install] +WantedBy=vyos.target diff --git a/src/systemd/vyos-system-update.service b/src/systemd/vyos-system-update.service index 032e5a14c..0e62a1814 100644 --- a/src/systemd/vyos-system-update.service +++ b/src/systemd/vyos-system-update.service @@ -1,5 +1,5 @@ [Unit] -Description=VyOS system udpate-check service +Description=VyOS system update-check service After=network.target vyos-router.service [Service] diff --git a/src/systemd/vyos.target b/src/systemd/vyos.target index 47c91c1cc..ea1593fe9 100644 --- a/src/systemd/vyos.target +++ b/src/systemd/vyos.target @@ -1,3 +1,3 @@ [Unit] Description=VyOS target -After=multi-user.target +After=multi-user.target vyos-grub-update.service systemd-sysctl.service diff --git a/src/tests/helper.py b/src/tests/helper.py index cc0710494..2c4421d0a 100644 --- a/src/tests/helper.py +++ b/src/tests/helper.py @@ -1,4 +1,4 @@ -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/tests/test_config_diff.py b/src/tests/test_config_diff.py index 4017fff4d..edbc0804e 100644 --- a/src/tests/test_config_diff.py +++ b/src/tests/test_config_diff.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/tests/test_config_merge.py b/src/tests/test_config_merge.py new file mode 100644 index 000000000..c9b4ad5c8 --- /dev/null +++ b/src/tests/test_config_merge.py @@ -0,0 +1,50 @@ +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 vyos.configtree + +from unittest import TestCase + +class TestConfigDiff(TestCase): + def setUp(self): + with open('tests/data/config.left', 'r') as f: + config_string = f.read() + self.config_left = vyos.configtree.ConfigTree(config_string) + + with open('tests/data/config.right', 'r') as f: + config_string = f.read() + self.config_right = vyos.configtree.ConfigTree(config_string) + + def test_merge_destructive(self): + res = vyos.configtree.merge(self.config_left, self.config_right, + destructive=True) + right_value = self.config_right.return_value(['node1', 'tag_node', 'foo', 'single']) + merge_value = res.return_value(['node1', 'tag_node', 'foo', 'single']) + + # Check includes new value + self.assertEqual(right_value, merge_value) + + # Check preserves non-confliciting paths + self.assertTrue(res.exists(['node3'])) + + def test_merge_non_destructive(self): + res = vyos.configtree.merge(self.config_left, self.config_right) + left_value = self.config_left.return_value(['node1', 'tag_node', 'foo', 'single']) + merge_value = res.return_value(['node1', 'tag_node', 'foo', 'single']) + + # Check includes original value + self.assertEqual(left_value, merge_value) + + # Check preserves non-confliciting paths + self.assertTrue(res.exists(['node3'])) diff --git a/src/tests/test_config_parser.py b/src/tests/test_config_parser.py index 1b4a57311..823d5a7c1 100644 --- a/src/tests/test_config_parser.py +++ b/src/tests/test_config_parser.py @@ -1,4 +1,4 @@ -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/tests/test_config_tree.py b/src/tests/test_config_tree.py new file mode 100644 index 000000000..d6339c570 --- /dev/null +++ b/src/tests/test_config_tree.py @@ -0,0 +1,48 @@ +# Copyright (C) VyOS Inc. +# +# 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 json +import unittest +from unittest import TestCase + +from vyos.configtree import ConfigTree +from vyos.referencetree import ReferenceTree +from vyos.derivedtree import subtree_from_list_of_partial_paths + + +class TestInitialSetup(TestCase): + def setUp(self): + with open('data/config.boot.default') as f: + config_str = f.read() + self.ct = ConfigTree(config_str) + + def test_subtree_from_partial(self): + reftree = ReferenceTree(cache_file='data/reftree.cache') + + # workaround since configtree.list_nodes does not take an empty path + d = json.loads(self.ct.to_json()) + top_nodes = list(d) + paths = [s.split() for s in top_nodes] + + reassemble = subtree_from_list_of_partial_paths( + self.ct, paths, reference_tree=reftree + ) + + self.assertEqual(self.ct, reassemble) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/tests/test_configd_inspect.py b/src/tests/test_configd_inspect.py index a0470221d..3363ab653 100644 --- a/src/tests/test_configd_inspect.py +++ b/src/tests/test_configd_inspect.py @@ -1,4 +1,4 @@ -# Copyright (C) 2020-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/tests/test_configverify.py b/src/tests/test_configverify.py index f1ec65cd2..8f80365d7 100644 --- a/src/tests/test_configverify.py +++ b/src/tests/test_configverify.py @@ -1,4 +1,4 @@ -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/tests/test_dependency_graph.py b/src/tests/test_dependency_graph.py index f3f1db376..12f50a1c4 100644 --- a/src/tests/test_dependency_graph.py +++ b/src/tests/test_dependency_graph.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/tests/test_dict_search.py b/src/tests/test_dict_search.py index 6b4bc933a..cbc170ecf 100644 --- a/src/tests/test_dict_search.py +++ b/src/tests/test_dict_search.py @@ -1,4 +1,4 @@ -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -44,6 +44,14 @@ class TestDictSearch(TestCase): self.assertEqual(dict_search('non_existing', data), None) self.assertEqual(dict_search('non.existing.fancy.key', data), None) + def test_non_existing_keys_with_default_positional(self): + # TestDictSearch: Return a default value when querying for non-existent key (positional arg) + self.assertEqual(dict_search('non.existing.fancy.key', data, 'test'), 'test') + + def test_non_existing_keys_with_default_named(self): + # TestDictSearch: Return a default value when querying for non-existent key (named arg) + self.assertEqual(dict_search('non.existing.fancy.key', data, default='test'), 'test') + def test_string(self): # TestDictSearch: Return value when querying string self.assertEqual(dict_search('string', data), data['string']) diff --git a/src/tests/test_find_device_file.py b/src/tests/test_find_device_file.py index 5b90f2034..02a33c224 100644 --- a/src/tests/test_find_device_file.py +++ b/src/tests/test_find_device_file.py @@ -1,4 +1,4 @@ -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/tests/test_initial_setup.py b/src/tests/test_initial_setup.py index 7737f9df5..09feea7ba 100644 --- a/src/tests/test_initial_setup.py +++ b/src/tests/test_initial_setup.py @@ -1,4 +1,4 @@ -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/tests/test_op_mode.py b/src/tests/test_op_mode.py index 23f709653..848a9666d 100644 --- a/src/tests/test_op_mode.py +++ b/src/tests/test_op_mode.py @@ -1,4 +1,4 @@ -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/tests/test_task_scheduler.py b/src/tests/test_task_scheduler.py index 795ffeb9d..0d0319495 100644 --- a/src/tests/test_task_scheduler.py +++ b/src/tests/test_task_scheduler.py @@ -1,4 +1,4 @@ -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/tests/test_template.py b/src/tests/test_template.py index 6377f6da5..e2548602d 100644 --- a/src/tests/test_template.py +++ b/src/tests/test_template.py @@ -1,4 +1,4 @@ -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -190,3 +190,21 @@ class TestVyOSTemplate(TestCase): for group_name, group_config in data['ike_group'].items(): ciphers = vyos.template.get_esp_ike_cipher(group_config) self.assertIn(IKEv2_DEFAULT, ','.join(ciphers)) + + def test_get_default_port(self): + from vyos.defaults import config_files + from vyos.defaults import internal_ports + + with self.assertRaises(RuntimeError): + vyos.template.get_default_config_file('UNKNOWN') + with self.assertRaises(RuntimeError): + vyos.template.get_default_port('UNKNOWN') + with self.assertRaises(RuntimeError): + vyos.template.nft_accept_invalid('UNKNOWN') + + self.assertEqual(vyos.template.get_default_config_file('sshd_user_ca'), + config_files['sshd_user_ca']) + self.assertEqual(vyos.template.get_default_port('certbot_haproxy'), + internal_ports['certbot_haproxy']) + self.assertEqual(vyos.template.nft_accept_invalid('arp'), + 'ct state invalid ether type arp counter accept') diff --git a/src/tests/test_utils.py b/src/tests/test_utils.py index 7bfd2618e..5022b24f6 100644 --- a/src/tests/test_utils.py +++ b/src/tests/test_utils.py @@ -1,4 +1,4 @@ -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -13,6 +13,8 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. from unittest import TestCase +from unittest.mock import patch + class TestVyOSUtils(TestCase): def test_key_mangling(self): from vyos.utils.dict import mangle_dict_keys @@ -23,4 +25,64 @@ class TestVyOSUtils(TestCase): def test_sysctl_read(self): from vyos.utils.system import sysctl_read - self.assertEqual(sysctl_read('net.ipv4.conf.lo.forwarding'), '1') + self.assertEqual(sysctl_read(['net', 'ipv4', 'conf', 'lo', 'forwarding']), '1') + + def test_sysctl_key_normalization(self): + from vyos.utils.system import sysctl_read + with patch('vyos.utils.system.run') as mock_run: + mock_run.return_value.stdout = b'1\n' + sysctl_read(['net', 'ipv4', 'conf', 'eth0.10', 'forwarding']) + mock_run.assert_called_with( + ['sysctl', '-nb', 'net.ipv4.conf.eth0/10.forwarding'], + capture_output=True, + ) + + def test_list_strip(self): + from vyos.utils.list import list_strip + + lst = ['a', 'b', 'c', 'd', 'e'] + sub = ['a', 'b'] + rsb = ['d', 'e'] + non = ['a', 'e'] + self.assertEqual(list_strip(lst, sub), ['c', 'd', 'e']) + self.assertEqual(list_strip(lst, rsb, right=True), ['a', 'b', 'c']) + self.assertEqual(list_strip(lst, non), []) + self.assertEqual(list_strip(sub, lst), []) + + def test_range_str_to_list(self): + from vyos.utils.convert import range_str_to_list + + # basic cases + self.assertEqual(range_str_to_list('1-3'), [1, 2, 3]) + self.assertEqual(range_str_to_list('1-3,5,7-8'), [1, 2, 3, 5, 7, 8]) + self.assertEqual(range_str_to_list('3'), [3]) + # empty string + self.assertEqual(range_str_to_list(''), []) + # unordered input + self.assertEqual(range_str_to_list('5,1-3,4'), [1, 2, 3, 4, 5]) + self.assertEqual(range_str_to_list('7-9,1-3'), [1, 2, 3, 7, 8, 9]) + # overlapping ranges + self.assertEqual(range_str_to_list('1-5,3-7'), [1, 2, 3, 4, 5, 6, 7]) + self.assertEqual(range_str_to_list('1-3,2-4,3-5'), [1, 2, 3, 4, 5]) + # duplicated values + self.assertEqual(range_str_to_list('1,1,2,2,3'), [1, 2, 3]) + self.assertEqual(range_str_to_list('5,1-3,2,3'), [1, 2, 3, 5]) + # adjacent ranges + self.assertEqual(range_str_to_list('1-3,4-6'), [1, 2, 3, 4, 5, 6]) + + def test_list_to_range_str(self): + from vyos.utils.convert import list_to_range_str + + # basic cases + self.assertEqual(list_to_range_str([1, 2, 3]), '1-3') + self.assertEqual(list_to_range_str([1, 2, 3, 5, 7, 8]), '1-3,5,7-8') + self.assertEqual(list_to_range_str([1, 3]), '1,3') + self.assertEqual(list_to_range_str([3]), '3') + # empty list + self.assertEqual(list_to_range_str([]), '') + # unordered input + self.assertEqual(list_to_range_str([5, 1, 2, 3, 4]), '1-5') + self.assertEqual(list_to_range_str([7, 8, 9, 1, 2, 3]), '1-3,7-9') + # duplicated values + self.assertEqual(list_to_range_str([1, 1, 2, 2, 3, 3]), '1-3') + self.assertEqual(list_to_range_str([5, 1, 2, 2, 3, 5]), '1-3,5') diff --git a/src/tests/test_utils_auth.py b/src/tests/test_utils_auth.py new file mode 100644 index 000000000..f3b52ab29 --- /dev/null +++ b/src/tests/test_utils_auth.py @@ -0,0 +1,75 @@ +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 pwd +import unittest + +from vyos.utils import auth + +class TestVyOSUtilsAuth(unittest.TestCase): + def test_uid_root(self): + self.assertEqual(auth.get_local_passwd_entries(0).pw_name, 'root') + self.assertEqual(auth.get_local_passwd_entries(0).pw_uid, 0) + + def test_uid_daemon(self): + uid = None + for user in auth.get_local_passwd_entries(): + if user.pw_name == 'daemon': + uid = user.pw_uid + break + + self.assertEqual(auth.get_local_passwd_entries(uid).pw_name, 'daemon') + self.assertEqual(auth.get_local_passwd_entries(uid).pw_uid, uid) + + def test_uid_not_found(self): + self.assertEqual(auth.get_local_passwd_entries(5465487635), None) + + def test_get_local_users_returns_existing_usernames(self): + # Returned users exist, skip list is excluded, and UIDs are in range + + all_users = set(s_user.pw_name for s_user in pwd.getpwall()) + local_users = auth.get_local_users() + + # All returned users must really exist + for user in local_users: + self.assertIn(user, all_users) + + # Nobody in the skip list + for skipped in auth.SYSTEM_USER_SKIP_LIST: + self.assertNotIn(skipped, local_users) + + # All are within UID range + for s_user in pwd.getpwall(): + if s_user.pw_name in local_users: + self.assertGreaterEqual(s_user.pw_uid, auth.MIN_USER_UID) + self.assertLessEqual(s_user.pw_uid, auth.MAX_USER_UID) + + def test_get_user_home_dir_for_real_user(self): + # User's homedir is a non-empty string for a valid user + + local_users = auth.get_local_users() + if local_users: + for user in local_users: + home_dir = auth.get_user_home_dir(user) + self.assertIsInstance(home_dir, str) + self.assertTrue(bool(home_dir)) # Should not be empty + else: + self.skipTest("No suitable non-system users found on this system") + + def test_get_user_home_dir_invalid_user(self): + # Raises KeyError for nonexistent username + + user = "__this_user_does_not_exist__" # Test using unlikely username + with self.assertRaises(KeyError): + auth.get_user_home_dir(user) diff --git a/src/tests/test_utils_file.py b/src/tests/test_utils_file.py new file mode 100644 index 000000000..4519cf03c --- /dev/null +++ b/src/tests/test_utils_file.py @@ -0,0 +1,86 @@ +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 tempfile +import unittest + +from pathlib import Path + +from vyos.utils.file import copy_recursive +from vyos.utils.file import move_recursive + + +class TestVyOSUtilsFile(unittest.TestCase): + def setUp(self): + """Create temporary directories for source and destination.""" + self.tmpdir = tempfile.TemporaryDirectory() + self.src = Path(self.tmpdir.name) / 'src' + self.dst = Path(self.tmpdir.name) / 'dst' + + # Create test directory structure in `src` + (self.src / 'subdir').mkdir(parents=True) + (self.dst).mkdir(parents=True) + + # Create files + (self.src / 'file1.txt').write_text('hello world') + (self.src / 'subdir' / 'file2.txt').write_text('subdir file') + + def tearDown(self): + """Cleanup temp directory.""" + self.tmpdir.cleanup() + + def test_copy_recursive_no_overwrite(self): + copy_recursive(str(self.src), str(self.dst), overwrite=False) + + self.assertTrue((self.dst / 'file1.txt').exists()) + self.assertTrue((self.dst / 'subdir' / 'file2.txt').exists()) + + def test_copy_recursive_skip_existing(self): + # Create conflicting file in destination + (self.dst / 'file1.txt').write_text('different content') + + copy_recursive(str(self.src), str(self.dst), overwrite=False) + + # Destination should remain the same (not overwritten) + content = (self.dst / 'file1.txt').read_text() + self.assertEqual(content, 'different content') + + def test_copy_recursive_overwrite(self): + (self.dst / 'file1.txt').write_text('different content') + + copy_recursive(str(self.src), str(self.dst), overwrite=True) + + # Destination should be overwritten with source content + content = (self.dst / 'file1.txt').read_text() + self.assertEqual(content, 'hello world') + + def test_move_recursive(self): + move_recursive(str(self.src), str(self.dst), overwrite=False) + + # Files should appear in destination + self.assertTrue((self.dst / 'file1.txt').exists()) + self.assertTrue((self.dst / 'subdir' / 'file2.txt').exists()) + + # Source should be removed + self.assertFalse(self.src.exists()) + + def test_move_recursive_overwrite(self): + # Prepare conflicting file in destination + (self.dst / 'file1.txt').write_text('conflicting') + + move_recursive(str(self.src), str(self.dst), overwrite=True) + + content = (self.dst / 'file1.txt').read_text() + self.assertEqual(content, 'hello world') # overwritten + self.assertFalse(self.src.exists()) # source cleaned up diff --git a/src/tests/test_utils_network.py b/src/tests/test_utils_network.py index d68dec16f..6d9a358c1 100644 --- a/src/tests/test_utils_network.py +++ b/src/tests/test_utils_network.py @@ -1,4 +1,4 @@ -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -43,3 +43,12 @@ class TestVyOSUtilsNetwork(TestCase): self.assertFalse(vyos.utils.network.is_loopback_addr('::2')) self.assertFalse(vyos.utils.network.is_loopback_addr('192.0.2.1')) + + def test_check_port_availability(self): + self.assertTrue(vyos.utils.network.check_port_availability('::1', 8080)) + self.assertTrue(vyos.utils.network.check_port_availability('127.0.0.1', 8080)) + self.assertTrue(vyos.utils.network.check_port_availability(None, 8080, protocol='udp')) + # We do not have 192.0.2.1 configured on this system + self.assertFalse(vyos.utils.network.check_port_availability('192.0.2.1', 443)) + # We do not have 2001:db8::1 configured on this system + self.assertFalse(vyos.utils.network.check_port_availability('2001:db8::1', 80, protocol='udp')) diff --git a/src/helpers/vyos_net_name b/src/udev/vyos_net_name index f5de182c6..568734cb7 100755 --- a/src/helpers/vyos_net_name +++ b/src/udev/vyos_net_name @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -43,7 +43,7 @@ def is_available(intfs: dict, intf_name: str) -> bool: def find_available(intfs: dict, prefix: str) -> str: - """Find lowest indexed iterface name that is not assigned""" + """Find lowest indexed interface name that is not assigned""" index_list = [ int(x.replace(prefix, '')) for x in list(intfs.values()) if prefix in x ] @@ -72,7 +72,7 @@ def mod_ifname(ifname: str) -> str: def get_biosdevname(ifname: str) -> str: """Use legacy vyatta-biosdevname to query for name - This is carried over for compatability only, and will likely be dropped + This is carried over for compatibility only, and will likely be dropped going forward. XXX: This throws an error, and likely has for a long time, unnoticed since vyatta_net_name redirected stderr to /dev/null. diff --git a/src/utils/add-config-sync-exclude-paths.py b/src/utils/add-config-sync-exclude-paths.py new file mode 100755 index 000000000..54a209490 --- /dev/null +++ b/src/utils/add-config-sync-exclude-paths.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 + +import sys +import json +import argparse + +parser = argparse.ArgumentParser() +parser.add_argument('--file', default='data/config-sync-exclude.json') +parser.add_argument('--paths', nargs='+') + +args = parser.parse_args() +paths = args.paths +file = args.file + +try: + with open(file) as f: + exclude_str = json.load(f) +except FileNotFoundError: + print(f'Adding new file: {file}') + exclude_str = [] +except json.JSONDecodeError as e: + sys.exit(e) + +for path in (paths or []): + exclude_str.append(path.split()) + +with open(file, 'w') as f: + json.dump(exclude_str, f, indent=1) + f.write('\n') diff --git a/src/utils/vyos-commands-to-config b/src/utils/vyos-commands-to-config index 927d9bd70..54dd4e9fa 100755 --- a/src/utils/vyos-commands-to-config +++ b/src/utils/vyos-commands-to-config @@ -1,6 +1,6 @@ #! /usr/bin/python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/utils/vyos-config-file-query b/src/utils/vyos-config-file-query index a10c7e9b3..2673fc487 100755 --- a/src/utils/vyos-config-file-query +++ b/src/utils/vyos-config-file-query @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/utils/vyos-hostsd-client b/src/utils/vyos-hostsd-client index a0515951a..937e657ff 100755 --- a/src/utils/vyos-hostsd-client +++ b/src/utils/vyos-hostsd-client @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/utils/vyos-show-config b/src/utils/vyos-show-config index 152322fc1..703b0ac9a 100755 --- a/src/utils/vyos-show-config +++ b/src/utils/vyos-show-config @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/validators/as-number-list b/src/validators/as-number-list index 432d44180..1df40cc3a 100755 --- a/src/validators/as-number-list +++ b/src/validators/as-number-list @@ -1,6 +1,6 @@ #!/bin/sh # -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/validators/base64 b/src/validators/base64 index a54168ef7..97d7a0398 100755 --- a/src/validators/base64 +++ b/src/validators/base64 @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/validators/bgp-extended-community b/src/validators/bgp-extended-community index d66665519..561d41bca 100755 --- a/src/validators/bgp-extended-community +++ b/src/validators/bgp-extended-community @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# Copyright 2019-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 diff --git a/src/validators/bgp-large-community b/src/validators/bgp-large-community index 386398308..2b9ef7a8a 100755 --- a/src/validators/bgp-large-community +++ b/src/validators/bgp-large-community @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# Copyright 2019-2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -48,6 +48,6 @@ if __name__ == '__main__': print("Invalid community format") exit(1) - # fail if none of validators catched the value + # fail if none of validators caught the value print("Invalid community format") exit(1)
\ No newline at end of file diff --git a/src/validators/bgp-large-community-list b/src/validators/bgp-large-community-list index 9ba5b27eb..8e4326a9c 100755 --- a/src/validators/bgp-large-community-list +++ b/src/validators/bgp-large-community-list @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -17,18 +17,27 @@ import re import sys -pattern = '(.*):(.*):(.*)' -allowedChars = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '+', '*', '?', '^', '$', '(', ')', '[', ']', '{', '}', '|', '\\', ':', '-' } +allowedChars = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '+', '*', '?', '^', '$', '(', ')', '[', ']', '{', '}', '|', '\\', ':', '-', '_', ' ' } if __name__ == '__main__': if len(sys.argv) != 2: sys.exit(1) - value = sys.argv[1].split(':') - if not len(value) == 3: + value = sys.argv[1] + + # Require at least one well-formed large-community tuple in the pattern. + tmp = value.split(':') + if len(tmp) < 3: + sys.exit(1) + + # Simple guard against invalid community & 1003.2 pattern chars + if not set(value).issubset(allowedChars): sys.exit(1) - if not (re.match(pattern, sys.argv[1]) and set(sys.argv[1]).issubset(allowedChars)): + # Don't feed FRR badly formed regex + try: + re.compile(value) + except re.error: sys.exit(1) sys.exit(0) diff --git a/src/validators/bgp-rd-rt b/src/validators/bgp-rd-rt index b2b69c9be..233f09696 100755 --- a/src/validators/bgp-rd-rt +++ b/src/validators/bgp-rd-rt @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/validators/bgp-regular-community b/src/validators/bgp-regular-community index d43a71eae..6df3e8d5d 100755 --- a/src/validators/bgp-regular-community +++ b/src/validators/bgp-regular-community @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# Copyright 2019-2022 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 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 @@ -45,6 +45,6 @@ if __name__ == '__main__': print("Invalid community format") exit(1) - # fail if none of validators catched the value + # fail if none of validators caught the value print("Invalid community format") exit(1)
\ No newline at end of file diff --git a/src/validators/cpu b/src/validators/cpu new file mode 100755 index 000000000..959a49248 --- /dev/null +++ b/src/validators/cpu @@ -0,0 +1,43 @@ +#!/usr/bin/python3 + +import re +import sys + +MAX_CPU = 511 + + +def validate_isolcpus(value): + pattern = re.compile(r'^(\d{1,3}(-\d{1,3})?)(,(\d{1,3}(-\d{1,3})?))*$') + if not pattern.fullmatch(value): + return False + + flat_list = [] + for part in value.split(','): + if '-' in part: + start, end = map(int, part.split('-')) + if start > end or start < 0 or end > MAX_CPU: + return False + flat_list.extend(range(start, end + 1)) + else: + num = int(part) + if num < 0 or num > MAX_CPU: + return False + flat_list.append(num) + + for i in range(1, len(flat_list)): + if flat_list[i] <= flat_list[i - 1]: + return False + + return True + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python3 cpu.py <cpu_list>") + sys.exit(1) + + input_value = sys.argv[1] + if validate_isolcpus(input_value): + sys.exit(0) + else: + sys.exit(1) diff --git a/src/validators/ddclient-protocol b/src/validators/ddclient-protocol index ce5efbd52..0d28039d3 100755 --- a/src/validators/ddclient-protocol +++ b/src/validators/ddclient-protocol @@ -1,6 +1,6 @@ #!/bin/sh # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/validators/ether-type b/src/validators/ether-type index 926db26d3..b3dc23b58 100644 --- a/src/validators/ether-type +++ b/src/validators/ether-type @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/validators/interface-address b/src/validators/interface-address index 2a2583fc3..3ea18b91f 100755 --- a/src/validators/interface-address +++ b/src/validators/interface-address @@ -1,3 +1,10 @@ #!/bin/sh -ipaddrcheck --is-any-host "$1" +ipaddrcheck --allow-loopback --is-valid-intf-address "$1" + +if [ $? -gt 0 ]; then + echo "Error: $1 is not a valid network interface address" + exit 1 +fi + +exit 0 diff --git a/src/validators/ip-protocol b/src/validators/ip-protocol index c4c882502..b7467a7a0 100755 --- a/src/validators/ip-protocol +++ b/src/validators/ip-protocol @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/validators/ipv6-eui64-prefix b/src/validators/ipv6-eui64-prefix index d7f262633..73ef9b29e 100755 --- a/src/validators/ipv6-eui64-prefix +++ b/src/validators/ipv6-eui64-prefix @@ -2,6 +2,8 @@ # Validator used to check if given IPv6 prefix is of size /64 required by EUI64 +import ipaddress + from sys import argv from sys import exit @@ -10,7 +12,14 @@ if __name__ == '__main__': exit(1) prefix = argv[1] - if prefix.split('/')[1] == '64': - exit(0) + + try: + network = ipaddress.ip_network(prefix) + if network.prefixlen == 64: + exit(0) + except ValueError: + print( + 'EUI64 prefix must be a valid IPv6 prefix in CIDR notation (e.g., 2001:db8::/64)' + ) exit(1) diff --git a/src/validators/psk-secret b/src/validators/psk-secret index c91aa95a8..69d8b75c4 100644 --- a/src/validators/psk-secret +++ b/src/validators/psk-secret @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/validators/script b/src/validators/script index eb176d23b..c3d39b347 100755 --- a/src/validators/script +++ b/src/validators/script @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License diff --git a/src/validators/sysctl b/src/validators/sysctl index 9b5bba3e1..cc21186f5 100755 --- a/src/validators/sysctl +++ b/src/validators/sysctl @@ -1,6 +1,6 @@ #!/bin/sh # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/validators/timezone b/src/validators/timezone index e55af8d2a..dd3e0654d 100755 --- a/src/validators/timezone +++ b/src/validators/timezone @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/validators/vrf-name b/src/validators/vrf-name index 29167c635..b56ebd036 100755 --- a/src/validators/vrf-name +++ b/src/validators/vrf-name @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2020 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -24,15 +24,22 @@ if __name__ == '__main__': vrf = argv[1] length = len(vrf) + # must not exceed Linux Kernel IFNAMSIZ definition if length not in range(1, 16): exit(1) + # allow only alpha numerical characters, - and _ + pattern = r'^[A-Za-z0-9_-]+$' + if not re.match(pattern, vrf): + exit(1) + # Treat loopback interface "lo" explicitly. Adding "lo" explicitly to the # following regex pattern would deny any VRF name starting with lo - thuse # local-vrf would be illegal - and that we do not want. if vrf == "lo": exit(1) + # VRF name must not conflict with local interface type prefix pattern = r'^(?!(bond|br|dum|eth|lan|eno|ens|enp|enx|gnv|ipoe|l2tp|l2tpeth|\ vtun|ppp|pppoe|peth|tun|vti|vxlan|wg|wlan|wwan|\d)\d*(\.\d+)?(v.+)?).*$' if not re.match(pattern, vrf): diff --git a/src/validators/watchdog-module b/src/validators/watchdog-module new file mode 100644 index 000000000..0ae68b46d --- /dev/null +++ b/src/validators/watchdog-module @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 re +import sys + +from vyos.utils.kernel import load_module +from vyos.utils.process import rc_cmd + + + +def main() -> int: + if len(sys.argv) < 2: + # No value to validate + return 1 + + module = sys.argv[1].strip() + if not module: + return 1 + + # Keep the module name format strict. + if not re.fullmatch(r"[a-zA-Z0-9_\-]+", module): + return 1 + + # Ensure the module exists and is loadable (dry-run). + # This does not load the module. + try: + rc = load_module(module, quiet=True, dry_run=True) + except OSError: + return 1 + + if rc != 0: + return 1 + + # Validate that the module looks like a watchdog driver. + # Use modinfo filename location as the heuristic. + rc, out = rc_cmd(["modinfo", "-F", "filename", module]) + if rc != 0: + return 1 + filename = (out or "").strip().lower() + + # Accept modules located under drivers/watchdog, plus explicit exception for + # ipmi_watchdog which lives in drivers/char/ipmi. + is_watchdog_driver = ( + ("/watchdog/" in filename) + or filename.endswith("/ipmi_watchdog.ko") + ) + + return 0 if is_watchdog_driver else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/validators/wireless-phy b/src/validators/wireless-phy index 513a902de..3d0abbb54 100755 --- a/src/validators/wireless-phy +++ b/src/validators/wireless-phy @@ -1,6 +1,6 @@ #!/bin/sh # -# Copyright (C) 2018-2020 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 |
