summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rwxr-xr-xsrc/conf_mode/container.py41
-rwxr-xr-xsrc/conf_mode/firewall.py98
-rwxr-xr-xsrc/conf_mode/interfaces_bonding.py66
-rwxr-xr-xsrc/conf_mode/interfaces_bridge.py5
-rwxr-xr-xsrc/conf_mode/interfaces_ethernet.py37
-rwxr-xr-xsrc/conf_mode/interfaces_geneve.py2
-rwxr-xr-xsrc/conf_mode/interfaces_openvpn.py28
-rwxr-xr-xsrc/conf_mode/interfaces_tunnel.py12
-rwxr-xr-xsrc/conf_mode/interfaces_vxlan.py2
-rwxr-xr-xsrc/conf_mode/interfaces_wireguard.py74
-rw-r--r--src/conf_mode/load-balancing_haproxy.py7
-rwxr-xr-xsrc/conf_mode/load-balancing_wan.py119
-rwxr-xr-xsrc/conf_mode/nat.py8
-rwxr-xr-xsrc/conf_mode/pki.py12
-rwxr-xr-xsrc/conf_mode/policy.py134
-rwxr-xr-xsrc/conf_mode/protocols_babel.py81
-rwxr-xr-xsrc/conf_mode/protocols_bfd.py43
-rwxr-xr-xsrc/conf_mode/protocols_bgp.py162
-rwxr-xr-xsrc/conf_mode/protocols_eigrp.py93
-rwxr-xr-xsrc/conf_mode/protocols_isis.py105
-rwxr-xr-xsrc/conf_mode/protocols_mpls.py46
-rwxr-xr-xsrc/conf_mode/protocols_nhrp.py118
-rw-r--r--src/conf_mode/protocols_openfabric.py67
-rwxr-xr-xsrc/conf_mode/protocols_ospf.py135
-rwxr-xr-xsrc/conf_mode/protocols_ospfv3.py125
-rwxr-xr-xsrc/conf_mode/protocols_pim.py107
-rwxr-xr-xsrc/conf_mode/protocols_pim6.py71
-rwxr-xr-xsrc/conf_mode/protocols_rip.py82
-rwxr-xr-xsrc/conf_mode/protocols_ripng.py67
-rwxr-xr-xsrc/conf_mode/protocols_rpki.py53
-rwxr-xr-xsrc/conf_mode/protocols_segment-routing.py96
-rwxr-xr-xsrc/conf_mode/protocols_static.py89
-rwxr-xr-xsrc/conf_mode/protocols_static_multicast.py135
-rwxr-xr-xsrc/conf_mode/service_console-server.py8
-rwxr-xr-xsrc/conf_mode/service_dhcp-server.py270
-rwxr-xr-xsrc/conf_mode/service_dns_forwarding.py7
-rw-r--r--[-rwxr-xr-x]src/conf_mode/service_monitoring_network_event.py (renamed from src/conf_mode/service_monitoring_frr-exporter.py)64
-rwxr-xr-xsrc/conf_mode/service_monitoring_node-exporter.py101
-rwxr-xr-xsrc/conf_mode/service_monitoring_prometheus.py206
-rwxr-xr-xsrc/conf_mode/service_monitoring_zabbix-agent.py23
-rwxr-xr-xsrc/conf_mode/service_snmp.py17
-rwxr-xr-xsrc/conf_mode/service_ssh.py57
-rwxr-xr-xsrc/conf_mode/system_flow-accounting.py53
-rwxr-xr-xsrc/conf_mode/system_host-name.py9
-rwxr-xr-xsrc/conf_mode/system_ip.py71
-rwxr-xr-xsrc/conf_mode/system_ipv6.py71
-rwxr-xr-xsrc/conf_mode/system_login.py31
-rwxr-xr-xsrc/conf_mode/system_login_banner.py8
-rwxr-xr-xsrc/conf_mode/system_option.py2
-rwxr-xr-xsrc/conf_mode/system_sflow.py2
-rwxr-xr-xsrc/conf_mode/system_syslog.py86
-rwxr-xr-xsrc/conf_mode/vpn_ipsec.py56
-rwxr-xr-xsrc/conf_mode/vrf.py53
-rwxr-xr-xsrc/etc/netplug/vyos-netplug-dhcp-client57
-rwxr-xr-xsrc/etc/ppp/ip-up.d/99-vyos-pppoe-wlb61
-rw-r--r--src/etc/rsyslog.conf67
-rw-r--r--src/etc/skel/.bashrc3
-rw-r--r--src/etc/sudoers.d/vyos3
-rw-r--r--src/etc/systemd/system/kea-ctrl-agent.service.d/override.conf1
-rw-r--r--src/etc/systemd/system/kea-dhcp4-server.service.d/override.conf2
-rw-r--r--src/etc/systemd/system/rsyslog.service.d/override.conf10
-rw-r--r--src/etc/udev/rules.d/90-vyos-serial.rules2
-rwxr-xr-xsrc/helpers/latest-image-url.py21
-rwxr-xr-xsrc/helpers/show_commit_data.py56
-rwxr-xr-xsrc/helpers/test_commit.py49
-rwxr-xr-xsrc/helpers/vyos-certbot-renew-pki.sh2
-rwxr-xr-xsrc/helpers/vyos-load-balancer.py312
-rwxr-xr-xsrc/init/vyos-router23
-rw-r--r--src/migration-scripts/bgp/5-to-639
-rw-r--r--src/migration-scripts/dns-dynamic/1-to-233
-rw-r--r--[-rwxr-xr-x]src/migration-scripts/firewall/16-to-170
-rwxr-xr-xsrc/migration-scripts/firewall/17-to-1841
-rw-r--r--src/migration-scripts/flow-accounting/1-to-263
-rw-r--r--src/migration-scripts/lldp/2-to-331
-rw-r--r--src/migration-scripts/monitoring/1-to-250
-rw-r--r--src/migration-scripts/nhrp/0-to-1129
-rw-r--r--src/migration-scripts/ntp/1-to-27
-rw-r--r--src/migration-scripts/policy/8-to-949
-rw-r--r--src/migration-scripts/quagga/11-to-1275
-rw-r--r--src/migration-scripts/system/28-to-2971
-rw-r--r--src/migration-scripts/wanloadbalance/3-to-433
-rwxr-xr-xsrc/op_mode/dhcp.py473
-rwxr-xr-xsrc/op_mode/firewall.py143
-rw-r--r--src/op_mode/generate_psk.py45
-rwxr-xr-xsrc/op_mode/image_installer.py147
-rwxr-xr-xsrc/op_mode/ipsec.py23
-rwxr-xr-xsrc/op_mode/load-balancing_wan.py117
-rwxr-xr-xsrc/op_mode/nhrp.py101
-rwxr-xr-xsrc/op_mode/qos.py2
-rwxr-xr-xsrc/op_mode/reset_wireguard.py55
-rwxr-xr-xsrc/op_mode/restart.py5
-rwxr-xr-xsrc/op_mode/stp.py185
-rw-r--r--src/op_mode/tech_support.py19
-rwxr-xr-xsrc/op_mode/vtysh_wrapper.sh2
-rw-r--r--src/op_mode/zone.py11
-rw-r--r--src/services/api/rest/models.py7
-rwxr-xr-xsrc/services/vyos-commitd457
-rwxr-xr-xsrc/services/vyos-configd55
-rwxr-xr-xsrc/services/vyos-conntrack-logger2
-rwxr-xr-xsrc/services/vyos-domain-resolver (renamed from src/helpers/vyos-domain-resolver.py)135
-rwxr-xr-xsrc/services/vyos-hostsd4
-rwxr-xr-xsrc/services/vyos-http-api-server46
-rw-r--r--src/services/vyos-network-event-logger1218
-rwxr-xr-xsrc/system/sync-dhcp-lease-to-hosts.py112
-rw-r--r--src/systemd/netplug.service9
-rw-r--r--src/systemd/vyconfd.service21
-rw-r--r--src/systemd/vyos-commitd.service27
-rw-r--r--src/systemd/vyos-domain-resolver.service4
-rw-r--r--src/systemd/vyos-network-event-logger.service21
-rw-r--r--src/systemd/vyos-wan-load-balance.service12
-rw-r--r--src/systemd/vyos.target2
-rw-r--r--src/tests/test_config_diff.py11
-rw-r--r--src/tests/test_config_parser.py4
-rw-r--r--src/tests/test_configd_inspect.py210
-rw-r--r--src/tests/test_initial_setup.py4
-rwxr-xr-xsrc/validators/base6422
-rw-r--r--src/validators/ether-type37
-rw-r--r--src/validators/ethernet-interface13
118 files changed, 5892 insertions, 2674 deletions
diff --git a/src/conf_mode/container.py b/src/conf_mode/container.py
index a7dc33d9d..18d660a4e 100755
--- a/src/conf_mode/container.py
+++ b/src/conf_mode/container.py
@@ -22,6 +22,7 @@ from ipaddress import ip_address
from ipaddress import ip_network
from json import dumps as json_write
+import psutil
from vyos.base import Warning
from vyos.config import Config
from vyos.configdict import dict_merge
@@ -148,6 +149,9 @@ def verify(container):
if network_name not in container.get('network', {}):
raise ConfigError(f'Container network "{network_name}" does not exist!')
+ 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!')
+
if 'address' in container_config['network'][network_name]:
cnt_ipv4 = 0
cnt_ipv6 = 0
@@ -220,6 +224,21 @@ def verify(container):
if not os.path.exists(source):
raise ConfigError(f'Volume "{volume}" source path "{source}" does not exist!')
+ if 'tmpfs' in container_config:
+ for tmpfs, tmpfs_config in container_config['tmpfs'].items():
+ if 'destination' not in tmpfs_config:
+ raise ConfigError(f'tmpfs "{tmpfs}" has no destination path configured!')
+ if 'size' in tmpfs_config:
+ free_mem_mb: int = psutil.virtual_memory().available / 1024 / 1024
+ if int(tmpfs_config['size']) > free_mem_mb:
+ Warning(f'tmpfs "{tmpfs}" size is greater than the current free memory!')
+
+ total_mem_mb: int = (psutil.virtual_memory().total / 1024 / 1024) / 2
+ if int(tmpfs_config['size']) > total_mem_mb:
+ raise ConfigError(f'tmpfs "{tmpfs}" size should not be more than 50% of total system memory!')
+ else:
+ raise ConfigError(f'tmpfs "{tmpfs}" has no size configured!')
+
if 'port' in container_config:
for tmp in container_config['port']:
if not {'source', 'destination'} <= set(container_config['port'][tmp]):
@@ -270,6 +289,13 @@ def verify(container):
if 'registry' in container:
for registry, registry_config in container['registry'].items():
+ if 'mirror' in registry_config:
+ if 'host_name' in registry_config['mirror'] and 'address' in registry_config['mirror']:
+ raise ConfigError(f'Container registry mirror address/host-name are mutually exclusive!')
+
+ if 'path' in registry_config['mirror'] and not registry_config['mirror']['path'].startswith('/'):
+ raise ConfigError('Container registry mirror path must start with "/"!')
+
if 'authentication' not in registry_config:
continue
if not {'username', 'password'} <= set(registry_config['authentication']):
@@ -359,13 +385,26 @@ def generate_run_arguments(name, container_config):
prop = vol_config['propagation']
volume += f' --volume {svol}:{dvol}:{mode},{prop}'
+ # Mount tmpfs
+ tmpfs = ''
+ if 'tmpfs' in container_config:
+ for tmpfs_config in container_config['tmpfs'].values():
+ dest = tmpfs_config['destination']
+ size = tmpfs_config['size']
+ tmpfs += f' --mount=type=tmpfs,tmpfs-size={size}M,destination={dest}'
+
host_pid = ''
if 'allow_host_pid' in container_config:
host_pid = '--pid host'
+ name_server = ''
+ if 'name_server' in container_config:
+ for ns in container_config['name_server']:
+ name_server += f'--dns {ns}'
+
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} ' \
- f'--name {name} {hostname} {device} {port} {volume} {env_opt} {label} {uid} {host_pid}'
+ f'--name {name} {hostname} {device} {port} {name_server} {volume} {tmpfs} {env_opt} {label} {uid} {host_pid}'
entrypoint = ''
if 'entrypoint' in container_config:
diff --git a/src/conf_mode/firewall.py b/src/conf_mode/firewall.py
index ffbd915a2..cebe57092 100755
--- a/src/conf_mode/firewall.py
+++ b/src/conf_mode/firewall.py
@@ -18,7 +18,6 @@ import os
import re
from sys import exit
-
from vyos.base import Warning
from vyos.config import Config
from vyos.configdict import is_node_changed
@@ -34,6 +33,8 @@ from vyos.utils.dict import dict_search_recursive
from vyos.utils.process import call
from vyos.utils.process import cmd
from vyos.utils.process import rc_cmd
+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
@@ -43,7 +44,7 @@ airbag.enable()
nftables_conf = '/run/nftables.conf'
domain_resolver_usage = '/run/use-vyos-domain-resolver-firewall'
-domain_resolver_usage_nat = '/run/use-vyos-domain-resolver-nat'
+firewall_config_dir = "/config/firewall"
sysctl_file = r'/run/sysctl/10-vyos-firewall.conf'
@@ -53,7 +54,8 @@ valid_groups = [
'network_group',
'port_group',
'interface_group',
- ## Added for group ussage in bridge firewall
+ 'remote_group',
+ ## Added for group usage in bridge firewall
'ipv4_address_group',
'ipv6_address_group',
'ipv4_network_group',
@@ -134,6 +136,27 @@ def get_config(config=None):
fqdn_config_parse(firewall, 'firewall')
+ if not os.path.exists(nftables_conf):
+ firewall['first_install'] = True
+
+ if 'zone' in firewall:
+ 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_conf['vrf_interfaces'] = {}
+ for vrf_name in local_zone_conf['member']['vrf']:
+ local_zone_conf['vrf_interfaces'][vrf_name] = ','.join(get_vrf_members(vrf_name))
+ continue
+
+ local_zone_conf['from_local'] = {}
+
+ for zone, zone_conf in firewall['zone'].items():
+ if zone == local_zone or 'from' not in zone_conf:
+ continue
+ if local_zone in zone_conf['from']:
+ local_zone_conf['from_local'][zone] = zone_conf['from'][local_zone]
+
set_dependents('conntrack', conf)
return firewall
@@ -290,8 +313,8 @@ def verify_rule(firewall, family, hook, priority, rule_id, rule_conf):
raise ConfigError('Only one of address, fqdn or geoip can be specified')
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')
+ if len({'address_group', 'network_group', 'domain_group', 'remote_group'} & set(side_conf['group'])) > 1:
+ raise ConfigError('Only one address-group, network-group, remote-group or domain-group can be specified')
for group in valid_groups:
if group in side_conf['group']:
@@ -311,7 +334,7 @@ def verify_rule(firewall, family, hook, priority, rule_id, rule_conf):
error_group = fw_group.replace("_", "-")
- if group in ['address_group', 'network_group', 'domain_group']:
+ if group in ['address_group', 'network_group', 'domain_group', 'remote_group']:
types = [t for t in ['address', 'fqdn', 'geoip'] if t in side_conf]
if types:
raise ConfigError(f'{error_group} and {types[0]} cannot both be defined')
@@ -421,6 +444,11 @@ def verify(firewall):
for group_name, group in groups.items():
verify_nested_group(group_name, group, groups, [])
+ if 'remote_group' in firewall['group']:
+ for group_name, group in firewall['group']['remote_group'].items():
+ if 'url' not in group:
+ raise ConfigError(f'remote-group {group_name} must have a url configured')
+
for family in ['ipv4', 'ipv6', 'bridge']:
if family in firewall:
for chain in ['name','forward','input','output', 'prerouting']:
@@ -442,28 +470,45 @@ def verify(firewall):
local_zone = False
zone_interfaces = []
+ zone_vrf = []
if 'zone' in firewall:
for zone, zone_conf in firewall['zone'].items():
- if 'local_zone' not in zone_conf and 'interface' not in zone_conf:
+ if 'local_zone' not in zone_conf and 'member' not in zone_conf:
raise ConfigError(f'Zone "{zone}" has no interfaces and is not the local zone')
if 'local_zone' in zone_conf:
if local_zone:
raise ConfigError('There cannot be multiple local zones')
- if 'interface' in zone_conf:
+ if 'member' in zone_conf:
raise ConfigError('Local zone cannot have interfaces assigned')
if 'intra_zone_filtering' in zone_conf:
raise ConfigError('Local zone cannot use intra-zone-filtering')
local_zone = True
- if 'interface' in zone_conf:
- found_duplicates = [intf for intf in zone_conf['interface'] if intf in zone_interfaces]
+ if 'member' in zone_conf:
+ if 'interface' in zone_conf['member']:
+ for iface in zone_conf['member']['interface']:
+
+ if iface in zone_interfaces:
+ raise ConfigError(f'Interfaces cannot be assigned to multiple zones')
- if found_duplicates:
- raise ConfigError(f'Interfaces cannot be assigned to multiple zones')
+ iface_vrf = get_interface_vrf(iface)
+ if iface_vrf != 'default':
+ Warning(f"Interface {iface} assigned to zone {zone} is in VRF {iface_vrf}. This might not work as expected.")
+ zone_interfaces.append(iface)
- zone_interfaces += zone_conf['interface']
+ if 'vrf' in zone_conf['member']:
+ for vrf in zone_conf['member']['vrf']:
+ if vrf in zone_vrf:
+ raise ConfigError(f'VRF cannot be assigned to multiple zones')
+ zone_vrf.append(vrf)
+
+ if 'vrf_interfaces' in zone_conf:
+ for vrf_name, vrf_interfaces in zone_conf['vrf_interfaces'].items():
+ if not vrf_interfaces:
+ raise ConfigError(
+ f'VRF "{vrf_name}" cannot be a member of any zone. It does not contain any interfaces.')
if 'intra_zone_filtering' in zone_conf:
intra_zone = zone_conf['intra_zone_filtering']
@@ -499,24 +544,17 @@ def verify(firewall):
return None
def generate(firewall):
- if not os.path.exists(nftables_conf):
- firewall['first_install'] = True
-
- if 'zone' in firewall:
- for local_zone, local_zone_conf in firewall['zone'].items():
- if 'local_zone' not in local_zone_conf:
- continue
-
- local_zone_conf['from_local'] = {}
-
- for zone, zone_conf in firewall['zone'].items():
- if zone == local_zone or 'from' not in zone_conf:
- continue
- if local_zone in zone_conf['from']:
- local_zone_conf['from_local'][zone] = zone_conf['from'][local_zone]
-
render(nftables_conf, 'firewall/nftables.j2', firewall)
render(sysctl_file, 'firewall/sysctl-firewall.conf.j2', firewall)
+
+ # Cleanup remote-group cache files
+ if os.path.exists(firewall_config_dir):
+ for fw_file in os.listdir(firewall_config_dir):
+ # Delete matching files in 'config/firewall' that no longer exist as a remote-group in config
+ if fw_file.startswith("R_") and fw_file.endswith(".txt"):
+ if 'group' not in firewall or 'remote_group' not in firewall['group'] or fw_file[2:-4] not in firewall['group']['remote_group'].keys():
+ os.unlink(os.path.join(firewall_config_dir, fw_file))
+
return None
def parse_firewall_error(output):
@@ -576,7 +614,7 @@ def apply(firewall):
## DOMAIN RESOLVER
domain_action = 'restart'
- if dict_search_args(firewall, 'group', 'domain_group') or firewall['ip_fqdn'].items() or firewall['ip6_fqdn'].items():
+ 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)
else:
diff --git a/src/conf_mode/interfaces_bonding.py b/src/conf_mode/interfaces_bonding.py
index bbbfb0385..84316c16e 100755
--- a/src/conf_mode/interfaces_bonding.py
+++ b/src/conf_mode/interfaces_bonding.py
@@ -30,19 +30,21 @@ 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.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.template import render_to_string
from vyos.utils.assertion import assert_mac
from vyos.utils.dict import dict_search
from vyos.utils.dict import dict_to_paths_values
from vyos.utils.network import interface_exists
+from vyos.utils.process import is_systemd_service_running
from vyos.configdict import has_address_configured
from vyos.configdict import has_vrf_configured
-from vyos.configdep import set_dependents, call_dependents
+from vyos.configdep import set_dependents
+from vyos.configdep import call_dependents
from vyos import ConfigError
-from vyos import frr
from vyos import airbag
airbag.enable()
@@ -87,10 +89,13 @@ def get_config(config=None):
bond['mode'] = get_bond_mode(bond['mode'])
tmp = is_node_changed(conf, base + [ifname, 'mode'])
- if tmp: bond['shutdown_required'] = {}
+ if tmp: bond.update({'shutdown_required' : {}})
tmp = is_node_changed(conf, base + [ifname, 'lacp-rate'])
- if tmp: bond['shutdown_required'] = {}
+ if tmp: bond.update({'shutdown_required' : {}})
+
+ tmp = is_node_changed(conf, base + [ifname, 'evpn'])
+ if tmp: bond.update({'frr_dict' : get_frrender_dict(conf)})
# determine which members have been removed
interfaces_removed = leaf_node_changed(conf, base + [ifname, 'member', 'interface'])
@@ -121,9 +126,8 @@ def get_config(config=None):
# Restore existing config level
conf.set_level(old_level)
- if dict_search('member.interface', bond):
- for interface, interface_config in bond['member']['interface'].items():
-
+ if dict_search('member.interface', bond) is not None:
+ for interface in bond['member']['interface']:
interface_ethernet_config = conf.get_config_dict(
['interfaces', 'ethernet', interface],
key_mangling=('-', '_'),
@@ -132,44 +136,45 @@ def get_config(config=None):
with_defaults=False,
with_recursive_defaults=False)
- interface_config['config_paths'] = dict_to_paths_values(interface_ethernet_config)
+ bond['member']['interface'][interface].update({'config_paths' :
+ dict_to_paths_values(interface_ethernet_config)})
# Check if member interface is a new member
if not conf.exists_effective(base + [ifname, 'member', 'interface', interface]):
bond['shutdown_required'] = {}
- interface_config['new_added'] = {}
+ 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']):
- interface_config['disable'] = ''
+ if tmp: bond['member']['interface'][interface].update({'disable': ''})
conf.set_level(old_level)
# Check if member interface is already member of another bridge
tmp = is_member(conf, interface, 'bridge')
- if tmp: interface_config['is_bridge_member'] = tmp
+ if tmp: bond['member']['interface'][interface].update({'is_bridge_member' : tmp})
# Check if member interface is already member of a bond
tmp = is_member(conf, interface, 'bonding')
- for tmp in is_member(conf, interface, 'bonding'):
- if bond['ifname'] == tmp:
- continue
- interface_config['is_bond_member'] = tmp
+ if ifname in tmp:
+ del tmp[ifname]
+ if tmp: bond['member']['interface'][interface].update({'is_bond_member' : tmp})
# Check if member interface is used as source-interface on another interface
tmp = is_source_interface(conf, interface)
- if tmp: interface_config['is_source_interface'] = tmp
+ if tmp: bond['member']['interface'][interface].update({'is_source_interface' : tmp})
# bond members must not have an assigned address
tmp = has_address_configured(conf, interface)
- if tmp: interface_config['has_address'] = {}
+ if tmp: bond['member']['interface'][interface].update({'has_address' : ''})
# bond members must not have a VRF attached
tmp = has_vrf_configured(conf, interface)
- if tmp: interface_config['has_vrf'] = {}
+ if tmp: bond['member']['interface'][interface].update({'has_vrf' : ''})
+
return bond
@@ -260,16 +265,16 @@ def verify(bond):
return None
def generate(bond):
- bond['frr_zebra_config'] = ''
- if 'deleted' not in bond:
- bond['frr_zebra_config'] = render_to_string('frr/evpn.mh.frr.j2', bond)
+ if 'frr_dict' in bond and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().generate(bond['frr_dict'])
return None
def apply(bond):
- ifname = bond['ifname']
- b = BondIf(ifname)
+ if 'frr_dict' in bond and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
+
+ b = BondIf(bond['ifname'])
if 'deleted' in bond:
- # delete interface
b.remove()
else:
b.update(bond)
@@ -281,17 +286,6 @@ def apply(bond):
raise ConfigError('Error in updating ethernet interface '
'after deleting it from bond')
- zebra_daemon = 'zebra'
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
-
- # The route-map used for the FIB (zebra) is part of the zebra daemon
- frr_cfg.load_configuration(zebra_daemon)
- frr_cfg.modify_section(f'^interface {ifname}', stop_pattern='^exit', remove_stop_mark=True)
- if 'frr_zebra_config' in bond:
- frr_cfg.add_before(frr.default_add_before, bond['frr_zebra_config'])
- frr_cfg.commit_configuration(zebra_daemon)
-
return None
if __name__ == '__main__':
diff --git a/src/conf_mode/interfaces_bridge.py b/src/conf_mode/interfaces_bridge.py
index 637db442a..aff93af2a 100755
--- a/src/conf_mode/interfaces_bridge.py
+++ b/src/conf_mode/interfaces_bridge.py
@@ -74,8 +74,9 @@ def get_config(config=None):
for interface in list(bridge['member']['interface']):
# Check if member interface is already member of another bridge
tmp = is_member(conf, interface, 'bridge')
- if tmp and bridge['ifname'] not in tmp:
- bridge['member']['interface'][interface].update({'is_bridge_member' : tmp})
+ if ifname in tmp:
+ del tmp[ifname]
+ if tmp: bridge['member']['interface'][interface].update({'is_bridge_member' : tmp})
# Check if member interface is already member of a bond
tmp = is_member(conf, interface, 'bonding')
diff --git a/src/conf_mode/interfaces_ethernet.py b/src/conf_mode/interfaces_ethernet.py
index 34ce7bc47..41c89fdf8 100755
--- a/src/conf_mode/interfaces_ethernet.py
+++ b/src/conf_mode/interfaces_ethernet.py
@@ -33,15 +33,16 @@ 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.frrender import FRRender
+from vyos.frrender import get_frrender_dict
from vyos.ifconfig import EthernetIf
from vyos.ifconfig import BondIf
-from vyos.template import render_to_string
from vyos.utils.dict import dict_search
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 import ConfigError
-from vyos import frr
from vyos import airbag
airbag.enable()
@@ -164,6 +165,9 @@ def get_config(config=None):
tmp = is_node_changed(conf, base + [ifname, 'duplex'])
if tmp: ethernet.update({'speed_duplex_changed': {}})
+ tmp = is_node_changed(conf, base + [ifname, 'evpn'])
+ if tmp: ethernet.update({'frr_dict' : get_frrender_dict(conf)})
+
return ethernet
def verify_speed_duplex(ethernet: dict, ethtool: Ethtool):
@@ -318,42 +322,25 @@ def verify_ethernet(ethernet):
return None
def generate(ethernet):
- if 'deleted' in ethernet:
- return None
-
- ethernet['frr_zebra_config'] = ''
- if 'deleted' not in ethernet:
- ethernet['frr_zebra_config'] = render_to_string('frr/evpn.mh.frr.j2', ethernet)
-
+ if 'frr_dict' in ethernet and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().generate(ethernet['frr_dict'])
return None
def apply(ethernet):
- ifname = ethernet['ifname']
-
- e = EthernetIf(ifname)
+ if 'frr_dict' in ethernet and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
+ e = EthernetIf(ethernet['ifname'])
if 'deleted' in ethernet:
- # delete interface
e.remove()
else:
e.update(ethernet)
-
- zebra_daemon = 'zebra'
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
-
- # The route-map used for the FIB (zebra) is part of the zebra daemon
- frr_cfg.load_configuration(zebra_daemon)
- frr_cfg.modify_section(f'^interface {ifname}', stop_pattern='^exit', remove_stop_mark=True)
- if 'frr_zebra_config' in ethernet:
- frr_cfg.add_before(frr.default_add_before, ethernet['frr_zebra_config'])
- frr_cfg.commit_configuration(zebra_daemon)
+ return None
if __name__ == '__main__':
try:
c = get_config()
verify(c)
generate(c)
-
apply(c)
except ConfigError as e:
print(e)
diff --git a/src/conf_mode/interfaces_geneve.py b/src/conf_mode/interfaces_geneve.py
index 007708d4a..1c5b4d0e7 100755
--- a/src/conf_mode/interfaces_geneve.py
+++ b/src/conf_mode/interfaces_geneve.py
@@ -47,7 +47,7 @@ def get_config(config=None):
# GENEVE interfaces are picky and require recreation if certain parameters
# change. But a GENEVE interface should - of course - not be re-created if
# it's description or IP address is adjusted. Feels somehow logic doesn't it?
- for cli_option in ['remote', 'vni', 'parameters']:
+ for cli_option in ['remote', 'vni', 'parameters', 'port']:
if is_node_changed(conf, base + [ifname, cli_option]):
geneve.update({'rebuild_required': {}})
diff --git a/src/conf_mode/interfaces_openvpn.py b/src/conf_mode/interfaces_openvpn.py
index 8c1213e2b..a9b4e570d 100755
--- a/src/conf_mode/interfaces_openvpn.py
+++ b/src/conf_mode/interfaces_openvpn.py
@@ -32,6 +32,7 @@ from vyos.base import DeprecationWarning
from vyos.config import Config
from vyos.configdict import get_interface_dict
from vyos.configdict import is_node_changed
+from vyos.configdiff import get_config_diff
from vyos.configverify import verify_vrf
from vyos.configverify import verify_bridge_delete
from vyos.configverify import verify_mirror_redirect
@@ -94,6 +95,23 @@ def get_config(config=None):
if 'deleted' in openvpn:
return openvpn
+ if not is_node_changed(conf, base) and dict_search_args(openvpn, 'tls'):
+ diff = get_config_diff(conf)
+ if diff.get_child_nodes_diff(['pki'], recursive=True).get('add') == ['ca', 'certificate']:
+ crl_path = os.path.join(cfg_dir, f'{ifname}_crl.pem')
+ if os.path.exists(crl_path):
+ # do not restart service when changed only CRL and crl file already exist
+ openvpn.update({'no_restart_crl': True})
+ for rec in diff.get_child_nodes_diff(['pki', 'ca'], recursive=True).get('add'):
+ if diff.get_child_nodes_diff(['pki', 'ca', rec], recursive=True).get('add') != ['crl']:
+ openvpn.update({'no_restart_crl': False})
+ break
+ if openvpn.get('no_restart_crl'):
+ for rec in diff.get_child_nodes_diff(['pki', 'certificate'], recursive=True).get('add'):
+ if diff.get_child_nodes_diff(['pki', 'certificate', rec], recursive=True).get('add') != ['revoke']:
+ openvpn.update({'no_restart_crl': False})
+ break
+
if is_node_changed(conf, base + [ifname, 'openvpn-option']):
openvpn.update({'restart_required': {}})
if is_node_changed(conf, base + [ifname, 'enable-dco']):
@@ -786,10 +804,12 @@ def apply(openvpn):
# No matching OpenVPN process running - maybe it got killed or none
# existed - nevertheless, spawn new OpenVPN process
- action = 'reload-or-restart'
- if 'restart_required' in openvpn:
- action = 'restart'
- call(f'systemctl {action} openvpn@{interface}.service')
+
+ if not openvpn.get('no_restart_crl'):
+ action = 'reload-or-restart'
+ if 'restart_required' in openvpn:
+ action = 'restart'
+ call(f'systemctl {action} openvpn@{interface}.service')
o = VTunIf(**openvpn)
o.update(openvpn)
diff --git a/src/conf_mode/interfaces_tunnel.py b/src/conf_mode/interfaces_tunnel.py
index 98ef98d12..ee1436e49 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-2024 yOS maintainers and contributors
+# Copyright (C) 2018-2025 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
@@ -13,9 +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/>.
-
from sys import exit
-
+import ipaddress
from vyos.config import Config
from vyos.configdict import get_interface_dict
from vyos.configdict import is_node_changed
@@ -89,6 +88,13 @@ def verify(tunnel):
raise ConfigError('Tunnel used for NHRP, it can not be deleted!')
return None
+ if 'nhrp' in tunnel:
+ if 'address' in tunnel:
+ address_list = dict_search('address', tunnel)
+ for tunip in address_list:
+ if ipaddress.ip_network(tunip, strict=False).prefixlen != 32:
+ raise ConfigError(
+ 'Tunnel is used for NHRP, Netmask should be /32!')
verify_tunnel(tunnel)
diff --git a/src/conf_mode/interfaces_vxlan.py b/src/conf_mode/interfaces_vxlan.py
index 68646e8ff..256b65708 100755
--- a/src/conf_mode/interfaces_vxlan.py
+++ b/src/conf_mode/interfaces_vxlan.py
@@ -95,6 +95,8 @@ def verify(vxlan):
if 'group' in vxlan:
if 'source_interface' not in vxlan:
raise ConfigError('Multicast VXLAN requires an underlaying interface')
+ if 'remote' in vxlan:
+ raise ConfigError('Both group and remote cannot be specified')
verify_source_interface(vxlan)
if not any(tmp in ['group', 'remote', 'source_address', 'source_interface'] for tmp in vxlan):
diff --git a/src/conf_mode/interfaces_wireguard.py b/src/conf_mode/interfaces_wireguard.py
index b6fd6b0b2..192937dba 100755
--- a/src/conf_mode/interfaces_wireguard.py
+++ b/src/conf_mode/interfaces_wireguard.py
@@ -19,6 +19,9 @@ from sys import exit
from vyos.config import Config
from vyos.configdict import get_interface_dict
from vyos.configdict import is_node_changed
+from vyos.configdict import is_source_interface
+from vyos.configdep import set_dependents
+from vyos.configdep import call_dependents
from vyos.configverify import verify_vrf
from vyos.configverify import verify_address
from vyos.configverify import verify_bridge_delete
@@ -29,8 +32,10 @@ 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 is_wireguard_key_pair
+from vyos.utils.process import call
from vyos import ConfigError
from vyos import airbag
+from pathlib import Path
airbag.enable()
@@ -54,11 +59,31 @@ def get_config(config=None):
if is_node_changed(conf, base + [ifname, 'peer']):
wireguard.update({'rebuild_required': {}})
+ wireguard['peers_need_resolve'] = []
+ if 'peer' in wireguard:
+ for peer, peer_config in wireguard['peer'].items():
+ if 'disable' not in peer_config and 'host_name' in peer_config:
+ wireguard['peers_need_resolve'].append(peer)
+
+ # Check if interface is used as source-interface on VXLAN interface
+ tmp = is_source_interface(conf, ifname, 'vxlan')
+ if tmp:
+ if 'deleted' not in wireguard:
+ set_dependents('vxlan', conf, tmp)
+ else:
+ wireguard['is_source_interface'] = tmp
+
return wireguard
+
def verify(wireguard):
if 'deleted' in wireguard:
verify_bridge_delete(wireguard)
+ if 'is_source_interface' in wireguard:
+ raise ConfigError(
+ f'Interface "{wireguard["ifname"]}" cannot be deleted as it is used '
+ f'as source interface for "{wireguard["is_source_interface"]}"!'
+ )
return None
verify_mtu_ipv6(wireguard)
@@ -82,28 +107,41 @@ def verify(wireguard):
for tmp in wireguard['peer']:
peer = wireguard['peer'][tmp]
+ base_error = f'WireGuard peer "{tmp}":'
+
+ if 'host_name' in peer and 'address' in peer:
+ raise ConfigError(f'{base_error} address/host-name are mutually exclusive!')
+
if 'allowed_ips' not in peer:
- raise ConfigError(f'Wireguard allowed-ips required for peer "{tmp}"!')
+ raise ConfigError(f'{base_error} missing mandatory allowed-ips!')
if 'public_key' not in peer:
- raise ConfigError(f'Wireguard public-key required for peer "{tmp}"!')
-
- if ('address' in peer and 'port' not in peer) or ('port' in peer and 'address' not in peer):
- raise ConfigError('Both Wireguard port and address must be defined '
- f'for peer "{tmp}" if either one of them is set!')
+ raise ConfigError(f'{base_error} missing mandatory public-key!')
if peer['public_key'] in public_keys:
- raise ConfigError(f'Duplicate public-key defined on peer "{tmp}"')
+ raise ConfigError(f'{base_error} duplicate public-key!')
if 'disable' not in peer:
if is_wireguard_key_pair(wireguard['private_key'], peer['public_key']):
- raise ConfigError(f'Peer "{tmp}" has the same public key as the interface "{wireguard["ifname"]}"')
+ tmp = wireguard["ifname"]
+ raise ConfigError(f'{base_error} identical public key as interface "{tmp}"!')
+
+ port_addr_error = f'{base_error} both port and address/host-name must '\
+ 'be defined if either one of them is set!'
+ if 'port' not in peer:
+ if 'host_name' in peer or 'address' in peer:
+ raise ConfigError(port_addr_error)
+ else:
+ if 'host_name' not in peer and 'address' not in peer:
+ raise ConfigError(port_addr_error)
public_keys.append(peer['public_key'])
+
def generate(wireguard):
return None
+
def apply(wireguard):
check_kmod('wireguard')
@@ -122,8 +160,28 @@ def apply(wireguard):
wg = WireGuardIf(**wireguard)
wg.update(wireguard)
+ domain_resolver_usage = '/run/use-vyos-domain-resolver-interfaces-wireguard-' + wireguard['ifname']
+
+ ## DOMAIN RESOLVER
+ domain_action = 'restart'
+ if 'peers_need_resolve' in wireguard and len(wireguard['peers_need_resolve']) > 0 and 'disable' not in 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)
+ write_file(domain_resolver_usage, text)
+ else:
+ Path(domain_resolver_usage).unlink(missing_ok=True)
+ if not Path('/run').glob('use-vyos-domain-resolver*'):
+ domain_action = 'stop'
+ call(f'systemctl {domain_action} vyos-domain-resolver.service')
+
+ call_dependents()
+
return None
+
if __name__ == '__main__':
try:
c = get_config()
diff --git a/src/conf_mode/load-balancing_haproxy.py b/src/conf_mode/load-balancing_haproxy.py
index 45042dd52..5fd1beec9 100644
--- a/src/conf_mode/load-balancing_haproxy.py
+++ b/src/conf_mode/load-balancing_haproxy.py
@@ -78,6 +78,13 @@ def verify(lb):
not is_listen_port_bind_service(int(tmp_port), 'haproxy'):
raise ConfigError(f'"TCP" port "{tmp_port}" is used by another service')
+ if 'http_compression' in front_config:
+ if front_config['mode'] != 'http':
+ raise ConfigError(f'service {front} must be set to http mode to use http-compression!')
+ if len(front_config['http_compression']['mime_type']) == 0:
+ raise ConfigError(f'service {front} must have at least one mime-type configured to use'
+ f'http_compression!')
+
for back, back_config in lb['backend'].items():
if 'http_check' in back_config:
http_check = back_config['http_check']
diff --git a/src/conf_mode/load-balancing_wan.py b/src/conf_mode/load-balancing_wan.py
index 5da0b906b..92d9acfba 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 VyOS maintainers and contributors
+# Copyright (C) 2023-2025 VyOS maintainers and contributors
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 or later as
@@ -14,24 +14,16 @@
# 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 shutil import rmtree
-from vyos.base import Warning
from vyos.config import Config
from vyos.configdep import set_dependents, call_dependents
from vyos.utils.process import cmd
-from vyos.template import render
from vyos import ConfigError
from vyos import airbag
airbag.enable()
-load_balancing_dir = '/run/load-balance'
-load_balancing_conf_file = f'{load_balancing_dir}/wlb.conf'
-systemd_service = 'vyos-wan-load-balance.service'
-
+service = 'vyos-wan-load-balance.service'
def get_config(config=None):
if config:
@@ -40,6 +32,7 @@ def get_config(config=None):
conf = Config()
base = ['load-balancing', 'wan']
+
lb = conf.get_config_dict(base, key_mangling=('-', '_'),
no_tag_node_value_mangle=True,
get_first_key=True,
@@ -59,87 +52,61 @@ def verify(lb):
if not lb:
return None
- if 'interface_health' not in lb:
- raise ConfigError(
- 'A valid WAN load-balance configuration requires an interface with a nexthop!'
- )
-
- for interface, interface_config in lb['interface_health'].items():
- if 'nexthop' not in interface_config:
- raise ConfigError(
- f'interface-health {interface} nexthop must be specified!')
-
- if 'test' in interface_config:
- for test_rule, test_config in interface_config['test'].items():
- if 'type' in test_config:
- if test_config['type'] == 'user-defined' and 'test_script' not in test_config:
- raise ConfigError(
- f'test {test_rule} script must be defined for test-script!'
- )
-
- if 'rule' not in lb:
- Warning(
- 'At least one rule with an (outbound) interface must be defined for WAN load balancing to be active!'
- )
+ if 'interface_health' in lb:
+ for ifname, health_conf in lb['interface_health'].items():
+ if 'nexthop' not in health_conf:
+ raise ConfigError(f'Nexthop must be configured for interface {ifname}')
+
+ if 'test' not in health_conf:
+ continue
+
+ for test_id, test_conf in health_conf['test'].items():
+ if 'type' not in test_conf:
+ raise ConfigError(f'No type configured for health test on interface {ifname}')
+
+ if test_conf['type'] == 'user-defined' and 'test_script' not in test_conf:
+ raise ConfigError(f'Missing user-defined script for health test on interface {ifname}')
else:
- for rule, rule_config in lb['rule'].items():
- if 'inbound_interface' not in rule_config:
- raise ConfigError(f'rule {rule} inbound-interface must be specified!')
- if {'failover', 'exclude'} <= set(rule_config):
- raise ConfigError(f'rule {rule} failover cannot be configured with exclude!')
- if {'limit', 'exclude'} <= set(rule_config):
- raise ConfigError(f'rule {rule} limit cannot be used with exclude!')
- if 'interface' not in rule_config:
- if 'exclude' not in rule_config:
- Warning(
- f'rule {rule} will be inactive because no (outbound) interfaces have been defined for this rule'
- )
- for direction in {'source', 'destination'}:
- if direction in rule_config:
- if 'protocol' in rule_config and 'port' in rule_config[
- direction]:
- if rule_config['protocol'] not in {'tcp', 'udp'}:
- raise ConfigError('ports can only be specified when protocol is "tcp" or "udp"')
+ raise ConfigError('Interface health tests must be configured')
+ if 'rule' in lb:
+ for rule_id, rule_conf in lb['rule'].items():
+ if 'interface' not in rule_conf and 'exclude' not in rule_conf:
+ raise ConfigError(f'Interface or exclude not specified on load-balancing wan rule {rule_id}')
-def generate(lb):
- if not lb:
- # Delete /run/load-balance/wlb.conf
- if os.path.isfile(load_balancing_conf_file):
- os.unlink(load_balancing_conf_file)
- # Delete old directories
- if os.path.isdir(load_balancing_dir):
- rmtree(load_balancing_dir, ignore_errors=True)
- if os.path.exists('/var/run/load-balance/wlb.out'):
- os.unlink('/var/run/load-balance/wlb.out')
+ if 'failover' in rule_conf and 'exclude' in rule_conf:
+ raise ConfigError(f'Failover cannot be configured with exclude on load-balancing wan rule {rule_id}')
- return None
+ if 'limit' in rule_conf:
+ if 'exclude' in rule_conf:
+ raise ConfigError(f'Limit cannot be configured with exclude on load-balancing wan rule {rule_id}')
- # Create load-balance dir
- if not os.path.isdir(load_balancing_dir):
- os.mkdir(load_balancing_dir)
+ if 'rate' in rule_conf['limit'] and 'period' not in rule_conf['limit']:
+ raise ConfigError(f'Missing "limit period" on load-balancing wan rule {rule_id}')
- render(load_balancing_conf_file, 'load-balancing/wlb.conf.j2', lb)
+ if 'period' in rule_conf['limit'] and 'rate' not in rule_conf['limit']:
+ raise ConfigError(f'Missing "limit rate" on load-balancing wan rule {rule_id}')
- return None
+ for direction in ['source', 'destination']:
+ if direction in rule_conf:
+ 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}')
+
+ if rule_conf['protocol'] not in ['tcp', 'udp', 'tcp_udp']:
+ raise ConfigError(f'Protocol must be tcp, udp or tcp_udp to specify port on load-balancing wan rule {rule_id}')
+def generate(lb):
+ return None
def apply(lb):
if not lb:
- try:
- cmd(f'systemctl stop {systemd_service}')
- except Exception as e:
- print(f"Error message: {e}")
-
+ cmd(f'sudo systemctl stop {service}')
else:
- cmd('sudo sysctl -w net.netfilter.nf_conntrack_acct=1')
- cmd(f'systemctl restart {systemd_service}')
+ cmd(f'sudo systemctl restart {service}')
call_dependents()
- return None
-
-
if __name__ == '__main__':
try:
c = get_config()
diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py
index 98b2f3f29..504b3e82a 100755
--- a/src/conf_mode/nat.py
+++ b/src/conf_mode/nat.py
@@ -17,6 +17,7 @@
import os
from sys import exit
+from pathlib import Path
from vyos.base import Warning
from vyos.config import Config
@@ -43,7 +44,6 @@ k_mod = ['nft_nat', 'nft_chain_nat']
nftables_nat_config = '/run/nftables_nat.conf'
nftables_static_nat_conf = '/run/nftables_static-nat-rules.nft'
domain_resolver_usage = '/run/use-vyos-domain-resolver-nat'
-domain_resolver_usage_firewall = '/run/use-vyos-domain-resolver-firewall'
valid_groups = [
'address_group',
@@ -265,9 +265,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):
- os.unlink(domain_resolver_usage)
- if not os.path.exists(domain_resolver_usage_firewall):
- # Firewall not using domain resolver
+ Path(domain_resolver_usage).unlink(missing_ok=True)
+
+ if not Path('/run').glob('use-vyos-domain-resolver*'):
domain_action = 'stop'
call(f'systemctl {domain_action} vyos-domain-resolver.service')
diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py
index acea2c9be..724f97555 100755
--- a/src/conf_mode/pki.py
+++ b/src/conf_mode/pki.py
@@ -440,13 +440,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 alredy 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
diff --git a/src/conf_mode/policy.py b/src/conf_mode/policy.py
index a5963e72c..a90e33e81 100755
--- a/src/conf_mode/policy.py
+++ b/src/conf_mode/policy.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
#
-# Copyright (C) 2021-2022 VyOS maintainers and contributors
+# Copyright (C) 2021-2024 VyOS maintainers and contributors
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 or later as
@@ -17,16 +17,16 @@
from sys import exit
from vyos.config import Config
-from vyos.configdict import dict_merge
-from vyos.template import render_to_string
+from vyos.configverify import has_frr_protocol_in_dict
+from vyos.frrender import FRRender
+from vyos.frrender import frr_protocols
+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 import frr
from vyos import airbag
-
airbag.enable()
-
def community_action_compatibility(actions: dict) -> bool:
"""
Check compatibility of values in community and large community sections
@@ -87,31 +87,27 @@ def get_config(config=None):
else:
conf = Config()
- base = ['policy']
- policy = conf.get_config_dict(base, key_mangling=('-', '_'),
- get_first_key=True,
- no_tag_node_value_mangle=True)
-
- # We also need some additional information from the config, prefix-lists
- # and route-maps for instance. They will be used in verify().
- #
- # XXX: one MUST always call this without the key_mangling() option! See
- # vyos.configverify.verify_common_route_maps() for more information.
- tmp = conf.get_config_dict(['protocols'], key_mangling=('-', '_'),
- no_tag_node_value_mangle=True)
- # Merge policy dict into "regular" config dict
- policy = dict_merge(tmp, policy)
- return policy
-
-
-def verify(policy):
- if not policy:
+ return get_frrender_dict(conf)
+
+
+def verify(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'policy'):
return None
- for policy_type in ['access_list', 'access_list6', 'as_path_list',
- 'community_list', 'extcommunity_list',
- 'large_community_list',
- 'prefix_list', 'prefix_list6', 'route_map']:
+ policy_types = ['access_list', 'access_list6', 'as_path_list',
+ 'community_list', 'extcommunity_list',
+ 'large_community_list', 'prefix_list',
+ 'prefix_list6', 'route_map']
+
+ policy = config_dict['policy']
+ for protocol in frr_protocols:
+ if protocol not in config_dict:
+ continue
+ if 'protocol' not in policy:
+ policy.update({'protocol': {}})
+ policy['protocol'].update({protocol : config_dict[protocol]})
+
+ for policy_type in policy_types:
# Bail out early and continue with next policy type
if policy_type not in policy:
continue
@@ -246,72 +242,36 @@ def verify(policy):
# When the "routing policy" changes and policies, route-maps etc. are deleted,
# it is our responsibility to verify that the policy can not be deleted if it
# is used by any routing protocol
- if 'protocols' in policy:
- for policy_type in ['access_list', 'access_list6', 'as_path_list',
- 'community_list',
- 'extcommunity_list', 'large_community_list',
- 'prefix_list', 'route_map']:
- if policy_type in policy:
- for policy_name in list(set(routing_policy_find(policy_type,
- policy[
- 'protocols']))):
- found = False
- if policy_name in policy[policy_type]:
- found = True
- # BGP uses prefix-list for selecting both an IPv4 or IPv6 AFI related
- # list - we need to go the extra mile here and check both prefix-lists
- if policy_type == 'prefix_list' and 'prefix_list6' in policy and policy_name in \
- policy['prefix_list6']:
- found = True
- if not found:
- tmp = policy_type.replace('_', '-')
- raise ConfigError(
- f'Can not delete {tmp} "{policy_name}", still in use!')
+ # Check if any routing protocol is activated
+ if 'protocol' in policy:
+ for policy_type in policy_types:
+ for policy_name in list(set(routing_policy_find(policy_type, policy['protocol']))):
+ found = False
+ if policy_type in policy and policy_name in policy[policy_type]:
+ found = True
+ # BGP uses prefix-list for selecting both an IPv4 or IPv6 AFI related
+ # list - we need to go the extra mile here and check both prefix-lists
+ if policy_type == 'prefix_list' and 'prefix_list6' in policy and policy_name in \
+ policy['prefix_list6']:
+ found = True
+ if not found:
+ tmp = policy_type.replace('_', '-')
+ raise ConfigError(
+ f'Can not delete {tmp} "{policy_name}", still in use!')
return None
-def generate(policy):
- if not policy:
- return None
- policy['new_frr_config'] = render_to_string('frr/policy.frr.j2', policy)
+def generate(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().generate(config_dict)
return None
-
-def apply(policy):
- bgp_daemon = 'bgpd'
- zebra_daemon = 'zebra'
-
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
-
- # The route-map used for the FIB (zebra) is part of the zebra daemon
- frr_cfg.load_configuration(bgp_daemon)
- frr_cfg.modify_section(r'^bgp as-path access-list .*')
- frr_cfg.modify_section(r'^bgp community-list .*')
- frr_cfg.modify_section(r'^bgp extcommunity-list .*')
- frr_cfg.modify_section(r'^bgp large-community-list .*')
- frr_cfg.modify_section(r'^route-map .*', stop_pattern='^exit',
- remove_stop_mark=True)
- if 'new_frr_config' in policy:
- frr_cfg.add_before(frr.default_add_before, policy['new_frr_config'])
- frr_cfg.commit_configuration(bgp_daemon)
-
- # The route-map used for the FIB (zebra) is part of the zebra daemon
- frr_cfg.load_configuration(zebra_daemon)
- frr_cfg.modify_section(r'^access-list .*')
- frr_cfg.modify_section(r'^ipv6 access-list .*')
- frr_cfg.modify_section(r'^ip prefix-list .*')
- frr_cfg.modify_section(r'^ipv6 prefix-list .*')
- frr_cfg.modify_section(r'^route-map .*', stop_pattern='^exit',
- remove_stop_mark=True)
- if 'new_frr_config' in policy:
- frr_cfg.add_before(frr.default_add_before, policy['new_frr_config'])
- frr_cfg.commit_configuration(zebra_daemon)
-
+def apply(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
return None
-
if __name__ == '__main__':
try:
c = get_config()
diff --git a/src/conf_mode/protocols_babel.py b/src/conf_mode/protocols_babel.py
index 90b6e4a31..80a847af8 100755
--- a/src/conf_mode/protocols_babel.py
+++ b/src/conf_mode/protocols_babel.py
@@ -17,15 +17,14 @@
from sys import exit
from vyos.config import Config
-from vyos.config import config_dict_merge
-from vyos.configdict import dict_merge
-from vyos.configdict import node_changed
+from vyos.configverify import has_frr_protocol_in_dict
from vyos.configverify import verify_access_list
from vyos.configverify import verify_prefix_list
+from vyos.frrender import FRRender
+from vyos.frrender import get_frrender_dict
from vyos.utils.dict import dict_search
-from vyos.template import render_to_string
+from vyos.utils.process import is_systemd_service_running
from vyos import ConfigError
-from vyos import frr
from vyos import airbag
airbag.enable()
@@ -34,46 +33,16 @@ def get_config(config=None):
conf = config
else:
conf = Config()
- base = ['protocols', 'babel']
- babel = conf.get_config_dict(base, key_mangling=('-', '_'),
- get_first_key=True)
- # FRR has VRF support for different routing daemons. As interfaces belong
- # to VRFs - or the global VRF, we need to check for changed interfaces so
- # that they will be properly rendered for the FRR config. Also this eases
- # removal of interfaces from the running configuration.
- interfaces_removed = node_changed(conf, base + ['interface'])
- if interfaces_removed:
- babel['interface_removed'] = list(interfaces_removed)
+ return get_frrender_dict(conf)
- # Bail out early if configuration tree does not exist
- if not conf.exists(base):
- babel.update({'deleted' : ''})
- return babel
-
- # We have gathered the dict representation of the CLI, but there are default
- # values which we need to update into the dictionary retrieved.
- default_values = conf.get_config_defaults(base, key_mangling=('-', '_'),
- get_first_key=True,
- recursive=True)
-
- # merge in default values
- babel = config_dict_merge(default_values, babel)
-
- # We also need some additional information from the config, prefix-lists
- # and route-maps for instance. They will be used in verify().
- #
- # XXX: one MUST always call this without the key_mangling() option! See
- # vyos.configverify.verify_common_route_maps() for more information.
- tmp = conf.get_config_dict(['policy'])
- # Merge policy dict into "regular" config dict
- babel = dict_merge(tmp, babel)
- return babel
-
-def verify(babel):
- if not babel:
+def verify(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'babel'):
return None
+ babel = config_dict['babel']
+ babel['policy'] = config_dict['policy']
+
# verify distribute_list
if "distribute_list" in babel:
acl_keys = {
@@ -120,32 +89,14 @@ def verify(babel):
verify_prefix_list(prefix_list, babel, version='6' if address_family == 'ipv6' else '')
-def generate(babel):
- if not babel or 'deleted' in babel:
- return None
-
- babel['new_frr_config'] = render_to_string('frr/babeld.frr.j2', babel)
+def generate(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().generate(config_dict)
return None
-def apply(babel):
- babel_daemon = 'babeld'
-
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
-
- frr_cfg.load_configuration(babel_daemon)
- frr_cfg.modify_section('^router babel', stop_pattern='^exit', remove_stop_mark=True)
-
- for key in ['interface', 'interface_removed']:
- if key not in babel:
- continue
- for interface in babel[key]:
- frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True)
-
- if 'new_frr_config' in babel:
- frr_cfg.add_before(frr.default_add_before, babel['new_frr_config'])
- frr_cfg.commit_configuration(babel_daemon)
-
+def apply(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
return None
if __name__ == '__main__':
diff --git a/src/conf_mode/protocols_bfd.py b/src/conf_mode/protocols_bfd.py
index 1361bb1a9..d3bc3e961 100755
--- a/src/conf_mode/protocols_bfd.py
+++ b/src/conf_mode/protocols_bfd.py
@@ -16,11 +16,13 @@
from vyos.config import Config
from vyos.configverify import verify_vrf
+from vyos.configverify import has_frr_protocol_in_dict
+from vyos.frrender import FRRender
+from vyos.frrender import get_frrender_dict
from vyos.template import is_ipv6
-from vyos.template import render_to_string
from vyos.utils.network import is_ipv6_link_local
+from vyos.utils.process import is_systemd_service_running
from vyos import ConfigError
-from vyos import frr
from vyos import airbag
airbag.enable()
@@ -29,22 +31,14 @@ def get_config(config=None):
conf = config
else:
conf = Config()
- base = ['protocols', 'bfd']
- bfd = conf.get_config_dict(base, key_mangling=('-', '_'),
- get_first_key=True,
- no_tag_node_value_mangle=True)
- # Bail out early if configuration tree does not exist
- if not conf.exists(base):
- return bfd
- bfd = conf.merge_defaults(bfd, recursive=True)
+ return get_frrender_dict(conf)
- return bfd
-
-def verify(bfd):
- if not bfd:
+def verify(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'bfd'):
return None
+ bfd = config_dict['bfd']
if 'peer' in bfd:
for peer, peer_config in bfd['peer'].items():
# IPv6 link local peers require an explicit local address/interface
@@ -83,22 +77,13 @@ def verify(bfd):
return None
-def generate(bfd):
- if not bfd:
- return None
- bfd['new_frr_config'] = render_to_string('frr/bfdd.frr.j2', bfd)
-
-def apply(bfd):
- bfd_daemon = 'bfdd'
-
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
- frr_cfg.load_configuration(bfd_daemon)
- frr_cfg.modify_section('^bfd', stop_pattern='^exit', remove_stop_mark=True)
- if 'new_frr_config' in bfd:
- frr_cfg.add_before(frr.default_add_before, bfd['new_frr_config'])
- frr_cfg.commit_configuration(bfd_daemon)
+def generate(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().generate(config_dict)
+def apply(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
return None
if __name__ == '__main__':
diff --git a/src/conf_mode/protocols_bgp.py b/src/conf_mode/protocols_bgp.py
index 22f020099..53e83c3b4 100755
--- a/src/conf_mode/protocols_bgp.py
+++ b/src/conf_mode/protocols_bgp.py
@@ -19,21 +19,20 @@ from sys import argv
from vyos.base import Warning
from vyos.config import Config
-from vyos.configdict import dict_merge
-from vyos.configdict import node_changed
+from vyos.configverify import has_frr_protocol_in_dict
from vyos.configverify import verify_prefix_list
from vyos.configverify import verify_route_map
from vyos.configverify import verify_vrf
+from vyos.frrender import FRRender
+from vyos.frrender import get_frrender_dict
from vyos.template import is_ip
from vyos.template import is_interface
-from vyos.template import render_to_string
from vyos.utils.dict import dict_search
from vyos.utils.network import get_interface_vrf
from vyos.utils.network import is_addr_assigned
+from vyos.utils.process import is_systemd_service_running
from vyos.utils.process import process_named_running
-from vyos.utils.process import call
from vyos import ConfigError
-from vyos import frr
from vyos import airbag
airbag.enable()
@@ -43,68 +42,7 @@ def get_config(config=None):
else:
conf = Config()
- vrf = None
- if len(argv) > 1:
- vrf = argv[1]
-
- base_path = ['protocols', 'bgp']
-
- # eqivalent of the C foo ? 'a' : 'b' statement
- base = vrf and ['vrf', 'name', vrf, 'protocols', 'bgp'] or base_path
- bgp = conf.get_config_dict(base, key_mangling=('-', '_'),
- get_first_key=True, no_tag_node_value_mangle=True)
-
- bgp['dependent_vrfs'] = conf.get_config_dict(['vrf', 'name'],
- key_mangling=('-', '_'),
- get_first_key=True,
- no_tag_node_value_mangle=True)
-
- # Remove per interface MPLS configuration - get a list if changed
- # nodes under the interface tagNode
- interfaces_removed = node_changed(conf, base + ['interface'])
- if interfaces_removed:
- bgp['interface_removed'] = list(interfaces_removed)
-
- # Assign the name of our VRF context. This MUST be done before the return
- # statement below, else on deletion we will delete the default instance
- # instead of the VRF instance.
- if vrf:
- bgp.update({'vrf' : vrf})
- # We can not delete the BGP VRF instance if there is a L3VNI configured
- # FRR L3VNI must be deleted first otherwise we will see error:
- # "FRR error: Please unconfigure l3vni 3000"
- tmp = ['vrf', 'name', vrf, 'vni']
- if conf.exists_effective(tmp):
- bgp.update({'vni' : conf.return_effective_value(tmp)})
- # We can safely delete ourself from the dependent vrf list
- if vrf in bgp['dependent_vrfs']:
- del bgp['dependent_vrfs'][vrf]
-
- bgp['dependent_vrfs'].update({'default': {'protocols': {
- 'bgp': conf.get_config_dict(base_path, key_mangling=('-', '_'),
- get_first_key=True,
- no_tag_node_value_mangle=True)}}})
-
- if not conf.exists(base):
- # If bgp instance is deleted then mark it
- bgp.update({'deleted' : ''})
- return bgp
-
- # We have gathered the dict representation of the CLI, but there are default
- # options which we need to update into the dictionary retrived.
- bgp = conf.merge_defaults(bgp, recursive=True)
-
- # We also need some additional information from the config, prefix-lists
- # and route-maps for instance. They will be used in verify().
- #
- # XXX: one MUST always call this without the key_mangling() option! See
- # vyos.configverify.verify_common_route_maps() for more information.
- tmp = conf.get_config_dict(['policy'])
- # Merge policy dict into "regular" config dict
- bgp = dict_merge(tmp, bgp)
-
- return bgp
-
+ return get_frrender_dict(conf, argv)
def verify_vrf_as_import(search_vrf_name: str, afi_name: str, vrfs_config: dict) -> bool:
"""
@@ -237,13 +175,24 @@ def verify_afi(peer_config, bgp_config):
if tmp: return True
return False
-def verify(bgp):
+def verify(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'bgp'):
+ return None
+
+ vrf = None
+ 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']
+ bgp['policy'] = config_dict['policy']
+
if 'deleted' in bgp:
- if 'vrf' in bgp:
+ if vrf:
# Cannot delete vrf if it exists in import vrf list in other vrfs
for tmp_afi in ['ipv4_unicast', 'ipv6_unicast']:
- if verify_vrf_as_import(bgp['vrf'], tmp_afi, bgp['dependent_vrfs']):
- raise ConfigError(f'Cannot delete VRF instance "{bgp["vrf"]}", ' \
+ if verify_vrf_as_import(vrf, tmp_afi, bgp['dependent_vrfs']):
+ raise ConfigError(f'Cannot delete VRF instance "{vrf}", ' \
'unconfigure "import vrf" commands!')
else:
# We are running in the default VRF context, thus we can not delete
@@ -252,8 +201,9 @@ def verify(bgp):
for vrf, vrf_options in bgp['dependent_vrfs'].items():
if vrf != 'default':
if dict_search('protocols.bgp', vrf_options):
- raise ConfigError('Cannot delete default BGP instance, ' \
- 'dependent VRF instance(s) exist(s)!')
+ dependent_vrfs = ', '.join(bgp['dependent_vrfs'].keys())
+ raise ConfigError(f'Cannot delete default BGP instance, ' \
+ f'dependent VRF instance(s): {dependent_vrfs}')
if 'vni' in vrf_options:
raise ConfigError('Cannot delete default BGP instance, ' \
'dependent L3VNI exists!')
@@ -281,9 +231,8 @@ def verify(bgp):
for interface in bgp['interface']:
error_msg = f'Interface "{interface}" belongs to different VRF instance'
tmp = get_interface_vrf(interface)
- if 'vrf' in bgp:
- if bgp['vrf'] != tmp:
- vrf = bgp['vrf']
+ if vrf:
+ if vrf != tmp:
raise ConfigError(f'{error_msg} "{vrf}"!')
elif tmp != 'default':
raise ConfigError(f'{error_msg} "{tmp}"!')
@@ -384,10 +333,8 @@ def verify(bgp):
# Only checks for ipv4 and ipv6 neighbors
# Check if neighbor address is assigned as system interface address
- vrf = None
vrf_error_msg = f' in default VRF!'
- if 'vrf' in bgp:
- vrf = bgp['vrf']
+ if vrf:
vrf_error_msg = f' in VRF "{vrf}"!'
if is_ip(peer) and is_addr_assigned(peer, vrf):
@@ -529,7 +476,7 @@ def verify(bgp):
f'{afi} administrative distance {key}!')
if afi in ['ipv4_unicast', 'ipv6_unicast']:
- vrf_name = bgp['vrf'] if dict_search('vrf', bgp) else 'default'
+ vrf_name = vrf if vrf else 'default'
# Verify if currant VRF contains rd and route-target options
# and does not exist in import list in other VRFs
if dict_search(f'rd.vpn.export', afi_config):
@@ -576,12 +523,21 @@ def verify(bgp):
raise ConfigError(
'Please unconfigure import vrf commands before using vpn commands in dependent VRFs!')
+ 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'] or dict_search('export.vpn', afi_config) != None:
+ if ('vpn' in afi_config['import']
+ or dict_search('export.vpn', afi_config) is not None):
raise ConfigError('Please unconfigure VPN to VRF commands before '\
'using "import vrf" commands!')
+ if (dict_search('route_map.vpn.import', afi_config) is not None
+ or dict_search('route_map.vpn.export', afi_config) is not None) :
+ 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)
@@ -602,46 +558,14 @@ def verify(bgp):
return None
-def generate(bgp):
- if not bgp or 'deleted' in bgp:
- return None
-
- bgp['frr_bgpd_config'] = render_to_string('frr/bgpd.frr.j2', bgp)
+def generate(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().generate(config_dict)
return None
-def apply(bgp):
- if 'deleted' in bgp:
- # We need to ensure that the L3VNI is deleted first.
- # This is not possible with old config backend
- # priority bug
- if {'vrf', 'vni'} <= set(bgp):
- call('vtysh -c "conf t" -c "vrf {vrf}" -c "no vni {vni}"'.format(**bgp))
-
- bgp_daemon = 'bgpd'
-
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
-
- # Generate empty helper string which can be ammended to FRR commands, it
- # will be either empty (default VRF) or contain the "vrf <name" statement
- vrf = ''
- if 'vrf' in bgp:
- vrf = ' vrf ' + bgp['vrf']
-
- frr_cfg.load_configuration(bgp_daemon)
-
- # Remove interface specific config
- for key in ['interface', 'interface_removed']:
- if key not in bgp:
- continue
- for interface in bgp[key]:
- frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True)
-
- frr_cfg.modify_section(f'^router bgp \d+{vrf}', stop_pattern='^exit', remove_stop_mark=True)
- if 'frr_bgpd_config' in bgp:
- frr_cfg.add_before(frr.default_add_before, bgp['frr_bgpd_config'])
- frr_cfg.commit_configuration(bgp_daemon)
-
+def apply(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
return None
if __name__ == '__main__':
diff --git a/src/conf_mode/protocols_eigrp.py b/src/conf_mode/protocols_eigrp.py
index c13e52a3d..324ff883f 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 VyOS maintainers and contributors
+# Copyright (C) 2022-2024 VyOS maintainers and contributors
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 or later as
@@ -18,94 +18,49 @@ from sys import exit
from sys import argv
from vyos.config import Config
-from vyos.configdict import dict_merge
+from vyos.configverify import has_frr_protocol_in_dict
from vyos.configverify import verify_vrf
-from vyos.template import render_to_string
+from vyos.utils.process import is_systemd_service_running
+from vyos.frrender import FRRender
+from vyos.frrender import get_frrender_dict
from vyos import ConfigError
-from vyos import frr
from vyos import airbag
airbag.enable()
-
def get_config(config=None):
if config:
conf = config
else:
conf = Config()
- vrf = None
- if len(argv) > 1:
- vrf = argv[1]
-
- base_path = ['protocols', 'eigrp']
-
- # eqivalent of the C foo ? 'a' : 'b' statement
- base = vrf and ['vrf', 'name', vrf, 'protocols', 'eigrp'] or base_path
- eigrp = conf.get_config_dict(base, key_mangling=('-', '_'),
- get_first_key=True, no_tag_node_value_mangle=True)
+ return get_frrender_dict(conf, argv)
- # Assign the name of our VRF context. This MUST be done before the return
- # statement below, else on deletion we will delete the default instance
- # instead of the VRF instance.
- if vrf: eigrp.update({'vrf' : vrf})
-
- if not conf.exists(base):
- eigrp.update({'deleted' : ''})
- if not vrf:
- # We are running in the default VRF context, thus we can not delete
- # our main EIGRP instance if there are dependent EIGRP VRF instances.
- eigrp['dependent_vrfs'] = conf.get_config_dict(['vrf', 'name'],
- key_mangling=('-', '_'),
- get_first_key=True,
- no_tag_node_value_mangle=True)
-
- return eigrp
-
- # We also need some additional information from the config, prefix-lists
- # and route-maps for instance. They will be used in verify().
- #
- # XXX: one MUST always call this without the key_mangling() option! See
- # vyos.configverify.verify_common_route_maps() for more information.
- tmp = conf.get_config_dict(['policy'])
- # Merge policy dict into "regular" config dict
- eigrp = dict_merge(tmp, eigrp)
+def verify(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'eigrp'):
+ return None
- return eigrp
+ vrf = None
+ if 'vrf_context' in config_dict:
+ vrf = config_dict['vrf_context']
-def verify(eigrp):
- if not eigrp or 'deleted' in eigrp:
- return
+ # eqivalent of the C foo ? 'a' : 'b' statement
+ eigrp = vrf and config_dict['vrf']['name'][vrf]['protocols']['eigrp'] or config_dict['eigrp']
+ eigrp['policy'] = config_dict['policy']
if 'system_as' not in eigrp:
raise ConfigError('EIGRP system-as must be defined!')
- if 'vrf' in eigrp:
- verify_vrf(eigrp)
-
-def generate(eigrp):
- if not eigrp or 'deleted' in eigrp:
- return None
-
- eigrp['frr_eigrpd_config'] = render_to_string('frr/eigrpd.frr.j2', eigrp)
+ if vrf:
+ verify_vrf({'vrf': vrf})
-def apply(eigrp):
- eigrp_daemon = 'eigrpd'
-
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
-
- # Generate empty helper string which can be ammended to FRR commands, it
- # will be either empty (default VRF) or contain the "vrf <name" statement
- vrf = ''
- if 'vrf' in eigrp:
- vrf = ' vrf ' + eigrp['vrf']
-
- frr_cfg.load_configuration(eigrp_daemon)
- frr_cfg.modify_section(f'^router eigrp \d+{vrf}', stop_pattern='^exit', remove_stop_mark=True)
- if 'frr_eigrpd_config' in eigrp:
- frr_cfg.add_before(frr.default_add_before, eigrp['frr_eigrpd_config'])
- frr_cfg.commit_configuration(eigrp_daemon)
+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 config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
return None
if __name__ == '__main__':
diff --git a/src/conf_mode/protocols_isis.py b/src/conf_mode/protocols_isis.py
index ba2f3cf0d..1c994492e 100755
--- a/src/conf_mode/protocols_isis.py
+++ b/src/conf_mode/protocols_isis.py
@@ -18,16 +18,16 @@ from sys import exit
from sys import argv
from vyos.config import Config
-from vyos.configdict import dict_merge
-from vyos.configdict import node_changed
+from vyos.configverify import has_frr_protocol_in_dict
from vyos.configverify import verify_common_route_maps
from vyos.configverify import verify_interface_exists
+from vyos.frrender import FRRender
+from vyos.frrender import get_frrender_dict
from vyos.ifconfig import Interface
from vyos.utils.dict import dict_search
from vyos.utils.network import get_interface_config
-from vyos.template import render_to_string
+from vyos.utils.process import is_systemd_service_running
from vyos import ConfigError
-from vyos import frr
from vyos import airbag
airbag.enable()
@@ -37,54 +37,21 @@ def get_config(config=None):
else:
conf = Config()
- vrf = None
- if len(argv) > 1:
- vrf = argv[1]
+ return get_frrender_dict(conf, argv)
+
+def verify(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'isis'):
+ return None
- base_path = ['protocols', 'isis']
+ vrf = None
+ if 'vrf_context' in config_dict:
+ vrf = config_dict['vrf_context']
# eqivalent of the C foo ? 'a' : 'b' statement
- base = vrf and ['vrf', 'name', vrf, 'protocols', 'isis'] or base_path
- isis = conf.get_config_dict(base, key_mangling=('-', '_'),
- get_first_key=True,
- no_tag_node_value_mangle=True)
-
- # Assign the name of our VRF context. This MUST be done before the return
- # statement below, else on deletion we will delete the default instance
- # instead of the VRF instance.
- if vrf: isis['vrf'] = vrf
-
- # FRR has VRF support for different routing daemons. As interfaces belong
- # to VRFs - or the global VRF, we need to check for changed interfaces so
- # that they will be properly rendered for the FRR config. Also this eases
- # removal of interfaces from the running configuration.
- interfaces_removed = node_changed(conf, base + ['interface'])
- if interfaces_removed:
- isis['interface_removed'] = list(interfaces_removed)
-
- # Bail out early if configuration tree does no longer exist. this must
- # be done after retrieving the list of interfaces to be removed.
- if not conf.exists(base):
- isis.update({'deleted' : ''})
- return isis
-
- # merge in default values
- isis = conf.merge_defaults(isis, recursive=True)
-
- # We also need some additional information from the config, prefix-lists
- # and route-maps for instance. They will be used in verify().
- #
- # XXX: one MUST always call this without the key_mangling() option! See
- # vyos.configverify.verify_common_route_maps() for more information.
- tmp = conf.get_config_dict(['policy'])
- # Merge policy dict into "regular" config dict
- isis = dict_merge(tmp, isis)
-
- return isis
-
-def verify(isis):
- # bail out early - looks like removal from running config
- if not isis or 'deleted' in isis:
+ isis = vrf and config_dict['vrf']['name'][vrf]['protocols']['isis'] or config_dict['isis']
+ isis['policy'] = config_dict['policy']
+
+ if 'deleted' in isis:
return None
if 'net' not in isis:
@@ -114,12 +81,11 @@ def verify(isis):
f'Recommended area lsp-mtu {recom_area_mtu} or less ' \
'(calculated on MTU size).')
- if 'vrf' in isis:
+ if vrf:
# If interface specific options are set, we must ensure that the
# interface is bound to our requesting VRF. Due to the VyOS
# priorities the interface is bound to the VRF after creation of
# the VRF itself, and before any routing protocol is configured.
- vrf = isis['vrf']
tmp = get_interface_config(interface)
if 'master' not in tmp or tmp['master'] != vrf:
raise ConfigError(f'Interface "{interface}" is not a member of VRF "{vrf}"!')
@@ -266,39 +232,14 @@ def verify(isis):
return None
-def generate(isis):
- if not isis or 'deleted' in isis:
- return None
-
- isis['frr_isisd_config'] = render_to_string('frr/isisd.frr.j2', isis)
+def generate(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().generate(config_dict)
return None
-def apply(isis):
- isis_daemon = 'isisd'
-
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
-
- # Generate empty helper string which can be ammended to FRR commands, it
- # will be either empty (default VRF) or contain the "vrf <name" statement
- vrf = ''
- if 'vrf' in isis:
- vrf = ' vrf ' + isis['vrf']
-
- frr_cfg.load_configuration(isis_daemon)
- frr_cfg.modify_section(f'^router isis VyOS{vrf}', stop_pattern='^exit', remove_stop_mark=True)
-
- for key in ['interface', 'interface_removed']:
- if key not in isis:
- continue
- for interface in isis[key]:
- frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True)
-
- if 'frr_isisd_config' in isis:
- frr_cfg.add_before(frr.default_add_before, isis['frr_isisd_config'])
-
- frr_cfg.commit_configuration(isis_daemon)
-
+def apply(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
return None
if __name__ == '__main__':
diff --git a/src/conf_mode/protocols_mpls.py b/src/conf_mode/protocols_mpls.py
index ad164db9f..33d9a6dae 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-2022 VyOS maintainers and contributors
+# Copyright (C) 2020-2024 VyOS maintainers and contributors
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 or later as
@@ -20,33 +20,32 @@ from sys import exit
from glob import glob
from vyos.config import Config
-from vyos.template import render_to_string
+from vyos.configverify import has_frr_protocol_in_dict
+from vyos.frrender import FRRender
+from vyos.frrender import get_frrender_dict
from vyos.utils.dict import dict_search
from vyos.utils.file import read_file
+from vyos.utils.process import is_systemd_service_running
from vyos.utils.system import sysctl_write
from vyos.configverify import verify_interface_exists
from vyos import ConfigError
-from vyos import frr
from vyos import airbag
airbag.enable()
-config_file = r'/tmp/ldpd.frr'
-
def get_config(config=None):
if config:
conf = config
else:
conf = Config()
- base = ['protocols', 'mpls']
- mpls = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True)
- return mpls
+ return get_frrender_dict(conf)
-def verify(mpls):
- # If no config, then just bail out early.
- if not mpls:
+def verify(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'mpls'):
return None
+ mpls = config_dict['mpls']
+
if 'interface' in mpls:
for interface in mpls['interface']:
verify_interface_exists(mpls, interface)
@@ -68,26 +67,19 @@ def verify(mpls):
return None
-def generate(mpls):
- # If there's no MPLS config generated, create dictionary key with no value.
- if not mpls or 'deleted' in mpls:
- return None
-
- mpls['frr_ldpd_config'] = render_to_string('frr/ldpd.frr.j2', mpls)
+def generate(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().generate(config_dict)
return None
-def apply(mpls):
- ldpd_damon = 'ldpd'
-
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
+def apply(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
- frr_cfg.load_configuration(ldpd_damon)
- frr_cfg.modify_section(f'^mpls ldp', stop_pattern='^exit', remove_stop_mark=True)
+ if not has_frr_protocol_in_dict(config_dict, 'mpls'):
+ return None
- if 'frr_ldpd_config' in mpls:
- frr_cfg.add_before(frr.default_add_before, mpls['frr_ldpd_config'])
- frr_cfg.commit_configuration(ldpd_damon)
+ mpls = config_dict['mpls']
# Set number of entries in the platform label tables
labels = '0'
diff --git a/src/conf_mode/protocols_nhrp.py b/src/conf_mode/protocols_nhrp.py
index 0bd68b7d8..ac92c9d99 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-2024 VyOS maintainers and contributors
+# Copyright (C) 2021-2025 VyOS maintainers and contributors
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 or later as
@@ -14,95 +14,112 @@
# 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 sys import argv
+import ipaddress
from vyos.config import Config
-from vyos.configdict import node_changed
from vyos.template import render
+from vyos.configverify import has_frr_protocol_in_dict
from vyos.utils.process import run
+from vyos.utils.dict import dict_search
from vyos import ConfigError
from vyos import airbag
+from vyos.frrender import FRRender
+from vyos.frrender import get_frrender_dict
+from vyos.utils.process import is_systemd_service_running
+
airbag.enable()
-opennhrp_conf = '/run/opennhrp/opennhrp.conf'
+nflog_redirect = 1
+nflog_multicast = 2
nhrp_nftables_conf = '/run/nftables_nhrp.conf'
+
def get_config(config=None):
if config:
conf = config
else:
conf = Config()
- base = ['protocols', 'nhrp']
-
- nhrp = conf.get_config_dict(base, key_mangling=('-', '_'),
- get_first_key=True, no_tag_node_value_mangle=True)
- nhrp['del_tunnels'] = node_changed(conf, base + ['tunnel'])
-
- if not conf.exists(base):
- return nhrp
- nhrp['if_tunnel'] = conf.get_config_dict(['interfaces', 'tunnel'], key_mangling=('-', '_'),
- get_first_key=True, no_tag_node_value_mangle=True)
+ return get_frrender_dict(conf, argv)
- nhrp['profile_map'] = {}
- profile = conf.get_config_dict(['vpn', 'ipsec', 'profile'], key_mangling=('-', '_'),
- get_first_key=True, no_tag_node_value_mangle=True)
- for name, profile_conf in profile.items():
- if 'bind' in profile_conf and 'tunnel' in profile_conf['bind']:
- interfaces = profile_conf['bind']['tunnel']
- if isinstance(interfaces, str):
- interfaces = [interfaces]
- for interface in interfaces:
- nhrp['profile_map'][interface] = name
-
- return nhrp
-
-def verify(nhrp):
- if 'tunnel' in nhrp:
- for name, nhrp_conf in nhrp['tunnel'].items():
- if not nhrp['if_tunnel'] or name not in nhrp['if_tunnel']:
+def verify(config_dict):
+ if not config_dict or 'deleted' in config_dict:
+ return None
+ if 'tunnel' in config_dict:
+ for name, nhrp_conf in config_dict['tunnel'].items():
+ if not config_dict['if_tunnel'] or name not in config_dict['if_tunnel']:
raise ConfigError(f'Tunnel interface "{name}" does not exist')
- tunnel_conf = nhrp['if_tunnel'][name]
+ tunnel_conf = config_dict['if_tunnel'][name]
+ if 'address' in tunnel_conf:
+ address_list = dict_search('address', tunnel_conf)
+ for tunip in address_list:
+ if ipaddress.ip_network(tunip,
+ strict=False).prefixlen != 32:
+ raise ConfigError(
+ f'Tunnel {name} is used for NHRP, Netmask should be /32!')
if 'encapsulation' not in tunnel_conf or tunnel_conf['encapsulation'] != 'gre':
raise ConfigError(f'Tunnel "{name}" is not an mGRE tunnel')
+ if 'network_id' not in nhrp_conf:
+ raise ConfigError(f'network-id is not specified in tunnel "{name}"')
+
if 'remote' in tunnel_conf:
raise ConfigError(f'Tunnel "{name}" cannot have a remote address defined')
- if 'map' in nhrp_conf:
- for map_name, map_conf in nhrp_conf['map'].items():
- if 'nbma_address' not in map_conf:
+ map_tunnelip = dict_search('map.tunnel_ip', nhrp_conf)
+ if map_tunnelip:
+ for map_name, map_conf in map_tunnelip.items():
+ if 'nbma' not in map_conf:
raise ConfigError(f'nbma-address missing on map {map_name} on tunnel {name}')
- if 'dynamic_map' in nhrp_conf:
- for map_name, map_conf in nhrp_conf['dynamic_map'].items():
- if 'nbma_domain_name' not in map_conf:
- raise ConfigError(f'nbma-domain-name missing on dynamic-map {map_name} on tunnel {name}')
+ nhs_tunnelip = dict_search('nhs.tunnel_ip', nhrp_conf)
+ nbma_list = []
+ if nhs_tunnelip:
+ for nhs_name, nhs_conf in nhs_tunnelip.items():
+ if 'nbma' not in nhs_conf:
+ raise ConfigError(f'nbma-address missing on map nhs {nhs_name} on tunnel {name}')
+ if nhs_name != 'dynamic':
+ if len(list(dict_search('nbma', nhs_conf))) > 1:
+ raise ConfigError(
+ f'Static nhs tunnel-ip {nhs_name} cannot contain multiple nbma-addresses')
+ for nbma_ip in dict_search('nbma', nhs_conf):
+ if nbma_ip not in nbma_list:
+ nbma_list.append(nbma_ip)
+ else:
+ raise ConfigError(
+ f'Nbma address {nbma_ip} cannot be maped to several tunnel-ip')
return None
-def generate(nhrp):
- if not os.path.exists(nhrp_nftables_conf):
- nhrp['first_install'] = True
- render(opennhrp_conf, 'nhrp/opennhrp.conf.j2', nhrp)
- render(nhrp_nftables_conf, 'nhrp/nftables.conf.j2', nhrp)
+def generate(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'nhrp'):
+ return None
+
+ if 'deleted' in config_dict['nhrp']:
+ return None
+ render(nhrp_nftables_conf, 'frr/nhrpd_nftables.conf.j2', config_dict['nhrp'])
+
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().generate(config_dict)
return None
-def apply(nhrp):
+
+def apply(config_dict):
+
nft_rc = run(f'nft --file {nhrp_nftables_conf}')
if nft_rc != 0:
raise ConfigError('Failed to apply NHRP tunnel firewall rules')
- action = 'restart' if nhrp and 'tunnel' in nhrp else 'stop'
- service_rc = run(f'systemctl {action} opennhrp.service')
- if service_rc != 0:
- raise ConfigError(f'Failed to {action} the NHRP service')
-
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
return None
+
if __name__ == '__main__':
try:
c = get_config()
@@ -112,3 +129,4 @@ if __name__ == '__main__':
except ConfigError as e:
print(e)
exit(1)
+
diff --git a/src/conf_mode/protocols_openfabric.py b/src/conf_mode/protocols_openfabric.py
index 8e8c50c06..7df11fb20 100644
--- a/src/conf_mode/protocols_openfabric.py
+++ b/src/conf_mode/protocols_openfabric.py
@@ -18,13 +18,13 @@ from sys import exit
from vyos.base import Warning
from vyos.config import Config
-from vyos.configdict import node_changed
from vyos.configverify import verify_interface_exists
-from vyos.template import render_to_string
+from vyos.configverify import has_frr_protocol_in_dict
+from vyos.utils.process import is_systemd_service_running
+from vyos.frrender import FRRender
+from vyos.frrender import get_frrender_dict
from vyos import ConfigError
-from vyos import frr
from vyos import airbag
-
airbag.enable()
def get_config(config=None):
@@ -33,32 +33,14 @@ def get_config(config=None):
else:
conf = Config()
- base_path = ['protocols', 'openfabric']
-
- openfabric = conf.get_config_dict(base_path, key_mangling=('-', '_'),
- get_first_key=True,
- no_tag_node_value_mangle=True)
-
- # Remove per domain MPLS configuration - get a list of all changed Openfabric domains
- # (removed and added) so that they will be properly rendered for the FRR config.
- openfabric['domains_all'] = list(conf.list_nodes(' '.join(base_path) + f' domain') +
- node_changed(conf, base_path + ['domain']))
-
- # Get a list of all interfaces
- openfabric['interfaces_all'] = []
- for domain in openfabric['domains_all']:
- interfaces_modified = list(node_changed(conf, base_path + ['domain', domain, 'interface']) +
- conf.list_nodes(' '.join(base_path) + f' domain {domain} interface'))
- openfabric['interfaces_all'].extend(interfaces_modified)
+ return get_frrender_dict(conf)
- if not conf.exists(base_path):
- openfabric.update({'deleted': ''})
-
- return openfabric
+def verify(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'openfabric'):
+ return None
-def verify(openfabric):
- # bail out early - looks like removal from running config
- if not openfabric or 'deleted' in openfabric:
+ openfabric = config_dict['openfabric']
+ if 'deleted' in openfabric:
return None
if 'net' not in openfabric:
@@ -107,31 +89,14 @@ def verify(openfabric):
return None
-def generate(openfabric):
- if not openfabric or 'deleted' in openfabric:
- return None
-
- openfabric['frr_fabricd_config'] = render_to_string('frr/fabricd.frr.j2', openfabric)
+def generate(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().generate(config_dict)
return None
-def apply(openfabric):
- openfabric_daemon = 'fabricd'
-
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
-
- frr_cfg.load_configuration(openfabric_daemon)
- for domain in openfabric['domains_all']:
- frr_cfg.modify_section(f'^router openfabric {domain}', stop_pattern='^exit', remove_stop_mark=True)
-
- for interface in openfabric['interfaces_all']:
- frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True)
-
- if 'frr_fabricd_config' in openfabric:
- frr_cfg.add_before(frr.default_add_before, openfabric['frr_fabricd_config'])
-
- frr_cfg.commit_configuration(openfabric_daemon)
-
+def apply(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
return None
if __name__ == '__main__':
diff --git a/src/conf_mode/protocols_ospf.py b/src/conf_mode/protocols_ospf.py
index 7347c4faa..c06c0aafc 100755
--- a/src/conf_mode/protocols_ospf.py
+++ b/src/conf_mode/protocols_ospf.py
@@ -18,18 +18,17 @@ from sys import exit
from sys import argv
from vyos.config import Config
-from vyos.config import config_dict_merge
-from vyos.configdict import dict_merge
-from vyos.configdict import node_changed
from vyos.configverify import verify_common_route_maps
from vyos.configverify import verify_route_map
from vyos.configverify import verify_interface_exists
from vyos.configverify import verify_access_list
-from vyos.template import render_to_string
+from vyos.configverify import has_frr_protocol_in_dict
+from vyos.frrender import FRRender
+from vyos.frrender import get_frrender_dict
from vyos.utils.dict import dict_search
from vyos.utils.network import get_interface_config
+from vyos.utils.process import is_systemd_service_running
from vyos import ConfigError
-from vyos import frr
from vyos import airbag
airbag.enable()
@@ -39,85 +38,19 @@ def get_config(config=None):
else:
conf = Config()
- vrf = None
- if len(argv) > 1:
- vrf = argv[1]
-
- base_path = ['protocols', 'ospf']
-
- # eqivalent of the C foo ? 'a' : 'b' statement
- base = vrf and ['vrf', 'name', vrf, 'protocols', 'ospf'] or base_path
- ospf = conf.get_config_dict(base, key_mangling=('-', '_'),
- get_first_key=True)
-
- # Assign the name of our VRF context. This MUST be done before the return
- # statement below, else on deletion we will delete the default instance
- # instead of the VRF instance.
- if vrf: ospf['vrf'] = vrf
-
- # FRR has VRF support for different routing daemons. As interfaces belong
- # to VRFs - or the global VRF, we need to check for changed interfaces so
- # that they will be properly rendered for the FRR config. Also this eases
- # removal of interfaces from the running configuration.
- interfaces_removed = node_changed(conf, base + ['interface'])
- if interfaces_removed:
- ospf['interface_removed'] = list(interfaces_removed)
-
- # Bail out early if configuration tree does no longer exist. this must
- # be done after retrieving the list of interfaces to be removed.
- if not conf.exists(base):
- ospf.update({'deleted' : ''})
- return ospf
+ return get_frrender_dict(conf, argv)
- # We have gathered the dict representation of the CLI, but there are default
- # options which we need to update into the dictionary retrived.
- default_values = conf.get_config_defaults(**ospf.kwargs, recursive=True)
-
- # We have to cleanup the default dict, as default values could enable features
- # which are not explicitly enabled on the CLI. Example: default-information
- # originate comes with a default metric-type of 2, which will enable the
- # entire default-information originate tree, even when not set via CLI so we
- # need to check this first and probably drop that key.
- if dict_search('default_information.originate', ospf) is None:
- del default_values['default_information']
- if 'mpls_te' not in ospf:
- del default_values['mpls_te']
- if 'graceful_restart' not in ospf:
- del default_values['graceful_restart']
- for area_num in default_values.get('area', []):
- if dict_search(f'area.{area_num}.area_type.nssa', ospf) is None:
- del default_values['area'][area_num]['area_type']['nssa']
-
- for protocol in ['babel', 'bgp', 'connected', 'isis', 'kernel', 'rip', 'static']:
- if dict_search(f'redistribute.{protocol}', ospf) is None:
- del default_values['redistribute'][protocol]
- if not bool(default_values['redistribute']):
- del default_values['redistribute']
-
- for interface in ospf.get('interface', []):
- # We need to reload the defaults on every pass b/c of
- # hello-multiplier dependency on dead-interval
- # If hello-multiplier is set, we need to remove the default from
- # dead-interval.
- if 'hello_multiplier' in ospf['interface'][interface]:
- del default_values['interface'][interface]['dead_interval']
-
- ospf = config_dict_merge(default_values, ospf)
-
- # We also need some additional information from the config, prefix-lists
- # and route-maps for instance. They will be used in verify().
- #
- # XXX: one MUST always call this without the key_mangling() option! See
- # vyos.configverify.verify_common_route_maps() for more information.
- tmp = conf.get_config_dict(['policy'])
- # Merge policy dict into "regular" config dict
- ospf = dict_merge(tmp, ospf)
+def verify(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'ospf'):
+ return None
- return ospf
+ vrf = None
+ if 'vrf_context' in config_dict:
+ vrf = config_dict['vrf_context']
-def verify(ospf):
- if not ospf:
- return None
+ # eqivalent of the C foo ? 'a' : 'b' statement
+ ospf = vrf and config_dict['vrf']['name'][vrf]['protocols']['ospf'] or config_dict['ospf']
+ ospf['policy'] = config_dict['policy']
verify_common_route_maps(ospf)
@@ -164,8 +97,7 @@ def verify(ospf):
# interface is bound to our requesting VRF. Due to the VyOS
# priorities the interface is bound to the VRF after creation of
# the VRF itself, and before any routing protocol is configured.
- if 'vrf' in ospf:
- vrf = ospf['vrf']
+ if vrf:
tmp = get_interface_config(interface)
if 'master' not in tmp or tmp['master'] != vrf:
raise ConfigError(f'Interface "{interface}" is not a member of VRF "{vrf}"!')
@@ -244,39 +176,14 @@ def verify(ospf):
return None
-def generate(ospf):
- if not ospf or 'deleted' in ospf:
- return None
-
- ospf['frr_ospfd_config'] = render_to_string('frr/ospfd.frr.j2', ospf)
+def generate(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().generate(config_dict)
return None
-def apply(ospf):
- ospf_daemon = 'ospfd'
-
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
-
- # Generate empty helper string which can be ammended to FRR commands, it
- # will be either empty (default VRF) or contain the "vrf <name" statement
- vrf = ''
- if 'vrf' in ospf:
- vrf = ' vrf ' + ospf['vrf']
-
- frr_cfg.load_configuration(ospf_daemon)
- frr_cfg.modify_section(f'^router ospf{vrf}', stop_pattern='^exit', remove_stop_mark=True)
-
- for key in ['interface', 'interface_removed']:
- if key not in ospf:
- continue
- for interface in ospf[key]:
- frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True)
-
- if 'frr_ospfd_config' in ospf:
- frr_cfg.add_before(frr.default_add_before, ospf['frr_ospfd_config'])
-
- frr_cfg.commit_configuration(ospf_daemon)
-
+def apply(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
return None
if __name__ == '__main__':
diff --git a/src/conf_mode/protocols_ospfv3.py b/src/conf_mode/protocols_ospfv3.py
index 60c2a9b16..2563eb7d5 100755
--- a/src/conf_mode/protocols_ospfv3.py
+++ b/src/conf_mode/protocols_ospfv3.py
@@ -18,18 +18,17 @@ from sys import exit
from sys import argv
from vyos.config import Config
-from vyos.config import config_dict_merge
-from vyos.configdict import dict_merge
-from vyos.configdict import node_changed
from vyos.configverify import verify_common_route_maps
from vyos.configverify import verify_route_map
from vyos.configverify import verify_interface_exists
-from vyos.template import render_to_string
+from vyos.configverify import has_frr_protocol_in_dict
+from vyos.frrender import FRRender
+from vyos.frrender import get_frrender_dict
from vyos.ifconfig import Interface
from vyos.utils.dict import dict_search
from vyos.utils.network import get_interface_config
+from vyos.utils.process import is_systemd_service_running
from vyos import ConfigError
-from vyos import frr
from vyos import airbag
airbag.enable()
@@ -39,75 +38,19 @@ def get_config(config=None):
else:
conf = Config()
- vrf = None
- if len(argv) > 1:
- vrf = argv[1]
+ return get_frrender_dict(conf, argv)
+
+def verify(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'ospfv3'):
+ return None
- base_path = ['protocols', 'ospfv3']
+ vrf = None
+ if 'vrf_context' in config_dict:
+ vrf = config_dict['vrf_context']
# eqivalent of the C foo ? 'a' : 'b' statement
- base = vrf and ['vrf', 'name', vrf, 'protocols', 'ospfv3'] or base_path
- ospfv3 = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True)
-
- # Assign the name of our VRF context. This MUST be done before the return
- # statement below, else on deletion we will delete the default instance
- # instead of the VRF instance.
- if vrf: ospfv3['vrf'] = vrf
-
- # FRR has VRF support for different routing daemons. As interfaces belong
- # to VRFs - or the global VRF, we need to check for changed interfaces so
- # that they will be properly rendered for the FRR config. Also this eases
- # removal of interfaces from the running configuration.
- interfaces_removed = node_changed(conf, base + ['interface'])
- if interfaces_removed:
- ospfv3['interface_removed'] = list(interfaces_removed)
-
- # Bail out early if configuration tree does no longer exist. this must
- # be done after retrieving the list of interfaces to be removed.
- if not conf.exists(base):
- ospfv3.update({'deleted' : ''})
- return ospfv3
-
- # We have gathered the dict representation of the CLI, but there are default
- # options which we need to update into the dictionary retrived.
- default_values = conf.get_config_defaults(**ospfv3.kwargs,
- recursive=True)
-
- # We have to cleanup the default dict, as default values could enable features
- # which are not explicitly enabled on the CLI. Example: default-information
- # originate comes with a default metric-type of 2, which will enable the
- # entire default-information originate tree, even when not set via CLI so we
- # need to check this first and probably drop that key.
- if dict_search('default_information.originate', ospfv3) is None:
- del default_values['default_information']
- if 'graceful_restart' not in ospfv3:
- del default_values['graceful_restart']
-
- for protocol in ['babel', 'bgp', 'connected', 'isis', 'kernel', 'ripng', 'static']:
- if dict_search(f'redistribute.{protocol}', ospfv3) is None:
- del default_values['redistribute'][protocol]
- if not bool(default_values['redistribute']):
- del default_values['redistribute']
-
- default_values.pop('interface', {})
-
- # merge in remaining default values
- ospfv3 = config_dict_merge(default_values, ospfv3)
-
- # We also need some additional information from the config, prefix-lists
- # and route-maps for instance. They will be used in verify().
- #
- # XXX: one MUST always call this without the key_mangling() option! See
- # vyos.configverify.verify_common_route_maps() for more information.
- tmp = conf.get_config_dict(['policy'])
- # Merge policy dict into "regular" config dict
- ospfv3 = dict_merge(tmp, ospfv3)
-
- return ospfv3
-
-def verify(ospfv3):
- if not ospfv3:
- return None
+ ospfv3 = vrf and config_dict['vrf']['name'][vrf]['protocols']['ospfv3'] or config_dict['ospfv3']
+ ospfv3['policy'] = config_dict['policy']
verify_common_route_maps(ospfv3)
@@ -137,47 +80,21 @@ def verify(ospfv3):
# interface is bound to our requesting VRF. Due to the VyOS
# priorities the interface is bound to the VRF after creation of
# the VRF itself, and before any routing protocol is configured.
- if 'vrf' in ospfv3:
- vrf = ospfv3['vrf']
+ if vrf:
tmp = get_interface_config(interface)
if 'master' not in tmp or tmp['master'] != vrf:
raise ConfigError(f'Interface "{interface}" is not a member of VRF "{vrf}"!')
return None
-def generate(ospfv3):
- if not ospfv3 or 'deleted' in ospfv3:
- return None
-
- ospfv3['new_frr_config'] = render_to_string('frr/ospf6d.frr.j2', ospfv3)
+def generate(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().generate(config_dict)
return None
-def apply(ospfv3):
- ospf6_daemon = 'ospf6d'
-
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
-
- # Generate empty helper string which can be ammended to FRR commands, it
- # will be either empty (default VRF) or contain the "vrf <name" statement
- vrf = ''
- if 'vrf' in ospfv3:
- vrf = ' vrf ' + ospfv3['vrf']
-
- frr_cfg.load_configuration(ospf6_daemon)
- frr_cfg.modify_section(f'^router ospf6{vrf}', stop_pattern='^exit', remove_stop_mark=True)
-
- for key in ['interface', 'interface_removed']:
- if key not in ospfv3:
- continue
- for interface in ospfv3[key]:
- frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True)
-
- if 'new_frr_config' in ospfv3:
- frr_cfg.add_before(frr.default_add_before, ospfv3['new_frr_config'])
-
- frr_cfg.commit_configuration(ospf6_daemon)
-
+def apply(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
return None
if __name__ == '__main__':
diff --git a/src/conf_mode/protocols_pim.py b/src/conf_mode/protocols_pim.py
index 79294a1f0..632099964 100755
--- a/src/conf_mode/protocols_pim.py
+++ b/src/conf_mode/protocols_pim.py
@@ -22,72 +22,33 @@ from signal import SIGTERM
from sys import exit
from vyos.config import Config
-from vyos.config import config_dict_merge
-from vyos.configdict import node_changed
from vyos.configverify import verify_interface_exists
+from vyos.configverify import has_frr_protocol_in_dict
+from vyos.frrender import FRRender
+from vyos.frrender import get_frrender_dict
+from vyos.frrender import pim_daemon
+from vyos.utils.process import is_systemd_service_running
from vyos.utils.process import process_named_running
from vyos.utils.process import call
-from vyos.template import render_to_string
from vyos import ConfigError
-from vyos import frr
from vyos import airbag
airbag.enable()
-RESERVED_MC_NET = '224.0.0.0/24'
-
-
def get_config(config=None):
if config:
conf = config
else:
conf = Config()
- base = ['protocols', 'pim']
-
- pim = conf.get_config_dict(base, key_mangling=('-', '_'),
- get_first_key=True, no_tag_node_value_mangle=True)
-
- # We can not run both IGMP proxy and PIM at the same time - get IGMP
- # proxy status
- if conf.exists(['protocols', 'igmp-proxy']):
- pim.update({'igmp_proxy_enabled' : {}})
-
- # FRR has VRF support for different routing daemons. As interfaces belong
- # to VRFs - or the global VRF, we need to check for changed interfaces so
- # that they will be properly rendered for the FRR config. Also this eases
- # removal of interfaces from the running configuration.
- interfaces_removed = node_changed(conf, base + ['interface'])
- if interfaces_removed:
- pim['interface_removed'] = list(interfaces_removed)
-
- # Bail out early if configuration tree does no longer exist. this must
- # be done after retrieving the list of interfaces to be removed.
- if not conf.exists(base):
- pim.update({'deleted' : ''})
- return pim
-
- # We have gathered the dict representation of the CLI, but there are default
- # options which we need to update into the dictionary retrived.
- default_values = conf.get_config_defaults(**pim.kwargs, recursive=True)
-
- # We have to cleanup the default dict, as default values could enable features
- # which are not explicitly enabled on the CLI. Example: default-information
- # originate comes with a default metric-type of 2, which will enable the
- # entire default-information originate tree, even when not set via CLI so we
- # need to check this first and probably drop that key.
- for interface in pim.get('interface', []):
- # We need to reload the defaults on every pass b/c of
- # hello-multiplier dependency on dead-interval
- # If hello-multiplier is set, we need to remove the default from
- # dead-interval.
- if 'igmp' not in pim['interface'][interface]:
- del default_values['interface'][interface]['igmp']
-
- pim = config_dict_merge(default_values, pim)
- return pim
-
-def verify(pim):
- if not pim or 'deleted' in pim:
+ return get_frrender_dict(conf)
+
+def verify(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'pim'):
+ return None
+
+ pim = config_dict['pim']
+
+ if 'deleted' in pim:
return None
if 'igmp_proxy_enabled' in pim:
@@ -96,6 +57,7 @@ def verify(pim):
if 'interface' not in pim:
raise ConfigError('PIM require defined interfaces!')
+ RESERVED_MC_NET = '224.0.0.0/24'
for interface, interface_config in pim['interface'].items():
verify_interface_exists(pim, interface)
@@ -124,41 +86,26 @@ def verify(pim):
raise ConfigError(f'{pim_base_error} must be unique!')
unique.append(gr_addr)
-def generate(pim):
- if not pim or 'deleted' in pim:
- return None
- pim['frr_pimd_config'] = render_to_string('frr/pimd.frr.j2', pim)
+def generate(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().generate(config_dict)
return None
-def apply(pim):
- pim_daemon = 'pimd'
- pim_pid = process_named_running(pim_daemon)
-
- if not pim or 'deleted' in pim:
- if 'deleted' in pim:
- os.kill(int(pim_pid), SIGTERM)
+def apply(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'pim'):
+ return None
+ pim_pid = process_named_running(pim_daemon)
+ pim = config_dict['pim']
+ if 'deleted' in pim:
+ os.kill(int(pim_pid), SIGTERM)
return None
if not pim_pid:
call('/usr/lib/frr/pimd -d -F traditional --daemon -A 127.0.0.1')
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
-
- frr_cfg.load_configuration(pim_daemon)
- frr_cfg.modify_section(f'^ip pim')
- frr_cfg.modify_section(f'^ip igmp')
-
- for key in ['interface', 'interface_removed']:
- if key not in pim:
- continue
- for interface in pim[key]:
- frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True)
-
- if 'frr_pimd_config' in pim:
- frr_cfg.add_before(frr.default_add_before, pim['frr_pimd_config'])
- frr_cfg.commit_configuration(pim_daemon)
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
return None
if __name__ == '__main__':
diff --git a/src/conf_mode/protocols_pim6.py b/src/conf_mode/protocols_pim6.py
index 581ffe238..03a79139a 100755
--- a/src/conf_mode/protocols_pim6.py
+++ b/src/conf_mode/protocols_pim6.py
@@ -19,12 +19,12 @@ from ipaddress import IPv6Network
from sys import exit
from vyos.config import Config
-from vyos.config import config_dict_merge
-from vyos.configdict import node_changed
+from vyos.configverify import has_frr_protocol_in_dict
from vyos.configverify import verify_interface_exists
-from vyos.template import render_to_string
+from vyos.utils.process import is_systemd_service_running
+from vyos.frrender import FRRender
+from vyos.frrender import get_frrender_dict
from vyos import ConfigError
-from vyos import frr
from vyos import airbag
airbag.enable()
@@ -33,34 +33,15 @@ def get_config(config=None):
conf = config
else:
conf = Config()
- base = ['protocols', 'pim6']
- pim6 = conf.get_config_dict(base, key_mangling=('-', '_'),
- get_first_key=True, with_recursive_defaults=True)
+ return get_frrender_dict(conf)
- # FRR has VRF support for different routing daemons. As interfaces belong
- # to VRFs - or the global VRF, we need to check for changed interfaces so
- # that they will be properly rendered for the FRR config. Also this eases
- # removal of interfaces from the running configuration.
- interfaces_removed = node_changed(conf, base + ['interface'])
- if interfaces_removed:
- pim6['interface_removed'] = list(interfaces_removed)
+def verify(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'pim6'):
+ return None
- # Bail out early if configuration tree does no longer exist. this must
- # be done after retrieving the list of interfaces to be removed.
- if not conf.exists(base):
- pim6.update({'deleted' : ''})
- return pim6
-
- # We have gathered the dict representation of the CLI, but there are default
- # options which we need to update into the dictionary retrived.
- default_values = conf.get_config_defaults(**pim6.kwargs, recursive=True)
-
- pim6 = config_dict_merge(default_values, pim6)
- return pim6
-
-def verify(pim6):
- if not pim6 or 'deleted' in pim6:
- return
+ pim6 = config_dict['pim6']
+ if 'deleted' in pim6:
+ return None
for interface, interface_config in pim6.get('interface', {}).items():
verify_interface_exists(pim6, interface)
@@ -94,32 +75,14 @@ def verify(pim6):
raise ConfigError(f'{pim_base_error} must be unique!')
unique.append(gr_addr)
-def generate(pim6):
- if not pim6 or 'deleted' in pim6:
- return
- pim6['new_frr_config'] = render_to_string('frr/pim6d.frr.j2', pim6)
+def generate(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().generate(config_dict)
return None
-def apply(pim6):
- if pim6 is None:
- return
-
- pim6_daemon = 'pim6d'
-
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
-
- frr_cfg.load_configuration(pim6_daemon)
-
- for key in ['interface', 'interface_removed']:
- if key not in pim6:
- continue
- for interface in pim6[key]:
- frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True)
-
- if 'new_frr_config' in pim6:
- frr_cfg.add_before(frr.default_add_before, pim6['new_frr_config'])
- frr_cfg.commit_configuration(pim6_daemon)
+def apply(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
return None
if __name__ == '__main__':
diff --git a/src/conf_mode/protocols_rip.py b/src/conf_mode/protocols_rip.py
index 9afac544d..ec9dfbb8b 100755
--- a/src/conf_mode/protocols_rip.py
+++ b/src/conf_mode/protocols_rip.py
@@ -17,15 +17,15 @@
from sys import exit
from vyos.config import Config
-from vyos.configdict import dict_merge
-from vyos.configdict import node_changed
+from vyos.configverify import has_frr_protocol_in_dict
from vyos.configverify import verify_common_route_maps
from vyos.configverify import verify_access_list
from vyos.configverify import verify_prefix_list
+from vyos.frrender import FRRender
+from vyos.frrender import get_frrender_dict
from vyos.utils.dict import dict_search
-from vyos.template import render_to_string
+from vyos.utils.process import is_systemd_service_running
from vyos import ConfigError
-from vyos import frr
from vyos import airbag
airbag.enable()
@@ -34,41 +34,16 @@ def get_config(config=None):
conf = config
else:
conf = Config()
- base = ['protocols', 'rip']
- rip = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True)
- # FRR has VRF support for different routing daemons. As interfaces belong
- # to VRFs - or the global VRF, we need to check for changed interfaces so
- # that they will be properly rendered for the FRR config. Also this eases
- # removal of interfaces from the running configuration.
- interfaces_removed = node_changed(conf, base + ['interface'])
- if interfaces_removed:
- rip['interface_removed'] = list(interfaces_removed)
+ return get_frrender_dict(conf)
- # Bail out early if configuration tree does not exist
- if not conf.exists(base):
- rip.update({'deleted' : ''})
- return rip
-
- # We have gathered the dict representation of the CLI, but there are default
- # options which we need to update into the dictionary retrived.
- rip = conf.merge_defaults(rip, recursive=True)
-
- # We also need some additional information from the config, prefix-lists
- # and route-maps for instance. They will be used in verify().
- #
- # XXX: one MUST always call this without the key_mangling() option! See
- # vyos.configverify.verify_common_route_maps() for more information.
- tmp = conf.get_config_dict(['policy'])
- # Merge policy dict into "regular" config dict
- rip = dict_merge(tmp, rip)
-
- return rip
-
-def verify(rip):
- if not rip:
+def verify(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'rip'):
return None
+ rip = config_dict['rip']
+ rip['policy'] = config_dict['policy']
+
verify_common_route_maps(rip)
acl_in = dict_search('distribute_list.access_list.in', rip)
@@ -93,39 +68,14 @@ def verify(rip):
raise ConfigError(f'You can not have "split-horizon poison-reverse" enabled ' \
f'with "split-horizon disable" for "{interface}"!')
-def generate(rip):
- if not rip or 'deleted' in rip:
- return None
-
- rip['new_frr_config'] = render_to_string('frr/ripd.frr.j2', rip)
+def generate(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().generate(config_dict)
return None
-def apply(rip):
- rip_daemon = 'ripd'
- zebra_daemon = 'zebra'
-
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
-
- # The route-map used for the FIB (zebra) is part of the zebra daemon
- frr_cfg.load_configuration(zebra_daemon)
- frr_cfg.modify_section('^ip protocol rip route-map [-a-zA-Z0-9.]+', stop_pattern='(\s|!)')
- frr_cfg.commit_configuration(zebra_daemon)
-
- frr_cfg.load_configuration(rip_daemon)
- frr_cfg.modify_section('^key chain \S+', stop_pattern='^exit', remove_stop_mark=True)
- frr_cfg.modify_section('^router rip', stop_pattern='^exit', remove_stop_mark=True)
-
- for key in ['interface', 'interface_removed']:
- if key not in rip:
- continue
- for interface in rip[key]:
- frr_cfg.modify_section(f'^interface {interface}', stop_pattern='^exit', remove_stop_mark=True)
-
- if 'new_frr_config' in rip:
- frr_cfg.add_before(frr.default_add_before, rip['new_frr_config'])
- frr_cfg.commit_configuration(rip_daemon)
-
+def apply(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
return None
if __name__ == '__main__':
diff --git a/src/conf_mode/protocols_ripng.py b/src/conf_mode/protocols_ripng.py
index 23416ff96..9a9ac8ec8 100755
--- a/src/conf_mode/protocols_ripng.py
+++ b/src/conf_mode/protocols_ripng.py
@@ -17,14 +17,15 @@
from sys import exit
from vyos.config import Config
-from vyos.configdict import dict_merge
+from vyos.configverify import has_frr_protocol_in_dict
from vyos.configverify import verify_common_route_maps
from vyos.configverify import verify_access_list
from vyos.configverify import verify_prefix_list
+from vyos.frrender import FRRender
+from vyos.frrender import get_frrender_dict
from vyos.utils.dict import dict_search
-from vyos.template import render_to_string
+from vyos.utils.process import is_systemd_service_running
from vyos import ConfigError
-from vyos import frr
from vyos import airbag
airbag.enable()
@@ -33,32 +34,16 @@ def get_config(config=None):
conf = config
else:
conf = Config()
- base = ['protocols', 'ripng']
- ripng = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True)
- # Bail out early if configuration tree does not exist
- if not conf.exists(base):
- return ripng
+ return get_frrender_dict(conf)
- # We have gathered the dict representation of the CLI, but there are default
- # options which we need to update into the dictionary retrived.
- ripng = conf.merge_defaults(ripng, recursive=True)
-
- # We also need some additional information from the config, prefix-lists
- # and route-maps for instance. They will be used in verify().
- #
- # XXX: one MUST always call this without the key_mangling() option! See
- # vyos.configverify.verify_common_route_maps() for more information.
- tmp = conf.get_config_dict(['policy'])
- # Merge policy dict into "regular" config dict
- ripng = dict_merge(tmp, ripng)
-
- return ripng
-
-def verify(ripng):
- if not ripng:
+def verify(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'ripng'):
return None
+ ripng = config_dict['ripng']
+ ripng['policy'] = config_dict['policy']
+
verify_common_route_maps(ripng)
acl_in = dict_search('distribute_list.access_list.in', ripng)
@@ -83,34 +68,14 @@ def verify(ripng):
raise ConfigError(f'You can not have "split-horizon poison-reverse" enabled ' \
f'with "split-horizon disable" for "{interface}"!')
-def generate(ripng):
- if not ripng:
- ripng['new_frr_config'] = ''
- return None
-
- ripng['new_frr_config'] = render_to_string('frr/ripngd.frr.j2', ripng)
+def generate(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().generate(config_dict)
return None
-def apply(ripng):
- ripng_daemon = 'ripngd'
- zebra_daemon = 'zebra'
-
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
-
- # The route-map used for the FIB (zebra) is part of the zebra daemon
- frr_cfg.load_configuration(zebra_daemon)
- frr_cfg.modify_section('^ipv6 protocol ripng route-map [-a-zA-Z0-9.]+', stop_pattern='(\s|!)')
- frr_cfg.commit_configuration(zebra_daemon)
-
- frr_cfg.load_configuration(ripng_daemon)
- frr_cfg.modify_section('key chain \S+', stop_pattern='^exit', remove_stop_mark=True)
- frr_cfg.modify_section('interface \S+', stop_pattern='^exit', remove_stop_mark=True)
- frr_cfg.modify_section('^router ripng', stop_pattern='^exit', remove_stop_mark=True)
- if 'new_frr_config' in ripng:
- frr_cfg.add_before(frr.default_add_before, ripng['new_frr_config'])
- frr_cfg.commit_configuration(ripng_daemon)
-
+def apply(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
return None
if __name__ == '__main__':
diff --git a/src/conf_mode/protocols_rpki.py b/src/conf_mode/protocols_rpki.py
index a59ecf3e4..ef0250e3d 100755
--- a/src/conf_mode/protocols_rpki.py
+++ b/src/conf_mode/protocols_rpki.py
@@ -20,13 +20,15 @@ from glob import glob
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.pki import wrap_openssh_public_key
from vyos.pki import wrap_openssh_private_key
-from vyos.template import render_to_string
from vyos.utils.dict import dict_search_args
from vyos.utils.file import write_file
+from vyos.utils.process import is_systemd_service_running
from vyos import ConfigError
-from vyos import frr
from vyos import airbag
airbag.enable()
@@ -37,25 +39,14 @@ def get_config(config=None):
conf = config
else:
conf = Config()
- base = ['protocols', 'rpki']
+ return get_frrender_dict(conf)
- rpki = conf.get_config_dict(base, key_mangling=('-', '_'),
- get_first_key=True, with_pki=True)
- # Bail out early if configuration tree does not exist
- if not conf.exists(base):
- rpki.update({'deleted' : ''})
- return rpki
-
- # We have gathered the dict representation of the CLI, but there are default
- # options which we need to update into the dictionary retrived.
- rpki = conf.merge_defaults(rpki, recursive=True)
-
- return rpki
-
-def verify(rpki):
- if not rpki:
+def verify(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'rpki'):
return None
+ rpki = config_dict['rpki']
+
if 'cache' in rpki:
preferences = []
for peer, peer_config in rpki['cache'].items():
@@ -81,12 +72,14 @@ def verify(rpki):
return None
-def generate(rpki):
+def generate(config_dict):
for key in glob(f'{rpki_ssh_key_base}*'):
os.unlink(key)
- if not rpki:
- return
+ if not has_frr_protocol_in_dict(config_dict, 'rpki'):
+ return None
+
+ rpki = config_dict['rpki']
if 'cache' in rpki:
for cache, cache_config in rpki['cache'].items():
@@ -102,21 +95,13 @@ def generate(rpki):
write_file(cache_config['ssh']['public_key_file'], wrap_openssh_public_key(public_key_data, public_key_type))
write_file(cache_config['ssh']['private_key_file'], wrap_openssh_private_key(private_key_data))
- rpki['new_frr_config'] = render_to_string('frr/rpki.frr.j2', rpki)
-
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().generate(config_dict)
return None
-def apply(rpki):
- bgp_daemon = 'bgpd'
-
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
- frr_cfg.load_configuration(bgp_daemon)
- frr_cfg.modify_section('^rpki', stop_pattern='^exit', remove_stop_mark=True)
- if 'new_frr_config' in rpki:
- frr_cfg.add_before(frr.default_add_before, rpki['new_frr_config'])
-
- frr_cfg.commit_configuration(bgp_daemon)
+def apply(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
return None
if __name__ == '__main__':
diff --git a/src/conf_mode/protocols_segment-routing.py b/src/conf_mode/protocols_segment-routing.py
index b36c2ca11..f2bd42a79 100755
--- a/src/conf_mode/protocols_segment-routing.py
+++ b/src/conf_mode/protocols_segment-routing.py
@@ -17,12 +17,15 @@
from sys import exit
from vyos.config import Config
-from vyos.configdict import node_changed
-from vyos.template import render_to_string
+from vyos.configdict import list_diff
+from vyos.configverify import has_frr_protocol_in_dict
+from vyos.frrender import FRRender
+from vyos.frrender import get_frrender_dict
+from vyos.ifconfig import Section
from vyos.utils.dict import dict_search
+from vyos.utils.process import is_systemd_service_running
from vyos.utils.system import sysctl_write
from vyos import ConfigError
-from vyos import frr
from vyos import airbag
airbag.enable()
@@ -32,25 +35,14 @@ def get_config(config=None):
else:
conf = Config()
- base = ['protocols', 'segment-routing']
- sr = conf.get_config_dict(base, key_mangling=('-', '_'),
- get_first_key=True,
- no_tag_node_value_mangle=True,
- with_recursive_defaults=True)
+ return get_frrender_dict(conf)
- # FRR has VRF support for different routing daemons. As interfaces belong
- # to VRFs - or the global VRF, we need to check for changed interfaces so
- # that they will be properly rendered for the FRR config. Also this eases
- # removal of interfaces from the running configuration.
- interfaces_removed = node_changed(conf, base + ['interface'])
- if interfaces_removed:
- sr['interface_removed'] = list(interfaces_removed)
+def verify(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'segment_routing'):
+ return None
- import pprint
- pprint.pprint(sr)
- return sr
+ sr = config_dict['segment_routing']
-def verify(sr):
if 'srv6' in sr:
srv6_enable = False
if 'interface' in sr:
@@ -62,47 +54,43 @@ def verify(sr):
raise ConfigError('SRv6 should be enabled on at least one interface!')
return None
-def generate(sr):
- if not sr:
- return None
-
- sr['new_frr_config'] = render_to_string('frr/zebra.segment_routing.frr.j2', sr)
+def generate(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().generate(config_dict)
return None
-def apply(sr):
- zebra_daemon = 'zebra'
+def apply(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'segment_routing'):
+ return None
- if 'interface_removed' in sr:
- for interface in sr['interface_removed']:
- # Disable processing of IPv6-SR packets
- sysctl_write(f'net.ipv6.conf.{interface}.seg6_enabled', '0')
+ sr = config_dict['segment_routing']
+
+ current_interfaces = Section.interfaces()
+ sr_interfaces = list(sr.get('interface', {}).keys())
- if 'interface' in sr:
- for interface, interface_config in sr['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')
- # 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')
- elif tmp == 'drop':
- sysctl_write(f'net.ipv6.conf.{interface}.seg6_require_hmac', '1')
- elif tmp == 'ignore':
- sysctl_write(f'net.ipv6.conf.{interface}.seg6_require_hmac', '-1')
- else:
- sysctl_write(f'net.ipv6.conf.{interface}.seg6_enabled', '0')
+ 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')
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
- frr_cfg.load_configuration(zebra_daemon)
- frr_cfg.modify_section(r'^segment-routing')
- if 'new_frr_config' in sr:
- frr_cfg.add_before(frr.default_add_before, sr['new_frr_config'])
- frr_cfg.commit_configuration(zebra_daemon)
+ 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')
+ # 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')
+ elif tmp == 'drop':
+ sysctl_write(f'net.ipv6.conf.{interface}.seg6_require_hmac', '1')
+ elif tmp == 'ignore':
+ sysctl_write(f'net.ipv6.conf.{interface}.seg6_require_hmac', '-1')
+ else:
+ sysctl_write(f'net.ipv6.conf.{interface}.seg6_enabled', '0')
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
return None
if __name__ == '__main__':
diff --git a/src/conf_mode/protocols_static.py b/src/conf_mode/protocols_static.py
index 430cc69d4..1b9e51167 100755
--- a/src/conf_mode/protocols_static.py
+++ b/src/conf_mode/protocols_static.py
@@ -14,19 +14,19 @@
# 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 ipaddress import IPv4Network
from sys import exit
from sys import argv
from vyos.config import Config
-from vyos.configdict import dict_merge
-from vyos.configdict import get_dhcp_interfaces
-from vyos.configdict import get_pppoe_interfaces
+from vyos.configverify import has_frr_protocol_in_dict
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.process import is_systemd_service_running
from vyos.template import render
-from vyos.template import render_to_string
from vyos import ConfigError
-from vyos import frr
from vyos import airbag
airbag.enable()
@@ -38,36 +38,20 @@ def get_config(config=None):
else:
conf = Config()
+ return get_frrender_dict(conf, argv)
+
+def verify(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'static'):
+ return None
+
vrf = None
- if len(argv) > 1:
- vrf = argv[1]
+ if 'vrf_context' in config_dict:
+ vrf = config_dict['vrf_context']
- base_path = ['protocols', 'static']
# eqivalent of the C foo ? 'a' : 'b' statement
- base = vrf and ['vrf', 'name', vrf, 'protocols', 'static'] or base_path
- static = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True)
-
- # Assign the name of our VRF context
- if vrf: static['vrf'] = vrf
-
- # We also need some additional information from the config, prefix-lists
- # and route-maps for instance. They will be used in verify().
- #
- # XXX: one MUST always call this without the key_mangling() option! See
- # vyos.configverify.verify_common_route_maps() for more information.
- tmp = conf.get_config_dict(['policy'])
- # Merge policy dict into "regular" config dict
- static = dict_merge(tmp, static)
-
- # T3680 - get a list of all interfaces currently configured to use DHCP
- tmp = get_dhcp_interfaces(conf, vrf)
- if tmp: static.update({'dhcp' : tmp})
- tmp = get_pppoe_interfaces(conf, vrf)
- if tmp: static.update({'pppoe' : tmp})
-
- return static
-
-def verify(static):
+ static = vrf and config_dict['vrf']['name'][vrf]['protocols']['static'] or config_dict['static']
+ static['policy'] = config_dict['policy']
+
verify_common_route_maps(static)
for route in ['route', 'route6']:
@@ -90,35 +74,34 @@ def verify(static):
raise ConfigError(f'Can not use both blackhole and reject for '\
f'prefix "{prefix}"!')
+ if 'multicast' in static and 'route' in static['multicast']:
+ for prefix, prefix_options in static['multicast']['route'].items():
+ if not IPv4Network(prefix).is_multicast:
+ raise ConfigError(f'{prefix} is not a multicast network!')
+
return None
-def generate(static):
- if not static:
+def generate(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'static'):
return None
- # Put routing table names in /etc/iproute2/rt_tables
- render(config_file, 'iproute2/static.conf.j2', static)
- static['new_frr_config'] = render_to_string('frr/staticd.frr.j2', static)
- return None
-
-def apply(static):
- static_daemon = 'staticd'
+ vrf = None
+ if 'vrf_context' in config_dict:
+ vrf = config_dict['vrf_context']
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
- frr_cfg.load_configuration(static_daemon)
+ # eqivalent of the C foo ? 'a' : 'b' statement
+ static = vrf and config_dict['vrf']['name'][vrf]['protocols']['static'] or config_dict['static']
- if 'vrf' in static:
- vrf = static['vrf']
- frr_cfg.modify_section(f'^vrf {vrf}', stop_pattern='^exit-vrf', remove_stop_mark=True)
- else:
- frr_cfg.modify_section(r'^ip route .*')
- frr_cfg.modify_section(r'^ipv6 route .*')
+ # Put routing table names in /etc/iproute2/rt_tables
+ render(config_file, 'iproute2/static.conf.j2', static)
- if 'new_frr_config' in static:
- frr_cfg.add_before(frr.default_add_before, static['new_frr_config'])
- frr_cfg.commit_configuration(static_daemon)
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().generate(config_dict)
+ return None
+def apply(config_dict):
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
return None
if __name__ == '__main__':
diff --git a/src/conf_mode/protocols_static_multicast.py b/src/conf_mode/protocols_static_multicast.py
deleted file mode 100755
index c8894fd41..000000000
--- a/src/conf_mode/protocols_static_multicast.py
+++ /dev/null
@@ -1,135 +0,0 @@
-#!/usr/bin/env python3
-#
-# Copyright (C) 2020-2024 VyOS maintainers and contributors
-#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# 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 ipaddress import IPv4Address
-from sys import exit
-
-from vyos import ConfigError
-from vyos import frr
-from vyos.config import Config
-from vyos.template import render_to_string
-
-from vyos import airbag
-airbag.enable()
-
-config_file = r'/tmp/static_mcast.frr'
-
-# Get configuration for static multicast route
-def get_config(config=None):
- if config:
- conf = config
- else:
- conf = Config()
- mroute = {
- 'old_mroute' : {},
- 'mroute' : {}
- }
-
- base_path = "protocols static multicast"
-
- if not (conf.exists(base_path) or conf.exists_effective(base_path)):
- return None
-
- conf.set_level(base_path)
-
- # Get multicast effective routes
- for route in conf.list_effective_nodes('route'):
- mroute['old_mroute'][route] = {}
- for next_hop in conf.list_effective_nodes('route {0} next-hop'.format(route)):
- mroute['old_mroute'][route].update({
- next_hop : conf.return_value('route {0} next-hop {1} distance'.format(route, next_hop))
- })
-
- # Get multicast effective interface-routes
- for route in conf.list_effective_nodes('interface-route'):
- if not route in mroute['old_mroute']:
- mroute['old_mroute'][route] = {}
- for next_hop in conf.list_effective_nodes('interface-route {0} next-hop-interface'.format(route)):
- mroute['old_mroute'][route].update({
- next_hop : conf.return_value('interface-route {0} next-hop-interface {1} distance'.format(route, next_hop))
- })
-
- # Get multicast routes
- for route in conf.list_nodes('route'):
- mroute['mroute'][route] = {}
- for next_hop in conf.list_nodes('route {0} next-hop'.format(route)):
- mroute['mroute'][route].update({
- next_hop : conf.return_value('route {0} next-hop {1} distance'.format(route, next_hop))
- })
-
- # Get multicast interface-routes
- for route in conf.list_nodes('interface-route'):
- if not route in mroute['mroute']:
- mroute['mroute'][route] = {}
- for next_hop in conf.list_nodes('interface-route {0} next-hop-interface'.format(route)):
- mroute['mroute'][route].update({
- next_hop : conf.return_value('interface-route {0} next-hop-interface {1} distance'.format(route, next_hop))
- })
-
- return mroute
-
-def verify(mroute):
- if mroute is None:
- return None
-
- for mcast_route in mroute['mroute']:
- route = mcast_route.split('/')
- if IPv4Address(route[0]) < IPv4Address('224.0.0.0'):
- raise ConfigError(f'{mcast_route} not a multicast network')
-
-
-def generate(mroute):
- if mroute is None:
- return None
-
- mroute['new_frr_config'] = render_to_string('frr/static_mcast.frr.j2', mroute)
- return None
-
-
-def apply(mroute):
- if mroute is None:
- return None
- static_daemon = 'staticd'
-
- frr_cfg = frr.FRRConfig()
- frr_cfg.load_configuration(static_daemon)
-
- if 'old_mroute' in mroute:
- for route_gr in mroute['old_mroute']:
- for nh in mroute['old_mroute'][route_gr]:
- if mroute['old_mroute'][route_gr][nh]:
- frr_cfg.modify_section(f'^ip mroute {route_gr} {nh} {mroute["old_mroute"][route_gr][nh]}')
- else:
- frr_cfg.modify_section(f'^ip mroute {route_gr} {nh}')
-
- if 'new_frr_config' in mroute:
- frr_cfg.add_before(frr.default_add_before, mroute['new_frr_config'])
-
- frr_cfg.commit_configuration(static_daemon)
-
- return None
-
-
-if __name__ == '__main__':
- try:
- c = get_config()
- verify(c)
- generate(c)
- apply(c)
- except ConfigError as e:
- print(e)
- exit(1)
diff --git a/src/conf_mode/service_console-server.py b/src/conf_mode/service_console-server.py
index b112add3f..b83c6dfb1 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-2021 VyOS maintainers and contributors
+# Copyright (C) 2018-2025 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
@@ -98,6 +98,12 @@ def generate(proxy):
return None
def apply(proxy):
+ if not os.path.exists('/etc/dropbear/dropbear_rsa_host_key'):
+ call('dropbearkey -t rsa -s 4096 -f /etc/dropbear/dropbear_rsa_host_key')
+
+ if not os.path.exists('/etc/dropbear/dropbear_ecdsa_host_key'):
+ call('dropbearkey -t ecdsa -f /etc/dropbear/dropbear_ecdsa_host_key')
+
call('systemctl daemon-reload')
call('systemctl stop dropbear@*.service conserver-server.service')
diff --git a/src/conf_mode/service_dhcp-server.py b/src/conf_mode/service_dhcp-server.py
index 9c59aa63d..5a729af74 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-2024 VyOS maintainers and contributors
+# Copyright (C) 2018-2025 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
@@ -38,6 +38,7 @@ from vyos.utils.network import is_subnet_connected
from vyos.utils.network import is_addr_assigned
from vyos import ConfigError
from vyos import airbag
+
airbag.enable()
ctrl_config_file = '/run/kea/kea-ctrl-agent.conf'
@@ -45,13 +46,13 @@ 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*'
-systemd_override = r'/run/systemd/system/kea-ctrl-agent.service.d/10-override.conf'
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 dhcp_slice_range(exclude_list, range_dict):
"""
This function is intended to slice a DHCP range. What does it mean?
@@ -74,19 +75,17 @@ def dhcp_slice_range(exclude_list, range_dict):
range_last_exclude = ''
for e in exclude_list:
- if (ip_address(e) >= ip_address(range_start)) and \
- (ip_address(e) <= ip_address(range_stop)):
+ if (ip_address(e) >= ip_address(range_start)) and (
+ ip_address(e) <= ip_address(range_stop)
+ ):
range_last_exclude = e
for e in exclude_list:
- if (ip_address(e) >= ip_address(range_start)) and \
- (ip_address(e) <= ip_address(range_stop)):
-
+ if (ip_address(e) >= ip_address(range_start)) and (
+ ip_address(e) <= ip_address(range_stop)
+ ):
# Build new address range ending one address before exclude address
- r = {
- 'start' : range_start,
- 'stop' : str(ip_address(e) -1)
- }
+ r = {'start': range_start, 'stop': str(ip_address(e) - 1)}
if 'option' in range_dict:
r['option'] = range_dict['option']
@@ -104,10 +103,7 @@ def dhcp_slice_range(exclude_list, range_dict):
# Take care of last IP address range spanning from the last exclude
# address (+1) to the end of the initial configured range
if ip_address(e) == ip_address(range_last_exclude):
- r = {
- 'start': str(ip_address(e) + 1),
- 'stop': str(range_stop)
- }
+ r = {'start': str(ip_address(e) + 1), 'stop': str(range_stop)}
if 'option' in range_dict:
r['option'] = range_dict['option']
@@ -115,14 +111,15 @@ def dhcp_slice_range(exclude_list, range_dict):
if not (ip_address(r['start']) > ip_address(r['stop'])):
output.append(r)
else:
- # if the excluded address was not part of the range, we simply return
- # the entire ranga again
- if not range_last_exclude:
- if range_dict not in output:
- output.append(range_dict)
+ # if the excluded address was not part of the range, we simply return
+ # the entire ranga again
+ if not range_last_exclude:
+ if range_dict not in output:
+ output.append(range_dict)
return output
+
def get_config(config=None):
if config:
conf = config
@@ -132,10 +129,13 @@ def get_config(config=None):
if not conf.exists(base):
return None
- dhcp = conf.get_config_dict(base, key_mangling=('-', '_'),
- no_tag_node_value_mangle=True,
- get_first_key=True,
- with_recursive_defaults=True)
+ dhcp = conf.get_config_dict(
+ base,
+ key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True,
+ get_first_key=True,
+ with_recursive_defaults=True,
+ )
if 'shared_network_name' in dhcp:
for network, network_config in dhcp['shared_network_name'].items():
@@ -147,22 +147,31 @@ def get_config(config=None):
new_range_id = 0
new_range_dict = {}
for r, r_config in subnet_config['range'].items():
- for slice in dhcp_slice_range(subnet_config['exclude'], r_config):
- new_range_dict.update({new_range_id : slice})
- new_range_id +=1
+ for slice in dhcp_slice_range(
+ subnet_config['exclude'], r_config
+ ):
+ new_range_dict.update({new_range_id: slice})
+ new_range_id += 1
dhcp['shared_network_name'][network]['subnet'][subnet].update(
- {'range' : new_range_dict})
+ {'range': new_range_dict}
+ )
if len(dhcp['high_availability']) == 1:
## only default value for mode is set, need to remove ha node
del dhcp['high_availability']
else:
if dict_search('high_availability.certificate', dhcp):
- dhcp['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True)
+ dhcp['pki'] = conf.get_config_dict(
+ ['pki'],
+ key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True,
+ )
return dhcp
+
def verify(dhcp):
# bail out early - looks like removal from running config
if not dhcp or 'disable' in dhcp:
@@ -170,13 +179,15 @@ def verify(dhcp):
# If DHCP is enabled we need one share-network
if 'shared_network_name' not in dhcp:
- raise ConfigError('No DHCP shared networks configured.\n' \
- 'At least one DHCP shared network must be configured.')
+ raise ConfigError(
+ 'No DHCP shared networks configured.\n'
+ 'At least one DHCP shared network must be configured.'
+ )
# Inspect shared-network/subnet
listen_ok = False
subnets = []
- shared_networks = len(dhcp['shared_network_name'])
+ shared_networks = len(dhcp['shared_network_name'])
disabled_shared_networks = 0
subnet_ids = []
@@ -187,12 +198,16 @@ def verify(dhcp):
disabled_shared_networks += 1
if 'subnet' not in network_config:
- raise ConfigError(f'No subnets defined for {network}. At least one\n' \
- 'lease subnet must be configured.')
+ raise ConfigError(
+ f'No subnets defined for {network}. At least one\n'
+ 'lease subnet must be configured.'
+ )
for subnet, subnet_config in network_config['subnet'].items():
if 'subnet_id' not in subnet_config:
- raise ConfigError(f'Unique subnet ID not specified for subnet "{subnet}"')
+ raise ConfigError(
+ f'Unique subnet ID not specified for subnet "{subnet}"'
+ )
if subnet_config['subnet_id'] in subnet_ids:
raise ConfigError(f'Subnet ID for subnet "{subnet}" is not unique')
@@ -203,32 +218,46 @@ def verify(dhcp):
if 'static_route' in subnet_config:
for route, route_option in subnet_config['static_route'].items():
if 'next_hop' not in route_option:
- raise ConfigError(f'DHCP static-route "{route}" requires router to be defined!')
+ raise ConfigError(
+ f'DHCP static-route "{route}" requires router to be defined!'
+ )
# Check if DHCP address range is inside configured subnet declaration
if 'range' in subnet_config:
networks = []
for range, range_config in subnet_config['range'].items():
if not {'start', 'stop'} <= set(range_config):
- raise ConfigError(f'DHCP range "{range}" start and stop address must be defined!')
+ raise ConfigError(
+ f'DHCP range "{range}" start and stop address must be defined!'
+ )
# Start/Stop address must be inside network
for key in ['start', 'stop']:
if ip_address(range_config[key]) not in ip_network(subnet):
- raise ConfigError(f'DHCP range "{range}" {key} address not within shared-network "{network}, {subnet}"!')
+ raise ConfigError(
+ f'DHCP range "{range}" {key} address not within shared-network "{network}, {subnet}"!'
+ )
# Stop address must be greater or equal to start address
- if ip_address(range_config['stop']) < ip_address(range_config['start']):
- raise ConfigError(f'DHCP range "{range}" stop address must be greater or equal\n' \
- 'to the ranges start address!')
+ if ip_address(range_config['stop']) < ip_address(
+ range_config['start']
+ ):
+ raise ConfigError(
+ f'DHCP range "{range}" stop address must be greater or equal\n'
+ 'to the ranges start address!'
+ )
for network in networks:
start = range_config['start']
stop = range_config['stop']
if start in network:
- raise ConfigError(f'Range "{range}" start address "{start}" already part of another range!')
+ raise ConfigError(
+ f'Range "{range}" start address "{start}" already part of another range!'
+ )
if stop in network:
- raise ConfigError(f'Range "{range}" stop address "{stop}" already part of another range!')
+ raise ConfigError(
+ f'Range "{range}" stop address "{stop}" already part of another range!'
+ )
tmp = IPRange(range_config['start'], range_config['stop'])
networks.append(tmp)
@@ -237,12 +266,16 @@ def verify(dhcp):
if 'exclude' in subnet_config:
for exclude in subnet_config['exclude']:
if ip_address(exclude) not in ip_network(subnet):
- raise ConfigError(f'Excluded IP address "{exclude}" not within shared-network "{network}, {subnet}"!')
+ raise ConfigError(
+ f'Excluded IP address "{exclude}" not within shared-network "{network}, {subnet}"!'
+ )
# At least one DHCP address range or static-mapping required
if 'range' not in subnet_config and 'static_mapping' not in subnet_config:
- raise ConfigError(f'No DHCP address range or active static-mapping configured\n' \
- f'within shared-network "{network}, {subnet}"!')
+ raise ConfigError(
+ f'No DHCP address range or active static-mapping configured\n'
+ f'within shared-network "{network}, {subnet}"!'
+ )
if 'static_mapping' in subnet_config:
# Static mappings require just a MAC address (will use an IP from the dynamic pool if IP is not set)
@@ -251,29 +284,42 @@ def verify(dhcp):
used_duid = []
for mapping, mapping_config in subnet_config['static_mapping'].items():
if 'ip_address' in mapping_config:
- if ip_address(mapping_config['ip_address']) not in ip_network(subnet):
- raise ConfigError(f'Configured static lease address for mapping "{mapping}" is\n' \
- f'not 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 ip_address(mapping_config['ip_address']) not in ip_network(
+ subnet
+ ):
+ raise ConfigError(
+ f'Configured static lease address for mapping "{mapping}" is\n'
+ f'not 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 'disable' not in mapping_config:
if mapping_config['ip_address'] in used_ips:
- raise ConfigError(f'Configured IP address for static mapping "{mapping}" already exists on another static mapping')
+ raise ConfigError(
+ f'Configured IP address for static mapping "{mapping}" already exists on another static mapping'
+ )
used_ips.append(mapping_config['ip_address'])
if 'disable' not in mapping_config:
if 'mac' in mapping_config:
if mapping_config['mac'] in used_mac:
- raise ConfigError(f'Configured MAC address for static mapping "{mapping}" already exists on another static mapping')
+ raise ConfigError(
+ f'Configured MAC address for static mapping "{mapping}" already exists on another static mapping'
+ )
used_mac.append(mapping_config['mac'])
if 'duid' in mapping_config:
if mapping_config['duid'] in used_duid:
- raise ConfigError(f'Configured DUID for static mapping "{mapping}" already exists on another static mapping')
+ raise ConfigError(
+ f'Configured DUID for static mapping "{mapping}" already exists on another static mapping'
+ )
used_duid.append(mapping_config['duid'])
# There must be one subnet connected to a listen interface.
@@ -284,73 +330,102 @@ def verify(dhcp):
# Subnets must be non overlapping
if subnet in subnets:
- raise ConfigError(f'Configured subnets must be unique! Subnet "{subnet}"\n'
- 'defined multiple times!')
+ raise ConfigError(
+ f'Configured subnets must be unique! Subnet "{subnet}"\n'
+ 'defined multiple times!'
+ )
subnets.append(subnet)
# Check for overlapping subnets
net = ip_network(subnet)
for n in subnets:
net2 = ip_network(n)
- if (net != net2):
+ if net != net2:
if net.overlaps(net2):
- raise ConfigError(f'Conflicting subnet ranges: "{net}" overlaps "{net2}"!')
+ raise ConfigError(
+ f'Conflicting subnet ranges: "{net}" overlaps "{net2}"!'
+ )
# Prevent 'disable' for shared-network if only one network is configured
if (shared_networks - disabled_shared_networks) < 1:
- raise ConfigError(f'At least one shared network must be active!')
+ raise ConfigError('At least one shared network must be active!')
if 'high_availability' in dhcp:
for key in ['name', 'remote', 'source_address', 'status']:
if key not in dhcp['high_availability']:
tmp = key.replace('_', '-')
- raise ConfigError(f'DHCP high-availability requires "{tmp}" to be specified!')
+ raise ConfigError(
+ f'DHCP high-availability requires "{tmp}" to be specified!'
+ )
if len({'certificate', 'ca_certificate'} & set(dhcp['high_availability'])) == 1:
- raise ConfigError(f'DHCP secured high-availability requires both certificate and CA certificate')
+ raise ConfigError(
+ 'DHCP secured high-availability requires both certificate and CA certificate'
+ )
if 'certificate' in dhcp['high_availability']:
cert_name = dhcp['high_availability']['certificate']
if cert_name not in dhcp['pki']['certificate']:
- raise ConfigError(f'Invalid certificate specified for DHCP high-availability')
-
- if not dict_search_args(dhcp['pki']['certificate'], cert_name, 'certificate'):
- raise ConfigError(f'Invalid certificate specified for DHCP high-availability')
-
- if not dict_search_args(dhcp['pki']['certificate'], cert_name, 'private', 'key'):
- raise ConfigError(f'Missing private key on certificate specified for DHCP high-availability')
+ raise ConfigError(
+ 'Invalid certificate specified for DHCP high-availability'
+ )
+
+ if not dict_search_args(
+ dhcp['pki']['certificate'], cert_name, 'certificate'
+ ):
+ raise ConfigError(
+ 'Invalid certificate specified for DHCP high-availability'
+ )
+
+ if not dict_search_args(
+ dhcp['pki']['certificate'], cert_name, 'private', 'key'
+ ):
+ raise ConfigError(
+ 'Missing private key on certificate specified for DHCP high-availability'
+ )
if 'ca_certificate' in dhcp['high_availability']:
ca_cert_name = dhcp['high_availability']['ca_certificate']
if ca_cert_name not in dhcp['pki']['ca']:
- raise ConfigError(f'Invalid CA certificate specified for DHCP high-availability')
+ raise ConfigError(
+ 'Invalid CA certificate specified for DHCP high-availability'
+ )
if not dict_search_args(dhcp['pki']['ca'], ca_cert_name, 'certificate'):
- raise ConfigError(f'Invalid CA certificate specified for DHCP high-availability')
+ raise ConfigError(
+ 'Invalid CA certificate specified for DHCP high-availability'
+ )
- for address in (dict_search('listen_address', dhcp) or []):
+ for address in dict_search('listen_address', dhcp) or []:
if is_addr_assigned(address, include_vrf=True):
listen_ok = True
# no need to probe further networks, we have one that is valid
continue
else:
- raise ConfigError(f'listen-address "{address}" not configured on any interface')
+ raise ConfigError(
+ f'listen-address "{address}" not configured on any interface'
+ )
if not listen_ok:
- raise ConfigError('None of the configured subnets have an appropriate primary IP address on any\n'
- 'broadcast interface configured, nor was there an explicit listen-address\n'
- 'configured for serving DHCP relay packets!')
+ raise ConfigError(
+ 'None of the configured subnets have an appropriate primary IP address on any\n'
+ 'broadcast interface configured, nor was there an explicit listen-address\n'
+ 'configured for serving DHCP relay packets!'
+ )
if 'listen_address' in dhcp and 'listen_interface' in dhcp:
- raise ConfigError(f'Cannot define listen-address and listen-interface at the same time')
+ raise ConfigError(
+ 'Cannot define listen-address and listen-interface at the same time'
+ )
- for interface in (dict_search('listen_interface', dhcp) or []):
+ for interface in dict_search('listen_interface', dhcp) or []:
if not interface_exists(interface):
raise ConfigError(f'listen-interface "{interface}" does not exist')
return None
+
def generate(dhcp):
# bail out early - looks like removal from running config
if not dhcp or 'disable' in dhcp:
@@ -382,8 +457,12 @@ def generate(dhcp):
cert_name = dhcp['high_availability']['certificate']
cert_data = dhcp['pki']['certificate'][cert_name]['certificate']
key_data = dhcp['pki']['certificate'][cert_name]['private']['key']
- write_file(cert_file, wrap_certificate(cert_data), user=user_group, mode=0o600)
- write_file(cert_key_file, wrap_private_key(key_data), user=user_group, mode=0o600)
+ write_file(
+ cert_file, wrap_certificate(cert_data), user=user_group, mode=0o600
+ )
+ write_file(
+ cert_key_file, wrap_private_key(key_data), user=user_group, mode=0o600
+ )
dhcp['high_availability']['cert_file'] = cert_file
dhcp['high_availability']['cert_key_file'] = cert_key_file
@@ -391,17 +470,33 @@ def generate(dhcp):
if 'ca_certificate' in dhcp['high_availability']:
ca_cert_name = dhcp['high_availability']['ca_certificate']
ca_cert_data = dhcp['pki']['ca'][ca_cert_name]['certificate']
- write_file(ca_cert_file, wrap_certificate(ca_cert_data), user=user_group, mode=0o600)
+ write_file(
+ ca_cert_file,
+ wrap_certificate(ca_cert_data),
+ user=user_group,
+ mode=0o600,
+ )
dhcp['high_availability']['ca_cert_file'] = ca_cert_file
- render(systemd_override, 'dhcp-server/10-override.conf.j2', dhcp)
-
- 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)
+ 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,
+ )
return None
+
def apply(dhcp):
services = ['kea-ctrl-agent', 'kea-dhcp4-server', 'kea-dhcp-ddns-server']
@@ -427,6 +522,7 @@ def apply(dhcp):
return None
+
if __name__ == '__main__':
try:
c = get_config()
diff --git a/src/conf_mode/service_dns_forwarding.py b/src/conf_mode/service_dns_forwarding.py
index e3bdbc9f8..5636d6f83 100755
--- a/src/conf_mode/service_dns_forwarding.py
+++ b/src/conf_mode/service_dns_forwarding.py
@@ -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_monitoring_frr-exporter.py b/src/conf_mode/service_monitoring_network_event.py
index 01527d579..104e6ce23 100755..100644
--- a/src/conf_mode/service_monitoring_frr-exporter.py
+++ b/src/conf_mode/service_monitoring_network_event.py
@@ -15,22 +15,18 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
+import json
from sys import exit
from vyos.config import Config
-from vyos.configdict import is_node_changed
-from vyos.configverify import verify_vrf
-from vyos.template import render
+from vyos.utils.file import write_file
from vyos.utils.process import call
from vyos import ConfigError
from vyos import airbag
-
-
airbag.enable()
-service_file = '/etc/systemd/system/frr_exporter.service'
-systemd_service = 'frr_exporter.service'
+vyos_network_event_logger_config = r'/run/vyos-network-event-logger.conf'
def get_config(config=None):
@@ -38,56 +34,52 @@ def get_config(config=None):
conf = config
else:
conf = Config()
- base = ['service', 'monitoring', 'frr-exporter']
+ base = ['service', 'monitoring', 'network-event']
if not conf.exists(base):
return None
- config_data = conf.get_config_dict(
- base, key_mangling=('-', '_'), get_first_key=True
- )
- config_data = conf.merge_defaults(config_data, recursive=True)
+ monitoring = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
- tmp = is_node_changed(conf, base + ['vrf'])
- if tmp:
- config_data.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.
+ monitoring = conf.merge_defaults(monitoring, recursive=True)
- return config_data
+ return monitoring
-def verify(config_data):
- # bail out early - looks like removal from running config
- if not config_data:
+def verify(monitoring):
+ if not monitoring:
return None
- verify_vrf(config_data)
return None
-def generate(config_data):
- if not config_data:
- # Delete systemd files
- if os.path.isfile(service_file):
- os.unlink(service_file)
+def generate(monitoring):
+ if not monitoring:
+ # Delete config
+ if os.path.exists(vyos_network_event_logger_config):
+ os.unlink(vyos_network_event_logger_config)
+
return None
- # Render frr_exporter service_file
- render(service_file, 'frr_exporter/frr_exporter.service.j2', config_data)
+ # Create config
+ log_conf_json = json.dumps(monitoring, indent=4)
+ write_file(vyos_network_event_logger_config, log_conf_json)
+
return None
-def apply(config_data):
+def apply(monitoring):
# Reload systemd manager configuration
- call('systemctl daemon-reload')
- if not config_data:
+ systemd_service = 'vyos-network-event-logger.service'
+
+ if not monitoring:
call(f'systemctl stop {systemd_service}')
return
- # we need to restart the service if e.g. the VRF name changed
- systemd_action = 'reload-or-restart'
- if 'restart_required' in config_data:
- systemd_action = 'restart'
-
- call(f'systemctl {systemd_action} {systemd_service}')
+ call(f'systemctl restart {systemd_service}')
if __name__ == '__main__':
diff --git a/src/conf_mode/service_monitoring_node-exporter.py b/src/conf_mode/service_monitoring_node-exporter.py
deleted file mode 100755
index db34bb5d0..000000000
--- a/src/conf_mode/service_monitoring_node-exporter.py
+++ /dev/null
@@ -1,101 +0,0 @@
-#!/usr/bin/env python3
-#
-# Copyright (C) 2024 VyOS maintainers and contributors
-#
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License version 2 or later as
-# published by the Free Software Foundation.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-import os
-
-from sys import exit
-
-from vyos.config import Config
-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 import ConfigError
-from vyos import airbag
-
-
-airbag.enable()
-
-service_file = '/etc/systemd/system/node_exporter.service'
-systemd_service = 'node_exporter.service'
-
-
-def get_config(config=None):
- if config:
- conf = config
- else:
- conf = Config()
- base = ['service', 'monitoring', 'node-exporter']
- if not conf.exists(base):
- return None
-
- config_data = conf.get_config_dict(
- base, key_mangling=('-', '_'), get_first_key=True
- )
- config_data = conf.merge_defaults(config_data, recursive=True)
-
- tmp = is_node_changed(conf, base + ['vrf'])
- if tmp:
- config_data.update({'restart_required': {}})
-
- return config_data
-
-
-def verify(config_data):
- # bail out early - looks like removal from running config
- if not config_data:
- return None
-
- verify_vrf(config_data)
- return None
-
-
-def generate(config_data):
- if not config_data:
- # Delete systemd files
- if os.path.isfile(service_file):
- os.unlink(service_file)
- return None
-
- # Render node_exporter service_file
- render(service_file, 'node_exporter/node_exporter.service.j2', config_data)
- return None
-
-
-def apply(config_data):
- # Reload systemd manager configuration
- call('systemctl daemon-reload')
- if not config_data:
- call(f'systemctl stop {systemd_service}')
- return
-
- # we need to restart the service if e.g. the VRF name changed
- systemd_action = 'reload-or-restart'
- if 'restart_required' in config_data:
- systemd_action = 'restart'
-
- call(f'systemctl {systemd_action} {systemd_service}')
-
-
-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_monitoring_prometheus.py b/src/conf_mode/service_monitoring_prometheus.py
new file mode 100755
index 000000000..9a07d8593
--- /dev/null
+++ b/src/conf_mode/service_monitoring_prometheus.py
@@ -0,0 +1,206 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2024 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+
+from sys import exit
+
+from vyos.config import Config
+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 import ConfigError
+from vyos import airbag
+
+airbag.enable()
+
+node_exporter_service_file = '/etc/systemd/system/node_exporter.service'
+node_exporter_systemd_service = 'node_exporter.service'
+node_exporter_collector_path = '/run/node_exporter/collector'
+
+frr_exporter_service_file = '/etc/systemd/system/frr_exporter.service'
+frr_exporter_systemd_service = 'frr_exporter.service'
+
+blackbox_exporter_service_file = '/etc/systemd/system/blackbox_exporter.service'
+blackbox_exporter_systemd_service = 'blackbox_exporter.service'
+
+
+def get_config(config=None):
+ if config:
+ conf = config
+ else:
+ conf = Config()
+ base = ['service', 'monitoring', 'prometheus']
+ if not conf.exists(base):
+ return None
+
+ monitoring = conf.get_config_dict(
+ base, key_mangling=('-', '_'), get_first_key=True, with_recursive_defaults=True
+ )
+
+ tmp = is_node_changed(conf, base + ['node-exporter', 'vrf'])
+ if tmp:
+ monitoring.update({'node_exporter_restart_required': {}})
+
+ tmp = is_node_changed(conf, base + ['frr-exporter', 'vrf'])
+ if tmp:
+ monitoring.update({'frr_exporter_restart_required': {}})
+
+ tmp = False
+ for node in ['vrf', 'config-file']:
+ tmp = tmp or is_node_changed(conf, base + ['blackbox-exporter', node])
+ if tmp:
+ monitoring.update({'blackbox_exporter_restart_required': {}})
+
+ return monitoring
+
+
+def verify(monitoring):
+ if not monitoring:
+ return None
+
+ if 'node_exporter' in monitoring:
+ verify_vrf(monitoring['node_exporter'])
+
+ if 'frr_exporter' in monitoring:
+ verify_vrf(monitoring['frr_exporter'])
+
+ if 'blackbox_exporter' in monitoring:
+ verify_vrf(monitoring['blackbox_exporter'])
+
+ if (
+ 'modules' in monitoring['blackbox_exporter']
+ and 'dns' in monitoring['blackbox_exporter']['modules']
+ and 'name' in monitoring['blackbox_exporter']['modules']['dns']
+ ):
+ for mod_name, mod_config in monitoring['blackbox_exporter']['modules'][
+ 'dns'
+ ]['name'].items():
+ if 'query_name' not in mod_config:
+ raise ConfigError(
+ f'query name not specified in dns module {mod_name}'
+ )
+
+ return None
+
+
+def generate(monitoring):
+ if not monitoring or 'node_exporter' not in monitoring:
+ # Delete systemd files
+ if os.path.isfile(node_exporter_service_file):
+ os.unlink(node_exporter_service_file)
+
+ if not monitoring or 'frr_exporter' not in monitoring:
+ # Delete systemd files
+ if os.path.isfile(frr_exporter_service_file):
+ os.unlink(frr_exporter_service_file)
+
+ if not monitoring or 'blackbox_exporter' not in monitoring:
+ # Delete systemd files
+ if os.path.isfile(blackbox_exporter_service_file):
+ os.unlink(blackbox_exporter_service_file)
+
+ if not monitoring:
+ return None
+
+ if 'node_exporter' in monitoring:
+ # Render node_exporter node_exporter_service_file
+ render(
+ node_exporter_service_file,
+ 'prometheus/node_exporter.service.j2',
+ monitoring['node_exporter'],
+ )
+ if (
+ 'collectors' in monitoring['node_exporter']
+ and 'textfile' in monitoring['node_exporter']['collectors']
+ ):
+ # Create textcollector folder
+ if not os.path.isdir(node_exporter_collector_path):
+ os.makedirs(node_exporter_collector_path)
+
+ if 'frr_exporter' in monitoring:
+ # Render frr_exporter service_file
+ render(
+ frr_exporter_service_file,
+ 'prometheus/frr_exporter.service.j2',
+ monitoring['frr_exporter'],
+ )
+
+ if 'blackbox_exporter' in monitoring:
+ # Render blackbox_exporter service_file
+ render(
+ blackbox_exporter_service_file,
+ 'prometheus/blackbox_exporter.service.j2',
+ monitoring['blackbox_exporter'],
+ )
+ # Render blackbox_exporter config file
+ render(
+ '/run/blackbox_exporter/config.yml',
+ 'prometheus/blackbox_exporter.yml.j2',
+ monitoring['blackbox_exporter'],
+ )
+
+ return None
+
+
+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 not monitoring or 'frr_exporter' not in monitoring:
+ 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 not monitoring:
+ return
+
+ if 'node_exporter' in monitoring:
+ # we need to restart the service if e.g. the VRF name changed
+ systemd_action = 'reload-or-restart'
+ if 'node_exporter_restart_required' in monitoring:
+ systemd_action = 'restart'
+
+ call(f'systemctl {systemd_action} {node_exporter_systemd_service}')
+
+ if 'frr_exporter' in monitoring:
+ # we need to restart the service if e.g. the VRF name changed
+ systemd_action = 'reload-or-restart'
+ if 'frr_exporter_restart_required' in monitoring:
+ systemd_action = 'restart'
+
+ call(f'systemctl {systemd_action} {frr_exporter_systemd_service}')
+
+ if 'blackbox_exporter' in monitoring:
+ # we need to restart the service if e.g. the VRF name changed
+ systemd_action = 'reload-or-restart'
+ if 'blackbox_exporter_restart_required' in monitoring:
+ systemd_action = 'restart'
+
+ call(f'systemctl {systemd_action} {blackbox_exporter_systemd_service}')
+
+
+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_monitoring_zabbix-agent.py b/src/conf_mode/service_monitoring_zabbix-agent.py
index 98d8a32ca..f17146a8d 100755
--- a/src/conf_mode/service_monitoring_zabbix-agent.py
+++ b/src/conf_mode/service_monitoring_zabbix-agent.py
@@ -18,6 +18,8 @@ import os
from vyos.config import Config
from vyos.template import render
+from vyos.utils.dict import dict_search
+from vyos.utils.file import write_file
from vyos.utils.process import call
from vyos import ConfigError
from vyos import airbag
@@ -26,6 +28,7 @@ airbag.enable()
service_name = 'zabbix-agent2'
service_conf = f'/run/zabbix/{service_name}.conf'
+service_psk_file = f'/run/zabbix/{service_name}.psk'
systemd_override = r'/run/systemd/system/zabbix-agent2.service.d/10-override.conf'
@@ -49,6 +52,8 @@ def get_config(config=None):
if 'directory' in config and config['directory'].endswith('/'):
config['directory'] = config['directory'][:-1]
+ config['service_psk_file'] = service_psk_file
+
return config
@@ -60,18 +65,34 @@ def verify(config):
if 'server' not in config:
raise ConfigError('Server is required!')
+ if 'authentication' in config and dict_search("authentication.mode",
+ config) == 'pre_shared_secret':
+ if 'id' not in config['authentication']['psk']:
+ raise ConfigError(
+ 'PSK identity is required for pre-shared-secret authentication mode')
+
+ if 'secret' not in config['authentication']['psk']:
+ raise ConfigError(
+ 'PSK secret is required for pre-shared-secret authentication mode')
+
def generate(config):
# bail out early - looks like removal from running config
if config is None:
# Remove old config and return
- config_files = [service_conf, systemd_override]
+ config_files = [service_conf, systemd_override, service_psk_file]
for file in config_files:
if os.path.isfile(file):
os.unlink(file)
return None
+ if not dict_search("authentication.psk.secret", config):
+ if os.path.isfile(service_psk_file):
+ os.unlink(service_psk_file)
+ else:
+ write_file(service_psk_file, config["authentication"]["psk"]["secret"])
+
# Write configuration file
render(service_conf, 'zabbix-agent/zabbix-agent.conf.j2', config)
render(systemd_override, 'zabbix-agent/10-override.conf.j2', config)
diff --git a/src/conf_mode/service_snmp.py b/src/conf_mode/service_snmp.py
index c9c0ed9a0..c64c59af7 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-2024 VyOS maintainers and contributors
+# Copyright (C) 2018-2025 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
@@ -22,6 +22,7 @@ from vyos.base import Warning
from vyos.config import Config
from vyos.configdict import dict_merge
from vyos.configverify import verify_vrf
+from vyos.defaults import systemd_services
from vyos.snmpv3_hashgen import plaintext_to_md5
from vyos.snmpv3_hashgen import plaintext_to_sha1
from vyos.snmpv3_hashgen import random
@@ -43,7 +44,7 @@ config_file_access = r'/usr/share/snmp/snmpd.conf'
config_file_user = r'/var/lib/snmp/snmpd.conf'
default_script_dir = r'/config/user-data/'
systemd_override = r'/run/systemd/system/snmpd.service.d/override.conf'
-systemd_service = 'snmpd.service'
+systemd_service = systemd_services['snmpd']
def get_config(config=None):
if config:
@@ -146,6 +147,9 @@ def verify(snmp):
return None
if 'user' in snmp['v3']:
+ if 'engineid' not in snmp['v3']:
+ raise ConfigError(f'EngineID must be configured for SNMPv3!')
+
for user, user_config in snmp['v3']['user'].items():
if 'group' not in user_config:
raise ConfigError(f'Group membership required for user "{user}"!')
@@ -260,15 +264,6 @@ def apply(snmp):
# start SNMP daemon
call(f'systemctl reload-or-restart {systemd_service}')
-
- # Enable AgentX in FRR
- # This should be done for each daemon individually because common command
- # works only if all the daemons started with SNMP support
- # Following daemons from FRR 9.0/stable have SNMP module compiled in VyOS
- frr_daemons_list = ['zebra', 'bgpd', 'ospf6d', 'ospfd', 'ripd', 'isisd', 'ldpd']
- for frr_daemon in frr_daemons_list:
- call(f'vtysh -c "configure terminal" -d {frr_daemon} -c "agentx" >/dev/null')
-
return None
if __name__ == '__main__':
diff --git a/src/conf_mode/service_ssh.py b/src/conf_mode/service_ssh.py
index 9abdd33dc..759f87bb2 100755
--- a/src/conf_mode/service_ssh.py
+++ b/src/conf_mode/service_ssh.py
@@ -23,10 +23,16 @@ from syslog import LOG_INFO
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.utils.process import call
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.utils.file import write_file
+
airbag.enable()
config_file = r'/run/sshd/sshd_config'
@@ -38,6 +44,9 @@ 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'
+
+
def get_config(config=None):
if config:
conf = config
@@ -47,10 +56,13 @@ def get_config(config=None):
if not conf.exists(base):
return None
- ssh = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=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': {}})
+ 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.
@@ -62,20 +74,32 @@ def get_config(config=None):
# Ignore default XML values if config doesn't exists
# Delete key from dict
if not conf.exists(base + ['dynamic-protection']):
- del ssh['dynamic_protection']
+ del ssh['dynamic_protection']
return ssh
+
def verify(ssh):
if not ssh:
return None
if 'rekey' in ssh and 'data' not in ssh['rekey']:
- raise ConfigError(f'Rekey data is required!')
+ 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')
+
+ 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")
verify_vrf(ssh)
return None
+
def generate(ssh):
if not ssh:
if os.path.isfile(config_file):
@@ -95,6 +119,24 @@ 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)
+
render(config_file, 'ssh/sshd_config.j2', ssh)
if 'dynamic_protection' in ssh:
@@ -103,12 +145,12 @@ def generate(ssh):
return None
+
def apply(ssh):
- systemd_service_ssh = 'ssh.service'
systemd_service_sshguard = 'sshguard.service'
if not ssh:
# SSH access is removed in the commit
- call(f'systemctl stop ssh@*.service')
+ call('systemctl stop ssh@*.service')
call(f'systemctl stop {systemd_service_sshguard}')
return None
@@ -122,13 +164,14 @@ def apply(ssh):
if 'restart_required' in ssh:
# this is only true if something for the VRFs changed, thus we
# stop all VRF services and only restart then new ones
- call(f'systemctl stop ssh@*.service')
+ call('systemctl stop ssh@*.service')
systemd_action = 'restart'
for vrf in ssh['vrf']:
call(f'systemctl {systemd_action} ssh@{vrf}.service')
return None
+
if __name__ == '__main__':
try:
c = get_config()
diff --git a/src/conf_mode/system_flow-accounting.py b/src/conf_mode/system_flow-accounting.py
index a12ee363d..925c4a562 100755
--- a/src/conf_mode/system_flow-accounting.py
+++ b/src/conf_mode/system_flow-accounting.py
@@ -18,7 +18,6 @@ import os
import re
from sys import exit
-from ipaddress import ip_address
from vyos.config import Config
from vyos.config import config_dict_merge
@@ -159,9 +158,9 @@ def get_config(config=None):
# delete individual flow type defaults - should only be added if user
# sets this feature
- for flow_type in ['sflow', 'netflow']:
- if flow_type not in flow_accounting and flow_type in default_values:
- del default_values[flow_type]
+ flow_type = 'netflow'
+ if flow_type not in flow_accounting and flow_type in default_values:
+ del default_values[flow_type]
flow_accounting = config_dict_merge(default_values, flow_accounting)
@@ -171,9 +170,9 @@ def verify(flow_config):
if not flow_config:
return None
- # check if at least one collector is enabled
- if 'sflow' not in flow_config and 'netflow' not in flow_config and 'disable_imt' in flow_config:
- raise ConfigError('You need to configure at least sFlow or NetFlow, ' \
+ # 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
@@ -185,45 +184,7 @@ def verify(flow_config):
for interface in flow_config['interface']:
verify_interface_exists(flow_config, interface, warning_only=True)
- # check sFlow configuration
- if 'sflow' in flow_config:
- # check if at least one sFlow collector is configured
- if 'server' not in flow_config['sflow']:
- raise ConfigError('You need to configure at least one sFlow server!')
-
- # check that all sFlow collectors use the same IP protocol version
- sflow_collector_ipver = None
- for server in flow_config['sflow']['server']:
- if sflow_collector_ipver:
- if sflow_collector_ipver != ip_address(server).version:
- raise ConfigError("All sFlow servers must use the same IP protocol")
- else:
- sflow_collector_ipver = ip_address(server).version
-
- # check if vrf is defined for Sflow
- verify_vrf(flow_config)
- sflow_vrf = None
- if 'vrf' in flow_config:
- sflow_vrf = flow_config['vrf']
-
- # check agent-id for sFlow: we should avoid mixing IPv4 agent-id with IPv6 collectors and vice-versa
- for server in flow_config['sflow']['server']:
- if 'agent_address' in flow_config['sflow']:
- if ip_address(server).version != ip_address(flow_config['sflow']['agent_address']).version:
- raise ConfigError('IPv4 and IPv6 addresses can not be mixed in "sflow agent-address" and "sflow '\
- 'server". You need to set the same IP version for both "agent-address" and '\
- 'all sFlow servers')
-
- if 'agent_address' in flow_config['sflow']:
- tmp = flow_config['sflow']['agent_address']
- if not is_addr_assigned(tmp, sflow_vrf):
- raise ConfigError(f'Configured "sflow agent-address {tmp}" does not exist in the system!')
-
- # Check if configured sflow source-address exist in the system
- if 'source_address' in flow_config['sflow']:
- if not is_addr_assigned(flow_config['sflow']['source_address'], sflow_vrf):
- tmp = flow_config['sflow']['source_address']
- raise ConfigError(f'Configured "sflow source-address {tmp}" does not exist on the system!')
+ verify_vrf(flow_config)
# check NetFlow configuration
if 'netflow' in flow_config:
diff --git a/src/conf_mode/system_host-name.py b/src/conf_mode/system_host-name.py
index 3f245f166..fef034d1c 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-2024 VyOS maintainers and contributors
+# Copyright (C) 2018-2025 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
@@ -23,6 +23,7 @@ import vyos.hostsd_client
from vyos.base import Warning
from vyos.config import Config
from vyos.configdict import leaf_node_changed
+from vyos.defaults import systemd_services
from vyos.ifconfig import Section
from vyos.template import is_ip
from vyos.utils.process import cmd
@@ -174,11 +175,13 @@ def apply(config):
# Restart services that use the hostname
if hostname_new != hostname_old:
- call("systemctl restart rsyslog.service")
+ tmp = systemd_services['rsyslog']
+ call(f'systemctl restart {tmp}')
# If SNMP is running, restart it too
if process_named_running('snmpd') and config['snmpd_restart_reqired']:
- call('systemctl restart snmpd.service')
+ tmp = systemd_services['snmpd']
+ call(f'systemctl restart {tmp}')
return None
diff --git a/src/conf_mode/system_ip.py b/src/conf_mode/system_ip.py
index c8a91fd2f..7f3796168 100755
--- a/src/conf_mode/system_ip.py
+++ b/src/conf_mode/system_ip.py
@@ -17,17 +17,17 @@
from sys import exit
from vyos.config import Config
-from vyos.configdict import dict_merge
+from vyos.configdep import set_dependents
+from vyos.configdep import call_dependents
+from vyos.configverify import has_frr_protocol_in_dict
from vyos.configverify import verify_route_map
-from vyos.template import render_to_string
+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_active
+from vyos.utils.process import is_systemd_service_running
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 frr
from vyos import airbag
airbag.enable()
@@ -36,42 +36,36 @@ def get_config(config=None):
conf = config
else:
conf = Config()
- base = ['system', 'ip']
-
- opt = conf.get_config_dict(base, key_mangling=('-', '_'),
- get_first_key=True,
- with_recursive_defaults=True)
-
- # When working with FRR we need to know the corresponding address-family
- opt['afi'] = 'ip'
-
- # We also need the route-map information from the config
- #
- # XXX: one MUST always call this without the key_mangling() option! See
- # vyos.configverify.verify_common_route_maps() for more information.
- tmp = {'policy' : {'route-map' : conf.get_config_dict(['policy', 'route-map'],
- get_first_key=True)}}
- # Merge policy dict into "regular" config dict
- opt = dict_merge(tmp, opt)
# If IPv4 ARP table size is set here and also manually in sysctl, the more
# fine grained value from sysctl must win
set_dependents('sysctl', conf)
+ return get_frrender_dict(conf)
+
+def verify(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'ip'):
+ return None
- return opt
+ opt = config_dict['ip']
+ opt['policy'] = config_dict['policy']
-def verify(opt):
if 'protocol' in opt:
for protocol, protocol_options in opt['protocol'].items():
if 'route_map' in protocol_options:
verify_route_map(protocol_options['route_map'], opt)
return
-def generate(opt):
- opt['frr_zebra_config'] = render_to_string('frr/zebra.route-map.frr.j2', opt)
- return
+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, 'ip'):
+
+ return None
+ opt = config_dict['ip']
-def apply(opt):
# Apply ARP threshold values
# table_size has a default value - thus the key always exists
size = int(dict_search('arp.table_size', opt))
@@ -82,11 +76,6 @@ def apply(opt):
# Minimum number of stored records is indicated which is not cleared
sysctl_write('net.ipv4.neigh.default.gc_thresh1', size // 8)
- # enable/disable IPv4 forwarding
- tmp = dict_search('disable_forwarding', opt)
- value = '0' if (tmp != None) else '1'
- write_file('/proc/sys/net/ipv4/conf/all/forwarding', value)
-
# configure multipath
tmp = dict_search('multipath.ignore_unreachable_nexthops', opt)
value = '1' if (tmp != None) else '0'
@@ -121,19 +110,11 @@ def apply(opt):
# running when this script is called first. Skip this part and wait for initial
# commit of the configuration to trigger this statement
if is_systemd_service_active('frr.service'):
- zebra_daemon = 'zebra'
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
-
- # The route-map used for the FIB (zebra) is part of the zebra daemon
- frr_cfg.load_configuration(zebra_daemon)
- frr_cfg.modify_section(r'no ip nht resolve-via-default')
- frr_cfg.modify_section(r'ip protocol \w+ route-map [-a-zA-Z0-9.]+', stop_pattern='(\s|!)')
- if 'frr_zebra_config' in opt:
- frr_cfg.add_before(frr.default_add_before, opt['frr_zebra_config'])
- frr_cfg.commit_configuration(zebra_daemon)
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
call_dependents()
+ return None
if __name__ == '__main__':
try:
diff --git a/src/conf_mode/system_ipv6.py b/src/conf_mode/system_ipv6.py
index a2442d009..309869b2f 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-2023 VyOS maintainers and contributors
+# Copyright (C) 2019-2024 VyOS maintainers and contributors
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 or later as
@@ -18,17 +18,18 @@ import os
from sys import exit
from vyos.config import Config
-from vyos.configdict import dict_merge
+from vyos.configdep import set_dependents
+from vyos.configdep import call_dependents
+from vyos.configverify import has_frr_protocol_in_dict
from vyos.configverify import verify_route_map
-from vyos.template import render_to_string
+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_active
+from vyos.utils.process import is_systemd_service_running
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 frr
from vyos import airbag
airbag.enable()
@@ -37,42 +38,35 @@ def get_config(config=None):
conf = config
else:
conf = Config()
- base = ['system', 'ipv6']
-
- opt = conf.get_config_dict(base, key_mangling=('-', '_'),
- get_first_key=True,
- with_recursive_defaults=True)
-
- # When working with FRR we need to know the corresponding address-family
- opt['afi'] = 'ipv6'
-
- # We also need the route-map information from the config
- #
- # XXX: one MUST always call this without the key_mangling() option! See
- # vyos.configverify.verify_common_route_maps() for more information.
- tmp = {'policy' : {'route-map' : conf.get_config_dict(['policy', 'route-map'],
- get_first_key=True)}}
- # Merge policy dict into "regular" config dict
- opt = dict_merge(tmp, opt)
# If IPv6 neighbor table size is set here and also manually in sysctl, the more
# fine grained value from sysctl must win
set_dependents('sysctl', conf)
+ return get_frrender_dict(conf)
+
+def verify(config_dict):
+ if not has_frr_protocol_in_dict(config_dict, 'ipv6'):
+ return None
- return opt
+ opt = config_dict['ipv6']
+ opt['policy'] = config_dict['policy']
-def verify(opt):
if 'protocol' in opt:
for protocol, protocol_options in opt['protocol'].items():
if 'route_map' in protocol_options:
verify_route_map(protocol_options['route_map'], opt)
return
-def generate(opt):
- opt['frr_zebra_config'] = render_to_string('frr/zebra.route-map.frr.j2', opt)
- return
+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, 'ipv6'):
+ return None
+ opt = config_dict['ipv6']
-def apply(opt):
# configure multipath
tmp = dict_search('multipath.layer4_hashing', opt)
value = '1' if (tmp != None) else '0'
@@ -88,11 +82,6 @@ def apply(opt):
# Minimum number of stored records is indicated which is not cleared
sysctl_write('net.ipv6.neigh.default.gc_thresh1', size // 8)
- # enable/disable IPv6 forwarding
- tmp = dict_search('disable_forwarding', opt)
- value = '0' if (tmp != None) else '1'
- write_file('/proc/sys/net/ipv6/conf/all/forwarding', value)
-
# configure IPv6 strict-dad
tmp = dict_search('strict_dad', opt)
value = '2' if (tmp != None) else '1'
@@ -105,19 +94,11 @@ def apply(opt):
# running when this script is called first. Skip this part and wait for initial
# commit of the configuration to trigger this statement
if is_systemd_service_active('frr.service'):
- zebra_daemon = 'zebra'
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
-
- # The route-map used for the FIB (zebra) is part of the zebra daemon
- frr_cfg.load_configuration(zebra_daemon)
- frr_cfg.modify_section(r'no ipv6 nht resolve-via-default')
- frr_cfg.modify_section(r'ipv6 protocol \w+ route-map [-a-zA-Z0-9.]+', stop_pattern='(\s|!)')
- if 'frr_zebra_config' in opt:
- frr_cfg.add_before(frr.default_add_before, opt['frr_zebra_config'])
- frr_cfg.commit_configuration(zebra_daemon)
+ if config_dict and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
call_dependents()
+ return None
if __name__ == '__main__':
try:
diff --git a/src/conf_mode/system_login.py b/src/conf_mode/system_login.py
index 439fa645b..4febb6494 100755
--- a/src/conf_mode/system_login.py
+++ b/src/conf_mode/system_login.py
@@ -24,10 +24,13 @@ from pwd import getpwuid
from sys import exit
from time import sleep
+from vyos.base import Warning
from vyos.config import Config
from vyos.configverify import verify_vrf
from vyos.template import render
from vyos.template import is_ipv4
+from vyos.utils.auth import EPasswdStrength
+from vyos.utils.auth import evaluate_strength
from vyos.utils.auth import get_current_user
from vyos.utils.configfs import delete_cli_node
from vyos.utils.configfs import add_cli_node
@@ -58,20 +61,21 @@ MAX_RADIUS_TIMEOUT: int = 50
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():
+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_USER_UID:
+ if getpwnam(s_user.pw_name).pw_uid < min_uid:
continue
- if getpwnam(s_user.pw_name).pw_uid > MAX_USER_UID:
+ if getpwnam(s_user.pw_name).pw_uid > max_uid:
continue
if s_user.pw_name in SYSTEM_USER_SKIP_LIST:
continue
@@ -119,6 +123,12 @@ def get_config(config=None):
rm_users = [tmp for tmp in all_users if tmp not in cli_users]
if rm_users: login.update({'rm_users' : rm_users})
+ # Build TACACS user mapping
+ if 'tacacs' in login:
+ login['exclude_users'] = get_local_users(min_uid=0,
+ max_uid=MIN_TACACS_UID) + cli_users
+ login['tacacs_min_uid'] = MIN_TACACS_UID
+
return login
def verify(login):
@@ -139,6 +149,19 @@ 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!')
+ # 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:
+ result = evaluate_strength(plaintext_password)
+ if result['strength'] in failed_check_status:
+ Warning(result['error'])
+
for pubkey, pubkey_options in (dict_search('authentication.public_keys', user_config) or {}).items():
if 'type' not in pubkey_options:
raise ConfigError(f'Missing type for public-key "{pubkey}"!')
diff --git a/src/conf_mode/system_login_banner.py b/src/conf_mode/system_login_banner.py
index 5826d8042..cdd066649 100755
--- a/src/conf_mode/system_login_banner.py
+++ b/src/conf_mode/system_login_banner.py
@@ -95,8 +95,12 @@ def apply(banner):
render(POSTLOGIN_FILE, 'login/default_motd.j2', banner,
permission=0o644, user='root', group='root')
- render(POSTLOGIN_VYOS_FILE, 'login/motd_vyos_nonproduction.j2', banner,
- permission=0o644, user='root', group='root')
+ if banner['version_data']['build_type'] != 'release':
+ render(POSTLOGIN_VYOS_FILE, 'login/motd_vyos_nonproduction.j2',
+ banner,
+ permission=0o644,
+ user='root',
+ group='root')
return None
diff --git a/src/conf_mode/system_option.py b/src/conf_mode/system_option.py
index e2832cde6..064a1aa91 100755
--- a/src/conf_mode/system_option.py
+++ b/src/conf_mode/system_option.py
@@ -86,7 +86,7 @@ def verify(options):
if 'source_address' in config:
if not is_addr_assigned(config['source_address']):
- raise ConfigError('No interface with give address specified!')
+ raise ConfigError('No interface with given address specified!')
if 'ssh_client' in options:
config = options['ssh_client']
diff --git a/src/conf_mode/system_sflow.py b/src/conf_mode/system_sflow.py
index 41119b494..a22dac36f 100755
--- a/src/conf_mode/system_sflow.py
+++ b/src/conf_mode/system_sflow.py
@@ -54,7 +54,7 @@ def verify(sflow):
# Check if configured sflow agent-address exist in the system
if 'agent_address' in sflow:
tmp = sflow['agent_address']
- if not is_addr_assigned(tmp):
+ if not is_addr_assigned(tmp, include_vrf=True):
raise ConfigError(
f'Configured "sflow agent-address {tmp}" does not exist in the system!'
)
diff --git a/src/conf_mode/system_syslog.py b/src/conf_mode/system_syslog.py
index eb2f02eb3..414bd4b6b 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-2024 VyOS maintainers and contributors
+# Copyright (C) 2018-2025 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
@@ -20,17 +20,22 @@ from sys import exit
from vyos.base import Warning
from vyos.config import Config
-from vyos.configdict import is_node_changed
from vyos.configverify import verify_vrf
+from vyos.defaults import systemd_services
+from vyos.utils.network import is_addr_assigned
from vyos.utils.process import call
from vyos.template import render
+from vyos.template import is_ipv4
+from vyos.template import is_ipv6
from vyos import ConfigError
from vyos import airbag
airbag.enable()
-rsyslog_conf = '/etc/rsyslog.d/00-vyos.conf'
+rsyslog_conf = '/run/rsyslog/rsyslog.conf'
logrotate_conf = '/etc/logrotate.d/vyos-rsyslog'
-systemd_override = r'/run/systemd/system/rsyslog.service.d/override.conf'
+
+systemd_socket = 'syslog.socket'
+systemd_service = systemd_services['rsyslog']
def get_config(config=None):
if config:
@@ -46,23 +51,17 @@ def get_config(config=None):
syslog.update({ 'logrotate' : logrotate_conf })
- tmp = is_node_changed(conf, base + ['vrf'])
- if tmp: syslog.update({'restart_required': {}})
-
syslog = conf.merge_defaults(syslog, recursive=True)
- if syslog.from_defaults(['global']):
- del syslog['global']
-
- if (
- 'global' in syslog
- and 'preserve_fqdn' in syslog['global']
- and conf.exists(['system', 'host-name'])
- and conf.exists(['system', 'domain-name'])
- ):
- hostname = conf.return_value(['system', 'host-name'])
- domain = conf.return_value(['system', 'domain-name'])
- fqdn = f'{hostname}.{domain}'
- syslog['global']['local_host_name'] = fqdn
+ if syslog.from_defaults(['local']):
+ del syslog['local']
+
+ if 'preserve_fqdn' in syslog:
+ if conf.exists(['system', 'host-name']):
+ tmp = conf.return_value(['system', 'host-name'])
+ syslog['preserve_fqdn']['host_name'] = tmp
+ if conf.exists(['system', 'domain-name']):
+ tmp = conf.return_value(['system', 'domain-name'])
+ syslog['preserve_fqdn']['domain_name'] = tmp
return syslog
@@ -70,13 +69,33 @@ def verify(syslog):
if not syslog:
return None
- if 'host' in syslog:
- for host, host_options in syslog['host'].items():
- if 'protocol' in host_options and host_options['protocol'] == 'udp':
- if 'format' in host_options and 'octet_counted' in host_options['format']:
- Warning(f'Syslog UDP transport for "{host}" should not use octet-counted format!')
-
- verify_vrf(syslog)
+ if 'preserve_fqdn' in syslog:
+ if 'host_name' not in syslog['preserve_fqdn']:
+ Warning('No "system host-name" defined - cannot set syslog FQDN!')
+ if 'domain_name' not in syslog['preserve_fqdn']:
+ Warning('No "system domain-name" defined - cannot set syslog FQDN!')
+
+ if 'remote' in syslog:
+ for remote, remote_options in syslog['remote'].items():
+ if 'protocol' in remote_options and remote_options['protocol'] == 'udp':
+ if 'format' in remote_options and 'octet_counted' in remote_options['format']:
+ Warning(f'Syslog UDP transport for "{remote}" should not use octet-counted format!')
+
+ if 'vrf' in remote_options:
+ verify_vrf(remote_options)
+
+ if 'source_address' in remote_options:
+ vrf = None
+ if 'vrf' in remote_options:
+ vrf = remote_options['vrf']
+ if not is_addr_assigned(remote_options['source_address'], vrf):
+ raise ConfigError('No interface with given address specified!')
+
+ source_address = remote_options['source_address']
+ if ((is_ipv4(remote) and is_ipv6(source_address)) or
+ (is_ipv6(remote) and is_ipv4(source_address))):
+ raise ConfigError(f'Source-address "{source_address}" does not match '\
+ f'address-family of remote "{remote}"!')
def generate(syslog):
if not syslog:
@@ -88,26 +107,15 @@ def generate(syslog):
return None
render(rsyslog_conf, 'rsyslog/rsyslog.conf.j2', syslog)
- render(systemd_override, 'rsyslog/override.conf.j2', syslog)
render(logrotate_conf, 'rsyslog/logrotate.j2', syslog)
-
- # Reload systemd manager configuration
- call('systemctl daemon-reload')
return None
def apply(syslog):
- systemd_socket = 'syslog.socket'
- systemd_service = 'syslog.service'
if not syslog:
call(f'systemctl stop {systemd_service} {systemd_socket}')
return None
- # we need to restart the service if e.g. the VRF name changed
- systemd_action = 'reload-or-restart'
- if 'restart_required' in syslog:
- systemd_action = 'restart'
-
- call(f'systemctl {systemd_action} {systemd_service}')
+ call(f'systemctl reload-or-restart {systemd_service}')
return None
if __name__ == '__main__':
diff --git a/src/conf_mode/vpn_ipsec.py b/src/conf_mode/vpn_ipsec.py
index e22b7550c..2754314f7 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-2024 VyOS maintainers and contributors
+# Copyright (C) 2021-2025 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
@@ -64,6 +64,7 @@ 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'
interface_conf = '/etc/strongswan.d/interfaces_use.conf'
swanctl_conf = f'{swanctl_dir}/swanctl.conf'
@@ -86,8 +87,6 @@ def get_config(config=None):
conf = Config()
base = ['vpn', 'ipsec']
l2tp_base = ['vpn', 'l2tp', 'remote-access', 'ipsec-settings']
- if not conf.exists(base):
- return None
# retrieve common dictionary keys
ipsec = conf.get_config_dict(base, key_mangling=('-', '_'),
@@ -95,6 +94,14 @@ def get_config(config=None):
get_first_key=True,
with_pki=True)
+ ipsec['nhrp_exists'] = conf.exists(['protocols', 'nhrp', 'tunnel'])
+ if ipsec['nhrp_exists']:
+ set_dependents('nhrp', conf)
+
+ if not conf.exists(base):
+ ipsec.update({'deleted' : ''})
+ return ipsec
+
# We have to cleanup the default dict, as default values could
# enable features which are not explicitly enabled on the
# CLI. E.g. dead-peer-detection defaults should not be injected
@@ -115,7 +122,6 @@ def get_config(config=None):
ipsec['dhcp_no_address'] = {}
ipsec['install_routes'] = 'no' if conf.exists(base + ["options", "disable-route-autoinstall"]) else default_install_routes
ipsec['interface_change'] = leaf_node_changed(conf, base + ['interface'])
- ipsec['nhrp_exists'] = conf.exists(['protocols', 'nhrp', 'tunnel'])
if ipsec['nhrp_exists']:
set_dependents('nhrp', conf)
@@ -151,6 +157,8 @@ def get_config(config=None):
_, vti = get_interface_dict(conf, ['interfaces', 'vti'], vti_interface)
ipsec['vti_interface_dicts'][vti_interface] = vti
+ ipsec['vpp_ipsec_exists'] = conf.exists(['vpp', 'settings', 'ipsec'])
+
return ipsec
def get_dhcp_address(iface):
@@ -196,8 +204,8 @@ def verify_pki_rsa(pki, rsa_conf):
return True
def verify(ipsec):
- if not ipsec:
- return None
+ if not ipsec or 'deleted' in ipsec:
+ return
if 'authentication' in ipsec:
if 'psk' in ipsec['authentication']:
@@ -479,6 +487,17 @@ def verify(ipsec):
else:
raise ConfigError(f"Missing ike-group on site-to-site peer {peer}")
+ # verify encryption algorithm compatibility for IKE with VPP
+ if ipsec['vpp_ipsec_exists']:
+ ike_group = ipsec['ike_group'][peer_conf['ike_group']]
+ for proposal, proposal_config in ike_group.get('proposal', {}).items():
+ algs = ['gmac', 'serpent', 'twofish']
+ if any(alg in proposal_config['encryption'] for alg in algs):
+ raise ConfigError(
+ f'Encryption algorithm {proposal_config["encryption"]} cannot be used '
+ f'for IKE proposal {proposal} for site-to-site peer {peer} with VPP'
+ )
+
if 'authentication' not in peer_conf or 'mode' not in peer_conf['authentication']:
raise ConfigError(f"Missing authentication on site-to-site peer {peer}")
@@ -557,7 +576,7 @@ def verify(ipsec):
esp_group_name = tunnel_conf['esp_group'] if 'esp_group' in tunnel_conf else peer_conf['default_esp_group']
- if esp_group_name not in ipsec['esp_group']:
+ if esp_group_name not in ipsec.get('esp_group'):
raise ConfigError(f"Invalid esp-group on tunnel {tunnel} for site-to-site peer {peer}")
esp_group = ipsec['esp_group'][esp_group_name]
@@ -569,6 +588,18 @@ def verify(ipsec):
if ('local' in tunnel_conf and 'prefix' in tunnel_conf['local']) or ('remote' in tunnel_conf and 'prefix' in tunnel_conf['remote']):
raise ConfigError(f"Local/remote prefix cannot be used with ESP transport mode on tunnel {tunnel} for site-to-site peer {peer}")
+ # verify ESP encryption algorithm compatibility with VPP
+ # because Marvel plugin for VPP doesn't support all algorithms that Strongswan does
+ if ipsec['vpp_ipsec_exists']:
+ for proposal, proposal_config in esp_group.get('proposal', {}).items():
+ algs = ['aes128', 'aes192', 'aes256', 'aes128gcm128', 'aes192gcm128', 'aes256gcm128']
+ if proposal_config['encryption'] not in algs:
+ raise ConfigError(
+ f'Encryption algorithm {proposal_config["encryption"]} cannot be used '
+ f'for ESP proposal {proposal} on tunnel {tunnel} for site-to-site peer {peer} with VPP'
+ )
+
+
def cleanup_pki_files():
for path in [CERT_PATH, CA_PATH, CRL_PATH, KEY_PATH, PUBKEY_PATH]:
if not os.path.exists(path):
@@ -624,7 +655,7 @@ def generate_pki_files_rsa(pki, rsa_conf):
def generate(ipsec):
cleanup_pki_files()
- if not ipsec:
+ if not ipsec or 'deleted' in ipsec:
for config_file in [charon_dhcp_conf, charon_radius_conf, interface_conf, swanctl_conf]:
if os.path.isfile(config_file):
os.unlink(config_file)
@@ -715,21 +746,19 @@ 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(interface_conf, 'ipsec/interfaces_use.conf.j2', ipsec)
render(swanctl_conf, 'ipsec/swanctl.conf.j2', ipsec)
def apply(ipsec):
systemd_service = 'strongswan.service'
- if not ipsec:
+ if not ipsec or 'deleted' in ipsec:
call(f'systemctl stop {systemd_service}')
-
if vti_updown_db_exists():
remove_vti_updown_db()
-
else:
call(f'systemctl reload-or-restart {systemd_service}')
-
if ipsec['enabled_vti_interfaces']:
with open_vti_updown_db_for_create_or_update() as db:
db.removeAllOtherInterfaces(ipsec['enabled_vti_interfaces'])
@@ -737,7 +766,7 @@ def apply(ipsec):
db.commit(lambda interface: ipsec['vti_interface_dicts'][interface])
elif vti_updown_db_exists():
remove_vti_updown_db()
-
+ if ipsec:
if ipsec.get('nhrp_exists', False):
try:
call_dependents()
@@ -746,7 +775,6 @@ def apply(ipsec):
# ConfigError("ConfigError('Interface ethN requires an IP address!')")
pass
-
if __name__ == '__main__':
try:
ipsec = get_config()
diff --git a/src/conf_mode/vrf.py b/src/conf_mode/vrf.py
index 72b178c89..8baf55857 100755
--- a/src/conf_mode/vrf.py
+++ b/src/conf_mode/vrf.py
@@ -19,23 +19,23 @@ from jmespath import search
from json import loads
from vyos.config import Config
-from vyos.configdict import dict_merge
from vyos.configdict import node_changed
from vyos.configverify import verify_route_map
from vyos.firewall import conntrack_required
+from vyos.frrender import FRRender
+from vyos.frrender import get_frrender_dict
from vyos.ifconfig import Interface
from vyos.template import render
-from vyos.template import render_to_string
from vyos.utils.dict import dict_search
from vyos.utils.network import get_vrf_tableid
from vyos.utils.network import get_vrf_members
from vyos.utils.network import interface_exists
from vyos.utils.process import call
from vyos.utils.process import cmd
+from vyos.utils.process import is_systemd_service_running
from vyos.utils.process import popen
from vyos.utils.system import sysctl_write
from vyos import ConfigError
-from vyos import frr
from vyos import airbag
airbag.enable()
@@ -132,15 +132,9 @@ def get_config(config=None):
if 'name' in vrf:
vrf['conntrack'] = conntrack_required(conf)
- # We also need the route-map information from the config
- #
- # XXX: one MUST always call this without the key_mangling() option! See
- # vyos.configverify.verify_common_route_maps() for more information.
- tmp = {'policy' : {'route-map' : conf.get_config_dict(['policy', 'route-map'],
- get_first_key=True)}}
-
- # Merge policy dict into "regular" config dict
- vrf = dict_merge(tmp, vrf)
+ # We need to merge the FRR rendering dict into the VRF dict
+ # this is required to get the route-map information to FRR
+ vrf.update({'frr_dict' : get_frrender_dict(conf)})
return vrf
def verify(vrf):
@@ -155,9 +149,11 @@ def verify(vrf):
f'static routes installed!')
if 'name' in vrf:
- reserved_names = ["add", "all", "broadcast", "default", "delete", "dev",
- "get", "inet", "mtu", "link", "type", "vrf"]
+ reserved_names = ['add', 'all', 'broadcast', 'default', 'delete', 'dev',
+ 'down', 'get', 'inet', 'link', 'mtu', 'type', 'up', 'vrf']
+
table_ids = []
+ vnis = []
for name, vrf_config in vrf['name'].items():
# Reserved VRF names
if name in reserved_names:
@@ -178,17 +174,24 @@ def verify(vrf):
raise ConfigError(f'VRF "{name}" table id is not unique!')
table_ids.append(vrf_config['table'])
+ # VRF VNIs must be unique on the system
+ if 'vni' in vrf_config:
+ vni = vrf_config['vni']
+ if vni in vnis:
+ raise ConfigError(f'VRF "{name}" VNI "{vni}" is not unique!')
+ vnis.append(vni)
+
tmp = dict_search('ip.protocol', vrf_config)
if tmp != None:
for protocol, protocol_options in tmp.items():
if 'route_map' in protocol_options:
- verify_route_map(protocol_options['route_map'], vrf)
+ verify_route_map(protocol_options['route_map'], vrf['frr_dict'])
tmp = dict_search('ipv6.protocol', vrf_config)
if tmp != None:
for protocol, protocol_options in tmp.items():
if 'route_map' in protocol_options:
- verify_route_map(protocol_options['route_map'], vrf)
+ verify_route_map(protocol_options['route_map'], vrf['frr_dict'])
return None
@@ -196,8 +199,9 @@ def verify(vrf):
def generate(vrf):
# Render iproute2 VR helper names
render(config_file, 'iproute2/vrf.conf.j2', vrf)
- # Render VRF Kernel/Zebra route-map filters
- vrf['frr_zebra_config'] = render_to_string('frr/zebra.vrf.route-map.frr.j2', vrf)
+
+ if 'frr_dict' in vrf and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().generate(vrf['frr_dict'])
return None
@@ -339,17 +343,8 @@ def apply(vrf):
if has_rule(afi, 2000, 'l3mdev'):
call(f'ip {afi} rule del pref 2000 l3mdev unreachable')
- # Apply FRR filters
- zebra_daemon = 'zebra'
- # Save original configuration prior to starting any commit actions
- frr_cfg = frr.FRRConfig()
-
- # The route-map used for the FIB (zebra) is part of the zebra daemon
- frr_cfg.load_configuration(zebra_daemon)
- frr_cfg.modify_section(f'^vrf .+', stop_pattern='^exit-vrf', remove_stop_mark=True)
- if 'frr_zebra_config' in vrf:
- frr_cfg.add_before(frr.default_add_before, vrf['frr_zebra_config'])
- frr_cfg.commit_configuration(zebra_daemon)
+ if 'frr_dict' in vrf and not is_systemd_service_running('vyos-configd.service'):
+ FRRender().apply()
return None
diff --git a/src/etc/netplug/vyos-netplug-dhcp-client b/src/etc/netplug/vyos-netplug-dhcp-client
index 55d15a163..a230fe900 100755
--- a/src/etc/netplug/vyos-netplug-dhcp-client
+++ b/src/etc/netplug/vyos-netplug-dhcp-client
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
#
-# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io>
+# 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
@@ -19,44 +19,53 @@ import sys
from time import sleep
-from vyos.configquery import ConfigTreeQuery
+from vyos.config import Config
from vyos.ifconfig import Section
from vyos.utils.boot import boot_configuration_complete
+from vyos.utils.process import cmd
+from vyos.utils.process import is_systemd_service_active
from vyos.utils.commit import commit_in_progress
-from vyos.utils.process import call
from vyos import airbag
+
airbag.enable()
if len(sys.argv) < 3:
- airbag.noteworthy("Must specify both interface and link status!")
+ airbag.noteworthy('Must specify both interface and link status!')
sys.exit(1)
if not boot_configuration_complete():
- airbag.noteworthy("System bootup not yet finished...")
+ airbag.noteworthy('System bootup not yet finished...')
sys.exit(1)
+interface = sys.argv[1]
+
while commit_in_progress():
- sleep(1)
+ sleep(0.250)
-interface = sys.argv[1]
in_out = sys.argv[2]
-config = ConfigTreeQuery()
+config = Config()
interface_path = ['interfaces'] + Section.get_config_path(interface).split()
-for _, interface_config in config.get_config_dict(interface_path).items():
- # Bail out early if we do not have an IP address configured
- if 'address' not in interface_config:
- continue
- # Bail out early if interface ist administrative down
- if 'disable' in interface_config:
- continue
- systemd_action = 'start'
- if in_out == 'out':
- systemd_action = 'stop'
- # Start/Stop DHCP service
- if 'dhcp' in interface_config['address']:
- call(f'systemctl {systemd_action} dhclient@{interface}.service')
- # Start/Stop DHCPv6 service
- if 'dhcpv6' in interface_config['address']:
- call(f'systemctl {systemd_action} dhcp6c@{interface}.service')
+systemdV4_service = f'dhclient@{interface}.service'
+systemdV6_service = f'dhcp6c@{interface}.service'
+if in_out == 'out':
+ # Interface moved state to down
+ if is_systemd_service_active(systemdV4_service):
+ cmd(f'systemctl stop {systemdV4_service}')
+ if is_systemd_service_active(systemdV6_service):
+ cmd(f'systemctl stop {systemdV6_service}')
+elif in_out == 'in':
+ if config.exists_effective(interface_path + ['address']):
+ tmp = config.return_effective_values(interface_path + ['address'])
+ # 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 a 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:
+ cmd(f'systemctl restart {systemdV4_service}')
+ if 'dhcpv6' in tmp:
+ cmd(f'systemctl restart {systemdV6_service}')
diff --git a/src/etc/ppp/ip-up.d/99-vyos-pppoe-wlb b/src/etc/ppp/ip-up.d/99-vyos-pppoe-wlb
new file mode 100755
index 000000000..fff258afa
--- /dev/null
+++ b/src/etc/ppp/ip-up.d/99-vyos-pppoe-wlb
@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2024 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+# This is a Python hook script which is invoked whenever a PPPoE session goes
+# "ip-up". It will call into our vyos.ifconfig library and will then execute
+# common tasks for the PPPoE interface. The reason we have to "hook" this is
+# that we can not create a pppoeX interface in advance in linux and then connect
+# pppd to this already existing interface.
+
+import os
+import signal
+
+from sys import argv
+from sys import exit
+
+from vyos.defaults import directories
+
+# When the ppp link comes up, this script is called with the following
+# parameters
+# $1 the interface name used by pppd (e.g. ppp3)
+# $2 the tty device name
+# $3 the tty device speed
+# $4 the local IP address for the interface
+# $5 the remote IP address
+# $6 the parameter specified by the 'ipparam' option to pppd
+
+if (len(argv) < 7):
+ exit(1)
+
+wlb_pid_file = '/run/wlb_daemon.pid'
+
+interface = argv[6]
+nexthop = argv[5]
+
+if not os.path.exists(directories['ppp_nexthop_dir']):
+ os.mkdir(directories['ppp_nexthop_dir'])
+
+nexthop_file = os.path.join(directories['ppp_nexthop_dir'], interface)
+
+with open(nexthop_file, 'w') as f:
+ f.write(nexthop)
+
+# Trigger WLB daemon update
+if os.path.exists(wlb_pid_file):
+ with open(wlb_pid_file, 'r') as f:
+ pid = int(f.read())
+
+ os.kill(pid, signal.SIGUSR2)
diff --git a/src/etc/rsyslog.conf b/src/etc/rsyslog.conf
deleted file mode 100644
index b3f41acb6..000000000
--- a/src/etc/rsyslog.conf
+++ /dev/null
@@ -1,67 +0,0 @@
-#################
-#### MODULES ####
-#################
-
-$ModLoad imuxsock # provides support for local system logging
-$ModLoad imklog # provides kernel logging support (previously done by rklogd)
-#$ModLoad immark # provides --MARK-- message capability
-
-$OmitLocalLogging off
-$SystemLogSocketName /run/systemd/journal/syslog
-
-$KLogPath /proc/kmsg
-
-###########################
-#### GLOBAL DIRECTIVES ####
-###########################
-
-# Use traditional timestamp format.
-# To enable high precision timestamps, comment out the following line.
-# A modern-style logfile format similar to TraditionalFileFormat, buth with high-precision timestamps and timezone information
-#$ActionFileDefaultTemplate RSYSLOG_FileFormat
-# The "old style" default log file format with low-precision timestamps
-$ActionFileDefaultTemplate RSYSLOG_TraditionalFileFormat
-
-# Filter duplicated messages
-$RepeatedMsgReduction on
-
-#
-# Set the default permissions for all log files.
-#
-$FileOwner root
-$FileGroup adm
-$FileCreateMode 0640
-$DirCreateMode 0755
-$Umask 0022
-
-#
-# Stop excessive logging of sudo
-#
-:msg, contains, " pam_unix(sudo:session): session opened for user root(uid=0) by" stop
-:msg, contains, "pam_unix(sudo:session): session closed for user root" stop
-
-#
-# Include all config files in /etc/rsyslog.d/
-#
-$IncludeConfig /etc/rsyslog.d/*.conf
-
-# The lines below cause all listed daemons/processes to be logged into
-# /var/log/auth.log, then drops the message so it does not also go to the
-# regular syslog so that messages are not duplicated
-
-$outchannel auth_log,/var/log/auth.log
-if $programname == 'CRON' or
- $programname == 'sudo' or
- $programname == 'su'
- then :omfile:$auth_log
-
-if $programname == 'CRON' or
- $programname == 'sudo' or
- $programname == 'su'
- then stop
-
-###############
-#### RULES ####
-###############
-# Emergencies are sent to everybody logged in.
-*.emerg :omusrmsg:* \ No newline at end of file
diff --git a/src/etc/skel/.bashrc b/src/etc/skel/.bashrc
index ba7d50003..f807f0c72 100644
--- a/src/etc/skel/.bashrc
+++ b/src/etc/skel/.bashrc
@@ -92,6 +92,9 @@ fi
#alias la='ls -A'
#alias l='ls -CF'
+# Disable iproute2 auto color
+alias ip="ip --color=never"
+
# Alias definitions.
# You may want to put all your additions into a separate file like
# ~/.bash_aliases, instead of adding them here directly.
diff --git a/src/etc/sudoers.d/vyos b/src/etc/sudoers.d/vyos
index 67d7babc4..198b9b9aa 100644
--- a/src/etc/sudoers.d/vyos
+++ b/src/etc/sudoers.d/vyos
@@ -1,7 +1,8 @@
#
# VyOS modifications to sudo configuration
#
-Defaults syslog_goodpri=info
+Defaults !syslog
+Defaults !pam_session
Defaults env_keep+=VYATTA_*
#
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
index 0f5bf801e..c74fafb42 100644
--- a/src/etc/systemd/system/kea-ctrl-agent.service.d/override.conf
+++ b/src/etc/systemd/system/kea-ctrl-agent.service.d/override.conf
@@ -1,6 +1,7 @@
[Unit]
After=
After=vyos-router.service
+ConditionFileNotEmpty=
[Service]
ExecStart=
diff --git a/src/etc/systemd/system/kea-dhcp4-server.service.d/override.conf b/src/etc/systemd/system/kea-dhcp4-server.service.d/override.conf
index 682e5bbce..4a04892c0 100644
--- a/src/etc/systemd/system/kea-dhcp4-server.service.d/override.conf
+++ b/src/etc/systemd/system/kea-dhcp4-server.service.d/override.conf
@@ -5,3 +5,5 @@ After=vyos-router.service
[Service]
ExecStart=
ExecStart=/usr/sbin/kea-dhcp4 -c /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/rsyslog.service.d/override.conf b/src/etc/systemd/system/rsyslog.service.d/override.conf
new file mode 100644
index 000000000..665b994d9
--- /dev/null
+++ b/src/etc/systemd/system/rsyslog.service.d/override.conf
@@ -0,0 +1,10 @@
+[Unit]
+StartLimitIntervalSec=0
+
+[Service]
+ExecStart=
+ExecStart=/usr/sbin/rsyslogd -n -iNONE -f /run/rsyslog/rsyslog.conf
+Restart=always
+RestartPreventExitStatus=
+RestartSec=10
+RuntimeDirectoryPreserve=yes
diff --git a/src/etc/udev/rules.d/90-vyos-serial.rules b/src/etc/udev/rules.d/90-vyos-serial.rules
index 30c1d3170..f86b2258f 100644
--- a/src/etc/udev/rules.d/90-vyos-serial.rules
+++ b/src/etc/udev/rules.d/90-vyos-serial.rules
@@ -8,7 +8,7 @@ SUBSYSTEMS=="pci", IMPORT{builtin}="hwdb --subsystem=pci"
SUBSYSTEMS=="usb", IMPORT{builtin}="usb_id", IMPORT{builtin}="hwdb --subsystem=usb"
# /dev/serial/by-path/, /dev/serial/by-id/ for USB devices
-KERNEL!="ttyUSB[0-9]*", GOTO="serial_end"
+KERNEL!="ttyUSB[0-9]*|ttyACM[0-9]*", GOTO="serial_end"
SUBSYSTEMS=="usb-serial", ENV{.ID_PORT}="$attr{port_number}"
diff --git a/src/helpers/latest-image-url.py b/src/helpers/latest-image-url.py
new file mode 100755
index 000000000..ea201ef7c
--- /dev/null
+++ b/src/helpers/latest-image-url.py
@@ -0,0 +1,21 @@
+#!/usr/bin/env python3
+
+import sys
+
+from vyos.configquery import ConfigTreeQuery
+from vyos.version import get_remote_version
+
+
+if __name__ == '__main__':
+ image_path = ''
+
+ config = ConfigTreeQuery()
+ if config.exists('system update-check url'):
+ configured_url_version = config.value('system update-check url')
+ remote_url_list = get_remote_version(configured_url_version)
+ if remote_url_list:
+ image_path = remote_url_list[0].get('url')
+ else:
+ sys.exit(1)
+
+ print(image_path)
diff --git a/src/helpers/show_commit_data.py b/src/helpers/show_commit_data.py
new file mode 100755
index 000000000..d507ed9a4
--- /dev/null
+++ b/src/helpers/show_commit_data.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2025 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 used to show the commit data of the configuration
+
+import sys
+from pathlib import Path
+from argparse import ArgumentParser
+
+from vyos.config_mgmt import ConfigMgmt
+from vyos.configtree import ConfigTree
+from vyos.configtree import show_commit_data
+
+cm = ConfigMgmt()
+
+parser = ArgumentParser(
+ description='Show commit priority queue; no options compares the last two commits'
+)
+parser.add_argument('--active-config', help='Path to the active configuration file')
+parser.add_argument('--proposed-config', help='Path to the proposed configuration file')
+args = parser.parse_args()
+
+active_arg = args.active_config
+proposed_arg = args.proposed_config
+
+if active_arg and not proposed_arg:
+ print('--proposed-config is required when --active-config is specified')
+ sys.exit(1)
+
+if not active_arg and not proposed_arg:
+ active = cm.get_config_tree_revision(1)
+ proposed = cm.get_config_tree_revision(0)
+else:
+ if active_arg:
+ active = ConfigTree(Path(active_arg).read_text())
+ else:
+ active = cm.get_config_tree_revision(0)
+
+ proposed = ConfigTree(Path(proposed_arg).read_text())
+
+ret = show_commit_data(active, proposed)
+print(ret)
diff --git a/src/helpers/test_commit.py b/src/helpers/test_commit.py
new file mode 100755
index 000000000..00a413687
--- /dev/null
+++ b/src/helpers/test_commit.py
@@ -0,0 +1,49 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2025 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 used to test execution of the commit algorithm by vyos-commitd
+
+from pathlib import Path
+from argparse import ArgumentParser
+from datetime import datetime
+
+from vyos.configtree import ConfigTree
+from vyos.configtree import test_commit
+
+
+parser = ArgumentParser(
+ description='Execute commit priority queue'
+)
+parser.add_argument(
+ '--active-config', help='Path to the active configuration file', required=True
+)
+parser.add_argument(
+ '--proposed-config', help='Path to the proposed configuration file', required=True
+)
+args = parser.parse_args()
+
+active_arg = args.active_config
+proposed_arg = args.proposed_config
+
+active = ConfigTree(Path(active_arg).read_text())
+proposed = ConfigTree(Path(proposed_arg).read_text())
+
+
+time_begin_commit = datetime.now()
+test_commit(active, proposed)
+time_end_commit = datetime.now()
+print(f'commit time: {time_end_commit - time_begin_commit}')
diff --git a/src/helpers/vyos-certbot-renew-pki.sh b/src/helpers/vyos-certbot-renew-pki.sh
index d0b663f7b..1c273d2fa 100755
--- a/src/helpers/vyos-certbot-renew-pki.sh
+++ b/src/helpers/vyos-certbot-renew-pki.sh
@@ -1,3 +1,3 @@
-#!/bin/sh
+#!/bin/vbash
source /opt/vyatta/etc/functions/script-template
/usr/libexec/vyos/conf_mode/pki.py certbot_renew
diff --git a/src/helpers/vyos-load-balancer.py b/src/helpers/vyos-load-balancer.py
new file mode 100755
index 000000000..30329fd5c
--- /dev/null
+++ b/src/helpers/vyos-load-balancer.py
@@ -0,0 +1,312 @@
+#!/usr/bin/python3
+
+# Copyright 2024-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 json
+import os
+import signal
+import sys
+import time
+
+from vyos.config import Config
+from vyos.template import render
+from vyos.utils.commit import commit_in_progress
+from vyos.utils.network import get_interface_address
+from vyos.utils.process import rc_cmd
+from vyos.utils.process import run
+from vyos.xml_ref import get_defaults
+from vyos.wanloadbalance import health_ping_host
+from vyos.wanloadbalance import health_ping_host_ttl
+from vyos.wanloadbalance import parse_dhcp_nexthop
+from vyos.wanloadbalance import parse_ppp_nexthop
+
+nftables_wlb_conf = '/run/nftables_wlb.conf'
+wlb_status_file = '/run/wlb_status.json'
+wlb_pid_file = '/run/wlb_daemon.pid'
+sleep_interval = 5 # Main loop sleep interval
+
+def health_check(ifname, conf, state, test_defaults):
+ # Run health tests for interface
+
+ if get_ipv4_address(ifname) is None:
+ return False
+
+ if 'test' not in conf:
+ resp_time = test_defaults['resp-time']
+ target = conf['nexthop']
+
+ if target == 'dhcp':
+ target = state['dhcp_nexthop']
+
+ if not target:
+ return False
+
+ return health_ping_host(target, ifname, wait_time=resp_time)
+
+ for test_id, test_conf in conf['test'].items():
+ check_type = test_conf['type']
+
+ if check_type == 'ping':
+ resp_time = test_conf['resp_time']
+ target = test_conf['target']
+ if not health_ping_host(target, ifname, wait_time=resp_time):
+ return False
+ elif check_type == 'ttl':
+ target = test_conf['target']
+ ttl_limit = test_conf['ttl_limit']
+ if not health_ping_host_ttl(target, ifname, ttl_limit=ttl_limit):
+ return False
+ elif check_type == 'user-defined':
+ script = test_conf['test_script']
+ rc = run(script)
+ if rc != 0:
+ return False
+
+ return True
+
+def on_state_change(lb, ifname, state):
+ # Run hook on state change
+ if 'hook' in lb:
+ script_path = os.path.join('/config/scripts/', lb['hook'])
+ env = {
+ 'WLB_INTERFACE_NAME': ifname,
+ 'WLB_INTERFACE_STATE': 'ACTIVE' if state else 'FAILED'
+ }
+
+ code = run(script_path, env=env)
+ if code != 0:
+ print('WLB hook returned non-zero error code')
+
+ print(f'INFO: State change: {ifname} -> {state}')
+
+def get_ipv4_address(ifname):
+ # Get primary ipv4 address on interface (for source nat)
+ addr_json = get_interface_address(ifname)
+ if addr_json and 'addr_info' in addr_json and len(addr_json['addr_info']) > 0:
+ for addr_info in addr_json['addr_info']:
+ if addr_info['family'] == 'inet':
+ if 'local' in addr_info:
+ return addr_json['addr_info'][0]['local']
+ return None
+
+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)
+
+ table_num = lb['health_state'][ifname]['table_number']
+
+ if dhcp_nexthop_addr and lb['health_state'][ifname]['dhcp_nexthop'] != dhcp_nexthop_addr:
+ lb['health_state'][ifname]['dhcp_nexthop'] = dhcp_nexthop_addr
+ run(f'ip route replace table {table_num} default dev {ifname} via {dhcp_nexthop_addr}')
+
+ if_addr = get_ipv4_address(ifname)
+ if if_addr and if_addr != lb['health_state'][ifname]['if_addr']:
+ lb['health_state'][ifname]['if_addr'] = if_addr
+ return True
+
+ return False
+
+def nftables_update(lb):
+ # Atomically reload nftables table from template
+ if not os.path.exists(nftables_wlb_conf):
+ lb['first_install'] = True
+ elif 'first_install' in lb:
+ del lb['first_install']
+
+ render(nftables_wlb_conf, 'load-balancing/nftables-wlb.j2', lb)
+
+ rc, out = rc_cmd(f'nft -f {nftables_wlb_conf}')
+
+ if rc != 0:
+ print('ERROR: Failed to apply WLB nftables config')
+ print('Output:', out)
+ return False
+
+ return True
+
+def cleanup(lb):
+ if 'interface_health' in lb:
+ index = 1
+ for ifname, health_conf in lb['interface_health'].items():
+ table_num = lb['mark_offset'] + index
+ run(f'ip route del table {table_num} default')
+ run(f'ip rule del fwmark {hex(table_num)} table {table_num}')
+ index += 1
+
+ run(f'nft delete table ip vyos_wanloadbalance')
+
+def get_config():
+ conf = Config()
+ base = ['load-balancing', 'wan']
+ lb = conf.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True, with_recursive_defaults=True)
+
+ lb['test_defaults'] = get_defaults(base + ['interface-health', 'A', 'test', 'B'], get_first_key=True)
+
+ return lb
+
+if __name__ == '__main__':
+ while commit_in_progress():
+ print("Notice: Waiting for commit to complete...")
+ time.sleep(1)
+
+ lb = get_config()
+
+ lb['health_state'] = {}
+ lb['mark_offset'] = 0xc8
+
+ # Create state dicts, interface address and nexthop, install routes and ip rules
+ if 'interface_health' in lb:
+ index = 1
+ for ifname, health_conf in lb['interface_health'].items():
+ table_num = lb['mark_offset'] + index
+ addr = get_ipv4_address(ifname)
+ lb['health_state'][ifname] = {
+ 'if_addr': addr,
+ 'failure_count': 0,
+ 'success_count': 0,
+ 'last_success': 0,
+ 'last_failure': 0,
+ 'state': addr is not None,
+ 'state_changed': False,
+ 'table_number': table_num,
+ 'mark': hex(table_num)
+ }
+
+ if health_conf['nexthop'] == 'dhcp':
+ lb['health_state'][ifname]['dhcp_nexthop'] = None
+
+ dynamic_nexthop_update(lb, ifname)
+ 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}')
+
+ index += 1
+
+ nftables_update(lb)
+
+ run('ip route flush cache')
+
+ if 'flush_connections' in lb:
+ run('conntrack --delete')
+ run('conntrack -F expect')
+
+ with open(wlb_status_file, 'w') as f:
+ f.write(json.dumps(lb['health_state']))
+
+ # Signal handler SIGUSR2 -> dhcpcd update
+ def handle_sigusr2(signum, frame):
+ for ifname, health_conf in lb['interface_health'].items():
+ if 'nexthop' in health_conf and health_conf['nexthop'] == 'dhcp':
+ retval = dynamic_nexthop_update(lb, ifname)
+
+ if retval:
+ nftables_update(lb)
+
+ # Signal handler SIGTERM -> exit
+ def handle_sigterm(signum, frame):
+ if os.path.exists(wlb_status_file):
+ os.unlink(wlb_status_file)
+
+ if os.path.exists(wlb_pid_file):
+ os.unlink(wlb_pid_file)
+
+ if os.path.exists(nftables_wlb_conf):
+ os.unlink(nftables_wlb_conf)
+
+ cleanup(lb)
+ sys.exit(0)
+
+ signal.signal(signal.SIGUSR2, handle_sigusr2)
+ signal.signal(signal.SIGINT, handle_sigterm)
+ signal.signal(signal.SIGTERM, handle_sigterm)
+
+ with open(wlb_pid_file, 'w') as f:
+ f.write(str(os.getpid()))
+
+ # Main loop
+
+ try:
+ while True:
+ ip_change = False
+
+ if 'interface_health' in lb:
+ for ifname, health_conf in lb['interface_health'].items():
+ state = lb['health_state'][ifname]
+
+ result = health_check(ifname, health_conf, state=state, test_defaults=lb['test_defaults'])
+
+ state_changed = result != state['state']
+ state['state_changed'] = False
+
+ if result:
+ state['failure_count'] = 0
+ state['success_count'] += 1
+ state['last_success'] = time.time()
+ if state_changed and state['success_count'] >= int(health_conf['success_count']):
+ state['state'] = True
+ state['state_changed'] = True
+ elif not result:
+ state['failure_count'] += 1
+ state['success_count'] = 0
+ state['last_failure'] = time.time()
+ if state_changed and state['failure_count'] >= int(health_conf['failure_count']):
+ state['state'] = False
+ state['state_changed'] = True
+
+ if state['state_changed']:
+ state['if_addr'] = get_ipv4_address(ifname)
+ on_state_change(lb, ifname, state['state'])
+
+ if dynamic_nexthop_update(lb, ifname):
+ ip_change = True
+
+ if any(state['state_changed'] for ifname, state in lb['health_state'].items()):
+ if not nftables_update(lb):
+ break
+
+ run('ip route flush cache')
+
+ if 'flush_connections' in lb:
+ run('conntrack --delete')
+ run('conntrack -F expect')
+
+ with open(wlb_status_file, 'w') as f:
+ f.write(json.dumps(lb['health_state']))
+ elif ip_change:
+ nftables_update(lb)
+
+ time.sleep(sleep_interval)
+ except Exception as e:
+ print('WLB ERROR:', e)
+
+ if os.path.exists(wlb_status_file):
+ os.unlink(wlb_status_file)
+
+ if os.path.exists(wlb_pid_file):
+ os.unlink(wlb_pid_file)
+
+ if os.path.exists(nftables_wlb_conf):
+ os.unlink(nftables_wlb_conf)
+
+ cleanup(lb)
diff --git a/src/init/vyos-router b/src/init/vyos-router
index f8cc87507..081adf214 100755
--- a/src/init/vyos-router
+++ b/src/init/vyos-router
@@ -24,6 +24,8 @@ declare action=$1; shift
declare -x BOOTFILE=$vyatta_sysconfdir/config/config.boot
declare -x DEFAULT_BOOTFILE=$vyatta_sysconfdir/config.boot.default
+declare -x VYCONF_CONFIG_DIR=/usr/libexec/vyos/vyconf/config
+
# If vyos-config= boot option is present, use that file instead
for x in $(cat /proc/cmdline); do
[[ $x = vyos-config=* ]] || continue
@@ -146,6 +148,10 @@ init_bootfile () {
chgrp ${GROUP} $BOOTFILE
chmod 660 $BOOTFILE
fi
+ if [ -d $VYCONF_CONFIG_DIR ] ; then
+ cp -f $BOOTFILE $VYCONF_CONFIG_DIR/config.boot
+ cp -f $DEFAULT_BOOTFILE $VYCONF_CONFIG_DIR/config.failsafe
+ fi
}
# if necessary, migrate initial config
@@ -154,6 +160,10 @@ 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"
+ # update vyconf copy after migration
+ if [ -d $VYCONF_CONFIG_DIR ] ; then
+ cp -f $BOOTFILE $VYCONF_CONFIG_DIR/config.boot
+ fi
fi
}
@@ -407,6 +417,7 @@ gen_duid ()
start ()
{
+ echo -e "Initializing VyOS router\033[0m"
# reset and clean config files
security_reset || log_failure_msg "security reset failed"
@@ -451,6 +462,7 @@ start ()
# 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"
${vyos_conf_scripts_dir}/system_console.py || log_failure_msg "could not reset serial console"
${vyos_conf_scripts_dir}/system_login_banner.py || log_failure_msg "could not reset motd and issue files"
${vyos_conf_scripts_dir}/system_option.py || log_failure_msg "could not reset system option files"
@@ -464,9 +476,10 @@ start ()
# enable some debugging before loading the configuration
if grep -q vyos-debug /proc/cmdline; then
log_action_begin_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
touch /tmp/vyos.ifconfig.debug
- touch /tmp/vyos.frr.debug
touch /tmp/vyos.container.debug
touch /tmp/vyos.smoketest.debug
fi
@@ -505,7 +518,6 @@ start ()
cleanup_post_commit_hooks
- log_daemon_msg "Starting VyOS router"
disabled migrate || migrate_bootfile
restore_if_missing_preconfig_script
@@ -518,6 +530,8 @@ start ()
disabled system_config || system_config
+ systemctl start vyconfd.service
+
for s in ${subinit[@]} ; do
if ! disabled $s; then
log_progress_msg $s
@@ -543,6 +557,9 @@ start ()
if [[ ! -z "$tmp" ]]; then
vtysh -c "rpki start"
fi
+
+ # Start netplug daemon
+ systemctl start netplug.service
}
stop()
@@ -560,6 +577,8 @@ stop()
umount ${vyatta_configdir}
log_action_end_msg $?
+ systemctl stop netplug.service
+ systemctl stop vyconfd.service
systemctl stop frr.service
unmount_encrypted_config
diff --git a/src/migration-scripts/bgp/5-to-6 b/src/migration-scripts/bgp/5-to-6
new file mode 100644
index 000000000..e6fea6574
--- /dev/null
+++ b/src/migration-scripts/bgp/5-to-6
@@ -0,0 +1,39 @@
+# Copyright 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/>.
+
+# T7163: migrate "address-family ipv4|6-unicast redistribute table" from a multi
+# leafNode to a tagNode. This is needed to support per table definition of a
+# route-map and/or metric
+
+from vyos.configtree import ConfigTree
+
+def migrate(config: ConfigTree) -> None:
+ bgp_base = ['protocols', 'bgp']
+ if not config.exists(bgp_base):
+ return
+
+ for address_family in ['ipv4-unicast', 'ipv6-unicast']:
+ # there is no non-main routing table beeing redistributed under this addres 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):
+ continue
+
+ tables = config.return_values(table_path)
+ config.delete(table_path)
+
+ for table in tables:
+ config.set(table_path + [table])
+ config.set_tag(table_path)
diff --git a/src/migration-scripts/dns-dynamic/1-to-2 b/src/migration-scripts/dns-dynamic/1-to-2
index 5dca9e32f..7f4938147 100644
--- a/src/migration-scripts/dns-dynamic/1-to-2
+++ b/src/migration-scripts/dns-dynamic/1-to-2
@@ -20,6 +20,10 @@
# - migrate "service dns dynamic address <interface> service <service> protocol dnsexit"
# to "service dns dynamic address <interface> service <service> protocol dnsexit2"
+# T6950:
+# - add if statement to prevent processing of "service dns dynamic address" options if they don't exist
+# due to the fact they are no longer valid syntax
+
from vyos.configtree import ConfigTree
base_path = ['service', 'dns', 'dynamic']
@@ -36,16 +40,19 @@ def migrate(config: ConfigTree) -> None:
if config.exists(timeout_path):
config.rename(timeout_path, 'interval')
- # Remove "service dns dynamic address <interface> web-options ..." when <interface> != "web"
- for address in config.list_nodes(address_path):
- if config.exists(address_path + [address, 'web-options']) and address != 'web':
- config.delete(address_path + [address, 'web-options'])
-
- # 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')
+ # T6950: Can't migrate address if it doesn't exist
+ if config.exists(address_path):
+
+ # Remove "service dns dynamic address <interface> web-options ..." when <interface> != "web"
+ for address in config.list_nodes(address_path):
+ if config.exists(address_path + [address, 'web-options']) and address != 'web':
+ config.delete(address_path + [address, 'web-options'])
+
+ # 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')
diff --git a/src/migration-scripts/firewall/16-to-17 b/src/migration-scripts/firewall/16-to-17
index ad0706f04..ad0706f04 100755..100644
--- a/src/migration-scripts/firewall/16-to-17
+++ b/src/migration-scripts/firewall/16-to-17
diff --git a/src/migration-scripts/firewall/17-to-18 b/src/migration-scripts/firewall/17-to-18
new file mode 100755
index 000000000..34ce6aa07
--- /dev/null
+++ b/src/migration-scripts/firewall/17-to-18
@@ -0,0 +1,41 @@
+# Copyright (C) 2024-2025 VyOS maintainers and contributors
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; 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 zone <zone> interface RED
+# set firewall zone <zone> interface eth0
+# To
+# set firewall zone <zone> member vrf RED
+# set firewall zone <zone> member interface eth0
+
+from vyos.configtree import ConfigTree
+
+base = ['firewall', 'zone']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ for zone in config.list_nodes(base):
+ zone_iface_base = base + [zone, 'interface']
+ zone_member_base = base + [zone, 'member']
+ if config.exists(zone_iface_base):
+ for iface in config.return_values(zone_iface_base):
+ if config.exists(['vrf', 'name', iface]):
+ config.set(zone_member_base + ['vrf'], value=iface, replace=False)
+ else:
+ config.set(zone_member_base + ['interface'], value=iface, replace=False)
+ config.delete(zone_iface_base)
diff --git a/src/migration-scripts/flow-accounting/1-to-2 b/src/migration-scripts/flow-accounting/1-to-2
new file mode 100644
index 000000000..5ffb1eec8
--- /dev/null
+++ b/src/migration-scripts/flow-accounting/1-to-2
@@ -0,0 +1,63 @@
+# Copyright 2021-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+# migrate 'system flow-accounting sflow' to 'system sflow'
+
+from vyos.configtree import ConfigTree
+
+base = ['system', 'flow-accounting']
+base_fa_sflow = base + ['sflow']
+base_sflow = ['system', 'sflow']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base_fa_sflow):
+ # Nothing to do
+ return
+
+ if not config.exists(base_sflow):
+
+ for iface in config.return_values(base + ['interface']):
+ config.set(base_sflow + ['interface'], value=iface, replace=False)
+
+ if config.exists(base + ['vrf']):
+ vrf = config.return_value(base + ['vrf'])
+ config.set(base_sflow + ['vrf'], value=vrf)
+
+ if config.exists(base + ['enable-egress']):
+ config.set(base_sflow + ['enable-egress'])
+
+ if config.exists(base_fa_sflow + ['agent-address']):
+ address = config.return_value(base_fa_sflow + ['agent-address'])
+ config.set(base_sflow + ['agent-address'], value=address)
+
+ if config.exists(base_fa_sflow + ['sampling-rate']):
+ sr = config.return_value(base_fa_sflow + ['sampling-rate'])
+ config.set(base_sflow + ['sampling-rate'], value=sr)
+
+ for server in config.list_nodes(base_fa_sflow + ['server']):
+ config.set(base_sflow + ['server'])
+ config.set_tag(base_sflow + ['server'])
+ config.set(base_sflow + ['server', server])
+ tmp = base_fa_sflow + ['server', server]
+ if config.exists(tmp + ['port']):
+ port = config.return_value(tmp + ['port'])
+ config.set(base_sflow + ['server', server, 'port'], value=port)
+
+ if config.exists(base + ['netflow']):
+ # delete only sflow from flow-accounting if netflow is set
+ config.delete(base_fa_sflow)
+ else:
+ # delete all flow-accounting config otherwise
+ config.delete(base)
diff --git a/src/migration-scripts/lldp/2-to-3 b/src/migration-scripts/lldp/2-to-3
new file mode 100644
index 000000000..93090756c
--- /dev/null
+++ b/src/migration-scripts/lldp/2-to-3
@@ -0,0 +1,31 @@
+# Copyright 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/>.
+
+# T7165: Migrate LLDP interface disable to 'mode disable'
+
+from vyos.configtree import ConfigTree
+
+base = ['service', 'lldp']
+
+def migrate(config: ConfigTree) -> None:
+ interface_base = base + ['interface']
+ if not config.exists(interface_base):
+ # Nothing to do
+ return
+
+ for interface in config.list_nodes(interface_base):
+ if config.exists(interface_base + [interface, 'disable']):
+ config.delete(interface_base + [interface, 'disable'])
+ config.set(interface_base + [interface, 'mode'], value='disable')
diff --git a/src/migration-scripts/monitoring/1-to-2 b/src/migration-scripts/monitoring/1-to-2
new file mode 100644
index 000000000..8bdaebae9
--- /dev/null
+++ b/src/migration-scripts/monitoring/1-to-2
@@ -0,0 +1,50 @@
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+# T6953: merge node and frr exporter under prometheus section
+
+from vyos.configtree import ConfigTree
+
+old_base = ['service', 'monitoring']
+new_base = ['service', 'monitoring', 'prometheus']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(old_base):
+ # Nothing to do
+ return
+
+ if config.exists(old_base + ['node-exporter']):
+ if config.exists(old_base + ['node-exporter', 'listen-address']):
+ tmp = config.return_value(old_base + ['node-exporter', 'listen-address'])
+ config.set(new_base + ['node-exporter', 'listen-address'], value=tmp)
+ if config.exists(old_base + ['node-exporter', 'port']):
+ tmp = config.return_value(old_base + ['node-exporter', 'port'])
+ config.set(new_base + ['node-exporter', 'port'], value=tmp)
+ if config.exists(old_base + ['node-exporter', 'vrf']):
+ tmp = config.return_value(old_base + ['node-exporter', 'vrf'])
+ config.set(new_base + ['node-exporter', 'vrf'], value=tmp)
+ config.delete(old_base + ['node-exporter'])
+
+ if config.exists(old_base + ['frr-exporter']):
+ if config.exists(old_base + ['frr-exporter', 'listen-address']):
+ tmp = config.return_value(old_base + ['frr-exporter', 'listen-address'])
+ config.set(new_base + ['frr-exporter', 'listen-address'], value=tmp)
+ if config.exists(old_base + ['frr-exporter', 'port']):
+ tmp = config.return_value(old_base + ['frr-exporter', 'port'])
+ config.set(new_base + ['frr-exporter', 'port'], value=tmp)
+ if config.exists(old_base + ['frr-exporter', 'vrf']):
+ tmp = config.return_value(old_base + ['frr-exporter', 'vrf'])
+ config.set(new_base + ['frr-exporter', 'vrf'], value=tmp)
+ config.delete(old_base + ['frr-exporter'])
diff --git a/src/migration-scripts/nhrp/0-to-1 b/src/migration-scripts/nhrp/0-to-1
new file mode 100644
index 000000000..badd88e04
--- /dev/null
+++ b/src/migration-scripts/nhrp/0-to-1
@@ -0,0 +1,129 @@
+# Copyright 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/>.
+
+# Migration from Opennhrp to FRR NHRP
+import ipaddress
+
+from vyos.configtree import ConfigTree
+
+base = ['protocols', 'nhrp', 'tunnel']
+interface_base = ['interfaces', 'tunnel']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+ networkid = 1
+ for tunnel_name in config.list_nodes(base):
+ ## Cisco Authentication migration
+ if config.exists(base + [tunnel_name,'cisco-authentication']):
+ auth = config.return_value(base + [tunnel_name,'cisco-authentication'])
+ config.delete(base + [tunnel_name,'cisco-authentication'])
+ config.set(base + [tunnel_name,'authentication'], value=auth)
+ ## Delete Dynamic-map to fqdn
+ if config.exists(base + [tunnel_name,'dynamic-map']):
+ config.delete(base + [tunnel_name,'dynamic-map'])
+ ## Holdtime migration
+ if config.exists(base + [tunnel_name,'holding-time']):
+ holdtime = config.return_value(base + [tunnel_name,'holding-time'])
+ config.delete(base + [tunnel_name,'holding-time'])
+ config.set(base + [tunnel_name,'holdtime'], value=holdtime)
+ ## Add network-id
+ config.set(base + [tunnel_name, 'network-id'], value=networkid)
+ networkid+=1
+ ## Map and nhs migration
+ nhs_tunnelip_list = []
+ nhs_nbmaip_list = []
+ is_nhs = False
+ if config.exists(base + [tunnel_name,'map']):
+ is_map = False
+ for tunnel_ip in config.list_nodes(base + [tunnel_name, 'map']):
+ tunnel_ip_path = base + [tunnel_name, 'map', tunnel_ip]
+ tunnel_ip = tunnel_ip.split('/')[0]
+ if config.exists(tunnel_ip_path + ['cisco']):
+ config.delete(tunnel_ip_path + ['cisco'])
+ if config.exists(tunnel_ip_path + ['nbma-address']):
+ nbma = config.return_value(tunnel_ip_path + ['nbma-address'])
+ if config.exists (tunnel_ip_path + ['register']):
+ config.delete(tunnel_ip_path + ['register'])
+ config.delete(tunnel_ip_path + ['nbma-address'])
+ config.set(base + [tunnel_name, 'nhs', 'tunnel-ip', tunnel_ip, 'nbma'], value=nbma)
+ is_nhs = True
+ if tunnel_ip not in nhs_tunnelip_list:
+ nhs_tunnelip_list.append(tunnel_ip)
+ if nbma not in nhs_nbmaip_list:
+ nhs_nbmaip_list.append(nbma)
+ else:
+ config.delete(tunnel_ip_path + ['nbma-address'])
+ config.set(base + [tunnel_name, 'map_test', 'tunnel-ip', tunnel_ip, 'nbma'], value=nbma)
+ is_map = True
+ config.delete(base + [tunnel_name,'map'])
+
+ if is_nhs:
+ config.set_tag(base + [tunnel_name, 'nhs', 'tunnel-ip'])
+
+ if is_map:
+ config.copy(base + [tunnel_name, 'map_test'], base + [tunnel_name, 'map'])
+ config.delete(base + [tunnel_name, 'map_test'])
+ config.set_tag(base + [tunnel_name, 'map', 'tunnel-ip'])
+
+ #
+ # Change netmask to /32 on tunnel interface
+ # If nhs is alone, add static route tunnel network to nhs
+ #
+ if config.exists(interface_base + [tunnel_name, 'address']):
+ tunnel_ip_list = []
+ for tunnel_ip in config.return_values(
+ interface_base + [tunnel_name, 'address']):
+ tunnel_ip_ch = tunnel_ip.split('/')[0]+'/32'
+ if tunnel_ip_ch not in tunnel_ip_list:
+ tunnel_ip_list.append(tunnel_ip_ch)
+ for nhs in nhs_tunnelip_list:
+ config.set(['protocols', 'static', 'route', str(ipaddress.ip_network(tunnel_ip, strict=False)), 'next-hop', nhs, 'distance'], value='250')
+ if nhs_tunnelip_list:
+ if not config.is_tag(['protocols', 'static', 'route']):
+ config.set_tag(['protocols', 'static', 'route'])
+ if not config.is_tag(['protocols', 'static', 'route', str(ipaddress.ip_network(tunnel_ip, strict=False)), 'next-hop']):
+ config.set_tag(['protocols', 'static', 'route', str(ipaddress.ip_network(tunnel_ip, strict=False)), 'next-hop'])
+
+ config.delete(interface_base + [tunnel_name, 'address'])
+ for tunnel_ip in tunnel_ip_list:
+ config.set(
+ interface_base + [tunnel_name, 'address'], value=tunnel_ip, replace=False)
+
+ ## Map multicast migration
+ if config.exists(base + [tunnel_name, 'multicast']):
+ multicast_map = config.return_value(
+ base + [tunnel_name, 'multicast'])
+ if multicast_map == 'nhs':
+ config.delete(base + [tunnel_name, 'multicast'])
+ for nbma in nhs_nbmaip_list:
+ config.set(base + [tunnel_name, 'multicast'], value=nbma,
+ replace=False)
+
+ ## Delete non-cahching
+ if config.exists(base + [tunnel_name, 'non-caching']):
+ config.delete(base + [tunnel_name, 'non-caching'])
+ ## Delete shortcut-destination
+ if config.exists(base + [tunnel_name, 'shortcut-destination']):
+ if not config.exists(base + [tunnel_name, 'shortcut']):
+ config.set(base + [tunnel_name, 'shortcut'])
+ config.delete(base + [tunnel_name, 'shortcut-destination'])
+ ## Delete shortcut-target
+ if config.exists(base + [tunnel_name, 'shortcut-target']):
+ if not config.exists(base + [tunnel_name, 'shortcut']):
+ 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
diff --git a/src/migration-scripts/ntp/1-to-2 b/src/migration-scripts/ntp/1-to-2
index fd7b08221..d5f800922 100644
--- a/src/migration-scripts/ntp/1-to-2
+++ b/src/migration-scripts/ntp/1-to-2
@@ -1,4 +1,4 @@
-# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+# 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
@@ -25,6 +25,11 @@ def migrate(config: ConfigTree) -> None:
# Nothing to do
return
+ # T6911: do not migrate NTP configuration if mandatory server is missing
+ if not config.exists(base_path + ['server']):
+ config.delete(base_path)
+ return
+
# config.copy does not recursively create a path, so create ['service'] if
# it doesn't yet exist, such as for config.boot.default
if not config.exists(['service']):
diff --git a/src/migration-scripts/policy/8-to-9 b/src/migration-scripts/policy/8-to-9
new file mode 100644
index 000000000..355e48e00
--- /dev/null
+++ b/src/migration-scripts/policy/8-to-9
@@ -0,0 +1,49 @@
+# Copyright (C) 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/>.
+
+# T7116: Remove unsupported "internet" community following FRR removal
+# From
+ # set policy route-map <name> rule <ord> set community [add | replace] internet
+ # set policy community-list <name> rule <ord> regex internet
+# To
+ # set policy route-map <name> rule <ord> set community [add | replace] 0:0
+ # set policy community-list <name> rule <ord> regex _0:0_
+
+# NOTE: In FRR expanded community-lists, without the '_' delimiters, a regex of
+# "0:0" will match "65000:0" as well as "0:0". This doesn't line up with what
+# we want when replacing "internet".
+
+from vyos.configtree import ConfigTree
+
+rm_base = ['policy', 'route-map']
+cl_base = ['policy', 'community-list']
+
+def migrate(config: ConfigTree) -> None:
+ if config.exists(rm_base):
+ for policy_name in config.list_nodes(rm_base):
+ for rule_ord in config.list_nodes(rm_base + [policy_name, 'rule'], path_must_exist=False):
+ tmp_path = rm_base + [policy_name, 'rule', rule_ord, 'set', 'community']
+ if config.exists(tmp_path + ['add']) and config.return_value(tmp_path + ['add']) == 'internet':
+ config.set(tmp_path + ['add'], '0:0')
+ if config.exists(tmp_path + ['replace']) and config.return_value(tmp_path + ['replace']) == 'internet':
+ config.set(tmp_path + ['replace'], '0:0')
+
+ if config.exists(cl_base):
+ for policy_name in config.list_nodes(cl_base):
+ for rule_ord in config.list_nodes(cl_base + [policy_name, 'rule'], path_must_exist=False):
+ tmp_path = cl_base + [policy_name, 'rule', rule_ord, 'regex']
+ if config.exists(tmp_path) and config.return_value(tmp_path) == 'internet':
+ config.set(tmp_path, '_0:0_')
+
diff --git a/src/migration-scripts/quagga/11-to-12 b/src/migration-scripts/quagga/11-to-12
new file mode 100644
index 000000000..8ae2023a1
--- /dev/null
+++ b/src/migration-scripts/quagga/11-to-12
@@ -0,0 +1,75 @@
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+# T6747:
+# - Migrate static BFD configuration to match FRR possibillities
+# - Consolidate static multicast routing configuration under a new node
+
+from vyos.configtree import ConfigTree
+
+static_base = ['protocols', 'static']
+
+def migrate(config: ConfigTree) -> None:
+ # Check for static route/route6 configuration
+ # Migrate static BFD configuration to match FRR possibillities
+ for route_route6 in ['route', 'route6']:
+ route_route6_base = static_base + [route_route6]
+ if not config.exists(route_route6_base):
+ continue
+
+ for prefix in config.list_nodes(route_route6_base):
+ next_hop_base = route_route6_base + [prefix, 'next-hop']
+ if not config.exists(next_hop_base):
+ continue
+
+ for next_hop in config.list_nodes(next_hop_base):
+ multi_hop_base = next_hop_base + [next_hop, 'bfd', 'multi-hop']
+
+ if not config.exists(multi_hop_base):
+ continue
+
+ mh_source_base = multi_hop_base + ['source']
+ source = None
+ profile = None
+ for src_ip in config.list_nodes(mh_source_base):
+ source = src_ip
+ if config.exists(mh_source_base + [source, 'profile']):
+ profile = config.return_value(mh_source_base + [source, 'profile'])
+ # FRR only supports one source, we will use the first one
+ break
+
+ config.delete(multi_hop_base)
+ config.set(multi_hop_base + ['source-address'], value=source)
+ config.set(next_hop_base + [next_hop, 'bfd', 'profile'], value=profile)
+
+ # Consolidate static multicast routing configuration under a new node
+ if config.exists(static_base + ['multicast']):
+ for mroute in ['interface-route', 'route']:
+ mroute_base = static_base + ['multicast', mroute]
+ if not config.exists(mroute_base):
+ continue
+ config.set(static_base + ['mroute'])
+ config.set_tag(static_base + ['mroute'])
+ for route in config.list_nodes(mroute_base):
+ config.copy(mroute_base + [route], static_base + ['mroute', route])
+
+ mroute_base = static_base + ['mroute']
+ if config.exists(mroute_base):
+ for mroute in config.list_nodes(mroute_base):
+ interface_path = mroute_base + [mroute, 'next-hop-interface']
+ if config.exists(interface_path):
+ config.rename(interface_path, 'interface')
+
+ config.delete(static_base + ['multicast'])
diff --git a/src/migration-scripts/system/28-to-29 b/src/migration-scripts/system/28-to-29
new file mode 100644
index 000000000..ccf7056c4
--- /dev/null
+++ b/src/migration-scripts/system/28-to-29
@@ -0,0 +1,71 @@
+# Copyright 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/>.
+
+# T6989:
+# - remove syslog arbitrary file logging
+# - remove syslog user console logging
+# - move "global preserve-fqdn" one CLI level up
+# - rename "host" to "remote"
+
+from vyos.configtree import ConfigTree
+
+base = ['system', 'syslog']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ return
+ # Drop support for custom file logging
+ if config.exists(base + ['file']):
+ config.delete(base + ['file'])
+
+ # Drop support for logging to a user tty
+ # This should be dynamically added via an op-mode command like "terminal monitor"
+ if config.exists(base + ['user']):
+ config.delete(base + ['user'])
+
+ # Move "global preserve-fqdn" one CLI level up, as it relates to all
+ # logging targets (console, global and remote)
+ preserve_fqdn_base = base + ['global', 'preserve-fqdn']
+ if config.exists(preserve_fqdn_base):
+ config.delete(preserve_fqdn_base)
+ config.set(base + ['preserve-fqdn'])
+
+ # Move "global marker" one CLI level up, as it relates to all
+ # logging targets (console, global and remote)
+ marker_base = base + ['global', 'marker']
+ if config.exists(marker_base):
+ config.copy(marker_base, base + ['marker'])
+ config.delete(marker_base)
+
+ # Rename "global" -> "local" as this describes what is logged locally
+ # on the router to a file on the filesystem
+ if config.exists(base + ['global']):
+ config.rename(base + ['global'], 'local')
+
+ vrf = ''
+ if config.exists(base + ['vrf']):
+ vrf = config.return_value(base + ['vrf'])
+ config.delete(base + ['vrf'])
+
+ # Rename host x.x.x.x -> remote x.x.x.x
+ if config.exists(base + ['host']):
+ 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)
+ config.delete(base + ['host'])
diff --git a/src/migration-scripts/wanloadbalance/3-to-4 b/src/migration-scripts/wanloadbalance/3-to-4
new file mode 100644
index 000000000..e49f46a5b
--- /dev/null
+++ b/src/migration-scripts/wanloadbalance/3-to-4
@@ -0,0 +1,33 @@
+# Copyright 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/>.
+
+from vyos.configtree import ConfigTree
+
+base = ['load-balancing', 'wan']
+
+def migrate(config: ConfigTree) -> None:
+ if not config.exists(base):
+ # Nothing to do
+ return
+
+ if config.exists(base + ['rule']):
+ for rule in config.list_nodes(base + ['rule']):
+ rule_base = base + ['rule', rule]
+
+ if config.exists(rule_base + ['inbound-interface']):
+ ifname = config.return_value(rule_base + ['inbound-interface'])
+
+ if ifname.endswith('+'):
+ config.set(rule_base + ['inbound-interface'], value=ifname.replace('+', '*'))
diff --git a/src/op_mode/dhcp.py b/src/op_mode/dhcp.py
index e5455c8af..725bfc75b 100755
--- a/src/op_mode/dhcp.py
+++ b/src/op_mode/dhcp.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
#
-# Copyright (C) 2022-2024 VyOS maintainers and contributors
+# Copyright (C) 2022-2025 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
@@ -19,6 +19,7 @@ import sys
import typing
from datetime import datetime
+from datetime import timezone
from glob import glob
from ipaddress import ip_address
from tabulate import tabulate
@@ -29,133 +30,72 @@ from vyos.base import Warning
from vyos.configquery import ConfigTreeQuery
from vyos.kea import kea_get_active_config
+from vyos.kea import kea_get_dhcp_pools
from vyos.kea import kea_get_leases
-from vyos.kea import kea_get_pool_from_subnet_id
+from vyos.kea import kea_get_server_leases
+from vyos.kea import kea_get_static_mappings
from vyos.kea import kea_delete_lease
-from vyos.utils.process import is_systemd_service_running
from vyos.utils.process import call
+from vyos.utils.process import is_systemd_service_running
-time_string = "%a %b %d %H:%M:%S %Z %Y"
+time_string = '%a %b %d %H:%M:%S %Z %Y'
config = ConfigTreeQuery()
-lease_valid_states = ['all', 'active', 'free', 'expired', 'released', 'abandoned', 'reset', 'backup']
-sort_valid_inet = ['end', 'mac', 'hostname', 'ip', 'pool', 'remaining', 'start', 'state']
-sort_valid_inet6 = ['end', 'duid', 'ip', 'last_communication', 'pool', 'remaining', 'state', 'type']
+lease_valid_states = [
+ 'all',
+ 'active',
+ 'free',
+ 'expired',
+ 'released',
+ 'abandoned',
+ 'reset',
+ 'backup',
+]
+sort_valid_inet = [
+ 'end',
+ 'mac',
+ 'hostname',
+ 'ip',
+ 'pool',
+ 'remaining',
+ 'start',
+ 'state',
+]
+sort_valid_inet6 = [
+ 'end',
+ 'duid',
+ 'ip',
+ 'last_communication',
+ 'pool',
+ 'remaining',
+ 'state',
+ 'type',
+]
mapping_sort_valid = ['mac', 'ip', 'pool', 'duid']
+stale_warn_msg = 'DHCP server is configured but not started. Data may be stale.'
+
ArgFamily = typing.Literal['inet', 'inet6']
-ArgState = typing.Literal['all', 'active', 'free', 'expired', 'released', 'abandoned', 'reset', 'backup']
+ArgState = typing.Literal[
+ 'all', 'active', 'free', 'expired', 'released', 'abandoned', 'reset', 'backup'
+]
ArgOrigin = typing.Literal['local', 'remote']
-def _utc_to_local(utc_dt):
- return datetime.fromtimestamp((datetime.fromtimestamp(utc_dt) - datetime(1970, 1, 1)).total_seconds())
-
-
-def _format_hex_string(in_str):
- out_str = ""
- # if input is divisible by 2, add : every 2 chars
- if len(in_str) > 0 and len(in_str) % 2 == 0:
- out_str = ':'.join(a+b for a,b in zip(in_str[::2], in_str[1::2]))
- else:
- out_str = in_str
-
- return out_str
-
-
-def _find_list_of_dict_index(lst, key='ip', value='') -> int:
- """
- Find the index entry of list of dict matching the dict value
- Exampe:
- % lst = [{'ip': '192.0.2.1'}, {'ip': '192.0.2.2'}]
- % _find_list_of_dict_index(lst, key='ip', value='192.0.2.2')
- % 1
- """
- idx = next((index for (index, d) in enumerate(lst) if d[key] == value), None)
- return idx
-
-def _get_raw_server_leases(family='inet', pool=None, sorted=None, state=[], origin=None) -> list:
- """
- Get DHCP server leases
- :return list
- """
+def _get_raw_server_leases(
+ config, family='inet', pool=None, sorted=None, state=[], origin=None
+) -> list:
inet_suffix = '6' if family == 'inet6' else '4'
- try:
- leases = kea_get_leases(inet_suffix)
- except:
- raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server lease information')
-
- if pool is None:
- pool = _get_dhcp_pools(family=family)
- else:
- pool = [pool]
-
- try:
- active_config = kea_get_active_config(inet_suffix)
- except:
- raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration')
+ pools = [pool] if pool else kea_get_dhcp_pools(config, inet_suffix)
- data = []
- for lease in leases:
- lifetime = lease['valid-lft']
- expiry = (lease['cltt'] + lifetime)
-
- lease['start_timestamp'] = datetime.utcfromtimestamp(expiry - lifetime)
- lease['expire_timestamp'] = datetime.utcfromtimestamp(expiry) if expiry else None
-
- data_lease = {}
- data_lease['ip'] = lease['ip-address']
- lease_state_long = {0: 'active', 1: 'rejected', 2: 'expired'}
- data_lease['state'] = lease_state_long[lease['state']]
- data_lease['pool'] = kea_get_pool_from_subnet_id(active_config, inet_suffix, lease['subnet-id']) if active_config else '-'
- data_lease['end'] = lease['expire_timestamp'].timestamp() if lease['expire_timestamp'] else None
- data_lease['origin'] = 'local' # TODO: Determine remote in HA
-
- if family == 'inet':
- data_lease['mac'] = lease['hw-address']
- data_lease['start'] = lease['start_timestamp'].timestamp()
- data_lease['hostname'] = lease['hostname']
-
- if family == 'inet6':
- data_lease['last_communication'] = lease['start_timestamp'].timestamp()
- data_lease['duid'] = _format_hex_string(lease['duid'])
- data_lease['type'] = lease['type']
-
- if lease['type'] == 'IA_PD':
- prefix_len = lease['prefix-len']
- data_lease['ip'] += f'/{prefix_len}'
-
- data_lease['remaining'] = '-'
-
- if lease['valid-lft'] > 0:
- data_lease['remaining'] = lease['expire_timestamp'] - datetime.utcnow()
-
- if data_lease['remaining'].days >= 0:
- # substraction gives us a timedelta object which can't be formatted with strftime
- # so we use str(), split gets rid of the microseconds
- data_lease['remaining'] = str(data_lease["remaining"]).split('.')[0]
-
- # Do not add old leases
- if data_lease['remaining'] != '' and data_lease['pool'] in pool and data_lease['state'] != 'free':
- if not state or state == 'all' or data_lease['state'] in state:
- data.append(data_lease)
-
- # deduplicate
- checked = []
- for entry in data:
- addr = entry.get('ip')
- if addr not in checked:
- checked.append(addr)
- else:
- idx = _find_list_of_dict_index(data, key='ip', value=addr)
- data.pop(idx)
+ mappings = kea_get_server_leases(config, inet_suffix, pools, state, origin)
if sorted:
if sorted == 'ip':
- data.sort(key = lambda x:ip_address(x['ip']))
+ mappings.sort(key=lambda x: ip_address(x['ip']))
else:
- data.sort(key = lambda x:x[sorted])
- return data
+ mappings.sort(key=lambda x: x[sorted])
+ return mappings
def _get_formatted_server_leases(raw_data, family='inet'):
@@ -165,46 +105,67 @@ def _get_formatted_server_leases(raw_data, family='inet'):
ipaddr = lease.get('ip')
hw_addr = lease.get('mac')
state = lease.get('state')
- start = lease.get('start')
- start = _utc_to_local(start).strftime('%Y/%m/%d %H:%M:%S')
- end = lease.get('end')
- end = _utc_to_local(end).strftime('%Y/%m/%d %H:%M:%S') if end else '-'
+ start = datetime.fromtimestamp(lease.get('start'), timezone.utc)
+ end = (
+ datetime.fromtimestamp(lease.get('end'), timezone.utc)
+ if lease.get('end')
+ else '-'
+ )
remain = lease.get('remaining')
pool = lease.get('pool')
hostname = lease.get('hostname')
origin = lease.get('origin')
- data_entries.append([ipaddr, hw_addr, state, start, end, remain, pool, hostname, origin])
-
- headers = ['IP Address', 'MAC address', 'State', 'Lease start', 'Lease expiration', 'Remaining', 'Pool',
- 'Hostname', 'Origin']
+ data_entries.append(
+ [ipaddr, hw_addr, state, start, end, remain, pool, hostname, origin]
+ )
+
+ headers = [
+ 'IP Address',
+ 'MAC address',
+ 'State',
+ 'Lease start',
+ 'Lease expiration',
+ 'Remaining',
+ 'Pool',
+ 'Hostname',
+ 'Origin',
+ ]
if family == 'inet6':
for lease in raw_data:
ipaddr = lease.get('ip')
state = lease.get('state')
- start = lease.get('last_communication')
- start = _utc_to_local(start).strftime('%Y/%m/%d %H:%M:%S')
- end = lease.get('end')
- end = _utc_to_local(end).strftime('%Y/%m/%d %H:%M:%S')
+ start = datetime.fromtimestamp(
+ lease.get('last_communication'), timezone.utc
+ )
+ end = (
+ datetime.fromtimestamp(lease.get('end'), timezone.utc)
+ if lease.get('end')
+ else '-'
+ )
remain = lease.get('remaining')
lease_type = lease.get('type')
pool = lease.get('pool')
host_identifier = lease.get('duid')
- data_entries.append([ipaddr, state, start, end, remain, lease_type, pool, host_identifier])
-
- headers = ['IPv6 address', 'State', 'Last communication', 'Lease expiration', 'Remaining', 'Type', 'Pool',
- 'DUID']
+ data_entries.append(
+ [ipaddr, state, start, end, remain, lease_type, pool, host_identifier]
+ )
+
+ headers = [
+ 'IPv6 address',
+ 'State',
+ 'Last communication',
+ 'Lease expiration',
+ 'Remaining',
+ 'Type',
+ 'Pool',
+ 'DUID',
+ ]
output = tabulate(data_entries, headers, numalign='left')
return output
-def _get_dhcp_pools(family='inet') -> list:
- v = 'v6' if family == 'inet6' else ''
- pools = config.list_nodes(f'service dhcp{v}-server shared-network-name')
- return pools
-
-
def _get_pool_size(pool, family='inet'):
v = 'v6' if family == 'inet6' else ''
base = f'service dhcp{v}-server shared-network-name {pool}'
@@ -224,26 +185,27 @@ def _get_pool_size(pool, family='inet'):
return size
-def _get_raw_pool_statistics(family='inet', pool=None):
- if pool is None:
- pool = _get_dhcp_pools(family=family)
- else:
- pool = [pool]
+def _get_raw_server_pool_statistics(config, family='inet', pool=None):
+ inet_suffix = '6' if family == 'inet6' else '4'
+ pools = [pool] if pool else kea_get_dhcp_pools(config, inet_suffix)
- v = 'v6' if family == 'inet6' else ''
stats = []
- for p in pool:
- subnet = config.list_nodes(f'service dhcp{v}-server shared-network-name {p} subnet')
+ for p in pools:
size = _get_pool_size(family=family, pool=p)
- leases = len(_get_raw_server_leases(family=family, pool=p))
+ leases = len(_get_raw_server_leases(config, family=family, pool=p))
use_percentage = round(leases / size * 100) if size != 0 else 0
- pool_stats = {'pool': p, 'size': size, 'leases': leases,
- 'available': (size - leases), 'use_percentage': use_percentage, 'subnet': subnet}
+ pool_stats = {
+ 'pool': p,
+ 'size': size,
+ 'leases': leases,
+ 'available': (size - leases),
+ 'use_percentage': use_percentage,
+ }
stats.append(pool_stats)
return stats
-def _get_formatted_pool_statistics(pool_data, family='inet'):
+def _get_formatted_server_pool_statistics(pool_data):
data_entries = []
for entry in pool_data:
pool = entry.get('pool')
@@ -254,53 +216,52 @@ def _get_formatted_pool_statistics(pool_data, family='inet'):
use_percentage = f'{use_percentage}%'
data_entries.append([pool, size, leases, available, use_percentage])
- headers = ['Pool', 'Size','Leases', 'Available', 'Usage']
+ headers = ['Pool', 'Size', 'Leases', 'Available', 'Usage']
output = tabulate(data_entries, headers, numalign='left')
return output
-def _get_raw_server_static_mappings(family='inet', pool=None, sorted=None):
- if pool is None:
- pool = _get_dhcp_pools(family=family)
- else:
- pool = [pool]
- v = 'v6' if family == 'inet6' else ''
- mappings = []
- for p in pool:
- pool_config = config.get_config_dict(['service', f'dhcp{v}-server', 'shared-network-name', p],
- get_first_key=True)
- if 'subnet' in pool_config:
- for subnet, subnet_config in pool_config['subnet'].items():
- if 'static-mapping' in subnet_config:
- for name, mapping_config in subnet_config['static-mapping'].items():
- mapping = {'pool': p, 'subnet': subnet, 'name': name}
- mapping.update(mapping_config)
- mappings.append(mapping)
+def _get_raw_server_static_mappings(config, family='inet', pool=None, sorted=None):
+ inet_suffix = '6' if family == 'inet6' else '4'
+ pools = [pool] if pool else kea_get_dhcp_pools(config, inet_suffix)
+
+ mappings = kea_get_static_mappings(config, inet_suffix, pools)
if sorted:
if sorted == 'ip':
- data.sort(key = lambda x:ip_address(x['ip-address']))
+ mappings.sort(key=lambda x: ip_address(x['ip']))
else:
- data.sort(key = lambda x:x[sorted])
+ mappings.sort(key=lambda x: x[sorted])
return mappings
-def _get_formatted_server_static_mappings(raw_data, family='inet'):
+
+def _get_formatted_server_static_mappings(raw_data):
data_entries = []
+
for entry in raw_data:
pool = entry.get('pool')
subnet = entry.get('subnet')
- name = entry.get('name')
- ip_addr = entry.get('ip-address', 'N/A')
+ hostname = entry.get('hostname')
+ ip_addr = entry.get('ip', 'N/A')
mac_addr = entry.get('mac', 'N/A')
duid = entry.get('duid', 'N/A')
- description = entry.get('description', 'N/A')
- data_entries.append([pool, subnet, name, ip_addr, mac_addr, duid, description])
-
- headers = ['Pool', 'Subnet', 'Name', 'IP Address', 'MAC Address', 'DUID', 'Description']
+ desc = entry.get('description', 'N/A')
+ data_entries.append([pool, subnet, hostname, ip_addr, mac_addr, duid, desc])
+
+ headers = [
+ 'Pool',
+ 'Subnet',
+ 'Hostname',
+ 'IP Address',
+ 'MAC Address',
+ 'DUID',
+ 'Description',
+ ]
output = tabulate(data_entries, headers, numalign='left')
return output
-def _verify(func):
+
+def _verify_server(func):
"""Decorator checks if DHCP(v6) config exists"""
from functools import wraps
@@ -314,8 +275,10 @@ def _verify(func):
if not config.exists(f'service dhcp{v}-server'):
raise vyos.opmode.UnconfiguredSubsystem(unconf_message)
return func(*args, **kwargs)
+
return _wrapper
+
def _verify_client(func):
"""Decorator checks if interface is configured as DHCP client"""
from functools import wraps
@@ -334,67 +297,124 @@ def _verify_client(func):
if not config.exists(f'interfaces {interface_path} address dhcp{v}'):
raise vyos.opmode.UnconfiguredObject(unconf_message)
return func(*args, **kwargs)
+
return _wrapper
-@_verify
-def show_pool_statistics(raw: bool, family: ArgFamily, pool: typing.Optional[str]):
- pool_data = _get_raw_pool_statistics(family=family, pool=pool)
+
+@_verify_server
+def show_server_pool_statistics(
+ raw: bool, family: ArgFamily, 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'):
+ Warning(stale_warn_msg)
+
+ try:
+ active_config = kea_get_active_config(inet_suffix)
+ except Exception:
+ 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!')
+
+ pool_data = _get_raw_server_pool_statistics(active_config, family=family, pool=pool)
if raw:
return pool_data
else:
- return _get_formatted_pool_statistics(pool_data, family=family)
+ return _get_formatted_server_pool_statistics(pool_data)
+
+
+@_verify_server
+def show_server_leases(
+ raw: bool,
+ family: ArgFamily,
+ pool: typing.Optional[str],
+ sorted: typing.Optional[str],
+ state: typing.Optional[ArgState],
+ origin: typing.Optional[ArgOrigin],
+):
+ 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'):
+ Warning(stale_warn_msg)
+ try:
+ active_config = kea_get_active_config(inet_suffix)
+ except Exception:
+ raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration')
-@_verify
-def show_server_leases(raw: bool, family: ArgFamily, pool: typing.Optional[str],
- sorted: typing.Optional[str], state: typing.Optional[ArgState],
- origin: typing.Optional[ArgOrigin] ):
- # if dhcp server is down, inactive leases may still be shown as active, so warn the user.
- v = '6' if family == 'inet6' else '4'
- if not is_systemd_service_running(f'kea-dhcp{v}-server.service'):
- Warning('DHCP server is configured but not started. Data may be stale.')
+ active_pools = kea_get_dhcp_pools(active_config, inet_suffix)
- v = 'v6' if family == 'inet6' else ''
- if pool and pool not in _get_dhcp_pools(family=family):
+ if pool and active_pools and pool not in active_pools:
raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!')
- if state and state not in lease_valid_states:
- raise vyos.opmode.IncorrectValue(f'DHCP{v} state "{state}" is invalid!')
-
sort_valid = sort_valid_inet6 if family == 'inet6' else sort_valid_inet
if sorted and sorted not in sort_valid:
raise vyos.opmode.IncorrectValue(f'DHCP{v} sort "{sorted}" is invalid!')
- lease_data = _get_raw_server_leases(family=family, pool=pool, sorted=sorted, state=state, origin=origin)
+ if state and state not in lease_valid_states:
+ raise vyos.opmode.IncorrectValue(f'DHCP{v} state "{state}" is invalid!')
+
+ lease_data = _get_raw_server_leases(
+ config=active_config,
+ family=family,
+ pool=pool,
+ sorted=sorted,
+ state=state,
+ origin=origin,
+ )
if raw:
return lease_data
else:
return _get_formatted_server_leases(lease_data, family=family)
-@_verify
-def show_server_static_mappings(raw: bool, family: ArgFamily, pool: typing.Optional[str],
- sorted: typing.Optional[str]):
+
+@_verify_server
+def show_server_static_mappings(
+ raw: bool,
+ family: ArgFamily,
+ pool: typing.Optional[str],
+ sorted: typing.Optional[str],
+):
v = 'v6' if family == 'inet6' else ''
- if pool and pool not in _get_dhcp_pools(family=family):
+ inet_suffix = '6' if family == 'inet6' else '4'
+
+ if not is_systemd_service_running(f'kea-dhcp{inet_suffix}-server.service'):
+ Warning(stale_warn_msg)
+
+ try:
+ active_config = kea_get_active_config(inet_suffix)
+ except Exception:
+ 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 sorted and sorted not in mapping_sort_valid:
raise vyos.opmode.IncorrectValue(f'DHCP{v} sort "{sorted}" is invalid!')
- static_mappings = _get_raw_server_static_mappings(family=family, pool=pool, sorted=sorted)
+ static_mappings = _get_raw_server_static_mappings(
+ config=active_config, family=family, pool=pool, sorted=sorted
+ )
if raw:
return static_mappings
else:
- return _get_formatted_server_static_mappings(static_mappings, family=family)
+ return _get_formatted_server_static_mappings(static_mappings)
+
def _lease_valid(inet, address):
leases = kea_get_leases(inet)
- for lease in leases:
- if address == lease['ip-address']:
- return True
- return False
+ return any(lease['ip-address'] == address for lease in leases)
-@_verify
+
+@_verify_server
def clear_dhcp_server_lease(family: ArgFamily, address: str):
v = 'v6' if family == 'inet6' else ''
inet = '6' if family == 'inet6' else '4'
@@ -409,6 +429,7 @@ def clear_dhcp_server_lease(family: ArgFamily, address: str):
print(f'Lease "{address}" has been cleared')
+
def _get_raw_client_leases(family='inet', interface=None):
from time import mktime
from datetime import datetime
@@ -437,21 +458,29 @@ def _get_raw_client_leases(family='inet', interface=None):
# format this makes less sense for an API and also the expiry
# timestamp is provided in UNIX time. Convert string (e.g. Sun Jul
# 30 18:13:44 CEST 2023) to UNIX time (1690733624)
- tmp.update({'last_update' : int(mktime(datetime.strptime(line, time_string).timetuple()))})
+ tmp.update(
+ {
+ 'last_update': int(
+ mktime(datetime.strptime(line, time_string).timetuple())
+ )
+ }
+ )
continue
k, v = line.split('=')
- tmp.update({k : v.replace("'", "")})
+ tmp.update({k: v.replace("'", '')})
if 'interface' in tmp:
vrf = get_interface_vrf(tmp['interface'])
- if vrf: tmp.update({'vrf' : vrf})
+ if vrf:
+ tmp.update({'vrf': vrf})
lease_data.append(tmp)
return lease_data
-def _get_formatted_client_leases(lease_data, family):
+
+def _get_formatted_client_leases(lease_data):
from time import localtime
from time import strftime
@@ -461,30 +490,34 @@ def _get_formatted_client_leases(lease_data, family):
for lease in lease_data:
if not lease.get('new_ip_address'):
continue
- data_entries.append(["Interface", lease['interface']])
+ data_entries.append(['Interface', lease['interface']])
if 'new_ip_address' in lease:
- tmp = '[Active]' if is_intf_addr_assigned(lease['interface'], lease['new_ip_address']) else '[Inactive]'
- data_entries.append(["IP address", lease['new_ip_address'], tmp])
+ tmp = (
+ '[Active]'
+ if is_intf_addr_assigned(lease['interface'], lease['new_ip_address'])
+ else '[Inactive]'
+ )
+ data_entries.append(['IP address', lease['new_ip_address'], tmp])
if 'new_subnet_mask' in lease:
- data_entries.append(["Subnet Mask", lease['new_subnet_mask']])
+ data_entries.append(['Subnet Mask', lease['new_subnet_mask']])
if 'new_domain_name' in lease:
- data_entries.append(["Domain Name", lease['new_domain_name']])
+ data_entries.append(['Domain Name', lease['new_domain_name']])
if 'new_routers' in lease:
- data_entries.append(["Router", lease['new_routers']])
+ data_entries.append(['Router', lease['new_routers']])
if 'new_domain_name_servers' in lease:
- data_entries.append(["Name Server", lease['new_domain_name_servers']])
+ data_entries.append(['Name Server', lease['new_domain_name_servers']])
if 'new_dhcp_server_identifier' in lease:
- data_entries.append(["DHCP Server", lease['new_dhcp_server_identifier']])
+ 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(['DHCP Server', lease['new_dhcp_lease_time']])
if 'vrf' in lease:
- data_entries.append(["VRF", lease['vrf']])
+ data_entries.append(['VRF', lease['vrf']])
if 'last_update' in lease:
tmp = strftime(time_string, localtime(int(lease['last_update'])))
- data_entries.append(["Last Update", tmp])
+ data_entries.append(['Last Update', tmp])
if 'new_expiry' in lease:
tmp = strftime(time_string, localtime(int(lease['new_expiry'])))
- data_entries.append(["Expiry", tmp])
+ data_entries.append(['Expiry', tmp])
# Add empty marker
data_entries.append([''])
@@ -493,12 +526,14 @@ def _get_formatted_client_leases(lease_data, family):
return output
+
def show_client_leases(raw: bool, family: ArgFamily, interface: typing.Optional[str]):
lease_data = _get_raw_client_leases(family=family, interface=interface)
if raw:
return lease_data
else:
- return _get_formatted_client_leases(lease_data, family=family)
+ return _get_formatted_client_leases(lease_data)
+
@_verify_client
def renew_client_lease(raw: bool, family: ArgFamily, interface: str):
@@ -510,6 +545,7 @@ def renew_client_lease(raw: bool, family: ArgFamily, interface: str):
else:
call(f'systemctl restart dhclient@{interface}.service')
+
@_verify_client
def release_client_lease(raw: bool, family: ArgFamily, interface: str):
if not raw:
@@ -520,6 +556,7 @@ def release_client_lease(raw: bool, family: ArgFamily, interface: str):
else:
call(f'systemctl stop dhclient@{interface}.service')
+
if __name__ == '__main__':
try:
res = vyos.opmode.run(sys.modules[__name__])
diff --git a/src/op_mode/firewall.py b/src/op_mode/firewall.py
index c197ca434..086536e4e 100755
--- a/src/op_mode/firewall.py
+++ b/src/op_mode/firewall.py
@@ -148,6 +148,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
@@ -253,15 +285,17 @@ def output_firewall_name_statistics(family, hook, prior, prior_conf, single_rule
if not source_addr:
source_addr = dict_search_args(rule_conf, 'source', 'group', 'domain_group')
if not source_addr:
- source_addr = dict_search_args(rule_conf, 'source', 'fqdn')
+ source_addr = dict_search_args(rule_conf, 'source', 'group', 'remote_group')
if not source_addr:
- source_addr = dict_search_args(rule_conf, 'source', 'geoip', 'country_code')
- if source_addr:
- source_addr = str(source_addr)[1:-1].replace('\'','')
- if 'inverse_match' in dict_search_args(rule_conf, 'source', 'geoip'):
- source_addr = 'NOT ' + str(source_addr)
+ source_addr = dict_search_args(rule_conf, 'source', 'fqdn')
if not source_addr:
- source_addr = 'any'
+ source_addr = dict_search_args(rule_conf, 'source', 'geoip', 'country_code')
+ if source_addr:
+ source_addr = str(source_addr)[1:-1].replace('\'','')
+ if 'inverse_match' in dict_search_args(rule_conf, 'source', 'geoip'):
+ source_addr = 'NOT ' + str(source_addr)
+ if not source_addr:
+ source_addr = 'any'
# Get destination
dest_addr = dict_search_args(rule_conf, 'destination', 'address')
@@ -272,15 +306,17 @@ def output_firewall_name_statistics(family, hook, prior, prior_conf, single_rule
if not dest_addr:
dest_addr = dict_search_args(rule_conf, 'destination', 'group', 'domain_group')
if not dest_addr:
- dest_addr = dict_search_args(rule_conf, 'destination', 'fqdn')
+ dest_addr = dict_search_args(rule_conf, 'destination', 'group', 'remote_group')
if not dest_addr:
- dest_addr = dict_search_args(rule_conf, 'destination', 'geoip', 'country_code')
- if dest_addr:
- dest_addr = str(dest_addr)[1:-1].replace('\'','')
- if 'inverse_match' in dict_search_args(rule_conf, 'destination', 'geoip'):
- dest_addr = 'NOT ' + str(dest_addr)
+ dest_addr = dict_search_args(rule_conf, 'destination', 'fqdn')
if not dest_addr:
- dest_addr = 'any'
+ dest_addr = dict_search_args(rule_conf, 'destination', 'geoip', 'country_code')
+ if dest_addr:
+ dest_addr = str(dest_addr)[1:-1].replace('\'','')
+ if 'inverse_match' in dict_search_args(rule_conf, 'destination', 'geoip'):
+ dest_addr = 'NOT ' + str(dest_addr)
+ if not dest_addr:
+ dest_addr = 'any'
# Get inbound interface
iiface = dict_search_args(rule_conf, 'inbound_interface', 'name')
@@ -552,30 +588,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'])))
- else:
- row.append('N/D')
- rows.append(row)
-
- else:
+ # interate over dynamic-groups
+ if group_type == 'dynamic_group':
if not args.detail:
header_tail = ['Timeout', 'Expires']
@@ -622,6 +636,59 @@ 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}')
+
+ if 'url' in remote_conf:
+ # display only the url if no members are found for both views
+ if not members:
+ if args.detail:
+ header_tail = ['Remote URL']
+ 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 = ['Remote URL']
+ row += [' '.join(members)]
+ 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/generate_psk.py b/src/op_mode/generate_psk.py
new file mode 100644
index 000000000..d51293712
--- /dev/null
+++ b/src/op_mode/generate_psk.py
@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2024 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+import argparse
+
+from vyos.utils.process import cmd
+
+
+def validate_hex_size(value):
+ """Validate that the hex_size is between 32 and 512."""
+ try:
+ value = int(value)
+ except ValueError:
+ raise argparse.ArgumentTypeError("hex_size must be integer.")
+
+ if value < 32 or value > 512:
+ raise argparse.ArgumentTypeError("hex_size must be between 32 and 512.")
+ return value
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "--hex_size",
+ type=validate_hex_size,
+ help='PKS value size in hex format. Default is 32 bytes.',
+ default=32,
+
+ required=False,
+ )
+ args = parser.parse_args()
+
+ print(cmd(f'openssl rand -hex {args.hex_size}')) \ No newline at end of file
diff --git a/src/op_mode/image_installer.py b/src/op_mode/image_installer.py
index bdc16de15..179913f15 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-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+# Copyright 2023-2025 VyOS maintainers and contributors <maintainers@vyos.io>
#
# This file is part of VyOS.
#
@@ -32,22 +32,33 @@ from errno import ENOSPC
from psutil import disk_partitions
+from vyos.base import Warning
from vyos.configtree import ConfigTree
-from vyos.configquery import ConfigTreeQuery
from vyos.remote import download
from vyos.system import disk, grub, image, compat, raid, SYSTEM_CFG_VER
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.file import chmod_2775
-from vyos.utils.process import cmd, run
-from vyos.version import get_remote_version, get_version_data
+from vyos.utils.process import cmd, run, 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_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_ARCHITECTURE_MISMATCH: str = 'Upgrading to a different image architecture will break your system.'
+MSG_ERR_INCOMPATIBLE_IMAGE: str = 'Image compatibility check failed, aborting installation.'
+MSG_ERR_ARCHITECTURE_MISMATCH: str = 'The current architecture is "{0}", the new image is for "{1}". Upgrading to a different image architecture will break your system.'
+MSG_ERR_FLAVOR_MISMATCH: str = 'The current image flavor is "{0}", the new image is "{1}". Upgrading to a non-matching flavor can have unpredictable consequences.'
+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.'
@@ -63,6 +74,7 @@ MSG_INPUT_CONFIG_FOUND: str = 'An active configuration was found. Would you like
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_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:'
@@ -79,8 +91,10 @@ MSG_WARN_ROOT_SIZE_TOOBIG: str = 'The size is too big. Try again.'
MSG_WARN_ROOT_SIZE_TOOSMALL: str = 'The size is too small. Try again'
MSG_WARN_IMAGE_NAME_WRONG: str = 'The suggested name is unsupported!\n'\
'It must be between 1 and 64 characters long and contains only the next characters: .+-_ a-z A-Z 0-9'
+
+MSG_WARN_CHANGE_PASSWORD: str = 'Default password used. Consider changing ' \
+ 'it on next login.'
MSG_WARN_PASSWORD_CONFIRM: str = 'The entered values did not match. Try again'
-MSG_WARN_FLAVOR_MISMATCH: str = 'The running image flavor is "{0}". The new image flavor is "{1}".\n' \
'Installing a different image flavor may cause functionality degradation or break your system.\n' \
'Do you want to continue with installation?'
CONST_MIN_DISK_SIZE: int = 2147483648 # 2 GB
@@ -96,9 +110,10 @@ DIR_ISO_MOUNT: str = f'{DIR_INSTALLATION}/iso_src'
DIR_DST_ROOT: str = f'{DIR_INSTALLATION}/disk_dst'
DIR_KERNEL_SRC: str = '/boot/'
FILE_ROOTFS_SRC: str = '/usr/lib/live/mount/medium/live/filesystem.squashfs'
-ISO_DOWNLOAD_PATH: str = '/tmp/vyos_installation.iso'
+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'
# default boot variables
DEFAULT_BOOT_VARS: dict[str, str] = {
@@ -501,7 +516,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:
@@ -510,11 +524,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:
@@ -532,10 +543,10 @@ def download_file(local_file: str, remote_path: str, vrf: str,
download(local_file, remote_path, progressbar=progressbar,
check_space=check_space, raise_error=True)
else:
- vrf_cmd = f'REMOTE_USERNAME={username} REMOTE_PASSWORD={password} \
- ip vrf exec {vrf} {external_download_script} \
- --local-file {local_file} --remote-path {remote_path}'
- cmd(vrf_cmd)
+ 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)
def image_fetch(image_path: str, vrf: str = None,
username: str = '', password: str = '',
@@ -548,25 +559,38 @@ def image_fetch(image_path: str, vrf: str = None,
Returns:
Path: a path to a local file
"""
+ import os.path
+ from uuid import uuid4
+
+ global ISO_DOWNLOAD_PATH
+
# Latest version gets url from configured "system update-check url"
if image_path == 'latest':
- config = ConfigTreeQuery()
- if config.exists('system update-check url'):
- configured_url_version = config.value('system update-check url')
- remote_url_list = get_remote_version(configured_url_version)
- image_path = remote_url_list[0].get('url')
+ 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)
+ if code:
+ print(output)
+ exit(MSG_INFO_INSTALL_EXIT)
+ image_path = output if output else image_path
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,
@@ -574,8 +598,8 @@ def image_fetch(image_path: str, vrf: str = None,
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:
@@ -697,30 +721,48 @@ def is_raid_install(install_object: Union[disk.DiskDetails, raid.RaidDetails]) -
return False
-def validate_compatibility(iso_path: str) -> None:
+def validate_compatibility(iso_path: str, force: bool = False) -> None:
"""Check architecture and flavor compatibility with the running image
Args:
iso_path (str): a path to the mounted ISO image
"""
- old_data = get_version_data()
- old_flavor = old_data.get('flavor', '')
- old_architecture = old_data.get('architecture') or cmd('dpkg --print-architecture')
+ current_data = get_version_data()
+ current_flavor = current_data.get('flavor')
+ current_architecture = current_data.get('architecture') or cmd('dpkg --print-architecture')
new_data = get_version_data(f'{iso_path}/version.json')
- new_flavor = new_data.get('flavor', '')
- new_architecture = new_data.get('architecture', '')
+ new_flavor = new_data.get('flavor')
+ new_architecture = new_data.get('architecture')
- if not old_architecture == new_architecture:
- print(MSG_ERR_ARCHITECTURE_MISMATCH)
+ if not current_flavor or not current_architecture:
+ # This may only happen if someone modified the version file.
+ # Unlikely but not impossible.
+ print(MSG_ERR_CORRUPT_CURRENT_IMAGE)
cleanup()
exit(MSG_INFO_INSTALL_EXIT)
- if not old_flavor == new_flavor:
- if not ask_yes_no(MSG_WARN_FLAVOR_MISMATCH.format(old_flavor, new_flavor), default=False):
- cleanup()
- exit(MSG_INFO_INSTALL_EXIT)
+ success = True
+ if current_architecture != new_architecture:
+ success = False
+ if not new_architecture:
+ print(MSG_ERR_MISSING_ARCHITECTURE)
+ else:
+ print(MSG_ERR_ARCHITECTURE_MISMATCH.format(current_architecture, new_architecture))
+
+ if current_flavor != new_flavor:
+ if not force:
+ success = False
+ if not new_flavor:
+ print(MSG_ERR_MISSING_FLAVOR)
+ else:
+ print(MSG_ERR_FLAVOR_MISMATCH.format(current_flavor, new_flavor))
+
+ if not success:
+ print(MSG_ERR_INCOMPATIBLE_IMAGE)
+ cleanup()
+ exit(MSG_INFO_INSTALL_EXIT)
def install_image() -> None:
"""Install an image to a disk
@@ -742,14 +784,25 @@ def install_image() -> None:
break
print(MSG_WARN_IMAGE_NAME_WRONG)
+ failed_check_status = [EPasswdStrength.WEAK, EPasswdStrength.ERROR]
# ask for password
while True:
user_password: str = ask_input(MSG_INPUT_PASSWORD, no_echo=True,
non_empty=True)
+
+ if user_password == DEFAULT_PASSWORD:
+ Warning(MSG_WARN_CHANGE_PASSWORD)
+ else:
+ result = evaluate_strength(user_password)
+ if result['strength'] in failed_check_status:
+ Warning(result['error'])
+
confirm: str = ask_input(MSG_INPUT_PASSWORD_CONFIRM, no_echo=True,
non_empty=True)
+
if user_password == confirm:
break
+
print(MSG_WARN_PASSWORD_CONFIRM)
# ask for default console
@@ -889,7 +942,7 @@ def install_image() -> None:
@compat.grub_cfg_update
def add_image(image_path: str, vrf: str = None, username: str = '',
- password: str = '', no_prompt: bool = False) -> None:
+ password: str = '', no_prompt: bool = False, force: bool = False) -> None:
"""Add a new image
Args:
@@ -906,7 +959,7 @@ def add_image(image_path: str, vrf: str = None, username: str = '',
disk.partition_mount(iso_path, DIR_ISO_MOUNT, 'iso9660')
print('Validating image compatibility')
- validate_compatibility(DIR_ISO_MOUNT)
+ validate_compatibility(DIR_ISO_MOUNT, force=force)
# check sums
print('Validating image checksums')
@@ -932,8 +985,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)
@@ -955,7 +1012,7 @@ def add_image(image_path: str, vrf: str = None, username: str = '',
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,
+ copytree('/opt/vyatta/etc/config/', target_config_dir, symlinks=True,
copy_function=copy_preserve_owner, dirs_exist_ok=True)
else:
Path(target_config_dir).mkdir(parents=True)
@@ -1027,6 +1084,9 @@ def parse_arguments() -> Namespace:
parser.add_argument('--image-path',
help='a path (HTTP or local file) to an image that needs to be installed'
)
+ parser.add_argument('--force', action='store_true',
+ help='Ignore flavor compatibility requirements.'
+ )
# parser.add_argument('--image_new_name', help='a new name for image')
args: Namespace = parser.parse_args()
# Validate arguments
@@ -1043,7 +1103,8 @@ if __name__ == '__main__':
install_image()
if args.action == 'add':
add_image(args.image_path, args.vrf,
- args.username, args.password, args.no_prompt)
+ args.username, args.password,
+ args.no_prompt, args.force)
exit()
diff --git a/src/op_mode/ipsec.py b/src/op_mode/ipsec.py
index 02ba126b4..1ab50b105 100755
--- a/src/op_mode/ipsec.py
+++ b/src/op_mode/ipsec.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
#
-# Copyright (C) 2022-2024 VyOS maintainers and contributors
+# Copyright (C) 2022-2025 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
@@ -700,15 +700,6 @@ def reset_profile_dst(profile: str, tunnel: str, nbma_dst: str):
]
)
)
- # initiate IKE SAs
- for ike in sa_nbma_list:
- if ike_sa_name in ike:
- vyos.ipsec.vici_initiate(
- ike_sa_name,
- 'dmvpn',
- ike[ike_sa_name]['local-host'],
- ike[ike_sa_name]['remote-host'],
- )
print(
f'Profile {profile} tunnel {tunnel} remote-host {nbma_dst} reset result: success'
)
@@ -732,18 +723,6 @@ def reset_profile_all(profile: str, tunnel: str):
)
# terminate IKE SAs
vyos.ipsec.terminate_vici_by_name(ike_sa_name, None)
- # initiate IKE SAs
- for ike in sa_list:
- if ike_sa_name in ike:
- vyos.ipsec.vici_initiate(
- ike_sa_name,
- 'dmvpn',
- ike[ike_sa_name]['local-host'],
- ike[ike_sa_name]['remote-host'],
- )
- print(
- f'Profile {profile} tunnel {tunnel} remote-host {ike[ike_sa_name]["remote-host"]} reset result: success'
- )
print(f'Profile {profile} tunnel {tunnel} reset result: success')
except vyos.ipsec.ViciInitiateError as err:
raise vyos.opmode.UnconfiguredSubsystem(err)
diff --git a/src/op_mode/load-balancing_wan.py b/src/op_mode/load-balancing_wan.py
new file mode 100755
index 000000000..9fa473802
--- /dev/null
+++ b/src/op_mode/load-balancing_wan.py
@@ -0,0 +1,117 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2024 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import json
+import re
+import sys
+
+from datetime import datetime
+
+from vyos.config import Config
+from vyos.utils.process import cmd
+
+import vyos.opmode
+
+wlb_status_file = '/run/wlb_status.json'
+
+status_format = '''Interface: {ifname}
+Status: {status}
+Last Status Change: {last_change}
+Last Interface Success: {last_success}
+Last Interface Failure: {last_failure}
+Interface Failures: {failures}
+'''
+
+def _verify(func):
+ """Decorator checks if WLB config exists"""
+ from functools import wraps
+
+ @wraps(func)
+ def _wrapper(*args, **kwargs):
+ config = Config()
+ if not config.exists(['load-balancing', 'wan']):
+ unconf_message = 'WAN load-balancing is not configured'
+ raise vyos.opmode.UnconfiguredSubsystem(unconf_message)
+ return func(*args, **kwargs)
+ return _wrapper
+
+def _get_raw_data():
+ with open(wlb_status_file, 'r') as f:
+ data = json.loads(f.read())
+ if not data:
+ return {}
+ return data
+
+def _get_formatted_output(raw_data):
+ 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()
+
+ fmt_data = {
+ 'ifname': ifname,
+ 'status': "active" if if_data['state'] else "failed",
+ 'last_change': change_dt.strftime("%Y-%m-%d %H:%M:%S") if change_dt else 'N/A',
+ 'last_success': str(now - success_dt) if success_dt else 'N/A',
+ 'last_failure': str(now - failure_dt) if failure_dt else 'N/A',
+ 'failures': if_data['failure_count']
+ }
+ print(status_format.format(**fmt_data))
+
+@_verify
+def show_summary(raw: bool):
+ data = _get_raw_data()
+
+ if raw:
+ return data
+ else:
+ return _get_formatted_output(data)
+
+@_verify
+def show_connection(raw: bool):
+ res = cmd('sudo conntrack -L -n')
+ lines = res.split("\n")
+ filtered_lines = [line for line in lines if re.search(r' mark=[1-9]', line)]
+
+ if raw:
+ return filtered_lines
+
+ for line in lines:
+ print(line)
+
+@_verify
+def show_status(raw: bool):
+ res = cmd('sudo nft list chain ip vyos_wanloadbalance wlb_mangle_prerouting')
+ lines = res.split("\n")
+ filtered_lines = [line.replace("\t", "") for line in lines[3:-2] if 'meta mark set' not in line]
+
+ if raw:
+ return filtered_lines
+
+ for line in filtered_lines:
+ print(line)
+
+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/nhrp.py b/src/op_mode/nhrp.py
deleted file mode 100755
index e66f33079..000000000
--- a/src/op_mode/nhrp.py
+++ /dev/null
@@ -1,101 +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/>.
-
-import sys
-import tabulate
-import vyos.opmode
-
-from vyos.utils.process import cmd
-from vyos.utils.process import process_named_running
-from vyos.utils.dict import colon_separated_to_dict
-
-
-def _get_formatted_output(output_dict: dict) -> str:
- """
- Create formatted table for CLI output
- :param output_dict: dictionary for API
- :type output_dict: dict
- :return: tabulate string
- :rtype: str
- """
- print(f"Status: {output_dict['Status']}")
- output: str = tabulate.tabulate(output_dict['routes'], headers='keys',
- numalign="left")
- return output
-
-
-def _get_formatted_dict(output_string: str) -> dict:
- """
- Format string returned from CMD to API list
- :param output_string: String received by CMD
- :type output_string: str
- :return: dictionary for API
- :rtype: dict
- """
- formatted_dict: dict = {
- 'Status': '',
- 'routes': []
- }
- output_list: list = output_string.split('\n\n')
- for list_a in output_list:
- output_dict = colon_separated_to_dict(list_a, True)
- if 'Status' in output_dict:
- formatted_dict['Status'] = output_dict['Status']
- else:
- formatted_dict['routes'].append(output_dict)
- return formatted_dict
-
-
-def show_interface(raw: bool):
- """
- Command 'show nhrp interface'
- :param raw: if API
- :type raw: bool
- """
- if not process_named_running('opennhrp'):
- raise vyos.opmode.UnconfiguredSubsystem('OpenNHRP is not running.')
- interface_string: str = cmd('sudo opennhrpctl interface show')
- interface_dict: dict = _get_formatted_dict(interface_string)
- if raw:
- return interface_dict
- else:
- return _get_formatted_output(interface_dict)
-
-
-def show_tunnel(raw: bool):
- """
- Command 'show nhrp tunnel'
- :param raw: if API
- :type raw: bool
- """
- if not process_named_running('opennhrp'):
- raise vyos.opmode.UnconfiguredSubsystem('OpenNHRP is not running.')
- tunnel_string: str = cmd('sudo opennhrpctl show')
- tunnel_dict: list = _get_formatted_dict(tunnel_string)
- if raw:
- return tunnel_dict
- else:
- return _get_formatted_output(tunnel_dict)
-
-
-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/qos.py b/src/op_mode/qos.py
index b8ca149a0..464b552ee 100755
--- a/src/op_mode/qos.py
+++ b/src/op_mode/qos.py
@@ -38,7 +38,7 @@ def get_tc_info(interface_dict, interface_name, policy_type):
if not policy_name:
return None, None
- class_dict = op_mode_config_dict(['qos', 'policy', policy_type, policy_name], key_mangling=('-', '_'),
+ class_dict = op_mode_config_dict(['qos', 'policy', policy_type, policy_name],
get_first_key=True)
if not class_dict:
return None, None
diff --git a/src/op_mode/reset_wireguard.py b/src/op_mode/reset_wireguard.py
new file mode 100755
index 000000000..1fcfb31b5
--- /dev/null
+++ b/src/op_mode/reset_wireguard.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2025 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+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
+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__])
+ if res:
+ print(res)
+ except (ValueError, vyos.opmode.Error) as e:
+ print(e)
+ sys.exit(1)
diff --git a/src/op_mode/restart.py b/src/op_mode/restart.py
index 3b0031f34..efa835485 100755
--- a/src/op_mode/restart.py
+++ b/src/op_mode/restart.py
@@ -53,6 +53,10 @@ service_map = {
'systemd_service': 'strongswan',
'path': ['vpn', 'ipsec'],
},
+ 'load-balancing_wan': {
+ 'systemd_service': 'vyos-wan-load-balance',
+ 'path': ['load-balancing', 'wan'],
+ },
'mdns_repeater': {
'systemd_service': 'avahi-daemon',
'path': ['service', 'mdns', 'repeater'],
@@ -86,6 +90,7 @@ services = typing.Literal[
'haproxy',
'igmp_proxy',
'ipsec',
+ 'load-balancing_wan',
'mdns_repeater',
'router_advert',
'snmp',
diff --git a/src/op_mode/stp.py b/src/op_mode/stp.py
new file mode 100755
index 000000000..fb57bd7ee
--- /dev/null
+++ b/src/op_mode/stp.py
@@ -0,0 +1,185 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2025 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+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 seperators 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/tech_support.py b/src/op_mode/tech_support.py
index f60bb87ff..24ac0af1b 100644
--- a/src/op_mode/tech_support.py
+++ b/src/op_mode/tech_support.py
@@ -97,21 +97,22 @@ def _get_boot_config():
return strip_config_source(config)
def _get_config_scripts():
- from os import listdir
+ from os import walk
from os.path import join
from vyos.utils.file import read_file
scripts = []
dir = '/config/scripts'
- for f in listdir(dir):
- script = {}
- path = join(dir, f)
- data = read_file(path)
- script["path"] = path
- script["data"] = data
-
- scripts.append(script)
+ for dirpath, _, filenames in walk(dir):
+ for filename in filenames:
+ script = {}
+ path = join(dirpath, filename)
+ data = read_file(path)
+ script["path"] = path
+ script["data"] = data
+
+ scripts.append(script)
return scripts
diff --git a/src/op_mode/vtysh_wrapper.sh b/src/op_mode/vtysh_wrapper.sh
index 25d09ce77..bc472f7bb 100755
--- a/src/op_mode/vtysh_wrapper.sh
+++ b/src/op_mode/vtysh_wrapper.sh
@@ -2,5 +2,5 @@
declare -a tmp
# FRR uses ospf6 where we use ospfv3, and we use reset over clear for BGP,
# thus alter the commands
-tmp=$(echo $@ | sed -e "s/ospfv3/ospf6/" | sed -e "s/^reset bgp/clear bgp/" | sed -e "s/^reset ip bgp/clear ip bgp/")
+tmp=$(echo $@ | sed -e "s/ospfv3/ospf6/" | sed -e "s/^reset bgp/clear bgp/" | sed -e "s/^reset ip bgp/clear ip bgp/"| sed -e "s/^reset ip nhrp/clear ip nhrp/")
vtysh -c "$tmp"
diff --git a/src/op_mode/zone.py b/src/op_mode/zone.py
index 49fecdf28..df39549d2 100644
--- a/src/op_mode/zone.py
+++ b/src/op_mode/zone.py
@@ -56,10 +56,15 @@ def _convert_one_zone_data(zone: str, zone_config: dict) -> dict:
from_zone_dict['firewall_v6'] = dict_search(
'firewall.ipv6_name', from_zone_config)
list_of_rules.append(from_zone_dict)
+ zone_members =[]
+ interface_members = dict_search('member.interface', zone_config)
+ vrf_members = dict_search('member.vrf', zone_config)
+ zone_members += interface_members if interface_members is not None else []
+ zone_members += vrf_members if vrf_members is not None else []
zone_dict = {
'name': zone,
- 'interface': dict_search('interface', zone_config),
+ 'members': zone_members,
'type': 'LOCAL' if dict_search('local_zone',
zone_config) is not None else None,
}
@@ -126,7 +131,7 @@ def output_zone_list(zone_conf: dict) -> list:
if zone_conf['type'] == 'LOCAL':
zone_info.append('LOCAL')
else:
- zone_info.append("\n".join(zone_conf['interface']))
+ zone_info.append("\n".join(zone_conf['members']))
from_zone = []
firewall = []
@@ -175,7 +180,7 @@ def get_formatted_output(zone_policy: list) -> str:
:rtype: str
"""
headers = ["Zone",
- "Interfaces",
+ "Members",
"From Zone",
"Firewall IPv4",
"Firewall IPv6"
diff --git a/src/services/api/rest/models.py b/src/services/api/rest/models.py
index 27d9fb5ee..dda50010f 100644
--- a/src/services/api/rest/models.py
+++ b/src/services/api/rest/models.py
@@ -293,6 +293,13 @@ class TracerouteModel(ApiModel):
}
+class InfoQueryParams(BaseModel):
+ model_config = {"extra": "forbid"}
+
+ version: bool = True
+ hostname: bool = True
+
+
class Success(BaseModel):
success: bool
data: Union[str, bool, Dict]
diff --git a/src/services/vyos-commitd b/src/services/vyos-commitd
new file mode 100755
index 000000000..e7f2d82c7
--- /dev/null
+++ b/src/services/vyos-commitd
@@ -0,0 +1,457 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2025 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 sys
+import grp
+import json
+import signal
+import socket
+import typing
+import logging
+import traceback
+import importlib.util
+import io
+from contextlib import redirect_stdout
+from dataclasses import dataclass
+from dataclasses import fields
+from dataclasses import field
+from dataclasses import asdict
+from pathlib import Path
+
+import tomli
+
+from google.protobuf.json_format import MessageToDict
+from google.protobuf.json_format import ParseDict
+
+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.config import Config
+from vyos.frrender import FRRender
+from vyos.frrender import get_frrender_dict
+from vyos import ConfigError
+
+from vyos.proto import vycall_pb2
+
+
+@dataclass
+class Status:
+ success: bool = False
+ out: str = ''
+
+
+@dataclass
+class Call:
+ script_name: str = ''
+ tag_value: str = None
+ arg_value: str = None
+ reply: Status = None
+
+ def set_reply(self, success: bool, out: str):
+ self.reply = Status(success=success, out=out)
+
+
+@dataclass
+class Session:
+ # pylint: disable=too-many-instance-attributes
+
+ session_id: str = ''
+ dry_run: bool = False
+ atomic: bool = False
+ background: bool = False
+ config: Config = None
+ init: Status = None
+ calls: list[Call] = field(default_factory=list)
+
+ def set_init(self, success: bool, out: str):
+ self.init = Status(success=success, out=out)
+
+
+@dataclass
+class ServerConf:
+ commitd_socket: str = ''
+ session_dir: str = ''
+ running_cache: str = ''
+ session_cache: str = ''
+
+
+server_conf = None
+SOCKET_PATH = None
+conf_mode_scripts = None
+frr = None
+
+CFG_GROUP = 'vyattacfg'
+
+script_stdout_log = '/tmp/vyos-commitd-script-stdout'
+
+debug = True
+
+logger = logging.getLogger(__name__)
+logs_handler = logging.StreamHandler()
+logger.addHandler(logs_handler)
+
+if debug:
+ logger.setLevel(logging.DEBUG)
+else:
+ logger.setLevel(logging.INFO)
+
+
+vyos_conf_scripts_dir = directories['conf_mode']
+commitd_include_file = os.path.join(directories['data'], 'configd-include.json')
+
+
+def key_name_from_file_name(f):
+ return os.path.splitext(f)[0]
+
+
+def module_name_from_key(k):
+ return k.replace('-', '_')
+
+
+def path_from_file_name(f):
+ return os.path.join(vyos_conf_scripts_dir, f)
+
+
+def load_conf_mode_scripts():
+ with open(commitd_include_file) as f:
+ try:
+ include = json.load(f)
+ except OSError as e:
+ logger.critical(f'configd include file error: {e}')
+ sys.exit(1)
+ except json.JSONDecodeError as e:
+ logger.critical(f'JSON load error: {e}')
+ sys.exit(1)
+
+ # import conf_mode scripts
+ (_, _, filenames) = next(iter(os.walk(vyos_conf_scripts_dir)))
+ filenames.sort()
+
+ # this is redundant, as all scripts are currently in the include file;
+ # leave it as an inexpensive check for future changes
+ load_filenames = [f for f in filenames if f in include]
+ imports = [key_name_from_file_name(f) for f in load_filenames]
+ module_names = [module_name_from_key(k) for k in imports]
+ paths = [path_from_file_name(f) for f in load_filenames]
+ to_load = list(zip(module_names, paths))
+
+ modules = []
+
+ for x in to_load:
+ spec = importlib.util.spec_from_file_location(x[0], x[1])
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ modules.append(module)
+
+ scripts = dict(zip(imports, modules))
+
+ return scripts
+
+
+def get_session_out(session: Session) -> str:
+ out = ''
+ if session.init and session.init.out:
+ out = f'{out} + init: {session.init.out} + \n'
+ for call in session.calls:
+ reply = call.reply
+ if reply and reply.out:
+ out = f'{out} + {call.script_name}: {reply.out} + \n'
+ return out
+
+
+def write_stdout_log(file_name, session):
+ if boot_configuration_complete():
+ return
+ with open(file_name, 'a') as f:
+ f.write(get_session_out(session))
+
+
+def msg_to_commit_data(msg: vycall_pb2.Commit) -> Session:
+ # pylint: disable=no-member
+
+ d = MessageToDict(msg, preserving_proto_field_name=True)
+
+ # wrap in dataclasses
+ session = Session(**d)
+ session.init = Status(**session.init) if session.init else None
+ session.calls = list(map(lambda x: Call(**x), session.calls))
+ for call in session.calls:
+ call.reply = Status(**call.reply) if call.reply else None
+
+ return session
+
+
+def commit_data_to_msg(obj: Session) -> vycall_pb2.Commit:
+ # pylint: disable=no-member
+
+ # avoid asdict attempt of deepcopy on Config obj
+ obj.config = None
+
+ msg = vycall_pb2.Commit()
+ msg = ParseDict(asdict(obj), msg, ignore_unknown_fields=True)
+
+ return msg
+
+
+def initialization(session: Session) -> Session:
+ running_cache = os.path.join(server_conf.session_dir, server_conf.running_cache)
+ session_cache = os.path.join(server_conf.session_dir, server_conf.session_cache)
+ try:
+ configsource = ConfigSourceCache(
+ running_config_cache=running_cache,
+ session_config_cache=session_cache,
+ )
+ except ConfigSourceError as e:
+ fail_msg = f'Failed to read config caches: {e}'
+ logger.critical(fail_msg)
+ session.set_init(False, fail_msg)
+ return session
+
+ session.set_init(True, '')
+
+ config = Config(config_source=configsource)
+
+ dependent_func: dict[str, list[typing.Callable]] = {}
+ setattr(config, 'dependent_func', dependent_func)
+
+ scripts_called = []
+ setattr(config, 'scripts_called', scripts_called)
+
+ 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
+
+ return session
+
+
+def run_script(script_name: str, config: Config, args: list) -> tuple[bool, str]:
+ # pylint: disable=broad-exception-caught
+
+ 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)
+ 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)
+ except Exception:
+ tb = traceback.format_exc()
+ logger.error(tb)
+ return False, tb
+
+ return True, ''
+
+
+def process_call_data(call: Call, config: Config, last: bool = False) -> None:
+ # pylint: disable=too-many-locals
+
+ script_name = key_name_from_file_name(call.script_name)
+
+ if script_name not in conf_mode_scripts:
+ fail_msg = f'No such script: {call.script_name}'
+ logger.critical(fail_msg)
+ call.set_reply(False, fail_msg)
+ return
+
+ config.dependency_list.clear()
+
+ tag_value = call.tag_value if call.tag_value is not None else ''
+ os.environ['VYOS_TAGNODE_VALUE'] = tag_value
+
+ args = call.arg_value.split() if call.arg_value else []
+ args.insert(0, f'{script_name}.py')
+
+ tag_ext = f'_{tag_value}' if tag_value else ''
+ script_record = f'{script_name}{tag_ext}'
+ scripts_called = getattr(config, 'scripts_called', [])
+ scripts_called.append(script_record)
+
+ with redirect_stdout(io.StringIO()) as o:
+ success, err_out = run_script(script_name, config, args)
+ amb_out = o.getvalue()
+ o.close()
+
+ out = amb_out + err_out
+
+ call.set_reply(success, out)
+
+ logger.info(f'[{script_name}] {out}')
+
+ if last:
+ scripts_called = getattr(config, 'scripts_called', [])
+ 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()
+
+
+def process_session_data(session: Session) -> Session:
+ if session.init is None or not session.init.success:
+ return session
+
+ config = session.config
+ len_calls = len(session.calls)
+ for index, call in enumerate(session.calls):
+ process_call_data(call, config, last=len_calls == index + 1)
+
+ return session
+
+
+def read_message(msg: bytes) -> Session:
+ """Read message into Session instance"""
+
+ message = vycall_pb2.Commit() # pylint: disable=no-member
+ message.ParseFromString(msg)
+ session = msg_to_commit_data(message)
+
+ session = initialization(session)
+ session = process_session_data(session)
+
+ write_stdout_log(script_stdout_log, session)
+
+ return session
+
+
+def write_reply(session: Session) -> bytearray:
+ """Serialize modified object to bytearray, prepending data length
+ header"""
+
+ reply = commit_data_to_msg(session)
+ encoded_data = reply.SerializeToString()
+ byte_size = reply.ByteSize()
+ length_bytes = byte_size.to_bytes(4)
+ arr = bytearray(length_bytes)
+ arr.extend(encoded_data)
+
+ return arr
+
+
+def load_server_conf() -> ServerConf:
+ # pylint: disable=import-outside-toplevel
+ # pylint: disable=broad-exception-caught
+ from vyos.defaults import vyconfd_conf
+
+ try:
+ with open(vyconfd_conf, 'rb') as f:
+ vyconfd_conf_d = tomli.load(f)
+
+ except Exception as e:
+ logger.critical(f'Failed to open the vyconfd.conf file {vyconfd_conf}: {e}')
+ sys.exit(1)
+
+ app = vyconfd_conf_d.get('appliance', {})
+
+ conf_data = {
+ k: v for k, v in app.items() if k in [_.name for _ in fields(ServerConf)]
+ }
+
+ conf = ServerConf(**conf_data)
+
+ return conf
+
+
+def remove_if_exists(f: str):
+ try:
+ os.unlink(f)
+ except FileNotFoundError:
+ pass
+
+
+def sig_handler(_signum, _frame):
+ logger.info('stopping server')
+ raise KeyboardInterrupt
+
+
+def run_server():
+ # pylint: disable=global-statement
+
+ global server_conf
+ global SOCKET_PATH
+ global conf_mode_scripts
+ global frr
+
+ signal.signal(signal.SIGTERM, sig_handler)
+ signal.signal(signal.SIGINT, sig_handler)
+
+ logger.info('starting server')
+
+ server_conf = load_server_conf()
+ SOCKET_PATH = server_conf.commitd_socket
+ conf_mode_scripts = load_conf_mode_scripts()
+
+ cfg_group = grp.getgrnam(CFG_GROUP)
+ os.setgid(cfg_group.gr_gid)
+
+ server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+
+ remove_if_exists(SOCKET_PATH)
+ server_socket.bind(SOCKET_PATH)
+ Path(SOCKET_PATH).chmod(0o775)
+
+ # We only need one long-lived instance of FRRender
+ frr = FRRender()
+
+ server_socket.listen(2)
+ while True:
+ try:
+ conn, _ = server_socket.accept()
+ logger.debug('connection accepted')
+ while True:
+ # receive size of data
+ data_length = conn.recv(4)
+ if not data_length:
+ logger.debug('no data')
+ # if no data break
+ break
+
+ length = int.from_bytes(data_length)
+ # receive data
+ data = conn.recv(length)
+
+ session = read_message(data)
+ reply = write_reply(session)
+ conn.sendall(reply)
+
+ conn.close()
+ logger.debug('connection closed')
+
+ except KeyboardInterrupt:
+ break
+
+ server_socket.close()
+ sys.exit(0)
+
+
+if __name__ == '__main__':
+ run_server()
diff --git a/src/services/vyos-configd b/src/services/vyos-configd
index d977ba2cb..28acccd2c 100755
--- a/src/services/vyos-configd
+++ b/src/services/vyos-configd
@@ -28,6 +28,7 @@ import traceback
import importlib.util
import io
from contextlib import redirect_stdout
+from enum import Enum
import zmq
@@ -37,6 +38,8 @@ from vyos.configsource import ConfigSourceString
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
from vyos import ConfigError
CFG_GROUP = 'vyattacfg'
@@ -58,11 +61,14 @@ SOCKET_PATH = 'ipc:///run/vyos-configd.sock'
MAX_MSG_SIZE = 65535
PAD_MSG_SIZE = 6
+
# Response error codes
-R_SUCCESS = 1
-R_ERROR_COMMIT = 2
-R_ERROR_DAEMON = 4
-R_PASS = 8
+class Response(Enum):
+ SUCCESS = 1
+ ERROR_COMMIT = 2
+ ERROR_DAEMON = 4
+ PASS = 8
+
vyos_conf_scripts_dir = directories['conf_mode']
configd_include_file = os.path.join(directories['data'], 'configd-include.json')
@@ -71,12 +77,15 @@ configd_env_unset_file = os.path.join(directories['data'], 'vyos-configd-env-uns
# sourced on entering config session
configd_env_file = '/etc/default/vyos-configd-env'
+
def key_name_from_file_name(f):
return os.path.splitext(f)[0]
+
def module_name_from_key(k):
return k.replace('-', '_')
+
def path_from_file_name(f):
return os.path.join(vyos_conf_scripts_dir, f)
@@ -124,7 +133,7 @@ def write_stdout_log(file_name, msg):
f.write(msg)
-def run_script(script_name, config, args) -> tuple[int, str]:
+def run_script(script_name, config, args) -> tuple[Response, str]:
# pylint: disable=broad-exception-caught
script = conf_mode_scripts[script_name]
@@ -137,13 +146,13 @@ def run_script(script_name, config, args) -> tuple[int, str]:
script.apply(c)
except ConfigError as e:
logger.error(e)
- return R_ERROR_COMMIT, str(e)
+ return Response.ERROR_COMMIT, str(e)
except Exception:
tb = traceback.format_exc()
logger.error(tb)
- return R_ERROR_COMMIT, tb
+ return Response.ERROR_COMMIT, tb
- return R_SUCCESS, ''
+ return Response.SUCCESS, ''
def initialization(socket):
@@ -193,8 +202,9 @@ def initialization(socket):
os.environ['VYATTA_CHANGES_ONLY_DIR'] = changes_only_dir_string
try:
- configsource = ConfigSourceString(running_config_text=active_string,
- session_config_text=session_string)
+ configsource = ConfigSourceString(
+ running_config_text=active_string, session_config_text=session_string
+ )
except ConfigSourceError as e:
logger.debug(e)
return None
@@ -212,11 +222,11 @@ def initialization(socket):
return config
-def process_node_data(config, data, _last: bool = False) -> tuple[int, str]:
+def process_node_data(config, data, _last: bool = False) -> tuple[Response, str]:
if not config:
out = 'Empty config'
logger.critical(out)
- return R_ERROR_DAEMON, out
+ return Response.ERROR_DAEMON, out
script_name = None
os.environ['VYOS_TAGNODE_VALUE'] = ''
@@ -232,7 +242,7 @@ def process_node_data(config, data, _last: bool = False) -> tuple[int, str]:
if not script_name:
out = 'Missing script_name'
logger.critical(out)
- return R_ERROR_DAEMON, out
+ return Response.ERROR_DAEMON, out
if res.group(3):
args = res.group(3).split()
args.insert(0, f'{script_name}.py')
@@ -244,7 +254,7 @@ def process_node_data(config, data, _last: bool = False) -> tuple[int, str]:
scripts_called.append(script_record)
if script_name not in include_set:
- return R_PASS, ''
+ return Response.PASS, ''
with redirect_stdout(io.StringIO()) as o:
result, err_out = run_script(script_name, config, args)
@@ -257,13 +267,15 @@ def process_node_data(config, data, _last: bool = False) -> tuple[int, str]:
def send_result(sock, err, msg):
+ err_no = err.value
+ err_name = err.name
msg = msg if msg else ''
msg_size = min(MAX_MSG_SIZE, len(msg))
- err_rep = err.to_bytes(1)
+ err_rep = err_no.to_bytes(1)
msg_size_rep = f'{msg_size:#0{PAD_MSG_SIZE}x}'
- logger.debug(f'Sending reply: error_code {err} with output')
+ logger.debug(f'Sending reply: {err_name} with output')
sock.send_multipart([err_rep, msg_size_rep.encode(), msg.encode()])
write_stdout_log(script_stdout_log, msg)
@@ -307,8 +319,10 @@ if __name__ == '__main__':
remove_if_file(configd_env_file)
os.symlink(configd_env_set_file, configd_env_file)
- config = None
+ # We only need one long-lived instance of FRRender
+ frr = FRRender()
+ config = None
while True:
# Wait for next request from client
msg = socket.recv().decode()
@@ -326,5 +340,12 @@ if __name__ == '__main__':
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()
else:
logger.critical(f'Unexpected message: {message}')
diff --git a/src/services/vyos-conntrack-logger b/src/services/vyos-conntrack-logger
index 9c31b465f..ec0e1f717 100755
--- a/src/services/vyos-conntrack-logger
+++ b/src/services/vyos-conntrack-logger
@@ -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/helpers/vyos-domain-resolver.py b/src/services/vyos-domain-resolver
index f5a1d9297..4419fc4a7 100755
--- a/src/helpers/vyos-domain-resolver.py
+++ b/src/services/vyos-domain-resolver
@@ -13,16 +13,22 @@
#
# 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 time
+import logging
+import os
from vyos.configdict import dict_merge
from vyos.configquery import ConfigTreeQuery
from vyos.firewall import fqdn_config_parse
from vyos.firewall import fqdn_resolve
+from vyos.ifconfig import WireGuardIf
+from vyos.remote import download
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.process import cmd
from vyos.utils.process import run
from vyos.xml_ref import get_defaults
@@ -32,6 +38,9 @@ timeout = 300
cache = False
base_firewall = ['firewall']
base_nat = ['nat']
+base_interfaces = ['interfaces']
+
+firewall_config_dir = "/config/firewall"
domain_state = {}
@@ -48,6 +57,11 @@ ipv6_tables = {
'ip6 raw'
}
+logger = logging.getLogger(__name__)
+logs_handler = logging.StreamHandler()
+logger.addHandler(logs_handler)
+logger.setLevel(logging.INFO)
+
def get_config(conf, node):
node_config = conf.get_config_dict(node, key_mangling=('-', '_'), get_first_key=True,
no_tag_node_value_mangle=True)
@@ -56,13 +70,15 @@ def get_config(conf, node):
node_config = dict_merge(default_values, node_config)
- global timeout, cache
+ if node == base_firewall and 'global_options' in node_config:
+ global_config = node_config['global_options']
+ global timeout, cache
- if 'resolver_interval' in node_config:
- timeout = int(node_config['resolver_interval'])
+ if 'resolver_interval' in global_config:
+ timeout = int(global_config['resolver_interval'])
- if 'resolver_cache' in node_config:
- cache = True
+ if 'resolver_cache' in global_config:
+ cache = True
fqdn_config_parse(node_config, node[0])
@@ -76,12 +92,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
@@ -110,6 +128,56 @@ def nft_valid_sets():
except:
return []
+def update_remote_group(config):
+ conf_lines = []
+ count = 0
+ valid_sets = nft_valid_sets()
+
+ remote_groups = dict_search_args(config, 'group', 'remote_group')
+ if remote_groups:
+ # Create directory for list files if necessary
+ if not os.path.isdir(firewall_config_dir):
+ makedir(firewall_config_dir, group='vyattacfg')
+ chmod_775(firewall_config_dir)
+
+ for set_name, remote_config in remote_groups.items():
+ if 'url' not in remote_config:
+ continue
+ nft_set_name = f'R_{set_name}'
+
+ # Create list file if necessary
+ list_file = os.path.join(firewall_config_dir, f"{nft_set_name}.txt")
+ if not os.path.exists(list_file):
+ write_file(list_file, '', user="root", group="vyattacfg", mode=0o644)
+
+ # Attempt to download file, use cached version if download fails
+ try:
+ download(list_file, remote_config['url'], raise_error=True)
+ except:
+ logger.error(f'Failed to download list-file for {set_name} remote group')
+ logger.info(f'Using cached list-file for {set_name} remote group')
+
+ # Read list file
+ ip_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)
+
+ # Load tables
+ for table in ipv4_tables:
+ if (table, nft_set_name) in valid_sets:
+ conf_lines += nft_output(table, nft_set_name, ip_list)
+
+ count += 1
+
+ nft_conf_str = "\n".join(conf_lines) + "\n"
+ code = run(f'nft --file -', input=nft_conf_str)
+
+ logger.info(f'Updated {count} remote-groups in firewall - result: {code}')
+
+
def update_fqdn(config, node):
conf_lines = []
count = 0
@@ -163,25 +231,66 @@ def update_fqdn(config, node):
nft_conf_str = "\n".join(conf_lines) + "\n"
code = run(f'nft --file -', input=nft_conf_str)
- print(f'Updated {count} sets in {node} - result: {code}')
+ logger.info(f'Updated {count} sets in {node} - result: {code}')
+
+def update_interfaces(config, node):
+ if node == 'interfaces':
+ wg_interfaces = dict_search_args(config, 'wireguard')
+ if wg_interfaces:
+
+ peer_public_keys = {}
+ # for each wireguard interfaces
+ for interface, wireguard in wg_interfaces.items():
+ peer_public_keys[interface] = []
+ for peer, peer_config in wireguard['peer'].items():
+ # check peer if peer host-name or address is set
+ if 'host_name' in peer_config or 'address' in peer_config:
+ # check latest handshake
+ peer_public_keys[interface].append(
+ peer_config['public_key']
+ )
+
+ now_time = time.time()
+ for (interface, check_peer_public_keys) in peer_public_keys.items():
+ if len(check_peer_public_keys) == 0:
+ continue
+
+ intf = WireGuardIf(interface, create=False, debug=False)
+ handshakes = intf.operational.get_latest_handshakes()
+
+ # WireGuard performs a handshake every WIREGUARD_REKEY_AFTER_TIME
+ # if data is being transmitted between the peers. If no data is
+ # transmitted, the handshake will not be initiated unless new
+ # data begins to flow. Each handshake generates a new session
+ # key, and the key is rotated at least every 120 seconds or
+ # upon data transmission after a prolonged silence.
+ for public_key, handshake_time in handshakes.items():
+ if public_key in check_peer_public_keys and (
+ handshake_time == 0
+ or (now_time - handshake_time > 3*WIREGUARD_REKEY_AFTER_TIME)
+ ):
+ intf.operational.reset_peer(public_key=public_key)
if __name__ == '__main__':
- print(f'VyOS domain resolver')
+ logger.info('VyOS domain resolver')
count = 1
while commit_in_progress():
if ( count % 60 == 0 ):
- print(f'Commit still in progress after {count}s - waiting')
+ logger.info(f'Commit still in progress after {count}s - waiting')
count += 1
time.sleep(1)
conf = ConfigTreeQuery()
firewall = get_config(conf, base_firewall)
nat = get_config(conf, base_nat)
+ interfaces = get_config(conf, base_interfaces)
- print(f'interval: {timeout}s - cache: {cache}')
+ logger.info(f'interval: {timeout}s - cache: {cache}')
while True:
update_fqdn(firewall, 'firewall')
update_fqdn(nat, 'nat')
+ update_remote_group(firewall)
+ update_interfaces(interfaces, 'interfaces')
time.sleep(timeout)
diff --git a/src/services/vyos-hostsd b/src/services/vyos-hostsd
index 1ba90471e..44f03586c 100755
--- a/src/services/vyos-hostsd
+++ b/src/services/vyos-hostsd
@@ -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 558561182..be3dd5051 100755
--- a/src/services/vyos-http-api-server
+++ b/src/services/vyos-http-api-server
@@ -20,18 +20,22 @@ import grp
import json
import logging
import signal
+import traceback
from time import sleep
+from typing import Annotated
-from fastapi import FastAPI
+from fastapi import FastAPI, Query
from fastapi.exceptions import RequestValidationError
from uvicorn import Config as UvicornConfig
from uvicorn import Server as UvicornServer
from vyos.configsession import ConfigSession
from vyos.defaults import api_config_state
+from vyos.utils.file import read_file
+from vyos.version import get_version
from api.session import SessionState
-from api.rest.models import error
+from api.rest.models import error, InfoQueryParams, success
CFG_GROUP = 'vyattacfg'
@@ -57,11 +61,49 @@ app = FastAPI(debug=True,
title="VyOS API",
version="0.1.0")
+
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(_request, exc):
return error(400, str(exc.errors()[0]))
+@app.get('/info')
+def info(q: Annotated[InfoQueryParams, Query()]):
+ show_version = q.version
+ show_hostname = q.hostname
+
+ prelogin_file = r'/etc/issue'
+ hostname_file = r'/etc/hostname'
+ default = 'Welcome to VyOS'
+
+ try:
+ res = {
+ 'banner': '',
+ 'hostname': '',
+ 'version': ''
+ }
+ if show_version:
+ res.update(version=get_version())
+
+ if show_hostname:
+ try:
+ hostname = read_file(hostname_file)
+ except Exception:
+ hostname = 'vyos'
+ res.update(hostname=hostname)
+
+ banner = read_file(prelogin_file, defaultonfailure=default)
+ if banner == f'{default} - \\n \\l':
+ banner = banner.partition(default)[1]
+
+ res.update(banner=banner)
+ except Exception:
+ LOG.critical(traceback.format_exc())
+ return error(500, 'An internal error occured. Check the logs for details.')
+
+ return success(res)
+
+
###
# Modify uvicorn to allow reloading server within the configsession
###
diff --git a/src/services/vyos-network-event-logger b/src/services/vyos-network-event-logger
new file mode 100644
index 000000000..840ff3cda
--- /dev/null
+++ b/src/services/vyos-network-event-logger
@@ -0,0 +1,1218 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2025 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 argparse
+import logging
+import multiprocessing
+import queue
+import signal
+import socket
+import threading
+from pathlib import Path
+from time import sleep
+from typing import Dict, AnyStr, List, Union
+
+from pyroute2.common import AF_MPLS
+from pyroute2.iproute import IPRoute
+from pyroute2.netlink import rtnl, nlmsg
+from pyroute2.netlink.nfnetlink.nfctsocket import nfct_msg
+from pyroute2.netlink.rtnl import (rt_proto as RT_PROTO, rt_type as RT_TYPES,
+ rtypes as RTYPES
+ )
+from pyroute2.netlink.rtnl.fibmsg import FR_ACT_GOTO, FR_ACT_NOP, FR_ACT_TO_TBL, \
+ fibmsg
+from pyroute2.netlink.rtnl import ifaddrmsg
+from pyroute2.netlink.rtnl import ifinfmsg
+from pyroute2.netlink.rtnl import ndmsg
+from pyroute2.netlink.rtnl import rtmsg
+from pyroute2.netlink.rtnl.rtmsg import nh, rtmsg_base
+
+from vyos.include.uapi.linux.fib_rules import *
+from vyos.include.uapi.linux.icmpv6 import *
+from vyos.include.uapi.linux.if_arp import *
+from vyos.include.uapi.linux.lwtunnel import *
+from vyos.include.uapi.linux.neighbour import *
+from vyos.include.uapi.linux.rtnetlink import *
+
+from vyos.utils.file import read_json
+
+
+manager = multiprocessing.Manager()
+cache = manager.dict()
+
+
+class UnsupportedMessageType(Exception):
+ pass
+
+shutdown_event = multiprocessing.Event()
+
+logging.basicConfig(level=logging.INFO, format='%(message)s')
+logger = logging.getLogger(__name__)
+
+
+class DebugFormatter(logging.Formatter):
+ def format(self, record):
+ self._style._fmt = '[%(asctime)s] %(levelname)s: %(message)s'
+ return super().format(record)
+
+
+def set_log_level(level: str) -> None:
+ if level == 'debug':
+ logger.setLevel(logging.DEBUG)
+ logger.parent.handlers[0].setFormatter(DebugFormatter())
+ else:
+ logger.setLevel(logging.INFO)
+
+IFF_FLAGS = {
+ 'RUNNING': ifinfmsg.IFF_RUNNING,
+ 'LOOPBACK': ifinfmsg.IFF_LOOPBACK,
+ 'BROADCAST': ifinfmsg.IFF_BROADCAST,
+ 'POINTOPOINT': ifinfmsg.IFF_POINTOPOINT,
+ 'MULTICAST': ifinfmsg.IFF_MULTICAST,
+ 'NOARP': ifinfmsg.IFF_NOARP,
+ 'ALLMULTI': ifinfmsg.IFF_ALLMULTI,
+ 'PROMISC': ifinfmsg.IFF_PROMISC,
+ 'MASTER': ifinfmsg.IFF_MASTER,
+ 'SLAVE': ifinfmsg.IFF_SLAVE,
+ 'DEBUG': ifinfmsg.IFF_DEBUG,
+ 'DYNAMIC': ifinfmsg.IFF_DYNAMIC,
+ 'AUTOMEDIA': ifinfmsg.IFF_AUTOMEDIA,
+ 'PORTSEL': ifinfmsg.IFF_PORTSEL,
+ 'NOTRAILERS': ifinfmsg.IFF_NOTRAILERS,
+ 'UP': ifinfmsg.IFF_UP,
+ 'LOWER_UP': ifinfmsg.IFF_LOWER_UP,
+ 'DORMANT': ifinfmsg.IFF_DORMANT,
+ 'ECHO': ifinfmsg.IFF_ECHO,
+}
+
+NEIGH_STATE_FLAGS = {
+ 'INCOMPLETE': ndmsg.NUD_INCOMPLETE,
+ 'REACHABLE': ndmsg.NUD_REACHABLE,
+ 'STALE': ndmsg.NUD_STALE,
+ 'DELAY': ndmsg.NUD_DELAY,
+ 'PROBE': ndmsg.NUD_PROBE,
+ 'FAILED': ndmsg.NUD_FAILED,
+ 'NOARP': ndmsg.NUD_NOARP,
+ 'PERMANENT': ndmsg.NUD_PERMANENT,
+}
+
+IFA_FLAGS = {
+ 'secondary': ifaddrmsg.IFA_F_SECONDARY,
+ 'temporary': ifaddrmsg.IFA_F_SECONDARY,
+ 'nodad': ifaddrmsg.IFA_F_NODAD,
+ 'optimistic': ifaddrmsg.IFA_F_OPTIMISTIC,
+ 'dadfailed': ifaddrmsg.IFA_F_DADFAILED,
+ 'home': ifaddrmsg.IFA_F_HOMEADDRESS,
+ 'deprecated': ifaddrmsg.IFA_F_DEPRECATED,
+ 'tentative': ifaddrmsg.IFA_F_TENTATIVE,
+ 'permanent': ifaddrmsg.IFA_F_PERMANENT,
+ 'mngtmpaddr': ifaddrmsg.IFA_F_MANAGETEMPADDR,
+ 'noprefixroute': ifaddrmsg.IFA_F_NOPREFIXROUTE,
+ 'autojoin': ifaddrmsg.IFA_F_MCAUTOJOIN,
+ 'stable-privacy': ifaddrmsg.IFA_F_STABLE_PRIVACY,
+}
+
+RT_SCOPE_TO_NAME = {
+ rtmsg.RT_SCOPE_UNIVERSE: 'global',
+ rtmsg.RT_SCOPE_SITE: 'site',
+ rtmsg.RT_SCOPE_LINK: 'link',
+ rtmsg.RT_SCOPE_HOST: 'host',
+ rtmsg.RT_SCOPE_NOWHERE: 'nowhere',
+}
+
+FAMILY_TO_NAME = {
+ socket.AF_INET: 'inet',
+ socket.AF_INET6: 'inet6',
+ socket.AF_PACKET: 'link',
+ AF_MPLS: 'mpls',
+ socket.AF_BRIDGE: 'bridge',
+}
+
+_INFINITY = 4294967295
+
+
+def _get_iif_name(idx: int) -> str:
+ """
+ Retrieves the interface name associated with a given index.
+ """
+ try:
+ if_info = IPRoute().link("get", index=idx)
+ if if_info:
+ return if_info[0].get_attr('IFLA_IFNAME')
+ except Exception as e:
+ pass
+
+ return ''
+
+
+def remember_if_index(idx: int, event_type: int) -> None:
+ """
+ Manages the caching of network interface names based on their index and event type.
+
+ - For RTM_DELLINK event, the interface name is removed from the cache if exists.
+ - For RTM_NEWLINK event, the interface name is retrieved and updated in the cache.
+ """
+ name = cache.get(idx)
+ if name:
+ if event_type == rtnl.RTM_DELLINK:
+ del cache[idx]
+ else:
+ name = _get_iif_name(idx)
+ if name:
+ cache[idx] = name
+ else:
+ cache[idx] = _get_iif_name(idx)
+
+
+class BaseFormatter:
+ """
+ A base class providing utility methods for formatting network message data.
+ """
+ def _get_if_name_by_index(self, idx: int) -> str:
+ """
+ Retrieves the name of a network interface based on its index.
+
+ Uses a cached lookup for efficiency. If the name is not found in the cache,
+ it queries the system and updates the cache.
+ """
+ if_name = cache.get(idx)
+ if not if_name:
+ if_name = _get_iif_name(idx)
+ cache[idx] = if_name
+
+ return if_name
+
+ def _format_rttable(self, idx: int) -> str:
+ """
+ Formats a route table identifier into a readable name.
+ """
+ return f'{RT_TABLE_TO_NAME.get(idx, idx)}'
+
+ def _parse_flag(self, data: int, flags: dict) -> list:
+ """
+ Extracts and returns flag names equal the bits set in a numeric value.
+ """
+ result = list()
+ if data:
+ for key, val in flags.items():
+ if data & val:
+ result.append(key)
+ data &= ~val
+
+ if data:
+ result.append(f"{data:#x}")
+
+ return result
+
+ def af_bit_len(self, af: int) -> int:
+ """
+ Gets the bit length of a given address family.
+ Supports common address families like IPv4, IPv6, and MPLS.
+ """
+ _map = {
+ socket.AF_INET6: 128,
+ socket.AF_INET: 32,
+ AF_MPLS: 20,
+ }
+
+ return _map.get(af)
+
+ def _format_simple_field(self, data: str, prefix: str='') -> str:
+ """
+ Formats a simple field with an optional prefix.
+
+ A simple field represents a value that does not require additional
+ parsing and is used as is.
+ """
+ return self._output(f'{prefix} {data}') if data is not None else ''
+
+ def _output(self, data: str) -> str:
+ """
+ Standardizes the output format.
+
+ Ensures that the output is enclosed with single spaces and has no leading
+ or trailing whitespace.
+ """
+ return f' {data.strip()} ' if data else ''
+
+
+class BaseMSGFormatter(BaseFormatter):
+ """
+ A base formatter class for network messages.
+ This class provides common methods for formatting network-related messages,
+ """
+
+ def _prepare_start_message(self, event: str) -> str:
+ """
+ Prepares a starting message string based on the event type.
+ """
+ if event in ['RTM_DELROUTE', 'RTM_DELLINK', 'RTM_DELNEIGH',
+ 'RTM_DELADDR', 'RTM_DELADDRLABEL', 'RTM_DELRULE',
+ 'RTM_DELNETCONF']:
+ return 'Deleted '
+ if event == 'RTM_GETNEIGH':
+ return 'Miss '
+ return ''
+
+ def _format_flow_field(self, data: int) -> str:
+ """
+ Formats a flow field to represent traffic realms.
+ """
+ to = data & 0xFFFF
+ from_ = data >> 16
+ result = f"realm{'s' if from_ else ''} "
+ if from_:
+ result += f'{from_}/'
+ result += f'{to}'
+
+ return self._output(result)
+
+ def format(self, msg: nlmsg) -> str:
+ """
+ Abstract method to format a complete message.
+
+ This method must be implemented by subclasses to provide specific formatting
+ logic for different types of messages.
+ """
+ raise NotImplementedError(f'{msg.get("event")}: {msg}')
+
+
+class LinkFormatter(BaseMSGFormatter):
+ """
+ A formatter class for handling link-related network messages
+ `RTM_NEWLINK` and `RTM_DELLINK`.
+ """
+ def _format_iff_flags(self, flags: int) -> str:
+ """
+ Formats interface flags into a human-readable string.
+ """
+ result = list()
+ if flags:
+ if flags & IFF_FLAGS['UP'] and not flags & IFF_FLAGS['RUNNING']:
+ result.append('NO-CARRIER')
+
+ flags &= ~IFF_FLAGS['RUNNING']
+
+ result.extend(self._parse_flag(flags, IFF_FLAGS))
+
+ return self._output(f'<{(",").join(result)}>')
+
+ def _format_if_props(self, data: ifinfmsg.ifinfbase.proplist) -> str:
+ """
+ Formats interface alternative name properties.
+ """
+ result = ''
+ for rec in data.altnames():
+ result += f'[altname {rec}] '
+ return self._output(result)
+
+ def _format_link(self, msg: ifinfmsg.ifinfmsg) -> str:
+ """
+ Formats the link attribute of a network interface message.
+ """
+ if msg.get_attr("IFLA_LINK") is not None:
+ iflink = msg.get_attr("IFLA_LINK")
+ if iflink:
+ if msg.get_attr("IFLA_LINK_NETNSID"):
+ return f'if{iflink}'
+ else:
+ return self._get_if_name_by_index(iflink)
+ return 'NONE'
+
+ def _format_link_info(self, msg: ifinfmsg.ifinfmsg) -> str:
+ """
+ Formats detailed information about the link, including type, address,
+ broadcast address, and permanent address.
+ """
+ result = f'link/{ARPHRD_TO_NAME.get(msg.get("ifi_type"), msg.get("ifi_type"))}'
+ result += self._format_simple_field(msg.get_attr('IFLA_ADDRESS'))
+
+ if msg.get_attr("IFLA_BROADCAST"):
+ if msg.get('flags') & ifinfmsg.IFF_POINTOPOINT:
+ result += f' peer'
+ else:
+ result += f' brd'
+ result += f' {msg.get_attr("IFLA_BROADCAST")}'
+
+ if msg.get_attr("IFLA_PERM_ADDRESS"):
+ if not msg.get_attr("IFLA_ADDRESS") or \
+ msg.get_attr("IFLA_ADDRESS") != msg.get_attr("IFLA_PERM_ADDRESS"):
+ result += f' permaddr {msg.get_attr("IFLA_PERM_ADDRESS")}'
+
+ return self._output(result)
+
+ def format(self, msg: ifinfmsg.ifinfmsg):
+ """
+ Formats a network link message into a structured output string.
+ """
+ if msg.get("family") not in [socket.AF_UNSPEC, socket.AF_BRIDGE]:
+ return None
+
+ message = self._prepare_start_message(msg.get('event'))
+
+ link = self._format_link(msg)
+
+ message += f'{msg.get("index")}: {msg.get_attr("IFLA_IFNAME")}'
+ message += f'@{link}' if link else ''
+ message += f': {self._format_iff_flags(msg.get("flags"))}'
+
+ message += self._format_simple_field(msg.get_attr('IFLA_MTU'), prefix='mtu')
+ message += self._format_simple_field(msg.get_attr('IFLA_QDISC'), prefix='qdisc')
+ message += self._format_simple_field(msg.get_attr('IFLA_OPERSTATE'), prefix='state')
+ message += self._format_simple_field(msg.get_attr('IFLA_GROUP'), prefix='group')
+ message += self._format_simple_field(msg.get_attr('IFLA_MASTER'), prefix='master')
+
+ message += self._format_link_info(msg)
+
+ if msg.get_attr('IFLA_PROP_LIST'):
+ message += self._format_if_props(msg.get_attr('IFLA_PROP_LIST'))
+
+ return self._output(message)
+
+
+class EncapFormatter(BaseFormatter):
+ """
+ A formatter class for handling encapsulation attributes in routing messages.
+ """
+ # TODO: implement other lwtunnel decoder in pyroute2
+ # https://github.com/svinota/pyroute2/blob/78cfe838bec8d96324811a3962bda15fb028e0ce/pyroute2/netlink/rtnl/rtmsg.py#L657
+ def __init__(self):
+ """
+ Initializes the EncapFormatter with supported encapsulation types.
+ """
+ self.formatters = {
+ rtmsg.LWTUNNEL_ENCAP_MPLS: self.mpls_format,
+ rtmsg.LWTUNNEL_ENCAP_SEG6: self.seg6_format,
+ rtmsg.LWTUNNEL_ENCAP_BPF: self.bpf_format,
+ rtmsg.LWTUNNEL_ENCAP_SEG6_LOCAL: self.seg6local_format,
+ }
+
+ def _format_srh(self, data: rtmsg_base.seg6_encap_info.ipv6_sr_hdr):
+ """
+ Formats Segment Routing Header (SRH) attributes.
+ """
+ result = ''
+ # pyroute2 decode mode only as inline or encap (encap, l2encap, encap.red, l2encap.red")
+ # https://github.com/svinota/pyroute2/blob/78cfe838bec8d96324811a3962bda15fb028e0ce/pyroute2/netlink/rtnl/rtmsg.py#L220
+ for key in ['mode', 'segs']:
+
+ val = data.get(key)
+
+ if val:
+ if key == 'segs':
+ result += f'{key} {len(val)} {val} '
+ else:
+ result += f'{key} {val} '
+
+ return self._output(result)
+
+ def _format_bpf_object(self, data: rtmsg_base.bpf_encap_info, attr_name: str, attr_key: str):
+ """
+ Formats eBPF program attributes.
+ """
+ attr = data.get_attr(attr_name)
+ if not attr:
+ return ''
+ result = ''
+ if attr.get_attr("LWT_BPF_PROG_NAME"):
+ result += f'{attr.get_attr("LWT_BPF_PROG_NAME")} '
+ if attr.get_attr("LWT_BPF_PROG_FD"):
+ result += f'{attr.get_attr("LWT_BPF_PROG_FD")} '
+
+ return self._output(f'{attr_key} {result.strip()}')
+
+ def mpls_format(self, data: rtmsg_base.mpls_encap_info):
+ """
+ Formats MPLS encapsulation attributes.
+ """
+ result = ''
+ if data.get_attr("MPLS_IPTUNNEL_DST"):
+ for rec in data.get_attr("MPLS_IPTUNNEL_DST"):
+ for key, val in rec.items():
+ if val:
+ result += f'{key} {val} '
+
+ if data.get_attr("MPLS_IPTUNNEL_TTL"):
+ result += f' ttl {data.get_attr("MPLS_IPTUNNEL_TTL")}'
+
+ return self._output(result)
+
+ def bpf_format(self, data: rtmsg_base.bpf_encap_info):
+ """
+ Formats eBPF encapsulation attributes.
+ """
+ result = ''
+ result += self._format_bpf_object(data, 'LWT_BPF_IN', 'in')
+ result += self._format_bpf_object(data, 'LWT_BPF_OUT', 'out')
+ result += self._format_bpf_object(data, 'LWT_BPF_XMIT', 'xmit')
+
+ if data.get_attr('LWT_BPF_XMIT_HEADROOM'):
+ result += f'headroom {data.get_attr("LWT_BPF_XMIT_HEADROOM")} '
+
+ return self._output(result)
+
+ def seg6_format(self, data: rtmsg_base.seg6_encap_info):
+ """
+ Formats Segment Routing (SEG6) encapsulation attributes.
+ """
+ result = ''
+ if data.get_attr("SEG6_IPTUNNEL_SRH"):
+ result += self._format_srh(data.get_attr("SEG6_IPTUNNEL_SRH"))
+
+ return self._output(result)
+
+ def seg6local_format(self, data: rtmsg_base.seg6local_encap_info):
+ """
+ Formats SEG6 local encapsulation attributes.
+ """
+ result = ''
+ formatters = {
+ 'SEG6_LOCAL_ACTION': lambda val: f' action {next((k for k, v in data.action.actions.items() if v == val), "unknown")}',
+ 'SEG6_LOCAL_SRH': lambda val: f' {self._format_srh(val)}',
+ 'SEG6_LOCAL_TABLE': lambda val: f' table {self._format_rttable(val)}',
+ 'SEG6_LOCAL_NH4': lambda val: f' nh4 {val}',
+ 'SEG6_LOCAL_NH6': lambda val: f' nh6 {val}',
+ 'SEG6_LOCAL_IIF': lambda val: f' iif {self._get_if_name_by_index(val)}',
+ 'SEG6_LOCAL_OIF': lambda val: f' oif {self._get_if_name_by_index(val)}',
+ 'SEG6_LOCAL_BPF': lambda val: f' endpoint {val.get("LWT_BPF_PROG_NAME")}',
+ 'SEG6_LOCAL_VRFTABLE': lambda val: f' vrftable {self._format_rttable(val)}',
+ }
+
+ for rec in data.get('attrs'):
+ if rec[0] in formatters:
+ result += formatters[rec[0]](rec[1])
+
+ return self._output(result)
+
+ def format(self, type: int, data: Union[rtmsg_base.mpls_encap_info,
+ rtmsg_base.bpf_encap_info,
+ rtmsg_base.seg6_encap_info,
+ rtmsg_base.seg6local_encap_info]):
+ """
+ Formats encapsulation attributes based on their type.
+ """
+ result = ''
+ formatter = self.formatters.get(type)
+
+ result += f'encap {ENCAP_TO_NAME.get(type, "unknown")}'
+
+ if formatter:
+ result += f' {formatter(data)}'
+
+ return self._output(result)
+
+
+class RouteFormatter(BaseMSGFormatter):
+ """
+ A formatter class for handling network routing messages
+ `RTM_NEWROUTE` and `RTM_DELROUTE`.
+ """
+
+ def _format_rt_flags(self, flags: int) -> str:
+ """
+ Formats route flags into a comma-separated string.
+ """
+ result = list()
+ result.extend(self._parse_flag(flags, RT_FlAGS))
+
+ return self._output(",".join(result))
+
+ def _format_rta_encap(self, type: int, data: Union[rtmsg_base.mpls_encap_info,
+ rtmsg_base.bpf_encap_info,
+ rtmsg_base.seg6_encap_info,
+ rtmsg_base.seg6local_encap_info]) -> str:
+ """
+ Formats encapsulation attributes.
+ """
+ return EncapFormatter().format(type, data)
+
+ def _format_rta_newdest(self, data: str) -> str:
+ """
+ Formats a new destination attribute.
+ """
+ return self._output(f'as to {data}')
+
+ def _format_rta_gateway(self, data: str) -> str:
+ """
+ Formats a gateway attribute.
+ """
+ return self._output(f'via {data}')
+
+ def _format_rta_via(self, data: str) -> str:
+ """
+ Formats a 'via' route attribute.
+ """
+ return self._output(f'{data}')
+
+ def _format_rta_metrics(self, data: rtmsg_base.metrics):
+ """
+ Formats routing metrics.
+ """
+ result = ''
+
+ def __format_metric_time(_val: int) -> str:
+ """Formats metric time values into seconds or milliseconds."""
+ return f"{_val / 1000}s" if _val >= 1000 else f"{_val}ms"
+
+ def __format_reatures(_val: int) -> str:
+ """Parse and formats routing feature flags."""
+ result = self._parse_flag(_val, {'ecn': RTAX_FEATURE_ECN,
+ 'tcp_usec_ts': RTAX_FEATURE_TCP_USEC_TS})
+ return ",".join(result)
+
+ formatters = {
+ 'RTAX_MTU': lambda val: f' mtu {val}',
+ 'RTAX_WINDOW': lambda val: f' window {val}',
+ 'RTAX_RTT': lambda val: f' rtt {__format_metric_time(val / 8)}',
+ 'RTAX_RTTVAR': lambda val: f' rttvar {__format_metric_time(val / 4)}',
+ 'RTAX_SSTHRESH': lambda val: f' ssthresh {val}',
+ 'RTAX_CWND': lambda val: f' cwnd {val}',
+ 'RTAX_ADVMSS': lambda val: f' advmss {val}',
+ 'RTAX_REORDERING': lambda val: f' reordering {val}',
+ 'RTAX_HOPLIMIT': lambda val: f' hoplimit {val}',
+ 'RTAX_INITCWND': lambda val: f' initcwnd {val}',
+ 'RTAX_FEATURES': lambda val: f' features {__format_reatures(val)}',
+ 'RTAX_RTO_MIN': lambda val: f' rto_min {__format_metric_time(val)}',
+ 'RTAX_INITRWND': lambda val: f' initrwnd {val}',
+ 'RTAX_QUICKACK': lambda val: f' quickack {val}',
+ }
+
+ for rec in data.get('attrs'):
+ if rec[0] in formatters:
+ result += formatters[rec[0]](rec[1])
+
+ return self._output(result)
+
+ def _format_rta_pref(self, data: int) -> str:
+ """
+ Formats a pref attribute.
+ """
+ pref = {
+ ICMPV6_ROUTER_PREF_LOW: "low",
+ ICMPV6_ROUTER_PREF_MEDIUM: "medium",
+ ICMPV6_ROUTER_PREF_HIGH: "high",
+ }
+
+ return self._output(f' pref {pref.get(data, data)}')
+
+ def _format_rta_multipath(self, mcast_cloned: bool, family: int, data: List[nh]) -> str:
+ """
+ Formats multipath route attributes.
+ """
+ result = ''
+ first = True
+ for rec in data:
+ if mcast_cloned:
+ if first:
+ result += ' Oifs: '
+ first = False
+ else:
+ result += ' '
+ else:
+ result += ' nexthop '
+
+ if rec.get_attr('RTA_ENCAP'):
+ result += self._format_rta_encap(rec.get_attr('RTA_ENCAP_TYPE'),
+ rec.get_attr('RTA_ENCAP'))
+
+ if rec.get_attr('RTA_NEWDST'):
+ result += self._format_rta_newdest(rec.get_attr('RTA_NEWDST'))
+
+ if rec.get_attr('RTA_GATEWAY'):
+ result += self._format_rta_gateway(rec.get_attr('RTA_GATEWAY'))
+
+ if rec.get_attr('RTA_VIA'):
+ result += self._format_rta_via(rec.get_attr('RTA_VIA'))
+
+ if rec.get_attr('RTA_FLOW'):
+ result += self._format_flow_field(rec.get_attr('RTA_FLOW'))
+
+ result += f' dev {self._get_if_name_by_index(rec.get("oif"))}'
+ if mcast_cloned:
+ if rec.get("hops") != 1:
+ result += f' (ttl>{rec.get("hops")})'
+ else:
+ if family != AF_MPLS:
+ result += f' weight {rec.get("hops") + 1}'
+
+ result += self._format_rt_flags(rec.get("flags"))
+
+ return self._output(result)
+
+ def format(self, msg: rtmsg.rtmsg) -> str:
+ """
+ Formats a network route message into a human-readable string representation.
+ """
+ message = self._prepare_start_message(msg.get('event'))
+
+ message += RT_TYPES.get(msg.get('type'))
+
+ if msg.get_attr('RTA_DST'):
+ host_len = self.af_bit_len(msg.get('family'))
+ if msg.get('dst_len') != host_len:
+ message += f' {msg.get_attr("RTA_DST")}/{msg.get("dst_len")}'
+ else:
+ message += f' {msg.get_attr("RTA_DST")}'
+ elif msg.get('dst_len'):
+ message += f' 0/{msg.get("dst_len")}'
+ else:
+ message += ' default'
+
+ if msg.get_attr('RTA_SRC'):
+ message += f' from {msg.get_attr("RTA_SRC")}'
+ elif msg.get('src_len'):
+ message += f' from 0/{msg.get("src_len")}'
+
+ message += self._format_simple_field(msg.get_attr('RTA_NH_ID'), prefix='nhid')
+
+ if msg.get_attr('RTA_NEWDST'):
+ message += self._format_rta_newdest(msg.get_attr('RTA_NEWDST'))
+
+ if msg.get_attr('RTA_ENCAP'):
+ message += self._format_rta_encap(msg.get_attr('RTA_ENCAP_TYPE'),
+ msg.get_attr('RTA_ENCAP'))
+
+ message += self._format_simple_field(msg.get('tos'), prefix='tos')
+
+ if msg.get_attr('RTA_GATEWAY'):
+ message += self._format_rta_gateway(msg.get_attr('RTA_GATEWAY'))
+
+ if msg.get_attr('RTA_VIA'):
+ message += self._format_rta_via(msg.get_attr('RTA_VIA'))
+
+ if msg.get_attr('RTA_OIF') is not None:
+ message += f' dev {self._get_if_name_by_index(msg.get_attr("RTA_OIF"))}'
+
+ if msg.get_attr("RTA_TABLE"):
+ message += f' table {self._format_rttable(msg.get_attr("RTA_TABLE"))}'
+
+ if not msg.get('flags') & RTM_F_CLONED:
+ message += f' proto {RT_PROTO.get(msg.get("proto"))}'
+
+ if not msg.get('scope') == rtmsg.RT_SCOPE_UNIVERSE:
+ message += f' scope {RT_SCOPE_TO_NAME.get(msg.get("scope"))}'
+
+ message += self._format_simple_field(msg.get_attr('RTA_PREFSRC'), prefix='src')
+ message += self._format_simple_field(msg.get_attr('RTA_PRIORITY'), prefix='metric')
+
+ message += self._format_rt_flags(msg.get("flags"))
+
+ if msg.get_attr('RTA_MARK'):
+ mark = msg.get_attr("RTA_MARK")
+ if mark >= 16:
+ message += f' mark 0x{mark:x}'
+ else:
+ message += f' mark {mark}'
+
+ if msg.get_attr('RTA_FLOW'):
+ message += self._format_flow_field(msg.get_attr('RTA_FLOW'))
+
+ message += self._format_simple_field(msg.get_attr('RTA_UID'), prefix='uid')
+
+ if msg.get_attr('RTA_METRICS'):
+ message += self._format_rta_metrics(msg.get_attr("RTA_METRICS"))
+
+ if msg.get_attr('RTA_IIF') is not None:
+ message += f' iif {self._get_if_name_by_index(msg.get_attr("RTA_IIF"))}'
+
+ if msg.get_attr('RTA_PREF') is not None:
+ 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"}'
+
+ if msg.get_attr('RTA_MULTIPATH') is not None:
+ _tmp = self._format_rta_multipath(
+ mcast_cloned=msg.get('flags') & RTM_F_CLONED and msg.get('type') == RTYPES['RTN_MULTICAST'],
+ family=msg.get('family'),
+ data=msg.get_attr("RTA_MULTIPATH"))
+ message += f' {_tmp}'
+
+ return self._output(message)
+
+
+class AddrFormatter(BaseMSGFormatter):
+ """
+ A formatter class for handling address-related network messages
+ `RTM_NEWADDR` and `RTM_DELADDR`.
+ """
+ INFINITY_LIFE_TIME = _INFINITY
+
+ def _format_ifa_flags(self, flags: int, family: int) -> str:
+ """
+ Formats address flags into a human-readable string.
+ """
+ result = list()
+ if flags:
+ if not flags & IFA_FLAGS['permanent']:
+ result.append('dynamic')
+ flags &= ~IFA_FLAGS['permanent']
+
+ if flags & IFA_FLAGS['temporary'] and family == socket.AF_INET6:
+ result.append('temporary')
+ flags &= ~IFA_FLAGS['temporary']
+
+ result.extend(self._parse_flag(flags, IFA_FLAGS))
+
+ return self._output(",".join(result))
+
+ def _format_ifa_addr(self, local: str, addr: str, preflen: int, priority: int) -> str:
+ """
+ Formats address information into a shuman-readable string.
+ """
+ result = ''
+ local = local or addr
+ addr = addr or local
+
+ if local:
+ result += f'{local}'
+ if addr and addr != local:
+ result += f' peer {addr}'
+ result += f'/{preflen}'
+
+ if priority:
+ result += f' {priority}'
+
+ return self._output(result)
+
+ def _format_ifa_cacheinfo(self, data: ifaddrmsg.ifaddrmsg.cacheinfo) -> str:
+ """
+ Formats cache information for an address.
+ """
+ result = ''
+ _map = {
+ 'ifa_valid': 'valid_lft',
+ 'ifa_preferred': 'preferred_lft',
+ }
+
+ for key in ['ifa_valid', 'ifa_preferred']:
+ val = data.get(key)
+ if val == self.INFINITY_LIFE_TIME:
+ result += f'{_map.get(key)} forever '
+ else:
+ result += f'{_map.get(key)} {val}sec '
+
+ return self._output(result)
+
+ def format(self, msg: ifaddrmsg.ifaddrmsg) -> str:
+ """
+ Formats a full network address message.
+ Combine attributes such as index, family, address, flags, and cache
+ information into a structured output string.
+ """
+ message = self._prepare_start_message(msg.get('event'))
+
+ message += f'{msg.get("index")}: {self._get_if_name_by_index(msg.get("index"))} '
+ message += f'{FAMILY_TO_NAME.get(msg.get("family"), msg.get("family"))} '
+
+ message += self._format_ifa_addr(
+ msg.get_attr('IFA_LOCAL'),
+ msg.get_attr('IFA_ADDRESS'),
+ msg.get('prefixlen'),
+ msg.get_attr('IFA_RT_PRIORITY')
+ )
+ message += self._format_simple_field(msg.get_attr('IFA_BROADCAST'), prefix='brd')
+ message += self._format_simple_field(msg.get_attr('IFA_ANYCAST'), prefix='any')
+
+ if msg.get('scope') is not None:
+ message += f' scope {RT_SCOPE_TO_NAME.get(msg.get("scope"))}'
+
+ message += self._format_ifa_flags(msg.get_attr("IFA_FLAGS"), msg.get("family"))
+ message += self._format_simple_field(msg.get_attr('IFA_LABEL'), prefix='label:')
+
+ if msg.get_attr('IFA_CACHEINFO'):
+ message += self._format_ifa_cacheinfo(msg.get_attr('IFA_CACHEINFO'))
+
+ return self._output(message)
+
+
+class NeighFormatter(BaseMSGFormatter):
+ """
+ A formatter class for handling neighbor-related network messages
+ `RTM_NEWNEIGH`, `RTM_DELNEIGH` and `RTM_GETNEIGH`
+ """
+ def _format_ntf_flags(self, flags: int) -> str:
+ """
+ Formats neighbor table entry flags into a human-readable string.
+ """
+ result = list()
+ result.extend(self._parse_flag(flags, NTF_FlAGS))
+
+ return self._output(",".join(result))
+
+ def _format_neigh_state(self, data: int) -> str:
+ """
+ Formats the state of a neighbor entry.
+ """
+ result = list()
+ result.extend(self._parse_flag(data, NEIGH_STATE_FLAGS))
+
+ return self._output(",".join(result))
+
+ def format(self, msg: ndmsg.ndmsg) -> str:
+ """
+ Formats a full neighbor-related network message.
+ Combine attributes such as destination, device, link-layer address,
+ flags, state, and protocol into a structured output string.
+ """
+ message = self._prepare_start_message(msg.get('event'))
+ message += self._format_simple_field(msg.get_attr('NDA_DST'), prefix='')
+
+ if msg.get("ifindex") is not None:
+ message += f' dev {self._get_if_name_by_index(msg.get("ifindex"))}'
+
+ message += self._format_simple_field(msg.get_attr('NDA_LLADDR'), prefix='lladdr')
+ message += f' {self._format_ntf_flags(msg.get("flags"))}'
+ message += f' {self._format_neigh_state(msg.get("state"))}'
+
+ if msg.get_attr('NDA_PROTOCOL'):
+ message += f' proto {RT_PROTO.get(msg.get_attr("NDA_PROTOCOL"), msg.get_attr("NDA_PROTOCOL"))}'
+
+ return self._output(message)
+
+
+class RuleFormatter(BaseMSGFormatter):
+ """
+ A formatter class for handling ruting tule network messages
+ `RTM_NEWRULE` and `RTM_DELRULE`
+ """
+ def _format_direction(self, data: str, length: int, host_len: int):
+ """
+ Formats the direction of traffic based on source or destination and prefix length.
+ """
+ result = ''
+ if data:
+ result += f' {data}'
+ if length != host_len:
+ result += f'/{length}'
+ elif length:
+ result += f' 0/{length}'
+
+ return self._output(result)
+
+ def _format_fra_interface(self, data: str, flags: int, prefix: str):
+ """
+ Formats interface-related attributes.
+ """
+ result = f'{prefix} {data}'
+ if flags & FIB_RULE_IIF_DETACHED:
+ result += '[detached]'
+
+ return self._output(result)
+
+ def _format_fra_range(self, data: [str, dict], prefix: str):
+ """
+ Formats a range of values (e.g., UID, sport, or dport).
+ """
+ result = ''
+ if data:
+ if isinstance(data, str):
+ result += f' {prefix} {data}'
+ else:
+ result += f' {prefix} {data.get("start")}:{data.get("end")}'
+ return self._output(result)
+
+ def _format_fra_table(self, msg: fibmsg):
+ """
+ Formats the lookup table and associated attributes in the message.
+ """
+ def __format_field(data: int, prefix: str):
+ if data and data not in [-1, _INFINITY]:
+ return f' {prefix} {data}'
+ return ''
+
+ result = ''
+ table = msg.get_attr('FRA_TABLE') or msg.get('table')
+ if table:
+ result += f' lookup {self._format_rttable(table)}'
+ result += __format_field(msg.get_attr('FRA_SUPPRESS_PREFIXLEN'), 'suppress_prefixlength')
+ result += __format_field(msg.get_attr('FRA_SUPPRESS_IFGROUP'), 'suppress_ifgroup')
+
+ return self._output(result)
+
+ def _format_fra_action(self, msg: fibmsg):
+ """
+ Formats the action associated with the rule.
+ """
+ result = ''
+ if msg.get('action') == RTYPES.get('RTN_NAT'):
+ if msg.get_attr('RTA_GATEWAY'): # looks like deprecated but still use in iproute2
+ result += f' map-to {msg.get_attr("RTA_GATEWAY")}'
+ else:
+ result += ' masquerade'
+
+ elif msg.get('action') == FR_ACT_GOTO:
+ result += f' goto {msg.get_attr("FRA_GOTO") or "none"}'
+ if msg.get('flags') & FIB_RULE_UNRESOLVED:
+ result += ' [unresolved]'
+
+ elif msg.get('action') == FR_ACT_NOP:
+ result += ' nop'
+
+ elif msg.get('action') != FR_ACT_TO_TBL:
+ result += f' {RTYPES.get(msg.get("action"))}'
+
+ return self._output(result)
+
+ def format(self, msg: fibmsg):
+ """
+ Formats a complete routing rule message.
+ Combines information about source, destination, interfaces, actions,
+ and other attributes into a single formatted string.
+ """
+ message = self._prepare_start_message(msg.get('event'))
+ host_len = self.af_bit_len(msg.get('family'))
+ message += self._format_simple_field(msg.get_attr('FRA_PRIORITY'), prefix='')
+
+ if msg.get('flags') & FIB_RULE_INVERT:
+ message += ' not'
+
+ tmp = self._format_direction(msg.get_attr('FRA_SRC'), msg.get('src_len'), host_len)
+ message += ' from' + (tmp if tmp else ' all ')
+
+ if msg.get_attr('FRA_DST'):
+ tmp = self._format_direction(msg.get_attr('FRA_DST'), msg.get('dst_len'), host_len)
+ message += ' to' + tmp
+
+ if msg.get('tos'):
+ message += f' tos {hex(msg.get("tos"))}'
+
+ if msg.get_attr('FRA_FWMARK') or msg.get_attr('FRA_FWMASK'):
+ mark = msg.get_attr('FRA_FWMARK') or 0
+ mask = msg.get_attr('FRA_FWMASK') or 0
+ if mask != 0xFFFFFFFF:
+ message += f' fwmark {mark}/{mask}'
+ else:
+ message += f' fwmark {mark}'
+
+ if msg.get_attr('FRA_IIFNAME'):
+ message += self._format_fra_interface(
+ msg.get_attr('FRA_IIFNAME'),
+ msg.get('flags'),
+ 'iif'
+ )
+
+ if msg.get_attr('FRA_OIFNAME'):
+ message += self._format_fra_interface(
+ msg.get_attr('FRA_OIFNAME'),
+ msg.get('flags'),
+ 'oif'
+ )
+
+ if msg.get_attr('FRA_L3MDEV'):
+ message += f' lookup [l3mdev-table]'
+
+ if msg.get_attr('FRA_UID_RANGE'):
+ message += self._format_fra_range(msg.get_attr('FRA_UID_RANGE'), 'uidrange')
+
+ message += self._format_simple_field(msg.get_attr('FRA_IP_PROTO'), prefix='ipproto')
+
+ if msg.get_attr('FRA_SPORT_RANGE'):
+ message += self._format_fra_range(msg.get_attr('FRA_SPORT_RANGE'), 'sport')
+
+ if msg.get_attr('FRA_DPORT_RANGE'):
+ message += self._format_fra_range(msg.get_attr('FRA_DPORT_RANGE'), 'dport')
+
+ message += self._format_simple_field(msg.get_attr('FRA_TUN_ID'), prefix='tun_id')
+
+ message += self._format_fra_table(msg)
+
+ if msg.get_attr('FRA_FLOW'):
+ message += self._format_flow_field(msg.get_attr('FRA_FLOW'))
+
+ message += self._format_fra_action(msg)
+
+ if msg.get_attr('FRA_PROTOCOL'):
+ message += f' proto {RT_PROTO.get(msg.get_attr("FRA_PROTOCOL"), msg.get_attr("FRA_PROTOCOL"))}'
+
+ return self._output(message)
+
+
+class AddrlabelFormatter(BaseMSGFormatter):
+ # Not implemented decoder on pytroute2 but ip monitor use it message
+ pass
+
+
+class PrefixFormatter(BaseMSGFormatter):
+ # Not implemented decoder on pytroute2 but ip monitor use it message
+ pass
+
+
+class NetconfFormatter(BaseMSGFormatter):
+ # Not implemented decoder on pytroute2 but ip monitor use it message
+ pass
+
+
+EVENT_MAP = {
+ rtnl.RTM_NEWROUTE: {'parser': RouteFormatter, 'event': 'route'},
+ rtnl.RTM_DELROUTE: {'parser': RouteFormatter, 'event': 'route'},
+ rtnl.RTM_NEWLINK: {'parser': LinkFormatter, 'event': 'link'},
+ rtnl.RTM_DELLINK: {'parser': LinkFormatter, 'event': 'link'},
+ rtnl.RTM_NEWADDR: {'parser': AddrFormatter, 'event': 'addr'},
+ rtnl.RTM_DELADDR: {'parser': AddrFormatter, 'event': 'addr'},
+ # rtnl.RTM_NEWADDRLABEL: {'parser': AddrlabelFormatter, 'event': 'addrlabel'},
+ # rtnl.RTM_DELADDRLABEL: {'parser': AddrlabelFormatter, 'event': 'addrlabel'},
+ rtnl.RTM_NEWNEIGH: {'parser': NeighFormatter, 'event': 'neigh'},
+ rtnl.RTM_DELNEIGH: {'parser': NeighFormatter, 'event': 'neigh'},
+ rtnl.RTM_GETNEIGH: {'parser': NeighFormatter, 'event': 'neigh'},
+ # rtnl.RTM_NEWPREFIX: {'parser': PrefixFormatter, 'event': 'prefix'},
+ rtnl.RTM_NEWRULE: {'parser': RuleFormatter, 'event': 'rule'},
+ rtnl.RTM_DELRULE: {'parser': RuleFormatter, 'event': 'rule'},
+ # rtnl.RTM_NEWNETCONF: {'parser': NetconfFormatter, 'event': 'netconf'},
+ # rtnl.RTM_DELNETCONF: {'parser': NetconfFormatter, 'event': 'netconf'},
+}
+
+
+def sig_handler(signum, frame):
+ process_name = multiprocessing.current_process().name
+ logger.debug(
+ f'[{process_name}]: {"Shutdown" if signum == signal.SIGTERM else "Reload"} signal received...'
+ )
+ shutdown_event.set()
+
+
+def parse_event_type(header: Dict) -> tuple:
+ """
+ Extract event type and parser.
+ """
+ event_type = EVENT_MAP.get(header['type'], {}).get('event', 'unknown')
+ _parser = EVENT_MAP.get(header['type'], {}).get('parser')
+
+ if _parser is None:
+ raise UnsupportedMessageType(f'Unsupported message type: {header["type"]}')
+
+ return event_type, _parser
+
+
+def is_need_to_log(event_type: AnyStr, conf_event: Dict):
+ """
+ Filter message by event type and protocols
+ """
+ conf = conf_event.get(event_type)
+ if conf == {}:
+ return True
+ return False
+
+
+def parse_event(msg: nfct_msg, conf_event: Dict) -> str:
+ """
+ Convert nfct_msg to internal data dict.
+ """
+ data = ''
+ event_type, parser = parse_event_type(msg['header'])
+ if event_type == 'link':
+ remember_if_index(idx=msg.get('index'), event_type=msg['header'].get('type'))
+
+ if not is_need_to_log(event_type, conf_event):
+ return data
+
+ message = parser().format(msg)
+ if message:
+ data = f'{f"[{event_type}]".upper():<{7}} {message}'
+
+ return data
+
+
+def worker(ct: IPRoute, shutdown_event: multiprocessing.Event, conf_event: Dict) -> None:
+ """
+ Main function of parser worker process
+ """
+ process_name = multiprocessing.current_process().name
+ logger.debug(f'[{process_name}] started')
+ timeout = 0.1
+ while not shutdown_event.is_set():
+ if not ct.buffer_queue.empty():
+ msg = None
+ try:
+ for msg in ct.get():
+ message = parse_event(msg, conf_event)
+ if message:
+ if logger.level == logging.DEBUG:
+ logger.debug(f'[{process_name}]: {message} raw: {msg}')
+ else:
+ logger.info(message)
+ except queue.Full:
+ logger.error('IPRoute message queue if full.')
+ except UnsupportedMessageType as e:
+ logger.debug(f'{e} =====> raw msg: {msg}')
+ except Exception as e:
+ logger.error(f'Unexpected error: {e.__class__} {e} [{msg}]')
+ else:
+ sleep(timeout)
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ '-c',
+ '--config',
+ action='store',
+ help='Path to vyos-network-event-logger configuration',
+ required=True,
+ type=Path,
+ )
+
+ args = parser.parse_args()
+ try:
+ config = read_json(args.config)
+ except Exception as err:
+ logger.error(f'Configuration file "{args.config}" does not exist or malformed: {err}')
+ exit(1)
+
+ set_log_level(config.get('log_level', 'info'))
+
+ signal.signal(signal.SIGHUP, sig_handler)
+ signal.signal(signal.SIGTERM, sig_handler)
+
+ if 'event' in config:
+ event_groups = list(config.get('event').keys())
+ else:
+ logger.error(f'Configuration is wrong. Event filter is empty.')
+ exit(1)
+
+ conf_event = config['event']
+ qsize = config.get('queue_size')
+ ct = IPRoute(async_qsize=int(qsize) if qsize else None)
+ ct.buffer_queue = multiprocessing.Queue(ct.async_qsize)
+ ct.bind(async_cache=True)
+
+ processes = list()
+ try:
+ for _ in range(multiprocessing.cpu_count()):
+ p = multiprocessing.Process(target=worker, args=(ct, shutdown_event, conf_event))
+ processes.append(p)
+ p.start()
+ logger.info('IPRoute socket bound and listening for messages.')
+
+ while not shutdown_event.is_set():
+ if not ct.pthread.is_alive():
+ if ct.buffer_queue.qsize() / ct.async_qsize < 0.9:
+ if not shutdown_event.is_set():
+ logger.debug('Restart listener thread')
+ # restart listener thread after queue overloaded when queue size low than 90%
+ ct.pthread = threading.Thread(name='Netlink async cache', target=ct.async_recv)
+ ct.pthread.daemon = True
+ ct.pthread.start()
+ else:
+ sleep(0.1)
+ finally:
+ for p in processes:
+ p.join()
+ if not p.is_alive():
+ logger.debug(f'[{p.name}]: finished')
+ ct.close()
+ logging.info('IPRoute socket closed.')
+ exit()
diff --git a/src/system/sync-dhcp-lease-to-hosts.py b/src/system/sync-dhcp-lease-to-hosts.py
new file mode 100755
index 000000000..5c8b18faf
--- /dev/null
+++ b/src/system/sync-dhcp-lease-to-hosts.py
@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2025 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 argparse
+import logging
+
+import vyos.opmode
+import vyos.hostsd_client
+
+from vyos.configquery import ConfigTreeQuery
+
+from vyos.kea import kea_get_active_config
+from vyos.kea import kea_get_dhcp_pools
+from vyos.kea import kea_get_server_leases
+
+# Configure logging
+logger = logging.getLogger(__name__)
+# set stream as output
+logs_handler = logging.StreamHandler()
+logger.addHandler(logs_handler)
+
+
+def _get_all_server_leases(inet_suffix='4') -> list:
+ mappings = []
+ try:
+ active_config = kea_get_active_config(inet_suffix)
+ 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
+ )
+ except Exception:
+ raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server leases')
+
+ return mappings
+
+
+if __name__ == '__main__':
+ # Parse command arguments
+ parser = argparse.ArgumentParser()
+ group = parser.add_mutually_exclusive_group(required=True)
+ group.add_argument('--inet', action='store_true', help='Use IPv4 DHCP leases')
+ group.add_argument('--inet6', action='store_true', help='Use IPv6 DHCP leases')
+ args = parser.parse_args()
+
+ inet_suffix = '4' if args.inet else '6'
+ service_suffix = '' if args.inet else 'v6'
+
+ if inet_suffix == '6':
+ raise vyos.opmode.UnsupportedOperation(
+ 'Syncing IPv6 DHCP leases are not supported yet'
+ )
+
+ # Load configuration
+ config = ConfigTreeQuery()
+
+ # Check if DHCP server is configured
+ # Using warning instead of error since this check may fail during first-time
+ # DHCP server setup when the service is not yet configured in the config tree.
+ # This happens when called from systemd's ExecStartPost the first time.
+ if not config.exists(f'service dhcp{service_suffix}-server'):
+ logger.warning(f'DHCP{service_suffix} server is not configured')
+
+ # Check if hostfile-update is enabled
+ if not config.exists(f'service dhcp{service_suffix}-server hostfile-update'):
+ logger.debug(
+ f'Hostfile update is disabled for DHCP{service_suffix} server, skipping hosts update'
+ )
+ exit(0)
+
+ lease_data = _get_all_server_leases(inet_suffix)
+
+ try:
+ hc = vyos.hostsd_client.Client()
+
+ for mapping in lease_data:
+ ip_addr = mapping.get('ip')
+ mac_addr = mapping.get('mac')
+ name = mapping.get('hostname')
+ name = name if name else f'host-{mac_addr.replace(":", "-")}'
+ domain = mapping.get('domain')
+ fqdn = f'{name}.{domain}' if domain else name
+ hc.add_hosts(
+ {
+ f'dhcp-server-{ip_addr}': {
+ fqdn: {'address': [ip_addr], 'aliases': []}
+ }
+ }
+ )
+
+ hc.apply()
+
+ logger.debug('Hosts store updated successfully')
+
+ except vyos.hostsd_client.VyOSHostsdError as e:
+ raise vyos.opmode.InternalError(str(e))
diff --git a/src/systemd/netplug.service b/src/systemd/netplug.service
new file mode 100644
index 000000000..928c553e8
--- /dev/null
+++ b/src/systemd/netplug.service
@@ -0,0 +1,9 @@
+[Unit]
+Description=Network cable hotplug management daemon
+Documentation=man:netplugd(8)
+After=vyos-router.service
+
+[Service]
+Type=forking
+PIDFile=/run/netplugd.pid
+ExecStart=/sbin/netplugd -c /etc/netplug/netplugd.conf -p /run/netplugd.pid
diff --git a/src/systemd/vyconfd.service b/src/systemd/vyconfd.service
new file mode 100644
index 000000000..ab2280263
--- /dev/null
+++ b/src/systemd/vyconfd.service
@@ -0,0 +1,21 @@
+[Unit]
+Description=VyOS vyconf daemon
+
+# Without this option, lots of default dependencies are added,
+# among them network.target, which creates a dependency cycle
+DefaultDependencies=no
+
+After=systemd-remount-fs.service
+
+[Service]
+ExecStart=/usr/libexec/vyos/vyconf/vyconfd --log-file /var/run/log/vyconfd.log
+Type=exec
+SyslogIdentifier=vyconfd
+SyslogFacility=daemon
+Restart=on-failure
+
+User=root
+Group=vyattacfg
+
+[Install]
+WantedBy=vyos.target
diff --git a/src/systemd/vyos-commitd.service b/src/systemd/vyos-commitd.service
new file mode 100644
index 000000000..5b083f500
--- /dev/null
+++ b/src/systemd/vyos-commitd.service
@@ -0,0 +1,27 @@
+[Unit]
+Description=VyOS commit 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-configd needs is read/write mounted root
+After=systemd-remount-fs.service
+Before=vyos-router.service
+
+[Service]
+ExecStart=/usr/bin/python3 -u /usr/libexec/vyos/services/vyos-commitd
+Type=idle
+
+SyslogIdentifier=vyos-commitd
+SyslogFacility=daemon
+
+Restart=on-failure
+
+# Does't work in Jessie but leave it here
+User=root
+Group=vyattacfg
+
+[Install]
+WantedBy=vyos.target
diff --git a/src/systemd/vyos-domain-resolver.service b/src/systemd/vyos-domain-resolver.service
index e63ae5e34..87a4748f4 100644
--- a/src/systemd/vyos-domain-resolver.service
+++ b/src/systemd/vyos-domain-resolver.service
@@ -6,7 +6,9 @@ ConditionPathExistsGlob=/run/use-vyos-domain-resolver*
[Service]
Type=simple
Restart=always
-ExecStart=/usr/bin/python3 -u /usr/libexec/vyos/vyos-domain-resolver.py
+ExecStart=/usr/bin/python3 -u /usr/libexec/vyos/services/vyos-domain-resolver
+SyslogIdentifier=vyos-domain-resolver
+SyslogFacility=daemon
StandardError=journal
StandardOutput=journal
diff --git a/src/systemd/vyos-network-event-logger.service b/src/systemd/vyos-network-event-logger.service
new file mode 100644
index 000000000..990dc43ba
--- /dev/null
+++ b/src/systemd/vyos-network-event-logger.service
@@ -0,0 +1,21 @@
+[Unit]
+Description=VyOS network-event logger daemon
+
+# Seemingly sensible way to say "as early as the system is ready"
+# All vyos-configd needs is read/write mounted root
+After=vyos.target
+
+[Service]
+ExecStart=/usr/bin/python3 -u /usr/libexec/vyos/services/vyos-network-event-logger -c /run/vyos-network-event-logger.conf
+Type=idle
+
+SyslogIdentifier=vyos-network-event-logger
+SyslogFacility=daemon
+
+Restart=on-failure
+
+User=root
+Group=vyattacfg
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/systemd/vyos-wan-load-balance.service b/src/systemd/vyos-wan-load-balance.service
index 7d62a2ff6..a59f2c3ae 100644
--- a/src/systemd/vyos-wan-load-balance.service
+++ b/src/systemd/vyos-wan-load-balance.service
@@ -1,15 +1,11 @@
[Unit]
-Description=VyOS WAN load-balancing service
+Description=VyOS WAN Load Balancer
After=vyos-router.service
[Service]
-ExecStart=/opt/vyatta/sbin/wan_lb -f /run/load-balance/wlb.conf -d -i /var/run/vyatta/wlb.pid
-ExecReload=/bin/kill -s SIGTERM $MAINPID && sleep 5 && /opt/vyatta/sbin/wan_lb -f /run/load-balance/wlb.conf -d -i /var/run/vyatta/wlb.pid
-ExecStop=/bin/kill -s SIGTERM $MAINPID
-PIDFile=/var/run/vyatta/wlb.pid
-KillMode=process
-Restart=on-failure
-RestartSec=5s
+Type=simple
+Restart=always
+ExecStart=/usr/bin/python3 /usr/libexec/vyos/vyos-load-balancer.py
[Install]
WantedBy=multi-user.target
diff --git a/src/systemd/vyos.target b/src/systemd/vyos.target
index 47c91c1cc..c5d04891d 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
diff --git a/src/tests/test_config_diff.py b/src/tests/test_config_diff.py
index 39e17613a..4017fff4d 100644
--- a/src/tests/test_config_diff.py
+++ b/src/tests/test_config_diff.py
@@ -31,11 +31,11 @@ class TestConfigDiff(TestCase):
def test_unit(self):
diff = vyos.configtree.DiffTree(self.config_left, self.config_null)
sub = diff.sub
- self.assertEqual(sub.to_string(), self.config_left.to_string())
+ self.assertEqual(sub, self.config_left)
diff = vyos.configtree.DiffTree(self.config_null, self.config_left)
add = diff.add
- self.assertEqual(add.to_string(), self.config_left.to_string())
+ self.assertEqual(add, self.config_left)
def test_symmetry(self):
lr_diff = vyos.configtree.DiffTree(self.config_left,
@@ -45,10 +45,10 @@ class TestConfigDiff(TestCase):
sub = lr_diff.sub
add = rl_diff.add
- self.assertEqual(sub.to_string(), add.to_string())
+ self.assertEqual(sub, add)
add = lr_diff.add
sub = rl_diff.sub
- self.assertEqual(add.to_string(), sub.to_string())
+ self.assertEqual(add, sub)
def test_identity(self):
lr_diff = vyos.configtree.DiffTree(self.config_left,
@@ -61,6 +61,9 @@ class TestConfigDiff(TestCase):
r_union = vyos.configtree.union(add, inter)
l_union = vyos.configtree.union(sub, inter)
+ # here we must compare string representations instead of using
+ # dunder equal, as we assert equivalence of the values list, which
+ # is optionally ordered at render
self.assertEqual(r_union.to_string(),
self.config_right.to_string(ordered_values=True))
self.assertEqual(l_union.to_string(),
diff --git a/src/tests/test_config_parser.py b/src/tests/test_config_parser.py
index 9a4f02859..1b4a57311 100644
--- a/src/tests/test_config_parser.py
+++ b/src/tests/test_config_parser.py
@@ -51,3 +51,7 @@ class TestConfigParser(TestCase):
def test_rename_duplicate(self):
with self.assertRaises(vyos.configtree.ConfigTreeError):
self.config.rename(["top-level-tag-node", "foo"], "bar")
+
+ def test_leading_slashes(self):
+ self.assertTrue(self.config.exists(["normal-node", "value-with-leading-slashes"]))
+ self.assertEqual(self.config.return_value(["normal-node", "value-with-leading-slashes"]), "//other-value")
diff --git a/src/tests/test_configd_inspect.py b/src/tests/test_configd_inspect.py
index ccd631893..a0470221d 100644
--- a/src/tests/test_configd_inspect.py
+++ b/src/tests/test_configd_inspect.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2020-2024 VyOS maintainers and contributors
+# Copyright (C) 2020-2025 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
@@ -12,93 +12,151 @@
# 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 ast
import json
-import warnings
-import importlib.util
-from inspect import signature
-from inspect import getsource
-from functools import wraps
from unittest import TestCase
INC_FILE = 'data/configd-include.json'
CONF_DIR = 'src/conf_mode'
-f_list = ['get_config', 'verify', 'generate', 'apply']
-
-def import_script(s):
- path = os.path.join(CONF_DIR, s)
- name = os.path.splitext(s)[0].replace('-', '_')
- spec = importlib.util.spec_from_file_location(name, path)
- module = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(module)
- return module
-
-# importing conf_mode scripts imports jinja2 with deprecation warning
-def ignore_deprecation_warning(f):
- @wraps(f)
- def decorated_function(*args, **kwargs):
- with warnings.catch_warnings():
- warnings.simplefilter("ignore")
- f(*args, **kwargs)
- return decorated_function
+funcs = ['get_config', 'verify', 'generate', 'apply']
+
+
+class FunctionSig(ast.NodeVisitor):
+ def __init__(self):
+ self.func_sig_len = dict.fromkeys(funcs, None)
+ self.get_config_default_values = []
+
+ def visit_FunctionDef(self, node):
+ func_name = node.name
+ if func_name in funcs:
+ self.func_sig_len[func_name] = len(node.args.args)
+
+ if func_name == 'get_config':
+ for default in node.args.defaults:
+ if isinstance(default, ast.Constant):
+ self.get_config_default_values.append(default.value)
+
+ self.generic_visit(node)
+
+ def get_sig_lengths(self):
+ return self.func_sig_len
+
+ def get_config_default(self):
+ return self.get_config_default_values[0]
+
+
+class LegacyCall(ast.NodeVisitor):
+ def __init__(self):
+ self.legacy_func_count = 0
+
+ def visit_Constant(self, node):
+ value = node.value
+ if isinstance(value, str):
+ if 'my_set' in value or 'my_delete' in value:
+ self.legacy_func_count += 1
+
+ self.generic_visit(node)
+
+ def get_legacy_func_count(self):
+ return self.legacy_func_count
+
+
+class ConfigInstance(ast.NodeVisitor):
+ def __init__(self):
+ self.count = 0
+
+ def visit_Call(self, node):
+ if isinstance(node.func, ast.Name):
+ name = node.func.id
+ if name == 'Config':
+ self.count += 1
+ self.generic_visit(node)
+
+ def get_count(self):
+ return self.count
+
+
+class FunctionConfigInstance(ast.NodeVisitor):
+ def __init__(self):
+ self.func_config_instance = dict.fromkeys(funcs, 0)
+
+ def visit_FunctionDef(self, node):
+ func_name = node.name
+ if func_name in funcs:
+ config_instance = ConfigInstance()
+ config_instance.visit(node)
+ self.func_config_instance[func_name] = config_instance.get_count()
+ self.generic_visit(node)
+
+ def get_func_config_instance(self):
+ return self.func_config_instance
+
class TestConfigdInspect(TestCase):
def setUp(self):
+ self.ast_list = []
+
with open(INC_FILE) as f:
self.inc_list = json.load(f)
- @ignore_deprecation_warning
- def test_signatures(self):
for s in self.inc_list:
- m = import_script(s)
- for i in f_list:
- f = getattr(m, i, None)
- self.assertIsNotNone(f, f"'{s}': missing function '{i}'")
- sig = signature(f)
- par = sig.parameters
- l = len(par)
- self.assertEqual(l, 1,
- f"'{s}': '{i}' incorrect signature")
- if i == 'get_config':
- for p in par.values():
- self.assertTrue(p.default is None,
- f"'{s}': '{i}' incorrect signature")
-
- @ignore_deprecation_warning
- def test_function_instance(self):
- for s in self.inc_list:
- m = import_script(s)
- for i in f_list:
- f = getattr(m, i, None)
- if not f:
- continue
- str_f = getsource(f)
- # Regex not XXXConfig() T3108
- n = len(re.findall(r'[^a-zA-Z]Config\(\)', str_f))
- if i == 'get_config':
- self.assertEqual(n, 1,
- f"'{s}': '{i}' no instance of Config")
- if i != 'get_config':
- self.assertEqual(n, 0,
- f"'{s}': '{i}' instance of Config")
-
- @ignore_deprecation_warning
- def test_file_instance(self):
- for s in self.inc_list:
- m = import_script(s)
- str_m = getsource(m)
- # Regex not XXXConfig T3108
- n = len(re.findall(r'[^a-zA-Z]Config\(\)', str_m))
- self.assertEqual(n, 1,
- f"'{s}' more than one instance of Config")
-
- @ignore_deprecation_warning
+ s_path = f'{CONF_DIR}/{s}'
+ with open(s_path) as f:
+ s_str = f.read()
+ s_tree = ast.parse(s_str)
+ self.ast_list.append((s, s_tree))
+
+ def test_signatures(self):
+ for s, t in self.ast_list:
+ visitor = FunctionSig()
+ visitor.visit(t)
+ sig_lens = visitor.get_sig_lengths()
+
+ for f in funcs:
+ self.assertIsNotNone(sig_lens[f], f"'{s}': '{f}' missing")
+ self.assertEqual(sig_lens[f], 1, f"'{s}': '{f}' incorrect signature")
+
+ self.assertEqual(
+ visitor.get_config_default(),
+ None,
+ f"'{s}': 'get_config' incorrect signature",
+ )
+
+ def test_file_config_instance(self):
+ for s, t in self.ast_list:
+ visitor = ConfigInstance()
+ visitor.visit(t)
+ count = visitor.get_count()
+
+ self.assertEqual(count, 1, f"'{s}' more than one instance of Config")
+
+ def test_function_config_instance(self):
+ for s, t in self.ast_list:
+ visitor = FunctionConfigInstance()
+ visitor.visit(t)
+ func_config_instance = visitor.get_func_config_instance()
+
+ for f in funcs:
+ if f == 'get_config':
+ self.assertTrue(
+ func_config_instance[f] > 0,
+ f"'{s}': '{f}' no instance of Config",
+ )
+ self.assertTrue(
+ func_config_instance[f] < 2,
+ f"'{s}': '{f}' more than one instance of Config",
+ )
+ else:
+ self.assertEqual(
+ func_config_instance[f], 0, f"'{s}': '{f}' instance of Config"
+ )
+
def test_config_modification(self):
- for s in self.inc_list:
- m = import_script(s)
- str_m = getsource(m)
- n = str_m.count('my_set')
- self.assertEqual(n, 0, f"'{s}' modifies config")
+ for s, t in self.ast_list:
+ visitor = LegacyCall()
+ visitor.visit(t)
+ legacy_func_count = visitor.get_legacy_func_count()
+
+ self.assertEqual(legacy_func_count, 0, f"'{s}' modifies config")
diff --git a/src/tests/test_initial_setup.py b/src/tests/test_initial_setup.py
index 4cd5fb169..7737f9df5 100644
--- a/src/tests/test_initial_setup.py
+++ b/src/tests/test_initial_setup.py
@@ -92,8 +92,8 @@ class TestInitialSetup(TestCase):
vis.set_default_gateway(self.config, '192.0.2.1')
self.assertTrue(self.config.exists(['protocols', 'static', 'route', '0.0.0.0/0', 'next-hop', '192.0.2.1']))
- self.assertTrue(self.xml.is_tag(['protocols', 'static', 'multicast', 'route', '0.0.0.0/0', 'next-hop']))
- self.assertTrue(self.xml.is_tag(['protocols', 'static', 'multicast', 'route']))
+ self.assertTrue(self.xml.is_tag(['protocols', 'static', 'mroute', '0.0.0.0/0', 'next-hop']))
+ self.assertTrue(self.xml.is_tag(['protocols', 'static', 'mroute']))
if __name__ == "__main__":
unittest.main()
diff --git a/src/validators/base64 b/src/validators/base64
index e2b1e730d..a54168ef7 100755
--- a/src/validators/base64
+++ b/src/validators/base64
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
#
-# Copyright (C) 2021 VyOS maintainers and contributors
+# Copyright (C) 2021-2025 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
@@ -15,13 +15,17 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import base64
-from sys import argv
+import argparse
-if __name__ == '__main__':
- if len(argv) != 2:
- exit(1)
- try:
- base64.b64decode(argv[1])
- except:
+parser = argparse.ArgumentParser(description="Validate base64 input.")
+parser.add_argument("base64", help="Base64 encoded string to validate")
+parser.add_argument("--decoded-len", type=int, help="Optional list of valid lengths for the decoded input")
+args = parser.parse_args()
+
+try:
+ decoded = base64.b64decode(args.base64)
+ if args.decoded_len and len(decoded) != args.decoded_len:
exit(1)
- exit(0)
+except:
+ exit(1)
+exit(0)
diff --git a/src/validators/ether-type b/src/validators/ether-type
new file mode 100644
index 000000000..926db26d3
--- /dev/null
+++ b/src/validators/ether-type
@@ -0,0 +1,37 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2024 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import re
+from sys import argv,exit
+
+if __name__ == '__main__':
+ if len(argv) != 2:
+ exit(1)
+
+ input = argv[1]
+ try:
+ # ethertype can be in the range 1 - 65535
+ if int(input) in range(1, 65536):
+ exit(0)
+ except ValueError:
+ pass
+
+ pattern = "!?\\b(all|ip|ipv6|ipx|802.1Q|802_2|802_3|aarp|aoe|arp|atalk|dec|lat|localtalk|rarp|snap|x25)\\b"
+ if re.match(pattern, input):
+ exit(0)
+
+ print(f'Error: {input} is not a valid ether type or protocol.')
+ exit(1)
diff --git a/src/validators/ethernet-interface b/src/validators/ethernet-interface
new file mode 100644
index 000000000..2bf92812e
--- /dev/null
+++ b/src/validators/ethernet-interface
@@ -0,0 +1,13 @@
+#!/bin/sh
+
+if ! [[ "$1" =~ ^(lan|eth|eno|ens|enp|enx)[0-9]+$ ]]; then
+ echo "Error: $1 is not an ethernet interface"
+ exit 1
+fi
+
+if ! [ -d "/sys/class/net/$1" ]; then
+ echo "Error: $1 interface does not exist in the system"
+ exit 1
+fi
+
+exit 0