diff --git a/network-formula/README.md b/network-formula/README.md index c9235820..74d7545f 100644 --- a/network-formula/README.md +++ b/network-formula/README.md @@ -12,7 +12,9 @@ Currently only [Wicked](https://github.com/openSUSE/wicked) is supported, howeve `network` -Configures all possible aspects using either the pillar specified or the default backend (Wicked). +Configures all the pillar specified or the default backend (Wicked). + +### Wicked `network.wicked` @@ -30,6 +32,22 @@ Configures routes using Wicked (`/etc/sysconfig/network/routes`). Configures netconfig (`/etc/sysconfig/network/config`). +### NetworkManager + +`network.NetworkManager` + +Configures all aspects using NetworkManager. + +`network.NetworkManager.packages` + +Installs NetworkManager and removes Wicked (TODO). + +`network.NetworkManager.connections` + +Configures NetworkManager connection profiles (`/etc/NetworkManager/system-connections/`). + +### systemd + `network.systemd.link` Configures network devices using `systemd.link(5)` (`/etc/systemd/network/*.link`). diff --git a/network-formula/network/NetworkManager/common.sls b/network-formula/network/NetworkManager/common.sls new file mode 100644 index 00000000..dc6740c1 --- /dev/null +++ b/network-formula/network/NetworkManager/common.sls @@ -0,0 +1,20 @@ +{%- from 'network/NetworkManager/map.jinja' import base, base_backup, script -%} + +network_nm_backup_directory: + file.directory: + - name: {{ base_backup }} + - mode: '0750' + - user: root + - group: root + +network_nm_script: + file.managed: + - name: {{ script }} + - source: salt://{{ slspath }}/files/saltsafe_nm + - mode: '0750' + - user: root + - group: root + - template: jinja + - context: + base: {{ base }} + base_backup: {{ base_backup }} diff --git a/network-formula/network/NetworkManager/connections.sls b/network-formula/network/NetworkManager/connections.sls new file mode 100644 index 00000000..cb12ebd0 --- /dev/null +++ b/network-formula/network/NetworkManager/connections.sls @@ -0,0 +1,483 @@ +#!py +# vim: ft=python ts=2 sts=2 sw=2 +""" +Salt state file for managing NetworkManager connections +Copyright (C) 2025 Georg Pfuetzenreuter + +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 3 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, see . +""" + +from ipaddress import ip_address, ip_network +from logging import getLogger +from uuid import uuid4 + +from gi.repository import GLib + +log = getLogger('salt.network-formula.NMInterfaces') + +DEFAULTS = { + 'ipv6': { + # default in nmcli as well + 'addr-gen-mode': 'default', + }, +} + +# Translation from ifcfg to NetworkManager options +# - dict keys define ifcfg keys +# - empty dicts as value indicate the option is to be skipped +# - bonding_module_opts bonding_slave* are translated in separate logic +IFCFG_NM_TRANS = { + 'bridge_ports': {}, + 'bonding_master': {}, + 'usercontrol': {}, + 'bootproto': { + 'section': 'ipv', # gets expanded to ipv4/ipv6 + 'name': 'method', + 'values': { + 'auto6': 'auto', + 'autoip': 'auto', + 'dhcp': 'auto', + 'dhcp+autoip': 'auto', + 'dhcp4': 'auto', + 'dhcp6': 'auto', + 'none': 'disabled', + 'static': 'manual', + }, + }, + 'startmode': { + 'section': 'connection', + 'name': 'autoconnect', + 'values': { + 'auto': True, + 'manual': False, + 'off': False, + 'no': False, + }, + }, + 'interfacetype': { + 'section': 'connection', + 'name': 'type', + # TODO: verify if all values are the same + }, + 'firewall': { + 'section': 'connection', + 'name': 'zone', + 'values': { + 'no': 'SKIP', + }, + }, +} + +SINGULARS = { + 'addresses': 'address', + 'routes': 'route', +} + +def _translate_generic(option, value): + # returns: section (str), option (str), value (str), skip (bool) + + if IFCFG_NM_TRANS[str(option)]: + log.warn(f'Processing legacy option "{option}".') + else: + log.warn(f'Skipping legacy option "{option}".') + return None, None, None, True + + new_option = IFCFG_NM_TRANS[option]['name'] + new_value = IFCFG_NM_TRANS[option].get('values', {}).get(value, value) + + if new_value == 'SKIP': + log.warn(f'Skipping legacy option "{option}" due to value.') + return None, None, None, True + + if new_value is None: + log.error(f'Unable to translate "{option}={value}".') + return None, None, None, True + + section = IFCFG_NM_TRANS[option]['section'] + + log.warn(f'Translated legacy setting "{option}={value}" to "{section}.{new_option}={new_value}".') + + return section, new_option, new_value, False + +def _translate_split(value): + # returns: expanded key/values (dict) + + out = {} + + for pair in value.split(): + k, v = pair.split('=') + + out[k] = v + + return out + +def run(): + states = {} + + jmap = __salt__['cp.cache_file']('salt://network/NetworkManager/map.jinja') + settings = { + setting: __salt__['jinja.load_map'](jmap, setting) + for setting in [ + 'base', + 'base_backup', + 'control', + 'interfaces', + 'routes', + 'script', + ] + } + + nm_data = {} + controllers = { + 'bridge': {}, + 'bond': {}, + } + + for interface, config in settings['interfaces'].items(): + nm_data[interface] = { + 'connection': {}, + 'ipv4': {}, + 'ipv6': {}, + # TODO: support more sections + } + + if 'address' in config: + addresses = config['address'] + elif 'addresses' in config: + addresses = config['addresses'] + else: + addresses = [] + + if isinstance(addresses, str): + addresses = [addresses] + + for address in addresses: + v = ip_network(address, False).version + + ipv = f'ipv{v}' + + if 'addresses' not in nm_data[interface][ipv]: + nm_data[interface][ipv]['addresses'] = [] + + nm_data[interface][ipv]['addresses'].append(address) + + for option, value in config.items(): + if option in ['address', 'addresses']: + continue + + option = option.lower() + # TODO: keyfile supports booleans + if value is True: + value = 'yes' + elif value is False: + value = 'no' + elif isinstance(value, str) and value != 'ethtool_options': + value = value.lower() + + if option in IFCFG_NM_TRANS: + section, new_option, new_value, skip = _translate_generic(option, value) + + if skip: + continue + + if section == 'ipv': + for ipv in ['ipv4', 'ipv6']: + if nm_data[interface][ipv].get('addresses'): + nm_data[interface][ipv][new_option] = new_value + else: + if section not in nm_data[interface]: + nm_data[interface][section] = {} + + nm_data[interface][section][new_option] = new_value + + elif option == 'bonding_module_opts': + log.warn(f'Processing legacy option "{option}".') + + bmo = _translate_split(value) + + if 'bond' in nm_data[interface]: + nm_data[interface]['bond'].update(bmo) + else: + nm_data[interface]['bond'] = bmo + + elif option[0:13] == 'bonding_slave': + log.warn(f'Adding bonding ports from legacy "{option}" option.') + + if interface not in controllers['bond']: + controllers['bond'][interface] = [] + + controllers['bond'][interface].append(value) + + # if the value is a dictionary, we expect it to be a native NetworkManager section + + elif option in nm_data[interface] and isinstance(value, dict): + nm_data[interface][option].update(value) + elif isinstance(option, dict): + nm_data[interface][option] = value + else: + log.error(f'Unable to parse "{option}".') + + if 'bridge_ports' in config: + log.warn('Adding bridge ports from legacy "bridge_ports" option.') + if 'type' not in nm_data[interface]['connection']: + nm_data[interface]['connection']['type'] = 'bridge' + + bridge_ports = config['bridge_ports'] + if isinstance(bridge_ports, str): + bridge_ports = bridge_ports.split() + + controllers['bridge'][interface] = bridge_ports + + for interface, config in settings['interfaces'].items(): + for ipv in ['ipv4', 'ipv6']: + if 'method' not in nm_data[interface][ipv]: + if nm_data[interface][ipv].get('addresses'): + nm_data[interface][ipv]['method'] = 'manual' + else: + nm_data[interface][ipv]['method'] = 'disabled' + + for section, options in DEFAULTS.items(): + for option, value in options.items(): + if option not in nm_data[interface][section]: + nm_data[interface][section][option] = value + + if 'type' not in nm_data[interface]['connection']: + if interface in controllers['bridge']: + t = 'bridge' + elif interface in controllers['bond']: + t = 'bond' + else: + t = 'ethernet' + + nm_data[interface]['connection']['type'] = t + + for controller, ports in controllers['bridge'].items(): + if interface in ports: + nm_data[interface]['connection']['port-type'] = 'bridge' + break + + for controller_type, controller_connections in controllers.items(): + for controller, ports in controller_connections.items(): + for port in ports: + if port in nm_data: + log.warn(f'Setting {port} as {controller_type} member under controller {controller}.') + + nm_data[port]['connection']['port-type'] = controller_type + nm_data[port]['connection']['controller'] = controller + + else: + log.warn(f'Assuming {controller_type} port {port} under controller {controller}.') + + nm_data[port] = { + 'connection': { + 'type': 'ethernet', + 'port-type': controller_type, + 'controller': controller, + }, + } + + interface_addresses = { + 'ipv4': {}, + 'ipv6': {}, + } + + for interface, config in nm_data.items(): + for section, options in config.items(): + if section not in ['ipv4', 'ipv6']: + continue + + for option, value in options.items(): + if option == 'addresses': + interface_addresses[section][interface] = value + + for destination_network, options in settings['routes'].items(): + if 'gateway' not in options: + continue + + if destination_network == 'default6': + family = 'ipv6' + destination = 'default' + elif destination_network == 'default4': + family = 'ipv4' + destination = 'default' + else: + destination = ip_network(destination_network, False) + family = 'ipv' + str(destination.version) + + gateway_address = options['gateway'] + gateway = ip_address(gateway_address) + + for interface, addresses in interface_addresses[family].items(): + for address in addresses: + if gateway in ip_network(address, False): + if destination == 'default': + if 'gateway' in nm_data[interface][family]: + log.warn(f'Skipping duplicate gateway definition for interface {interface}.') + else: + nm_data[interface][family]['gateway'] = gateway_address + else: + if 'routes' not in nm_data[interface][family]: + nm_data[interface][family]['routes'] = [] + + nm_data[interface][family]['routes'].append((destination_network, gateway_address)) + + break + + interface_files = {} + interface_uuids = {} + + for interface, config in nm_data.items(): + file = f'{settings["base"]}/{interface}.nmconnection' + + if __salt__['file.file_exists'](file): + interface_files[interface] = file + + kf = GLib.KeyFile() + kf.load_from_file(file, GLib.KeyFileFlags.NONE) + + interface_uuids[interface] = kf.get_string('connection', 'uuid') + + """ + states['network_nm_backup_directory'] = { + 'file.directory': [ + {'name': base_backup}, + ] + } + """ + + if interface_files: + states['network_nm_nmconnection_backup'] = { + 'file.copy': [ + { + 'names': [ + { + f'{settings["base_backup"]}/{interface}.nmconnection': [ + {'source': file}, + ], + } for interface, file in interface_files.items() + ], + }, + { + 'require': [ + {'file': 'network_nm_backup_directory'}, + ], + }, + ], + } + + data = {} + + for interface, config in nm_data.items(): + kf = GLib.KeyFile() + + kf.set_comment(None, None, __salt__['pillar.get']('managed_by_salt_formula', ' This file is managed by the Salt network formula - do not edit it manually.')) + + if interface in interface_uuids: + uuid = interface_uuids[interface] + elif __opts__['test']: + uuid = 'will-generate-a-new-uuid' + else: + uuid = str(uuid4()) + + kf.set_string('connection', 'id', interface) + kf.set_string('connection', 'interface-name', interface) + kf.set_string('connection', 'uuid', uuid) + + for section, options in dict(sorted(config.items())).items(): + if not isinstance(options, dict): + log.error(f'Expected section, got "{section}={options}".') + continue + + for option, value in dict(sorted(options.items())).items(): + if option in ['addresses', 'routes']: + option = SINGULARS[option] + + i = 1 + + for element in value: + if isinstance(element, tuple): + element = ','.join(element) + + kf.set_string(section, f'{option}{i}', element) + + i += 1 + + elif isinstance(value, str): + kf.set_string(section, option, value) + + elif isinstance(value, bool): + kf.set_boolean(section, option, value) + + else: + log.error(f'Unhandled data type "{option}={value}".') + + data[interface] = kf.to_data()[0] + + if data: + states['network_nm_nmconnections'] = { + 'file.managed': [ + { + 'names': [ + { + f'{settings["base"]}/{interface}.nmconnection': [ + {'contents': content}, + ], + } for interface, content in data.items() + ], + }, + {'mode': '0600'}, + {'user': 'root'}, + {'group': 'root'}, + { + 'require': [{'file': 'network_nm_nmconnection_backup'}] if interface_files else [], + }, + ], + } + + if settings['control'].get('apply', True): + require = [ + {'file': 'network_nm_script'}, + ] + + if interface_files: + require.append({'file': 'network_nm_nmconnection_backup'}) + + states['network_nm_reload'] = { + 'cmd.run': [ + { + 'names': [ + { + f'{settings["script"]} reload {interface}': [ + {'stateful': True}, + {'onchanges': [{'file': f'{settings["base"]}/{interface}.nmconnection'}]}, + ], + } for interface in data + ], + }, + { + 'env': [ + {'SALTSAFE_TEST_MASTER': int(settings['control'].get('test_master', True))}, + ], + }, + { + 'onchanges': [ + {'file': 'network_nm_nmconnections'}, + ], + }, + {'require': require}, + {'shell': '/bin/sh'}, + ], + } + + return states diff --git a/network-formula/network/NetworkManager/files/saltsafe_nm b/network-formula/network/NetworkManager/files/saltsafe_nm new file mode 100755 index 00000000..ce82fac9 --- /dev/null +++ b/network-formula/network/NetworkManager/files/saltsafe_nm @@ -0,0 +1,194 @@ +#!/bin/bash +# shellcheck disable=SC2162 + +set -Cu + +base='{{ base }}' +base_backup='{{ base_backup }}' + +#{%- raw %} +call="${1?Missing command.}" +shift +interfaces=("$@") + +logtool="$(type -P logger) -t saltsafe_nm" || logtool='echo' + +fail() { + echo "$1" + exit 1 +} + +if ! type nmcli >/dev/null +then + fail 'Cannot locate nmcli.' +fi + +log() { + local msg="$2" + case $1 in + 0 ) + $logtool "$msg" + ;; + + 1 ) + if [ "$logtool" == 'echo' ] + then + local msg="saltsafe_nm: $msg" + >&2 $logtool "$msg" + else + $logtool -s "$msg" + fi + ;; + * ) + fail 'Invalid function call' + ;; + esac + +} + +quit() { + case "$1" in + 0 ) result="$result result=True" ;; + 1 ) result="$result result=False" ;; + esac + echo + echo "$result" + exit "$1" +} + +check() { + if [ "$SALTSAFE_TEST_MASTER" -eq 0 ] + then + target='localhost' + salt='--local' + else + target="$master_ip" + salt='' + fi + + if ! ping -c3 -w5 -q "$target" >/dev/null + then + return 1 + fi + if ! timeout 20 salt-call -t15 --out quiet $salt test.ping 2>/dev/null + then + return 1 + fi +} + +rollback() { + rollback=yes + cp -v "$file_backup" "$file" +} + +run() { + for file + do + if ! nmcli connection load "$base/$file" + then + break + fi + done +} + +backup() { + for file + do + file_backup="$base_backup/$file" + if [ "$1" != 'init' ] && [ -f "$file_backup" ] + then + mv "$file_backup" "$file_backup.previous" + fi + if [ -f "$file" ] + then + cp "$file" "$file_backup" + fi + done +} + +run_cycle() { + if run + then + if check + then + if [ "$rollback" == 'yes' ] + then + log 1 "Reverted configuration of interface(s): ${interfaces[*]}." + result='changed=yes comment="NM configuration reverted.' + else + log 0 "Reloaded configuration of interface(s): ${interfaces[*]}." + result='changed=yes comment="NM configuration applied."' + backup + fi + quit 0 + else + log 1 "Validation failed after configuration reload of interface(s): ${interfaces[*]}." + result='changed=yes comment="NM configuration applied, but validation failed."' + fi + if [ "$rollback" = 'yes' ] + then + log 1 'Rollback was not successful. Giving up.' + result='changed=yes comment="Failed to revert interface configuration, this situation is fatal."' + quit 1 + fi + else + result='changed=yes comment="Execution failed."' + return "$?" + fi +} + +if [ "$call" != 'reload' ] +then + fail 'Invalid invocation.' +fi + +if ! (( ${#interfaces[@]} )) +then + fail 'Invalid invocation, need at least one interface to operate.' +fi + +backup init + +danger=no +rollback=no + +if [ "$SALTSAFE_TEST_MASTER" -eq 1 ] +then + # Get IP addresses of the Salt minion and master + read minion_ip master_ip < <(ss -HntA tcp dst :4505 | awk 'END { gsub(/\[|\]/,""); split($4, con_out, /:[[:digit:]]{4,5}$/); split($5, con_in, /:[[:digit:]]{4,5}$/); print con_out[1] " " con_in[1] }') + + if [ -z "$minion_ip" ] || [ -z "$master_ip" ] + then + fail 'Unable to determine Salt connection, refusing to operate.' + fi + + # Assess whether the master is located in a remote network, requiring routing for the connection + if [ "$(ip -ts r g "$master_ip" | awk -v ip="$master_ip" '$0 ~ ip { print $2 }')" == 'via' ] + then + danger=yes + fi + + # Assess whether the network interface used for connectivity to the Salt master is part of the interfaces to be modified + if [[ " ${interfaces[*]} " == *"$(ip -br a sh | awk -v ip="$minion_ip" '$0 ~ ip { print $1 }')"* ]] + then + danger=yes + if ! check + then + log 1 "Failed to verify Salt master connectivity, refusing to operate on potentially dangerous interfaces: ${interfaces[*]}." + result='changed=no comment="Interface is used for Salt connectivity, but functionality could not be validated. Refusing to risk bringing it down."' + quit 1 + fi + fi +fi + +run_cycle + +if [ "$danger" == 'yes' ] +then + log 1 'Rolling back ...' + rollback + run_cycle +fi + +quit 1 +#{%- endraw %} diff --git a/network-formula/network/NetworkManager/init.sls b/network-formula/network/NetworkManager/init.sls new file mode 100644 index 00000000..3ec24ae1 --- /dev/null +++ b/network-formula/network/NetworkManager/init.sls @@ -0,0 +1,22 @@ +{#- +Salt state file for managing NetworkManager +Copyright (C) 2025 Georg Pfuetzenreuter + +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 3 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, see . +-#} + +include: + - .common + - .packages + - .connections diff --git a/network-formula/network/NetworkManager/map.jinja b/network-formula/network/NetworkManager/map.jinja new file mode 100644 index 00000000..32bffea3 --- /dev/null +++ b/network-formula/network/NetworkManager/map.jinja @@ -0,0 +1,27 @@ +{#- +Jinja variables file for the NetworkManager Salt states +Copyright (C) 2025 Georg Pfuetzenreuter + +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 3 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, see . +-#} + +{%- from 'network/map.jinja' import config, interfaces, routes, control -%} +{%- set config = config -%} +{%- set control = control -%} +{%- set interfaces = interfaces -%} +{%- set routes = routes -%} + +{%- set base = '/etc/NetworkManager/system-connections' -%} +{%- set base_backup = '/var/adm/backup/salt-nm' -%} +{%- set script = '/usr/local/libexec/saltsafe_nm' -%} diff --git a/network-formula/network/NetworkManager/packages.sls b/network-formula/network/NetworkManager/packages.sls new file mode 100644 index 00000000..23a5f3b5 --- /dev/null +++ b/network-formula/network/NetworkManager/packages.sls @@ -0,0 +1,44 @@ +#!py +# vim: ft=python ts=2 sts=2 sw=2 +""" +Salt state file for managing NetworkManager packages +Copyright (C) 2025 Georg Pfuetzenreuter + +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 3 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, see . +""" + +def run(): + return { + 'network_nm_packages_install': { + 'pkg.installed': [ + { + 'pkgs': [ + 'NetworkManager', + 'NetworkManager-config-server', + ] + } + ] + }, + 'network_nm_packages_remove': { + 'pkg.removed': [ + { + 'pkgs': [ + 'NetworkManager-tui', + ] + } + ] + }, + } + + diff --git a/network-formula/network/map.jinja b/network-formula/network/map.jinja index 51c68dd0..e176f372 100644 --- a/network-formula/network/map.jinja +++ b/network-formula/network/map.jinja @@ -17,11 +17,20 @@ along with this program. If not, see . -#} {%- set network = salt.pillar.get('network', {}) -%} -{%- set backend = network.get('backend', 'wicked') -%} {%- set config = network.get('config', {}) %} {%- set routes = network.get('routes', {}) -%} {%- set interfaces = network.get('interfaces', {}) -%} {%- set control = network.get('control', {}) -%} {%- set do_apply = control.get('apply', True) -%} -{%- set legal_backends = ['wicked'] -%} +{#- TODO: add another conditional with OS backend detection logic #} +{%- if 'backend' in network %} + {%- set backend = network['backend'] %} +{%- else %} + {%- set backend = 'wicked' %} +{%- endif %} + +{%- set legal_backends = [ + 'NetworkManager', + 'wicked', +] -%} diff --git a/network-formula/pillar.example b/network-formula/pillar.example index 61e3c23f..6dea5daa 100644 --- a/network-formula/pillar.example +++ b/network-formula/pillar.example @@ -1,4 +1,7 @@ network: + # by default the configuration happens through wicked + # alternative choice is "NetworkManager" + backend: wicked control: # by default, changes will be applied after configuration files have been written # this can be set to False if it's desired for no reload operations to be performed @@ -15,9 +18,13 @@ network: netconfig_dns_resolver_options: - attempts:1 - timeout:1 - # each listed interface will generate an ifcfg- file + # each listed interface will generate an ifcfg- file (wicked backend) or a .nmconnection file (NetworkManager backend) interfaces: eth0: + ## Variant 1: ifcfg-style configuration + ## - with the wicked backend, many of these will be written as is + ## - with the NetworkManager backend, all keys and values will be translated if possible, and otherwise skippeed + # STARTMODE is "auto" by default, causing the interface to be started. if set to "off", it will be stopped. # other startmodes will not trigger any action by Salt. startmode: auto @@ -37,6 +44,23 @@ network: - fe80::1/64 mlx0: ethtool_options: -K foo rxvlan off + + ## Variant 2: NM-style configuration + ## - with the wicked backend, this will lead to failure and possibly undefined behavior + ## - with the NetworkManager backend, all sections, keys and values will be written as is, except for special keys "addresses" and "routes", which will be expanded for convenience + + nm0: + connection: + type: ethernet + ipv4: + addresses: + - 10.128.4.129/27 + # method will default to "manual" if addresses are specified, or to "disabled" otherwise + method: manual + ipv6: + addresses: + - 2001:db8:1::10/64 + # each listed route will be written into /etc/sysconfig/network/routes and applied # "default4" and "default6" will be written as "default" routes: